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
//! The secret rules applied to every command a Bash command's substitutions
//! run (#8756 round 2).
//!
//! Why: `echo "$(pm2 jlist)"`, `` echo `pm2 jlist` `` and
//! `x=$(launchctl print gui/501/x); echo "$x"` ran a process dump that the
//! argv-reading rules never saw: the lexer hands them `"$(pm2 jlist)"` as one
//! word, or `$(pm2` and `jlist)` as two, and neither names `pm2`. The pod rule
//! had the same blind spot.
//! What: [`evaluate_nested_secret_rules`] runs the pod and process dump rules
//! over the command, then over the body of each substitution
//! `pm_guard_bash::command_substitutions` finds, recursively, with the
//! secret-file rule added at every nested level. The substitution scanner is
//! the one the forbidden-verb guard uses, so both decompose the same bodies to
//! the same depth. An unclosed substitution is read from its opener to the end
//! of the text, so one naming a dump fails closed through the rule's own
//! unlexable arm. Past the depth bound the remaining text is flattened and
//! read once, which denies only when it names a dump. Here-document bodies no
//! program runs as code are blanked before the dump rules read argv, so a
//! commit message or PR body naming `pm2 jlist` is text, and a grouping paren
//! becomes a space, so a subshell group `(pm2 jlist)` reads as `pm2 jlist`.
//!
//! #8875: the `gh api` secret-DELETE rule rides the same walk, so a DELETE in
//! a substitution, a subshell group or a here-document run as code is read too.
//!
//! The credential rule (#8596) is NOT re-run on bodies: it follows
//! substitutions itself, and it allows a value captured into a variable or a
//! header (`TOKEN=$(gcloud auth print-access-token)`), which a body read alone
//! would refuse.
//! Test: `refuses_a_process_dump_inside_a_command_substitution`,
//! `refuses_an_unclosed_substitution_that_names_a_dump`,
//! `bounds_nesting_and_still_refuses_a_dump`,
//! `keeps_the_credential_capture_forms_allowed`.
use crate::commands::pm_guard_bash::{
MAX_SUBSTITUTION_DEPTH, command_substitutions, evaluate_pod_env_dump_command,
evaluate_process_env_dump_command, without_inert_heredoc_bodies,
};
use crate::commands::pm_guard_secret_consumers::evaluate_gh_api_secret_delete;
use crate::commands::pm_guard_secret_read::evaluate_secret_file_read_command;
/// Refuse a pod or process dump anywhere in `command`, or a secret-file read
/// inside one of its substitutions: `Some(reason)` denies.
///
/// Test: `refuses_a_process_dump_inside_a_command_substitution`,
/// `allows_routine_commands_that_carry_a_substitution`.
pub(crate) fn evaluate_nested_secret_rules(command: &str) -> Option<String> {
evaluate_at(command, 0, true)
}
/// [`evaluate_nested_secret_rules`] without the #8875 secret-DELETE rule: the
/// credential-READ subset a script body is judged by (#8879, ruling 268).
/// That rule reads every pair of rewritten words as a possible `gh api` call,
/// so over a whole shell program (`[ "$A" -eq 0 ]`) it refuses ordinary code.
/// Test: `pm_guard_secret_script::tests::the_repo_gate_scripts_allow_8879`.
pub(crate) fn evaluate_nested_secret_read_rules(command: &str) -> Option<String> {
evaluate_at(command, 0, false)
}
/// [`evaluate_nested_secret_rules`] at substitution depth `depth`.
fn evaluate_at(command: &str, depth: usize, with_delete: bool) -> Option<String> {
if let Some(reason) = argv_rules(command, depth > 0, with_delete) {
return Some(reason);
}
command_substitutions(command).iter().find_map(|body| {
if depth + 1 >= MAX_SUBSTITUTION_DEPTH {
// #8756: too deep to decompose; read what is left once, flattened.
argv_rules(&flatten(body.text()), true, with_delete)
} else {
evaluate_at(body.text(), depth + 1, with_delete)
}
})
}
/// The argv-reading rules over one command text; the entry point already ran
/// the secret-file rule on the top-level text, so `with_file_rule` adds it for
/// a body only.
fn argv_rules(command: &str, with_file_rule: bool, with_delete: bool) -> Option<String> {
let file = with_file_rule
.then(|| evaluate_secret_file_read_command(command))
.flatten();
let text = ungroup(&without_inert_heredoc_bodies(command));
file.or_else(|| evaluate_pod_env_dump_command(&text))
.or_else(|| evaluate_process_env_dump_command(&text))
// #8875: a `gh api` DELETE of a secret names no secret-shaped word.
.or_else(|| {
with_delete
.then(|| evaluate_gh_api_secret_delete(&text))
.flatten()
})
}
/// `text` with each grouping paren turned into a space (#8756).
///
/// Why: a subshell `(pm2 jlist)` lexes as `(pm2` and `jlist)`, which name no
/// program. A `(` right after `$`, `<` or `>` opens a substitution, which
/// [`command_substitutions`] reads instead, so it stays; an escaped `\$(x)`
/// therefore stays the literal word `$(x`.
fn ungroup(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut prev = ' ';
for c in text.chars() {
let opens_substitution = c == '(' && matches!(prev, '$' | '<' | '>');
out.push(if matches!(c, '(' | ')') && !opens_substitution {
' '
} else {
c
});
prev = c;
}
out
}
/// `text` with its substitution syntax turned into spaces, so every command
/// word inside it is an ordinary word.
fn flatten(text: &str) -> String {
text.replace(['$', '(', ')', '`'], " ")
}
#[cfg(test)]
#[path = "pm_guard_secret_nested_tests.rs"]
mod tests;