# systemd -- lintian check script -*- perl -*- # # Copyright (C) 2013 Michael Stapelberg # Copyright (C) 2016-2020 Chris Lamb # Copyright (C) 2021 Felix Lechner # # based on the apache2 checks file by: # Copyright (C) 2012 Arno Toell # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, you can find it on the World Wide # Web at https://www.gnu.org/copyleft/gpl.html, or write to the Free # Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, # MA 02110-1301, USA. package Lintian::Check::Systemd; use v5.20; use warnings; use utf8; use Const::Fast; use Data::Validate::URI qw(is_uri); use List::Compare; use List::SomeUtils qw(any none); use Text::ParseWords qw(shellwords); use Moo; use namespace::clean; with 'Lintian::Check'; const my $EMPTY => q{}; # "Usual" targets for WantedBy const my @WANTEDBY_WHITELIST => qw{ default.target graphical.target multi-user.target network-online.target sleep.target sysinit.target }; # Known hardening flags in [Service] section const my @HARDENING_FLAGS => qw{ CapabilityBoundingSet DeviceAllow DynamicUser IPAddressDeny InaccessiblePaths KeyringMode LimitNOFILE LockPersonality MemoryDenyWriteExecute MountFlags NoNewPrivileges PrivateDevices PrivateMounts PrivateNetwork PrivateTmp PrivateUsers ProtectControlGroups ProtectHome ProtectHostname ProtectKernelLogs ProtectKernelModules ProtectKernelTunables ProtectSystem ReadOnlyPaths RemoveIPC RestrictAddressFamilies RestrictNamespaces RestrictRealtime RestrictSUIDSGID SystemCallArchitectures SystemCallFilter UMask }; # init scripts that do not need a service file has PROVIDED_BY_SYSTEMD => ( is => 'rw', lazy => 1, default =>sub { my ($self) = @_; return $self->data->load('systemd/init-whitelist'); } ); # array of names provided by the service files. # This includes Alias= directives, so after parsing # NetworkManager.service, it will contain NetworkManager and # network-manager. has service_names => (is => 'rw', default => sub { [] }); has timer_files => (is => 'rw', default => sub { [] }); has init_files_by_service_name => (is => 'rw', default => sub { {} }); has cron_scripts => (is => 'rw', default => sub { [] }); has is_rcs_script_by_name => (is => 'rw', default => sub { {} }); sub visit_installed_files { my ($self, $item) = @_; if ($item->name =~ m{/systemd/system/.*\.service$}) { $self->check_systemd_service_file($item); my $service_name = $item->basename; $service_name =~ s/@?\.service$//; push(@{$self->service_names}, $service_name); my @aliases = $self->extract_service_file_values($item, 'Install', 'Alias'); for my $alias (@aliases) { $self->pointed_hint('systemd-service-alias-without-extension', $item->pointer) if $alias !~ m/\.service$/; # maybe issue a tag for duplicates? $alias =~ s{ [.]service $}{}x; push(@{$self->service_names}, $alias); } } push(@{$self->timer_files}, $item) if $item->name =~ m{^(?:usr/)?lib/systemd/system/[^\/]+\.timer$}; push(@{$self->cron_scripts}, $item) if $item->dirname =~ m{^ etc/cron[.][^\/]+ / $}x; if ( $item->dirname eq 'etc/init.d/' && !$item->is_dir && (none { $item->basename eq $_} qw{README skeleton rc rcS}) && $self->processable->name ne 'initscripts' && $item->link ne 'lib/init/upstart-job' ) { unless ($item->is_file) { $self->pointed_hint('init-script-is-not-a-file', $item->pointer); return; } # sysv generator drops the .sh suffix my $service_name = $item->basename; $service_name =~ s{ [.]sh $}{}x; $self->init_files_by_service_name->{$service_name} //= []; push(@{$self->init_files_by_service_name->{$service_name}}, $item); $self->is_rcs_script_by_name->{$item->name} = $self->check_init_script($item); } if ($item->name =~ m{ /systemd/system/ .*[.]socket $}x) { my @values = $self->extract_service_file_values($item,'Socket','ListenStream'); $self->pointed_hint('systemd-service-file-refers-to-var-run', $item->pointer, 'ListenStream', $_) for grep { m{^/var/run/} } @values; } return; } sub installable { my ($self) = @_; my $lc = List::Compare->new([keys %{$self->init_files_by_service_name}], $self->service_names); my @missing_service_names = $lc->get_Lonly; for my $service_name (@missing_service_names) { next if $self->PROVIDED_BY_SYSTEMD->recognizes($service_name); my @init_files = @{$self->init_files_by_service_name->{$service_name} // []}; for my $init_file (@init_files) { # rcS scripts are particularly bad; always tag $self->pointed_hint( 'missing-systemd-service-for-init.d-rcS-script', $init_file->pointer, $service_name) if $self->is_rcs_script_by_name->{$init_file->name}; $self->pointed_hint('omitted-systemd-service-for-init.d-script', $init_file->pointer, $service_name) if @{$self->service_names} && !$self->is_rcs_script_by_name->{$init_file->name}; $self->pointed_hint('missing-systemd-service-for-init.d-script', $init_file->pointer, $service_name) if !@{$self->service_names} && !$self->is_rcs_script_by_name->{$init_file->name}; } } if (!@{$self->timer_files}) { $self->pointed_hint('missing-systemd-timer-for-cron-script', $_->pointer) for @{$self->cron_scripts}; } return; } # Verify that each init script includes /lib/lsb/init-functions, # because that is where the systemd diversion happens. sub check_init_script { my ($self, $item) = @_; my $lsb_source_seen; my $is_rcs_script = 0; my @lines = split(/\n/, $item->decoded_utf8); my $position = 1; for my $line (@lines) { # trim left $line =~ s/^\s+//; $lsb_source_seen = 1 if $position == 1 && $line =~ m{\A [#]! \s* (?:/usr/bin/env)? \s* /lib/init/init-d-script}xsm; $is_rcs_script = 1 if $line =~ m{#.*Default-Start:.*S}; next if $line =~ /^#/; $lsb_source_seen = 1 if $line =~ m{(?:\.|source)\s+/lib/(?:lsb/init-functions|init/init-d-script)}; } continue { ++$position; } $self->pointed_hint('init.d-script-does-not-source-init-functions', $item->pointer) unless $lsb_source_seen; return $is_rcs_script; } sub check_systemd_service_file { my ($self, $item) = @_; # ambivalent about /lib or /usr/lib $self->pointed_hint('systemd-service-in-odd-location', $item->pointer) if $item =~ m{^etc/systemd/system/}; unless ($item->is_open_ok || ($item->is_symlink && $item->link eq '/dev/null')) { $self->pointed_hint('service-file-is-not-a-file', $item->pointer); return 0; } my @values = $self->extract_service_file_values($item, 'Unit', 'After'); my @obsolete = grep { /^(?:syslog|dbus)\.target$/ } @values; $self->pointed_hint('systemd-service-file-refers-to-obsolete-target', $item->pointer, $_) for @obsolete; $self->pointed_hint('systemd-service-file-refers-to-obsolete-bindto', $item->pointer) if $self->extract_service_file_values($item, 'Unit', 'BindTo'); for my $key ( qw(ExecStart ExecStartPre ExecStartPost ExecReload ExecStop ExecStopPost) ) { $self->pointed_hint('systemd-service-file-wraps-init-script', $item->pointer, $key) if any { m{^/etc/init\.d/} } $self->extract_service_file_values($item, 'Service', $key); } unless ($item->link eq '/dev/null') { my @wanted_by = $self->extract_service_file_values($item, 'Install', 'WantedBy'); my $is_oneshot = any { $_ eq 'oneshot' } $self->extract_service_file_values($item, 'Service', 'Type'); # We are a "standalone" service file if we have no .path or .timer # equivalent. my $is_standalone = 1; if ($item =~ m{^(usr/)?lib/systemd/system/([^/]*?)@?\.service$}) { my ($usr, $service) = ($1 // $EMPTY, $2); $is_standalone = 0 if $self->processable->installed->resolve_path( "${usr}lib/systemd/system/${service}.path") || $self->processable->installed->resolve_path( "${usr}lib/systemd/system/${service}.timer"); } for my $target (@wanted_by) { $self->pointed_hint( 'systemd-service-file-refers-to-unusual-wantedby-target', $item->pointer, $target) unless (any { $target eq $_ } @WANTEDBY_WHITELIST) || $self->processable->name eq 'systemd'; } my @documentation = $self->extract_service_file_values($item, 'Unit','Documentation'); $self->pointed_hint('systemd-service-file-missing-documentation-key', $item->pointer) unless @documentation; for my $documentation (@documentation) { my @uris = split(m{\s+}, $documentation); my @invalid = grep { !is_uri($_) } @uris; $self->pointed_hint('invalid-systemd-documentation', $item->pointer, $_) for @invalid; } my @kill_modes = $self->extract_service_file_values($item, 'Service','KillMode'); for my $kill_mode (@kill_modes) { # trim both ends $kill_mode =~ s/^\s+|\s+$//g; $self->pointed_hint('kill-mode-none',$item->pointer, $_) if $kill_mode eq 'none'; } if ( !@wanted_by && !$is_oneshot && $is_standalone && $item =~ m{^(?:usr/)?lib/systemd/[^\/]+/[^\/]+\.service$} && $item !~ m{@\.service$}) { $self->pointed_hint('systemd-service-file-missing-install-key', $item->pointer) unless $self->extract_service_file_values($item, 'Install', 'RequiredBy') || $self->extract_service_file_values($item, 'Install', 'Also'); } my @pidfile = $self->extract_service_file_values($item,'Service','PIDFile'); for my $x (@pidfile) { $self->pointed_hint('systemd-service-file-refers-to-var-run', $item->pointer, 'PIDFile', $x) if $x =~ m{^/var/run/}; } my $seen_hardening = any { $self->extract_service_file_values($item, 'Service', $_) } @HARDENING_FLAGS; $self->pointed_hint('systemd-service-file-missing-hardening-features', $item->pointer) unless $seen_hardening || $is_oneshot || any { 'sleep.target' eq $_ } @wanted_by; if ( $self->extract_service_file_values( $item, 'Unit', 'DefaultDependencies', 1 ) ) { my @before = $self->extract_service_file_values($item, 'Unit','Before'); my @conflicts = $self->extract_service_file_values($item, 'Unit','Conflicts'); $self->pointed_hint('systemd-service-file-shutdown-problems', $item->pointer) if (none { $_ eq 'shutdown.target' } @before) && (any { $_ eq 'shutdown.target' } @conflicts); } my %bad_users = ( 'User' => 'nobody', 'Group' => 'nogroup', ); for my $key (keys %bad_users) { my $value = $bad_users{$key}; $self->pointed_hint('systemd-service-file-uses-nobody-or-nogroup', $item->pointer, "$key=$value") if any { $_ eq $value } $self->extract_service_file_values($item, 'Service',$key); } for my $key (qw(StandardError StandardOutput)) { for my $value (qw(syslog syslog-console)) { $self->pointed_hint( 'systemd-service-file-uses-deprecated-syslog-facility', $item->pointer, "$key=$value") if any { $_ eq $value } $self->extract_service_file_values($item, 'Service',$key); } } } return 1; } sub service_file_lines { my ($item) = @_; my @output; return @output if $item->is_symlink and $item->link eq '/dev/null'; my @lines = split(/\n/, $item->decoded_utf8); my $continuation = $EMPTY; my $position = 1; for my $line (@lines) { $line = $continuation . $line; $continuation = $EMPTY; if ($line =~ s/\\$/ /) { $continuation = $line; next; } # trim right $line =~ s/\s+$//; next unless length $line; next if $line =~ /^[#;\n]/; push(@output, $line); } return @output; } # Extracts the values of a specific Key from a .service file sub extract_service_file_values { my ($self, $item, $extract_section, $extract_key) = @_; return () unless length $extract_section && length $extract_key; my @values; my $section; my @lines = service_file_lines($item); for my $line (@lines) { # section header if ($line =~ /^\[([^\]]+)\]$/) { $section = $1; next; } if (!defined($section)) { # Assignment outside of section. Ignoring. next; } my ($key, $value) = ($line =~ m{^(.*)\s*=\s*(.*)$}); if ( defined($key) && $section eq $extract_section && $key eq $extract_key) { if (length $value) { push(@values, shellwords($value)); } else { # Empty assignment resets the list @values = (); } } } return @values; } 1; # Local Variables: # indent-tabs-mode: nil # cperl-indent-level: 4 # End: # vim: syntax=perl sw=4 sts=4 sr et