Skip to content

Commit 7ca1464

Browse files
author
Alade Yessoufou
committed
Add security audit to doctor command with --security flag (Task #3)
1 parent f720978 commit 7ca1464

4 files changed

Lines changed: 310 additions & 2 deletions

File tree

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
- Install and configure fail2ban (optional).
2323
- Ensure all changes are opt-in with clear prompts and rollback hints.
2424

25-
### 3) Doctor Security Audit
25+
### 3) Doctor Security Audit ✅ COMPLETED
2626
- Add `shipnode doctor --security` for non-destructive checks:
2727
- SSH config posture (root login, password auth, port).
2828
- Firewall status and allowed ports.

lib/commands/doctor.sh

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@
33
# ShipNode Doctor - Pre-flight diagnostic checks
44

55
cmd_doctor() {
6+
# Check for --security flag
7+
local security_mode=false
8+
if [ "$1" = "--security" ]; then
9+
security_mode=true
10+
fi
11+
12+
if [ "$security_mode" = true ]; then
13+
cmd_doctor_security
14+
return
15+
fi
16+
617
info "Running ShipNode diagnostics..."
718
echo ""
819

@@ -52,6 +63,49 @@ cmd_doctor() {
5263
fi
5364
}
5465

66+
# Security audit - non-destructive checks only
67+
cmd_doctor_security() {
68+
info "Running security audit..."
69+
echo ""
70+
71+
local has_warnings=false
72+
local has_info=false
73+
74+
# Local security checks
75+
info "Local security checks:"
76+
check_local_file_permissions || has_warnings=true
77+
echo ""
78+
79+
# SSH connectivity required for remote checks
80+
if ! check_ssh_connection_quiet; then
81+
warn "Cannot perform remote security checks - SSH connection failed"
82+
echo ""
83+
else
84+
echo ""
85+
86+
# Remote security checks
87+
info "Remote security checks:"
88+
check_ssh_security || has_warnings=true
89+
check_firewall_status || has_info=true
90+
check_fail2ban_status || has_info=true
91+
echo ""
92+
fi
93+
94+
# Summary
95+
echo ""
96+
if [ "$has_warnings" = true ]; then
97+
warn "Security audit completed with warnings. Review the issues above."
98+
echo ""
99+
info "Run 'shipnode harden' for interactive security hardening."
100+
elif [ "$has_info" = true ]; then
101+
info "Security audit completed with informational notices."
102+
echo ""
103+
info "No critical issues found. Review the notices above."
104+
else
105+
success "Security audit passed! No issues found."
106+
fi
107+
}
108+
55109
# Check if shipnode.conf exists and required vars are set
56110
check_local_config() {
57111
if [ ! -f "shipnode.conf" ]; then
@@ -295,3 +349,255 @@ REMOTE_CHECKS
295349
[ "$has_errors" = true ] && return 1
296350
return 0
297351
}
352+
353+
# Quiet SSH connection check (no output)
354+
check_ssh_connection_quiet() {
355+
if [ ! -f "shipnode.conf" ]; then
356+
return 1
357+
fi
358+
359+
set +e
360+
source shipnode.conf 2>/dev/null
361+
set -e
362+
363+
if [ -z "$SSH_USER" ] || [ -z "$SSH_HOST" ]; then
364+
return 1
365+
fi
366+
367+
remote_exec "exit" &>/dev/null
368+
}
369+
370+
# Check local file permissions for sensitive files
371+
check_local_file_permissions() {
372+
local has_issues=false
373+
374+
# Check shipnode.conf permissions
375+
if [ -f "shipnode.conf" ]; then
376+
local conf_perms
377+
conf_perms=$(stat -c "%a" shipnode.conf 2>/dev/null || stat -f "%Lp" shipnode.conf 2>/dev/null)
378+
if [ -n "$conf_perms" ]; then
379+
# Check if permissions are too permissive (readable by group/others)
380+
local other_read=$((conf_perms % 10 / 1))
381+
local group_read=$(((conf_perms / 10) % 10 / 1))
382+
383+
if [ "$other_read" -ge 4 ] || [ "$group_read" -ge 4 ]; then
384+
echo " ⚠ shipnode.conf has overly permissive permissions ($conf_perms)"
385+
echo " Recommendation: Run 'chmod 600 shipnode.conf'"
386+
has_issues=true
387+
else
388+
echo " ✓ shipnode.conf permissions are secure ($conf_perms)"
389+
fi
390+
fi
391+
fi
392+
393+
# Check .env file permissions
394+
if [ -f ".env" ]; then
395+
local env_perms
396+
env_perms=$(stat -c "%a" .env 2>/dev/null || stat -f "%Lp" .env 2>/dev/null)
397+
if [ -n "$env_perms" ]; then
398+
local other_read=$((env_perms % 10 / 1))
399+
local group_read=$(((env_perms / 10) % 10 / 1))
400+
401+
if [ "$other_read" -ge 4 ] || [ "$group_read" -ge 4 ]; then
402+
echo " ⚠ .env file has overly permissive permissions ($env_perms)"
403+
echo " Recommendation: Run 'chmod 600 .env'"
404+
has_issues=true
405+
else
406+
echo " ✓ .env file permissions are secure ($env_perms)"
407+
fi
408+
fi
409+
fi
410+
411+
# Check users.yml file permissions
412+
if [ -f "users.yml" ]; then
413+
local users_perms
414+
users_perms=$(stat -c "%a" users.yml 2>/dev/null || stat -f "%Lp" users.yml 2>/dev/null)
415+
if [ -n "$users_perms" ]; then
416+
local other_read=$((users_perms % 10 / 1))
417+
local group_read=$(((users_perms / 10) % 10 / 1))
418+
419+
if [ "$other_read" -ge 4 ] || [ "$group_read" -ge 4 ]; then
420+
echo " ⚠ users.yml has overly permissive permissions ($users_perms)"
421+
echo " Recommendation: Run 'chmod 600 users.yml'"
422+
has_issues=true
423+
else
424+
echo " ✓ users.yml permissions are secure ($users_perms)"
425+
fi
426+
fi
427+
fi
428+
429+
[ "$has_issues" = true ] && return 1
430+
return 0
431+
}
432+
433+
# Check SSH configuration security
434+
check_ssh_security() {
435+
local has_issues=false
436+
437+
# Fetch SSH configuration from remote server
438+
local ssh_config
439+
ssh_config=$(remote_exec "cat /etc/ssh/sshd_config 2>/dev/null || echo 'SSHD_CONFIG_MISSING'")
440+
441+
if [ "$ssh_config" = "SSHD_CONFIG_MISSING" ]; then
442+
echo " ⚠ Could not read /etc/ssh/sshd_config"
443+
return 1
444+
fi
445+
446+
# Check PermitRootLogin
447+
local root_login
448+
root_login=$(echo "$ssh_config" | grep -i "^PermitRootLogin" | tail -1 | awk '{print $2}')
449+
if [ -z "$root_login" ]; then
450+
root_login="prohibit-password" # Default in modern OpenSSH
451+
fi
452+
453+
case "$root_login" in
454+
yes|prohibit-password)
455+
echo " ⚠ PermitRootLogin is enabled ($root_login)"
456+
echo " Recommendation: Set 'PermitRootLogin no' in /etc/ssh/sshd_config"
457+
has_issues=true
458+
;;
459+
no)
460+
echo " ✓ PermitRootLogin is disabled"
461+
;;
462+
*)
463+
echo " ℹ PermitRootLogin setting: $root_login"
464+
;;
465+
esac
466+
467+
# Check PasswordAuthentication
468+
local pass_auth
469+
pass_auth=$(echo "$ssh_config" | grep -i "^PasswordAuthentication" | tail -1 | awk '{print $2}')
470+
if [ -z "$pass_auth" ]; then
471+
pass_auth="yes" # Default
472+
fi
473+
474+
case "$pass_auth" in
475+
yes)
476+
echo " ⚠ PasswordAuthentication is enabled"
477+
echo " Recommendation: Set 'PasswordAuthentication no' in /etc/ssh/sshd_config"
478+
has_issues=true
479+
;;
480+
no)
481+
echo " ✓ PasswordAuthentication is disabled"
482+
;;
483+
esac
484+
485+
# Check SSH Port
486+
local ssh_port
487+
ssh_port=$(echo "$ssh_config" | grep -i "^Port" | tail -1 | awk '{print $2}')
488+
if [ -z "$ssh_port" ]; then
489+
ssh_port="22" # Default
490+
fi
491+
492+
if [ "$ssh_port" = "22" ]; then
493+
echo " ℹ SSH is running on default port 22"
494+
echo " Recommendation: Consider changing to a non-standard port for security by obscurity"
495+
else
496+
echo " ✓ SSH is running on non-default port $ssh_port"
497+
fi
498+
499+
[ "$has_issues" = true ] && return 1
500+
return 0
501+
}
502+
503+
# Check firewall status
504+
check_firewall_status() {
505+
local has_info=false
506+
507+
# Check if ufw is available and active
508+
local ufw_status
509+
ufw_status=$(remote_exec "sudo ufw status 2>/dev/null || echo 'UFW_NOT_FOUND'")
510+
511+
if [ "$ufw_status" != "UFW_NOT_FOUND" ]; then
512+
if echo "$ufw_status" | grep -q "Status: active"; then
513+
echo " ✓ UFW firewall is active"
514+
515+
# Check if SSH port is allowed
516+
if echo "$ufw_status" | grep -q "22/tcp\|SSH"; then
517+
echo " ✓ SSH port is allowed in UFW"
518+
else
519+
echo " ⚠ SSH port may not be explicitly allowed in UFW"
520+
has_info=true
521+
fi
522+
523+
# Check if HTTP/HTTPS are allowed
524+
if echo "$ufw_status" | grep -q "80/tcp\|80,443/tcp\|Nginx Full\|Apache Full"; then
525+
echo " ✓ HTTP/HTTPS ports are allowed in UFW"
526+
else
527+
echo " ℹ HTTP/HTTPS ports not explicitly allowed (may use different firewall)"
528+
has_info=true
529+
fi
530+
else
531+
echo " ⚠ UFW is installed but not active"
532+
echo " Recommendation: Run 'sudo ufw enable' to activate the firewall"
533+
has_info=true
534+
fi
535+
return 0
536+
fi
537+
538+
# Check if firewalld is running
539+
local firewalld_status
540+
firewalld_status=$(remote_exec "systemctl is-active firewalld 2>/dev/null || echo 'inactive'")
541+
542+
if [ "$firewalld_status" = "active" ]; then
543+
echo " ✓ firewalld is active"
544+
return 0
545+
fi
546+
547+
# Check iptables directly
548+
local iptables_rules
549+
iptables_rules=$(remote_exec "sudo iptables -L -n 2>/dev/null | head -5 || echo 'IPTABLES_NOT_AVAILABLE'")
550+
551+
if [ "$iptables_rules" != "IPTABLES_NOT_FOUND" ] && echo "$iptables_rules" | grep -q "Chain INPUT"; then
552+
if echo "$iptables_rules" | grep -q "DROP"; then
553+
echo " ✓ iptables has active rules with DROP policies"
554+
else
555+
echo " ℹ iptables has rules but no explicit DROP policy detected"
556+
has_info=true
557+
fi
558+
else
559+
echo " ⚠ No active firewall detected (UFW, firewalld, or iptables)"
560+
echo " Recommendation: Enable UFW with 'shipnode harden' or manually configure iptables"
561+
has_info=true
562+
fi
563+
564+
[ "$has_info" = true ] && return 1
565+
return 0
566+
}
567+
568+
# Check fail2ban status
569+
check_fail2ban_status() {
570+
local has_info=false
571+
572+
# Check if fail2ban is installed
573+
local fail2ban_installed
574+
fail2ban_installed=$(remote_exec "command -v fail2ban-server 2>/dev/null || echo 'NOT_INSTALLED'")
575+
576+
if [ "$fail2ban_installed" = "NOT_INSTALLED" ]; then
577+
echo " ℹ fail2ban is not installed"
578+
echo " Recommendation: Install fail2ban for brute-force protection (use 'shipnode harden')"
579+
has_info=true
580+
else
581+
# Check if fail2ban service is running
582+
local fail2ban_status
583+
fail2ban_status=$(remote_exec "sudo systemctl is-active fail2ban 2>/dev/null || sudo service fail2ban status 2>/dev/null | grep -i running || echo 'NOT_RUNNING'")
584+
585+
if [ "$fail2ban_status" = "active" ] || echo "$fail2ban_status" | grep -q "running"; then
586+
echo " ✓ fail2ban is installed and running"
587+
588+
# Get list of active jails
589+
local jails
590+
jails=$(remote_exec "sudo fail2ban-client status 2>/dev/null | grep 'Jail list' || echo ''")
591+
if [ -n "$jails" ]; then
592+
echo " $jails"
593+
fi
594+
else
595+
echo " ℹ fail2ban is installed but not running"
596+
echo " Recommendation: Start fail2ban with 'sudo systemctl start fail2ban'"
597+
has_info=true
598+
fi
599+
fi
600+
601+
[ "$has_info" = true ] && return 1
602+
return 0
603+
}

lib/commands/help.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Commands:
1313
deploy Deploy the application
1414
deploy --skip-build Deploy without running build step
1515
doctor Run pre-flight diagnostic checks
16+
doctor --security Run non-destructive security audit
1617
harden Interactive server security hardening wizard
1718
env Upload .env file to server
1819
status Check application status
@@ -66,6 +67,7 @@ Examples:
6667
shipnode init --list-templates # List all available templates
6768
shipnode setup # Setup server (first time)
6869
shipnode doctor # Run diagnostics
70+
shipnode doctor --security # Run security audit
6971
shipnode harden # Interactive security hardening
7072
shipnode deploy # Deploy your app
7173
shipnode env # Upload .env file to server

lib/commands/main.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ main() {
1010
cmd_deploy "$2"
1111
;;
1212
doctor)
13-
cmd_doctor
13+
cmd_doctor "$2"
1414
;;
1515
env)
1616
cmd_env

0 commit comments

Comments
 (0)