feat: initial release of the scripts collection
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,706 @@
|
||||
#!/usr/bin/env perl
|
||||
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# Checks for security-audit.pl: the SSH configuration readers and their
|
||||
# evaluator, the socket and firewall parsers with their coverage and exposure
|
||||
# rules, SELinux, updates, accounts, the kernel hardening verdicts, the deep
|
||||
# scan evaluator, the grade, the findings order and the JSON encoder.
|
||||
#
|
||||
# Run from anywhere: perl tests/security-audit.pl
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
|
||||
my $root = $0 =~ m{^(.*)/[^/]+$} ? "$1/.." : '..';
|
||||
$root = "./$root" if $root !~ m{^/};
|
||||
require "$root/security-audit.pl";
|
||||
|
||||
my ($passed, $failed) = (0, 0);
|
||||
|
||||
sub is {
|
||||
my ($got, $want, $label) = @_;
|
||||
$got = 'undef' unless defined $got;
|
||||
$want = 'undef' unless defined $want;
|
||||
if ($got eq $want) {
|
||||
$passed++;
|
||||
print "ok $label\n";
|
||||
return 1;
|
||||
}
|
||||
$failed++;
|
||||
print "FAIL $label\n got: $got\n want: $want\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
sub check {
|
||||
my ($cond, $label) = @_;
|
||||
# A match in an argument list yields the empty list on failure, which would
|
||||
# shift the label into the condition slot, so the condition is forced first.
|
||||
return is($cond ? 1 : 0, 1, $label);
|
||||
}
|
||||
|
||||
sub like {
|
||||
my ($got, $re, $label) = @_;
|
||||
my $matched = (defined $got && $got =~ $re) ? 1 : 0;
|
||||
if ($matched) {
|
||||
$passed++;
|
||||
print "ok $label\n";
|
||||
return 1;
|
||||
}
|
||||
$failed++;
|
||||
print "FAIL $label\n got: " . (defined $got ? $got : 'undef') . "\n want: $re\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
# The severity a check id carries in a list of checks, and the message with it.
|
||||
sub severity_of {
|
||||
my ($checks, $id) = @_;
|
||||
for my $check (@$checks) {
|
||||
return ($check->{severity}, $check->{message}) if $check->{id} eq $id;
|
||||
}
|
||||
return ('missing', '');
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RPM version comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
is(rpmvercmp('1.0', '1.0'), 0, 'rpmvercmp: equal');
|
||||
is(rpmvercmp('1.0', '1.0.1'), -1, 'rpmvercmp: an extra segment is newer');
|
||||
is(rpmvercmp('2.0', '1.9.9'), 1, 'rpmvercmp: the major decides');
|
||||
is(rpmvercmp('1.10', '1.9'), 1, 'rpmvercmp: numeric segments compare by value');
|
||||
is(rpmvercmp('1.007', '1.7'), 0, 'rpmvercmp: leading zeros are stripped');
|
||||
is(rpmvercmp('1.0~rc1', '1.0'), -1, 'rpmvercmp: tilde sorts before the release');
|
||||
is(rpmvercmp('1.0~rc1', '1.0~rc2'), -1, 'rpmvercmp: two pre releases');
|
||||
is(rpmvercmp('1.0.fc41', '1.0.fc41'), 0, 'rpmvercmp: identical dist tags');
|
||||
is(rpmvercmp('6.12.4-200.fc41', '6.12.4-100.fc41'), 1, 'rpmvercmp: a kernel release');
|
||||
is(rpmvercmp('1.0b', '1.0'), 1, 'rpmvercmp: an alpha suffix is newer than none');
|
||||
is(rpmvercmp('1.0', '1.0b'), -1, 'rpmvercmp: and the mirror side');
|
||||
is(rpmvercmp('2016a', '2015616'), -1, 'rpmvercmp: a numeric segment beats an alpha one');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSH configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
my ($values, $ports) = parse_sshd_effective(<<'SSHD');
|
||||
port 22
|
||||
port 2222
|
||||
addressfamily any
|
||||
listenaddress 0.0.0.0:22
|
||||
permitrootlogin prohibit-password
|
||||
passwordauthentication no
|
||||
permitemptypasswords no
|
||||
pubkeyauthentication yes
|
||||
x11forwarding no
|
||||
maxauthtries 3
|
||||
SSHD
|
||||
is($values->{permitrootlogin}, 'prohibit-password', 'sshd -T: a keyword reads back');
|
||||
is($values->{passwordauthentication}, 'no', 'sshd -T: the key is lowercased');
|
||||
is(scalar @$ports, 2, 'sshd -T: both port lines are kept');
|
||||
is($ports->[1], 2222, 'sshd -T: the second port in order');
|
||||
|
||||
my $tmp_dir = "/tmp/security-audit-test.$$";
|
||||
mkdir($tmp_dir, 0700) or die "cannot mkdir $tmp_dir: $!\n";
|
||||
open(my $main_fh, '>', "$tmp_dir/sshd_config") or die "cannot write: $!\n";
|
||||
print {$main_fh} <<'CONF';
|
||||
# a comment
|
||||
Port 22
|
||||
PasswordAuthentication yes
|
||||
|
||||
Match User backup
|
||||
PasswordAuthentication no
|
||||
CONF
|
||||
close($main_fh);
|
||||
open(my $drop_fh, '>', "$tmp_dir/50-audit.conf") or die "cannot write: $!\n";
|
||||
print {$drop_fh} "PasswordAuthentication no\nMaxAuthTries 2\n";
|
||||
close($drop_fh);
|
||||
|
||||
my $conf_state = { values => {}, seen => {}, accum => {} };
|
||||
read_sshd_config("$tmp_dir/sshd_config", $conf_state, 0);
|
||||
is($conf_state->{values}{passwordauthentication}, 'yes', 'config: the first value wins');
|
||||
is($conf_state->{values}{permitrootlogin}, undef, 'config: an absent key stays absent');
|
||||
my $port_count = grep { $_ eq '22' } @{ $conf_state->{accum}{port} // [] };
|
||||
is($port_count, 1, 'config: the port accumulates');
|
||||
|
||||
# Include expansion, with a path that is not under /etc/ssh.
|
||||
open($main_fh, '>', "$tmp_dir/sshd_config") or die "cannot write: $!\n";
|
||||
print {$main_fh} "Include $tmp_dir/*.conf\nPasswordAuthentication yes\n";
|
||||
close($main_fh);
|
||||
$conf_state = { values => {}, seen => {}, accum => {} };
|
||||
read_sshd_config("$tmp_dir/sshd_config", $conf_state, 0);
|
||||
is($conf_state->{values}{passwordauthentication}, 'no',
|
||||
'config: an include is read before the lines after it');
|
||||
is($conf_state->{values}{maxauthtries}, '2', 'config: the drop-in carries its own key');
|
||||
# The Match block of the first fixture must not be picked up either: the first
|
||||
# value is the one a global reading of the file yields, which is what the
|
||||
# fallback promises.
|
||||
|
||||
# A Match block alone sets nothing: everything after the Match line is
|
||||
# conditional on a pattern the fallback cannot evaluate.
|
||||
open($main_fh, '>', "$tmp_dir/sshd_config") or die "cannot write: $!\n";
|
||||
print {$main_fh} "Match User admin\n PermitRootLogin yes\n";
|
||||
close($main_fh);
|
||||
$conf_state = { values => {}, seen => {}, accum => {} };
|
||||
read_sshd_config("$tmp_dir/sshd_config", $conf_state, 0);
|
||||
is($conf_state->{values}{permitrootlogin}, undef, 'config: a Match block alone sets nothing');
|
||||
|
||||
sub ssh_values {
|
||||
my %pair = @_;
|
||||
my %values;
|
||||
$values{$_} = $pair{$_} for keys %pair;
|
||||
return \%values;
|
||||
}
|
||||
|
||||
my @checks = evaluate_ssh({
|
||||
available => 1, source => 'effective',
|
||||
values => ssh_values(
|
||||
permitrootlogin => 'yes', passwordauthentication => 'no',
|
||||
permitemptypasswords => 'no', pubkeyauthentication => 'yes',
|
||||
x11forwarding => 'no', maxauthtries => '3',
|
||||
),
|
||||
ports => [22],
|
||||
});
|
||||
is((severity_of(\@checks, 'ssh.permit_root_login'))[0], 'critical',
|
||||
'ssh: root login yes is critical');
|
||||
|
||||
@checks = evaluate_ssh({
|
||||
available => 1, source => 'config',
|
||||
values => ssh_values(
|
||||
permitrootlogin => 'prohibit-password', passwordauthentication => 'yes',
|
||||
permitemptypasswords => 'yes',
|
||||
),
|
||||
ports => [22],
|
||||
});
|
||||
is((severity_of(\@checks, 'ssh.permit_root_login'))[0], 'pass',
|
||||
'ssh: prohibit-password passes');
|
||||
is((severity_of(\@checks, 'ssh.password_authentication'))[0], 'warn',
|
||||
'ssh: password authentication on is a warning');
|
||||
is((severity_of(\@checks, 'ssh.permit_empty_passwords'))[0], 'critical',
|
||||
'ssh: empty passwords permitted is critical');
|
||||
is((severity_of(\@checks, 'ssh.max_auth_tries'))[0], 'pass',
|
||||
'ssh: an absent MaxAuthTries falls back to OpenSSH default and passes');
|
||||
is((severity_of(\@checks, 'ssh.max_auth_tries'))[1], 'MaxAuthTries 6',
|
||||
'ssh: the OpenSSH default for MaxAuthTries is 6');
|
||||
|
||||
# sshd accepts and lowercases the Yes and No values, so an uppercase value in
|
||||
# the configuration file is graded by its meaning and not by its case.
|
||||
@checks = evaluate_ssh({
|
||||
available => 1, source => 'config',
|
||||
values => ssh_values(permitrootlogin => 'YES', passwordauthentication => 'No'),
|
||||
ports => [22],
|
||||
});
|
||||
is((severity_of(\@checks, 'ssh.permit_root_login'))[0], 'critical',
|
||||
'ssh: an uppercase Yes is still root login yes');
|
||||
is((severity_of(\@checks, 'ssh.password_authentication'))[0], 'pass',
|
||||
'ssh: an uppercase No still turns password auth off');
|
||||
|
||||
@checks = evaluate_ssh({ available => 0, source => '', values => {}, ports => [] });
|
||||
is(scalar @checks, 0, 'ssh: an unavailable source yields no checks');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sockets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
my @listeners = @{
|
||||
parse_ss_listeners(<<'SS')
|
||||
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1180,fd=3))
|
||||
tcp LISTEN 0 5 192.168.1.7:5432 0.0.0.0:* users:(("postgres",pid=900,fd=5))
|
||||
tcp LISTEN 0 128 127.0.0.1:6379 0.0.0.0:*
|
||||
tcp LISTEN 0 128 [::1]:6379 [::]:*
|
||||
tcp LISTEN 0 128 [::]:80 [::]:* users:(("nginx",pid=800,fd=6))
|
||||
udp UNCONN 0 0 0.0.0.0:5353 0.0.0.0:*
|
||||
udp UNCONN 0 0 [fe80::a7f4:eb68:1d07:aac3]%wlp1s0:3702 [::]:* users:(("wsdd",pid=7,fd=13))
|
||||
tcp ESTAB 0 0 192.168.1.7:22 192.168.1.2:51000
|
||||
SS
|
||||
};
|
||||
is(scalar @listeners, 7, 'ss: only LISTEN and UNCONN lines are sockets');
|
||||
is($listeners[0]{proto}, 'tcp', 'ss: the protocol');
|
||||
is($listeners[0]{port}, 22, 'ss: the port');
|
||||
is($listeners[0]{loopback}, 0, 'ss: a wildcard v4 socket is not loopback');
|
||||
is($listeners[0]{process}, 'sshd', 'ss: the process name');
|
||||
is($listeners[1]{loopback}, 0, 'ss: a specific address is not loopback');
|
||||
is($listeners[2]{loopback}, 1, 'ss: 127.0.0.1 is loopback');
|
||||
is($listeners[3]{loopback}, 1, 'ss: [::1] is loopback');
|
||||
is($listeners[3]{address}, '::1', 'ss: the brackets go');
|
||||
is($listeners[4]{address}, '::', 'ss: a v6 wildcard');
|
||||
is($listeners[5]{proto}, 'udp', 'ss: a udp socket is kept');
|
||||
is($listeners[6]{address}, 'fe80::a7f4:eb68:1d07:aac3',
|
||||
'ss: the scope suffix and the brackets both go');
|
||||
|
||||
is((split_listener_address('127.0.0.53%lo:53'))[0], '127.0.0.53', 'address: a v4 scope suffix');
|
||||
is((split_listener_address('garbage'))[0], undef, 'address: no port means no socket');
|
||||
is(is_loopback_address('127.255.0.1'), 1, 'address: the whole 127/8 is loopback');
|
||||
is(is_loopback_address('0.0.0.0'), 0, 'address: the wildcard is not loopback');
|
||||
is(is_wildcard_address('*'), 1, 'address: ss writes * for a v6 wildcard');
|
||||
is(is_wildcard_address('192.168.1.7'), 0, 'address: a specific address');
|
||||
is(parse_process_field('users:(("sshd",pid=1180,fd=3))'), 'sshd', 'process: the name');
|
||||
is(parse_process_field(''), '', 'process: nothing to read');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# firewalld
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
my @xml_ports = parse_service_xml(<<'XML');
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>SSH</short>
|
||||
<description>Secure Shell</description>
|
||||
<port port="22" protocol="tcp"/>
|
||||
</service>
|
||||
XML
|
||||
is(join(',', @xml_ports), '22/tcp', 'service xml: port first');
|
||||
|
||||
@xml_ports = parse_service_xml('<service><port protocol="udp" port="5353"/><port port="6000-6009" protocol="tcp"/></service>');
|
||||
is(join(',', @xml_ports), '5353/udp,6000-6009/tcp', 'service xml: attribute order varies and ranges pass');
|
||||
|
||||
@xml_ports = parse_service_xml('<service><description>no ports</description></service>');
|
||||
is(scalar @xml_ports, 0, 'service xml: a service without ports');
|
||||
|
||||
is(join('|', parse_active_zones("FedoraWorkstation (default)\n interfaces: wlp1s0\n")),
|
||||
'FedoraWorkstation', 'zones: the decoration is not part of the name');
|
||||
is(join('|', parse_active_zones("public\n interfaces: eth0\nwork\n interfaces: eth1\n")),
|
||||
'public|work', 'zones: two zones, the second undecorated');
|
||||
|
||||
my $info = parse_info_zone(<<'ZONE');
|
||||
FedoraWorkstation (default, active)
|
||||
target: default
|
||||
ingress-priority: 0
|
||||
interfaces: wlp1s0
|
||||
sources:
|
||||
services: dhcpv6-client samba-client ssh
|
||||
ports: 1025-65535/udp 1025-65535/tcp
|
||||
rich rules:
|
||||
ZONE
|
||||
is($info->{name}, 'FedoraWorkstation (default, active)', 'info zone: the name line');
|
||||
is($info->{target}, 'default', 'info zone: the target');
|
||||
is(join('|', @{ $info->{services} }), 'dhcpv6-client|samba-client|ssh', 'info zone: the services');
|
||||
is(join('|', @{ $info->{ports} }), '1025-65535/udp|1025-65535/tcp', 'info zone: the ports');
|
||||
|
||||
my $allowed = build_allowed([
|
||||
{
|
||||
ports => ['8080/tcp', '53/udp'],
|
||||
services => ['ssh'],
|
||||
service_ports => ['22/tcp'],
|
||||
target => 'default',
|
||||
},
|
||||
]);
|
||||
check(port_allowed($allowed, 'tcp', 22), 'coverage: a service port is allowed');
|
||||
check(port_allowed($allowed, 'tcp', 8080), 'coverage: a listed port is allowed');
|
||||
check(port_allowed($allowed, 'udp', 53), 'coverage: the protocol matters');
|
||||
check(!port_allowed($allowed, 'tcp', 8081), 'coverage: the next port is not');
|
||||
check(!port_allowed($allowed, 'tcp', 53), 'coverage: udp 53 is not tcp 53');
|
||||
|
||||
# --state answers on stdout only for a live daemon: a stopped firewalld is
|
||||
# installed but inactive, not absent.
|
||||
is(join(',', classify_firewalld(1, "running\n")), '1,1',
|
||||
'firewalld: a running daemon');
|
||||
is(join(',', classify_firewalld(1, '')), '1,0',
|
||||
'firewalld: installed but stopped is available and inactive');
|
||||
is(join(',', classify_firewalld(0, '')), '0,0',
|
||||
'firewalld: no binary is unavailable');
|
||||
|
||||
$allowed = build_allowed([ { ports => ['6000-6009/tcp'], services => [], service_ports => [] } ]);
|
||||
check(port_allowed($allowed, 'tcp', 6000), 'coverage: the start of a range');
|
||||
check(port_allowed($allowed, 'tcp', 6009), 'coverage: the end of a range');
|
||||
check(!port_allowed($allowed, 'tcp', 6010), 'coverage: past the end');
|
||||
|
||||
# The exposure model: behind a filtering zone only allowed ports are exposed;
|
||||
# an ACCEPT target exposes everything; without a firewall the socket is exposed
|
||||
# unless it sits on loopback.
|
||||
sub firewall_fixture {
|
||||
my (%override) = @_;
|
||||
my %fw = (
|
||||
available => 1, active => 1, default_zone => 'public',
|
||||
zones => [ {
|
||||
name => 'public', target => 'default',
|
||||
ports => ['8080/tcp'], services => ['ssh'], service_ports => ['22/tcp'],
|
||||
} ],
|
||||
listeners => [
|
||||
{ proto => 'tcp', address => '0.0.0.0', port => 22, loopback => 0, process => 'sshd' },
|
||||
{ proto => 'tcp', address => '0.0.0.0', port => 9999, loopback => 0, process => '' },
|
||||
{ proto => 'tcp', address => '127.0.0.1', port => 6379, loopback => 1, process => 'redis' },
|
||||
],
|
||||
);
|
||||
$fw{$_} = $override{$_} for keys %override;
|
||||
return \%fw;
|
||||
}
|
||||
|
||||
my @fw_checks = evaluate_firewall(firewall_fixture());
|
||||
my $listeners_check = (grep { $_->{id} eq 'firewall.listeners' } @fw_checks)[0];
|
||||
like($listeners_check->{message}, qr/1 exposed/, 'firewall: one socket exposed behind a filtering zone');
|
||||
is($listeners_check->{detail}[0], 'tcp 0.0.0.0:22 exposed (sshd)', 'firewall: the exposed socket is the allowed one');
|
||||
is($listeners_check->{detail}[2], 'tcp 127.0.0.1:6379 local (redis)',
|
||||
'firewall: a loopback listener is local, not filtered');
|
||||
is((severity_of(\@fw_checks, 'firewall.active'))[0], 'pass', 'firewall: running firewalld passes');
|
||||
is((severity_of(\@fw_checks, 'firewall.zone_target'))[0], 'pass', 'firewall: a filtering target passes');
|
||||
is((severity_of(\@fw_checks, 'firewall.allowed_no_listener'))[0], 'warn',
|
||||
'firewall: 8080 allowed with nothing listening is stale');
|
||||
|
||||
@fw_checks = evaluate_firewall(firewall_fixture(
|
||||
zones => [ { name => 'home', target => 'accept', ports => [], services => ['ssh'], service_ports => ['22/tcp'] } ],
|
||||
));
|
||||
is((severity_of(\@fw_checks, 'firewall.zone_target'))[0], 'warn',
|
||||
'firewall: an ACCEPT target zone is a warning');
|
||||
like((severity_of(\@fw_checks, 'firewall.listeners'))[0], qr/info/,
|
||||
'firewall: an ACCEPT target exposes the unallowed socket without failing');
|
||||
is((grep { $_->{id} eq 'firewall.listeners' } @fw_checks)[0]{message} =~ /2 exposed/ ? 'two' : 'other',
|
||||
'two', 'firewall: two sockets exposed under ACCEPT');
|
||||
|
||||
@fw_checks = evaluate_firewall(firewall_fixture(
|
||||
available => 0, active => 0, zones => [],
|
||||
));
|
||||
is((severity_of(\@fw_checks, 'firewall.active'))[0], 'critical',
|
||||
'firewall: no firewall and exposed sockets is critical');
|
||||
|
||||
@fw_checks = evaluate_firewall(firewall_fixture(
|
||||
available => 0, active => 0, zones => [],
|
||||
listeners => [ { proto => 'tcp', address => '127.0.0.1', port => 6379, loopback => 1, process => '' } ],
|
||||
));
|
||||
is((severity_of(\@fw_checks, 'firewall.active'))[0], 'warn',
|
||||
'firewall: no firewall with loopback-only sockets is a warning');
|
||||
|
||||
@fw_checks = evaluate_firewall(firewall_fixture(
|
||||
available => 1, active => 0, zones => [],
|
||||
listeners => undef,
|
||||
));
|
||||
is((severity_of(\@fw_checks, 'firewall.active'))[0], 'unknown',
|
||||
'firewall: an inactive firewall and no inventory is unknown');
|
||||
is((severity_of(\@fw_checks, 'firewall.listeners'))[0], 'unknown',
|
||||
'firewall: no ss means no inventory');
|
||||
|
||||
@fw_checks = evaluate_firewall(firewall_fixture(zones => []));
|
||||
is((severity_of(\@fw_checks, 'firewall.zone_target'))[0], 'unknown',
|
||||
'firewall: firewalld running without an active zone says so');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SELinux
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
is(parse_selinux_config("# comment\nSELINUX=enforcing\n"), 'enforcing', 'selinux config: the value');
|
||||
is(parse_selinux_config("SELINUXTYPE=targeted\nSELINUX = permissive\n"), 'permissive',
|
||||
'selinux config: spaces around the assignment');
|
||||
is(parse_selinux_config("SELINUXTYPE=targeted\n"), '', 'selinux config: no SELINUX key');
|
||||
|
||||
@checks = evaluate_selinux({ available => 1, runtime => 'Enforcing', configured => 'enforcing' });
|
||||
is((severity_of(\@checks, 'selinux.runtime'))[0], 'pass', 'selinux: enforcing passes');
|
||||
is((severity_of(\@checks, 'selinux.config'))[0], 'pass', 'selinux: matching config passes');
|
||||
@checks = evaluate_selinux({ available => 1, runtime => 'Permissive', configured => 'enforcing' });
|
||||
is((severity_of(\@checks, 'selinux.runtime'))[0], 'warn', 'selinux: permissive is a warning');
|
||||
is((severity_of(\@checks, 'selinux.config'))[0], 'info', 'selinux: a future enforcement is noted');
|
||||
@checks = evaluate_selinux({ available => 1, runtime => 'Enforcing', configured => 'disabled' });
|
||||
is((severity_of(\@checks, 'selinux.config'))[0], 'warn',
|
||||
'selinux: an enforcing runtime with a disabled config downgrades at boot');
|
||||
@checks = evaluate_selinux({ available => 1, runtime => 'Disabled', configured => 'enforcing' });
|
||||
is((severity_of(\@checks, 'selinux.config'))[0], 'warn',
|
||||
'selinux: a disabled runtime with an enforcing config is a contradiction, not a promise');
|
||||
@checks = evaluate_selinux({ available => 1, runtime => 'Enforcing', configured => '' });
|
||||
is((severity_of(\@checks, 'selinux.config'))[0], 'unknown',
|
||||
'selinux: no configuration file is unknown');
|
||||
@checks = evaluate_selinux({ available => 0, runtime => '', configured => '' });
|
||||
is(scalar @checks, 0, 'selinux: no tooling, no checks');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Updates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
is(count_security_updates(<<'DNF4'), 2, 'updateinfo: two dnf 4 advisory lines');
|
||||
FEDORA-2026-aaaaaa security kernel-6.12.4-200.fc41.noarch
|
||||
FEDORA-2026-bbbbbb security/important openssl-3.2.0-1.fc41.x86_64
|
||||
DNF4
|
||||
is(count_security_updates("FEDORA-2026-cccccc bugfix bash-5.2.0-1.fc41.x86_64\n"), 0,
|
||||
'updateinfo: a bugfix line is not security');
|
||||
is(count_security_updates("Last metadata expiration check: 2:04:11 ago on Fri.\n"), 0,
|
||||
'updateinfo: a progress line is not an advisory');
|
||||
|
||||
is(parse_automatic_conf("[commands]\napply_updates = yes\n"), 'yes', 'automatic.conf: yes');
|
||||
is(parse_automatic_conf("[commands]\n# apply_updates = yes\napply_updates = no\n"), 'no',
|
||||
'automatic.conf: the last assignment, comments skipped');
|
||||
is(parse_automatic_conf("[commands]\nupgrade_type = security\n"), '', 'automatic.conf: the key is absent');
|
||||
|
||||
# On a dnf5 host dnf-automatic.timer is an alias whose target carries the
|
||||
# enablement, and "alias" itself names no state.
|
||||
is(join('|', pick_timer_unit({
|
||||
'dnf5-automatic.timer' => 'enabled',
|
||||
'dnf-automatic-install.timer' => 'not-found',
|
||||
'dnf-automatic.timer' => 'alias',
|
||||
})), 'dnf5-automatic.timer|enabled',
|
||||
'timers: an alias defers to the dnf5 unit name');
|
||||
is(join('|', pick_timer_unit({
|
||||
'dnf5-automatic.timer' => 'not-found',
|
||||
'dnf-automatic-install.timer' => 'disabled',
|
||||
'dnf-automatic.timer' => 'disabled',
|
||||
})), 'dnf-automatic-install.timer|disabled',
|
||||
'timers: the dnf4 install timer is still found');
|
||||
is(join('|', pick_timer_unit({
|
||||
'dnf5-automatic.timer' => 'not-found',
|
||||
'dnf-automatic-install.timer' => 'not-found',
|
||||
'dnf-automatic.timer' => 'alias',
|
||||
})), '|', 'timers: alias and not-found are no answer');
|
||||
|
||||
# An absent apply_updates is the documented default: no, download only.
|
||||
@checks = evaluate_updates({
|
||||
security_pending => 0, timer => 'enabled', timer_name => 'dnf5-automatic.timer',
|
||||
apply_updates => 'no', apply_updates_defaulted => 1,
|
||||
running_kernel => '6.12.4-200.fc44.x86_64', newest_kernel => '', reboot_required => 0,
|
||||
});
|
||||
is((severity_of(\@checks, 'updates.apply_updates'))[0], 'warn',
|
||||
'updates: the default downloads without installing');
|
||||
like((severity_of(\@checks, 'updates.apply_updates'))[1], qr/no \(the default\)/,
|
||||
'updates: a defaulted value says so');
|
||||
@checks = evaluate_updates({
|
||||
timer => 'unknown', timer_name => '', apply_updates => undef,
|
||||
security_pending => undef, pending_error => '', running_kernel => '',
|
||||
newest_kernel => '', reboot_required => undef,
|
||||
});
|
||||
is((severity_of(\@checks, 'updates.apply_updates'))[0], 'unknown',
|
||||
'updates: no readable configuration is unknown');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accounts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
my $users = parse_passwd([
|
||||
'root:x:0:0:root:/root:/bin/bash',
|
||||
'petrbalvin:x:1000:1000:Petr:/home/petrbalvin:/bin/zsh',
|
||||
'daemon:x:2:2:daemon:/sbin:/sbin/nologin',
|
||||
'brokenline',
|
||||
]);
|
||||
is(scalar @$users, 3, 'passwd: a malformed line is skipped');
|
||||
is($users->[0]{uid}, 0, 'passwd: the uid');
|
||||
|
||||
is(join(',', @{ empty_password_users(['root:', 'daemon:*', 'svc:!!']) }), 'root',
|
||||
'shadow: an empty hash, a locked one and a bang one');
|
||||
# A real shadow line carries the ageing fields behind the hash, so the hash is
|
||||
# the second field and not everything after the name.
|
||||
is(join(',', @{ empty_password_users([
|
||||
'svc::19997:0:99999:7:::',
|
||||
'daemon:*:19997:0:99999:7:::',
|
||||
'alice:$6$rounds=656000$ salted:19997:0:99999:7:::',
|
||||
'root:',
|
||||
]) }), 'svc,root', 'shadow: the empty hash is the second field, ageing fields aside');
|
||||
|
||||
my @principals = parse_sudoers_text(<<'SUDO');
|
||||
Defaults env_reset
|
||||
%wheel ALL=(ALL) ALL
|
||||
petrbalvin ALL=(ALL) NOPASSWD: ALL
|
||||
# %commented ALL=(ALL) NOPASSWD: ALL
|
||||
SUDO
|
||||
is(join('|', @principals), 'petrbalvin', 'sudoers: the NOPASSWD principal, comments skipped');
|
||||
|
||||
@checks = evaluate_accounts({
|
||||
uid_zero => ['root'], empty_passwords => undef, nopasswd => undef,
|
||||
human_users => ['petrbalvin (1000)'], root_authorized_keys => undef,
|
||||
});
|
||||
is((severity_of(\@checks, 'accounts.uid_zero'))[0], 'pass', 'accounts: only root at uid 0');
|
||||
is((severity_of(\@checks, 'accounts.empty_passwords'))[0], 'unknown',
|
||||
'accounts: shadow unreadable is unknown, not a pass');
|
||||
@checks = evaluate_accounts({
|
||||
uid_zero => ['root', 'toor'], empty_passwords => ['svc'], nopasswd => ['%wheel'],
|
||||
human_users => [], root_authorized_keys => 2,
|
||||
});
|
||||
is((severity_of(\@checks, 'accounts.uid_zero'))[0], 'critical', 'accounts: a second uid 0 is critical');
|
||||
is((severity_of(\@checks, 'accounts.empty_passwords'))[0], 'critical', 'accounts: an empty password is critical');
|
||||
is((severity_of(\@checks, 'accounts.nopasswd_sudo'))[0], 'warn', 'accounts: NOPASSWD is a warning');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Services and the kernel sysctls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
is((severity_of([evaluate_services([])], 'services.failed_units'))[0], 'pass', 'services: none failed');
|
||||
is((severity_of([evaluate_services([{ unit => 'sshd.service', state => 'failed' }])],
|
||||
'services.failed_units'))[0], 'warn', 'services: a failed unit is a warning');
|
||||
is((severity_of([evaluate_services(undef)], 'services.failed_units'))[0], 'unknown',
|
||||
'services: no systemctl is unknown');
|
||||
|
||||
# The columns of `systemctl --failed --no-legend --plain` are unit, load,
|
||||
# active, sub, description: the report names the active state.
|
||||
my $failed_units = parse_failed_units(
|
||||
"sshd.service loaded failed failed OpenSSH server daemon\n" .
|
||||
"dnf5-automatic.service not-found failed failed (bad unit)\n");
|
||||
is(scalar @$failed_units, 2, 'services: two failed lines parse');
|
||||
is($failed_units->[0]{state}, 'failed', 'services: the state is the active column, not the load');
|
||||
is($failed_units->[1]{unit}, 'dnf5-automatic.service', 'services: a not-found load state still parses');
|
||||
|
||||
my %sysctl_ok = (
|
||||
'/proc/sys/kernel/kptr_restrict' => '1',
|
||||
'/proc/sys/kernel/dmesg_restrict' => '1',
|
||||
'/proc/sys/kernel/unprivileged_bpf_disabled' => '2',
|
||||
'/proc/sys/kernel/yama/ptrace_scope' => '1',
|
||||
'/proc/sys/fs/protected_symlinks' => '1',
|
||||
'/proc/sys/fs/protected_hardlinks' => '1',
|
||||
'/proc/sys/fs/protected_fifos' => '1',
|
||||
'/proc/sys/fs/protected_regular' => '2',
|
||||
'/proc/sys/net/ipv4/tcp_syncookies' => '1',
|
||||
'/proc/sys/kernel/randomize_va_space' => '2',
|
||||
);
|
||||
@checks = evaluate_kernel({ %sysctl_ok });
|
||||
my $pass_count = grep { $_->{severity} eq 'pass' } @checks;
|
||||
is($pass_count, 7, 'kernel: a hardened machine passes every check');
|
||||
|
||||
my %sysctl_weak = (%sysctl_ok,
|
||||
'/proc/sys/kernel/kptr_restrict' => '0',
|
||||
'/proc/sys/kernel/randomize_va_space' => '1',
|
||||
);
|
||||
delete $sysctl_weak{'/proc/sys/kernel/yama/ptrace_scope'};
|
||||
@checks = evaluate_kernel({ %sysctl_weak });
|
||||
is((severity_of(\@checks, 'kernel.kptr_restrict'))[0], 'warn', 'kernel: a visible kptr is a warning');
|
||||
is((severity_of(\@checks, 'kernel.randomize_va_space'))[0], 'warn', 'kernel: reduced ASLR is a warning');
|
||||
is((severity_of(\@checks, 'kernel.yama_ptrace_scope'))[0], 'unknown', 'kernel: an absent sysctl is unknown');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The deep scan evaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@checks = evaluate_files({
|
||||
suid => ['/usr/bin/sudo', '/tmp/backdoor'],
|
||||
world_writable_files => ['/var/tmp/junk'],
|
||||
world_writable_dirs => [],
|
||||
unowned => [],
|
||||
});
|
||||
is((severity_of(\@checks, 'files.suid_staging'))[0], 'critical',
|
||||
'files: a SUID binary under /tmp is critical');
|
||||
like((severity_of(\@checks, 'files.world_writable'))[1], qr/1 world writable file/,
|
||||
'files: the world writable count');
|
||||
|
||||
@checks = evaluate_files({
|
||||
suid => ['/usr/bin/sudo'],
|
||||
world_writable_files => [], world_writable_dirs => [], unowned => [],
|
||||
});
|
||||
is((severity_of(\@checks, 'files.suid_staging'))[0], 'pass', 'files: a clean staging check');
|
||||
is((severity_of(\@checks, 'files.unowned'))[0], 'pass', 'files: nothing unowned');
|
||||
|
||||
@checks = evaluate_files({
|
||||
suid => [], world_writable_files => [], world_writable_dirs => ['/tmp'], unowned => ['x'],
|
||||
});
|
||||
is((severity_of(\@checks, 'files.world_writable'))[0], 'warn',
|
||||
'files: a stickyless world writable directory is a warning');
|
||||
is((severity_of(\@checks, 'files.unowned'))[0], 'warn', 'files: an unowned file is a warning');
|
||||
|
||||
# A scan that did not complete is unknown, never a clean pass: an interrupted
|
||||
# find leaves an undef slot behind.
|
||||
@checks = evaluate_files({
|
||||
suid => undef, world_writable_files => [], world_writable_dirs => [], unowned => [],
|
||||
});
|
||||
is((severity_of(\@checks, 'files.suid_staging'))[0], 'unknown',
|
||||
'files: an interrupted SUID scan is unknown');
|
||||
@checks = evaluate_files({
|
||||
suid => [], world_writable_files => undef, world_writable_dirs => [], unowned => [],
|
||||
});
|
||||
is((severity_of(\@checks, 'files.world_writable'))[0], 'unknown',
|
||||
'files: an interrupted world writable scan is unknown');
|
||||
@checks = evaluate_files({
|
||||
suid => [], world_writable_files => [], world_writable_dirs => [], unowned => undef,
|
||||
});
|
||||
is((severity_of(\@checks, 'files.unowned'))[0], 'unknown',
|
||||
'files: an interrupted unowned scan is unknown');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grade and findings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
my $grade = compute_grade({});
|
||||
is($grade->{grade}, 'N/A', 'grade: nothing assessable is not graded');
|
||||
is($grade->{score}, undef, 'grade: no score');
|
||||
|
||||
my %by_id;
|
||||
$by_id{"s.$_"} = {
|
||||
id => "s.$_",
|
||||
section => 's',
|
||||
severity => $_ eq 'a' ? 'pass' : $_ eq 'b' ? 'warn' : $_ eq 'c' ? 'critical' : 'unknown',
|
||||
weight => $_ eq 'd' ? 0 : 8,
|
||||
} for qw(a b c d e);
|
||||
$grade = compute_grade({ %by_id });
|
||||
# a: 8, b: 4, c: 0, d: weight 0 so not counted, e: unknown so out of the pool.
|
||||
is($grade->{maxima}{d}, undef, 'grade: a weightless check is not in the maxima');
|
||||
is($grade->{score}, 50, 'grade: 12 of the 24 remaining points');
|
||||
is($grade->{grade}, 'F', 'grade: the letter follows the thresholds');
|
||||
is($grade->{sections}{s}{possible}, 24, 'grade: the unknown check leaves the pool');
|
||||
is($grade->{skipped}{'s.e'}, 1, 'grade: the unknown check is named as skipped');
|
||||
|
||||
%by_id = map {
|
||||
("sec.$_" => { id => "sec.$_", section => 'sec', severity => 'pass', weight => 10 });
|
||||
} qw(one two);
|
||||
$grade = compute_grade({ %by_id });
|
||||
is($grade->{score}, 100, 'grade: full marks');
|
||||
|
||||
my $findings = findings_of(\%by_id);
|
||||
is(scalar @$findings, 0, 'findings: a clean set is empty');
|
||||
|
||||
%by_id = (
|
||||
'a.w' => { id => 'a.w', section => 'a', severity => 'warn', weight => 4, message => 'w' },
|
||||
'a.c' => { id => 'a.c', section => 'a', severity => 'critical', weight => 4, message => 'c' },
|
||||
'b.w' => { id => 'b.w', section => 'b', severity => 'warn', weight => 4, message => 'w2' },
|
||||
'a.p' => { id => 'a.p', section => 'a', severity => 'pass', weight => 4, message => 'p' },
|
||||
);
|
||||
$findings = findings_of(\%by_id);
|
||||
is(join(',', map { $_->{id} } @$findings), 'a.c,a.w,b.w',
|
||||
'findings: criticals first, then warnings by id, passes left out');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
like(json_encode({ root => 1, deep => 0 }), qr/"root": true/, 'json: a listed boolean path');
|
||||
like(json_encode({ sections => { firewall => { listeners => [ { loopback => 1, exposed => 0 } ] } } }),
|
||||
qr/"loopback": true/, 'json: a listener boolean by shape');
|
||||
like(json_encode({ sections => { ssh => { available => 1 }, selinux => { available => 0 } } }),
|
||||
qr/"available": true/, 'json: the section available flags are booleans');
|
||||
like(json_encode({ sections => { firewall => { listeners => [ { allowed => 1 } ] } } }),
|
||||
qr/"allowed": true/, 'json: a listener allowed flag is a boolean');
|
||||
like(json_encode({ a => 'text', b => 3 }), qr/"b": 3/, 'json: a number stays a number');
|
||||
like(json_encode({ s => "quote\"end\ning" }), qr/"quote\\"end\\ning"/, 'json: the escapes');
|
||||
like(json_encode({}), qr/\{\}/, 'json: an empty object');
|
||||
like(json_encode([]), qr/\[\]/, 'json: an empty array');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arguments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
my @saved = @ARGV;
|
||||
@ARGV = ('--json', '--strict', '--deep', '--section', 'ssh,firewall');
|
||||
my %args = parse_args();
|
||||
is($args{json}, 1, 'args: --json');
|
||||
is($args{strict}, 1, 'args: --strict');
|
||||
is($args{deep}, 1, 'args: --deep');
|
||||
is($args{section}, 'ssh,firewall', 'args: --section takes a list');
|
||||
@ARGV = ('--section=ssh');
|
||||
%args = parse_args();
|
||||
is($args{section}, 'ssh', 'args: --section=VALUE');
|
||||
@ARGV = ();
|
||||
%args = parse_args();
|
||||
is($args{deep}, 0, 'args: deep is off by default');
|
||||
@ARGV = @saved;
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section selection and the exit status of the program
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
my ($resolved, $section_error) = resolve_sections('ssh,firewall', 0);
|
||||
is(join(',', @$resolved), 'ssh,firewall', 'sections: a plain list resolves');
|
||||
is($section_error, undef, 'sections: no error for a plain list');
|
||||
($resolved, $section_error) = resolve_sections('ssh,ssh', 0);
|
||||
is(join(',', @$resolved), 'ssh', 'sections: a repeated name runs once');
|
||||
($resolved, $section_error) = resolve_sections('ssh', 1);
|
||||
is(join(',', @$resolved), 'ssh,files', 'sections: --deep adds the file scan');
|
||||
($resolved, $section_error) = resolve_sections('', 0);
|
||||
is(scalar @$resolved, 7, 'sections: the default run is every section but files');
|
||||
($resolved, $section_error) = resolve_sections('nope', 0);
|
||||
is($resolved, undef, 'sections: an unknown name yields no list');
|
||||
like($section_error, qr/unknown section/, 'sections: an unknown name is reported');
|
||||
($resolved, $section_error) = resolve_sections('files', 0);
|
||||
like($section_error, qr/requires --deep/, 'sections: files without --deep is refused');
|
||||
|
||||
# The validation runs before any collection, and a usage error exits 2 as the
|
||||
# usage text promises, not 1.
|
||||
my $exit_code = system($^X, "$root/security-audit.pl", '--section', 'nope') >> 8;
|
||||
is($exit_code, 2, 'cli: an unknown section is a usage error, exit 2');
|
||||
$exit_code = system($^X, "$root/security-audit.pl", '--section', 'files') >> 8;
|
||||
is($exit_code, 2, 'cli: files without --deep is a usage error, exit 2');
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
unlink("$tmp_dir/sshd_config", "$tmp_dir/50-audit.conf");
|
||||
rmdir($tmp_dir);
|
||||
|
||||
print "\n" . ($failed ? "$failed FAILED of " . ($passed + $failed) : "all $passed checks passed") . "\n";
|
||||
exit($failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user