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
//! Refs #406 (CRUX audit E04): keep resolved secrets out of run transcripts.
//!
//! The executor resolves `{{secrets.*}}` INTO the resource before codegen, so
//! by the time `run_capture` writes `<res>.script`, `<res>.<action>.log` and
//! `<res>.<action>.json` the plaintext is already in the script. `--auto-commit`
//! then runs `git add state`. `redact_secrets` had shipped since FJ-2300 with no
//! production caller at all.
//!
//! # Why a literal `str::replace` is not enough
//!
//! Measured on unfixed `main`, a `type: file` resource whose `content:` holds a
//! secret produces this transcript:
//!
//! ```text
//! echo 'YXBpX3Rva2VuPWUwNC1QTEFJTlRFWFQtWnE3eDRLdjlMbTJSdzhUbi1E…' | base64 -d > '…'
//! ```
//!
//! The plaintext is not in that line, so #406's own success criterion ("grep the
//! state tree for the plaintext — zero matches") passes VACUOUSLY against the
//! bug. And `redact_secrets(script, [secret])` finds nothing either: the blob
//! encodes `api_token=` + secret + `\n`, and since `api_token=` is 10 bytes the
//! secret does not begin on a 3-byte boundary, so `base64(secret)` is not a
//! substring of `base64(content)`.
//!
//! So redaction here works on what a blob DECODES to, not on how it is spelled:
//! every base64 run in the transcript is decoded, and any run whose plaintext
//! contains a secret is replaced wholesale. `sensitive: true` remains the answer
//! for the general case — a value forjar cannot name (derived on the host,
//! compressed, re-encoded) can never be redacted by matching.
use crateB64;
use crate;
use Engine;
/// Shortest base64 run worth decoding. Four characters is one full base64
/// group; anything shorter cannot encode a byte.
const MIN_BLOB_LEN: usize = 4;
/// The plaintext values this resource's templates resolve to.
///
/// Takes the UNRESOLVED resource — the one still carrying `{{secrets.*}}` — and
/// re-resolves each referenced key through the same provider the executor used,
/// so the caller can strike those values out of the transcript.
///
/// A key that fails to resolve is skipped rather than fatal: this runs on the
/// reporting path, after the resource has already executed, and a redaction
/// pass must never be able to fail an apply that converged.
/// Refs #406: does this resource carry an `ENC[age,…]` ciphertext?
///
/// THE SHAPE #406's SUCCESS CRITERION NAMES, and the one redaction cannot see.
/// `resolve_template_with_secrets` decrypts `ENC[age,…]` markers AFTER template
/// substitution, so the plaintext never passes through a `{{secrets.*}}` span:
/// `collect_secret_values` returns an empty list, `redact_transcript` short
/// circuits on it, and the decrypted bytes are written to the transcript
/// verbatim. Naming those values would mean decrypting a second time on the
/// reporting path, with the identity file, after the resource has already
/// converged.
///
/// Until then the honest answer is the one `sensitive: true` gives: write no
/// transcript. Under default features this changes nothing observable —
/// `resolve_template_with_secrets` refuses an `ENC[` marker outright when the
/// `encryption` feature is off, so the resource never executes.
/// Every `secrets.*` key named by a `{{ … }}` span in `yaml`.
///
/// Scans the serialized resource rather than a hand-maintained field list:
/// `resolve_resource_templates_with_secrets` already needs such a list and has
/// its own completeness test guarding it, and a redactor that misses a field is
/// a leak rather than an unresolved template.
/// Strike every recoverable form of `secrets` out of one transcript stream.
///
/// Two passes, because a secret reaches a transcript two ways: spliced literally
/// into a `command:`, and base64-encoded inside a `file` resource's content.
/// Replace any base64 run whose DECODED bytes contain a secret.
/// Emit `run` — as `***` when it decodes to something holding a secret.
/// Does this base64 run decode to text containing one of `secrets`?