syncable_cli/analyzer/hadolint/rules/
dl3013.rs

1//! DL3013: Pin versions in pip install
2//!
3//! Package versions should be pinned in pip install to ensure
4//! reproducible builds.
5
6use crate::analyzer::hadolint::parser::instruction::Instruction;
7use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
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                        // Get pip install packages
21                        let packages = pip_packages(shell);
22                        // Check if using requirements file
23                        let uses_requirements = uses_requirements_file(shell);
24                        // All packages should have versions pinned or use requirements
25                        uses_requirements || packages.iter().all(|pkg| is_pip_version_pinned(pkg))
26                    } else {
27                        true
28                    }
29                }
30                _ => true,
31            }
32        },
33    )
34}
35
36/// Extract packages from pip install commands.
37fn 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            // Get arguments that aren't flags and aren't pip-related commands
43            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
57/// Check if pip uses a requirements file.
58fn uses_requirements_file(shell: &ParsedShell) -> bool {
59    shell.any_command(|cmd| {
60        cmd.is_pip_install() && (cmd.has_any_flag(&["r", "requirement"]) || cmd.has_flag("constraint"))
61    })
62}
63
64/// Check if a pip package has a version pinned.
65fn is_pip_version_pinned(package: &str) -> bool {
66    // Skip if it starts with - (it's a flag)
67    if package.starts_with('-') {
68        return true;
69    }
70
71    // Skip if it looks like a URL or path
72    if package.contains("://") || package.starts_with('/') || package.starts_with('.') {
73        return true;
74    }
75
76    // Version pinned: package==version or package>=version, etc.
77    package.contains("==")
78        || package.contains(">=")
79        || package.contains("<=")
80        || package.contains("!=")
81        || package.contains("~=")
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::analyzer::hadolint::parser::instruction::RunArgs;
88    use crate::analyzer::hadolint::rules::{Rule, RuleState};
89
90    #[test]
91    fn test_pinned_version() {
92        let rule = rule();
93        let mut state = RuleState::new();
94
95        let instr = Instruction::Run(RunArgs::shell("pip install requests==2.28.0"));
96        let shell = ParsedShell::parse("pip install requests==2.28.0");
97        rule.check(&mut state, 1, &instr, Some(&shell));
98        assert!(state.failures.is_empty());
99    }
100
101    #[test]
102    fn test_unpinned_version() {
103        let rule = rule();
104        let mut state = RuleState::new();
105
106        let instr = Instruction::Run(RunArgs::shell("pip install requests"));
107        let shell = ParsedShell::parse("pip install requests");
108        rule.check(&mut state, 1, &instr, Some(&shell));
109        assert_eq!(state.failures.len(), 1);
110        assert_eq!(state.failures[0].code.as_str(), "DL3013");
111    }
112
113    #[test]
114    fn test_requirements_file() {
115        let rule = rule();
116        let mut state = RuleState::new();
117
118        let instr = Instruction::Run(RunArgs::shell("pip install -r requirements.txt"));
119        let shell = ParsedShell::parse("pip install -r requirements.txt");
120        rule.check(&mut state, 1, &instr, Some(&shell));
121        assert!(state.failures.is_empty());
122    }
123
124    #[test]
125    fn test_min_version() {
126        let rule = rule();
127        let mut state = RuleState::new();
128
129        let instr = Instruction::Run(RunArgs::shell("pip install requests>=2.28.0"));
130        let shell = ParsedShell::parse("pip install requests>=2.28.0");
131        rule.check(&mut state, 1, &instr, Some(&shell));
132        assert!(state.failures.is_empty());
133    }
134}