1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
//! Strip embedded credentials from a remote URL before it is logged (#9124).
//!
//! Why: an operator's `remote.origin.url` can be
//! `https://<user>:<token>@github.com/o/r.git`. The managed-clone path logged
//! that value verbatim at INFO, and two daemon log files held a live GitHub
//! token. Every site that logs or formats a remote URL — or text that may
//! quote one, such as `git clone` stderr — goes through this module.
//! What: [`redact_url`] replaces the userinfo of every `scheme://userinfo@`
//! authority in its input with [`REDACTED`], and the value of every
//! token-bearing query key ([`TOKEN_KEYS`]); text with neither is returned
//! borrowed and unchanged. An scp-style `git@host:o/r` carries no secret and
//! is left alone. [`redact_stored_url`] is the variant for one stored URL,
//! whose authority a quote or whitespace does not end (#9227); every site that
//! prints one known URL uses it, and free text keeps [`redact_url`] (#9259).
//! Test: `redact_url_strips_user_and_token` and its siblings in
//! `remote_url_redact_tests.rs`;
//! `a_credentialed_origin_never_reaches_the_log_or_the_error` for the clone
//! log line.
use std::borrow::Cow;
use trusty_common::url_userinfo::{ends_authority, ends_url, userinfo_end};
/// What a URL's userinfo is replaced with.
pub const REDACTED: &str = "***";
/// Query keys whose value is a credential (GitHub, GitLab, OAuth), matched
/// case-insensitively after a `?` or `&`.
pub const TOKEN_KEYS: &[&str] = &["access_token", "private_token", "oauth_token", "token"];
/// A `repo_url` that keeps its raw value but prints redacted under `Debug`
/// (#9124): a derived `Debug` on an error variant would otherwise show it.
/// Test: `daemon::error::tests::project_not_found_message_redacts_the_url_credentials`,
/// `every_single_url_type_masks_a_quoted_or_spaced_password`.
#[derive(Clone, PartialEq, Eq)]
pub struct RedactedUrl(pub String);
impl std::ops::Deref for RedactedUrl {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for RedactedUrl {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for RedactedUrl {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl std::fmt::Debug for RedactedUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// #9259: one stored URL, whose password may hold a quote or space.
std::fmt::Debug::fmt(&redact_stored_url(&self.0), f)
}
}
/// `text` with every URL credential replaced by [`REDACTED`]: the userinfo of
/// each `scheme://userinfo@host`, and the value of each [`TOKEN_KEYS`] query
/// parameter.
///
/// Why: see the module docs.
/// What: the userinfo pass, then the query pass; borrowed when neither
/// changed anything.
/// Test: `redact_url_strips_user_and_token`,
/// `redact_url_strips_an_x_access_token`, `redact_url_leaves_a_clean_url_alone`,
/// `redact_url_redacts_every_url_in_free_text`,
/// `redact_url_masks_a_password_holding_a_raw_slash`,
/// `redact_url_masks_a_password_holding_a_raw_query_or_fragment_char`,
/// `redact_url_masks_query_string_tokens`.
pub fn redact_url(text: &str) -> Cow<'_, str> {
let userinfo = redact_userinfo(text);
let query = match redact_query_tokens(&userinfo) {
Cow::Owned(s) => Some(s),
Cow::Borrowed(_) => None,
};
query.map_or(userinfo, Cow::Owned)
}
/// [`redact_url`] for one stored URL, such as a registry entry, rather than
/// free text (#9227).
///
/// Why: [`redact_url`] ends an authority at a quote or whitespace, so a
/// pre-#9124 entry `https://u:pa'ss@host/o/r` came back unchanged and
/// `tm list` printed the password.
/// What: masks the userinfo [`authority_userinfo_end`] finds after a git
/// scheme's `://`, or the `user:password` of an scp-style `user:pw@host:path`
/// (a bare `git@` is kept), then runs [`redact_url`] over the result for the
/// query tokens and any nested URL. A clean URL comes back borrowed.
/// Test: `redact_stored_url_masks_a_quoted_or_spaced_password`,
/// `redact_stored_url_keeps_a_clean_url_as_redact_url_shows_it`.
pub fn redact_stored_url(url: &str) -> Cow<'_, str> {
match mask_stored_userinfo(url) {
Some(masked) => Cow::Owned(redact_url(&masked).into_owned()),
None => redact_url(url),
}
}
/// `url` with its stored userinfo replaced by [`REDACTED`]; `None` when the
/// stored-URL boundaries find none.
fn mask_stored_userinfo(url: &str) -> Option<String> {
if let Some(at) = url.find("://").filter(|&at| is_git_scheme(&url[..at])) {
let tail = &url[at + 3..];
let cut = authority_userinfo_end(tail)?;
return Some(format!("{}{REDACTED}{}", &url[..at + 3], &tail[cut..]));
}
// git reads `u:T@h:o/r://x` as scp-style; a bare login such as `git@` is
// no secret and stays.
let head = &url[..url.find('/').unwrap_or(url.len())];
let at = head.rfind('@').filter(|&at| head[at..].contains(':'))?;
url[..at]
.contains(':')
.then(|| format!("{REDACTED}{}", &url[at..]))
}
/// [`userinfo_end`] for a stored URL, which is not free text (#9227).
///
/// What: the authority runs to the first `/`, `?` or `#` only, so a quote or
/// whitespace cannot end it; its last `@` ends the userinfo. With no `@` but a
/// `:`, the search runs to the end of the URL, as `userinfo_end`'s over-read
/// does. The result is never before [`userinfo_end`]'s cut: the larger of the
/// two is returned, so an early end fails closed at runtime.
/// Test: `clone_url_strips_or_refuses`,
/// `redact_stored_url_masks_a_quoted_or_spaced_password`.
pub(crate) fn authority_userinfo_end(tail: &str) -> Option<usize> {
let authority = &tail[..tail.find(['/', '?', '#']).unwrap_or(tail.len())];
let cut = match authority.rfind('@') {
Some(at) => Some(at),
None => authority
.find(':')
.and_then(|colon| tail[colon..].rfind('@').map(|i| colon + i)),
};
// `None < Some(_)`, so a free-text cut can only push this one later.
cut.max(userinfo_end(tail))
}
/// Whether git reads `scheme` as a URL scheme: an alphanumeric, then
/// alphanumerics, `+`, `-` or `.` (git's `is_urlschemechar`).
pub(crate) fn is_git_scheme(scheme: &str) -> bool {
scheme.starts_with(|c: char| c.is_ascii_alphanumeric())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
/// The userinfo pass of [`redact_url`].
///
/// What: for each `://`, the userinfo is everything before the `@` that
/// [`userinfo_end`] finds, and is replaced. That boundary is trusty-common's,
/// shared with the stripper that runs before identity derivation (#9124), so
/// the two cannot disagree on where a credential ends; see its docs for the
/// raw-`/` password rule.
fn redact_userinfo(text: &str) -> Cow<'_, str> {
if !text.contains("://") || !text.contains('@') {
return Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
let mut changed = false;
while let Some(at) = rest.find("://") {
let (head, tail) = rest.split_at(at + 3);
out.push_str(head);
match userinfo_end(tail) {
Some(cut) => {
out.push_str(REDACTED);
rest = &tail[cut..];
changed = true;
}
None => {
let end = tail.find(ends_authority).unwrap_or(tail.len());
out.push_str(&tail[..end]);
rest = &tail[end..];
}
}
}
out.push_str(rest);
if changed {
Cow::Owned(out)
} else {
Cow::Borrowed(text)
}
}
/// The query pass of [`redact_url`]: the value after each `?key=` / `&key=`
/// for a key in [`TOKEN_KEYS`], up to the next `&`, `#` or [`ends_url`] char.
fn redact_query_tokens(text: &str) -> Cow<'_, str> {
let mut out = String::new();
let mut copied = 0;
for (sep, _) in text.match_indices(['?', '&']) {
if sep < copied {
continue;
}
let after = &text[sep + 1..];
let Some(key) = TOKEN_KEYS.iter().find(|k| {
after
.get(..k.len())
.is_some_and(|head| head.eq_ignore_ascii_case(k))
&& after[k.len()..].starts_with('=')
}) else {
continue;
};
let start = sep + 1 + key.len() + 1;
let len = text[start..]
.find(|c: char| ends_url(c) || matches!(c, '&' | '#'))
.unwrap_or(text.len() - start);
if len == 0 {
continue;
}
out.push_str(&text[copied..start]);
out.push_str(REDACTED);
copied = start + len;
}
if copied == 0 {
return Cow::Borrowed(text);
}
out.push_str(&text[copied..]);
Cow::Owned(out)
}
#[cfg(test)]
#[path = "remote_url_redact_tests.rs"]
mod tests;