Skip to main content

forensicnomicon/heuristics/
paths.rs

1//! File path and name anomaly heuristics + Zone.Identifier constants.
2
3// ── Zone.Identifier (Mark-of-the-Web) ─────────────────────────────────────
4
5pub const ZONE_LOCAL: u32 = 0;
6pub const ZONE_INTRANET: u32 = 1;
7pub const ZONE_TRUSTED: u32 = 2;
8pub const ZONE_INTERNET: u32 = 3;
9pub const ZONE_RESTRICTED: u32 = 4;
10
11/// Returns `true` if the ZoneId indicates the file was downloaded from the internet
12/// or a restricted zone (ZoneId >= 3). Executables with this mark running without
13/// warning indicate MOTW bypass (T1553.005).
14#[must_use]
15pub fn is_internet_download(zone_id: u32) -> bool {
16    zone_id >= ZONE_INTERNET
17}
18
19// ── File name anomalies ────────────────────────────────────────────────────
20
21/// Returns `true` if the filename has a double extension (e.g. `invoice.pdf.exe`).
22/// Social engineering technique to disguise executable as document.
23///
24/// Logic: the filename (without directory) contains at least two `.` characters
25/// and neither the first nor second extension from the right is empty.
26#[must_use]
27pub fn is_double_extension(filename: &str) -> bool {
28    // Work on the base name only (after last path separator)
29    let name = filename.rsplit(['/', '\\']).next().unwrap_or(filename);
30    let parts: Vec<&str> = name.splitn(3, '.').collect();
31    // Need at least: stem, first-ext, second-ext — all non-empty
32    parts.len() == 3 && parts.iter().all(|p| !p.is_empty())
33}
34
35/// Returns `true` if the path contains an Alternate Data Stream separator.
36/// ADS paths look like `C:\file.txt:hidden_stream`.
37/// Skips the drive-letter colon (first two characters).
38#[must_use]
39pub fn is_alternate_data_stream(path: &str) -> bool {
40    path.chars().skip(2).any(|c| c == ':')
41}
42
43/// Returns `true` if the filename begins with a dot (Linux/macOS hidden file convention).
44#[must_use]
45pub fn is_linux_hidden_name(name: &str) -> bool {
46    name.starts_with('.') && name.len() > 1
47}
48
49/// Returns `true` if the path begins with a UNC prefix (`\\` or `//`).
50/// UNC paths in LNK files or prefetch indicate network execution (T1021).
51#[must_use]
52pub fn is_unc_path(path: &str) -> bool {
53    path.starts_with("\\\\") || path.starts_with("//")
54}
55
56/// Path prefixes associated with suspicious execution locations — the DFIR
57/// "execution from an unusual location" triage baseline (SANS FOR508 / 13Cubed).
58pub const SUSPICIOUS_EXEC_PREFIXES: &[&str] = &[
59    "\\Temp\\",
60    "\\tmp\\",
61    "\\AppData\\Local\\Temp\\",
62    "\\Users\\Public\\",
63    "\\ProgramData\\",
64    "\\Downloads\\",
65    "\\$Recycle.Bin\\",
66    "\\PerfLogs\\",
67    "/tmp/",
68    "/dev/shm/",
69    "/run/shm/",
70    "/var/tmp/",
71];
72
73/// Returns `true` if the path contains a suspicious execution prefix.
74///
75/// Matching is case-insensitive: Windows paths are case-insensitive, and
76/// artifacts such as Prefetch record paths upper-cased, so a case-sensitive
77/// `contains` would miss them.
78#[must_use]
79pub fn is_suspicious_exec_path(path: &str) -> bool {
80    let lower = path.to_ascii_lowercase();
81    SUSPICIOUS_EXEC_PREFIXES
82        .iter()
83        .any(|p| lower.contains(&p.to_ascii_lowercase()))
84}
85
86// ── HKCU\Console value-name allowlist (Valley RAT) ─────────────────────────
87//
88// The legitimate `HKCU\Console` key normally contains only a small,
89// well-documented set of Console subsystem display values (FaceName,
90// FontSize, ColorTable*, CursorSize, WindowSize, ScreenColors, etc.). Per
91// Carvey's commentary on Valley RAT (Silver Fox campaign), the malware
92// abuses this exact key to store its configuration as binary blobs under
93// non-standard value names, and stores downloaded plugins under
94// `HKCU\Console\0\<md5_hash>` — a numeric subkey path that does not match
95// any documented Windows Console behavior.
96//
97// Source: https://windowsir.blogspot.com/2026/01/grab-bag.html
98// Source: https://www.cloudsek.com/blog/silver-fox-targeting-india-using-tax-themed-phishing-lures
99
100/// Documented value names that legitimately appear under `HKCU\Console`
101/// (and per-application Console subkeys reuse the same set).
102pub const CONSOLE_KNOWN_VALUE_NAMES: &[&str] = &[
103    "ColorTable00",
104    "ColorTable01",
105    "ColorTable02",
106    "ColorTable03",
107    "ColorTable04",
108    "ColorTable05",
109    "ColorTable06",
110    "ColorTable07",
111    "ColorTable08",
112    "ColorTable09",
113    "ColorTable10",
114    "ColorTable11",
115    "ColorTable12",
116    "ColorTable13",
117    "ColorTable14",
118    "ColorTable15",
119    "CtrlKeyShortcutsDisabled",
120    "CursorColor",
121    "CursorSize",
122    "CursorType",
123    "DefaultBackground",
124    "DefaultForeground",
125    "EnableColorSelection",
126    "ExtendedEditKey",
127    "ExtendedEditKeyCustom",
128    "FaceName",
129    "FilterOnPaste",
130    "FontFamily",
131    "FontSize",
132    "FontWeight",
133    "ForceV2",
134    "HistoryBufferSize",
135    "HistoryNoDup",
136    "InsertMode",
137    "LineSelection",
138    "LineWrap",
139    "LoadConIme",
140    "NumberOfHistoryBuffers",
141    "PopupColors",
142    "QuickEdit",
143    "ScreenBufferSize",
144    "ScreenColors",
145    "TerminalScrolling",
146    "TrimLeadingZeros",
147    "WindowAlpha",
148    "WindowPosition",
149    "WindowSize",
150    "WordDelimiters",
151];
152
153/// Returns `true` if the value name is NOT in the documented `HKCU\Console`
154/// allowlist — i.e. an unexpected value name that warrants investigation.
155///
156/// Comparison is case-insensitive (registry value names are case-insensitive
157/// on Windows). An empty value name (the default unnamed value) is also
158/// flagged: the legitimate Console key does not use it.
159#[must_use]
160pub fn is_suspicious_console_value_name(name: &str) -> bool {
161    if name.is_empty() {
162        return true;
163    }
164    !CONSOLE_KNOWN_VALUE_NAMES
165        .iter()
166        .any(|known| known.eq_ignore_ascii_case(name))
167}
168
169/// Returns `true` if the registry path is a non-standard subkey directly
170/// under `HKCU\Console` whose first segment is purely numeric (e.g.
171/// `HKCU\Console\0\<md5_hash>` — the Valley RAT plugin store).
172///
173/// The legitimate Console key holds per-application subkeys whose names
174/// are derived from the executable name (e.g. `cmd.exe` or
175/// `%SystemRoot%_System32_cmd.exe`); a bare integer subkey is unique to
176/// the Valley RAT layout. Comparison is case-insensitive (registry key
177/// paths are case-insensitive on Windows).
178#[must_use]
179pub fn is_suspicious_console_subkey(key_path: &str) -> bool {
180    const PREFIX: &str = "HKCU\\Console\\";
181    if key_path.len() <= PREFIX.len() {
182        return false;
183    }
184    if !key_path
185        .get(..PREFIX.len())
186        .is_some_and(|p| p.eq_ignore_ascii_case(PREFIX))
187    {
188        return false;
189    }
190    let tail = &key_path[PREFIX.len()..];
191    let first_segment = tail.split('\\').next().unwrap_or(tail);
192    !first_segment.is_empty() && first_segment.chars().all(|c| c.is_ascii_digit())
193}
194
195// ── NTUSER.MAN mandatory-profile persistence ───────────────────────────────
196//
197// Per DeceptIQ (27 Dec 2025) and Carvey's grab-bag commentary, the mere
198// existence of an `NTUSER.MAN` mandatory-profile hive is a high-confidence
199// indicator of compromise outside kiosk/shared-workstation deployments.
200// Windows loads `NTUSER.MAN` *instead of* `NTUSER.DAT`, so a planted
201// `.MAN` bypasses EDR registry callbacks entirely.
202//
203// Source: https://deceptiq.com/blog/ntuser-man-registry-persistence
204// Source: https://windowsir.blogspot.com/2026/01/grab-bag.html
205
206/// Returns `true` if the file path's basename is exactly `NTUSER.MAN`
207/// (case-insensitive — Windows file names are case-insensitive).
208///
209/// Caller is responsible for the kiosk/shared-workstation context check;
210/// in environments not using mandatory profiles, any hit warrants
211/// investigation.
212#[must_use]
213pub fn is_ntuser_man_path(path: &str) -> bool {
214    let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
215    base.eq_ignore_ascii_case("NTUSER.MAN")
216}
217
218/// Returns `true` if the COM handler DLL path for a Scheduled Task action is
219/// outside the expected Windows system directories, indicating potential abuse.
220///
221/// # Detection
222/// Legitimate built-in task COM handlers (e.g. RegIdleBackup → regidle.dll)
223/// reside in `%SystemRoot%\System32`. An attacker abusing Scheduled Task COM
224/// handler hijacking (T1053.005 + T1218) places a malicious DLL in a
225/// user-writable path. The RegIdleBackup technique was observed in TA505/
226/// GraceWire campaigns (Fox-IT/NCC Group report, 2021).
227///
228/// Match is case-insensitive; backslash and forward-slash normalized.
229///
230/// Source: <https://windowsir.blogspot.com/2022/12/why-i-love-regripper.html>
231#[must_use]
232pub fn is_task_com_handler_dll_suspicious(dll_path: &str) -> bool {
233    if dll_path.is_empty() {
234        return false;
235    }
236    let lower = dll_path.to_ascii_lowercase();
237    let safe_prefixes = [
238        r"%systemroot%\system32",
239        r"%windir%\system32",
240        r"c:\windows\system32",
241        r"%systemroot%\syswow64",
242        r"%windir%\syswow64",
243        r"c:\windows\syswow64",
244    ];
245    !safe_prefixes.iter().any(|p| lower.starts_with(p))
246}
247
248// ── Tests ─────────────────────────────────────────────────────────────────────
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn zone_internet_is_internet_download() {
255        assert!(is_internet_download(ZONE_INTERNET));
256    }
257
258    #[test]
259    fn zone_restricted_is_internet_download() {
260        assert!(is_internet_download(ZONE_RESTRICTED));
261    }
262
263    #[test]
264    fn zone_local_is_not_internet_download() {
265        assert!(!is_internet_download(ZONE_LOCAL));
266    }
267
268    #[test]
269    fn zone_trusted_is_not_internet_download() {
270        assert!(!is_internet_download(ZONE_TRUSTED));
271    }
272
273    #[test]
274    fn double_extension_pdf_exe() {
275        assert!(is_double_extension("invoice.pdf.exe"));
276    }
277
278    #[test]
279    fn double_extension_doc_exe() {
280        assert!(is_double_extension("report.doc.exe"));
281    }
282
283    #[test]
284    fn single_extension_not_double() {
285        assert!(!is_double_extension("program.exe"));
286    }
287
288    #[test]
289    fn no_extension_not_double() {
290        assert!(!is_double_extension("makefile"));
291    }
292
293    #[test]
294    fn double_extension_empty_part_not_flagged() {
295        // ".hidden.exe" — the stem before the first dot is empty
296        assert!(!is_double_extension(".hidden.exe"));
297    }
298
299    #[test]
300    fn ads_path_detected() {
301        assert!(is_alternate_data_stream(r"C:\file.txt:stream"));
302    }
303
304    #[test]
305    fn normal_path_not_ads() {
306        assert!(!is_alternate_data_stream(r"C:\file.txt"));
307    }
308
309    #[test]
310    fn drive_colon_not_ads() {
311        // Only the drive-letter colon at position 1 — no ADS colon after skip(2)
312        assert!(!is_alternate_data_stream(r"C:\dir\file.txt"));
313    }
314
315    #[test]
316    fn linux_hidden_dot_file() {
317        assert!(is_linux_hidden_name(".bashrc"));
318    }
319
320    #[test]
321    fn linux_hidden_double_dot() {
322        assert!(is_linux_hidden_name("..file"));
323    }
324
325    #[test]
326    fn linux_non_hidden() {
327        assert!(!is_linux_hidden_name("bashrc"));
328    }
329
330    #[test]
331    fn linux_single_dot_not_hidden() {
332        // len must be > 1
333        assert!(!is_linux_hidden_name("."));
334    }
335
336    #[test]
337    fn unc_path_backslash() {
338        assert!(is_unc_path(r"\\server\share"));
339    }
340
341    #[test]
342    fn unc_path_forward_slash() {
343        assert!(is_unc_path("//server/share"));
344    }
345
346    #[test]
347    fn normal_path_not_unc() {
348        assert!(!is_unc_path(r"C:\Windows"));
349    }
350
351    #[test]
352    fn suspicious_exec_tmp_path() {
353        assert!(is_suspicious_exec_path(
354            r"C:\Users\bob\AppData\Local\Temp\evil.exe"
355        ));
356    }
357
358    #[test]
359    fn suspicious_exec_dev_shm() {
360        assert!(is_suspicious_exec_path("/dev/shm/payload"));
361    }
362
363    #[test]
364    fn normal_exec_path_not_suspicious() {
365        assert!(!is_suspicious_exec_path(r"C:\Windows\System32\calc.exe"));
366    }
367
368    #[test]
369    fn suspicious_exec_path_is_case_insensitive() {
370        // Prefetch records upper-cased volume-relative paths.
371        assert!(is_suspicious_exec_path(
372            r"\VOLUME{X}\USERS\BOB\DOWNLOADS\INVOICE.EXE"
373        ));
374        assert!(is_suspicious_exec_path(r"\VOLUME{X}\WINDOWS\TEMP\X.EXE"));
375        assert!(is_suspicious_exec_path(r"\VOLUME{X}\$RECYCLE.BIN\A.EXE"));
376        assert!(is_suspicious_exec_path(r"\VOLUME{X}\PERFLOGS\B.EXE"));
377    }
378
379    // ── HKCU\Console allowlist (Valley RAT detection) ──────────────────────
380    // Source: https://windowsir.blogspot.com/2026/01/grab-bag.html
381    // Source: https://www.cloudsek.com/blog/silver-fox-targeting-india-using-tax-themed-phishing-lures
382
383    #[test]
384    fn console_facename_is_known() {
385        assert!(!is_suspicious_console_value_name("FaceName"));
386    }
387
388    #[test]
389    fn console_fontsize_is_known() {
390        assert!(!is_suspicious_console_value_name("FontSize"));
391    }
392
393    #[test]
394    fn console_colortable00_is_known() {
395        assert!(!is_suspicious_console_value_name("ColorTable00"));
396    }
397
398    #[test]
399    fn console_known_value_case_insensitive() {
400        // Windows registry value names are case-insensitive
401        assert!(!is_suspicious_console_value_name("facename"));
402        assert!(!is_suspicious_console_value_name("FACENAME"));
403    }
404
405    #[test]
406    fn console_arbitrary_blob_name_is_suspicious() {
407        // Valley RAT writes config under non-standard value names
408        assert!(is_suspicious_console_value_name("config"));
409        assert!(is_suspicious_console_value_name(
410            "d33f351a4aeea5e608853d1a56661059"
411        ));
412    }
413
414    #[test]
415    fn console_empty_value_name_is_suspicious() {
416        // The default unnamed value is not used by the legitimate Console key
417        assert!(is_suspicious_console_value_name(""));
418    }
419
420    #[test]
421    fn console_numeric_subkey_is_suspicious() {
422        // HKCU\Console\0\<md5> is the Valley RAT plugin store path
423        assert!(is_suspicious_console_subkey(r"HKCU\Console\0"));
424        assert!(is_suspicious_console_subkey(
425            r"HKCU\Console\0\d33f351a4aeea5e608853d1a56661059"
426        ));
427    }
428
429    #[test]
430    fn console_app_subkey_not_suspicious() {
431        // Per-app Console subkeys (cmd.exe, etc.) are legitimate
432        assert!(!is_suspicious_console_subkey(r"HKCU\Console\cmd.exe"));
433        assert!(!is_suspicious_console_subkey(
434            r"HKCU\Console\%SystemRoot%_System32_cmd.exe"
435        ));
436    }
437
438    #[test]
439    fn console_root_key_not_flagged_as_subkey() {
440        // The root HKCU\Console key itself is not a subkey
441        assert!(!is_suspicious_console_subkey(r"HKCU\Console"));
442        assert!(!is_suspicious_console_subkey(r"HKCU\Console\"));
443    }
444
445    #[test]
446    fn console_subkey_check_case_insensitive() {
447        // Registry key paths are case-insensitive on Windows
448        assert!(is_suspicious_console_subkey(r"hkcu\console\0"));
449    }
450
451    #[test]
452    fn non_console_key_not_flagged() {
453        assert!(!is_suspicious_console_subkey(
454            r"HKCU\Software\Microsoft\Windows"
455        ));
456    }
457
458    // ── NTUSER.MAN mandatory-profile persistence ──────────────────────────
459    // Source: https://deceptiq.com/blog/ntuser-man-registry-persistence
460    // Source: https://windowsir.blogspot.com/2026/01/grab-bag.html
461
462    #[test]
463    fn ntuser_man_in_userprofile_detected() {
464        assert!(is_ntuser_man_path(r"C:\Users\bob\NTUSER.MAN"));
465    }
466
467    #[test]
468    fn ntuser_man_case_insensitive() {
469        // Windows file names are case-insensitive
470        assert!(is_ntuser_man_path(r"C:\Users\bob\ntuser.man"));
471        assert!(is_ntuser_man_path(r"C:\Users\bob\NtUser.Man"));
472    }
473
474    #[test]
475    fn ntuser_dat_not_flagged() {
476        // The legitimate per-user hive must not match
477        assert!(!is_ntuser_man_path(r"C:\Users\bob\NTUSER.DAT"));
478    }
479
480    #[test]
481    fn ntuser_man_on_unc_share_detected() {
482        // Roaming-profile-share placement is also a vector
483        assert!(is_ntuser_man_path(r"\\server\share\profile.v6\NTUSER.MAN"));
484    }
485
486    #[test]
487    fn ntuser_man_substring_in_other_filename_not_flagged() {
488        // Only the basename is matched — substrings elsewhere are not
489        assert!(!is_ntuser_man_path(
490            r"C:\Users\bob\notes\ntuser.man.backup.txt"
491        ));
492    }
493
494    // ── is_task_com_handler_dll_suspicious ────────────────────────────────────
495
496    #[test]
497    fn system32_dll_is_not_suspicious() {
498        assert!(!is_task_com_handler_dll_suspicious(
499            r"%SystemRoot%\System32\regidle.dll"
500        ));
501    }
502
503    #[test]
504    fn system32_dll_case_insensitive() {
505        assert!(!is_task_com_handler_dll_suspicious(
506            r"C:\WINDOWS\system32\DeviceDirectoryClient.dll"
507        ));
508    }
509
510    #[test]
511    fn syswow64_dll_is_not_suspicious() {
512        assert!(!is_task_com_handler_dll_suspicious(
513            r"%SystemRoot%\SysWOW64\example.dll"
514        ));
515    }
516
517    #[test]
518    fn temp_dir_dll_is_suspicious() {
519        assert!(is_task_com_handler_dll_suspicious(
520            r"C:\Users\bob\AppData\Local\Temp\evil.dll"
521        ));
522    }
523
524    #[test]
525    fn programdata_dll_is_suspicious() {
526        assert!(is_task_com_handler_dll_suspicious(
527            r"C:\ProgramData\payload.dll"
528        ));
529    }
530
531    #[test]
532    fn empty_dll_path_is_not_flagged() {
533        assert!(!is_task_com_handler_dll_suspicious(""));
534    }
535}
536
537// ── PAM module / exec heuristics (stub — RED phase) ──────────────────────────
538
539/// Returns `true` if a PAM shared-object module path is outside the standard
540/// system PAM module directories.
541///
542/// # Detection
543/// PamDOORa (T1556.003) drops `pam_linux.so` as an additional auth module
544/// loaded via `/etc/pam.d/sshd` instead of replacing `pam_unix.so`. Any `.so`
545/// referenced in PAM config that does not live under a known system path is a
546/// backdoor candidate and warrants immediate hash comparison and analysis.
547#[must_use]
548pub fn is_pam_module_path_suspicious(module_path: &str) -> bool {
549    if module_path.is_empty() {
550        return false;
551    }
552    let lower = module_path.to_ascii_lowercase();
553    let safe_prefixes = [
554        "/lib/security/",
555        "/lib/x86_64-linux-gnu/security/",
556        "/lib/aarch64-linux-gnu/security/",
557        "/usr/lib/security/",
558        "/usr/lib/x86_64-linux-gnu/security/",
559        "/usr/lib64/security/",
560    ];
561    !safe_prefixes.iter().any(|p| lower.starts_with(p))
562}
563
564/// Returns `true` if a `pam_exec` script path is in a world-writable or
565/// temporary directory.
566///
567/// # Detection
568/// PamDOORa abuses `pam_exec optional /tmp/capture.sh` style lines in
569/// `/etc/pam.d/sshd` to execute attacker scripts during authentication
570/// (T1556.003). Legitimate `pam_exec` scripts live under `/etc/` or
571/// `/usr/local/`. A path under `/tmp/`, `/var/tmp/`, `/dev/shm/`, or a
572/// user home directory is a high-confidence IOC.
573#[must_use]
574pub fn is_pam_exec_script_suspicious(script_path: &str) -> bool {
575    if script_path.is_empty() {
576        return false;
577    }
578    let lower = script_path.to_ascii_lowercase();
579    let suspicious_prefixes = [
580        "/tmp/",
581        "/var/tmp/",
582        "/dev/shm/",
583        "/home/",
584        "/root/",
585        "/run/user/",
586    ];
587    suspicious_prefixes.iter().any(|p| lower.starts_with(p))
588}
589
590#[cfg(test)]
591mod tests_pam_heuristics {
592    use super::*;
593
594    // is_pam_module_path_suspicious
595
596    #[test]
597    fn standard_lib_security_so_is_safe() {
598        assert!(!is_pam_module_path_suspicious("/lib/security/pam_unix.so"));
599    }
600
601    #[test]
602    fn debian_security_path_is_safe() {
603        assert!(!is_pam_module_path_suspicious(
604            "/lib/x86_64-linux-gnu/security/pam_sss.so"
605        ));
606    }
607
608    #[test]
609    fn usr_lib_security_is_safe() {
610        assert!(!is_pam_module_path_suspicious(
611            "/usr/lib/x86_64-linux-gnu/security/pam_unix.so"
612        ));
613    }
614
615    #[test]
616    fn tmp_pam_so_is_suspicious() {
617        assert!(is_pam_module_path_suspicious("/tmp/pam_linux.so"));
618    }
619
620    #[test]
621    fn home_dir_pam_so_is_suspicious() {
622        assert!(is_pam_module_path_suspicious("/home/attacker/pam_linux.so"));
623    }
624
625    #[test]
626    fn var_tmp_pam_so_is_suspicious() {
627        assert!(is_pam_module_path_suspicious("/var/tmp/pam_evil.so"));
628    }
629
630    #[test]
631    fn empty_pam_module_path_is_not_suspicious() {
632        assert!(!is_pam_module_path_suspicious(""));
633    }
634
635    // is_pam_exec_script_suspicious
636
637    #[test]
638    fn etc_script_is_not_suspicious() {
639        assert!(!is_pam_exec_script_suspicious("/etc/pam_scripts/notify.sh"));
640    }
641
642    #[test]
643    fn usr_local_script_is_not_suspicious() {
644        assert!(!is_pam_exec_script_suspicious(
645            "/usr/local/bin/pam_check.sh"
646        ));
647    }
648
649    #[test]
650    fn tmp_capture_script_is_suspicious() {
651        assert!(is_pam_exec_script_suspicious("/tmp/capture.sh"));
652    }
653
654    #[test]
655    fn dev_shm_script_is_suspicious() {
656        assert!(is_pam_exec_script_suspicious("/dev/shm/exfil.sh"));
657    }
658
659    #[test]
660    fn home_dir_script_is_suspicious() {
661        assert!(is_pam_exec_script_suspicious("/home/user/.config/run.sh"));
662    }
663
664    #[test]
665    fn var_tmp_script_is_suspicious() {
666        assert!(is_pam_exec_script_suspicious("/var/tmp/backdoor.sh"));
667    }
668
669    #[test]
670    fn root_home_script_is_suspicious() {
671        assert!(is_pam_exec_script_suspicious("/root/.bashrc_extra.sh"));
672    }
673
674    #[test]
675    fn empty_script_path_is_not_suspicious() {
676        assert!(!is_pam_exec_script_suspicious(""));
677    }
678}