#!/usr/bin/perl -w

=head1 NAME 
 
user_access_audit.pl

=head1 SYNOPSIS

C<user_access_audit.pl> [C<--usernames-only>] [C<--show-all>]

=head1 DESCRIPTION

C<user_access_audit.pl> reports on which ActiveDirectory users are allowed
to login to an HP-UX systems based on the policy set by pam_authz.

=head1 OPTIONS

=over

=item C<--usernames-only>

Don't print out a full report. Print out a list of usernames.

=item C<--show-all>

Include users who can't possibly log in, e.g. because their shell is
C</bin/false> or because C</etc/shadow> has C<!!> for their password.


=head1 BUGS

C<user_access_audit.pl> doesn't understand the full pam_authz policy syntax.
It does not check to see if there are different pam policies for different
services.

It wouldn't be very difficult to adapt this to work with Linux boxes which
authenticate to ActiveDirectory, but I haven't done that yet.

=head1 AUTHOR

Greg Baker C<gregb@ifost.org.au>

=cut

use strict;
use Getopt::Long qw{:config auto_help};
use Pod::Usage;

my $usernames_only = 0;
my $show_all = 0;

my $opt_result = GetOptions ("usernames-only|users-only|username-only|only-usernames|only-users" => \$usernames_only,
			     "show-all-users|showall|show-all|all" => \$show_all);

pod2usage(1) unless $opt_result;

die "Must run as root.\n" unless $> == 0;
&check_nsswitch_conf();
&check_pam_conf();


my @all_groups;
my %gid_lookup;
my %groups_of_user;
my %name_of_user;
my %prohibited_users;
my %directory_source_of_user;
my %directory_source_of_group;

my $g;
my $m;
my $pam_authz = "/etc/opt/ldapux/pam_authz.policy";
my $groups;


######################################################################
# Yes, I really want to use grget. I want to make sure this runs
# correctly even if Perl was compiled against a different libc to the system.
# Get the groups first so that we know the name of users' primary group.
open(GRGET,"grget|") || die "Can't run grget";
GROUP:
while (<GRGET>) {
  chomp;
  next unless /:.*:.*:/;
  my ($group_name,$group_password,$gid,$members) = split(/:/);
  next GROUP if exists $gid_lookup{$gid};
  $gid_lookup{$gid} = $group_name;
  my @member_list = split(/,/,$members);
  foreach $m (@member_list) {
    $groups_of_user{$m} = [] unless exists $groups_of_user{$m};
    push(@{$groups_of_user{$m}},$group_name);
  }
  $directory_source_of_group{$group_name} = "AD";
}

######################################################################
# We assume that the gecos field is the user's email address and that
# the email address is firstname.lastname@company.com
# Run pwget to find out all possibly valid users regardless of libc issues.
open(PWGET,"pwget|") || die "Can't run pwget";
USER:
while (<PWGET>) {
  chomp;
  my ($username,$passwd_placeholder,$userid,$groupid,$gecos,$homedir,$shell) = split(/:/);
  $directory_source_of_user{$username} = "AD";
  if ($gecos =~ /@/) {
    my ($lhs,$rhs) = split(/@/,$gecos);
    my ($firstname,$lastname) = split(/\./,$lhs);
    $name_of_user{$username} = (ucfirst $firstname)." ".(ucfirst $lastname);
  }
  $prohibited_users{$username} = 'shell' if $shell =~ m:/false|/true$:;
  $groups_of_user{$username} = [] unless exists $groups_of_user{$username};
  push(@{$groups_of_user{$username}},$gid_lookup{$groupid}) if
    exists $gid_lookup{$groupid};
}
close(PWGET);

######################################################################
# Figure which are the local groups.
open(LOCAL_GROUPS,"/etc/group") || die "Can't read /etc/group";
while (<LOCAL_GROUPS>) {
  chomp;
  next unless /:/;
  my ($groupname,@junk) = split(/:/);
  $directory_source_of_group{$groupname} = "local";
}
close(LOCAL_GROUPS);


######################################################################
# Now figure out which are local users 
open(SHADOW,"/etc/shadow")|| die "Can't open /etc/shadow (which is necessary to figure out what users have passwords";
while (<SHADOW>) {
  my ($username,$encrypted_password,$gecos,@other_junk) = split(/:/);
  $directory_source_of_user{$username} = "local";
  $name_of_user{$username} = $gecos;
  if (
       ($encrypted_password eq "*" or 
        $encrypted_password eq "x" or 
	$encrypted_password eq "!!")
      ) {
    $prohibited_users{$username} = 'shadow';
    next;
  }
}

######################################################################
# Read the pam_authz.policy file, and normalise the rules so that
# to ALLOW group -group +local users -local users

my @rules;
open(AUTHZ_POLICY,$pam_authz) || die "Can't read $pam_authz";
POLICY_LINE:
while (<AUTHZ_POLICY>) {
  chomp;
  s/#.*//;
  next if /^\s*$/;
  my ($action,$type,$object) = split(/:/);
  my $action_letter = $action eq 'allow' ? 'ALLOW' : 'DENY';
  if ($type eq 'unix_local_user') {
    push(@rules,"${action_letter} local users");
    next POLICY_LINE;
  }
  if ($type eq 'unix_group') {
    my @groups = split(/,/,$object);
    foreach $g (@groups) {
      push(@rules,"$action_letter GROUP $g");
    }
    next POLICY_LINE;
  }
  die "Didn't understand line $. of $pam_authz: '$_' (type = $type)";
}

######################################################################
# Nice to know who has logged in, and when.
my %last_login = ();
open(LAST,"last|") || die "Can't run last";
while(<LAST>) {
  my @line_fields = split(/\s+/);
  next if $#line_fields == -1;
  my ($username,$tty,$day,$month,$date,$time,@junk) = @line_fields;
  next if exists $last_login{$username};
  $last_login{$username} = "$day $month $date $time";
}


######################################################################
# We're here now. Let's look through every user, and figure out 
# whether they are allowed to log in, and write out the re
# But first, head up the report.

my $username;
my $hostname_string = `hostname`;
my $run_date = uc scalar localtime;
my $rules_string = join(", THEN ",@rules);
my $source_details;
my $local_group;
my $ldap_group;

format STDOUT_TOP =
-------------------------------------------------------------------------------

 USER ACCESS AUDIT REPORT FOR @||||||||||||||||||| ON @>>>>>>>>>>>>>>>>>>>>>>>>
$hostname_string, $run_date

~ Access rules: ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
$rules_string
~~              ^<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
$rules_string

                                    PAGE @<<
$%
-------------------------------------------------------------------------------
Username   Directory source /       What group gives access?      Last login
           Full name                   Local    AD groups
-------------------------------------------------------------------------------
.
format STDOUT =
@<<<<<<<<< @<<<<<<<<<<<<<<<<<<<<<<<<<< @<<<<<<< @<<<<<<<    @>>>>>>>>>>>>>>>>>>
$username, $source_details, $local_group, $ldap_group, $last_login{$username}
.
;

USER_REPORT:
foreach $username (sort keys %groups_of_user) {
  next USER_REPORT if exists $prohibited_users{$username} and !$show_all;
  $last_login{$username} = 'never' unless exists $last_login{$username};
  $last_login{$username} = "Prohibited: $prohibited_users{$username}"
    if exists $prohibited_users{$username};
  if (!exists $directory_source_of_user{$username}) { 
    $source_details = "-";
    $directory_source_of_user{$username} = '-';
  } elsif ($directory_source_of_user{$username} eq 'local') {
    $source_details = "PWD:";
  } elsif (exists $name_of_user{$username} and defined $name_of_user{$username}) {
    $source_details = "AD: $name_of_user{$username}";
  } else {
    $source_details = "AD: (no email)";
  }
  $local_group = "";
  $ldap_group = "";
  my $rule;
 EVALUATE_RULE:
  foreach $rule (@rules) {
    my ($operation,$predicate) = split(/\s+/,$rule,2);
    if ($predicate eq 'local users') {
      if ($directory_source_of_user{$username} eq 'local') {
	# The rule applies
	next USER_REPORT if $operation eq 'DENY';
	$local_group = '-';
	$ldap_group = '-';
	if ($usernames_only) { print "$username\n"; } else { write; }
	next USER_REPORT;
      }
      next EVALUATE_RULE;
    }
    if ($predicate =~ /GROUP (.*)$/) {
      my $relevant_group = $1;
      foreach $g (@{$groups_of_user{$username}}) {
	if ($g eq $relevant_group) {
	  # Then this rule applies.
	  next USER_REPORT if $operation eq 'DENY';
	  if ($directory_source_of_group{$g} eq 'local') {
	    $local_group = $g;
	  } else {
	    $ldap_group = $g;
	  }
	  if ($usernames_only) { print "$username\n"; } else { write; }
	  next USER_REPORT;
	}
      }
      next EVALUATE_RULE;
    }
    die "Unhandled rule: $rule";
  }
  # No rules saying one way or the other. The default is to deny.
}


sub check_nsswitch_conf {
  open(NSSWITCH_CONF,"/etc/nsswitch.conf")||die "Can't read /etc/nsswitch.conf";
  while(<NSSWITCH_CONF>) {
    s/#.*//;
    next if /^\s*$/;
    next if (/^passwd:\s*files\s*ldap/);
    die "$0 only makes sense to run if /etc/nsswitch.conf contains passwd: files ldap" if /^passwd:/;
    next if (/^group:\s*files\s*ldap/);
    die "$0 only makes sense to run if /etc/nsswitch.conf contains group: files ldap" if /^passwd:/;
  }
}

sub check_pam_conf {
  open(PAM_CONF,"/etc/pam.conf")||die "Can't read /etc/pam.conf";
  my $seen_authz = 0;
  while(<PAM_CONF>) {
    next unless /account/;
    s/#.*//;
    next unless /required.*libpam_authz/;
    $seen_authz = 1;
    last;
  }
  die "$0 only makes sense to run if /etc/pam.conf mentions libpam_authz"
    unless $seen_authz;
}


