#!/usr/bin/perl #! $Id: sudoscriptd-in,v 1.9 2004/11/12 05:27:54 hbo Exp $ use strict; use warnings; use POSIX qw(mkfifo setsid); use POSIX qw(:sys_wait_h); use Fcntl qw(O_RDWR O_RDONLY O_WRONLY); use File::Path; use Sys::Syslog qw(:DEFAULT setlogsock); use File::Basename qw(dirname basename); use Getopt::Long; use lib "/usr/lib/sudoscript"; use Sudoscript; our $ss=Sudoscript->new(); exit if (! defined $ss); # We take one option: --datefmt|-d which controls how dates are written in the log. my ($DATEFMT,$killme); GetOptions ( "datefmt:s" => \$DATEFMT, "kill" => \$killme, ); $DATEFMT='short' if (! defined $DATEFMT); my $dpid=$ss->checkpid(); if ($killme){ if ($dpid){ `kill -HUP $dpid`; print "Sudoscriptd at $dpid killed\n"; } else { print "Can't find a sudoscriptd to kill!\n"; } exit; } if ($dpid){ print STDERR "sudoscriptd already running at $dpid\n"; print STDERR "Can't start new sudoscriptd\n"; exit 1; } # Become a daemon my $pid=fork; exit if $pid; die "Couldn't fork $!" unless defined($pid); our $rundir="/var/run"; our $fifodir="$rundir/sudoscript"; our $logdir="/var/log"; foreach my $tag ('','log','merge','comp'){ unlink "$fifodir/stderr$tag" if (-e "$fifodir/stderr$tag"); } $ss->daemon_io(); die "Couldn't start new session $!" unless POSIX::setsid(); # Change our ps id, if supported by OS $0="sudoscriptd: main"; # 2 MiByte limit on log size before compression/rotation. my $MAXLOGSIZE=1024*1024*2; # Open syslog my $facility='AUTHPRIV'; setlogsock 'unix'; openlog("sudoscriptd",'pid',$facility) || die "Can't open syslog $!"; syslog('info',"Master sudoscriptd starting"); # Declare these for the interrupt handler below. # Hash of outstanding logger PIDs by session tag my %LOGGERS; # PID of the log merger my $mergerpid; # interrupt handler to reap outstanding children sub main_exit_handler{ # We could be called directly, or through a signal # If the former, we'll have parameters. my $exitseverity=shift; my $exitmsg=shift; $exitseverity="info" if (!$exitseverity || $exitseverity eq 'HUP'); $exitmsg="Master sudoscriptd caught signal. Exiting" if (!$exitmsg); syslog($exitseverity,$exitmsg); # This is main daemon shutdown. Kill all other daemons we know about. foreach (keys %LOGGERS){ kill 1,$LOGGERS{$_}; waitpid $LOGGERS{$_},&WNOHANG; } if ($mergerpid){ kill 1, $mergerpid; waitpid $mergerpid,&WNOHANG; } closelog(); exit; }; $SIG{'HUP'} = \&main_exit_handler; # Check to see if we have a ssers group our ($grname,$grpasswd,$gid) = getgrnam 'ssers'; $grpasswd=""; # make strict happy # Set up state directories create_state_dirs(); # Record our PID for those who would wish us ill 8) if (! (open PID,">$rundir/sudoscriptd.pid")){ syslog('crit',"Can't open $rundir/sudoscriptd.pid %m"); closelog(); die "Couldn't record pid, $!"; } print PID "$$\n"; close PID; # The front-end FIFO my $fifo="$fifodir/rendezvous"; # Create the FIFO that sudoshell will rendezvous on # FIFO must me writable by group if group exists if ($grname){ mktypescript($fifo,0,0620,$gid); } else { mktypescript($fifo,0,0600); } # Create the back-end merge daemon $mergerpid=log_merger($DATEFMT); my $handshake; # sudoshell's id string while (1){ # Open the rendezvous FIFO or exit main_exit_handler('crit',"Couldn't open FIFO ($fifo), %m") if (!sysopen(FIFO, $fifo, O_RDONLY)); # Main input loop. ss is on the writing end. while (<FIFO>){ if (/^HELO (.*)/){ # HELO handshake. Fork a new logger $handshake=$1; syslog('info',"Forking new logger for sudoshell $handshake"); $pid=new_logger(split /\s+/,$handshake); # Recored the new logger's PID $LOGGERS{$handshake}=$pid; } if (/^GDBY (.*)/){ # GDBY handshake. Signal and reap the old logger $handshake=$1; $pid=$LOGGERS{$handshake}; if ($pid){ my $count= kill 1,$pid; waitpid $pid,0; delete $LOGGERS{$handshake}; syslog('info',"session $handshake closed"); } else { syslog('err',"No PID recorded for handshake $handshake. Can't clean up session!"); } } } # End of master daemon } sub create_state_dirs{ # Set up logging and fifo directories # Create directories as needed if (!-d $rundir){ if (!mkdir $rundir,0755){ syslog('crit',"Can't mkdir $rundir %m"); closelog(); die "Can't mkdir $rundir $!"; } } # FIFO dir has to be group writeable if ss is to become a user other than root my $fifodirmode; if ($grname){ $fifodirmode=0770; } else { $fifodirmode=0700; } `rm -fr $fifodir`; if (!mkdir $fifodir,$fifodirmode){ syslog('crit',"Can't mkdir $fifodir %m"); closelog(); die "Can't mkdir $fifodir $!"; } if ($grname){ chown 0,$gid,$fifodir; } if (!-d $logdir){ if(!mkdir $logdir,0700){ syslog('crit',"Can't mkdir $logdir %m"); closelog(); die "Can't mkdir $logdir $!"; } } # Let caller know where the fifo dir is return $fifodir; } # # # merger daemon - # Collect all session's data (pre tagged by the loggers) into # a single log file. Rotate the log if it exceeds $MAXLOGSIZE in size. sub log_merger{ my $ss=Sudoscript->new(); my $pid=fork; return $pid if ($pid); # caller needs pid to signal us if (!defined $pid){ syslog ('crit','Log merger encountered fork error %m'); closelog(); die "Couldn't fork $!"; } $ss->daemon_io('merge'); my $session=POSIX::setsid(); if (!$session){ syslog ('crit','Log merger Couldn\'t setsid() %m'); closelog(); die "Couldn't start new session $!"; } # Change our ps id, if supported by OS $0="sudoscriptd: merger"; # close parent's syslog closelog(); # close parent's rendezvous FIFO close FIFO; # Open our own syslog my $facility='AUTHPRIV'; openlog("sudoscriptd-merger",'pid',$facility) || die "Can't open syslog $!"; # Predeclares for the signal handler # Our FIFO my $fifo=$fifodir."/merge"; # List of compressor PIDs my @comppids; # Merger signal handler # Close and unlink FIFO # Reap any outstanding compressors $SIG{'HUP'} = sub { syslog('info',"Merger caught signal. Exiting"); print LOG "Merger caught signal. Exiting\n"; close LOG; close MYFIFO; unlink $fifo; closelog(); foreach(@comppids){ kill 1,$_; waitpid $_,&WNOHANG; } exit; }; # Announce ourselves to syslog syslog('info',"New merger"); # Open the log file, obtaining the current log size and possibly a compressor PID my ($size,$comppid)=openmylog("$logdir/sudoscript"); # Save PID, if any push @comppids,$comppid if($comppid); # Announce ourselves in the log file print LOG $ss->datestamp($DATEFMT)." New Merger\n"; # Create out merge FIFO mktypescript($fifo); # Announce success print LOG "opened FIFO $fifo\n"; # Merger input loop while(1){ # open the FIFO sysopen(MYFIFO, $fifo,O_RDONLY) or die "Couldn't open FIFO ($fifo), $!"; while (<MYFIFO>){ # Datestamp the input $_=$ss->datestamp($DATEFMT)." $_"; # Keep track of the size $size += length($_) +1; # Log the input print LOG; # Rotate the log if $MAXLOGSIZE is exceeded if ($size > $MAXLOGSIZE){ # as above ($size,$comppid)=openmylog("$logdir/sudoscript"); push @comppids,$comppid if($comppid); } # if we have outstanding compressor kids, wait for them here if ($#comppids >=0){ if (waitpid $comppids[0],&WNOHANG >0){ shift @comppids; } } } } close MYFIFO; # End of merger daemon } # # # Logger daemon # Creates a session FIFO for sudoshell # Accepts script(1) output from sudoshell # Tags it with session ID, and passes it on to the merger daemon # sub new_logger{ # real user, sudoshell pid, effective user (or root if undef) my ($user,$sspid,$runas)=@_; my $ss=Sudoscript->new(); my $pid=fork; return $pid if ($pid); if (!defined $pid){ syslog ('crit','Child logger encountered fork error %m'); closelog(); die "Couldn't fork $!"; } $ss->daemon_io('log'); my $session=POSIX::setsid(); if (!$session){ syslog ('crit','Child logger Couldn\'t setsid() %m'); closelog(); die "Couldn't start new session $!"; } # Close parent's syslog. closelog(); # Close parent's rendezvous FIFO close FIFO; # Change our ps id, if supported by OS $0="sudoscriptd: logger"; # Open our own syslog my $facility='AUTHPRIV'; openlog("sudoscriptd-logger",'pid',$facility) || die "Can't open syslog $!"; # Set up the session FIFO directory # We create a subdirectory in case we are granting access to the run # directory to the ssers group. If we are, that group must have # read/write access. The directory we create here has no group or other access, # thereby limiting access to the user only. (This is still a security hole if we are enabling # a role account shared by two or more real users.) my $sessdir; $sessdir=$fifodir."/ssd.${user}_"; if ($runas){ $sessdir .= "${runas}_$sspid"; } else { $sessdir .= "root_$sspid"; } if (!-d $sessdir){ `rm -fr $sessdir` if (-e $sessdir); mkdir $sessdir,0700; } # If we have an "effective" username, get the UID and chown the session dir to that user. my ($tuname,$tpasswd,$tuid); # "target" user if (defined $runas){ ($tuname,$tpasswd,$tuid)=getpwnam $runas; } else { $tuid=0; } chown $tuid,0,$sessdir; # Predeclare the merge FIFO name for the signal handler. my $mergefifo="$fifodir/merge"; $SIG{'HUP'} = # Remove the session directory and FIFO and exit sub { syslog('info',"logger ($user,$sspid) caught signal. Exiting"); print MERGEFIFO "logger ($user,$sspid) caught signal. Exiting\n"; close MERGEFIFO; close SESSFIFO; `/bin/rm -fr $sessdir`; closelog(); exit; }; # Announce ourselves to syslog syslog('info',"new_logger for user $user with pid $sspid"); # Open the merge FIFO for output if (!sysopen(MERGEFIFO, $mergefifo,O_WRONLY)){ syslog('crit',"Couldn't open FIFO ($mergefifo) %m"); closelog(); die "Couldn't open FIFO ($mergefifo), $!"; } # Unbuffer our OUTPUT my $foo=select MERGEFIFO; $|=1; select $foo; # Announce ourselves on the merge FIFO print MERGEFIFO "New logger for $user with pid $sspid\n"; # Create the session FIFO my $sessfifo="$sessdir/$user$sspid.fifo"; mktypescript($sessfifo,$tuid,0200); # Give ss time to Posix::pause sleep 1; # Signal sudoshell that we are ready kill "WINCH",$sspid; # Logger input/output loop while(1){ # Open the session FIFO for read if (!sysopen(SESSFIFO, $sessfifo,O_RDONLY)){ syslog('crit',"Couldn't open FIFO ($sessfifo) %m"); close MERGEFIFO; closelog(); die "Couldn't open FIFO ($sessfifo), $!"; } while (<SESSFIFO>){ # Tag the input with the session ID $_="$user:$sspid $_"; # And send it to the merge FIFO print MERGEFIFO; } } # End of logger daemon } # # # Create a FIFO with optional owner group and mode overriding defaults sub mktypescript { my ($fifo,$owner,$mode,$group)=@_; # Delete the FIFO if it exists. if (-e $fifo){ unlink $fifo; } # Default mode is r/w to owner $mode=0600 if (! defined $mode); if (!mkfifo($fifo,$mode)){ syslog('crit',"mktypescript() couldn't make new fifo $fifo %m"); closelog(); die "Can't make fifo $fifo $!"; } # Default owner is root $owner=0 if (!defined $owner); # Default group is wheel/root $group=0 if (!defined $group); chown $owner,$group,$fifo; chmod $mode,$fifo; } # # (re)open the log file # Rotate the logs if larger than $MAXLOGSIZE sub openmylog{ # Passed the log file name my $log=shift; # compressor PID my $pid; # File stat values my($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size); # Get the logfile size if it exists if (-e $log){ ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size)=stat $log; # If te size exceeds $MAXLOGSIZE, fork a rotator/compressor if ($size>$MAXLOGSIZE){ $pid=rotate_log($log); # rotator/compressor moves old log out of our way $size=0; } } # Open the log file # Default is create new .. my $op = ">"; #.. but append if it already existts $op.=">" if (-e $log); chmod 0600,$log; if (!open LOG, "$op$log"){ syslog('crit',"openmylog() couldn't open new log $op$log %m"); die "Couldn't open log ($op$log), $!"; } my $foo=select LOG; $|=1; # Unbuffered select $foo; # Return the current log size and any compressor PID that we may have spawned. return ($size,$pid); } # # # Rotate and compress the log files. # Called by openmylog() when log file size exceeds $MAXLOGSIZE bytes sub rotate_log { # We are passed the log file name my $log =shift; # We compute the dirname and basename my $logdir=dirname($log); my $logbase=basename($log); # Temp log file name my $tlog="$log.$$"; # Move the old log out of the way link $log, $tlog; unlink $log; my $ss=Sudoscript->new(); # Fork a child to do the rotation/compression my ($pid)=fork; # Parent gets the child PID so we can interrupt if necessary # We've moved the old log out of the way so the parent can create a new one. return $pid if $pid; # CHILD $ss->daemon_io('comp'); die "Couldn't start new session $!" unless POSIX::setsid(); # Fix up our ps name if OS supports it $0="sudoscriptd: compressor"; my $MAXLOGS=10; # Rename the old log files incrementing numeric extensions # The $MAXLOGth one goes in the bit bucket my ($currlog,$lastlog); for (my $i=$MAXLOGS-1;$i;$i--){ $currlog="$logdir/$logbase.$i.gz"; $lastlog="$logdir/$logbase.".($i+1).".gz"; if (-e $currlog){ unlink $lastlog; link $currlog,$lastlog; } } # dot one is now linked with dot two # clean it up unlink "$logdir/$logbase.1.gz"; # compress the temp log onto the dot one my $cmd="gzip -c $tlog >$logdir/$logbase.1.gz"; `$cmd`; chmod 0600,"$logdir/$logbase.1.gz"; unlink $tlog; exit 0; } =pod =head1 NAME sudoscriptd - logging daemons for sudoshell(1) =head1 SYNOPSIS sudoscriptd [-d|--datefmt long|short|sortable] =head1 VERSION This manpage documents version 2.1.2 of sudoscriptd =head1 DESCRIPTION I<sudoscriptd> is a daemon for logging output from L<sudoshell(8)>. Used with that script, it provides an audit trail for shells run under sudo. =head1 README When I<sudoscriptd> starts, it creates a named pipe (FIFO) in a spool area. Then it forks a log management daemon that opens another FIFO and hangs around waiting for someone to write to it. When a new sudoshell starts, it writes the name of the user who ran it (from SUDO_UID) and its own PID to the first FIFO, then pauses waiting for a signal. Sudoscriptd forks a logger with the information given by sudoshell, which opens yet another FIFO, whose name is derived from the username and PID. The logger then sends the signal that sudoshell is waiting for. Sudoshell then runs script(1) on the session FIFO. The logger takes the output thus produced, tags it with a session ID, and writes it to the log management daemon's (remember him?) FIFO. The log daemon tags the data with a datestamp and writes it to a log file. It also manages the logs so they don't overflow the logging partition. When the user ends her script(1) session, sudoshell tells the front end daemon that it is done. The daemon signals the session logger to wrap up its work, which it does by deleting the session FIFO and exiting. =head1 CONFIGURATION I<sudoshell> uses L<sudo(8)> to perform all its authentication and privilege escalation. The I<sudoshell> user must therefore be in the I<sudoers> file (See L<sudoers(5)>.) with an entry that allows running I<sudoshell> as the desired user. See the SUDOCONFIG file in the distribution for details. (On Linux, this will be in /usr/share/doc/sudoscript-VERSION. Everywhere else, it's in /usr/local/doc/sudoscript-VERSION.) =head1 IS THIS SECURE? In a word, no. Giving a user a root shell is a bad idea if you don't trust him or her. There are countless ways to evade the audit trail provided by sudoscript, even without root privilege. Let me highlight the last part of that sentence: I<even without root privilege!> (Think about the implications of the fact that a user must have write access to the logging FIFO to see what I mean.) That means you can't rely on this tool to maintain security for you. So, what good is sudoscript? It's useful in an at least two environments. First, you trust your users, but need a record of what they do for auditing purposes. Second, you may or may not trust your users, but they have successfully agitated for a root (or other) shell. Sudoscript then provides an audit trail as long as your users don't try to evade it. See the file SECURITY (in the same place as SUDOCONFIG, above) for more on sudoscript's security assumptions. =head1 SWITCHES One optional switch, C<--datefmt>, is accepted by C<sudoscriptd>. This controls the format of the datestamps in the log file. Three options are available. =over 4 =item long This selects a long date format of 'wdy mon dd hh:mm:dd ZZZ YYYY' where 'wdy' is the weekday name, 'mon' is the three letter month name, 'dd' is the day of the month, hh:mm:ss' is the local time, 'ZZZ' is the local time zone name and 'YYYY' is the four digit year. =item short This selects a shorter date format of 'wdy mon dd hh:mm:dd'. This is just the long with the time zone and year removed. C<short> is the default format if no C<--datefmt> is given. =item sortable This selects a compressed and numerically sortable format of 'yyyymmddhhmmss'. =back =head1 FILES The front end fifo is /var/run/sudocript/rendezvous. The backend FIFO is /var/run/sudocript/merge. These two are semi-permanent. The session FIFOs are named /var/run/sudocript/ssd{username}{pid}. They go away once the session closes. The log file is named /var/log/sudoscript. When the backend daemon rotates the log, it forks a compressor that creates files called /var/log/sudoscript.{n}.gz, where {n} is one through ten. Sudoscriptd stores its PID in /var/run/sudoscriptd.pid. =head1 BUGS The script(1) output is pretty ugly. All control characters are preserved exactly as typed, or worse, as displayed by curses based console apps like vi. The content of such logs can look completely unintelligible unless they are cleaned up first. A shell script from the "Unix Power Tools" book that uses sed(1) to do a first pass over such logs is available at L<ftp://ftp.oreilly.com/pub/examples/power_tools/unix/split/script.tidy>. I considered building something like that into sudoscriptd, but rejected it for two reasons. First, the daemon needs to get back to reading the FIFO as quickly as possible to avoid losing data to an over-full buffer. Second, any cleanup of the logs would I<remove information>. This could be bad if I were over-zealous in my clean up. As it stands, you can run your own clean up on the log data without destroying the original log. The datestamp() routine is not locale aware and returns American English values. =head1 SEE ALSO sudoscript(8) sudoshell(1) Sudoscript(3pm) sudo(8) sudoers(5) =head1 PREREQUISITES sudo - L<http://www.courtesan.com/sudo/index.html> =head1 OSNAMES C<Solaris> C<Linux> C<FreeBSD> C<OpenBSD> C<HP-UX> =head1 SCRIPT CATEGORIES UNIX/System_administration =head1 CONTRIBUTORS The following people offered helpful advice and/or code: Dan Rich (drich@emplNOoyeeSPAMs.org) Alex Griffiths (dag@unifiedNOcomputingSPAM.com) Bruce Gray (bruce.gray@aNOcSPAMm.org) Chan Wilson (cwilson@coNrOp.sSgPi.cAoMm> Tommy Smith (tsNmOith@eSaPtAeMl.net) Donny Jekels (donny@jNOeSkPeAlMs.com =head1 AUTHOR Howard Owen, E<lt>hbo@egbok.comE<gt> =head1 COPYRIGHT AND LICENSE Copyright 2002,2003 by Howard Owen sudoscript is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =cut