sudo_controls/update_sudo.pl

798 lines
27 KiB
Perl

#!/usr/bin/env perl
#******************************************************************************
# @(#) update_sudo.pl
#******************************************************************************
# @(#) Copyright (C) 2014 by KUDOS BV <info@kudos.be>. All rights reserved.
#
# This program is a free software; you can redistribute it and/or modify
# it under the same 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
#******************************************************************************
# This script distributes SUDO fragments to the appropriate files into a
# designated repository based on the 'grants', 'alias' and 'fragments' files.
# Superfluous usage of 'hostname' reporting in log messages is encouraged to
# make reading of multiplexed output from update_sudo.pl through backgrounded
# jobs via manage_sudo.sh much easier.
#
# @(#) HISTORY: see perldoc 'update_sudo.pl'
# -----------------------------------------------------------------------------
# DO NOT CHANGE THIS FILE UNLESS YOU KNOW WHAT YOU ARE DOING!
#******************************************************************************
#******************************************************************************
# PRAGMAs/LIBs
#******************************************************************************
use strict;
use Net::Domain qw(hostfqdn hostname);
use POSIX qw(uname);
use Data::Dumper;
use Getopt::Long;
use Pod::Usage;
use File::Basename;
use File::Temp qw(tempfile);
#******************************************************************************
# DATA structures
#******************************************************************************
# ------------------------- CONFIGURATION starts here -------------------------
# define the version (YYYY-MM-DD)
my $script_version = "2025-04-27";
# name of global configuration file (no path, must be located in the script directory)
my $global_config_file = "update_sudo.conf";
# name of localized configuration file (no path, must be located in the script directory)
my $local_config_file = "update_sudo.conf.local";
# maxiumum level of recursion for alias resolution
my $max_recursion = 5;
# selinux context label of sudoers fragment files
my $selinux_context = "etc_t";
# ------------------------- CONFIGURATION ends here ---------------------------
# initialize variables
my ($debug, $verbose, $preview, $global, $use_fqdn, $ignore_errors) = (0,0,0,0,0,0);
my (@config_files, $fragments_dir, $visudo_bin, $immutable_self_file, $immutable_self_cmd);
my (%options, @uname, %aliases, %frags, @grants);
my ($os, $host, $hostname, $run_dir);
my ($selinux_status, $has_selinux, $recursion_count) = ("",0,1);
$|++;
#******************************************************************************
# SUBroutines
#******************************************************************************
# -----------------------------------------------------------------------------
sub do_log {
my $message = shift;
if ($message =~ /^ERROR:/ || $message =~ /^WARN:/) {
print STDERR "$message\n";
} elsif ($message =~ /^DEBUG:/) {
print STDOUT "$message\n" if ($debug);
} else {
print STDOUT "$message\n" if ($verbose);
}
return (1);
}
# -----------------------------------------------------------------------------
sub parse_config_file {
my $config_file = shift;
unless (open (CONF_FD, "<", $config_file)) {
do_log ("ERROR: failed to open the configuration file ${config_file} [$!/$hostname]")
and exit (1);
}
while (<CONF_FD>) {
chomp ();
# parse settings
if (/^\s*$/ || /^#/) {
next;
} else {
if (/^\s*use_fqdn\s*=\s*(0|1)\s*$/) {
$use_fqdn = $1;
do_log ("DEBUG: picking up setting: use_fqdn=${use_fqdn}");
}
if (/^\s*ignore_errors\s*=\s*(0|1)\s*$/) {
$ignore_errors = $1;
do_log ("DEBUG: picking up setting: ignore_errors=${ignore_errors}");
}
if (/^\s*fragments_dir\s*=\s*([0-9A-Za-z_\-\.\/~]+)\s*$/) {
$fragments_dir = $1;
do_log ("DEBUG: picking up setting: fragments_dir=${fragments_dir}");
}
if (/^\s*visudo_bin\s*=\s*([0-9A-Za-z_\-\.\/~]+)\s*$/) {
$visudo_bin = $1;
do_log ("DEBUG: picking up setting: visudo_bin=${visudo_bin}");
}
if (/^\s*immutable_self_file\s*=\s*([0-9A-Za-z_\-\.\/~]+)\s*$/) {
$immutable_self_file = $1;
do_log ("DEBUG: picking up setting: immutable_self_file=${immutable_self_file}");
}
if (/^\s*immutable_self_cmd\s*=\s*([0-9A-Za-z_\-\.\/~%:=\(\) ]+)\s*$/) {
$immutable_self_cmd = $1;
do_log ("DEBUG: picking up setting: immutable_self_cmd=${immutable_self_cmd}");
}
}
}
# parameter checks
if (not defined ($immutable_self_file) or $immutable_self_file eq "") {
do_log ("ERROR: 'immutable_self_file' parameter not defined [$hostname]")
and exit(1);
}
return (1);
}
# -----------------------------------------------------------------------------
sub resolve_aliases
{
my $input = shift;
my (@tmp_array, @new_array, $entry);
@tmp_array = split (/,/, $input);
foreach $entry (@tmp_array) {
if ($entry =~ /^\@/) {
($aliases{$entry})
? push (@new_array, @{$aliases{$entry}})
: do_log ("WARN: unable to resolve alias $entry [$hostname]");
} else {
($entry)
? push (@new_array, $entry)
: do_log ("WARN: unable to resolve alias $entry [$hostname]");
}
}
return (@new_array);
}
# -----------------------------------------------------------------------------
sub set_file {
my ($file, $perm, $uid, $gid) = @_;
my $rc = chmod ($perm, "$file");
if (!$rc) {
if ($ignore_errors) {
do_log ("ERROR: cannot set permissions on $file [$!/$hostname] -- IGNORED");
} else {
do_log ("ERROR: cannot set permissions on $file [$!/$hostname]");
exit (1);
}
}
my $rc = chown ($uid, $gid, "$file");
if (!$rc) {
if ($ignore_errors) {
do_log ("ERROR: cannot set ownerships on $file [$!/$hostname] -- IGNORED");
} else {
do_log ("ERROR: cannot set ownerships on $file [$!/$hostname]");
exit (1);
}
}
return (1);
}
#******************************************************************************
# MAIN routine
#******************************************************************************
# -----------------------------------------------------------------------------
# process script arguments & options
# -----------------------------------------------------------------------------
if ( @ARGV > 0 ) {
Getopt::Long::Configure ('prefix_pattern=(--|-|\/)', 'bundling', 'no_ignore_case');
GetOptions (\%options,
qw(
debug|d
help|h|?
global|g
ignore|i
preview|p
verbose|v
version|V
)) || pod2usage(-verbose => 0);
}
pod2usage(-verbose => 0) unless (%options);
# check version parameter
if ($options{'version'}) {
$verbose = 1;
do_log ("INFO: $0: version $script_version");
exit (0);
}
# check help parameter
if ($options{'help'}) {
pod2usage(-verbose => 3);
exit (0);
};
# check global parameter
if ($options{'global'}) {
$global = 1;
}
# check ignore parameter
if ($options{'ignore'}) {
$ignore_errors = 1;
}
# check preview parameter
if ($options{'preview'}) {
$preview = 1;
$verbose = 1;
if ($global) {
do_log ("INFO: running in GLOBAL PREVIEW mode");
} else {
do_log ("INFO: running in PREVIEW mode");
}
} else {
do_log ("INFO: running in UPDATE mode");
}
# debug & verbose
if ($options{'debug'}) {
$debug = 1;
$verbose = 1;
}
$verbose = 1 if ($options{'verbose'});
# -----------------------------------------------------------------------------
# check/process configuration files, environment checks
# -----------------------------------------------------------------------------
# where am I? (1/2)
$0 =~ /^(.+[\\\/])[^\\\/]+[\\\/]*$/;
$run_dir = $1 || ".";
$run_dir =~ s#/$##; # remove trailing slash
# don't do anything without configuration file(s)
do_log ("INFO: parsing configuration file(s) ...");
push (@config_files, "$run_dir/$global_config_file") if (-f "$run_dir/$global_config_file");
push (@config_files, "$run_dir/$local_config_file") if (-f "$run_dir/$local_config_file");
unless (@config_files) {
do_log ("ERROR: unable to find any configuration file, bailing out [$hostname]")
and exit (1);
}
# process configuration file: global first, local may override
foreach my $config_file (@config_files) {
parse_config_file ($config_file);
}
# is the target directory for fragments present? (not for global preview)
unless ($preview and $global) {
do_log ("INFO: checking for SUDO control mode ...");
if (-d $fragments_dir) {
do_log ("INFO: host is under SUDO control via $fragments_dir");
} else {
do_log ("ERROR: host is not under SUDO control [$hostname]")
and exit (1);
}
}
# is syntax checking possible? (not for global preview)
unless ($preview and $global) {
unless (-x $visudo_bin) {
do_log ("ERROR: 'visudo' tool could not be found, will not continue [$hostname]")
and exit (1);
}
}
# what am I?
@uname = uname();
$os = $uname[0];
# who am I?
unless ($preview and $global) {
if ($< != 0) {
do_log ("ERROR: script must be invoked as user 'root' [$hostname]")
and exit (1);
}
}
# where am I? (2/2)
if ($use_fqdn) {
$hostname = hostfqdn();
} else {
$hostname = hostname();
}
do_log ("INFO: runtime info: ".getpwuid ($<)."; ${hostname}\@${run_dir}; Perl v$]");
# -----------------------------------------------------------------------------
# read aliases for teams, servers and users
# result: %aliases
# -----------------------------------------------------------------------------
do_log ("INFO: reading 'alias' file ...");
open (ALIASES, "<", "${run_dir}/alias")
or do_log ("ERROR: cannot read 'alias' file [$!/$hostname]") and exit (1);
while (<ALIASES>) {
my ($key, $value, @values);
chomp ();
next if (/^$/ || /\#/);
s/\s+//g;
($key, $value) = split (/:/);
next unless ($value);
@values = sort (split (/\,/, $value));
$aliases{$key} = [@values];
};
close (ALIASES);
do_log ("DEBUG: dumping unexpanded aliases:");
print Dumper (\%aliases) if $debug;
# resolve aliases recursively to a maxium of $max_recursion
while ($recursion_count <= $max_recursion) {
# crawl over all items in the hash %aliases
foreach my $key (keys (%aliases)) {
# crawl over all items in the array @{aliases{$key}}
my @new_array; my @filtered_array; # these are the working stashes
do_log ("DEBUG: expanded alias $key before recursion $recursion_count [$hostname]");
print Dumper (\@{$aliases{$key}}) if $debug;
foreach my $item (@{$aliases{$key}}) {
# is it a group?
if ($item =~ /^\@/) {
# expand the group if it exists
if ($aliases{$item}) {
# add current and new items to the working stash
if (@new_array) {
push (@new_array, @{$aliases{$item}});
} else {
@new_array = (@{$aliases{$key}}, @{$aliases{$item}});
}
# remove the original group item from the working stash
@filtered_array = grep { $_ ne $item } @new_array;
@new_array = @filtered_array;
} else {
do_log ("WARN: unable to resolve alias $item [$hostname]");
}
# no group, just add the item as-is to working stash
} else {
push (@new_array, $item);
}
}
my %seen;
@filtered_array = grep { not $seen{$_}++ } @new_array;
# re-assign working stash back to our original hash key
@{$aliases{$key}} = @filtered_array;
do_log ("DEBUG: expanded alias $key after recursion $recursion_count [$hostname]");
print Dumper (\@{$aliases{$key}}) if $debug;
}
$recursion_count++;
}
do_log ("INFO: ".scalar (keys (%aliases))." aliases found on $hostname");
do_log ("DEBUG: dumping expanded aliases:");
print Dumper (\%aliases) if $debug;
# -----------------------------------------------------------------------------
# read SUDO fragments stored in a single 'fragments' file or in
# individual fragment files from a 'fragments.d' directory
# result: %frags
# -----------------------------------------------------------------------------
do_log ("INFO: reading 'fragment' file(s) ...");
my @frag_files;
# check if the SUDO fragments are stored in a directory or file
if (-d "${run_dir}/fragments.d" && -f "${run_dir}/fragments") {
do_log ("WARN: found both a 'fragments' file and 'fragments.d' directory. Ignoring the 'fragments' file [$hostname]")
}
if (-d "${run_dir}/fragments.d") {
do_log ("INFO: local 'fragments' are stored in a DIRECTORY on $hostname");
opendir (FRAGS_DIR, "${run_dir}/fragments.d")
or do_log ("ERROR: cannot open 'fragments.d' directory [$!/$hostname]")
and exit (1);
while (my $frag_file = readdir (FRAGS_DIR)) {
next if ($frag_file =~ /^\./);
push (@frag_files, "${run_dir}/fragments.d/$frag_file");
}
closedir (FRAGS_DIR);
} elsif (-f "${run_dir}/fragments") {
do_log ("INFO: local 'fragments' are stored in a FILE on $hostname");
push (@frag_files, "${run_dir}/fragments");
} else {
do_log ("ERROR: cannot find any SUDO fragments in the repository! [$hostname]")
and exit (1);
}
# process 'fragments' files
foreach my $frag_file (@frag_files) {
open (FRAGS, "<", $frag_file)
or do_log ("ERROR: cannot read 'fragments' file [$!/$hostname]")
and exit (1);
do_log ("INFO: reading SUDO fragments from file: $frag_file");
my @frag_file = <FRAGS>;
# check for fragments header(s): if there is no fragment header, then we
# consider this a single fragment file, otherwise we consider it a
# collection of fragments that needs to be broken down in individual fragments
if (grep { /^%%%/s } @frag_file) {
do_log ("INFO: fragment file $frag_file contains multiple fragments, parsing ...");
my ($frag_file, $frag_def);
my $count = 1;
foreach (@frag_file) {
# first header found
if (/^%%%/ && (not defined ($frag_def) or $frag_def eq "")) {
# look for fragment file name
($frag_file) = (split (/%%%/, $_))[1];
chomp ($frag_file);
unless (defined ($frag_file) && $frag_file ne "") {
do_log ("WARN: no fragment file name found in header at line $count [$hostname]")
}
# next header found, flush previous fragment
} elsif (/^%%%/ && (defined ($frag_def) or $frag_def ne "")) {
if (defined ($frag_file) && $frag_file ne "") {
$frags{$frag_file} = $frag_def;
undef $frag_def;
} else {
do_log ("WARN: fragment without file name? (to line: $count) [$hostname]");
}
undef $frag_file;
# get new file name
($frag_file) = (split ('%%%', $_))[1];
chomp ($frag_file);
unless (defined ($frag_file) && $frag_file ne "") {
do_log ("WARN: no fragment file name found in header at line $count [$hostname]")
}
} else {
# process fragment definition
$frag_def .= $_;
}
# check for last fragment
if ($frag_file && $frag_def ne "") {
$frags{$frag_file} = $frag_def;
}
$count++;
};
} else {
# strip off path from file name for hash key
$frag_file = fileparse ($frag_file, qr/\.[^.]*/);
do_log ("INFO: fragment file $frag_file contains only 1 fragment on $hostname");
$frags{$frag_file} = join ("\n", @frag_file);
}
close (FRAGS);
}
do_log ("INFO: ".scalar (keys (%frags))." SUDO fragment(s) found on $hostname");
print Dumper(\%frags) if $debug;
# -----------------------------------------------------------------------------
# syntax checking sudo fragments (visudo)
# -----------------------------------------------------------------------------
do_log ("INFO: syntax checking sudo fragments ...");
# create one large sudoers file out of the fragments, if the syntax check fails
# then we keep the temporary file for further inspection
my ($sudo_fh, $sudo_file) = tempfile(UNLINK => 0);
print $sudo_fh join("\n", map { "$frags{$_}" } keys %frags);
$sudo_fh->flush;
my @syntax_check = `${visudo_bin} -c -f $sudo_file 2>/dev/null`;
if ($? == 0) {
do_log ("INFO: syntax check of sudo fragments is OK on $hostname");
unlink $sudo_file;
} else {
do_log "ERROR: visudo check failed: ".join ("\n", @syntax_check)." [$hostname]"
and exit(1);
}
# -----------------------------------------------------------------------------
# read grant definitions
# result: @grants (array): fragments for which grants have been defined
# for this server.
# -----------------------------------------------------------------------------
do_log ("INFO: reading 'grants' file ...");
open (GRANTS, "<", "${run_dir}/grants")
or do_log ("ERROR: cannot read 'grants' file [$!/$hostname]") and exit (1);
while (<GRANTS>) {
my ($what, $where, @what, @where);
chomp ();
next if (/^$/ || /\#/);
s/\s+//g;
($what, $where) = split (/:/);
next unless ($where);
@what = resolve_aliases ($what);
@where = resolve_aliases ($where);
unless (@what and @where) {
do_log ("WARN: ignoring line $. in 'grants' due to missing/non-resolving values [$hostname]");
next;
}
foreach my $grant (sort (@what)) {
foreach my $server (sort (@where)) {
do_log ("DEBUG: adding grants for $grant on $server in \@grants")
if ($server eq $hostname);
# add sudo fragment to grants list if the entry is for this host
push (@grants, $grant) if ($server eq $hostname);
}
}
};
close (GRANTS);
# remove duplicates in @grants
@grants = keys (%{{ map { $_ => 1 } @grants}});
do_log ("INFO: ".scalar (@grants)." SUDO fragments with applicable grants requested on $hostname");
print Dumper(\@grants) if $debug;
# -----------------------------------------------------------------------------
# global preview, show full configuration data only
# -----------------------------------------------------------------------------
if ($preview && $global) {
open (GRANTS, "<", "${run_dir}/grants")
or do_log ("ERROR: cannot read 'grants' file [$!/$hostname]") and exit (1);
while (<GRANTS>) {
my ($what, $where, @what, @where);
chomp ();
next if (/^$/ || /\#/);
s/\s+//g;
($what, $where) = split (/:/);
next unless ($where);
@what = resolve_aliases ($what);
@where = resolve_aliases ($where);
unless (@what and @where) {
do_log ("WARN: ignoring line $. in 'grants' due to missing/non-resolving values [$hostname]");
next;
}
foreach my $grant (sort (@what)) {
foreach my $server (sort (@where)) {
do_log ("$grant|$server")
}
}
};
close (GRANTS);
exit (0);
}
# -----------------------------------------------------------------------------
# distribute sudo fragments into $fragments_dir
# -----------------------------------------------------------------------------
do_log ("INFO: (de)-activating SUDO fragments ....");
# check for SELinux
unless ($preview) {
SWITCH: {
$os eq "Linux" && do {
$selinux_status = qx#/usr/sbin/getenforce 2>/dev/null#;
chomp ($selinux_status);
if ($selinux_status eq "Permissive" or $selinux_status eq "Enforcing") {
do_log ("INFO: runtime info: detected active SELinux system on $hostname");
$has_selinux = 1;
}
last SWITCH;
};
}
}
# remove previous fragment files first
opendir (FRAGS_DIR, "${fragments_dir}")
or do_log ("ERROR: cannot open ${fragments_dir} directory [$!/$hostname]")
and exit (1);
while (my $frag_file = readdir (FRAGS_DIR)) {
next if ($frag_file =~ /^\./ or $frag_file eq $immutable_self_file);
# safe to ignore . (dot) files as sudo also does as well
unless ($preview) {
my $frag_file = "$fragments_dir/$frag_file";
if (unlink ($frag_file)) {
do_log ("INFO: de-activating fragment file $frag_file on $hostname");
} else {
do_log ("ERROR: cannot de-activate fragment file(s) [$!/$hostname]");
exit (1);
}
}
}
closedir (FRAGS_DIR);
# re-active current fragments
foreach my $grant (@grants) {
# do not create empty sudo files
if (exists ($frags{$grant})) {
my $sudo_file = "$fragments_dir/$grant";
unless ($preview) {
open (SUDO_FILE, "+>", $sudo_file)
or do_log ("ERROR: cannot open file for writing in $fragments_dir [$!/$hostname]")
and exit (1);
}
print SUDO_FILE "$frags{$grant}\n" unless $preview;
do_log ("INFO: activating fragment $grant on $hostname");
close (SUDO_FILE) unless $preview;
# set permissions to world readable & SELinux contexts
unless ($preview) {
SWITCH: {
$os eq "HP-UX" && do {
set_file ($sudo_file, 0440, 2, 2);
last SWITCH;
};
$os eq "Linux" && do {
if ($has_selinux) {
system ("/usr/bin/chcon -t $selinux_context $sudo_file") == 0 or
do_log ("WARN: failed to set SELinux context $selinux_context on $sudo_file [$hostname]");
}
set_file ($sudo_file, 0440, 0, 0);
last SWITCH;
};
}
}
} else {
do_log ("WARN: no matching SUDO rule found available for $grant [$hostname]");
}
}
# re-apply the immutable self fragment, just in case ;-)
unless ($preview) {
my $self_file = "$fragments_dir/$immutable_self_file";
open (SELF_FILE, "+>", $self_file)
or do_log ("ERROR: cannot open file for writing in $fragments_dir [$!/$hostname]")
and exit (1);
print SELF_FILE "# THIS IS THE IMMUTABLE SELF FRAGMENT OF SUDO CONTROLS\n";
print SELF_FILE $immutable_self_cmd."\n";
do_log ("INFO: activating immutable self fragment $immutable_self_file on $hostname");
SWITCH: {
$os eq "HP-UX" && do {
set_file ($self_file, 0440, 2, 2);
last SWITCH;
};
$os eq "Linux" && do {
if ($has_selinux) {
system ("/usr/bin/chcon -t $selinux_context $self_file") == 0 or
do_log ("WARN: failed to set SELinux context $selinux_context on $self_file [$hostname]");
}
set_file ($self_file, 0440, 0, 0);
last SWITCH;
};
}
close (SELF_FILE);
}
exit (0);
#******************************************************************************
# End of SCRIPT
#******************************************************************************
__END__
#******************************************************************************
# POD
#******************************************************************************
# -----------------------------------------------------------------------------
=head1 NAME
update_sudo.pl - distributes SUDO fragments according to a desired state model.
=head1 SYNOPSIS
update_sudo.pl [-d|--debug]
[-h|--help]
[-i|--ignore]
([-p|--preview] [-g|--global])
[-v|--verbose]
[-V|--version]
=head1 DESCRIPTION
B<update_sudo.pl> distributes SUDO fragments into the C<$fragments_dir> repository based on the F<grants>, F<alias> and F<fragments> files.
This script should be run on each host where SUDO is the required method of privilege escalation.
Orginally SUDO fragments must be stored in a generic F<fragments> file within the same directory as B<update_sudo.pl> script.
Alternatively SUDO fragments may be stored as set of individual files within a called sub-directory called F<fragments.d>.
Both methods are mutually exclusive and the latter always take precedence.
=head1 CONFIGURATION
B<update_sudo.pl> requires the presence of at least one of the following configuration files:
=over 2
=item * F<update_sudo.conf>
=item * F<update_sudo.conf.local>
=back
Use F<update_sudo.conf.local> for localized settings per host. Settings in the localized configuration file will always override other values.
Following settings must be configured:
=over 2
=item * B<use_fqdn> : whether to use short or FQDN host names
=item * B<ignore_errors> : whether to ignore errors during fragment deployment
=item * B<fragments_dir> : target directory for SUDO fragments files
=item * B<visudo_bin> : path to the visudo tool (for sudo rules syntax checking)
=item * B<immutable_self_file> : name of the file that contains sudo code to allow this script to run with elevated privileges
=back
=head1 OPTIONS
=over 2
=item -d | --debug
S< >Be I<very> verbose during execution; show array/hash dumps.
=item -h | --help
S< >Show the help page.
=item -i | --ignore
S< >Ignore errors during fragment deployment.
=item -p | --preview
S< >Do not actually distribute any SUDO fragments, nor update/remove SUDO files.
=item -p | --global
S< >Must be used in conjunction with the --preview option. This will dump the global namespace/configuration to STDOUT.
=item -v | --verbose
S< >Be verbose during exection.
=item -V | --version
S< >Show version of the script.
=back
=head1 NOTES
=over 2
=item * Options may be preceded by a - (dash), -- (double dash) or a / (slash).
=item * Options may be bundled (e.g. -vp)
=back
=head1 AUTHOR
(c) KUDOS BV, Patrick Van der Veken