-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlinbean.sh
More file actions
1846 lines (1723 loc) · 76.3 KB
/
Copy pathlinbean.sh
File metadata and controls
1846 lines (1723 loc) · 76.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
set -u
set -o pipefail 2>/dev/null || true
IFS='
'
umask 077
VERSION="1.0.0"
COLOR=1
QUIET=0
JSON=0
MARKDOWN=0
ACTIVE=0
FULL=1
FAST=0
OUTFILE=""
TMPDIR="${TMPDIR:-/tmp}"
FINDING_COUNT=0
COUNTS_CRITICAL=0
COUNTS_HIGH=0
COUNTS_MEDIUM=0
COUNTS_LOW=0
COUNTS_INFO=0
FINDINGS_TEXT=""
FINDINGS_JSON=""
FINDINGS_MD=""
TOP_FINDINGS_TEXT=""
TOP_FINDINGS_MD=""
SUMMARY_TEXT=""
START_TS="$(date '+%Y-%m-%d %H:%M:%S %z' 2>/dev/null || date)"
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
COLOR=1
else
COLOR=0
fi
usage() {
cat <<'EOF'
linbean.sh - read-only Linux privilege-escalation risk auditor
Usage:
./linbean.sh [options]
Options:
--help Show this help text
--no-color Disable ANSI colors
--quiet Suppress informational findings in text output
--json Emit valid JSON instead of text
--markdown Emit Markdown instead of text
--active Run controlled write/execute validation probes
--output <file> Write the report to a file; path must be under /tmp
--full Run broader filesystem checks (default)
--fast Skip slower filesystem checks
Safety:
Default mode is read-only except for optional report output under /tmp.
--active creates and removes harmless marker files to validate exposure.
It does not exploit vulnerabilities, dump secrets, brute force, persist, or
transmit data.
EOF
}
die() {
printf '%s\n' "Error: $*" >&2
exit 1
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--help) usage; exit 0 ;;
--no-color) COLOR=0 ;;
--quiet) QUIET=1 ;;
--json) JSON=1; MARKDOWN=0; COLOR=0 ;;
--markdown) MARKDOWN=1; JSON=0; COLOR=0 ;;
--active) ACTIVE=1 ;;
--full) FULL=1; FAST=0 ;;
--fast) FAST=1; FULL=0 ;;
--output)
shift
[ "$#" -gt 0 ] || die "--output requires a file path"
OUTFILE="$1"
;;
*) die "unknown option: $1" ;;
esac
shift
done
if [ -n "$OUTFILE" ]; then
case "$OUTFILE" in
/tmp/*) : ;;
*) die "--output must be under /tmp to preserve read-only behavior elsewhere" ;;
esac
fi
}
color() {
[ "$COLOR" -eq 1 ] || return 0
case "$1" in
red) printf '\033[31m' ;;
green) printf '\033[32m' ;;
yellow) printf '\033[33m' ;;
blue) printf '\033[34m' ;;
magenta) printf '\033[35m' ;;
cyan) printf '\033[36m' ;;
bold) printf '\033[1m' ;;
reset) printf '\033[0m' ;;
esac
}
has_cmd() {
command -v "$1" >/dev/null 2>&1
}
safe_read() {
[ -r "$1" ] || return 1
sed -n '1,80p' "$1" 2>/dev/null
}
one_line() {
printf '%s' "$*" | tr '\n\r\t' ' ' | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
}
json_escape() {
printf '%s' "$*" | awk '
BEGIN { ORS="" }
{
gsub(/\\/,"\\\\")
gsub(/"/,"\\\"")
gsub(/\t/,"\\t")
gsub(/\r/,"\\r")
gsub(/\n/,"\\n")
print
if (NR != 0) print "\\n"
}' | sed 's/\\n$//'
}
md_escape_table() {
printf '%s' "$*" | tr '\n\r\t' ' ' | sed 's/\\/\\\\/g; s/|/\\|/g; s/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//'
}
md_bullets() {
text="$1"
printf '%s\n' "$text" | awk '
BEGIN { RS = ";"; ORS = "" }
{
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0)
if ($0 != "") print $0 "\n"
}' | while IFS= read -r item; do
[ -n "$item" ] || continue
printf -- '- %s\n' "$item"
done
}
severity_rank() {
case "$1" in
Critical) printf 5 ;;
High) printf 4 ;;
Medium) printf 3 ;;
Low) printf 2 ;;
Info) printf 1 ;;
*) printf 0 ;;
esac
}
severity_color() {
case "$1" in
Critical|High) color red ;;
Medium) color yellow ;;
Low) color cyan ;;
Info) color green ;;
esac
}
term_width() {
width=100
if has_cmd tput; then
width="$(tput cols 2>/dev/null || printf 100)"
fi
case "$width" in
''|*[!0-9]*) width=100 ;;
esac
[ "$width" -lt 78 ] && width=78
[ "$width" -gt 120 ] && width=120
printf '%s' "$width"
}
rule() {
char="${1:--}"
width="$(term_width)"
awk -v c="$char" -v w="$width" 'BEGIN { for (i = 0; i < w; i++) printf "%s", c; printf "\n" }'
}
wrap_text() {
indent="$1"
text="$2"
width="$(term_width)"
body_width=$((width - indent))
[ "$body_width" -lt 40 ] && body_width=40
pad="$(printf '%*s' "$indent" '')"
printf '%s\n' "$text" | fold -s -w "$body_width" 2>/dev/null | sed "s/^/$pad/"
}
print_preserved() {
indent="$1"
text="$2"
pad="$(printf '%*s' "$indent" '')"
printf '%s\n' "$text" | sed "s/^/$pad/"
}
format_label() {
label="$1"
value="$2"
printf ' %s%-13s%s ' "$(color bold)" "$label" "$(color reset)"
wrap_text 17 "$value" | sed '1s/^ //'
}
format_bullets() {
text="$1"
printf '%s\n' "$text" | awk '
BEGIN { RS = ";"; ORS = "" }
{
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0)
if ($0 != "") print $0 "\n"
}' | while IFS= read -r item; do
[ -n "$item" ] || continue
printf ' - '
print_preserved 6 "$item" | sed '1s/^ //'
done
}
severity_badge() {
sev="$1"
printf '%s[%s]%s' "$(severity_color "$sev")" "$sev" "$(color reset)"
}
format_finding_text() {
id="$1"
title="$2"
severity="$3"
confidence="$4"
evidence="$5"
why="$6"
remediation="$7"
commands="$8"
printf '%s\n' "$(rule '-')"
printf '%sFinding %03d%s %s\n' "$(color bold)" "$id" "$(color reset)" "$title"
printf ' Severity %s\n' "$(severity_badge "$severity")"
printf ' Confidence %s\n\n' "$confidence"
printf ' %sEvidence%s\n' "$(color bold)" "$(color reset)"
format_bullets "$evidence"
printf '\n'
format_label "Impact" "$why"
format_label "Fix" "$remediation"
if [ -n "$commands" ]; then
format_label "Commands" "$commands"
fi
printf '\n'
}
format_top_finding() {
id="$1"
title="$2"
severity="$3"
confidence="$4"
evidence="$5"
printf ' %s #%03d %s\n' "$(severity_badge "$severity")" "$id" "$title"
printf ' Confidence: %s\n' "$confidence"
printf ' Evidence: '
print_preserved 18 "$(one_line "$evidence")" | sed '1s/^ //'
printf '\n'
}
format_finding_md() {
id="$1"
title="$2"
severity="$3"
confidence="$4"
evidence="$5"
why="$6"
remediation="$7"
commands="$8"
printf '## Finding %03d: %s\n\n' "$id" "$title"
printf '| Field | Value |\n'
printf '|---|---|\n'
printf '| Severity | %s |\n' "$(md_escape_table "$severity")"
printf '| Confidence | %s |\n\n' "$(md_escape_table "$confidence")"
printf '### Evidence\n\n'
md_bullets "$evidence"
printf '\n### Impact\n\n%s\n\n' "$why"
printf '### Recommended Remediation\n\n%s\n\n' "$remediation"
if [ -n "$commands" ]; then
printf '### Commands Used\n\n`%s`\n\n' "$(printf '%s' "$commands" | sed 's/`/'\''/g')"
fi
}
format_top_finding_md() {
id="$1"
title="$2"
severity="$3"
confidence="$4"
evidence="$5"
printf '| %03d | %s | %s | %s | %s |\n' \
"$id" \
"$(md_escape_table "$severity")" \
"$(md_escape_table "$confidence")" \
"$(md_escape_table "$title")" \
"$(md_escape_table "$(one_line "$evidence")")"
}
increment_count() {
case "$1" in
Critical) COUNTS_CRITICAL=$((COUNTS_CRITICAL + 1)) ;;
High) COUNTS_HIGH=$((COUNTS_HIGH + 1)) ;;
Medium) COUNTS_MEDIUM=$((COUNTS_MEDIUM + 1)) ;;
Low) COUNTS_LOW=$((COUNTS_LOW + 1)) ;;
Info) COUNTS_INFO=$((COUNTS_INFO + 1)) ;;
esac
}
add_finding() {
title="$1"
severity="$2"
confidence="$3"
evidence="$4"
why="$5"
remediation="$6"
commands="${7:-}"
[ "$QUIET" -eq 1 ] && [ "$severity" = "Info" ] && return 0
FINDING_COUNT=$((FINDING_COUNT + 1))
increment_count "$severity"
text_block="$(format_finding_text "$FINDING_COUNT" "$title" "$severity" "$confidence" "$evidence" "$why" "$remediation" "$commands")"
FINDINGS_TEXT="${FINDINGS_TEXT}${text_block}"$'\n'
FINDINGS_TEXT="${FINDINGS_TEXT}"$'\n'
if [ "$(severity_rank "$severity")" -ge 3 ]; then
top_block="$(format_top_finding "$FINDING_COUNT" "$title" "$severity" "$confidence" "$evidence")"
TOP_FINDINGS_TEXT="${TOP_FINDINGS_TEXT}${top_block}"$'\n\n'
top_md_block="$(format_top_finding_md "$FINDING_COUNT" "$title" "$severity" "$confidence" "$evidence")"
TOP_FINDINGS_MD="${TOP_FINDINGS_MD}${top_md_block}"$'\n'
fi
md_block="$(format_finding_md "$FINDING_COUNT" "$title" "$severity" "$confidence" "$evidence" "$why" "$remediation" "$commands")"
FINDINGS_MD="${FINDINGS_MD}${md_block}"$'\n\n'
json_obj="$(
printf '{"id":%s,' "$FINDING_COUNT"
printf '"title":"%s",' "$(json_escape "$title")"
printf '"severity":"%s",' "$(json_escape "$severity")"
printf '"severity_rank":%s,' "$(severity_rank "$severity")"
printf '"confidence":"%s",' "$(json_escape "$confidence")"
printf '"evidence":"%s",' "$(json_escape "$evidence")"
printf '"why_it_matters":"%s",' "$(json_escape "$why")"
printf '"recommended_remediation":"%s",' "$(json_escape "$remediation")"
printf '"commands_used":"%s"}' "$(json_escape "$commands")"
)"
if [ -n "$FINDINGS_JSON" ]; then
FINDINGS_JSON="${FINDINGS_JSON},${json_obj}"
else
FINDINGS_JSON="$json_obj"
fi
}
is_writable_path() {
path="$1"
[ -e "$path" ] || return 1
[ -w "$path" ] && return 0
return 1
}
is_world_writable() {
path="$1"
[ -e "$path" ] || return 1
[ -L "$path" ] && return 1
if has_cmd stat; then
mode="$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path" 2>/dev/null || true)"
case "$mode" in
???|????)
last="${mode#${mode%?}}"
[ $((last & 2)) -ne 0 ] && return 0
;;
esac
fi
[ -w "$path" ] && [ ! -O "$path" ] && return 0
return 1
}
owner_of() {
if has_cmd stat; then
stat -c '%U:%G' "$1" 2>/dev/null || stat -f '%Su:%Sg' "$1" 2>/dev/null || printf 'unknown'
else
ls -ld "$1" 2>/dev/null | awk '{print $3 ":" $4}'
fi
}
mode_of() {
if has_cmd stat; then
stat -c '%A %a' "$1" 2>/dev/null || stat -f '%Sp %Lp' "$1" 2>/dev/null || printf 'unknown'
else
ls -ld "$1" 2>/dev/null | awk '{print $1}'
fi
}
active_marker_name() {
printf '.linbean_probe_%s_%s' "$$" "$(date +%s 2>/dev/null || printf 0)"
}
active_write_probe_dir() {
[ "$ACTIVE" -eq 1 ] || return 1
dir="$1"
[ -d "$dir" ] || return 1
marker="$dir/$(active_marker_name)"
if (umask 077; : > "$marker") 2>/dev/null; then
rm -f "$marker" 2>/dev/null || true
return 0
fi
rm -f "$marker" 2>/dev/null || true
return 1
}
active_exec_probe_dir() {
[ "$ACTIVE" -eq 1 ] || return 1
dir="$1"
[ -d "$dir" ] || return 1
marker="$dir/$(active_marker_name).sh"
if ({ printf '%s\n' '#!/bin/sh' 'exit 23' > "$marker"; } 2>/dev/null) && chmod 700 "$marker" 2>/dev/null; then
"$marker" >/dev/null 2>&1
rc="$?"
rm -f "$marker" 2>/dev/null || true
[ "$rc" -eq 23 ] && return 0
fi
rm -f "$marker" 2>/dev/null || true
return 1
}
limit_lines() {
cat
}
section() {
:
}
collect_system_info() {
os="unknown"
[ -r /etc/os-release ] && os="$(awk -F= '/^PRETTY_NAME=/{gsub(/^"|"$/,"",$2); print $2}' /etc/os-release 2>/dev/null)"
kernel="$(uname -r 2>/dev/null || printf unknown)"
arch="$(uname -m 2>/dev/null || printf unknown)"
host="$(hostname 2>/dev/null || uname -n 2>/dev/null || printf unknown)"
up="$(uptime -p 2>/dev/null || uptime 2>/dev/null || printf unknown)"
user="$(id -un 2>/dev/null || printf unknown)"
uid="$(id -u 2>/dev/null || printf unknown)"
groups="$(id -nG 2>/dev/null || id 2>/dev/null || printf unknown)"
root_note="Non-root run: some root-only checks may be unavailable."
[ "$uid" = "0" ] && root_note="Running as root: permission checks may differ from unprivileged reality."
add_finding "Audit execution context" "Info" "High" \
"OS=$os; kernel=$kernel; arch=$arch; host=$host; uptime=$up; user=$user uid=$uid groups=$groups; $root_note" \
"Privilege-escalation triage depends on distribution, kernel, identity, group memberships, and whether protected files were readable." \
"Re-run from the target user account that needs assessment; compare with a root-run inventory only when authorized." \
"uname, hostname, uptime, id, /etc/os-release"
}
check_sudo() {
if ! has_cmd sudo; then
add_finding "sudo command not installed or not in PATH" "Info" "High" \
"sudo was not found." \
"sudo-based privilege escalation checks are unavailable on this host or from this PATH." \
"Confirm whether privilege delegation is handled by sudo, doas, polkit, or another control." \
"command -v sudo"
return
fi
out="$(sudo -n -l 2>&1 || true)"
case "$out" in
*"may run the following commands"*|*"NOPASSWD"*|*"SETENV"*|*"!authenticate"*)
severity="Medium"
conf="Medium"
why="sudo rules can permit direct administrative actions or controlled command execution. Rules with NOPASSWD, SETENV, wildcard arguments, or shell-capable programs require careful review."
case "$out" in
*"NOPASSWD"*|*"SETENV"*) severity="High"; conf="High" ;;
esac
add_finding "sudo privileges available without prompting" "$severity" "$conf" \
"sudo -n -l returned rules: $(printf '%s' "$out" | limit_lines 12 | tr '\n' '; ')" \
"$why" \
"Apply least privilege, require authentication where practical, avoid SETENV unless necessary, pin exact command paths and arguments, and review shell-capable binaries against GTFOBins or vendor guidance." \
"sudo -n -l"
;;
*"a password is required"*|*"Password is required"*)
add_finding "sudo privileges may require authentication" "Low" "Medium" \
"sudo -n -l reported that a password is required." \
"The user may still have sudo rights, but this non-invasive audit did not prompt for credentials." \
"Manually run sudo -l during an authorized interactive review and inspect allowed commands." \
"sudo -n -l"
;;
*"not allowed"*|*"not in the sudoers"*)
add_finding "No non-interactive sudo privileges detected" "Info" "Medium" \
"sudo -n -l did not return allowed commands." \
"This lowers sudo-specific risk but does not rule out other privilege delegation paths." \
"Keep sudoers minimal and audited." \
"sudo -n -l"
;;
*)
add_finding "sudo privileges inconclusive" "Info" "Low" \
"sudo -n -l output: $(printf '%s' "$out" | limit_lines 6 | tr '\n' '; ')" \
"sudo output varies by policy and configuration; inconclusive results need manual review." \
"Run sudo -l interactively if authorized and inspect /etc/sudoers plus /etc/sudoers.d with appropriate privileges." \
"sudo -n -l"
;;
esac
}
check_groups() {
groups="$(id -nG 2>/dev/null || true)"
risky=""
for g in docker podman lxd lxc libvirt kvm disk shadow adm sudo wheel root systemd-journal; do
case " $groups " in
*" $g "*) risky="${risky}${g} " ;;
esac
done
[ -n "$risky" ] || return 0
add_finding "Membership in high-impact local groups" "Medium" "High" \
"Current groups include: $(one_line "$risky")" \
"Some local groups grant broad host access, container control, raw disk access, log access, or administrative delegation. Container-control groups are often equivalent to root on the host if daemon access is unrestricted." \
"Remove unnecessary memberships, require audited break-glass workflows for admin groups, and restrict container daemon socket access." \
"id -nG"
}
check_sensitive_permissions() {
for p in /etc/passwd /etc/shadow /etc/sudoers /etc/group; do
[ -e "$p" ] || continue
mode="$(mode_of "$p")"
owner="$(owner_of "$p")"
if is_writable_path "$p"; then
add_finding "Sensitive file is writable by current user: $p" "Critical" "High" \
"$p mode=$mode owner=$owner current_user=$(id -un 2>/dev/null || printf unknown)" \
"Write access to identity, password, group, or sudo policy files can directly enable privilege escalation or persistent administrative access." \
"Restore vendor-default ownership and permissions immediately, investigate why the permission changed, and review recent modifications." \
"test -w, stat"
elif is_world_writable "$p"; then
add_finding "Sensitive file appears world-writable: $p" "Critical" "High" \
"$p mode=$mode owner=$owner" \
"World-writable sensitive policy files can allow unprivileged users to alter authentication or authorization." \
"Restore strict permissions such as root ownership and distro-appropriate modes; validate package integrity." \
"stat"
fi
done
for d in /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly /etc/sudoers.d; do
[ -e "$d" ] || continue
if is_writable_path "$d" || is_world_writable "$d"; then
add_finding "Sensitive configuration directory is writable: $d" "High" "High" \
"$d mode=$(mode_of "$d") owner=$(owner_of "$d")" \
"Writable privileged configuration directories can allow users to add or alter jobs or policy snippets executed or trusted by root." \
"Restrict ownership to root and remove group/world write permissions unless a documented control requires them." \
"test -w, stat"
fi
done
}
check_path_risks() {
path_value="${PATH:-}"
[ -n "$path_value" ] || return 0
empty_seen=0
writable=""
relative=""
oldifs="$IFS"
IFS=':'
for d in $path_value; do
IFS="$oldifs"
[ -n "$d" ] || { empty_seen=1; IFS=':'; continue; }
case "$d" in
/*) : ;;
*) relative="${relative}${d} " ;;
esac
if [ -d "$d" ] && is_writable_path "$d"; then
writable="${writable}${d}($(mode_of "$d")) "
fi
IFS=':'
done
IFS="$oldifs"
if [ "$empty_seen" -eq 1 ] || [ -n "$relative" ]; then
add_finding "PATH contains relative or empty entries" "Medium" "High" \
"PATH=$path_value; relative_entries=$(one_line "$relative"); empty_entry=$empty_seen" \
"Relative or empty PATH entries can cause commands to resolve from the current directory, which is risky when privileged scripts or sudo rules preserve PATH." \
"Use absolute PATH entries only and set secure_path for privileged execution contexts." \
"PATH inspection"
fi
if [ -n "$writable" ]; then
add_finding "Writable directory present in PATH" "High" "High" \
"Writable PATH directories: $(one_line "$writable")" \
"If privileged scripts or misconfigured sudo rules execute commands without absolute paths, writable PATH directories can enable command hijacking." \
"Remove writable directories from PATH, make shared binary directories root-owned and non-writable, and use absolute command paths in privileged scripts." \
"test -w, stat, PATH inspection"
fi
}
check_suid_sgid() {
[ "$FAST" -eq 1 ] && return 0
paths="/bin /sbin /usr/bin /usr/sbin /usr/local/bin /usr/local/sbin /opt"
[ "$FULL" -eq 1 ] && paths="/"
if ! has_cmd find; then
return 0
fi
list="$(find $paths -xdev \( -perm -4000 -o -perm -2000 \) -type f -print 2>/dev/null | sort 2>/dev/null | limit_lines 80)"
[ -n "$list" ] || return 0
interesting="$(printf '%s\n' "$list" | awk '
/(bash|dash|sh|zsh|ksh|python|perl|ruby|php|node|vim|vi|nvim|find|cp|tar|rsync|nmap|env|awk|sed|less|more|nano|openssl|socat|nc|netcat)$/ { print }
')"
if [ -n "$interesting" ]; then
add_finding "SUID/SGID shell-capable or file-manipulation binaries found" "High" "Medium" \
"Interesting SUID/SGID paths: $(printf '%s' "$interesting" | tr '\n' '; ')" \
"Unexpected SUID/SGID on interpreters, editors, shells, or file tools can be directly dangerous. Some may be legitimate, so ownership, package provenance, and intended mode must be verified." \
"Remove unnecessary SUID/SGID bits, reinstall affected packages if tampering is suspected, and maintain an approved baseline for privileged binaries." \
"find -perm -4000/-2000"
else
add_finding "SUID/SGID binaries inventory" "Info" "High" \
"Found SUID/SGID files: $(printf '%s' "$list" | tr '\n' '; ')" \
"SUID/SGID binaries are normal on Linux, but unexpected additions are a common escalation path." \
"Compare this list to a known-good baseline for the distribution and installed packages." \
"find -perm -4000/-2000"
fi
}
check_capabilities() {
if ! has_cmd getcap; then
add_finding "Linux capabilities check unavailable" "Info" "High" \
"getcap was not found." \
"File capabilities can grant powerful privileges without SUID bits, so missing tooling leaves a visibility gap." \
"Install libcap tools in a controlled admin workflow or inspect package baselines." \
"command -v getcap"
return 0
fi
[ "$FAST" -eq 1 ] && roots="/usr/bin /usr/sbin /bin /sbin" || roots="/usr/bin /usr/sbin /bin /sbin /usr/local/bin /usr/local/sbin /opt"
caps="$(getcap -r $roots 2>/dev/null | sort 2>/dev/null | limit_lines 80)"
[ -n "$caps" ] || return 0
danger="$(printf '%s\n' "$caps" | awk '/cap_setuid|cap_setgid|cap_dac_read_search|cap_dac_override|cap_sys_admin|cap_sys_ptrace|cap_sys_module|cap_net_admin/ {print}')"
if [ -n "$danger" ]; then
add_finding "Powerful Linux file capabilities present" "High" "Medium" \
"Capabilities: $(printf '%s' "$danger" | tr '\n' '; ')" \
"Capabilities such as cap_setuid, cap_dac_override, cap_sys_admin, and cap_sys_ptrace can bypass important privilege boundaries when assigned to abusable binaries." \
"Verify each capability is required, remove unnecessary capabilities with setcap -r during an approved change, and compare against package defaults." \
"getcap -r"
else
add_finding "Linux capabilities inventory" "Info" "High" \
"Capabilities: $(printf '%s' "$caps" | tr '\n' '; ')" \
"File capabilities are legitimate in many packages but should be baselined." \
"Review against vendor defaults and monitor for drift." \
"getcap -r"
fi
}
check_world_writable() {
[ "$FAST" -eq 1 ] && return 0
has_cmd find || return 0
roots="/tmp /var/tmp /dev/shm /var/www /opt /usr/local /home"
[ "$FULL" -eq 1 ] && roots="/"
ww_dirs="$(find $roots -xdev -type d -perm -0002 ! -perm -1000 -print 2>/dev/null | sort 2>/dev/null | limit_lines 60)"
if [ -n "$ww_dirs" ]; then
add_finding "World-writable directories without sticky bit" "Medium" "High" \
"Directories: $(printf '%s' "$ww_dirs" | tr '\n' '; ')" \
"World-writable directories without the sticky bit allow users to delete or replace files owned by others, which can affect privileged workflows." \
"Add the sticky bit where shared write is required, or remove world write permissions." \
"find -perm -0002 ! -perm -1000"
fi
ww_files="$(find $roots -xdev -type f -perm -0002 -print 2>/dev/null | sort 2>/dev/null | limit_lines 60)"
if [ -n "$ww_files" ]; then
add_finding "World-writable files found" "Medium" "High" \
"Files: $(printf '%s' "$ww_files" | tr '\n' '; ')" \
"World-writable files can be altered by unprivileged users and become dangerous when consumed by privileged services, cron jobs, or administrators." \
"Remove world write permissions and ensure ownership matches the responsible service or package." \
"find -perm -0002"
fi
}
check_cron() {
has_cmd find || return 0
cron_paths="/etc/crontab /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly /var/spool/cron /var/spool/cron/crontabs"
writable=""
scripts=""
for p in $cron_paths; do
[ -e "$p" ] || continue
if is_writable_path "$p" || is_world_writable "$p"; then
writable="${writable}${p} mode=$(mode_of "$p") owner=$(owner_of "$p"); "
fi
done
for d in /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/cron.weekly /etc/cron.monthly; do
[ -d "$d" ] || continue
found="$(find "$d" -maxdepth 1 -type f \( -writable -o -perm -0002 \) -print 2>/dev/null | limit_lines 30)"
[ -n "$found" ] && scripts="${scripts}${found}
"
done
if [ -n "$writable" ]; then
add_finding "Writable cron configuration path" "High" "High" \
"Writable cron paths: $writable" \
"Writable cron configuration can allow commands to run as root or service users on schedule." \
"Restrict cron paths to root-owned, non-writable permissions and audit any recently changed jobs." \
"test -w, stat"
fi
if [ -n "$scripts" ]; then
add_finding "Writable scripts in privileged cron directories" "High" "High" \
"Writable cron scripts: $(printf '%s' "$scripts" | tr '\n' '; ')" \
"Cron executes scripts from these directories with elevated context on many systems. Writable scripts can become a direct privilege-escalation path." \
"Remove write access for non-privileged users and verify script contents from trusted sources." \
"find cron directories -writable"
fi
}
check_systemd() {
if ! has_cmd systemctl; then
return 0
fi
units="$(systemctl list-unit-files --type=service --no-pager --no-legend 2>/dev/null | awk '{print $1}' | limit_lines 300)"
[ -n "$units" ] || return 0
writable_units=""
writable_execs=""
for unit in $units; do
frag="$(systemctl show "$unit" -p FragmentPath --value 2>/dev/null || true)"
[ -n "$frag" ] && [ "$frag" != "n/a" ] || continue
if [ -e "$frag" ] && { is_writable_path "$frag" || is_world_writable "$frag"; }; then
writable_units="${writable_units}${unit}:${frag} mode=$(mode_of "$frag"); "
fi
[ "$FAST" -eq 1 ] && continue
execs="$(systemctl show "$unit" -p ExecStart --value 2>/dev/null | sed 's/[{}]//g' | tr ' ;' '\n' | awk '/^\// {print}' | limit_lines 8)"
for exe in $execs; do
[ -e "$exe" ] || continue
if is_writable_path "$exe" || is_world_writable "$exe"; then
writable_execs="${writable_execs}${unit}:${exe} mode=$(mode_of "$exe"); "
fi
done
done
if [ -n "$writable_units" ]; then
add_finding "Writable systemd unit files" "High" "High" \
"Writable units: $writable_units" \
"Writable service unit files can alter commands run by privileged services on restart or boot." \
"Make unit files root-owned and non-writable by unprivileged users; reload systemd only after approved review." \
"systemctl show FragmentPath, test -w"
fi
if [ -n "$writable_execs" ]; then
add_finding "Writable executables referenced by systemd services" "High" "High" \
"Writable service executables: $writable_execs" \
"Services often run as root or privileged service accounts. Writable executables in ExecStart paths can allow code replacement." \
"Restore trusted binaries/scripts, restrict write access, and verify package or deployment integrity." \
"systemctl show ExecStart, test -w"
fi
}
check_timers() {
if ! has_cmd systemctl; then
return 0
fi
timers="$(systemctl list-timers --all --no-pager --no-legend 2>/dev/null | limit_lines 40)"
[ -n "$timers" ] || return 0
add_finding "Systemd timers present" "Info" "High" \
"Timers: $(printf '%s' "$timers" | tr '\n' '; ')" \
"Timers are scheduled execution paths similar to cron. They are usually normal but relevant when paired with writable units or scripts." \
"Review timer ownership and linked service units during privileged execution audits." \
"systemctl list-timers"
}
check_processes() {
if ! has_cmd ps; then
return 0
fi
procs="$(ps -eo user,pid,ppid,comm,args 2>/dev/null | awk 'NR==1 || $1=="root" {print}' | limit_lines 80)"
[ -n "$procs" ] && add_finding "Root-owned process inventory" "Info" "Medium" \
"Root processes: $(printf '%s' "$procs" | tr '\n' '; ')" \
"Root-owned processes define privileged execution surfaces. Writable scripts, unusual interpreters, or user-controlled arguments in these processes deserve manual review." \
"Inspect unusual root processes and verify referenced files are root-owned and non-writable." \
"ps -eo user,pid,ppid,comm,args"
}
check_environment() {
env_names="$(env 2>/dev/null | awk -F= '/^(LD_PRELOAD|LD_LIBRARY_PATH|PYTHONPATH|PERL5LIB|RUBYLIB|NODE_PATH|PATH|IFS|BASH_ENV|ENV|SUDO_|KUBECONFIG|AWS_|GOOGLE_|AZURE_|DOCKER_|PODMAN_)/ {print $1}' | sort -u | tr '\n' ' ')"
[ -n "$env_names" ] || return 0
add_finding "Interesting environment variables present" "Low" "High" \
"Variable names only: $(one_line "$env_names")" \
"Loader, interpreter, cloud, container, and sudo-related environment variables can influence privileged commands or reveal operational context. Values are intentionally not printed." \
"Avoid preserving risky environment variables into privileged contexts; use sudo env_reset and explicit allowlists." \
"env variable-name filtering"
}
check_history_indicators() {
files=""
for f in "$HOME/.bash_history" "$HOME/.zsh_history" "$HOME/.mysql_history" "$HOME/.psql_history" "$HOME/.python_history"; do
[ -e "$f" ] || continue
meta="$f mode=$(mode_of "$f") owner=$(owner_of "$f")"
files="${files}${meta}; "
done
[ -n "$files" ] || return 0
add_finding "Shell and tool history files exist" "Low" "High" \
"$files" \
"History files sometimes contain credentials or administrative commands. This audit reports metadata only and does not dump contents." \
"Set restrictive permissions, avoid entering secrets on command lines, and rotate any secrets known to have been typed into shells." \
"stat history file paths"
}
check_ssh() {
evidence=""
for f in /etc/ssh/sshd_config "$HOME/.ssh/config" "$HOME/.ssh/authorized_keys"; do
[ -r "$f" ] || continue
case "$f" in
*/sshd_config)
hits="$(awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*(PermitRootLogin|PasswordAuthentication|PermitEmptyPasswords|PubkeyAuthentication|AuthorizedKeysFile)/ {print}' "$f" 2>/dev/null)"
;;
*)
hits="$f mode=$(mode_of "$f") owner=$(owner_of "$f")"
;;
esac
[ -n "$hits" ] && evidence="${evidence}${f}: $(one_line "$hits"); "
done
[ -n "$evidence" ] || return 0
severity="Info"
case "$evidence" in
*"PermitRootLogin yes"*|*"PasswordAuthentication yes"*|*"PermitEmptyPasswords yes"*) severity="Medium" ;;
esac
add_finding "SSH configuration risk indicators" "$severity" "Medium" \
"$evidence" \
"SSH settings and authorized key permissions affect remote administrative access. Some directives are safe only with compensating controls." \
"Disable empty passwords, avoid direct root login, prefer key-based authentication with MFA where available, and keep user SSH files private." \
"awk selected sshd_config directives, stat user SSH files"
}
check_mounts_nfs() {
mount_data="$(mount 2>/dev/null || safe_read /proc/mounts || true)"
[ -n "$mount_data" ] || return 0
nfs="$(printf '%s\n' "$mount_data" | awk '/ type nfs| type nfs4| nfs / {print}' | limit_lines 20)"
risky="$(printf '%s\n' "$mount_data" | awk '/(rw,|,rw,)/ && /(nosuid|noexec)/ == 0 {print}' | awk '/\/tmp|\/var\/tmp|\/dev\/shm|\/home|\/var\/www|\/opt/ {print}' | limit_lines 30)"
if [ -n "$nfs" ]; then
add_finding "NFS mounts detected" "Low" "Medium" \
"NFS mounts: $(printf '%s' "$nfs" | tr '\n' '; ')" \
"NFS export options such as no_root_squash or overly broad write access can create privilege risks, but client-side mount data alone does not prove exploitability." \
"Review server export options, enforce root_squash where appropriate, and restrict writable exports to necessary clients." \
"mount or /proc/mounts"
fi
if [ -n "$risky" ]; then
add_finding "Writable execution-capable mounts in sensitive locations" "Low" "Medium" \
"Mounts: $(printf '%s' "$risky" | tr '\n' '; ')" \
"Writable mounts without nosuid/noexec in shared or application paths may increase impact of other weaknesses." \
"Consider noexec,nodev,nosuid for temporary and shared writable filesystems after compatibility testing." \
"mount or /proc/mounts"
fi
}
check_containers() {
evidence=""
[ -f /.dockerenv ] && evidence="${evidence}/.dockerenv present; "
grep -qaE 'docker|kubepods|containerd|lxc|libpod' /proc/1/cgroup 2>/dev/null && evidence="${evidence}/proc/1/cgroup suggests container; "
for s in /var/run/docker.sock /run/podman/podman.sock /var/run/crio/crio.sock; do
[ -e "$s" ] || continue
evidence="${evidence}${s} mode=$(mode_of "$s") owner=$(owner_of "$s"); "
if is_writable_path "$s"; then
add_finding "Writable container daemon socket" "High" "High" \
"$s is writable by current user; mode=$(mode_of "$s") owner=$(owner_of "$s")" \
"Write access to container daemon sockets can permit creating privileged containers or mounting host paths in many configurations." \
"Restrict socket permissions, remove unnecessary daemon group membership, and audit daemon authorization plugins or rootless boundaries." \
"test -w, stat"
fi
done
if [ -n "$evidence" ]; then
add_finding "Containerization indicators" "Info" "Medium" \
"$evidence" \
"Container context changes the meaning of host findings. Some risks may be container-local unless host mounts, privileged mode, or daemon sockets are exposed." \
"Validate namespace, mount, capability, and daemon-socket exposure against the intended container security profile." \
"/.dockerenv, /proc/1/cgroup, socket metadata"
fi
}
check_configs_exposure() {
has_cmd find || return 0
roots="/var/www /srv /opt /etc /home"
[ "$FAST" -eq 1 ] && roots="/var/www /srv /opt /etc"
patterns='.*([.]env|wp-config[.]php|config[.]php|settings[.]php|database[.]yml|secrets?[.]ya?ml|credentials?[.]json|id_rsa|backup|[.]bak|[.]old|[.]save|[.]sql|[.]dump)$'
found="$(find $roots -xdev -type f 2>/dev/null | awk -v pat="$patterns" 'BEGIN{IGNORECASE=1} $0 ~ pat {print}' | limit_lines 80)"
[ -n "$found" ] || return 0
add_finding "Credential-like, backup, or local service config filenames found" "Low" "Medium" \
"Paths only, contents not read: $(printf '%s' "$found" | tr '\n' '; ')" \
"Config, key, database dump, and backup filenames often point to sensitive material. Filename presence is not proof of exposed secrets." \
"Restrict permissions, move secrets out of web roots, delete stale backups, and rotate credentials if contents are confirmed exposed." \
"find filename matching"
}
check_network() {
if has_cmd ss; then
listeners="$(ss -lntup 2>/dev/null | limit_lines 80)"
cmd="ss -lntup"
elif has_cmd netstat; then
listeners="$(netstat -lntup 2>/dev/null | limit_lines 80)"
cmd="netstat -lntup"
else
return 0
fi
[ -n "$listeners" ] || return 0
add_finding "Network listeners inventory" "Info" "Medium" \
"Listeners: $(printf '%s' "$listeners" | tr '\n' '; ')" \
"Local listeners expose services that may run with elevated privileges or provide administrative surfaces. Listener presence alone is not a vulnerability." \
"Disable unnecessary services, bind admin interfaces to localhost or management networks, and patch exposed daemons." \
"$cmd"
}
check_packages_kernel() {
kernel="$(uname -r 2>/dev/null || true)"
add_finding "Kernel version risk hint" "Info" "Low" \
"Kernel version: $kernel" \
"Kernel age can suggest areas for patch review, but version strings alone do not verify exploitability because distributions backport fixes." \
"Compare installed kernel security patch level against vendor advisories for the exact distribution and package release." \
"uname -r"
if has_cmd dpkg-query; then
pkgs="$(dpkg-query -W -f='${Package} ${Version}\n' sudo openssh-server systemd docker.io podman 2>/dev/null | limit_lines 30)"
cmd="dpkg-query"
elif has_cmd rpm; then
pkgs="$(rpm -q sudo openssh-server systemd docker podman 2>/dev/null | limit_lines 30)"
cmd="rpm -q"
elif has_cmd apk; then
pkgs="$(apk info -v sudo openssh systemd docker podman 2>/dev/null | limit_lines 30)"
cmd="apk info"
else
pkgs=""
cmd=""
fi
[ -n "$pkgs" ] && add_finding "Selected package versions" "Info" "Medium" \
"Packages: $(printf '%s' "$pkgs" | tr '\n' '; ')" \
"Package versions help correlate findings with vendor advisories, but version comparison must be distribution-aware." \
"Use the distribution security tracker or package manager advisory tooling to confirm patch status." \
"$cmd"
}
check_privileged_paths() {
has_cmd find || return 0
paths="/usr/local/bin /usr/local/sbin /opt /var/www"
writable=""
user_owned=""
for d in $paths; do
[ -d "$d" ] || continue
w="$(find "$d" -xdev -type f \( -writable -o -perm -0002 \) -print 2>/dev/null | limit_lines 40)"
[ -n "$w" ] && writable="${writable}${w}
"
u="$(find "$d" -xdev -type f -user "$(id -u 2>/dev/null || printf 999999)" -print 2>/dev/null | limit_lines 40)"
[ -n "$u" ] && user_owned="${user_owned}${u}
"
done
if [ -n "$writable" ]; then
add_finding "Writable files in privileged or service execution paths" "Medium" "High" \
"Files: $(printf '%s' "$writable" | tr '\n' '; ')" \
"Files in common service or administrator execution paths can become risky when referenced by cron, systemd, sudo, or operational scripts." \
"Restrict write permissions and verify whether any privileged task executes these paths." \
"find -writable"
fi
if [ -n "$user_owned" ]; then
add_finding "Current-user-owned files in privileged-looking paths" "Low" "Medium" \
"Files: $(printf '%s' "$user_owned" | tr '\n' '; ')" \
"User-owned files in shared privileged-looking paths are not automatically vulnerable, but they deserve review when privileged automation consumes them." \
"Move user-managed files to user-owned locations or document and restrict the deployment path." \
"find -user current_uid"
fi
}
check_home_permissions() {
home="${HOME:-}"
[ -n "$home" ] && [ -d "$home" ] || return 0
if is_world_writable "$home"; then
add_finding "Home directory appears world-writable" "Medium" "High" \
"$home mode=$(mode_of "$home") owner=$(owner_of "$home")" \
"World-writable home directories can allow tampering with shell startup files, SSH configuration, or user-owned scripts." \
"Remove world write permissions and review files in the home directory for unauthorized changes." \
"stat"
fi
for f in "$home/.bashrc" "$home/.profile" "$home/.bash_profile" "$home/.zshrc"; do
[ -e "$f" ] || continue
if is_world_writable "$f"; then
add_finding "Shell startup file is world-writable: $f" "Medium" "High" \
"$f mode=$(mode_of "$f") owner=$(owner_of "$f")" \
"Writable startup files can execute attacker-controlled commands when the user opens a shell; impact rises if administrators source or switch into the account." \
"Remove world write permissions and inspect the file contents manually for unauthorized commands." \
"stat"
fi
done
}
check_dynamic_linker() {
evidence=""
for p in /etc/ld.so.preload /etc/ld.so.conf; do
[ -e "$p" ] || continue
evidence="${evidence}${p} mode=$(mode_of "$p") owner=$(owner_of "$p"); "
if is_writable_path "$p" || is_world_writable "$p"; then
add_finding "Writable dynamic linker configuration: $p" "Critical" "High" \
"$p mode=$(mode_of "$p") owner=$(owner_of "$p")" \
"Writable linker configuration can force privileged programs to load attacker-controlled shared libraries." \
"Restore root ownership and strict permissions, verify file contents from a trusted baseline, and investigate recent changes." \