Skip to main content

alint_rules/
executable_has_shebang.rs

1//! `executable_has_shebang` — every `+x` file in scope must
2//! begin with a shebang line (`#!`).
3//!
4//! Catches the common bug where a script has been marked
5//! executable but its content is something else (a text file,
6//! a binary missing the shebang). Running such a file silently
7//! invokes the user's login shell — surprising at best, exploit
8//! vector at worst.
9//!
10//! Non-Unix platforms: rule is a no-op (no real +x semantics).
11//! No fix op — the correct resolution (add shebang vs. remove +x)
12//! is a human judgment call.
13
14use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
15
16#[cfg(unix)]
17use crate::io::read_prefix_n;
18
19#[derive(Debug)]
20// Fields are read only by the `#[cfg(unix)]` evaluate path; on
21// Windows the struct is constructed but never inspected.
22#[cfg_attr(not(unix), allow(dead_code))]
23pub struct ExecutableHasShebangRule {
24    id: String,
25    level: Level,
26    policy_url: Option<String>,
27    message: Option<String>,
28    scope: Scope,
29}
30
31impl Rule for ExecutableHasShebangRule {
32    alint_core::rule_common_impl!();
33
34    #[cfg(unix)]
35    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
36        use std::os::unix::fs::PermissionsExt;
37
38        let mut violations = Vec::new();
39        for entry in ctx.index.files() {
40            if !self.scope.matches(&entry.path, ctx.index) {
41                continue;
42            }
43            let full = ctx.root.join(&entry.path);
44            let Ok(meta) = std::fs::metadata(&full) else {
45                continue;
46            };
47            if meta.permissions().mode() & 0o111 == 0 {
48                continue;
49            }
50            // Only the first 2 bytes (`#!`) matter — bounded
51            // read instead of the whole file. Non-`+x` files
52            // already short-circuited above; this read only
53            // happens on actual executables.
54            let Ok(bytes) = read_prefix_n(&full, 2) else {
55                continue;
56            };
57            if !bytes.starts_with(b"#!") {
58                let msg = self
59                    .message
60                    .clone()
61                    .unwrap_or_else(|| "executable file has no shebang (#!)".to_string());
62                violations.push(Violation::new(msg).with_path(entry.path.clone()));
63            }
64        }
65        Ok(violations)
66    }
67
68    #[cfg(not(unix))]
69    fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
70        Ok(Vec::new())
71    }
72}
73
74pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
75    let _paths = spec.paths.as_ref().ok_or_else(|| {
76        Error::rule_config(&spec.id, "executable_has_shebang requires a `paths` field")
77    })?;
78    if spec.fix.is_some() {
79        return Err(Error::rule_config(
80            &spec.id,
81            "executable_has_shebang has no fix op — add a shebang or clear +x is a human call",
82        ));
83    }
84    Ok(Box::new(ExecutableHasShebangRule {
85        id: spec.id.clone(),
86        level: spec.level,
87        policy_url: spec.policy_url.clone(),
88        message: spec.message.clone(),
89        scope: Scope::from_spec(spec)?,
90    }))
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::test_support::spec_yaml;
97    #[cfg(unix)]
98    use crate::test_support::{ctx, tempdir_with_files};
99
100    #[test]
101    fn build_rejects_missing_paths_field() {
102        let spec = spec_yaml(
103            "id: t\n\
104             kind: executable_has_shebang\n\
105             level: warning\n",
106        );
107        assert!(build(&spec).is_err());
108    }
109
110    #[test]
111    fn build_rejects_fix_block() {
112        let spec = spec_yaml(
113            "id: t\n\
114             kind: executable_has_shebang\n\
115             paths: \"scripts/**\"\n\
116             level: warning\n\
117             fix:\n  \
118               file_remove: {}\n",
119        );
120        assert!(build(&spec).is_err());
121    }
122
123    #[cfg(unix)]
124    #[test]
125    fn evaluate_fires_when_exec_lacks_shebang() {
126        use std::os::unix::fs::PermissionsExt;
127        let spec = spec_yaml(
128            "id: t\n\
129             kind: executable_has_shebang\n\
130             paths: \"scripts/**\"\n\
131             level: warning\n",
132        );
133        let rule = build(&spec).unwrap();
134        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"echo hi\n")]);
135        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
136            .unwrap()
137            .permissions();
138        perms.set_mode(0o755);
139        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
140        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
141        assert_eq!(v.len(), 1, "exec without shebang must fire");
142    }
143
144    #[cfg(unix)]
145    #[test]
146    fn evaluate_passes_when_exec_has_shebang() {
147        use std::os::unix::fs::PermissionsExt;
148        let spec = spec_yaml(
149            "id: t\n\
150             kind: executable_has_shebang\n\
151             paths: \"scripts/**\"\n\
152             level: warning\n",
153        );
154        let rule = build(&spec).unwrap();
155        let (tmp, idx) = tempdir_with_files(&[("scripts/a.sh", b"#!/bin/sh\necho hi\n")]);
156        let mut perms = std::fs::metadata(tmp.path().join("scripts/a.sh"))
157            .unwrap()
158            .permissions();
159        perms.set_mode(0o755);
160        std::fs::set_permissions(tmp.path().join("scripts/a.sh"), perms).unwrap();
161        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
162        assert!(v.is_empty(), "exec with shebang should pass: {v:?}");
163    }
164
165    #[cfg(unix)]
166    #[test]
167    fn evaluate_silent_on_non_exec_files() {
168        use std::os::unix::fs::PermissionsExt;
169        let spec = spec_yaml(
170            "id: t\n\
171             kind: executable_has_shebang\n\
172             paths: \"**/*\"\n\
173             level: warning\n",
174        );
175        let rule = build(&spec).unwrap();
176        let (tmp, idx) = tempdir_with_files(&[("README.md", b"# title\n")]);
177        let mut perms = std::fs::metadata(tmp.path().join("README.md"))
178            .unwrap()
179            .permissions();
180        perms.set_mode(0o644);
181        std::fs::set_permissions(tmp.path().join("README.md"), perms).unwrap();
182        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
183        assert!(v.is_empty(), "non-exec doesn't need shebang: {v:?}");
184    }
185
186    #[cfg(unix)]
187    #[test]
188    fn scope_filter_narrows() {
189        use std::os::unix::fs::PermissionsExt;
190        // Two +x scripts without shebang; only the one inside a
191        // dir with `marker.lock` as ancestor should fire.
192        let spec = spec_yaml(
193            "id: t\n\
194             kind: executable_has_shebang\n\
195             paths: \"**/*.sh\"\n\
196             scope_filter:\n  \
197               has_ancestor: marker.lock\n\
198             level: warning\n",
199        );
200        let rule = build(&spec).unwrap();
201        let (tmp, idx) = tempdir_with_files(&[
202            ("pkg/marker.lock", b""),
203            ("pkg/a.sh", b"echo hi\n"),
204            ("other/a.sh", b"echo hi\n"),
205        ]);
206        for rel in ["pkg/a.sh", "other/a.sh"] {
207            let mut perms = std::fs::metadata(tmp.path().join(rel))
208                .unwrap()
209                .permissions();
210            perms.set_mode(0o755);
211            std::fs::set_permissions(tmp.path().join(rel), perms).unwrap();
212        }
213        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
214        assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
215        assert_eq!(v[0].path.as_deref(), Some(std::path::Path::new("pkg/a.sh")));
216    }
217}