use super::is_redirect_shaped;
use crate::commands::pm_guard_bash::{split_shell_segments, tokenize};
use crate::commands::pm_guard_secret_read::command_basename;
const GH_API_SWITCHES: &[&str] = &[
"--include",
"--paginate",
"--silent",
"--slurp",
"--verbose",
"--help",
];
const GH_API_VALUED: &[&str] = &[
"--method",
"--header",
"--raw-field",
"--field",
"--input",
"--jq",
"--template",
"--cache",
"--preview",
"--hostname",
];
const CURL_VALUED_SHORT: &[char] = &[
'A', 'b', 'c', 'C', 'd', 'D', 'e', 'E', 'F', 'H', 'K', 'm', 'o', 'P', 'Q', 'r', 't', 'T', 'u',
'U', 'w', 'x', 'y', 'Y', 'z',
];
const CURL_VALUED_LONG: &[&str] = &[
"header",
"data",
"data-raw",
"data-binary",
"data-urlencode",
"data-ascii",
"json",
"user",
"output",
"user-agent",
"referer",
"cookie",
"cookie-jar",
"form",
"write-out",
"proxy",
"oauth2-bearer",
"max-time",
"connect-timeout",
"config",
"cert",
"key",
"cacert",
"retry",
"upload-file",
"dump-header",
];
const OPERAND_RUNNERS: &[&str] = &["ssh", "eval"];
const MAX_DEPTH: usize = 8;
pub(crate) fn evaluate_gh_api_secret_delete(command: &str) -> Option<String> {
judge(command, 0).then(deny_reason)
}
fn judge(command: &str, depth: usize) -> bool {
if depth > MAX_DEPTH {
return true;
}
let secrets_named = names_secrets(command);
split_shell_segments(command)
.iter()
.any(|segment| segment_deletes_a_secret(segment, secrets_named, depth))
}
fn segment_deletes_a_secret(segment: &str, secrets_named: bool, depth: usize) -> bool {
let Ok(argv) = tokenize(segment) else {
return starts_a_watched_call(segment);
};
reruns_a_refused_command(&argv, depth)
|| gh_calls(&argv, "api").any(|(_, tail)| gh_api_deletes(tail, secrets_named))
|| gh_calls(&argv, "secret").any(|(sub, tail)| secret_subcommand_deletes(sub, tail))
|| gh_calls(&argv, "alias").any(|(sub, tail)| alias_defines_a_delete(sub, tail, depth))
|| argv
.iter()
.enumerate()
.any(|(at, w)| possibly_named(w, "curl") && curl_deletes_a_secret(&argv[at + 1..]))
}
fn starts_a_watched_call(segment: &str) -> bool {
let rewritten_delete =
names_secrets(segment) && segment.to_ascii_lowercase().contains("delete");
[
segment.replace(['\'', '"', '\\'], ""),
segment.replace(['\'', '"', '\\'], " "),
]
.iter()
.any(|flat| {
let words: Vec<&str> = flat.split_whitespace().collect();
words
.iter()
.any(|w| command_basename(w) == "curl" || (rewritten_delete && is_rewritten(w)))
|| words.windows(2).any(|w| {
["api", "secret", "alias"]
.iter()
.any(|sub| possibly_named(w[0], "gh") && possibly_sub(w[1], sub))
})
})
}
fn reruns_a_refused_command(argv: &[String], depth: usize) -> bool {
let runner = argv
.iter()
.position(|w| OPERAND_RUNNERS.contains(&command_basename(w).as_str()));
if runner.is_some_and(|at| judge(&argv[at + 1..].join(" "), depth + 1)) {
return true;
}
argv.windows(3).any(|w| {
let c_flag = w[1].starts_with('-') && !w[1].starts_with("--") && w[1].contains('c');
is_rewritten(&w[0]) && c_flag && judge(&w[2], depth + 1)
})
}
fn gh_calls<'a>(argv: &'a [String], sub: &'a str) -> impl Iterator<Item = (&'a str, &'a [String])> {
argv.windows(2)
.enumerate()
.filter(move |(_, w)| possibly_named(&w[0], "gh") && possibly_sub(&w[1], sub))
.map(move |(at, w)| (w[1].as_str(), &argv[at + 2..]))
}
fn possibly_named(program: &str, name: &str) -> bool {
command_basename(program) == name || could_rewrite_to(program, name)
}
fn possibly_sub(word: &str, sub: &str) -> bool {
word == sub || could_rewrite_to(word, sub)
}
fn could_rewrite_to(word: &str, name: &str) -> bool {
if !is_rewritten(word) {
return false;
}
word.contains(['$', '`', '{', '}']) || glob_could_match(&command_basename(word), name)
}
fn glob_could_match(pattern: &str, name: &str) -> bool {
let mut tokens: Vec<Option<Option<u8>>> = Vec::new();
let bytes = pattern.as_bytes();
let mut i = 0;
while let Some(&b) = bytes.get(i) {
match b {
b'*' if tokens.last() == Some(&None) => {}
b'*' => tokens.push(None),
b'?' => tokens.push(Some(None)),
b'[' => match bytes[i + 1..].iter().position(|&c| c == b']') {
Some(close) if close > 0 => {
tokens.push(Some(None));
i += close + 1;
}
_ => return true,
},
c if c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-') => {
tokens.push(Some(Some(c.to_ascii_lowercase())));
}
_ => return true,
}
i += 1;
}
if tokens.iter().filter(|t| t.is_some()).count() > name.len() {
return false;
}
wildcard_match(&tokens, name.to_ascii_lowercase().as_bytes())
}
fn wildcard_match(tokens: &[Option<Option<u8>>], text: &[u8]) -> bool {
match tokens.split_first() {
None => text.is_empty(),
Some((None, rest)) => (0..=text.len()).any(|skip| wildcard_match(rest, &text[skip..])),
Some((Some(atom), rest)) => text.split_first().is_some_and(|(&b, tail)| {
atom.is_none_or(|want| want == b) && wildcard_match(rest, tail)
}),
}
}
fn is_rewritten(word: &str) -> bool {
word.contains(['$', '`', '*', '?', '[', ']', '{', '}'])
}
fn gh_api_deletes(tail: &[String], secrets_named: bool) -> bool {
let Some((methods, endpoints)) = parse_gh_api(tail) else {
let words: Vec<&str> = tail.iter().map(String::as_str).collect();
return secrets_named || suspicious(&words);
};
maybe_delete(&methods)
&& match endpoints.as_slice() {
[endpoint] if is_literal_endpoint(endpoint) => names_secrets(endpoint),
_ => true,
}
}
fn maybe_delete(methods: &[&str]) -> bool {
methods
.iter()
.any(|m| m.eq_ignore_ascii_case("DELETE") || !is_literal_method(m))
}
fn secret_subcommand_deletes(sub: &str, tail: &[String]) -> bool {
tail.iter()
.any(|w| matches!(w.as_str(), "delete" | "remove"))
|| (sub == "secret"
&& tail
.iter()
.find(|w| !w.starts_with('-'))
.is_some_and(|w| is_rewritten(w)))
}
fn alias_defines_a_delete(sub: &str, tail: &[String], depth: usize) -> bool {
let shell = tail
.iter()
.any(|w| w == "--shell" || (w.starts_with('-') && !w.starts_with("--") && w.contains('s')));
let words: Vec<&str> = tail
.iter()
.map(String::as_str)
.filter(|w| *w == "-" || !w.starts_with('-'))
.collect();
match words.as_slice() {
["import", ..] | ["set", _, "-", ..] => true,
[verb, ..] if sub == "alias" && is_rewritten(verb) => true,
["set", _, expansion, ..] => expansion_deletes(expansion, shell, depth),
_ => false,
}
}
fn expansion_deletes(expansion: &str, shell: bool, depth: usize) -> bool {
if let Some(script) = expansion.strip_prefix('!').or(shell.then_some(expansion)) {
return judge(script, depth + 1);
}
let Ok(words) = tokenize(expansion) else {
return true;
};
let secret_delete = words
.windows(2)
.any(|w| w[0] == "secret" && matches!(w[1].as_str(), "delete" | "remove"));
let api_delete = words.first().is_some_and(|w| w == "api")
&& parse_gh_api(&words[1..]).is_none_or(|(methods, _)| maybe_delete(&methods));
secret_delete || api_delete
}
fn curl_deletes_a_secret(tail: &[String]) -> bool {
let mut methods = Vec::new();
let mut urls = Vec::new();
let mut i = 0;
while let Some(tok) = tail.get(i) {
i += 1;
let mut take = |joined: Option<&'_ str>| match joined {
Some(v) => v.to_string(),
None => {
i += 1;
tail.get(i - 1).cloned().unwrap_or_default()
}
};
if let Some(long) = tok.strip_prefix("--") {
let (name, joined) = long
.split_once('=')
.map_or((long, None), |(n, v)| (n, Some(v)));
match name {
"request" => methods.push(take(joined)),
"url" => urls.push(take(joined)),
_ if CURL_VALUED_LONG.contains(&name) && joined.is_none() => i += 1,
_ => {}
}
} else if let Some(cluster) = tok.strip_prefix('-').filter(|c| !c.is_empty()) {
let valued = cluster
.char_indices()
.find(|(_, c)| *c == 'X' || CURL_VALUED_SHORT.contains(c));
if let Some((k, letter)) = valued {
let rest = &cluster[k + 1..];
let value = take((!rest.is_empty()).then_some(rest));
if letter == 'X' {
methods.push(value);
}
}
} else {
urls.push(tok.clone());
}
}
let methods: Vec<&str> = methods.iter().map(String::as_str).collect();
maybe_delete(&methods)
&& (urls.is_empty()
|| urls
.iter()
.any(|u| names_secrets(u) || is_rewritten(u) || u.starts_with('~')))
}
fn suspicious(words: &[&str]) -> bool {
words
.iter()
.any(|w| w.to_ascii_lowercase().contains("delete") || w.contains(['$', '`']))
}
fn parse_gh_api(args: &[String]) -> Option<(Vec<&str>, Vec<&str>)> {
let mut methods = Vec::new();
let mut endpoints = Vec::new();
let mut i = 0;
while i < args.len() {
let tok = args[i].as_str();
i += 1;
if tok == "--" {
endpoints.extend(args[i..].iter().map(String::as_str));
break;
}
if is_redirect_shaped(tok) {
i += usize::from(tok.ends_with(['<', '>']));
continue;
}
let (is_method, joined) = if let Some(long) = tok.strip_prefix("--") {
let (name, joined) = long
.split_once('=')
.map_or((long, None), |(n, v)| (n, Some(v)));
let name = &tok[..name.len() + 2];
if GH_API_SWITCHES.contains(&name) {
continue;
}
GH_API_VALUED.contains(&name).then_some(())?;
(name == "--method", joined)
} else if let Some(cluster) = tok.strip_prefix('-').filter(|c| !c.is_empty()) {
let mut chars = cluster.trim_start_matches(['i', 'h']).chars();
let Some(letter) = chars.next() else {
continue;
};
matches!(letter, 'X' | 'H' | 'f' | 'F' | 'q' | 't' | 'p').then_some(())?;
let rest = chars.as_str();
let rest = rest.strip_prefix('=').unwrap_or(rest);
(letter == 'X', (!rest.is_empty()).then_some(rest))
} else {
endpoints.push(tok);
continue;
};
let value = match joined {
Some(v) => v,
None => {
i += 1;
args.get(i - 1)?.as_str()
}
};
if is_method {
methods.push(value);
}
}
Some((methods, endpoints))
}
fn is_literal_method(method: &str) -> bool {
!method.is_empty() && method.bytes().all(|b| b.is_ascii_alphabetic())
}
fn names_secrets(text: &str) -> bool {
let decoded = percent_decode(&text.replace(['\'', '"', '\\'], "")).to_ascii_lowercase();
decoded
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.any(|word| word == "secrets")
}
fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = bytes
.get(i + 1..i + 3)
.and_then(|h| std::str::from_utf8(h).ok());
match hex
.filter(|_| bytes[i] == b'%')
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
Some(b) => {
out.push(b);
i += 3;
}
None => {
out.push(bytes[i]);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn is_literal_endpoint(endpoint: &str) -> bool {
let unplaced = ["{owner}", "{repo}", "{branch}"]
.iter()
.fold(endpoint.to_string(), |text, p| text.replace(p, ""));
let query_ok = endpoint
.split_once('?')
.is_none_or(|(_, query)| !query.contains(['/', '?']));
query_ok
&& !endpoint.starts_with('~')
&& !unplaced.contains(['$', '`', '*', '[', ',', '{', '}'])
}
fn deny_reason() -> String {
"a `gh api` DELETE of a GitHub secret (`…/secrets/NAME` of a repo, org, user or \
environment, Actions, Dependabot or Codespaces) is refused (issue #8875) — it destroys a \
secret other workflows depend on. A GET that lists secret names is still allowed (issue \
#8869). A `gh api` DELETE whose endpoint this guard cannot read literally (a variable, \
`$'…'`, a brace or glob, or one fed in by `xargs`), or whose method it cannot read, is \
refused too; write the endpoint out literally. So are `gh secret delete`, a `gh alias` \
that runs a DELETE, a `curl` DELETE of a secrets URL, and a `gh api`, `gh secret`, \
`gh alias` or `curl` call this guard cannot parse. Ask the operator to delete a secret."
.to_string()
}
#[cfg(test)]
#[path = "pm_guard_secret_gh_api_delete_tests.rs"]
mod tests;