Files

2351 lines
82 KiB
Perl
Raw Permalink Normal View History

#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
# Security audit: SSH, firewall, SELinux, updates, accounts, services, kernel
# hardening, and an opt-in file system scan, graded A to F.
#
# The audit reads the state the machine is in, not what a configuration file
# promises. sshd is asked for its effective configuration through `sshd -T`,
# the firewall is read from the running firewalld and compared against the
# sockets that really have a listener, SELinux is read at runtime and checked
# against what it will be at the next boot, and pending security updates come
# from dnf's own metadata. Where a source cannot answer, the check is reported
# as unknown and the grade is renormalised, so missing tooling never reads as a
# security failure; an explicitly weakened control still scores nothing.
#
# It runs unprivileged where that is enough. As root, sshd answers with its
# effective values, /etc/shadow and the sudoers files open, and dnf's cache is
# the system's. Unprivileged, the same sections degrade to unknown rather than
# to a guess, and the report names the sources that need root.
#
# Deliberate limits, stated rather than hidden:
#
# * firewalld rich rules, direct rules and source based allowances are not
# modelled. A listener the zone rules do not explain may be intentional;
# the finding says what was and was not matched, not what is wrong.
# * dnf is queried with --cacheonly, so the audit never touches the network.
# Metadata that is not cached means an unknown, not an empty answer.
# * the sshd fallback that parses /etc/ssh/sshd_config when `sshd -T` cannot
# run applies OpenSSH's defaults to absent keywords and ignores Match
# blocks, which `sshd -T` would have resolved. The report says when the
# source is the file rather than the running daemon.
# * the deep file scan covers the root file system only (-xdev).
#
# A critical finding makes the exit status 1, so a cron job or a pipeline fails
# loudly; --strict extends that to warnings. A usage error exits 2.
#
# Perl builtins only: no module has to be installed. The command runner forks
# and keeps stdout and stderr apart in a private scratch directory, the JSON
# encoder for --json is written by hand, and the port ranges of a firewalld
# service are read from its XML with a regular expression.
#
# External binaries used: sshd, ss, firewall-cmd, getenforce, dnf, rpm,
# systemctl, find and uname.
#
# Linux only. Progress goes to stderr, the report to stdout.
#
# Usage:
# security-audit.pl # full audit
# security-audit.pl --section ssh,firewall # only selected sections
# security-audit.pl --deep # also scan the file system
# security-audit.pl --json # machine-readable output
# security-audit.pl --strict # fail on warnings too
# security-audit.pl --version
use strict;
use warnings;
my $VERSION = '2.0.0';
# Colour, in the shape the collection uses: NO_COLOR (https://no-color.org) or a
# stdout that is not a terminal disables every escape.
my ($BOLD, $RED, $GREEN, $YELLOW, $CYAN, $DIM, $RESET) = (
"\033[1m", "\033[31m", "\033[32m", "\033[33m",
"\033[36m", "\033[2m", "\033[0m",
);
if (defined $ENV{NO_COLOR} || !-t STDOUT) {
($BOLD, $RED, $GREEN, $YELLOW, $CYAN, $DIM, $RESET) = ('') x 7;
}
# Letter grade to colour, applied when rendering only.
my %GRADE_COLOURS = (A => $GREEN, B => $GREEN, C => $YELLOW, D => $YELLOW, F => $RED);
my $WIDTH = 60;
my @ALL_SECTIONS = qw(ssh firewall selinux updates accounts services kernel files);
my %SECTION_TITLES = (
ssh => 'SSH',
firewall => 'Firewall',
selinux => 'SELinux',
updates => 'Updates',
accounts => 'Accounts',
services => 'Services',
kernel => 'Kernel hardening',
files => 'File system scan',
);
# OpenSSH's own defaults, applied to keywords an effective dump never omits but
# a configuration file can. sshd_config(5) is the source for each one.
my %SSHD_DEFAULTS = (
permitrootlogin => 'prohibit-password',
passwordauthentication => 'yes',
permitemptypasswords => 'no',
pubkeyauthentication => 'yes',
x11forwarding => 'no',
maxauthtries => '6',
);
my $TMP_DIR; # private scratch directory, created only when needed
my $PARENT_PID = $$; # nothing forks today, but the cleanup contract stays
my $RUN_SEQ = 0; # per-call suffix for the runner's files
# ---------------------------------------------------------------------------
# Progress, on stderr so stdout stays clean
# ---------------------------------------------------------------------------
sub _status {
my ($msg) = @_;
print STDERR " $msg...";
return;
}
sub _status_done {
my ($msg) = @_;
$msg = 'done' unless defined $msg;
print STDERR " $msg\n";
return;
}
# ---------------------------------------------------------------------------
# Commands, files and small helpers
# ---------------------------------------------------------------------------
sub _trim {
my ($text) = @_;
return '' unless defined $text;
$text =~ s/^\s+//;
$text =~ s/\s+$//;
return $text;
}
sub find_exe {
my ($name) = @_;
return undef unless defined $name && length $name;
if (index($name, '/') >= 0) {
return (-f $name && -x _) ? $name : undef;
}
for my $dir (split /:/, ($ENV{PATH} // '')) {
next unless length $dir;
my $path = "$dir/$name";
return $path if -f $path && -x _;
}
return undef;
}
sub scratch_dir {
return $TMP_DIR if defined $TMP_DIR;
my $base = $ENV{TMPDIR} // '/tmp';
for my $attempt (0 .. 9) {
my $dir = "$base/security-audit.$$" . ($attempt ? ".$attempt" : '');
if (mkdir($dir, 0700)) {
$TMP_DIR = $dir;
return $dir;
}
}
die "cannot create a scratch directory under $base\n";
}
sub remove_scratch {
return unless defined $TMP_DIR;
return unless $$ == $PARENT_PID;
if (opendir(my $dh, $TMP_DIR)) {
for my $entry (readdir($dh)) {
next if $entry eq '.' || $entry eq '..';
unlink("$TMP_DIR/$entry");
}
closedir($dh);
}
rmdir($TMP_DIR);
undef $TMP_DIR;
return;
}
sub slurp {
my ($path) = @_;
open(my $fh, '<', $path) or return '';
my $text = do { local $/ = undef; <$fh> };
close($fh);
return defined $text ? $text : '';
}
# Run a command and return { rc, out, err }. The locale is forced to C for
# English output, and a timeout or a missing binary yields empty output rather
# than an exception, which is the contract every caller below expects.
sub run {
my ($cmd, $timeout) = @_;
$timeout = 15 unless defined $timeout;
my $exe = find_exe($cmd->[0]);
return { rc => 127, out => '', err => '' } unless defined $exe;
my $dir = scratch_dir();
$RUN_SEQ++;
my $out_file = "$dir/out.$$.$RUN_SEQ";
my $err_file = "$dir/err.$$.$RUN_SEQ";
my $pid = fork();
die "cannot fork: $!\n" unless defined $pid;
if ($pid == 0) {
if (open(STDOUT, '>', $out_file) && open(STDERR, '>', $err_file)) {
$ENV{LANG} = 'C';
$ENV{LC_ALL} = 'C';
exec { $exe } @$cmd;
}
exit 126;
}
my $timed_out = 0;
eval {
local $SIG{ALRM} = sub { die "alarm\n" };
alarm($timeout);
waitpid($pid, 0);
alarm(0);
1;
} or do { $timed_out = 1; alarm(0) };
my $rc;
if ($timed_out) {
kill('TERM', $pid);
select(undef, undef, undef, 0.1);
kill('KILL', $pid);
waitpid($pid, 0);
$rc = 124;
}
else {
$rc = $? >> 8;
}
my $out = slurp($out_file);
my $err = slurp($err_file);
unlink($out_file, $err_file);
return { rc => $rc, out => $timed_out ? '' : $out, err => $timed_out ? '' : $err };
}
sub read_file {
my ($path) = @_;
open(my $fh, '<', $path) or return '';
my $text = do { local $/ = undef; <$fh> };
close($fh);
return defined $text ? _trim($text) : '';
}
sub read_lines {
my ($path) = @_;
open(my $fh, '<', $path) or return [];
my @lines;
while (my $line = <$fh>) {
$line =~ s/^\s+//;
$line =~ s/\s+$//;
push @lines, $line;
}
close($fh);
return \@lines;
}
sub can_read_file {
my ($path) = @_;
return -f $path && -r _ ? 1 : 0;
}
sub is_root {
return $> == 0 ? 1 : 0;
}
# Seconds since the epoch in the local calendar, so a UTC offset can be derived.
sub _days_from_civil {
my ($year, $month, $day) = @_;
$year -= $month <= 2 ? 1 : 0;
my $era = int(($year >= 0 ? $year : $year - 399) / 400);
my $yoe = $year - $era * 400;
my $doy = int((153 * ($month + ($month > 2 ? -3 : 9)) + 2) / 5) + $day - 1;
my $doe = $yoe * 365 + int($yoe / 4) - int($yoe / 100) + $doy;
return $era * 146097 + $doe - 719468;
}
# The +hhmm offset. Perl's localtime reports no offset, so the same instant is
# read as both local and UTC calendar time and the difference is the offset.
sub utc_offset {
my ($t) = @_;
my @lt = localtime($t);
my @gt = gmtime($t);
my $offset = (_days_from_civil($lt[5] + 1900, $lt[4] + 1, $lt[3]) * 1440 + $lt[2] * 60 + $lt[1])
- (_days_from_civil($gt[5] + 1900, $gt[4] + 1, $gt[3]) * 1440 + $gt[2] * 60 + $gt[1]);
my $sign = $offset < 0 ? '-' : '+';
$offset = -$offset if $offset < 0;
return sprintf('%s%02d%02d', $sign, int($offset / 60), $offset % 60);
}
sub timestamp_fields {
my ($t) = @_;
my @lt = localtime($t);
return sprintf('%04d-%02d-%02dT%02d:%02d:%02d%s',
$lt[5] + 1900, $lt[4] + 1, $lt[3], $lt[2], $lt[1], $lt[0], utc_offset($t));
}
sub hostname_of {
my $name = read_file('/proc/sys/kernel/hostname');
return length $name ? $name : 'unknown';
}
# ---------------------------------------------------------------------------
# JSON, written by hand
# ---------------------------------------------------------------------------
sub json_quote {
my ($text) = @_;
my $bytes = $text;
# Non-ASCII escapes as \uXXXX, so the byte string is decoded to characters
# before escaping.
utf8::decode($bytes);
my $out = '"';
for my $char (split //, $bytes) {
my $code = ord($char);
if ($char eq '"') { $out .= '\\"' }
elsif ($char eq '\\') { $out .= '\\\\' }
elsif ($char eq "\b") { $out .= '\\b' }
elsif ($char eq "\f") { $out .= '\\f' }
elsif ($char eq "\n") { $out .= '\\n' }
elsif ($char eq "\r") { $out .= '\\r' }
elsif ($char eq "\t") { $out .= '\\t' }
elsif ($code < 0x20) { $out .= sprintf('\\u%04x', $code) }
elsif ($code > 0x7e) {
if ($code > 0xffff) {
my $rest = $code - 0x10000;
$out .= sprintf('\\u%04x\\u%04x', 0xd800 + ($rest >> 10), 0xdc00 + ($rest & 0x3ff));
}
else {
$out .= sprintf('\\u%04x', $code);
}
}
else { $out .= $char }
}
return $out . '"';
}
# Values written as true and false, because Perl has no boolean type and the
# paths must be listed. The exact paths first, then the shape of the listener
# entries: array elements carry no index in a path, so one shape covers them.
my @JSON_BOOLEANS = (
'root',
'deep',
'sections.ssh.available',
'sections.firewall.available',
'sections.firewall.active',
'sections.selinux.available',
'sections.updates.reboot_required',
);
my %JSON_BOOLEAN = map { $_ => 1 } @JSON_BOOLEANS;
my @JSON_BOOLEAN_PATHS = (
qr/^sections\.firewall\.listeners\.(?:loopback|allowed|exposed)$/,
);
sub json_render {
my ($value, $path, $indent, $out) = @_;
if (!defined $value) {
$$out .= 'null';
return;
}
if (ref $value eq 'HASH') {
my @keys = sort keys %$value;
if (!@keys) {
$$out .= '{}';
return;
}
$$out .= "{\n";
for my $index (0 .. $#keys) {
my $key = $keys[$index];
my $child_path = length $path ? "$path.$key" : $key;
$$out .= (' ' x (2 * ($indent + 1))) . json_quote($key) . ': ';
json_render($value->{$key}, $child_path, $indent + 1, $out);
$$out .= ',' if $index < $#keys;
$$out .= "\n";
}
$$out .= (' ' x (2 * $indent)) . '}';
return;
}
if (ref $value eq 'ARRAY') {
if (!@$value) {
$$out .= '[]';
return;
}
$$out .= "[\n";
for my $index (0 .. $#$value) {
$$out .= ' ' x (2 * ($indent + 1));
json_render($value->[$index], $path, $indent + 1, $out);
$$out .= ',' if $index < $#$value;
$$out .= "\n";
}
$$out .= (' ' x (2 * $indent)) . ']';
return;
}
if ($JSON_BOOLEAN{$path} || grep { $path =~ $_ } @JSON_BOOLEAN_PATHS) {
$$out .= $value ? 'true' : 'false';
return;
}
if ($value =~ /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/) {
$$out .= $value;
return;
}
$$out .= json_quote($value);
return;
}
sub json_encode {
my ($value) = @_;
my $out = '';
json_render($value, '', 0, \$out);
return $out . "\n";
}
# ---------------------------------------------------------------------------
# RPM version comparison, the collection's own implementation of rpmvercmp
# ---------------------------------------------------------------------------
# Segments alternate between digits and letters, numeric segments compare by
# value, tilde sorts before everything, and separators are skipped.
sub rpmvercmp {
my ($ver_a, $ver_b) = @_;
my $len_a = length $ver_a;
my $len_b = length $ver_b;
my ($pos_a, $pos_b) = (0, 0);
while ($pos_a < $len_a || $pos_b < $len_b) {
my $tilde_a = $pos_a < $len_a && substr($ver_a, $pos_a, 1) eq '~';
my $tilde_b = $pos_b < $len_b && substr($ver_b, $pos_b, 1) eq '~';
if ($tilde_a || $tilde_b) {
if ($tilde_a && $tilde_b) { $pos_a++; $pos_b++; next }
return $tilde_a ? -1 : 1;
}
$pos_a++ while $pos_a < $len_a && substr($ver_a, $pos_a, 1) !~ /[A-Za-z0-9]/;
$pos_b++ while $pos_b < $len_b && substr($ver_b, $pos_b, 1) !~ /[A-Za-z0-9]/;
last if $pos_a >= $len_a || $pos_b >= $len_b;
my $numeric_a = substr($ver_a, $pos_a, 1) =~ /[0-9]/ ? 1 : 0;
my $numeric_b = substr($ver_b, $pos_b, 1) =~ /[0-9]/ ? 1 : 0;
if ($numeric_a != $numeric_b) {
return $numeric_a ? 1 : -1;
}
my ($start_a, $start_b) = ($pos_a, $pos_b);
my ($seg_a, $seg_b);
if ($numeric_a) {
$pos_a++ while $pos_a < $len_a && substr($ver_a, $pos_a, 1) =~ /[0-9]/;
$pos_b++ while $pos_b < $len_b && substr($ver_b, $pos_b, 1) =~ /[0-9]/;
$seg_a = substr($ver_a, $start_a, $pos_a - $start_a);
$seg_b = substr($ver_b, $start_b, $pos_b - $start_b);
$seg_a =~ s/^0+//;
$seg_b =~ s/^0+//;
$seg_a = '0' unless length $seg_a;
$seg_b = '0' unless length $seg_b;
if (length $seg_a != length $seg_b) {
return length $seg_a > length $seg_b ? 1 : -1;
}
}
else {
$pos_a++ while $pos_a < $len_a && substr($ver_a, $pos_a, 1) =~ /[A-Za-z]/;
$pos_b++ while $pos_b < $len_b && substr($ver_b, $pos_b, 1) =~ /[A-Za-z]/;
$seg_a = substr($ver_a, $start_a, $pos_a - $start_a);
$seg_b = substr($ver_b, $start_b, $pos_b - $start_b);
}
if ($seg_a ne $seg_b) {
return $seg_a gt $seg_b ? 1 : -1;
}
}
return 1 if $pos_a < $len_a;
return -1 if $pos_b < $len_b;
return 0;
}
# ---------------------------------------------------------------------------
# Check construction
# ---------------------------------------------------------------------------
# One check: an id, a severity of pass, warn, critical, unknown or info, a weight
# that must be an even number (a warning is worth half), a short title for the
# report, a message, and optionally a detail list of lines.
sub _check {
my (%arg) = @_;
my %check = (
id => $arg{id},
severity => $arg{severity},
weight => $arg{weight} // 0,
title => $arg{title},
message => $arg{message},
);
$check{detail} = [ @{ $arg{detail} } ] if $arg{detail};
return \%check;
}
# ---------------------------------------------------------------------------
# Section: SSH
# ---------------------------------------------------------------------------
# The effective configuration, one "keyword value" pair per line, as `sshd -T`
# prints it. Port may repeat, because sshd listens on every port it is given.
sub parse_sshd_effective {
my ($out) = @_;
my %values;
my @ports;
for my $line (split /\n/, $out) {
my @fields = split ' ', $line, 2;
next unless @fields == 2;
my ($key, $value) = (lc $fields[0], _trim($fields[1]));
if ($key eq 'port') {
push @ports, $value + 0 if $value =~ /^\d+$/;
}
$values{$key} = $value unless exists $values{$key};
}
return (\%values, \@ports);
}
# The fallback: the configuration files, with Include expanded relative to
# /etc/ssh, in the order OpenSSH reads them. The first value of a keyword wins,
# which is OpenSSH's own rule; Port and ListenAddress accumulate instead, since
# sshd binds every one. Match blocks are not resolved: reading stops at the
# first Match line, because everything after it is conditional on a pattern
# this parser cannot evaluate.
sub read_sshd_config {
my ($path, $state, $depth) = @_;
return if $depth > 5;
return unless can_read_file($path);
for my $line (@{ read_lines($path) }) {
next unless length $line;
next if index($line, '#') == 0;
my @fields = split ' ', $line, 2;
next unless @fields >= 1;
my $key = lc $fields[0];
my $value = @fields == 2 ? _trim($fields[1]) : '';
if ($key eq 'include') {
for my $pattern (split ' ', $value) {
next unless length $pattern;
$pattern = "/etc/ssh/$pattern" unless index($pattern, '/') == 0;
for my $file (sort glob($pattern)) {
read_sshd_config($file, $state, $depth + 1);
}
}
next;
}
if ($key eq 'match') {
# Everything after a Match line is conditional on a pattern this
# parser cannot evaluate, so the global reading of the file ends.
last;
}
if ($key eq 'port' || $key eq 'listenaddress') {
push @{ $state->{accum}{$key} }, $value if length $value;
next;
}
next if $state->{seen}{$key}++;
$state->{values}{$key} = $value;
}
return;
}
sub sshd_config_values {
my $state = { values => {}, seen => {}, accum => {} };
read_sshd_config('/etc/ssh/sshd_config', $state, 0);
my @ports = map { $_ + 0 } grep { /^\d+$/ } @{ $state->{accum}{port} // [] };
return ($state->{values}, \@ports);
}
sub collect_ssh {
my %ssh = (available => 0, source => '', values => {}, ports => []);
my $sshd = find_exe('sshd');
$sshd = find_exe('/usr/sbin/sshd') unless defined $sshd;
if (defined $sshd) {
my $p = run([$sshd, '-T'], 15);
if ($p->{rc} == 0 && length $p->{out}) {
my ($values, $ports) = parse_sshd_effective($p->{out});
%ssh = (available => 1, source => 'effective', values => $values, ports => $ports);
return \%ssh;
}
}
my ($values, $ports) = sshd_config_values();
if (%$values) {
%ssh = (available => 1, source => 'config', values => $values, ports => $ports);
}
return \%ssh;
}
sub effective_value {
my ($values, $key) = @_;
return exists $values->{$key} ? $values->{$key} : $SSHD_DEFAULTS{$key};
}
sub used_default {
my ($values, $key) = @_;
return !exists $values->{$key} && exists $SSHD_DEFAULTS{$key};
}
sub evaluate_ssh {
my ($ssh) = @_;
return () unless $ssh->{available};
my $values = $ssh->{values};
my @checks;
# sshd_config(5) calls its arguments case-sensitive, but sshd itself
# accepts and lowercases the Yes and No values these verdicts compare,
# as `sshd -T` prints them back, so the comparisons are lowercased.
my $root_login = lc effective_value($values, 'permitrootlogin');
my $defaulted = used_default($values, 'permitrootlogin') ? " (OpenSSH's default)" : '';
if ($root_login eq 'yes') {
push @checks, _check(
id => 'ssh.permit_root_login', severity => 'critical', weight => 4,
title => 'Root login',
message => "PermitRootLogin yes$defaulted: root is reachable by any authentication method",
);
}
elsif ($root_login eq 'no') {
push @checks, _check(
id => 'ssh.permit_root_login', severity => 'pass', weight => 4,
title => 'Root login', message => "PermitRootLogin no$defaulted",
);
}
elsif ($root_login eq 'forced-commands-only') {
push @checks, _check(
id => 'ssh.permit_root_login', severity => 'pass', weight => 4,
title => 'Root login', message => 'PermitRootLogin forced-commands-only',
);
}
else { # prohibit-password
push @checks, _check(
id => 'ssh.permit_root_login', severity => 'pass', weight => 4,
title => 'Root login',
message => "PermitRootLogin $root_login$defaulted: keys only",
);
}
my $password = lc effective_value($values, 'passwordauthentication');
$defaulted = used_default($values, 'passwordauthentication')
? " (OpenSSH's default)" : '';
if ($password eq 'no') {
push @checks, _check(
id => 'ssh.password_authentication', severity => 'pass', weight => 4,
title => 'Password auth', message => "PasswordAuthentication no$defaulted",
);
}
else {
push @checks, _check(
id => 'ssh.password_authentication', severity => 'warn', weight => 4,
title => 'Password auth',
message => "PasswordAuthentication $password$defaulted: keys are preferred on an exposed host",
);
}
my $empty = lc effective_value($values, 'permitemptypasswords');
$defaulted = used_default($values, 'permitemptypasswords') ? " (OpenSSH's default)" : '';
push @checks, _check(
id => 'ssh.permit_empty_passwords',
severity => $empty eq 'no' ? 'pass' : 'critical', weight => 2,
title => 'Empty passwords',
message => $empty eq 'no'
? "PermitEmptyPasswords no$defaulted"
: "PermitEmptyPasswords $empty: an account without a password is reachable over SSH",
);
my $pubkey = lc effective_value($values, 'pubkeyauthentication');
push @checks, _check(
id => 'ssh.pubkey_authentication',
severity => $pubkey eq 'yes' ? 'pass' : 'warn', weight => 2,
title => 'Pubkey auth',
message => $pubkey eq 'yes'
? 'PubkeyAuthentication yes'
: "PubkeyAuthentication $pubkey: authentication falls back to weaker methods",
);
my $x11 = lc effective_value($values, 'x11forwarding');
push @checks, _check(
id => 'ssh.x11forwarding',
severity => $x11 eq 'no' ? 'pass' : 'warn', weight => 2,
title => 'X11 forwarding',
message => $x11 eq 'no'
? 'X11Forwarding no'
: "X11Forwarding $x11: an SSH client reaches the local display",
);
my $max_tries = effective_value($values, 'maxauthtries');
if ($max_tries =~ /^\d+$/ && $max_tries + 0 > 6) {
push @checks, _check(
id => 'ssh.max_auth_tries', severity => 'warn', weight => 2,
title => 'Max auth tries',
message => "MaxAuthTries $max_tries widens the brute force window",
);
}
else {
push @checks, _check(
id => 'ssh.max_auth_tries', severity => 'pass', weight => 2,
title => 'Max auth tries',
message => "MaxAuthTries $max_tries",
);
}
return @checks;
}
# ---------------------------------------------------------------------------
# Section: Firewall
# ---------------------------------------------------------------------------
# The ports a firewalld service XML allows, read with a regular expression.
# Attribute order in the <port> element varies, so the attributes are read
# separately; a missing protocol falls back to tcp, which is what the schema
# makes it.
sub parse_service_xml {
my ($xml) = @_;
my @ports;
while ($xml =~ /<port\s+([^>]*?)\/?>/g) {
my $attrs = $1;
my ($port) = $attrs =~ /\bport="([^"]+)"/;
my ($proto) = $attrs =~ /\bprotocol="([^"]+)"/;
next unless defined $port && length $port;
$proto = 'tcp' unless defined $proto && length $proto;
push @ports, lc($port) . '/' . lc($proto);
}
return @ports;
}
# The service name to ports map. /etc overrides /usr/lib, so the system tree is
# read first and the administrator's tree overwrites it.
sub load_service_map {
my %map;
for my $dir ('/usr/lib/firewalld/services', '/etc/firewalld/services') {
next unless -d $dir;
my @entries;
if (opendir(my $dh, $dir)) {
@entries = sort grep { /\.xml$/ && -f "$dir/$_" } readdir($dh);
closedir($dh);
}
for my $entry (@entries) {
my @ports = parse_service_xml(slurp("$dir/$entry"));
my $name = $entry;
$name =~ s/\.xml$//;
$map{$name} = \@ports if @ports;
}
}
return \%map;
}
# The zone names sit at column 0; the member lines are indented. firewalld
# decorates the name, as "FedoraWorkstation (default)", and the decoration is
# not part of the name the next firewall-cmd call accepts.
sub parse_active_zones {
my ($out) = @_;
my @zones;
for my $line (split /\n/, $out) {
next unless length _trim($line);
next if index($line, ' ') == 0 || index($line, "\t") == 0;
my $name = _trim($line);
$name =~ s/\s+\([^)]*\)$//;
push @zones, $name;
}
return @zones;
}
# One zone through --info-zone, which answers for the running firewall: the
# first line carries the name, then "key: value" lines carry the target, the
# services and the ports.
sub parse_info_zone {
my ($out) = @_;
my %info = (name => '', target => '', services => [], ports => []);
my $have_name = 0;
for my $line (split /\n/, $out) {
if (!$have_name) {
next unless length _trim($line);
$info{name} = _trim($line);
$have_name = 1;
next;
}
my ($key, $value) = $line =~ /^\s*(\S+):\s*(.*)$/ or next;
if ($key eq 'target') {
$info{target} = lc _trim($value);
}
elsif ($key eq 'services') {
$info{services} = [split ' ', _trim($value)];
}
elsif ($key eq 'ports') {
$info{ports} = [split ' ', _trim($value)];
}
}
return \%info;
}
sub split_listener_address {
my ($local) = @_;
my ($addr, $port);
if ($local =~ /^(.*):(\d+)$/) {
($addr, $port) = ($1, $2);
}
else {
return (undef, undef);
}
# ss writes the scope suffix outside the brackets:
# "[fe80::a7f4:eb68:1d07:aac3]%wlp193s0:3702". The scope strip therefore
# runs before the bracket strips, or the closing bracket survives it.
$addr =~ s/%.*//;
$addr =~ s/^\[//;
$addr =~ s/\]$//;
return ($addr, $port + 0);
}
sub is_loopback_address {
my ($addr) = @_;
return 1 if $addr =~ /^127\./;
return 1 if $addr eq '::1';
return 1 if $addr eq '0:0:0:0:0:0:0:1';
return 0;
}
sub is_wildcard_address {
my ($addr) = @_;
return 1 if $addr eq '' || $addr eq '*' || $addr eq '0.0.0.0' || $addr eq '::';
return 0;
}
# The first process name inside users:(("sshd",pid=1,fd=3)).
sub parse_process_field {
my ($field) = @_;
return '' unless defined $field;
my ($name) = $field =~ /users:\(\("([^"]+)"/;
return defined $name ? $name : '';
}
# Every listening socket, from `ss -tulnp`. A line carries the protocol, the
# state, two queues, the local address and the peer, and, as root, the process.
sub parse_ss_listeners {
my ($out) = @_;
my @listeners;
for my $line (split /\n/, $out) {
my @fields = split ' ', $line;
next unless @fields >= 5;
next unless $fields[1] eq 'LISTEN' || $fields[1] eq 'UNCONN';
my $proto = lc $fields[0];
# tcp6 and udp6 belong to the same check as tcp and udp.
$proto =~ s/\d+$//;
next unless $proto eq 'tcp' || $proto eq 'udp';
my ($addr, $port) = split_listener_address($fields[4]);
next unless defined $port;
my $process = parse_process_field($fields[6]);
push @listeners, {
proto => $proto,
address => $addr,
port => $port,
loopback => is_loopback_address($addr),
process => $process,
};
}
return \@listeners;
}
# firewalld availability: the binary present is availability, and --state
# answers "running" on stdout only for a live daemon; a daemon that is down
# answers on stderr and nothing on stdout, which is "installed but not
# running" rather than "not installed".
sub classify_firewalld {
my ($present, $out) = @_;
return (0, 0) unless $present;
my $state = _trim($out);
return (1, $state eq 'running' ? 1 : 0);
}
sub collect_firewall {
my %fw = (
available => 0, active => 0, default_zone => '', zones => [],
listeners => undef, listeners_error => '',
);
my $present = defined find_exe('firewall-cmd') ? 1 : 0;
($fw{available}, $fw{active})
= classify_firewalld($present, run(['firewall-cmd', '--state'], 5)->{out});
if ($fw{available} && $fw{active}) {
my $p = run(['firewall-cmd', '--get-default-zone'], 5);
$fw{default_zone} = _trim($p->{out}) if $p->{rc} == 0;
my @zone_names = parse_active_zones(run(['firewall-cmd', '--get-active-zones'], 5)->{out});
my $svc_map = load_service_map();
for my $zone (@zone_names) {
my $info = parse_info_zone(run(['firewall-cmd', '--info-zone=' . $zone], 10)->{out});
my %entry = (
name => length $info->{name} ? $info->{name} : $zone,
ports => $info->{ports},
services => $info->{services},
service_ports => [],
target => $info->{target},
);
for my $service (@{ $entry{services} }) {
push @{ $entry{service_ports} }, @{ $svc_map->{$service} // [] };
}
push @{ $fw{zones} }, \%entry;
}
}
if (defined find_exe('ss')) {
my $p = run(['ss', '-tulnp'], 15);
$fw{listeners} = parse_ss_listeners($p->{out}) if $p->{rc} == 0;
$fw{listeners_error} = 'ss failed' if $p->{rc} != 0;
}
return \%fw;
}
# Whether a zone's port list and its services allow a port. A spec is
# "port", "port/proto", or a range "6000-6009/tcp".
sub port_allowed {
my ($allowed, $proto, $port) = @_;
my $specs = $allowed->{$proto} or return 0;
for my $spec (@$specs) {
if ($spec =~ /^(\d+)-(\d+)$/) { return 1 if $port >= $1 && $port <= $2 }
elsif ($spec =~ /^\d+$/) { return 1 if $port == $spec }
}
return 0;
}
# The allowed set across every active zone, keyed by protocol.
sub build_allowed {
my ($zones) = @_;
my %allowed;
for my $zone (@$zones) {
for my $spec (@{ $zone->{ports} // [] }, @{ $zone->{service_ports} // [] }) {
my ($range, $proto) = split m{/}, $spec, 2;
next unless defined $range && length $range;
$proto = 'tcp' unless defined $proto && length $proto;
push @{ $allowed{lc $proto} }, $range;
}
}
return \%allowed;
}
sub evaluate_firewall {
my ($fw) = @_;
my @checks;
my $listeners = $fw->{listeners};
my $known_listeners = defined $listeners ? 1 : 0;
# Exposure: behind a running firewalld a socket is exposed when a zone
# allows its port or a zone's target accepts everything; without a running
# firewall, every non-loopback socket is exposed.
my $any_accept = 0;
for my $zone (@{ $fw->{zones} // [] }) {
$any_accept = 1 if ($zone->{target} // '') eq 'accept';
}
my $allowed = build_allowed($fw->{zones} // []);
my @exposed;
my @filtered;
my @listening_ports; # (proto, port) pairs, loopback included
if ($known_listeners) {
for my $l (@$listeners) {
push @listening_ports, "$l->{proto}:$l->{port}";
if ($l->{loopback}) {
$l->{exposed} = 0;
next;
}
my $is_allowed = port_allowed($allowed, $l->{proto}, $l->{port});
$l->{allowed} = $is_allowed;
if (!$fw->{active} || $is_allowed || $any_accept) {
$l->{exposed} = 1;
push @exposed, $l;
}
else {
$l->{exposed} = 0;
push @filtered, $l;
}
}
}
my $listener_line = sub {
my ($l) = @_;
my $label = $l->{loopback} ? 'local' : $l->{exposed} ? 'exposed' : 'filtered';
my $host = $l->{address} =~ /:/ ? "[$l->{address}]" : $l->{address};
my $proc = length $l->{process} ? " ($l->{process})" : '';
return "$l->{proto} $host:$l->{port} $label$proc";
};
if ($fw->{available} && $fw->{active}) {
push @checks, _check(
id => 'firewall.active', severity => 'pass', weight => 6,
title => 'Firewall active',
message => 'firewalld is running',
);
}
elsif ($fw->{available}) {
if ($known_listeners && @exposed) {
push @checks, _check(
id => 'firewall.active', severity => 'critical', weight => 6,
title => 'Firewall active',
message => 'firewalld is installed but not running, and '
. scalar(@exposed) . ' socket(s) listen on public addresses',
detail => [map { $listener_line->($_) } @exposed],
);
}
elsif ($known_listeners) {
push @checks, _check(
id => 'firewall.active', severity => 'warn', weight => 6,
title => 'Firewall active',
message => 'firewalld is installed but not running; nothing listens on a public address',
);
}
else {
push @checks, _check(
id => 'firewall.active', severity => 'unknown', weight => 6,
title => 'Firewall active',
message => 'firewalld is not running and no listener inventory is available',
);
}
}
else {
if ($known_listeners && @exposed) {
push @checks, _check(
id => 'firewall.active', severity => 'critical', weight => 6,
title => 'Firewall active',
message => 'firewalld is not installed, and '
. scalar(@exposed) . ' socket(s) listen on public addresses',
detail => [map { $listener_line->($_) } @exposed],
);
}
elsif ($known_listeners) {
push @checks, _check(
id => 'firewall.active', severity => 'warn', weight => 6,
title => 'Firewall active',
message => 'firewalld is not installed; nothing listens on a public address',
);
}
else {
push @checks, _check(
id => 'firewall.active', severity => 'unknown', weight => 6,
title => 'Firewall active',
message => 'no firewalld and no listener inventory is available',
);
}
}
# A zone whose target is ACCEPT allows every port, so the rules under it
# decide nothing: the exposure is the design of the zone. No active zone at
# all while firewalld runs is its own answer: unzoned interfaces are not
# filtered by firewalld.
my @accept_zones = grep { ($_->{target} // '') eq 'accept' } @{ $fw->{zones} // [] };
if (!@{ $fw->{zones} // [] }) {
if ($fw->{active}) {
push @checks, _check(
id => 'firewall.zone_target', severity => 'unknown', weight => 4,
title => 'Zone targets',
message => 'firewalld is running but reports no active zone: '
. 'unzoned interfaces are not filtered',
);
}
}
elsif (@accept_zones) {
push @checks, _check(
id => 'firewall.zone_target', severity => 'warn', weight => 4,
title => 'Zone targets',
message => 'zone target is ACCEPT, so every listening port is reachable: '
. join(', ', map { $_->{name} } @accept_zones),
);
}
else {
push @checks, _check(
id => 'firewall.zone_target', severity => 'pass', weight => 4,
title => 'Zone targets',
message => 'every active zone filters by default',
);
}
if ($known_listeners) {
push @checks, _check(
id => 'firewall.listeners', severity => 'info', weight => 0,
title => 'Listeners',
message => scalar(@$listeners) . ' listening socket(s): '
. scalar(@exposed) . ' exposed, '
. (scalar(@$listeners) - scalar(@exposed)) . ' local or filtered',
detail => [map { $listener_line->($_) } @$listeners],
);
}
else {
push @checks, _check(
id => 'firewall.listeners', severity => 'unknown', weight => 0,
title => 'Listeners',
message => 'no listener inventory: ss is not installed'
. (length $fw->{listeners_error} ? " ($fw->{listeners_error})" : ''),
);
}
# A port a zone allows with nothing behind it is surface in waiting. A
# spec with not one listener anywhere in its span is the stale entry.
if ($fw->{active} && $known_listeners) {
my @stale;
for my $proto (sort keys %$allowed) {
for my $range (@{ $allowed->{$proto} }) {
my ($lo, $hi) = $range =~ /^(\d+)-(\d+)$/ ? ($1, $2) : ($range, $range);
next unless $lo =~ /^\d+$/ && $hi =~ /^\d+$/;
my $used = 0;
for my $key (@listening_ports) {
my ($lproto, $lport) = split /:/, $key, 2;
next unless $lproto eq $proto;
if ($lport >= $lo && $lport <= $hi) { $used = 1; last }
}
push @stale, "$proto/$range" unless $used;
}
}
if (@stale) {
my @detail = sort @stale;
$#detail = 49 if $#detail > 49;
push @checks, _check(
id => 'firewall.allowed_no_listener', severity => 'warn', weight => 0,
title => 'Open without listener',
message => scalar(@stale) . ' allowed port(s) or range(s) with nothing listening',
detail => \@detail,
);
}
else {
push @checks, _check(
id => 'firewall.allowed_no_listener', severity => 'pass', weight => 0,
title => 'Open without listener',
message => 'every allowed port has a listener',
);
}
}
return @checks;
}
# ---------------------------------------------------------------------------
# Section: SELinux
# ---------------------------------------------------------------------------
sub parse_selinux_config {
my ($text) = @_;
for my $line (split /\n/, $text) {
$line = _trim($line);
next unless length $line;
next if index($line, '#') == 0;
my ($key, $value) = split /=/, $line, 2;
next unless defined $key && defined $value;
return lc _trim($value) if _trim($key) eq 'SELINUX';
}
return '';
}
sub collect_selinux {
my %sel = (available => 0, runtime => '', configured => '');
my $out = _trim(run(['getenforce'], 5)->{out});
if (length $out) {
$sel{available} = 1;
$sel{runtime} = $out;
}
if (can_read_file('/etc/selinux/config')) {
$sel{configured} = parse_selinux_config(slurp('/etc/selinux/config'));
}
return \%sel;
}
sub evaluate_selinux {
my ($sel) = @_;
return () unless $sel->{available};
my @checks;
my $runtime = $sel->{runtime};
if ($runtime eq 'Enforcing') {
push @checks, _check(
id => 'selinux.runtime', severity => 'pass', weight => 6,
title => 'SELinux runtime', message => 'enforcing',
);
}
elsif ($runtime eq 'Permissive') {
push @checks, _check(
id => 'selinux.runtime', severity => 'warn', weight => 6,
title => 'SELinux runtime',
message => 'permissive: violations are logged, not blocked',
);
}
else {
push @checks, _check(
id => 'selinux.runtime', severity => 'critical', weight => 6,
title => 'SELinux runtime', message => 'disabled',
);
}
my $configured = $sel->{configured};
if (!length $configured) {
my $why = -e '/etc/selinux/config' ? 'is unreadable by this user' : 'does not exist';
push @checks, _check(
id => 'selinux.config', severity => 'unknown', weight => 2,
title => 'SELinux config',
message => "/etc/selinux/config $why",
);
return @checks;
}
my $runtime_lc = lc $runtime;
if ($configured eq $runtime_lc) {
push @checks, _check(
id => 'selinux.config', severity => 'pass', weight => 2,
title => 'SELinux config',
message => "the next boot keeps $configured",
);
}
elsif ($runtime_lc eq 'enforcing' && $configured ne 'enforcing') {
push @checks, _check(
id => 'selinux.config', severity => 'warn', weight => 2,
title => 'SELinux config',
message => "enforcing now, but /etc/selinux/config says $configured: the next boot downgrades",
);
}
elsif ($configured eq 'enforcing' && $runtime_lc eq 'permissive') {
push @checks, _check(
id => 'selinux.config', severity => 'info', weight => 2,
title => 'SELinux config',
message => 'permissive now; enforcing at the next boot',
);
}
else {
push @checks, _check(
id => 'selinux.config', severity => 'warn', weight => 2,
title => 'SELinux config',
message => "runtime $runtime_lc, configuration $configured",
);
}
return @checks;
}
# ---------------------------------------------------------------------------
# Section: Updates
# ---------------------------------------------------------------------------
# An advisory line names an id, the word security, and a package; header and
# progress lines do not.
sub count_security_updates {
my ($out) = @_;
my $count = 0;
for my $line (split /\n/, $out) {
my @fields = split ' ', $line;
next unless @fields >= 3;
next unless $fields[0] =~ /[-:]/;
next unless $fields[1] =~ /^security(?:\/|$)/;
$count++;
}
return $count;
}
sub parse_automatic_conf {
my ($text) = @_;
for my $line (split /\n/, $text) {
$line = _trim($line);
next unless length $line;
next if index($line, '#') == 0;
next unless index($line, '=') >= 0;
my ($key, $value) = split /=/, $line, 2;
next unless _trim($key) eq 'apply_updates';
return lc _trim($value);
}
return '';
}
sub rpm_installed {
my ($name) = @_;
return run(['rpm', '-q', $name], 15)->{rc} == 0 ? 1 : 0;
}
# The first unit whose is-enabled answer names a real state. "not-found" is
# no answer, and "alias" reports the shape of the unit file rather than its
# enablement: on a dnf5 host dnf-automatic.timer is an alias whose target,
# dnf5-automatic.timer, carries the answer.
sub pick_timer_unit {
my ($states) = @_;
for my $unit (qw(dnf5-automatic.timer dnf-automatic-install.timer dnf-automatic.timer)) {
my $state = $states->{$unit} // '';
return ($unit, $state) if grep { $state eq $_ }
qw(enabled enabled-runtime disabled static indirect);
}
return ('', '');
}
sub collect_updates {
my %up = (
security_pending => undef, pending_error => '',
timer => 'unknown', timer_name => '',
apply_updates => undef, apply_updates_defaulted => 0,
running_kernel => '', newest_kernel => '', reboot_required => undef,
);
if (defined find_exe('dnf')) {
my $p = run(['dnf', '-q', '--cacheonly', 'updateinfo', 'list', 'available', '--security'], 120);
if ($p->{rc} == 0) {
$up{security_pending} = count_security_updates($p->{out});
}
else {
my $err = _trim((split /\n/, $p->{err})[0] // '');
$up{pending_error} = length $err ? $err : 'dnf failed';
}
}
if (defined find_exe('systemctl')) {
my %states;
for my $unit ('dnf5-automatic.timer', 'dnf-automatic-install.timer', 'dnf-automatic.timer') {
my $p = run(['systemctl', 'is-enabled', $unit, '--no-pager'], 10);
# is-enabled answers "disabled" with exit status 1, so the word and
# not the status decides.
$states{$unit} = _trim($p->{out});
}
($up{timer_name}, $up{timer}) = pick_timer_unit(\%states);
if (!length $up{timer_name}) {
$up{timer} = rpm_installed('dnf-automatic') || rpm_installed('dnf5-plugin-automatic')
? 'disabled' : 'missing';
}
}
# dnf5 takes its defaults from /usr/share and applies the host overrides
# from /etc/dnf/automatic.conf on top; dnf4 ships /etc/dnf/automatic.conf
# with the key commented out. An absent key therefore means the documented
# default: no, download only.
for my $conf ('/etc/dnf/automatic.conf', '/usr/share/dnf5/dnf5-plugins/automatic.conf') {
next unless can_read_file($conf);
my $value = parse_automatic_conf(slurp($conf));
$up{apply_updates} = length $value ? $value : 'no';
$up{apply_updates_defaulted} = length $value ? 0 : 1;
last;
}
my $running = _trim(run(['uname', '-r'], 5)->{out});
if (length $running && defined find_exe('rpm')) {
my $p = run(['rpm', '-q', '--qf', '%{VERSION}-%{RELEASE}\n', 'kernel'], 30);
if ($p->{rc} == 0) {
my @installed = grep { length } split /\n/, $p->{out};
my ($newest) = grep { rpmvercmp($_, $running) > 0 } @installed;
$up{running_kernel} = $running;
$up{reboot_required} = defined $newest ? 1 : 0;
if (defined $newest) {
for my $version (@installed) {
$up{newest_kernel} = $version
if rpmvercmp($version, $up{newest_kernel} || $version) >= 0;
}
}
}
}
return \%up;
}
sub evaluate_updates {
my ($up) = @_;
my @checks;
if (!defined $up->{security_pending}) {
push @checks, _check(
id => 'updates.security_pending', severity => 'unknown', weight => 6,
title => 'Security updates',
message => 'pending security updates could not be counted'
. (length $up->{pending_error} ? " ($up->{pending_error})" : '')
. ': the metadata must be in dnf\'s cache',
);
}
elsif ($up->{security_pending} == 0) {
push @checks, _check(
id => 'updates.security_pending', severity => 'pass', weight => 6,
title => 'Security updates',
message => 'none pending',
);
}
else {
push @checks, _check(
id => 'updates.security_pending', severity => 'warn', weight => 6,
title => 'Security updates',
message => "$up->{security_pending} pending",
);
}
my $timer = $up->{timer};
if ($timer eq 'unknown') {
push @checks, _check(
id => 'updates.automatic_timer', severity => 'unknown', weight => 4,
title => 'Automatic updates',
message => 'systemctl is not available',
);
}
elsif ($timer eq 'enabled' || $timer eq 'enabled-runtime') {
push @checks, _check(
id => 'updates.automatic_timer', severity => 'pass', weight => 4,
title => 'Automatic updates',
message => "$up->{timer_name} is $timer",
);
}
elsif ($timer eq 'missing') {
push @checks, _check(
id => 'updates.automatic_timer', severity => 'warn', weight => 4,
title => 'Automatic updates',
message => 'dnf-automatic is not installed: nothing applies updates unattended',
);
}
else {
push @checks, _check(
id => 'updates.automatic_timer', severity => 'warn', weight => 4,
title => 'Automatic updates',
message => $up->{timer_name} ? "$up->{timer_name} is $timer"
: $timer eq 'missing'
? 'dnf-automatic is not installed: nothing applies updates unattended'
: 'no dnf-automatic timer is enabled',
);
}
my $apply = $up->{apply_updates};
if (!defined $apply) {
push @checks, _check(
id => 'updates.apply_updates', severity => 'unknown', weight => 2,
title => 'Apply updates',
message => 'no dnf-automatic configuration could be read',
);
}
elsif ($apply eq 'yes') {
push @checks, _check(
id => 'updates.apply_updates', severity => 'pass', weight => 2,
title => 'Apply updates',
message => 'apply_updates = yes',
);
}
else {
push @checks, _check(
id => 'updates.apply_updates', severity => 'warn', weight => 2,
title => 'Apply updates',
message => 'apply_updates = ' . $apply
. ($up->{apply_updates_defaulted} ? ' (the default)' : '')
. ': downloads without installing',
);
}
my $reboot = $up->{reboot_required};
if (!defined $reboot) {
push @checks, _check(
id => 'updates.reboot_required', severity => 'unknown', weight => 2,
title => 'Reboot required',
message => 'the installed kernels could not be compared with the running one',
);
}
elsif ($reboot) {
push @checks, _check(
id => 'updates.reboot_required', severity => 'warn', weight => 2,
title => 'Reboot required',
message => "running $up->{running_kernel}"
. (length $up->{newest_kernel} ? ", installed $up->{newest_kernel}" : '')
. ': the fixes are on disk, not in memory',
);
}
else {
push @checks, _check(
id => 'updates.reboot_required', severity => 'pass', weight => 2,
title => 'Reboot required',
message => "running kernel $up->{running_kernel} is the newest installed",
);
}
return @checks;
}
# ---------------------------------------------------------------------------
# Section: Accounts
# ---------------------------------------------------------------------------
# A passwd line: name:passwd:uid:gid:gecos:home:shell.
sub parse_passwd {
my ($lines) = @_;
my @users;
for my $line (@$lines) {
next unless length $line;
my @fields = split /:/, $line, 7;
next unless @fields == 7;
next unless $fields[2] =~ /^\d+$/;
push @users, {
name => $fields[0],
uid => $fields[2] + 0,
shell => $fields[6],
};
}
return \@users;
}
# A shadow line: name:hash:and the ageing fields. The hash is the second field
# only; an empty hash is an account that accepts no password and any password
# at once, while a leading ! or a * locks the account.
sub empty_password_users {
my ($lines) = @_;
my @names;
for my $line (@$lines) {
next unless length $line;
my @fields = split /:/, $line, 3;
next unless @fields >= 2;
push @names, $fields[0] if $fields[1] eq '';
}
return \@names;
}
# The principals a sudoers tree grants NOPASSWD to. Comment lines are skipped,
# which also skips the #includedir directive; the directory is read explicitly.
sub parse_sudoers_text {
my ($text) = @_;
my @principals;
for my $line (split /\n/, $text) {
$line = _trim($line);
next unless length $line;
next if index($line, '#') == 0;
next unless $line =~ /NOPASSWD\s*:/;
my ($principal) = split ' ', $line, 2;
push @principals, $principal if length $principal;
}
return @principals;
}
sub collect_accounts {
my %acc = (
uid_zero => undef, empty_passwords => undef, nopasswd => undef,
human_users => [], root_authorized_keys => undef,
);
if (can_read_file('/etc/passwd')) {
my $users = parse_passwd(read_lines('/etc/passwd'));
my @root_like = map { $_->{name} } grep { $_->{uid} == 0 } @$users;
$acc{uid_zero} = \@root_like;
$acc{human_users} = [
map { "$_->{name} ($_->{uid})" }
grep { $_->{uid} >= 1000 && $_->{shell} !~ /(?:nologin|false)$/ }
@$users
];
}
if (can_read_file('/etc/shadow')) {
$acc{empty_passwords} = empty_password_users(read_lines('/etc/shadow'));
}
if (can_read_file('/etc/sudoers')) {
my %principals;
for my $principal (parse_sudoers_text(slurp('/etc/sudoers'))) {
$principals{$principal} = 1;
}
if (opendir(my $dh, '/etc/sudoers.d')) {
for my $entry (sort grep { !/^[.]/ && -f "/etc/sudoers.d/$_" } readdir($dh)) {
for my $principal (parse_sudoers_text(slurp("/etc/sudoers.d/$entry"))) {
$principals{$principal} = 1;
}
}
closedir($dh);
}
$acc{nopasswd} = [sort keys %principals];
}
if (can_read_file('/root/.ssh/authorized_keys')) {
my @keys = grep { length && index($_, '#') != 0 } @{ read_lines('/root/.ssh/authorized_keys') };
$acc{root_authorized_keys} = scalar @keys;
}
return \%acc;
}
sub evaluate_accounts {
my ($acc) = @_;
my @checks;
if (!defined $acc->{uid_zero}) {
push @checks, _check(
id => 'accounts.uid_zero', severity => 'unknown', weight => 4,
title => 'UID 0 accounts', message => '/etc/passwd is unreadable',
);
}
elsif (@{ $acc->{uid_zero} } > 1) {
push @checks, _check(
id => 'accounts.uid_zero', severity => 'critical', weight => 4,
title => 'UID 0 accounts',
message => 'more than one account holds UID 0: ' . join(', ', @{ $acc->{uid_zero} }),
);
}
else {
push @checks, _check(
id => 'accounts.uid_zero', severity => 'pass', weight => 4,
title => 'UID 0 accounts',
message => 'root is the only account with UID 0',
);
}
if (!defined $acc->{empty_passwords}) {
push @checks, _check(
id => 'accounts.empty_passwords', severity => 'unknown', weight => 4,
title => 'Empty passwords',
message => '/etc/shadow is readable by root only',
);
}
elsif (@{ $acc->{empty_passwords} }) {
push @checks, _check(
id => 'accounts.empty_passwords', severity => 'critical', weight => 4,
title => 'Empty passwords',
message => 'accounts with an empty password field: '
. join(', ', @{ $acc->{empty_passwords} }),
);
}
else {
push @checks, _check(
id => 'accounts.empty_passwords', severity => 'pass', weight => 4,
title => 'Empty passwords',
message => 'no account has an empty password field',
);
}
if (!defined $acc->{nopasswd}) {
push @checks, _check(
id => 'accounts.nopasswd_sudo', severity => 'unknown', weight => 2,
title => 'NOPASSWD sudo',
message => 'the sudoers files are readable by root only',
);
}
elsif (@{ $acc->{nopasswd} }) {
push @checks, _check(
id => 'accounts.nopasswd_sudo', severity => 'warn', weight => 2,
title => 'NOPASSWD sudo',
message => 'NOPASSWD granted to: ' . join(', ', @{ $acc->{nopasswd} }),
);
}
else {
push @checks, _check(
id => 'accounts.nopasswd_sudo', severity => 'pass', weight => 2,
title => 'NOPASSWD sudo',
message => 'every sudo grant asks for a password',
);
}
my $humans = $acc->{human_users} // [];
my @keys = ($acc->{root_authorized_keys});
my @parts;
push @parts, 'human users: ' . (@$humans ? join(', ', @$humans) : 'none');
push @parts, defined $keys[0] ? "$keys[0] root authorised key(s)"
: 'root authorised keys unreadable';
push @checks, _check(
id => 'accounts.human_users', severity => 'info', weight => 0,
title => 'Human users', message => join('; ', @parts),
);
return @checks;
}
# ---------------------------------------------------------------------------
# Section: Services
# ---------------------------------------------------------------------------
# One line of `systemctl --failed --no-legend --plain`: the unit name, the
# load state, the active state, the sub state and the description. The state
# the report names is the active one, which is the failed one here.
sub parse_failed_units {
my ($out) = @_;
my @units;
for my $line (split /\n/, _trim($out)) {
next unless length $line;
my @fields = split ' ', $line, 5;
next if @fields < 3;
push @units, { unit => $fields[0], state => $fields[2] };
}
return \@units;
}
sub collect_services {
return undef unless defined find_exe('systemctl');
# --plain keeps the bullet prefix out of the field layout.
my $p = run(['systemctl', '--failed', '--no-legend', '--no-pager', '--plain'], 10);
return undef if $p->{rc} != 0 && !length _trim($p->{out});
return parse_failed_units($p->{out});
}
sub evaluate_services {
my ($units) = @_;
if (!defined $units) {
return _check(
id => 'services.failed_units', severity => 'unknown', weight => 4,
title => 'Failed units', message => 'systemctl is not available',
);
}
return _check(
id => 'services.failed_units', severity => 'pass', weight => 4,
title => 'Failed units', message => 'none',
) unless @$units;
return _check(
id => 'services.failed_units', severity => 'warn', weight => 4,
title => 'Failed units',
message => scalar(@$units) . ' unit(s) in a failed state',
detail => [map { "$_->{unit} ($_->{state})" } @$units],
);
}
# ---------------------------------------------------------------------------
# Section: Kernel hardening
# ---------------------------------------------------------------------------
# Each entry: the check id, the title, the sysctl path under /proc/sys, and the
# verdict as a function of the value.
my @SYSCTL_CHECKS = (
{
id => 'kernel.kptr_restrict',
title => 'Kernel pointers',
path => '/proc/sys/kernel/kptr_restrict',
ok => sub { $_[0] >= 1 },
weak => 'kernel pointers are visible to unprivileged users',
},
{
id => 'kernel.dmesg_restrict',
title => 'dmesg restriction',
path => '/proc/sys/kernel/dmesg_restrict',
ok => sub { $_[0] >= 1 },
weak => 'the kernel ring buffer is readable unprivileged',
},
{
id => 'kernel.unprivileged_bpf_disabled',
title => 'Unprivileged BPF',
path => '/proc/sys/kernel/unprivileged_bpf_disabled',
ok => sub { $_[0] >= 1 },
weak => 'unprivileged code may load BPF programs',
},
{
id => 'kernel.yama_ptrace_scope',
title => 'ptrace scope',
path => '/proc/sys/kernel/yama/ptrace_scope',
ok => sub { $_[0] >= 1 },
weak => 'any process may ptrace its peers',
},
{
id => 'kernel.fs_protected',
title => 'Protected links',
path => '/proc/sys/fs/protected_symlinks',
paths => [qw(
/proc/sys/fs/protected_symlinks
/proc/sys/fs/protected_hardlinks
/proc/sys/fs/protected_fifos
/proc/sys/fs/protected_regular
)],
ok => sub { $_[0] >= 1 },
weak => 'sticky directory races are not fully protected against',
},
{
id => 'kernel.tcp_syncookies',
title => 'TCP syncookies',
path => '/proc/sys/net/ipv4/tcp_syncookies',
ok => sub { $_[0] == 1 },
weak => 'syn flood protection is off',
},
{
id => 'kernel.randomize_va_space',
title => 'Address space layout',
path => '/proc/sys/kernel/randomize_va_space',
ok => sub { $_[0] >= 2 },
weak => 'address space randomisation is reduced',
},
);
sub collect_kernel {
my %values;
my %seen_path;
for my $entry (@SYSCTL_CHECKS) {
my @paths = $entry->{paths} ? @{ $entry->{paths} } : ($entry->{path});
for my $path (@paths) {
next if $seen_path{$path}++;
my $value = read_file($path);
$values{$path} = $value if length $value;
}
}
return \%values;
}
sub evaluate_kernel {
my ($values) = @_;
my @checks;
for my $entry (@SYSCTL_CHECKS) {
my @paths = $entry->{paths} ? @{ $entry->{paths} } : ($entry->{path});
my @readings;
for my $path (@paths) {
push @readings, $values->{$path} if exists $values->{$path};
}
if (!@readings) {
push @checks, _check(
id => $entry->{id}, severity => 'unknown', weight => 2,
title => $entry->{title},
message => 'the sysctl is not exposed by this kernel',
);
next;
}
my @bad = grep { $_ !~ /^\d+$/ || !$entry->{ok}->($_ + 0) } @readings;
if (@bad) {
push @checks, _check(
id => $entry->{id}, severity => 'warn', weight => 2,
title => $entry->{title},
message => join(', ', map { _trim($_) } @readings) . ": $entry->{weak}",
);
}
else {
push @checks, _check(
id => $entry->{id}, severity => 'pass', weight => 2,
title => $entry->{title},
message => join(', ', map { _trim($_) } @readings),
);
}
}
return @checks;
}
# ---------------------------------------------------------------------------
# Section: File system scan, opt-in through --deep
# ---------------------------------------------------------------------------
# Directories anyone can write into, where a planted SUID binary waits for a
# victim. /dev is a separate mount, so -xdev leaves /dev/shm out already.
my $STAGING_RE = qr{^/(?:tmp|var/tmp|home|root|srv)/};
# A slot holds a list only when its scan completed: a find that failed or was
# interrupted leaves undef behind, which evaluates to unknown rather than to a
# clean pass.
sub collect_files {
my %files = (
suid => undef, world_writable_files => undef,
world_writable_dirs => undef, unowned => undef,
);
return \%files unless defined find_exe('find');
my @scans = (
['suid', ['/', '-xdev', '-type', 'f', '-perm', '-4000']],
['world_writable_files', ['/', '-xdev', '-type', 'f', '-perm', '-0002']],
['world_writable_dirs', ['/', '-xdev', '-type', 'd', '-perm', '-0002', '!', '-perm', '-1000']],
['unowned', ['/', '-xdev', '(', '-nouser', '-o', '-nogroup', ')', '-print']],
);
for my $scan (@scans) {
my ($key, $args) = @$scan;
my $p = run(['find', @$args], 300);
next unless $p->{rc} == 0;
$files{$key} = [grep { length } split /\n/, _trim($p->{out})];
}
return \%files;
}
sub evaluate_files {
my ($files) = @_;
my @checks;
my $why = defined find_exe('find')
? 'the scan did not complete' : 'find is not installed';
if (!defined $files->{suid}) {
push @checks, _check(
id => 'files.suid_staging', severity => 'unknown', weight => 4,
title => 'SUID staging', message => $why,
);
}
else {
my @staging = grep { $_ =~ $STAGING_RE } @{ $files->{suid} };
if (@staging) {
push @checks, _check(
id => 'files.suid_staging', severity => 'critical', weight => 4,
title => 'SUID staging',
message => 'SUID binaries under user writable or temporary directories',
detail => [map { "$_ (SUID)" } @{ $files->{suid} }],
);
}
else {
my @inventory = map { "$_ (SUID)" } @{ $files->{suid} };
$#inventory = 49 if $#inventory > 49;
push @checks, _check(
id => 'files.suid_staging', severity => 'pass', weight => 4,
title => 'SUID staging',
message => 'none of the ' . scalar(@{ $files->{suid} })
. ' SUID binaries sits in a staging directory',
detail => \@inventory,
);
}
}
if (!defined $files->{world_writable_files} || !defined $files->{world_writable_dirs}) {
push @checks, _check(
id => 'files.world_writable', severity => 'unknown', weight => 4,
title => 'World writable', message => $why,
);
}
elsif (scalar @{ $files->{world_writable_files} } || scalar @{ $files->{world_writable_dirs} }) {
my @parts;
push @parts, scalar(@{ $files->{world_writable_files} }) . ' world writable file(s)'
if @{ $files->{world_writable_files} };
push @parts, scalar(@{ $files->{world_writable_dirs} })
. ' world writable directory(ies) without the sticky bit'
if @{ $files->{world_writable_dirs} };
push @checks, _check(
id => 'files.world_writable', severity => 'warn', weight => 4,
title => 'World writable',
message => join(', ', @parts),
detail => [
(map { "$_ (file)" } @{ $files->{world_writable_files} }),
(map { "$_ (directory)" } @{ $files->{world_writable_dirs} }),
],
);
}
else {
push @checks, _check(
id => 'files.world_writable', severity => 'pass', weight => 4,
title => 'World writable',
message => 'no world writable files, no stickyless directories',
);
}
if (!defined $files->{unowned}) {
push @checks, _check(
id => 'files.unowned', severity => 'unknown', weight => 2,
title => 'Unowned files', message => $why,
);
}
elsif (@{ $files->{unowned} }) {
push @checks, _check(
id => 'files.unowned', severity => 'warn', weight => 2,
title => 'Unowned files',
message => scalar(@{ $files->{unowned} })
. ' file(s) with no owning user or group, the residue of a deleted account',
detail => [ @{ $files->{unowned} } ],
);
}
else {
push @checks, _check(
id => 'files.unowned', severity => 'pass', weight => 2,
title => 'Unowned files',
message => 'every file has an owning user and group',
);
}
return @checks;
}
# ---------------------------------------------------------------------------
# Grade
# ---------------------------------------------------------------------------
# Every weighted check counts its weight in full on a pass, half on a warning
# and nothing on a critical. An unknown check leaves the pool, and the score is
# renormalised against the weights that remain, so missing tooling never reads
# as a security failure. All weights are even, so half is a whole number.
sub compute_grade {
my ($checks) = @_;
my ($earned, $possible) = (0, 0);
my (%breakdown, %maxima, %sections, %skipped);
for my $id (sort keys %$checks) {
my $check = $checks->{$id};
my $weight = $check->{weight} // 0;
next unless $weight > 0;
my $severity = $check->{severity};
if ($severity eq 'unknown') {
$skipped{$id} = 1;
next;
}
my $points = $severity eq 'pass' ? $weight
: $severity eq 'warn' ? $weight / 2
: 0;
$breakdown{$id} = $points;
$maxima{$id} = $weight;
my $section = $check->{section};
$sections{$section}{earned} += $points;
$sections{$section}{possible} += $weight;
$earned += $points;
$possible += $weight;
}
if ($possible <= 0) {
return {
score => undef, max_score => 100, grade => 'N/A',
breakdown => \%breakdown, maxima => \%maxima,
sections => \%sections, skipped => \%skipped,
};
}
my $score = sprintf('%.0f', 100 * $earned / $possible);
my $letter = $score >= 90 ? 'A' : $score >= 80 ? 'B' : $score >= 70 ? 'C'
: $score >= 60 ? 'D' : 'F';
return {
score => $score + 0, max_score => 100, grade => $letter,
breakdown => \%breakdown, maxima => \%maxima,
sections => \%sections, skipped => \%skipped,
};
}
sub findings_of {
my ($checks) = @_;
my @order = (critical => 0, warn => 1);
my %rank = @order;
my @findings = grep { $_->{severity} eq 'critical' || $_->{severity} eq 'warn' }
values %$checks;
return [
sort {
($rank{ $a->{severity} } // 9) <=> ($rank{ $b->{severity} } // 9)
|| $a->{id} cmp $b->{id}
} @findings
];
}
# ---------------------------------------------------------------------------
# Human-readable report
# ---------------------------------------------------------------------------
my %SEVERITY_MARKS = (
pass => ['✓', $GREEN],
warn => ['!', $YELLOW],
critical => ['✗', $RED],
unknown => ['·', $DIM],
info => ['·', $DIM],
);
sub section_header {
my ($title) = @_;
print "\n${BOLD}── $title ──$RESET\n";
return;
}
sub kv {
my ($label, $value) = @_;
printf " %-22s %s\n", $label, $value;
return;
}
sub print_check {
my ($check) = @_;
my $mark = $SEVERITY_MARKS{ $check->{severity} } // ['·', ''];
my ($glyph, $colour) = @$mark;
printf " %s%s%s %s%-24s%s %s\n",
$colour, $glyph, $RESET, $BOLD, $check->{title}, $RESET, $check->{message};
for my $line (@{ $check->{detail} // [] }) {
my $display = length($line) > 100 ? substr($line, 0, 100) . "…" : $line;
print " ${DIM}$display$RESET\n";
}
return;
}
sub print_report {
my ($data, $strict) = @_;
my $grade = $data->{grade} // {};
my $letter = $grade->{grade} // '?';
my $grade_colour = $GRADE_COLOURS{$letter} // $RESET;
my $score_text = !defined $grade->{score} ? 'not assessed'
: '(' . $grade->{score} . '/' . $grade->{max_score} . ')';
my @present = grep { exists $data->{sections}{$_} } @ALL_SECTIONS;
my %position;
$position{ $present[$_] } = $_ for 0 .. $#present;
my $bar = '═' x $WIDTH;
print "\n";
print "${BOLD}$bar$RESET\n";
print " ${BOLD}Security Audit v$VERSION$RESET\n";
print "${BOLD}$bar$RESET\n";
kv('Hostname', $data->{hostname} // 'unknown');
my @now = localtime(time());
printf " %-22s %04d-%02d-%02d %02d:%02d:%02d\n", 'Date',
$now[5] + 1900, $now[4] + 1, $now[3], $now[2], $now[1], $now[0];
kv('Mode', ($data->{root} ? 'root' : 'unprivileged')
. ($data->{deep} ? ', deep scan' : ''));
kv('Grade', "$grade_colour$BOLD$letter$RESET $score_text");
for my $section (@present) {
section_header(($position{$section} + 1) . '. ' . ($SECTION_TITLES{$section} // $section));
my $section_data = $data->{sections}{$section};
if ($section eq 'ssh' && $section_data->{available}) {
kv('Source', $section_data->{source} eq 'effective'
? 'sshd -T (the running daemon)'
: 'the configuration files (run as root for sshd -T)');
}
for my $check (@{ $data->{order} // [] }) {
next unless $check->{section} eq $section;
print_check($check);
}
my $agg = $grade->{sections}{$section};
if ($agg && $agg->{possible} > 0) {
my $cells = $agg->{earned};
my $row = ('█' x $cells) . ('░' x ($agg->{possible} - $cells));
printf " ${DIM}%-22s %s %d/%d$RESET\n",
'section score', $row, $agg->{earned}, $agg->{possible};
}
}
my $findings = $data->{findings} // [];
section_header((scalar(@present) + 1) . '. Findings');
if (!@$findings) {
print " ${GREEN}Nothing to act on; every check passed.$RESET\n";
}
for my $check (@$findings) {
my $is_critical = $check->{severity} eq 'critical';
printf " %s%s%s %-9s %s: %s\n",
$is_critical ? $RED : $YELLOW, $is_critical ? '✗' : '!', $RESET,
uc($check->{severity}), $check->{id}, $check->{message};
}
section_header((scalar(@present) + 2) . '. Score');
my @skipped = sort keys %{ $grade->{skipped} // {} };
if (!defined $grade->{score}) {
print " ${DIM}Nothing was assessable on this machine, so no score is given.$RESET\n";
}
else {
for my $section (@present) {
my $agg = $grade->{sections}{$section};
next unless $agg && $agg->{possible} > 0;
my $cells = $agg->{earned};
my $row = ('█' x $cells) . ('░' x ($agg->{possible} - $cells));
printf " %-22s %s %d/%d\n",
($SECTION_TITLES{$section} // $section), $row,
$agg->{earned}, $agg->{possible};
}
}
if (@skipped) {
print " ${DIM}Not assessed (the source did not answer, the score renormalised):$RESET\n";
print " ${DIM}" . join(', ', @skipped) . "$RESET\n";
}
my $criticals = scalar grep { $_->{severity} eq 'critical' } @$findings;
my $warnings = scalar @$findings - $criticals;
print "\n";
print "${BOLD}$bar$RESET\n";
if ($criticals) {
print " ${BOLD}Result: ${RED}FAILED$RESET ${BOLD}$criticals critical finding(s)"
. ($warnings ? ", $warnings warning(s)" : '') . "$RESET\n";
}
elsif ($warnings && $strict) {
print " ${BOLD}Result: ${YELLOW}FAILED (strict)$RESET ${BOLD}"
. "$warnings warning(s), and --strict fails on warnings$RESET\n";
}
else {
print " ${BOLD}Result: ${GREEN}PASSED$RESET ${BOLD}"
. ($warnings ? "$warnings warning(s)" : 'no findings')
. ($warnings ? ' (use --strict to fail on warnings)' : '') . "$RESET\n";
}
print "${BOLD}$bar$RESET\n";
print "\n";
return;
}
# ---------------------------------------------------------------------------
# Collection
# ---------------------------------------------------------------------------
# The sections are read one after another: each is a handful of cheap commands,
# and the only slow one, the deep file scan, is opt-in.
sub collect_all {
my ($sections, $deep) = @_;
my %data = (
version => $VERSION,
timestamp => timestamp_fields(time()),
hostname => hostname_of(),
root => is_root(),
deep => $deep ? 1 : 0,
sections => {},
);
my (@checks, @sections_order);
my %gather = (
ssh => sub {
my $data = collect_ssh();
return ($data, [evaluate_ssh($data)]);
},
firewall => sub {
my $data = collect_firewall();
return ($data, [evaluate_firewall($data)]);
},
selinux => sub {
my $data = collect_selinux();
return ($data, [evaluate_selinux($data)]);
},
updates => sub {
my $data = collect_updates();
return ($data, [evaluate_updates($data)]);
},
accounts => sub {
my $data = collect_accounts();
return ($data, [evaluate_accounts($data)]);
},
services => sub {
my $units = collect_services();
return ({ failed_units => $units }, [evaluate_services($units)]);
},
kernel => sub {
my $values = collect_kernel();
return ({ values => $values }, [evaluate_kernel($values)]);
},
files => sub {
my $files = collect_files();
return ({ scan => $files }, [evaluate_files($files)]);
},
);
for my $section (@$sections) {
_status('Auditing ' . ($SECTION_TITLES{$section} // $section));
my ($section_data, $checks) = $gather{$section}->();
for my $check (@$checks) {
$check->{section} = $section;
push @checks, $check;
}
if ($section eq 'services') {
$data{sections}{$section} = { failed_units => $section_data->{failed_units} };
}
elsif ($section eq 'kernel') {
$data{sections}{$section} = { values => $section_data->{values} };
}
else {
$data{sections}{$section} = $section_data;
}
push @sections_order, $section;
_status_done();
}
my %by_id = map { $_->{id} => $_ } @checks;
$data{sections} = { map { $_ => $data{sections}{$_} } @sections_order };
$data{order} = \@checks;
$data{checks} = \%by_id;
$data{grade} = compute_grade(\%by_id);
$data{findings} = findings_of(\%by_id);
return \%data;
}
# ---------------------------------------------------------------------------
# Command line
# ---------------------------------------------------------------------------
# The section list to run: unknown names and files without --deep are usage
# errors, and a repeated name runs once.
sub resolve_sections {
my ($list, $deep) = @_;
if (length $list) {
my @requested = map { lc _trim($_) } split /,/, $list;
my @unknown = grep {
my $name = $_;
!grep { $_ eq $name } @ALL_SECTIONS;
} @requested;
return (undef, 'unknown section(s): ' . join(', ', @unknown)) if @unknown;
my %seen;
my @sections = grep { !$seen{$_}++ } @requested;
return (undef, 'section files requires --deep')
if !$deep && grep { $_ eq 'files' } @sections;
push @sections, 'files' if $deep && !grep { $_ eq 'files' } @sections;
return (\@sections, undef);
}
# The default run is the fast one: the deep scan joins only with --deep.
my @sections = grep { $_ ne 'files' } @ALL_SECTIONS;
push @sections, 'files' if $deep;
return (\@sections, undef);
}
sub usage {
my $name = $0;
$name =~ s{.*/}{};
return <<"USAGE";
Usage: $name [options]
Security audit: SSH, firewall, SELinux, updates, accounts, services, kernel
hardening, and an opt-in file system scan, graded A to F
Options:
--section LIST Comma-separated sections to run. Available: @{[ join(', ', @ALL_SECTIONS) ]} [default: all]
--deep Also scan the file system: SUID binaries, world writable files
and directories, unowned files. Covers the root file system only
--json Output machine-readable JSON to stdout
--strict Exit non-zero on warnings as well as on critical findings
--version Show the version and exit
-h, --help Show this help and exit
Exit status: 0 with nothing to act on, 1 when a critical finding (or a warning
under --strict) demands attention, 2 for a usage error.
USAGE
}
sub parse_args {
my %opt = (section => '', deep => 0, json => 0, strict => 0);
my @argv = @ARGV;
while (defined(my $arg = shift @argv)) {
if ($arg eq '--section') {
my $value = shift @argv;
if (!defined $value) {
print STDERR "argument --section: expected one argument\n";
exit 2;
}
$opt{section} = $value;
next;
}
if ($arg =~ /^--section=(.*)$/s) { $opt{section} = $1; next }
if ($arg eq '--deep') { $opt{deep} = 1; next }
if ($arg eq '--json') { $opt{json} = 1; next }
if ($arg eq '--strict') { $opt{strict} = 1; next }
if ($arg eq '--help' || $arg eq '-h') { print usage(); exit 0 }
if ($arg eq '--version') {
my $name = $0;
$name =~ s{.*/}{};
print "$name $VERSION\n";
exit 0;
}
print STDERR "unrecognised argument: $arg\n";
print STDERR usage();
exit 2;
}
return %opt;
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
sub main {
my %opt = parse_args();
if ($^O ne 'linux') {
print STDERR "${RED}Error: security-audit.pl currently supports Linux only "
. "(detected platform: $^O).$RESET\n";
exit 1;
}
my ($sections_ref, $error) = resolve_sections($opt{section}, $opt{deep});
if (defined $error) {
# A usage error exits 2, as the usage text promises.
print STDERR "${RED}Error: $error$RESET\n";
print STDERR 'Available: ' . join(', ', @ALL_SECTIONS) . "\n"
if $error =~ /^unknown section/;
exit 2;
}
my @sections = @$sections_ref;
my $data = collect_all(\@sections, $opt{deep});
if ($opt{json}) {
# The report's order array and the checks hash are the same objects; the
# JSON carries the hash, whose keys are sorted by the encoder.
delete $data->{order};
print json_encode($data);
}
else {
print_report($data, $opt{strict});
}
my $findings = $data->{findings} // [];
my $criticals = scalar grep { $_->{severity} eq 'critical' } @$findings;
my $warnings = scalar grep { $_->{severity} eq 'warn' } @$findings;
return ($criticals || ($opt{strict} && $warnings)) ? 1 : 0;
}
$SIG{INT} = sub {
print STDERR "\nInterrupted.\n";
remove_scratch();
exit 130;
};
$SIG{TERM} = sub {
remove_scratch();
exit 143;
};
END {
remove_scratch();
}
# Only when this file is the program: a test harness may require it and call the
# pure functions directly.
exit(main()) unless caller;