Edit

IABSD.fr/src/libexec/security/security

Branch :

  • Show log

    Commit

  • Author : millert
    Date : 2020-10-11 18:28:17
    Hash : 14dbc680
    Message : Don't skip file systems just because the parent fs is nodev and nosuid. Fixes instances where a mount point uses the nodev and nosuid options but another file system mounted inside that hierarchy does not. OK schwarze@

  • libexec/security/security
  • #!/usr/bin/perl -T
    
    # $OpenBSD: security,v 1.41 2020/10/11 18:28:17 millert Exp $
    #
    # Copyright (c) 2011, 2012, 2014, 2015 Ingo Schwarze <schwarze@openbsd.org>
    # Copyright (c) 2011 Andrew Fresh <andrew@afresh1.com>
    #
    # Permission to use, copy, modify, and distribute this software for any
    # purpose with or without fee is hereby granted, provided that the above
    # copyright notice and this permission notice appear in all copies.
    #
    # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
    # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
    # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
    # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
    # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
    
    use warnings;
    use strict;
    
    use Digest::SHA qw(sha256_hex);
    use Errno qw(ENOENT);
    use Fcntl qw(O_RDONLY O_NONBLOCK :mode);
    use File::Basename qw(basename);
    use File::Compare qw(compare);
    use File::Copy qw(copy);
    require File::Find;
    
    use constant {
    	BACKUP_DIR => '/var/backups/',
    };
    
    $ENV{PATH} = '/bin:/usr/bin:/sbin:/usr/sbin';
    delete $ENV{ENV};
    umask 077;
    
    my $check_title;
    my $return_code = 0;
    
    sub nag ($$) {
    	my ($cond, $msg) = @_;
    	if ($cond) {
    		if ($check_title) {
    			print "\n$check_title\n";
    			undef $check_title;
    		}
    		print "$msg\n";
    		$return_code = 1;
    	}
    	return $cond;
    }
    
    sub close_or_nag {
    	my ($fh, $cmd) = @_;
    	my $res = close $fh;
    	nag !$res, "$cmd: " .
    	    ($! ? "error closing pipe: $!" : "exit code " . ($? >> 8));
    	return $res;
    }
    
    sub check_access_file {
    	my ($filename, $login) = @_;
    	return unless -e $filename;
    	my $mode = (stat(_))[2];
    	nag $mode & (S_IRUSR | S_IRGRP | S_IROTH) && ! -O $filename,
    	    "Login $login is off but still has a valid shell " .
    	    "and alternate access files in\n" .
    	    "\t home directory are still readable.";
    }
    
    sub check_passwd {
    	my $filename = '/etc/master.passwd';
    	$check_title = "Checking the $filename file:";
    	nag !(open my $fh, '<', $filename), "open: $filename: $!" and return;
    	my (%logins, %uids);
    	while (my $line = <$fh>) {
    		chomp $line;
    		nag $line !~ /\S/,
    		    "Line $. is a blank line."
    		    and next;
    		my @f = split /:/, $line, -1;
    		nag @f != 10,
    		    "Line $. has the wrong number of fields:\n$line";
    		my ($name, $pwd, $uid, $gid, $class, $chg, $exp, $gecos,
    		    $home, $shell) = @f;
    		next if $name =~ /^[+-]/;  # skip YP lines
    		unless (nag $name eq '',
    		    "Line $. has an empty login field:\n$line") {
    			nag $name !~ /^[A-Za-z0-9_][-.A-Za-z0-9_]*\$?$/,
    			    "Login $name has non-alphanumeric characters.";
    			nag $logins{$name}++,
    			    "Duplicate user name $name.";
    		}
    		nag length $name > 31,
    		    "Login $name has more than 31 characters.";
    		nag $pwd eq '' && !($name eq 'anoncvs' &&
    				    $shell =~ /\/anoncvssh$/),
    		    "Login $name has no password.";
    		if ($pwd ne '' &&
    		    $pwd ne 'skey' &&
    		    length $pwd != 13 &&
    		    $pwd !~ /^\$[0-9a-f]+\$/ &&
    		    ($shell eq '' || $shell =~ /sh$/)) {
    			nag -s "/etc/skey/$name",
    			    "Login $name is off but still has a valid " .
    			    "shell and an entry in /etc/skey.";
    			nag -d $home && ! -r $home,
    			    "Login $name is off but still has valid " .
    			    "shell and home directory is unreadable\n" .
    			    "\t by root; cannot check for existence " .
    			    "of alternate access files."
    			or check_access_file "$home/.$_", $name
    			    foreach qw(ssh rhosts shosts);
    		}
    		nag $uid == 0 && $name ne 'root',
    		    "Login $name has a user ID of 0.";
    		nag $uid < 0,
    		    "Login $name has a negative user ID.";
    		nag $uids{$uid}++,
    		    "Login $name has duplicate user ID $uid.";
    		nag $gid < 0,
    		    "Login $name has a negative group ID.";
    		nag $exp != 0 && $exp < time,
    		    "Login $name has expired.";
    	}
    	close $fh;
    }
    
    # Backup the master password file; a special case, the normal backup
    # mechanisms also print out file differences and we don't want to do
    # that because this file has encrypted passwords in it.
    sub backup_passwd {
    	my $base = 'master.passwd';
    	my $orig = "/etc/$base";
    	my $curr = BACKUP_DIR . "$base.current";
    	if (!-s $curr) {
    		# nothing
    	} elsif (compare $curr, $orig) {
    		copy $curr, BACKUP_DIR . "$base.backup";
    	} else {
    		return;
    	}
    	copy $orig, $curr;
    	chown 0, 0, $curr;
    }
    
    # Check the group file syntax.
    sub check_group {
    	my $filename = '/etc/group';
    	$check_title = "Checking the $filename file:";
    	nag !(open my $fh, '<', $filename), "open: $filename: $!" and return;
    	my (%names, $global_yp);
    	while (my $line = <$fh>) {
    		chomp $line;
    		nag $global_yp,
    		    'Global YP inclusion ("+") is not the last line.'
    		    and undef $global_yp;
    		if ($line eq '+') {
    			$global_yp = 1;
    			next;
    		}
    		nag $line !~ /\S/,
    		    "Line $. is a blank line."
    		    and next;
    		my @f = split /:/, $line, -1;
    		nag @f != 4,
    		    "Line $. has the wrong number of fields:\n$line";
    		my ($name, $pwd, $gid, $members) = @f;
    		next if $name =~ /^[+-]/;  # skip YP lines
    		unless (nag $name eq '',
    		    "Line $. has an empty group name field:\n$line") {
    			nag $name !~ /^[A-Za-z0-9_][-.A-Za-z0-9_]*$/,
    			    "Group $name has non-alphanumeric characters.";
    			nag $names{$name}++,
    			    "Duplicate group name $name.";
    		}
    		nag length $name > 31,
    		    "Group $name has more than 31 characters.";
    		nag $gid =~ /[^\d]/,
    		    "Group $name has an invalid group ID.";
    	}
    	close $fh;
    }
    
    sub check_umask {
    	my ($filename) = @_;
    	nag !(open my $fh, '<', $filename), "open: $filename: $!" and return;
    	my $umaskset;
    	while (<$fh>) {
    		next unless /^\s*umask\s+([0-7]+)/;
    		my $umask = "0$1";
    		$umaskset = 1;
    		my ($other, $group) = reverse split '', $umask;
    		nag $group =~ /^[0145]$/,
    		    "Root umask is group writable";
    		nag $other =~ /^[0145]$/,
    		    "Root umask is other writable";
    	}
    	close $fh;
    	return $umaskset;
    }
    
    # This type of test by spawning a shell is messy and fragile.
    # Instead, consider modifying the shells to warn about '.' in the PATH.
    sub check_root_path {
    	my ($path, $filename) = @_;
    	nag !(defined $path && $path =~ s/^PATH=[:\s]*//),
    	    "Failed to find PATH in $filename."
    	    and return;
    	foreach my $dir (split /[:\s]+/, $path) {
    		nag $dir eq '.', "The root path includes ." and next;
    		next unless -d $dir;
    		my $mode = (stat(_))[2];
    		nag $mode & S_IWGRP,
    		    "Root path directory $dir is group writable.";
    		nag $mode & S_IWOTH,
    		    "Root path directory $dir is other writable.";
    	}
    }
    
    # Check for umask values and root paths in startup files.
    sub check_csh {
    	my @list = qw(/etc/csh.cshrc /etc/csh.login /root/.cshrc /root/.login);
    	$check_title = "Checking root csh paths, umask values:\n@list";
    
    	my $umaskset = 0;
    	foreach my $filename (@list) {
    		next unless -s $filename;
    		$umaskset = 1 if check_umask $filename;
    
    		nag !(open my $fh, '-|', qw(/bin/csh -f -c),
    			"eval 'source $filename' >& /dev/null; " .
    			"echo PATH=\$path"),
    		    "cannot spawn /bin/csh: $!"
    		    and next;
    		my @output = <$fh>;
    		close_or_nag $fh, "csh $filename" or next;
    		chomp @output;
    		check_root_path pop @output, $filename;
    	}
    	nag !$umaskset,
    	    "\nRoot csh startup files do not set the umask.";
    }
    
    sub check_sh {
    	my @list = qw(/etc/profile /root/.profile);
    	$check_title = "Checking root sh paths, umask values:\n@list";
    
    	my @env_path;
    	my $umaskset = 0;
    	foreach my $filename (@list) {
    		next unless -s $filename;
    		$umaskset ||= check_umask($filename);
    
    		nag !(open my $fh, '-|', qw(/bin/sh -c),
    			". $filename; echo ENV=\$ENV; echo PATH=\$PATH"),
    		    "cannot spawn /bin/sh: $!"
    		    and next;
    		my @output = <$fh>;
    		close_or_nag $fh, "sh $filename" or next;
    		chomp @output;
    		check_root_path pop @output, $filename;
    
    		my $env = pop @output;
    		nag !(defined $env && $env =~ /^ENV=\s*(\S*)/),
    		    "Failed to find ENV in $filename."
    		    and next;
    		push @env_path, $1 if $1 ne '';
    	}
    	nag !$umaskset,
    	    "\nRoot sh startup files do not set the umask.";
    	return @env_path;
    }
    
    sub check_ksh {
    	my @list = ('/etc/ksh.kshrc', @_);
    	$check_title = "Checking root ksh paths, umask values:\n@list";
    
    	# Usually, we are at HOME anyway, but for the ENV check, this
    	# is particularly important, so make sure we are really there.
    	chdir '/root';
    
    	# A good .kshrc will not have a umask or path, 
    	# that being set in .profile; check anyway.
    	foreach my $filename (@list) {
    		next unless -s $filename;
    		check_umask($filename);
    
    		nag !(open my $fh, '-|', qw(/bin/ksh -c),
    			". $filename; echo PATH=\$PATH"),
    		    "cannot spawn /bin/ksh: $!"
    		    and next;
    		my @output = <$fh>;
    		close_or_nag $fh, "ksh $filename" or next;
    		chomp @output;
    		check_root_path pop @output, $filename;
    	}
    }
    
    # Uudecode should not be in the /etc/mail/aliases file.
    sub check_mail_aliases {
    	my $filename = '/etc/mail/aliases';
    	nag !(open my $fh, '<', $filename), "open: $filename: $!" and return;
    	no warnings 'uninitialized';
    	nag /^((?:uu)?decode)/,
    	    "There is an entry for $1 in the $filename file."
    	    while <$fh>;
    	close $fh;
    }
    
    # hostname.if files may contain secrets and should not be world-readable.
    sub check_hostname_if {
    	while (my $filename = glob '/etc/hostname.*') {
    		next unless -e $filename;
    		my $mode = (stat(_))[2];
    		nag $mode & S_IRWXO,
    		    "$filename is world readable.";
    	}
    }
    
    # hosts.lpd should not have + signs.
    sub check_hosts_lpd {
    	my $filename = '/etc/hosts.lpd';
    	-s $filename or return;
    	nag !(open my $fh, '<', $filename), "open: $filename: $!" and return;
    	nag /^\+/ && !/^\+@/,
    	    "Plus sign in $filename file."
    	    while <$fh>;
    	close $fh;
    }
    
    sub find_homes {
    	my $filename = '/etc/passwd';
    	nag !(open my $fh, '<', $filename),
    	    "open: $filename: $!"
    	    and return [];
    	my $homes = [];
    	while (<$fh>) {
    		my $entry = [ @{[split /:/]}[0,2,5] ];
    		chomp;
    		nag !defined $entry->[2],
    		    "Incomplete line \"$_\" in $filename."
    		    and next;
    		chomp $entry->[2];
    		push @$homes, $entry;
    	}
    	close $fh;
    	return $homes;
    }
    
    # Check for special users with .rhosts/.shosts files.
    # Only root should have .rhosts/.shosts files.
    sub check_rhosts_owner {
    	my ($name, $uid, $home) = @_;
    	return if $name =~ /^[+-]/;  # skip YP lines
    	foreach my $base (qw(rhosts shosts)) {
    		my $filename = "$home/.$base";
    		next unless -s $filename;
    		nag ! -O $filename &&
    		    ($name eq 'ftp' || ($uid < 100 && $name ne 'root')),
    		    "$filename is not owned by root.";
    	}
    }
    
    # Also, .rhosts/.shosts files should not have plus signs.
    sub check_rhosts_content {
    	my ($name, $uid, $home) = @_;
    	foreach my $base (qw(rhosts shosts)) {
    		my $filename = "$home/.$base";
    		next unless -s $filename;
    		nag !sysopen(my $fh, $filename, O_RDONLY | O_NONBLOCK),
    		    "open: $filename: $!"
    		    and next;
    		nag !(-f $fh),
    		    "$filename is not a regular file"
    		    and next;
    		local $_;
    		nag /^\+\s*$/,
    		    "$filename has + sign in it."
    		    while <$fh>;
    		close $fh;
    	}
    }
    
    # Home directories should not be owned by someone else or writeable.
    sub check_homedir {
    	my ($name, $uid, $home) = @_;
    	return if $name =~ /^[+-]/;  # skip YP lines
    	return unless -d $home;
    	my ($mode, $fuid) = (stat(_))[2,4];
    	nag $fuid && $fuid != $uid,
    	    "user $name home directory is owned by " .
    	    ((getpwuid $fuid)[0] // $fuid);
    	nag $mode & S_IWGRP,
    	    "user $name home directory is group writable";
    	nag $mode & S_IWOTH,
    	    "user $name home directory is other writable";
    }
    
    # Files that should not be owned by someone else or readable.
    sub check_dot_readable {
    	my ($name, $uid, $home) = @_;
    	return if $name =~ /^[+-]/;  # skip YP lines
    	foreach my $f (qw(
    	    .netrc .rhosts .gnupg/secring.gpg .gnupg/random_seed
    	    .pgp/secring.pgp .shosts .ssh/identity .ssh/id_dsa .ssh/id_ecdsa
    	    .ssh/id_rsa .ssh/id_ed25519
    	)) {
    		next unless -e "$home/$f";
    		my ($mode, $fuid) = (stat(_))[2,4];
    		nag $fuid && $fuid != $uid,
    		    "user $name $f file is owned by " .
    		    ((getpwuid $fuid)[0] // $fuid);
    		nag $mode & S_IRGRP,
    		    "user $name $f file is group readable";
    		nag $mode & S_IROTH,
    		    "user $name $f file is other readable";
    		nag $mode & S_IWGRP,
    		    "user $name $f file is group writable";
    		nag $mode & S_IWOTH,
    		    "user $name $f file is other writable";
    	}
    }
    
    # Files that should not be owned by someone else or writeable.
    sub check_dot_writeable {
    	my ($name, $uid, $home) = @_;
    	return if $name =~ /^[+-]/;  # skip YP lines
    	foreach my $f (qw(
    	    .bashrc .bash_profile .bash_login .bash_logout .cshrc
    	    .emacs .exrc .forward .fvwmrc .inputrc .kshrc .login
    	    .logout .nexrc .profile .screenrc .ssh .ssh/config
    	    .ssh/authorized_keys .ssh/authorized_keys2 .ssh/environment
    	    .ssh/known_hosts .ssh/rc .tcshrc .twmrc .xsession .xinitrc
    	    .Xdefaults .Xauthority
            )) {
    		next unless -e "$home/$f";
    		my ($mode, $fuid) = (stat(_))[2,4];
    		nag $fuid && $fuid != $uid,
    		    "user $name $f file is owned by " .
    		    ((getpwuid $fuid)[0] // $fuid);
    		nag $mode & S_IWGRP,
    		    "user $name $f file is group writable";
    		nag $mode & S_IWOTH,
    		    "user $name $f file is other writable";
    	}
    }
    
    # Mailboxes should be owned by the user and unreadable.
    sub check_mailboxes {
    	my $dir = '/var/mail';
    	nag !(opendir my $dh, $dir), "opendir: $dir: $!" and return;
    	foreach my $name (readdir $dh) {
    		next if $name =~ /^\.\.?$/;
    		next if $name =~ /.\.lock$/;
    		my ($mode, $fuid, $fgid) = (stat "$dir/$name")[2,4,5];
    		unless (defined $mode) {
    			nag !$!{ENOENT}, "stat: $dir/$name: $!";
    			next;
    		}
    		my $fname = (getpwuid $fuid)[0] // $fuid;
    		my $gname = (getgrgid $fgid)[0] // $fgid;
    		nag $fname ne $name,
    		    "user $name mailbox is owned by $fname";
    		nag S_IMODE($mode) != (S_IRUSR | S_IWUSR),
    		    sprintf 'user %s mailbox is %s, group %s',
    		        $name, strmode($mode), $gname;
    	}
    	closedir $dh;
    }
    
    # File systems should not be globally exported.
    sub check_exports {
    	my $filename = '/etc/exports';
    	return unless -e $filename;
    	nag !(open my $fh, '<', $filename), "open: $filename: $!" and return;
    
    	LINE: while (<$fh>) {
    		chomp;
    		next if /^(?:#|$)/;
    
    		my @fs;
    		my $readonly = 0;
    		foreach (split) {
    			if (/^\//)                   { push @fs, $_; }
    			elsif ($_ eq '-ro')          { $readonly = 1; }
    			elsif (/^(?:[^-]|-network)/) { next LINE; }
    		}
    
    		nag 1, "File system @fs globally exported, "
    		    . ($readonly ? 'read-only.' : 'read-write.');
    	}
    	close $fh;
    }
    
    sub strmode_x {
    	my ($mode, $x, $s) = @_;
    	$x &= $mode;
    	$s &= $mode;
    	return ($x && $s) ? 's' : $x ? 'x' : $s ? 'S' : '-';
    }
    
    sub strmode {
    	my ($mode) = @_;
    
    	my %types = (
    		S_IFDIR,  'd',    # directory
    		S_IFCHR,  'c',    # character special
    		S_IFBLK,  'b',    # block special
    		S_IFREG,  '-',    # regular
    		S_IFLNK,  'l',    # symbolic link
    		S_IFSOCK, 's',    # socket
    		S_IFIFO,  'p',    # fifo
    	);
    
    	return
    	      ($types{ $mode & S_IFMT } || '?')
    	    . (($mode & S_IRUSR) ? 'r' : '-')
    	    . (($mode & S_IWUSR) ? 'w' : '-')
    	    . (strmode_x $mode, S_IXUSR, S_ISUID)
    	    . (($mode & S_IRGRP) ? 'r' : '-')
    	    . (($mode & S_IWGRP) ? 'w' : '-')
    	    . (strmode_x $mode, S_IXGRP, S_ISGID)
    	    . (($mode & S_IROTH) ? 'r' : '-')
    	    . (($mode & S_IWOTH) ? 'w' : '-')
    	    . (strmode_x $mode, S_IXOTH, S_ISVTX);
    }
    
    sub find_special_files {
    	my (%skip, @fs);
    
    	%skip = map { $_ => 1 } split ' ', $ENV{SUIDSKIP}
    	    if $ENV{SUIDSKIP};
    
    	# Add mount points of non-local file systems
    	# to the list of directories to skip.
    	nag !(open my $fh, '-|', 'mount'),
    	    "cannot spawn mount: $!"
    	    and return;
    	while (<$fh>) {
    		my ($path, $opt) = /\son\s+(.*?)\s+type\s+\w+(.*)/;
    		push @fs, $path if $path && $opt =~ /local/ &&
    		    !($opt =~ /nodev/ && $opt =~ /nosuid/);
    	}
    	close_or_nag $fh, "mount" or return;
    	return unless @fs;
    
    	my $setuid_files = {};
    	my $device_files = {};
    	my $uudecode_is_setuid = 0;
    
    	File::Find::find({no_chdir => 1, wanted => sub {
    
    		if ($skip{$_}) {
    			$File::Find::prune = 1;
    			return;
    		}
    
    		my ($dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size,
    		    $atime, $mtime, $ctime, $blksize, $blocks) = lstat;
    		if (defined $dev) {
    			no warnings 'once';
    			if ($dev != $File::Find::topdev) {
    				$File::Find::prune = 1;
    				return;
    			}
    		} else {
    			nag !$!{ENOENT}, "stat: $_: $!";
    			return;
    		}
    
    		# SUID/SGID files
    		my $file = {};
    		if (-f _ && $mode & (S_ISUID | S_ISGID)) {
    			$setuid_files->{$File::Find::name} = $file;
    			$uudecode_is_setuid = 1
    			    if basename($_) eq 'uudecode';
    		}
    
    		# Special Files
    		elsif (!-d _ && !-f _ && !-l _ && !-S _ && !-p _ ) {
    			$device_files->{$File::Find::name} = $file;
    			$file->{major} = (($rdev >> 8) & 0xff) . ',';
    			$file->{minor} = (($rdev >> 8) & 0xffff00) |
    			    ($rdev & 0xff);
    		} else {
    			return;
    		}
    
    		$file->{mode}    = $mode;
    		$file->{strmode} = strmode $mode;
    		$file->{nlink}   = $nlink;
    		$file->{user}    = (getpwuid $uid)[0] // $uid;
    		$file->{group}   = (getgrgid $gid)[0] // $gid;
    		$file->{size}    = $size;
    		@$file{qw(wday mon day time year)} =
    		    split ' ', localtime $mtime;
    	}}, @fs);
    
    	nag $uudecode_is_setuid, 'Uudecode is setuid.';
    	return $setuid_files, $device_files;
    }
    
    sub adjust_columns {
    	my (@table) = @_;
    
    	my @s;
    	foreach my $row (@table) {
    		for (0 .. $#$row) {
    			$s[$_] = length $row->[$_]
    			    if (!$s[$_] || length $row->[$_] > $s[$_]);
    		}
    	}
    	$s[-1] = '0';
    	my $fmt = join ' ', map { m/(\d+)/ && "%-$1s"} @s;
    
    	return map { sprintf $fmt, @$_ } @table;
    }
    
    # Display any changes in setuid/setgid files and devices.
    sub check_filelist {
    	my ($files, $mode) = @_;
    	my $current = BACKUP_DIR . "$mode.current";
    	my $backup  = BACKUP_DIR . "$mode.backup";
    	my @fields  = (
    	    qw(strmode nlink user group),
    	    $mode eq 'device' ?  qw(major minor) : 'size',
    	    qw(mon day time year)
    	);
    
    	my %current;
    	if (-s $current) {
    		nag !(open my $fh, '<', $current), "open: $current: $!"
    		    and return;
    		while (<$fh>) {
    			chomp;
    			my (%f, $file);
    			(@f{@fields}, $file) = split ' ', $_, @fields + 1;
    			$current{$file} = \%f;
    		}
    		close $fh;
    	}
    
    	my %changed;
    	foreach my $f (sort keys %$files) {
    		if (my $old = delete $current{$f}) {
    			next if $mode eq 'device' &&
    			    !S_ISBLK($files->{$f}{mode});
    			foreach my $k (@fields) {
    				next if $old->{$k} eq $files->{$f}{$k};
    				push @{$changed{changes}},
    				    [ @$old{@fields}, $f ],
    				    [ @{$files->{$f}}{@fields}, $f ];
    				last;
    			}
    			next;
    		}
    		push @{$changed{additions}}, [ @{$files->{$f}}{@fields}, $f ];
    	}
    	foreach my $f (sort keys %current) {
    		push @{$changed{deletions}}, [ @{$current{$f}}{@fields}, $f ];
    	};
    
    	foreach my $k (qw( additions deletions changes )) {
    		next unless exists $changed{$k};
    		$mode = 'block device' if $mode eq 'device' && $k eq 'changes';
    		$check_title = (ucfirst $mode) . " $k:";
    		nag 1, $_ for adjust_columns @{$changed{$k}};
    	}
    
    	return if !%changed;
    	copy $current, $backup;
    
    	nag !(open my $fh, '>', $current), "open: $current: $!" and return;
    	print $fh "@{$files->{$_}}{@fields} $_\n" foreach sort keys %$files;
    	close $fh;
    }
    
    # Check for block and character disk devices that are readable or writeable
    # or not owned by root.operator.
    sub check_disks {
    	my ($files) = @_;
    
    	my $disk_re = qr/
    	    \/
    	    (?:ccd|dk|fd|hd|hk|hp|jb|kra|ra|rb|rd|rl|rx|rz|sd|up|vnd|wd|xd)
    	    \d+ [B-H]? [a-p] 
    	    $
    	/x;
    
    	foreach my $file (sort keys %$files) {
    		next if $file !~ /$disk_re/;
    		my $f = $files->{$file};
    		nag $f->{user} ne 'root' || $f->{group} ne 'operator' ||
    			S_IMODE($f->{mode}) != (S_IRUSR | S_IWUSR | S_IRGRP),
    		    sprintf("Disk %s is user %s, group %s, permissions %s.",
    			$file, $f->{user}, $f->{group}, $f->{strmode});
    	}
    }
    
    # Check special files and system binaries.
    #
    # Create the mtree tree specifications using:
    #
    #       mtree -cx -p DIR -K sha256digest,type > /etc/mtree/DIR.secure
    #       chown root:wheel /etc/mtree/DIR.secure
    #       chmod 600 /etc/mtree/DIR.secure
    #
    # Note, this is not complete protection against Trojan horsed binaries, as
    # the hacker can modify the tree specification to match the replaced binary.
    # For details on really protecting yourself against modified binaries, see
    # the mtree(8) manual page.
    sub check_mtree {
    	nag !-d '/etc/mtree', '/etc/mtree is missing' and return;
    
    	if (open my $fh, '-|', qw(mtree -e -l -p / -f /etc/mtree/special)) {
    		nag 1, $_ for map { chomp; $_ } <$fh>;
    		close_or_nag $fh, "mtree special";
    	} else { nag 1, "cannot spawn mtree: $!"; }
    
    	while (my $filename = glob '/etc/mtree/*.secure') {
    		nag !(open my $fh, '<', $filename),
    		    "open: $filename: $!"
    		    and next;
    
    		my $tree;
    		while (<$fh>) {
    			last unless /^#/;
    			($tree) = /^#\s+tree:\s+(.*)/ and last;
    		}
    		next unless $tree;
    
    		$check_title = "Checking system binaries in $tree:";
    		nag !(open $fh, '-|', 'mtree', '-f', $filename, '-p', $tree),
    		    "cannot spawn mtree: $!"
    		    and next;
    		nag 1, $_ for map { chomp; $_ } <$fh>;
    		close_or_nag $fh, "mtree $filename";
    	}
    }
    
    sub diff {
    	nag !(open my $fh, '-|', qw(diff -ua), @_),
    	    "cannot spawn diff: $!"
    	    and return;
    	local $/;
    	my $diff = <$fh>;
    	{
    		close $fh and last;
    		nag $!, "diff: error closing pipe: $!" and last;
    		nag $? >> 8 > 1, "diff: exit code " . ($? >> 8);
    	}
    	return nag !!$diff, $diff;
    }
    
    sub backup_if_changed {
    	my ($orig) = @_;
    
    	my ($backup) = $orig =~ /(.*)/;
    	if (index $backup, BACKUP_DIR) {
    		$backup =~ s{^/}{};
    		$backup =~ s{/}{_}g;
    		$backup = BACKUP_DIR . $backup;
    	}
    	my $current = "$backup.current";
    	$backup .= '.backup';
    	my $last = -s $current ? $current : '/dev/null';
    	$orig    = '/dev/null' unless -s $orig;
    
    	diff $last, $orig or return;
    
    	if (-s $current) {
    		copy $current, $backup;
    		chown 0, 0, $backup;
    	}
    	if ($orig eq '/dev/null') {
    		unlink $current;
    	} else {
    		copy $orig, $current;
    		chown 0, 0, $current;
    	}
    }
    
    sub backup_digest {
    	my ($orig) = @_;
    
    	my ($backup) = $orig =~ m{^/?(.*)};
    	$backup =~ s{/}{_}g;
    	my $current = BACKUP_DIR . "$backup.current.sha256";
    	$backup = BACKUP_DIR . "$backup.backup.sha256";
    
    	my $digest_new = 0;
    	if (-s $orig) {
    		if (open my $fh, '<', $orig) {
    			binmode $fh;
    			local $/;
    			$digest_new = sha256_hex(<$fh>);
    			close $fh;
    		} else { nag 1, "open: $orig: $!"; }
    	}
    
    	my $digest_old = 0;
    	if (-s $current) {
    		if (open my $fh, '<', $current) {
    			$digest_old = <$fh>;
    			close $fh;
    			chomp $digest_old;
    		} else { nag 1, "open: $current: $!"; }
    	}
    
    	return if $digest_old eq $digest_new;
    
    	if ($digest_old && $digest_new) {
    		copy $current, $backup;
    		chown 0, 0, $backup;
    		chmod 0600, $backup;
    	} elsif ($digest_old) {
    		$check_title = "======\n$orig removed SHA-256 checksum\n======";
    		unlink $current;
    	} elsif ($digest_new) {
    		$check_title = "======\n$orig new SHA-256 checksum\n======";
    	}
    
    	if ($digest_new) {
    		if (open my $fh, '>', $current) {
    			print $fh "$digest_new\n";
    			close $fh;
    		} else { nag 1, "open: $current: $!\n"; }
    		chown 0, 0, $current;
    		chmod 0600, $current;
    	}
    
    	nag $digest_old, "OLD: $digest_old";
    	nag $digest_new, "NEW: $digest_new";
    }
    
    # List of files that get backed up and checked for any modifications.  Each
    # file is expected to have two backups, /var/backups/file.{current,backup}.
    # Any changes cause the files to rotate.
    sub check_changelist {
    	my $filename = '/etc/changelist';
    	-s $filename or return;
    	nag !(open my $fh, '<', $filename), "open: $filename: $!" and return;
    
    	my @relative;
    	while (<$fh>) {
    		next if /^(?:#|\s*$)/;
    		chomp;
    		my $plus = s/^\+//;
    		unless (/^\//) {
    			push @relative, $_;
    			next;
    		}
    		my $tilda = /~$/;
    
    		foreach (glob) {
    			next if $_ eq '/etc/master.passwd';
    			next if /~$/ && !$tilda;
    			next if -d $_;
    
    			if ($plus) {
    				$check_title =
    				    "======\n$_ SHA-256 checksums\n======";
    				backup_digest $_;
    			} else {
    				$check_title =
    				    "======\n$_ diffs (-OLD  +NEW)\n======";
    				backup_if_changed $_;
    			}
    		}
    	}
    	close $fh;
    
    	$check_title = "Skipped relative paths in changelist(5):";
    	nag 1, $_ foreach @relative;
    }
    
    # Make backups of the labels for any mounted disks
    # and produce diffs when they change.
    sub check_disklabels {
    	nag !(open my $fh, '-|', qw(df -ln)),
    	    "cannot spawn df: $!"
    	    and return;
    	my @disks = sort map m{^/dev/(\w*\d*)[a-p]}, <$fh>;
    	close_or_nag $fh, "df";
    
    	foreach my $disk (@disks) {
    		$check_title = "======\n$disk diffs (-OLD  +NEW)\n======";
    		my $filename = BACKUP_DIR . "disklabel.$disk";
    		system "disklabel $disk > $filename";
    		backup_if_changed $filename;
    		unlink $filename;
    	}
    }
    
    # Backup the list of installed packages and produce diffs when it changes.
    sub check_pkglist {
    	$check_title = "======\nPackage list changes (-OLD  +NEW)\n======";
    	my $filename = BACKUP_DIR . 'pkglist';
    	system "pkg_info > $filename 2>&1";
    	backup_if_changed $filename;
    	unlink $filename;
    }
    
    # main program
    check_passwd;
    backup_passwd;
    check_group;
    check_csh;
    check_ksh(check_sh);
    $check_title = "Checking configuration files:";
    check_mail_aliases;
    check_hostname_if;
    check_hosts_lpd;
    $check_title = "Checking for special users with .rhosts/.shosts files.";
    my $homes = find_homes;
    check_rhosts_owner @$_ foreach @$homes;
    $check_title = "Checking .rhosts/.shosts files syntax.";
    check_rhosts_content @$_ foreach @$homes;
    $check_title = "Checking home directories.";
    check_homedir @$_ foreach @$homes;
    $check_title = "Checking dot files.";
    check_dot_readable @$_ foreach @$homes;
    check_dot_writeable @$_ foreach @$homes;
    $check_title = "Checking mailbox ownership.";
    check_mailboxes;
    $check_title = "Checking for globally exported file systems.";
    check_exports;
    $check_title = "Setuid/device find errors:";
    my ($setuid_files, $device_files) = find_special_files;
    $check_title = "Checking setuid/setgid files and devices:";
    check_filelist $setuid_files, 'setuid' if $setuid_files;
    $check_title = "Checking disk ownership and permissions.";
    check_disks $device_files;
    check_filelist $device_files, 'device' if $device_files;
    $check_title = "Checking special files and directories.\n" .
        "Output format is:\n\tfilename:\n\t\tcriteria (shouldbe, reallyis)";
    check_mtree;
    $check_title = "Backing up and comparing configuration files.";
    check_changelist;
    $check_title = "Checking disklabels of mounted disks:";
    check_disklabels;
    check_pkglist;
    exit $return_code;