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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
//! Keep credential VALUES out of every generated launchd plist (#8236).
//!
//! Why: `~/Library/LaunchAgents/*.plist` is a world-readable file (`0644` by
//! launchd convention, and `LaunchdConfig::install` never tightens it). A
//! credential written into its `EnvironmentVariables` dict is therefore
//! readable by every process running as the user, lands in every Time Machine
//! backup, and is printed in full by the `plutil -p` any agent runs while
//! hunting for a log path — which is exactly how #8236 was found. The
//! workspace already ships a credential path that is not world-readable
//! (`credentials::resolve_env_var_bounded`: process env, then `.env.local`,
//! then a `0600` file store or the Keychain), so a plaintext plist entry is
//! never the only way to configure a daemon, only the most exposed one.
//!
//! What: the detection half is [`is_credential_env_key`] — REGISTRY membership
//! first, then a `_`-delimited suffix heuristic as a second net — and
//! [`looks_like_credential_value`] (a value carrying a well-known vendor
//! prefix, for `ProgramArguments`, where there is no key to read). The acting
//! half is [`strip_credential_env`], which `crate::launchd::LaunchdConfig::render_plist`
//! applies to every unit it renders, and the `plist` submodule (private; its
//! items are re-exported below), which reads and rewrites an
//! ALREADY-INSTALLED plist so `tm doctor --fix` can remediate a host without
//! reinstalling anything.
//!
//! Nothing here ever returns, logs, or formats a credential value: findings
//! are reported as KEY NAMES, and the one type that carries a value —
//! [`PlistSecret`] — cannot be printed. See `crate::credentials::redact` for
//! the masking used where a value genuinely has to be named.
//!
//! Deliberately NOT gated behind the `credentials` feature, and holding no
//! dependency on it: `launchd` is unconditional, and a guard that compiles out
//! under some feature set is not a guard. The registry it consults lives in
//! the equally unconditional [`crate::credential_registry`] for that reason.
//!
//! Test: `launchd_secrets/tests.rs`.
//!
//! [`is_credential_env_key`]: crate::launchd_secrets::is_credential_env_key
//! [`looks_like_credential_value`]: crate::launchd_secrets::looks_like_credential_value
//! [`strip_credential_env`]: crate::launchd_secrets::strip_credential_env
//! [`PlistSecret`]: crate::launchd_secrets::PlistSecret
pub use ;
/// Key suffixes whose value IS a credential.
///
/// Why: matched on `_`-delimited suffixes rather than substrings so
/// `MAX_TOKENS` and `TOKEN_BUDGET` — both real keys in this workspace — are not
/// swept up by a bare `contains("TOKEN")`. This is the SECOND net: a key the
/// registry already names is a credential before this table is consulted.
/// What: compared against the upper-cased key; a bare key equal to the suffix
/// (`TOKEN`, `PASSWORD`) matches too.
/// Test: `credential_keys_are_detected`, `tunable_keys_are_not_credentials`.
const CREDENTIAL_KEY_SUFFIXES: & = &;
/// Key suffixes that name a POINTER to a credential, never the value.
///
/// Why: `TRUSTY_BUGREPORT_GH_APP_KEY_FILE` holds a path and
/// `AWS_ACCESS_KEY_ID` holds an identifier. Stripping either from a plist
/// would break the daemon while protecting nothing, and a guard that breaks
/// working installs gets turned off.
/// What: checked BEFORE [`CREDENTIAL_KEY_SUFFIXES`], so the pointer reading
/// wins on any key that could be read both ways. It does NOT override the
/// registry: a name the registry declares a credential is one.
/// Test: `credential_reference_keys_are_not_credentials`.
const REFERENCE_KEY_SUFFIXES: & = &;
/// Vendor prefixes that mark a bare string as a credential.
///
/// Why: a `ProgramArguments` entry has no key to read, so the only signal left
/// is the value itself. These are the issuer-assigned prefixes, not a guess at
/// entropy — a heuristic that guessed would strip an argv the daemon needs.
/// Test: `credential_values_are_detected_by_prefix`.
const CREDENTIAL_VALUE_PREFIXES: & = &;
/// Shortest string [`looks_like_credential_value`] will call a credential.
///
/// Why: a literal `sk-` or a redacted `ghp_…` placeholder carries the prefix
/// and no secret. Every real token of these families is far longer, so the
/// floor costs no coverage and stops the argv check firing on documentation.
const MIN_CREDENTIAL_VALUE_LEN: usize = 20;
/// Does `key` name a credential VALUE?
///
/// Why: the one predicate every writer and every diagnostic here shares, so a
/// plist the renderer refuses to write and a plist `tm doctor` flags can never
/// disagree about what counts.
/// What: two nets, in order. A key registered in
/// [`crate::credential_registry::REGISTRY`] is a credential by DECLARATION and
/// returns `true` immediately — no heuristic gets a chance to disagree with the
/// table the resolver itself uses. Otherwise the key is upper-cased, rejected
/// for any [`REFERENCE_KEY_SUFFIXES`] ending (a path/id pointing AT a
/// credential), and accepted when it is, or ends in `_` plus, one of
/// [`CREDENTIAL_KEY_SUFFIXES`].
/// Test: `credential_keys_are_detected`, `tunable_keys_are_not_credentials`,
/// `credential_reference_keys_are_not_credentials`,
/// `every_registry_name_is_detected_as_a_credential`.
///
/// # Code Contract
/// Preconditions:
/// - None. Every `&str` is accepted, including the empty string.
///
/// Postconditions:
/// - Pure and total: no I/O, no panic, no allocation of the input's content
/// beyond one upper-cased copy.
/// - Every registered credential env var returns `true`.
/// - A key matching a reference suffix and absent from the registry is NEVER
/// reported as a credential, whatever else it ends in.
/// Is `upper` exactly `suffix`, or does it end in `_` + `suffix`?
///
/// Why: the `_` boundary is the whole point — see [`CREDENTIAL_KEY_SUFFIXES`].
/// Test: covered through [`is_credential_env_key`]'s tests.
/// Does `value` carry a known credential prefix?
///
/// Why: used where there is no key — a `ProgramArguments` string. See
/// [`CREDENTIAL_VALUE_PREFIXES`] for why this is a prefix table and not an
/// entropy heuristic.
/// What: `true` when the trimmed value is at least
/// [`MIN_CREDENTIAL_VALUE_LEN`] bytes AND starts with a known prefix, or has
/// the Telegram bot-token shape (`<digits>:<30+ token chars>`).
/// Test: `credential_values_are_detected_by_prefix`,
/// `credential_values_are_detected_for_telegram_shape`,
/// `ordinary_arguments_are_not_credential_values`.
/// The Telegram bot-token shape: `<6..=12 digits>:<30+ token characters>`.
///
/// Why: the second credential #8236 found had no vendor prefix at all, so the
/// prefix table alone would have missed it. Hand-rolled rather than pulled in
/// as a regex — this module is unconditional and owes its dependents no new
/// crate.
/// Test: `credential_values_are_detected_for_telegram_shape`.
/// Remove every credential-keyed pair from `pairs`, returning the keys removed.
///
/// Why: this is the acting half of the guard at the single choke point
/// `crate::launchd::LaunchdConfig::render_plist`. Dropping the pair rather
/// than failing the render is deliberate: a render that failed would abort
/// `service install` on exactly the hosts that most need the plist rewritten,
/// and the rewritten plist IS the remediation.
/// What: retains pairs whose key [`is_credential_env_key`] rejects, in order;
/// returns the removed KEYS, never their values.
/// Test: `strip_credential_env_removes_only_the_credential_pair`,
/// `strip_credential_env_is_a_noop_when_clean`.
///
/// # Code Contract
/// Postconditions:
/// - No element of the returned `Vec` is a credential value — only key names.
/// - After the call, `pairs.iter().all(|(k, _)| !is_credential_env_key(k))`.
/// - The relative order of the retained pairs is unchanged.
/// Names of the credential-bearing `EnvironmentVariables` keys in `xml`.
///
/// Why: the read-only half, for a diagnostic that reports without writing.
/// What: [`scrub_plist_credential_env`]'s `keys`, discarding the rewrite.
///
/// # Errors
///
/// As [`scrub_plist_credential_env`].
///
/// Test: `plist_credential_env_keys_names_the_key`.