syncable_cli/analyzer/hadolint/rules/
dl3013.rs1use crate::analyzer::hadolint::parser::instruction::Instruction;
7use crate::analyzer::hadolint::rules::{SimpleRule, simple_rule};
8use crate::analyzer::hadolint::shell::ParsedShell;
9use crate::analyzer::hadolint::types::Severity;
10
11pub fn rule() -> SimpleRule<impl Fn(&Instruction, Option<&ParsedShell>) -> bool + Send + Sync> {
12 simple_rule(
13 "DL3013",
14 Severity::Warning,
15 "Pin versions in pip. Instead of `pip install <package>` use `pip install <package>==<version>` or `pip install --requirement <requirements file>`",
16 |instr, shell| {
17 match instr {
18 Instruction::Run(_) => {
19 if let Some(shell) = shell {
20 let packages = pip_packages(shell);
22 let uses_requirements = uses_requirements_file(shell);
24 uses_requirements || packages.iter().all(|pkg| is_pip_version_pinned(pkg))
26 } else {
27 true
28 }
29 }
30 _ => true,
31 }
32 },
33 )
34}
35
36fn pip_packages(shell: &ParsedShell) -> Vec<String> {
38 let mut packages = Vec::new();
39
40 for cmd in &shell.commands {
41 if cmd.is_pip_install() {
42 let skip_args = ["install", "pip", "-m"];
44 let args: Vec<&str> = cmd
45 .args_no_flags()
46 .into_iter()
47 .filter(|a| !skip_args.contains(a))
48 .collect();
49
50 packages.extend(args.into_iter().map(|s| s.to_string()));
51 }
52 }
53
54 packages
55}
56
57fn uses_requirements_file(shell: &ParsedShell) -> bool {
59 shell.any_command(|cmd| {
60 cmd.is_pip_install()
61 && (cmd.has_any_flag(&["r", "requirement"]) || cmd.has_flag("constraint"))
62 })
63}
64
65fn is_pip_version_pinned(package: &str) -> bool {
67 if package.starts_with('-') {
69 return true;
70 }
71
72 if package.contains("://") || package.starts_with('/') || package.starts_with('.') {
74 return true;
75 }
76
77 package.contains("==")
79 || package.contains(">=")
80 || package.contains("<=")
81 || package.contains("!=")
82 || package.contains("~=")
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use crate::analyzer::hadolint::parser::instruction::RunArgs;
89 use crate::analyzer::hadolint::rules::{Rule, RuleState};
90
91 #[test]
92 fn test_pinned_version() {
93 let rule = rule();
94 let mut state = RuleState::new();
95
96 let instr = Instruction::Run(RunArgs::shell("pip install requests==2.28.0"));
97 let shell = ParsedShell::parse("pip install requests==2.28.0");
98 rule.check(&mut state, 1, &instr, Some(&shell));
99 assert!(state.failures.is_empty());
100 }
101
102 #[test]
103 fn test_unpinned_version() {
104 let rule = rule();
105 let mut state = RuleState::new();
106
107 let instr = Instruction::Run(RunArgs::shell("pip install requests"));
108 let shell = ParsedShell::parse("pip install requests");
109 rule.check(&mut state, 1, &instr, Some(&shell));
110 assert_eq!(state.failures.len(), 1);
111 assert_eq!(state.failures[0].code.as_str(), "DL3013");
112 }
113
114 #[test]
115 fn test_requirements_file() {
116 let rule = rule();
117 let mut state = RuleState::new();
118
119 let instr = Instruction::Run(RunArgs::shell("pip install -r requirements.txt"));
120 let shell = ParsedShell::parse("pip install -r requirements.txt");
121 rule.check(&mut state, 1, &instr, Some(&shell));
122 assert!(state.failures.is_empty());
123 }
124
125 #[test]
126 fn test_min_version() {
127 let rule = rule();
128 let mut state = RuleState::new();
129
130 let instr = Instruction::Run(RunArgs::shell("pip install requests>=2.28.0"));
131 let shell = ParsedShell::parse("pip install requests>=2.28.0");
132 rule.check(&mut state, 1, &instr, Some(&shell));
133 assert!(state.failures.is_empty());
134 }
135}