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