deps_core/redact/key.rs
1//! Declaration-key and parse-error-message redaction: the credential-shape gate applied to a
2//! value that is not always a URL (an opaque label, a manifest declaration key, or a parser's
3//! error message), unlike [`super::url`]'s URL-specific redaction.
4
5use super::url::{
6 is_authority_bearing_url, redact_userinfo_with_parsed, segment_has_credential_colon,
7 url_for_tracing_with_parsed,
8};
9
10/// Redacts a [`crate::ecosystem::BlockedRegistryOccurrence::declaration_key`] value for safe
11/// inclusion in a client-visible diagnostic message.
12///
13/// `key` is not always a URL: most ecosystems set it to a short opaque label (`"top-level"`,
14/// `"source:Blocked"`, `"scope:@myorg"`), and only some set it to a real registry URL, sometimes
15/// prefixed with an opaque label of the ecosystem's own choosing
16/// (`"source:https://user:pass@host/index"`). Running [`super::url::url_for_tracing`]'s aggressive
17/// text-scan fallbacks unconditionally would mangle a label like `"scope:@myorg"` into
18/// `"***@myorg"` (#981), so redaction only runs when `key` shows real URL structure
19/// (`is_authority_bearing_url`) or a credential shape of its own: at least one `@` whose own
20/// preceding segment (back to the previous `@`, or `key`'s own start) has a credential-shaped
21/// `:` once its own trailing `:` is stripped (`segment_has_credential_colon`, checked
22/// per-segment so a decoy trailing `label:@...` segment can't shadow an earlier real credential
23/// — #993 M5). Stripping the trailing `:` first is what keeps `"scope:@myorg"` — an
24/// empty-username userinfo shape used as an opaque label, not a credential — from matching,
25/// since after stripping it the segment is just `"scope"`, with no `:` left to trip the check.
26/// This is a shape check, not a separator check: it has no opinion on `/` at all, so it catches
27/// a credential regardless of what separates the opaque label from it
28/// (`"source:user:ghp_SECRET@host"`, `"source:feed/user:hunter2@host"`,
29/// `"source:feed//user:hunter2@host"`, even a percent-encoded
30/// `"source:feed%2F%2Fuser:hunter2@h"`, since the scan only looks at `:`/`@`, never `/`), while
31/// never mangling a `/`-containing label that has no `@` at all (`"source:feed//mirror"`,
32/// `"component-host:gitlab.example.com//group"` are left untouched — #993 S2, a regression in
33/// an earlier, separator-based version of this gate). Every other key takes the plain
34/// query/fragment-strip fallback instead.
35///
36/// Two accepted trade-offs, both favoring over-redaction over under-redaction per this module's
37/// own stated design (see [`super::url::redact_userinfo`]'s doc):
38/// - Once triggered, the actual redaction ([`super::url::url_for_tracing`]) can still destroy more of `key`
39/// than just the credential — e.g. `"source:feed//user:hunter2@h"` redacts to `"***@h"`,
40/// losing the `"source:feed//"` label entirely — since the fallback scan this reaches has no
41/// way to tell where the opaque label ends and true userinfo begins once neither
42/// `is_authority_bearing_url` nor a real authority is available to anchor on. This defeats
43/// `declaration_key`'s own disambiguation purpose (see its doc, and
44/// `build_blocked_registry_diagnostic`'s) for that one value.
45/// - A non-credential `label:text@text`-shaped key is redacted too, since the gate cannot tell
46/// it apart from a real credential — e.g. `"source:contoso@internal"` redacts to
47/// `"***@internal"` even though `contoso` is just a source name, not a password. In practice
48/// only a free-text label an ecosystem builds from unvalidated user input (e.g. NuGet's
49/// `format!("source:{}", entry.key)` from a `NuGet.config` `<add key>` attribute) is likely to
50/// contain an `@` at all — most ecosystems' own fixed/opaque labels cannot.
51///
52/// This is a best-effort heuristic gate, not a formally verified one: it closes every
53/// known-realistic and known-adversarial leak shape found so far (#981, #993), but a
54/// sufficiently unusual `key` could in principle still slip past both `is_authority_bearing_url`
55/// and this shape check.
56///
57/// # Examples
58///
59/// ```
60/// use deps_core::redact::redact_declaration_key;
61///
62/// assert_eq!(
63/// redact_declaration_key("https://user:hunter2@10.0.0.1/index"),
64/// "https://***@10.0.0.1/index"
65/// );
66/// assert_eq!(
67/// redact_declaration_key("source:https://user:hunter2@10.0.0.1/v3/index.json"),
68/// "source:https://***@10.0.0.1/v3/index.json"
69/// );
70/// assert_eq!(redact_declaration_key("source:Blocked"), "source:Blocked");
71/// assert_eq!(redact_declaration_key("scope:@myorg"), "scope:@myorg");
72/// // A single `/` (not `//`) still redacts — this is a credential-shape gate, not a
73/// // separator-substring gate.
74/// assert_eq!(
75/// redact_declaration_key("source:feed/user:hunter2@nuget.internal"),
76/// "***@nuget.internal"
77/// );
78/// // No `@` at all: never redacted, even with a `/`-heavy label.
79/// assert_eq!(
80/// redact_declaration_key("source:feed//mirror"),
81/// "source:feed//mirror"
82/// );
83/// // A control character survives the credential-shape gate (no `@` here at all) but is still
84/// // swept, since it could otherwise forge a log line on its own (#1469).
85/// assert_eq!(
86/// redact_declaration_key("lodash\nERROR deps_lsp: FORGED"),
87/// "lodash ERROR deps_lsp: FORGED"
88/// );
89/// ```
90#[must_use]
91#[expect(
92 clippy::string_slice,
93 reason = "idx comes from find of ASCII '?'/'#' bytes on key, so it always lands on a char \
94 boundary"
95)]
96pub fn redact_declaration_key(key: &str) -> std::borrow::Cow<'_, str> {
97 // Parsed once and reused for both the authority-bearing check and the redaction call below
98 // (#1317) — `is_authority_bearing_url` hands back the parsed `Url` instead of a plain `bool`
99 // for exactly this reason.
100 let parsed = is_authority_bearing_url(key);
101 let redacted = if parsed.is_some() || has_credential_shape(key) {
102 // The gate firing doesn't mean the text actually changed (e.g. an authority-bearing,
103 // credential-free URL) — a value-equality check is needed to still borrow in that case,
104 // not just when the gate never fires at all (critic S1, #1317). Note this still costs
105 // `url_for_tracing_with_parsed`'s own internal `String` allocation on that no-op path
106 // (code-review follow-up, #1317) — the `Cow::Borrowed` below only avoids a caller
107 // re-allocating a second time on top of it via `Display`/`.to_string()`.
108 let redacted = url_for_tracing_with_parsed(key, parsed);
109 if redacted == key {
110 std::borrow::Cow::Borrowed(key)
111 } else {
112 std::borrow::Cow::Owned(redacted)
113 }
114 } else {
115 match key.find(['?', '#']) {
116 Some(idx) => std::borrow::Cow::Owned(key[..idx].to_string()),
117 None => std::borrow::Cow::Borrowed(key),
118 }
119 };
120 // #1469: the credential-shape gate above has no opinion on control characters, so a
121 // manifest-controlled `key` with no credential shape at all (e.g. a plain package name
122 // carrying a raw `\n`/`\r`/ESC) would otherwise reach this function's callers — including
123 // `PackageName::for_tracing()` — unsanitized.
124 match sanitize_invisible(&redacted) {
125 std::borrow::Cow::Borrowed(_) => redacted,
126 std::borrow::Cow::Owned(sanitized) => std::borrow::Cow::Owned(sanitized),
127 }
128}
129
130/// Replaces every Unicode `Cc` (Control), `Cf` (Format), `Zl` (Line Separator), and `Zp`
131/// (Paragraph Separator) character in `s` with a single space, borrowing when `s` has none
132/// (#1242, #1246).
133///
134/// `Cc` covers ASCII/C1 control characters, including `\n`/`\r` and raw ANSI escape
135/// (`\x1B`) bytes, that could otherwise splice new lines into a single-line rendering
136/// (table row, JSON string, SARIF message) or forge terminal escape sequences. `Cf`
137/// additionally covers invisible formatting characters with no glyph of their own —
138/// bidirectional overrides (e.g. U+202E RIGHT-TO-LEFT OVERRIDE, the Trojan Source vector),
139/// zero-width joiners/spaces (U+200B, U+200C, U+200D), and the byte-order mark (U+FEFF) —
140/// which can visually reorder or hide text without tripping a `Cc`-only check. `Zl`/`Zp`
141/// (U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR) are neither `Cc` nor `Cf`, but are
142/// still line terminators for JS/`eval` consumers of `--format json` output and are treated
143/// as breaks by some editor renderers (critic follow-up M1, #1246).
144///
145/// This is deliberately wider than [`crate::lsp_helpers::escape_markdown`] and
146/// [`crate::lsp_helpers::markdown_code_span`]'s own narrow, explicit bidi/invisible-character
147/// list (`is_markdown_unsafe`, #1248): those helpers also run on registry-supplied hover
148/// *descriptions*, where legitimate right-to-left text carries real `Cf` marks (e.g. U+200F
149/// RIGHT-TO-LEFT MARK, U+061C ARABIC LETTER MARK) and emoji ZWJ sequences carry U+200D, so
150/// treating the whole `Cf` category as unsafe there would mangle genuine Arabic/Hebrew text
151/// or emoji. A manifest-declared package/coordinate *name*, by contrast, has no legitimate
152/// use for any `Cf`/`Zl`/`Zp` character, so this dedicated helper — for name-shaped values
153/// only — can safely treat the whole categories as unsafe.
154///
155/// # Examples
156///
157/// ```
158/// use deps_core::redact::sanitize_invisible;
159///
160/// assert_eq!(sanitize_invisible("left-pad"), "left-pad");
161/// assert_eq!(sanitize_invisible("a\nb\rc"), "a b c");
162/// assert_eq!(sanitize_invisible("bidi\u{202E}gnp.exe"), "bidi gnp.exe");
163/// assert_eq!(sanitize_invisible("a\u{2028}b\u{2029}c"), "a b c");
164/// ```
165#[must_use]
166pub fn sanitize_invisible(s: &str) -> std::borrow::Cow<'_, str> {
167 if !s.chars().any(is_invisible) {
168 return std::borrow::Cow::Borrowed(s);
169 }
170 std::borrow::Cow::Owned(
171 s.chars()
172 .map(|c| if is_invisible(c) { ' ' } else { c })
173 .collect(),
174 )
175}
176
177/// Whether `c` belongs to the Unicode `Cc`/`Cf`/`Zl`/`Zp` general categories
178/// [`sanitize_invisible`] sweeps.
179///
180/// `pub(crate)` rather than private so `lsp_helpers::mod`'s drift-guard test (#1323) can
181/// scan the full Unicode code space and assert that every character this predicate
182/// flags is either blocked by `is_markdown_unsafe` or in that function's documented,
183/// named exempt set — without duplicating this category logic in the test itself,
184/// which would defeat the point of a drift guard.
185pub(crate) fn is_invisible(c: char) -> bool {
186 use unicode_general_category::{GeneralCategory, get_general_category};
187
188 matches!(
189 get_general_category(c),
190 GeneralCategory::Control
191 | GeneralCategory::Format
192 | GeneralCategory::LineSeparator
193 | GeneralCategory::ParagraphSeparator
194 )
195}
196
197/// Longest prefix (in bytes, not chars — see [`redact_parse_error_for_log`]'s slicing) of a
198/// redacted parse-error message logged, post-redaction, by [`redact_parse_error_for_log`].
199///
200/// `pub`, not `pub(crate)`: `deps_pypi::parser::truncate_for_log` (a thin wrapper delegating to
201/// [`redact_parse_error_for_log`]) mirrors this value for its own test fixtures rather than
202/// hardcoding a second `200` that could silently drift from this one.
203pub const MAX_PARSE_ERROR_LOG_BYTES: usize = 200;
204
205/// Redacts, then bounds, a parse-error's `Display` text before it reaches a log sink.
206///
207/// `toml_span::Error` and `yaml_rust2::ScanError` can both embed a manifest/lockfile's
208/// offending duplicate key or table name verbatim in their `Display` output, and that name can
209/// itself be a credential-bearing URL (#1240). [`redact_declaration_key`] runs first, then the
210/// result is truncated to a bounded prefix — in that order, since truncating first could cut the
211/// string exactly at the boundary the credential-shape scan depends on, leaking a credential
212/// that straddles the cut. Truncation is an independent concern from redaction: the file `raw`
213/// derives from can be as large as the ~10 MB read cap
214/// ([`crate::fs_probe::read_to_string_capped`]'s bound), so an unbounded
215/// `tracing::warn!`/`debug!`/`error!` call would be a synchronous multi-megabyte write to a (by
216/// default, unbuffered, stderr-backed) log sink — this mostly matters on the benign path, since
217/// once redaction actually fires it collapses `raw` to a short fixed-shape string well under the
218/// cap. Falls back to `raw` unchanged (as a `Cow::Borrowed`, so a caller that just needs to read
219/// or forward the result avoids a second clone) when redaction was a no-op and it's already
220/// short enough.
221///
222/// [`redact_declaration_key`] itself returns a `Cow`, so this function's own no-op path never
223/// re-allocates on top of it (#1317) — it just forwards `redact_declaration_key`'s own
224/// `Cow::Borrowed`. This is guaranteed allocation-free only for `redact_declaration_key`'s plain
225/// opaque-key fallback (no `?`/`#`, no URL/credential shape); its authority-bearing,
226/// credential-free case (e.g. a plain registry URL with no query) still allocates internally
227/// before that `Cow::Borrowed` short-circuit discards the allocation (code-review follow-up,
228/// #1317) — the win there is one fewer allocation, not zero.
229///
230/// This is the single shared implementation behind both `deps-core`'s own parse-error sinks and
231/// `deps_pypi::parser::truncate_for_log`'s delegation to it — see CLAUDE.md's cross-ecosystem
232/// rule: the same redact-then-truncate shape was independently needed in ≥2 crates (#1228,
233/// #1239, #1240), so it lives here once rather than being reimplemented per-crate.
234///
235/// # Examples
236///
237/// ```
238/// use deps_core::redact::redact_parse_error_for_log;
239///
240/// assert_eq!(
241/// redact_parse_error_for_log(
242/// "duplicate key: `https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x`"
243/// ),
244/// "duplicate key: `https://***@pkg.internal.corp/x`"
245/// );
246/// // Benign errors are left unchanged — the credential-shape gate does not fire on them.
247/// assert_eq!(redact_parse_error_for_log("duplicate key: `serde`"), "duplicate key: `serde`");
248/// ```
249#[must_use]
250#[expect(
251 clippy::string_slice,
252 reason = "`boundary` comes from `floor_char_boundary`, so the slice always lands on a char \
253 boundary"
254)]
255pub fn redact_parse_error_for_log(raw: &str) -> std::borrow::Cow<'_, str> {
256 let text = redact_declaration_key(raw);
257 if text.len() <= MAX_PARSE_ERROR_LOG_BYTES {
258 return text;
259 }
260 let boundary = text.floor_char_boundary(MAX_PARSE_ERROR_LOG_BYTES);
261 // `raw.len()`, not `text.len()`: the annotation describes the original attacker-controlled
262 // payload's size, which redaction must not misreport just because it happened to shorten
263 // the visible text (#1228 M1).
264 std::borrow::Cow::Owned(format!(
265 "{}... ({} bytes total)",
266 &text[..boundary],
267 raw.len()
268 ))
269}
270
271/// Redacts `e` via [`redact_parse_error_for_log`] and wraps the result as the boxed error
272/// [`crate::error::DepsError::ParseError`]'s `source` field expects.
273///
274/// Centralizes the whole "redact, then wrap into an `io::Error`" step — not just the redaction
275/// itself — as one call, `deps_core::redact::parse_error_source(&e)`, in place of
276/// `Box::new(std::io::Error::other(redact_parse_error_for_log(&e.to_string())))` repeated at
277/// each `toml_span`/`yaml-rust2` `map_err` site. A future ecosystem crate adding a new parse-error
278/// site is forced through the safe path by construction, rather than being able to copy an old,
279/// unfixed `Box::new(std::io::Error::other(e.to_string()))` from elsewhere and reintroduce #1240.
280///
281/// # Examples
282///
283/// ```
284/// use deps_core::redact::parse_error_source;
285///
286/// let source = parse_error_source(&"duplicate key: `serde`");
287/// assert_eq!(source.to_string(), "duplicate key: `serde`");
288/// ```
289#[must_use]
290pub fn parse_error_source(e: &dyn std::fmt::Display) -> Box<dyn std::error::Error + Send + Sync> {
291 Box::new(std::io::Error::other(redact_parse_error_for_log_owned(
292 e.to_string(),
293 )))
294}
295
296/// [`redact_parse_error_for_log`]'s owned-input counterpart: `raw` is already an owned
297/// allocation the caller has no further use for, so the no-op path returns it directly instead
298/// of cloning a borrow of it via `Cow::into_owned` (#1317) — the exact allocation
299/// [`parse_error_source`] used to pay twice for on the benign (no-credential) path.
300#[must_use]
301fn redact_parse_error_for_log_owned(raw: String) -> String {
302 // Relies on `Borrowed` always meaning "the full, untruncated `raw`" — true today, since
303 // `redact_parse_error_for_log`'s truncation branch always returns `Owned` (never a
304 // `Cow::Borrowed(&raw[..boundary])` slice) specifically so this substitution stays correct;
305 // that invariant would need to move here too if the truncation branch ever changed.
306 match redact_parse_error_for_log(&raw) {
307 std::borrow::Cow::Borrowed(_) => raw,
308 std::borrow::Cow::Owned(s) => s,
309 }
310}
311
312/// The segment-bounded `@`/`:` scan shared by [`redact_declaration_key`] (decides whether a
313/// non-URL label needs redacting at all) and [`is_credential_or_query_bearing`] (decides whether an
314/// outbound value must be rejected outright) — extracted so the two callers can't drift apart
315/// on what counts as "looks like a credential" (#1206).
316///
317/// See [`redact_declaration_key`]'s own doc for the exact segment/trim rule this implements.
318#[expect(
319 clippy::string_slice,
320 reason = "`at` comes from `match_indices('@')` on ASCII '@' bytes, so every slice bound \
321 always lands on a char boundary"
322)]
323fn has_credential_shape(key: &str) -> bool {
324 let mut prev = 0;
325 key.match_indices('@').any(|(at, _)| {
326 let segment = &key[prev..at];
327 prev = at + 1;
328 segment_has_credential_colon(segment.trim_end_matches(':'))
329 })
330}
331
332/// Whether `value` carries credential-shaped userinfo of its own, or an untrusted
333/// query/fragment component that could hide one.
334///
335/// Detects, in order:
336/// - a `?`/`#` anywhere in `value` (#1206 S2) — a query string is exactly where a token can
337/// travel (`?token=...`, `?access_token=...`) without ever taking the `user:pass@host`
338/// shape the checks below look for, and [`super::url::url_for_tracing`] already treats *any* query or
339/// fragment as untrusted-by-default (it drops both unconditionally, per #866/#858) — this
340/// function applies that same always-suspect rule to the reject-before-search decision, not
341/// just to what gets logged afterward;
342/// - for a real URL authority (`host()` is `Some`), whether [`super::url::redact_userinfo`] would change
343/// `value` at all — not just a non-empty username/password on the authority itself. A
344/// credential-shaped `user:pass@host` span can also sit *after* the authority, later in the
345/// path (e.g. `https://mirror.example/redirect/user:pass@evil.com`, code-review round 1
346/// #1206 finding 1): checking only `url.username()`/`url.password()` missed this, since that
347/// pair only reflects the authority's own userinfo. Delegating to `redact_userinfo` reuses
348/// its existing authority-then-tail scan instead of re-deriving it, and is deliberately
349/// scoped to the `host().is_some()` case alone — see the over-rejection note below for why
350/// this isn't applied unconditionally to every input;
351/// - for text with no parseable URL structure (e.g. a `.package(url: "...")` literal with its
352/// scheme already stripped before reaching a registry-search query), the same
353/// segment-bounded credential-colon shape [`redact_declaration_key`]'s fallback scan uses
354/// (`has_credential_shape`).
355///
356/// Unlike `is_authority_bearing_url`, this is a genuine credential-detection predicate, not
357/// "does this parse as a URL at all" — an ordinary credential-free, query-free URL
358/// (`https://host/path`) returns `false` here.
359///
360/// Narrower than [`super::url::url_for_tracing`]'s own redaction coverage in one specific way: a
361/// scheme-stripped, colon-less, token-only userinfo (e.g. `ghp_TOKEN@host/path`, with no `:`
362/// anywhere) is not flagged here, even though `url_for_tracing` would still redact it via
363/// `find_token_prefix_at`'s prefix sniffing. Not widened to match: probe-testing found that
364/// treating "the redactor would change this string" as the reject criterion false-positives on
365/// ordinary non-credential input containing a bare `@` with no colon at all — a Maven
366/// coordinate like `com.google.guava:guava` (no `@`, unaffected) is fine, but an SSH-style Git
367/// remote like `git@github.com:apple/swift-nio.git` would trip a token-prefix-agnostic
368/// widening. The `user:pass@`/`label:value@` colon-shaped case this function does catch covers
369/// every shape in the actual #1206 repro family (a full `.package(url:)` literal, with or
370/// without its scheme).
371///
372/// Conversely, delegating to `redact_userinfo` for the `host().is_some()` case inherits that
373/// function's own documented over-redaction: a colon in the path with no `@` at all (e.g.
374/// `https://10.0.0.1/v1/items:search`, a REST-style path segment, no credential involved) is
375/// still flagged here, because `redact_userinfo` still rewrites it. Accepted per this module's
376/// stated over-redact/over-reject-over-leak trade-off (the same one the `?`/`#` check above
377/// already applies) — an occasional false-positive-rejected completion keystroke costs far less
378/// than a missed credential leak, and this narrower delegation (scoped to values that already
379/// parse as a real URL authority) is far more targeted than falling through to
380/// `has_credential_shape` would be, which over-triggers on *any* `scheme://...@...` value purely
381/// from the scheme's own leading colon (verified while fixing this finding: naively falling
382/// through to `has_credential_shape` for every `host().is_some()` value flagged an ordinary
383/// scoped-package-style path like `https://github.com/@babel/core` with no credential anywhere).
384///
385/// Intended as a reject-before-search gate (issue #1206): a value this function flags must
386/// never reach an outbound registry-search query or an unredacted `tracing` field — a redacted
387/// value is not a useful search term, so the caller should drop the request entirely rather
388/// than redact-and-search.
389///
390/// # Examples
391///
392/// ```
393/// use deps_core::redact::is_credential_or_query_bearing;
394///
395/// assert!(is_credential_or_query_bearing(
396/// "https://user:hunter2@registry.example/simple"
397/// ));
398/// // Scheme already stripped by the caller (e.g. Swift's `.package(url:)` completion) — no
399/// // longer parses as a URL, but still shows the same credential shape.
400/// assert!(is_credential_or_query_bearing(
401/// "deploy:AUDITSENTINEL0000@git.internal.corp/team/x.git"
402/// ));
403/// // A query string can carry a token with no userinfo shape at all.
404/// assert!(is_credential_or_query_bearing(
405/// "https://github.com/apple/swift-nio?token=SECRET"
406/// ));
407/// // A credential-shaped span after the authority, later in the path, is caught too.
408/// assert!(is_credential_or_query_bearing(
409/// "https://mirror.example/redirect/user:pass@evil.com"
410/// ));
411/// assert!(!is_credential_or_query_bearing("apple/swift-nio"));
412/// assert!(!is_credential_or_query_bearing("https://registry.example/simple"));
413/// ```
414#[must_use]
415pub fn is_credential_or_query_bearing(value: &str) -> bool {
416 if value.contains(['?', '#']) {
417 return true;
418 }
419 // Parsed once and reused below (#1317) instead of parsing `value` again inside
420 // `redact_userinfo`.
421 let parsed = url::Url::parse(value).ok();
422 if parsed
423 .as_ref()
424 .is_some_and(|url| !url.cannot_be_a_base() && url.host().is_some())
425 {
426 return redact_userinfo_with_parsed(value, parsed) != value;
427 }
428 has_credential_shape(value)
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 /// #993 M5 (impl-critic on the first credential-shape fix): the carve-out used to inspect
436 /// only the *last* `@` found by the old `find_credential_at`-based scan, so a trailing
437 /// `label:@`-shaped decoy segment could shadow an earlier, real credential. The per-segment
438 /// scan (`segment_has_credential_colon` on each `@`-delimited segment, independently) fixes
439 /// this: every segment is judged on its own, so a decoy segment after the real credential
440 /// can no longer suppress redaction of the one before it.
441 #[test]
442 fn redact_declaration_key_trailing_decoy_segment_does_not_shadow_earlier_credential() {
443 assert_eq!(
444 redact_declaration_key("source:user:hunter2@nuget.internal:@x"),
445 "***@x"
446 );
447 assert_eq!(
448 redact_declaration_key("source:user:hunter2@host/scope:@myorg"),
449 "***@host/***@myorg"
450 );
451 assert_eq!(redact_declaration_key("named:user:ghp_SECRET@h:@"), "***@");
452 for adversarial in [
453 "source:user:hunter2@nuget.internal:@x",
454 "source:user:hunter2@host/scope:@myorg",
455 "named:user:ghp_SECRET@h:@",
456 "source:user:hunter2@h@scope:@myorg",
457 ] {
458 assert!(
459 !redact_declaration_key(adversarial).contains("hunter2")
460 && !redact_declaration_key(adversarial).contains("SECRET"),
461 "credential in {adversarial:?} must not survive redaction"
462 );
463 }
464 }
465
466 /// Critic S2 (#1317): pins `url_for_tracing_with_parsed`'s `end != raw.len()` fallback —
467 /// when `key` has a query/fragment to truncate away, the `Url` parsed from the *untruncated*
468 /// `key` by `is_authority_bearing_url` must be discarded and the truncated slice re-parsed,
469 /// or the query string (and any credential inside it) would leak past #866's truncate-first
470 /// guarantee.
471 #[test]
472 fn redact_declaration_key_truncates_query_credential_on_authority_bearing_url() {
473 let redacted =
474 redact_declaration_key("https://user:hunter2@registry.example/index?token=SECRET");
475 assert!(!redacted.contains("hunter2"));
476 assert!(!redacted.contains("SECRET"));
477 assert!(!redacted.contains("token"));
478 assert_eq!(redacted, "https://***@registry.example/index");
479 }
480
481 #[test]
482 fn redact_parse_error_for_log_redacts_credential_and_keeps_host() {
483 let redacted = redact_parse_error_for_log(
484 "duplicate key: `https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x`",
485 );
486 assert!(!redacted.contains("ghp_SUPERSECRETTOKEN123"));
487 assert!(!redacted.contains("svcacct"));
488 assert!(redacted.contains("pkg.internal.corp"));
489 }
490
491 #[test]
492 fn redact_parse_error_for_log_leaves_benign_message_unchanged() {
493 assert_eq!(
494 redact_parse_error_for_log("duplicate key: `serde`"),
495 "duplicate key: `serde`"
496 );
497 }
498
499 /// #1240 (impl-critic S3 follow-up on the developer's first attempt): the original fixture
500 /// placed the whole credential past [`MAX_PARSE_ERROR_LOG_BYTES`], so a truncate-first bug
501 /// would have dropped it too — the ordering invariant went untested. This fixture instead
502 /// straddles the cut: the secret token sits before the boundary, and the disambiguating `@`
503 /// that `has_credential_shape` needs lands after it. A truncate-first implementation would
504 /// see only the `@`-less prefix, never detect a credential, and emit the secret almost
505 /// verbatim; redact-then-truncate (the correct order) sees the whole string, including the
506 /// `@`, and redacts it before the cut ever happens.
507 ///
508 /// #1240 round 2 (impl-critic R2-M1): asserting on the *full* `"ghp_SUPERSECRETTOKEN123"` is
509 /// not load-bearing on its own — the boundary cuts mid-token (at byte 200, one byte before
510 /// the token's own end at byte 201), so even a truncate-first bug drops the trailing digits
511 /// and that assertion passes under either ordering. `"SUPERSECRETTOKEN"` (no trailing
512 /// digits) ends before the boundary, so it fully survives a buggy truncate-first prefix —
513 /// only the correct redact-first order removes it, making this assertion actually gate the
514 /// invariant, same as the `"svcacct"` one already did.
515 #[test]
516 fn redact_parse_error_for_log_redacts_before_truncating() {
517 let raw = format!(
518 "{}svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x",
519 "x".repeat(170)
520 );
521 // Named `needle_end`/`at_sign_offset`, not `secret_*` — these are byte offsets into the
522 // fixture, not the credential text itself, but CodeQL's cleartext-logging heuristic
523 // flags on variable-name pattern, not on what the value actually holds.
524 let needle_end = raw.find("SUPERSECRETTOKEN").unwrap() + "SUPERSECRETTOKEN".len();
525 let at_sign_offset = raw.find('@').unwrap();
526 assert!(
527 needle_end < MAX_PARSE_ERROR_LOG_BYTES && at_sign_offset > MAX_PARSE_ERROR_LOG_BYTES,
528 "fixture must straddle the truncation boundary: needle ends at {needle_end}, '@' at {at_sign_offset}, boundary {MAX_PARSE_ERROR_LOG_BYTES}"
529 );
530
531 let redacted = redact_parse_error_for_log(&raw);
532 assert!(!redacted.contains("SUPERSECRETTOKEN"));
533 assert!(!redacted.contains("svcacct"));
534 }
535
536 /// #1240 round 2 (impl-critic R2-M2): the truncation branch itself — including the
537 /// original-vs-redacted length annotation rule (round 1 S2) — was only exercised via
538 /// `deps_pypi::parser::truncate_for_log`'s inherited test, not directly in `deps-core`.
539 /// Mirrors that test's fixture here so the rule stays covered even if the pypi wrapper is
540 /// ever removed.
541 #[test]
542 fn redact_parse_error_for_log_truncation_annotation_reports_original_length() {
543 let credential = "user:hunter2very-long-password-padding-to-cross-the-cap@";
544 let host_and_path = "example.com/".repeat(20);
545 let raw = format!("https://{credential}{host_and_path}");
546 assert!(
547 raw.len() > MAX_PARSE_ERROR_LOG_BYTES,
548 "fixture must exceed the cap"
549 );
550
551 let redacted = redact_parse_error_for_log(&raw);
552 assert!(!redacted.contains("hunter2"));
553 assert!(
554 redacted.contains(&format!("({} bytes total)", raw.len())),
555 "byte count must reflect the original input length, not the (shorter, \
556 post-redaction) visible text: {redacted:?}"
557 );
558 }
559
560 /// #993 M6: the credential-shape gate cannot distinguish a real credential from a
561 /// non-credential `label:text@text` key, so a free-text label containing an `@` (only
562 /// realistically reachable via NuGet's unvalidated `NuGet.config` `<add key>` attribute) is
563 /// redacted too, even though nothing in it is a password. Accepted per this module's
564 /// over-redact-over-leak design; pinned here as a documented, intentional trade-off rather
565 /// than an accidental regression.
566 #[test]
567 fn redact_declaration_key_over_redacts_non_credential_label_at_text_shape() {
568 assert_eq!(
569 redact_declaration_key("source:contoso@internal"),
570 "***@internal"
571 );
572 assert_eq!(redact_declaration_key("named:my-index@v1"), "***@v1");
573 }
574
575 // --- is_credential_or_query_bearing (#1206) ---
576
577 #[test]
578 fn is_credential_or_query_bearing_detects_real_url_userinfo() {
579 assert!(is_credential_or_query_bearing(
580 "https://user:hunter2@registry.example/simple"
581 ));
582 assert!(is_credential_or_query_bearing(
583 "https://tokenonly@registry.example"
584 ));
585 }
586
587 /// The exact shape of issue #1206: Swift's completion strips the `https://github.com/`
588 /// scheme off a `.package(url:)` literal before it becomes a search query, so a
589 /// credential-bearing value no longer parses as a URL by the time this gate sees it.
590 #[test]
591 fn is_credential_or_query_bearing_detects_scheme_stripped_credential() {
592 assert!(is_credential_or_query_bearing(
593 "deploy:AUDITSENTINEL0000@git.internal.corp/team/x.git"
594 ));
595 }
596
597 #[test]
598 fn is_credential_or_query_bearing_false_for_ordinary_url() {
599 assert!(!is_credential_or_query_bearing(
600 "https://registry.example/simple"
601 ));
602 }
603
604 #[test]
605 fn is_credential_or_query_bearing_false_for_plain_package_name() {
606 assert!(!is_credential_or_query_bearing("apple/swift-nio"));
607 assert!(!is_credential_or_query_bearing("serde"));
608 }
609
610 /// A scoped npm-style name (`@scope/pkg`) must not false-positive: the segment before its
611 /// only `@` is empty, so no credential-shaped `:` can be found in it.
612 #[test]
613 fn is_credential_or_query_bearing_false_for_scoped_package_name() {
614 assert!(!is_credential_or_query_bearing("@babel/core"));
615 }
616
617 /// A Windows drive-letter colon (`c:/...`) must not be mistaken for credential shape,
618 /// mirroring `redact_userinfo`'s own drive-letter carve-out.
619 #[test]
620 fn is_credential_or_query_bearing_false_for_drive_letter_colon() {
621 assert!(!is_credential_or_query_bearing("c:/user@evil"));
622 }
623
624 /// #1206 S2: a query-string token has no `user:pass@` userinfo shape at all, so it must be
625 /// caught by the `?`/`#` check rather than falling through to the credential-colon scan.
626 #[test]
627 fn is_credential_or_query_bearing_true_for_query_string_token() {
628 assert!(is_credential_or_query_bearing(
629 "https://github.com/apple/swift-nio?token=SECRET"
630 ));
631 assert!(is_credential_or_query_bearing(
632 "apple/swift-nio?token=SECRET"
633 ));
634 assert!(is_credential_or_query_bearing("apple/swift-nio#fragment"));
635 }
636
637 /// #1206 M1: a Maven coordinate and an SSH-style Git remote must not false-positive —
638 /// pinned here since a naive "the redactor would touch this string" widening (considered
639 /// and rejected, see this function's own doc) would flag both.
640 #[test]
641 fn is_credential_or_query_bearing_false_for_maven_coordinate_and_ssh_remote() {
642 assert!(!is_credential_or_query_bearing("com.google.guava:guava"));
643 assert!(!is_credential_or_query_bearing(
644 "git@github.com:apple/swift-nio.git"
645 ));
646 }
647
648 /// Code-review round 1 finding 1: a credential-shaped span after a real URL authority —
649 /// not in the authority's own userinfo — must still be caught, matching what
650 /// `url_for_tracing` already redacts for the identical input.
651 #[test]
652 fn is_credential_or_query_bearing_true_for_credential_after_authority_in_path() {
653 assert!(is_credential_or_query_bearing(
654 "https://mirror.example/redirect/user:pass@evil.com"
655 ));
656 }
657
658 /// A scoped-package-style path segment (`@babel/core`-shaped) appended to a clean URL
659 /// authority must not false-positive purely from the scheme's own leading colon — the
660 /// specific over-trigger a naive fallback to `has_credential_shape` would have introduced
661 /// (see this function's own doc; caught while fixing the finding above).
662 #[test]
663 fn is_credential_or_query_bearing_false_for_scoped_package_path_after_authority() {
664 assert!(!is_credential_or_query_bearing(
665 "https://github.com/@babel/core"
666 ));
667 }
668
669 /// Documented, accepted over-rejection (see this function's own doc): a REST-style path
670 /// colon with no credential at all still gets rejected, because `redact_userinfo` itself
671 /// still rewrites it. Pinned so a future change to this trade-off is a deliberate doc +
672 /// test update, not a silent behavior drift.
673 #[test]
674 fn is_credential_or_query_bearing_true_for_rest_style_path_colon_no_credential() {
675 assert!(is_credential_or_query_bearing(
676 "https://10.0.0.1/v1/items:search"
677 ));
678 }
679
680 /// #1209: `redact_declaration_key` is the primitive [`crate::PackageName::for_tracing`]
681 /// and [`RedactedName`] build on to redact package/coordinate names before they reach a
682 /// log or client-visible message. One genuine, legitimate name per ecosystem (from the
683 /// security audit's live probe against all 14 ecosystems) must survive unredacted —
684 /// otherwise every hover/completion/diagnostic for that ecosystem would render a mangled
685 /// name.
686 #[test]
687 fn redact_declaration_key_leaves_every_ecosystems_legitimate_names_untouched() {
688 for name in [
689 "serde", // Cargo
690 "com.google.guava:guava", // Maven/Gradle
691 "org.springframework.boot:spring-boot-starter-web", // Maven/Gradle
692 "@types/node", // npm
693 "@babel/core", // npm
694 "jsr:@std/path", // Deno
695 "npm:@scope/pkg", // Deno
696 "requests", // PyPI
697 "github.com/gin-gonic/gin", // Go
698 "gopkg.in/yaml.v3", // Go
699 "rails", // Bundler
700 "path", // Dart
701 "git@github.com:apple/swift-nio.git", // Swift
702 "monolog/monolog", // Composer
703 "Newtonsoft.Json", // NuGet
704 "actions/checkout", // GitHub Actions
705 "gitlab.com/components/sast", // GitLab CI
706 "ghcr.io/owner/image@sha256:abcdef", // GitLab CI (component ref)
707 "com.example:${project.version}", // Maven (unresolved property)
708 "alternate registry (not registered)", // shared fallback label
709 ] {
710 assert_eq!(
711 redact_declaration_key(name),
712 name,
713 "legitimate name {name:?} must survive redaction unchanged"
714 );
715 }
716 }
717
718 /// #1209 M2 (impl-critic follow-up): `redact_declaration_key` cannot distinguish a real
719 /// `label:value@suffix`-shaped name from a genuine credential — the same structural
720 /// ambiguity [`redact_declaration_key`]'s own doc already calls out. No current ecosystem
721 /// emits this shape in a `PackageName` (Deno keeps the version out of the name —
722 /// `deps_deno::specifier::ParsedSpecifier`'s `name` field never carries a trailing
723 /// `@version`), so this is not a live bug — but since [`RedactedName`] now feeds
724 /// [`crate::DepsError::PackageNotFound`]'s `Display`, which reaches a `window/showMessage`
725 /// toast, a future ecosystem emitting this shape would silently over-redact a legitimate
726 /// name in a user-visible message. This test documents the known limitation explicitly
727 /// (asserting the *actual*, over-redacting behavior) rather than leaving it as a silent
728 /// gap — a change to any of these outcomes should be a deliberate, reviewed one.
729 #[test]
730 fn redact_declaration_key_over_redacts_documented_label_value_at_suffix_shapes() {
731 assert_eq!(redact_declaration_key("npm:express@4.18.2"), "***@4.18.2");
732 assert_eq!(
733 redact_declaration_key("com.example:artifact@1.0"),
734 "***@1.0"
735 );
736 assert_eq!(
737 redact_declaration_key("alpine:3.18@sha256:abc"),
738 "***@sha256:***"
739 );
740 assert_eq!(
741 redact_declaration_key("ghcr.io/owner/image:1.2.3@sha256:abcdef"),
742 "ghcr.io/owner/image:***"
743 );
744 }
745
746 /// Critic follow-up M1 (#1242, #1246): `sanitize_invisible` covers `Cc`/`Cf`, but U+2028
747 /// LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are neither category — both are line
748 /// terminators for JS/`eval` consumers of `--format json` output and are treated as
749 /// breaks by some editor renderers, so they must be sanitized too.
750 #[test]
751 fn sanitize_invisible_replaces_line_and_paragraph_separators() {
752 assert_eq!(
753 sanitize_invisible("a\u{2028}b\u{2029}c"),
754 "a b c",
755 "U+2028/U+2029 must not survive sanitize_invisible"
756 );
757 }
758
759 #[test]
760 fn sanitize_invisible_borrows_when_nothing_needs_sanitizing() {
761 assert!(matches!(
762 sanitize_invisible("com.google.guava:guava"),
763 std::borrow::Cow::Borrowed(_)
764 ));
765 }
766}