#!/bin/sh -efu
#
# Copyright (C) 2023  BaseALT /basealt.ru/
#
# This file 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 3 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, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.
#

PROG="${0##*/}"
PROG_VERSION=0.2.1

CONFIG=/etc/audit/buildnode-audit-rules.conf

HASHER_PRIV_DIR="${HASHER_PRIV_DIR:-/etc/hasher-priv}"

. shell-config

show_help()
{
	cat <<EOF
Usage: $PROG [options]

$PROG is a tool to manage audit rules for logging package build
processes.

Main options:

  -C CONF, --conf=CONF    path to the configuration file CONF (the
                          default is the
			  ${CONFIG##*/} file in the
                          #{CONFIG%/*} directory;

  -i, --insert    only insert the rules to the kernel audit subsystem;

  -r, --remove    remove the configured audit rules;

  -c, --check     check that the present set of audit rules matches
                  the given configuration;

  -n, --dry-run   do not perform any modifications --- just print out
                  what would be done (list the generated rules or the
                  rules to be removed);

  -q, --quiet     do not print any info while running;

  -v, --verbose   print more info while running;

  -d, --debug     even more info, useful for debugging; implies -v;

  -V,--version    print program version and exit;

  -h,--help       show this text and exit.


By default, $PROG performes the -ic combined action: first it inserts
the rules into the kernel and then verifies the resulting state.

For more information see buildnode-audit-rules(1).
Report bugs to https://bugzilla.altlinux.org/.
EOF
}

print_version()
{
	cat <<EOF
$PROG version $PROG_VERSION
Written by: see the source for author info.

Copyright (C) 2022 BaseALT /basealt.ru/
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE.
EOF
}

show_usage()
{
	cat <<EOF
Usage: $PROG [options]

Run $PROG -h to see the help page.
EOF
    return 1
}

OPTS=`getopt -n $PROG -o C:,i,r,c,n,q,v,d,V,h \
             -l conf:,insert,remove,check,dry-run,quiet,verbose,debug,version,help -- "$@"` || show_usage
eval set -- "$OPTS"

# Builder configuration options:
do_default=1
do_insert=0
do_remove=0
do_check=0
dry_run=0
verbose=0
debug=0
quiet=0

while :; do
    case "$1" in
	-C|--conf)
	    shift
	    CONFIG="$1"
	    ;;
	-i|--insert)
	    do_insert=1
	    do_default=0
	    ;;
	-r|--remove)
	    do_remove=1
	    do_default=0
	    ;;
	-c|--check)
	    do_check=1
	    do_default=0
	    ;;
        -n|--dry-run)
            dry_run=1
            ;;
        -q|--quiet)
            quiet=1
            verbose=0
            debug=0
            ;;
        -v|--verbose)
            verbose=1
            quiet=0
            ;;
        -d|--debug)
            verbose=1
	    quiet=0
            debug=1
            ;;
        -V|--version)
	    print_version
	    exit 0
            ;;
	-h|--help)
            show_help
            exit 0
            ;;
        --)
            shift
            break
            ;;
	*)
            echo "ERROR: Unrecognized option: $1" >&2
            exit 1
            ;;
    esac
    shift
done


## Process the options

if [ $do_default -ne 0 ]; then
    do_insert=1
    do_check=1
fi


## Configuration

if [ ! -e "$CONFIG" ]; then
    echo "Configuration file $CONFIG not found" >&2
    exit 1
fi

# The list of package builders (users) to monitor. If not specified,
# all *_b satellites of current Hasher users are considered
# for auditing.
PACKAGE_BUILDERS="$(shell_config_get "$CONFIG" 'PACKAGE_BUILDERS')"

# The names of syscalls to monitor. The reasonable default value to
# consider is: "execve exit_group".
SYSCALLS="$(shell_config_get "$CONFIG" 'SYSCALLS')"
if [ $verbose -ne 0 ]; then
    echo "Configured syscalls: $SYSCALLS" >&2
fi

# The list of hardware architectures to generate rules for. If not
# specified, the script uses all architectures supported by the host
# that are known by the audit subsystem.
ARCH_LIST="$(shell_config_get "$CONFIG" 'ARCH_LIST')"
if [ -z "$ARCH_LIST" ]; then
    ARCH_LIST='b32 b64'
fi
if [ $verbose -ne 0 ]; then
    if [ -n "$ARCH_LIST" ]; then
	echo "Configured architectures: $ARCH_LIST" >&2
    fi
fi

if echo "$ARCH_LIST" | tr '[[:space:]]' '\n' | grep -v -qFx -e 'b32' -e 'b64'
then
    echo "Please, do not use other values than 'b32' and 'b64' for ARCH_LIST." >&2
    exit 1
fi

### Configuration processing

list_hashmen() {
    ls "${HASHER_PRIV_DIR%/}/user.d" | (
	hashmen=
	while read user; do
	    case "$user" in
		*~)
		    # Skip bak files.
		    continue
		    ;;
	    esac

	    [ -z "$hashmen" ] || hashmen="$hashmen "
	    hashmen="$hashmen$user"
	done

	if [ -n "$hashmen" ]; then
	    echo "$hashmen"
	fi
    )
}

run() {
    local cmd="$1"; shift
    local ret=0

    if [ $debug -ne 0 ]; then
	echo "Running '$cmd "$@"' ..." >&2
	"$cmd" "$@" || ret=$?
	if [ $ret -ne 0 ]; then
	    echo "'$cmd "$@"' failed." >&2
	fi
    else
	"$cmd" "$@" 2>/dev/null || ret=$?
    fi

    return $ret
}

get_satellites() {
    local package_builders="$1"
    local actual_builders=

    for user in $package_builders; do
	# Check if the user actually exists:
	run id -u "$user" 1>/dev/null || continue

	user="$(id -un "$user")"

	# Check if the *_b satellite user actually exists:
	run id -u "${user}_b" 1>/dev/null || continue

	# Append to the list:
	[ -z "$actual_builders" ] || \
	    actual_builders="$actual_builders "
	actual_builders="$actual_builders${user}_b"
    done

    if [ -n "$actual_builders" ]; then
	echo "$actual_builders"
    fi
}

if [ -z "$PACKAGE_BUILDERS" ]; then
    PACKAGE_BUILDERS="$(list_hashmen)"
fi

actual_builders="$(get_satellites "$PACKAGE_BUILDERS")"
if [ $verbose -ne 0 ]; then
    if [ -n "$actual_builders" ]; then
	echo "Package builders: $actual_builders" >&2
    else
	echo "Package builders: (no builders, nothing to do!)" >&2
    fi
fi


## Managing the audit rules

### Rule managing functions

list_arches() {
    if [ -z "$ARCH_LIST" ]; then
	echo 'b32'
	echo 'b64'
    else
	echo "$ARCH_LIST" | tr '[[:space:]]' '\n'
    fi
}

list_syscall_arches() {
    local syscall="$1"

    local arch_list=
    local num_list=
    for arch in $(list_arches); do
	# Check the syscall with ausyscall:
	if num=$(run ausyscall "$arch" "$syscall" --exact)
	then
	    # Check if the number is already on the list
	    # and update the lists otherwise.
	    if [ -z "$num_list" ]; then
		num_list="$num"
	    elif ! echo "$num_list" | grep -q -wF "$num"; then
		num_list="$num_list $num"
	    else
		continue
	    fi
	    if [ -z "$arch_list" ]; then
		arch_list="$arch"
	    else
		arch_list="$arch_list $arch"
	    fi
	fi
    done

    if [ -n "$arch_list" ]; then
	echo "$arch_list"
    fi
}

compose_rule() {
    local user="$1"
    local syscall="$2"
    local arch="$3"
    local prefix="${4:-}"

    local _user="$(run id -u "$user" 2>/dev/null)"
    if [ -n "$_user" ]; then
	user="$_user"
    fi

    echo "${prefix:+$prefix }always,exit -F arch=$arch -S $syscall -F uid=$user"
}

generate_rules() {
    local handler="$1"; shift

    for syscall in $SYSCALLS; do
	local syscall_arches="$(list_syscall_arches "$syscall")"
	if [ -z "$syscall_arches" ]; then
	    if [ $quiet -eq 0 ]; then
		echo "ERROR: The call '$syscall' is not defined for any of the configured architectures." >&2
	    fi
	    return 2
	fi

	for arch in $syscall_arches; do
	    for user in $actual_builders; do
		"$handler" "$user" "$syscall" "$arch" "$@" || \
		    return $?
	    done
	done
    done
}

insert_rules() {
    if [ $dry_run -ne 0 ]; then
	echo "Would insert audit rules:" >&2
    else
	if [ $verbose -ne 0 ]; then
	    echo "Inserting audit rules:" >&2
	fi
    fi

    generate_rules compose_rule '-a' >"$workdir/wanted.rules"

    if [ $verbose -ne 0 ]; then
	if [ ! -s "$workdir/wanted.rules" ]; then
	    echo "No rules." >&2
	    return 0
	else
	    cat "$workdir/wanted.rules" >&2
	fi
    fi

    chmod 0600 "$workdir/wanted.rules"
    if run auditctl -R "$workdir/wanted.rules"; then
	if [ $verbose -ne 0 ]; then
	    echo "Rule insertion OK." >&2
	fi
    else
	if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
	    echo "Rule insertion FAILED." >&2
	fi
	return 1
    fi
}

prepare_rules() {
    if [ ! -e "$workdir/wanted.rules" ]; then
	generate_rules compose_rule '-a' | sort >"$workdir/expected.rules"
    else
	sort "$workdir/wanted.rules" >"$workdir/expected.rules"
    fi
    run auditctl -l | grep -e '-a always,exit' | sort >"$workdir/actual.rules"
}

check_rules() {    
    if [ $verbose -ne 0 ]; then
	echo "Checking audit rules:" >&2
    fi

    prepare_rules

    if [ $verbose -ne 0 ]; then
	if [ ! -s "$workdir/expected.rules" ]; then
	    echo "No rules." >&2
	    return 0
	else
	    cat "$workdir/expected.rules" >&2
	fi
    fi

    if [ $(comm -23 "$workdir/expected.rules" \
	   "$workdir/actual.rules" | wc -l) -eq 0 ]
    then
	if [ $verbose -ne 0 ]; then
	    echo "Rule check OK." >&2
	fi
    else
	if [ $verbose -ne 0 ]; then
	    echo "The following rules aren't found:" >&2
	    comm -23 "$workdir/expected.rules" "$workdir/actual.rules" >&2
    	    echo "Rule check FAILED." >&2
	fi
	return 1
    fi
}

remove_config_rules() {
    if [ $dry_run -ne 0 ]; then
	echo "Would remove audit rules:" >&2
    else
	if [ $verbose -ne 0 ]; then
	    echo "Removing audit rules:" >&2
	fi
    fi

    prepare_rules

    if [ -e "$workdir/expected.rules" -a ! -s "$workdir/expected.rules" ]
    then
	echo "No rules." >&2
	return 0
    fi

    comm -23 "$workdir/expected.rules" "$workdir/actual.rules" \
	 >"$workdir/missing.rules"

    if [ $verbose -ne 0 ]; then
	if [ ! -s "$workdir/expected.rules" ]; then
	    echo "No rules." >&2
	    return 0
	else
	    cat "$workdir/expected.rules" >&2
	fi
    fi

    comm -12 "$workdir/expected.rules" "$workdir/actual.rules" | \
	sed -e 's/^-a/-d/' >"$workdir/unwanted.rules"

    chmod 0600 "$workdir/unwanted.rules"
    if run auditctl -R "$workdir/unwanted.rules"; then
	if [ -s "$workdir/missing.rules" ]; then
	    if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
		echo "The following rules are missing:" >&2
		comm -23 "$workdir/expected.rules" "$workdir/actual.rules" >&2
	    fi
	else
	    if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
		echo "Rule deletion OK." >&2
	    fi
	fi
    else
	if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
    	    echo "Rule deletion FAILED." >&2
	fi
	return 1
    fi
}


### Perform the actions

workdir="$(mktemp -d --tmpdir $PROG.XXXX)"
trap "rm -rf '$workdir'" EXIT

if [ $do_insert -ne 0 ]; then
    if insert_rules "$actual_builders"; then
	if [ $verbose -ne 0 ]; then
	    echo "Successfully inserted the configured rules." >&2
	fi
    else
	if [ $quiet -eq 0 ]; then
	    echo "ERROR: Failed to insert some of the configured rules." >&2
	fi
	exit 1
    fi
fi

if [ $do_check -ne 0 ]; then
    if check_rules "$actual_builders"; then
	if [ $verbose -ne 0 ]; then
	    echo "Current rules seem to be OK." >&2
	fi
    else
	if [ $quiet -eq 0 ]; then
	    echo "ERROR: Current rules differ from what is configured." >&2
	fi
	exit 1
    fi
fi

if [ $do_remove -ne 0 ]; then
    if remove_config_rules "$actual_builders"; then
	if [ $verbose -ne 0 ]; then
	    echo "Successfully removed the configured rules." >&2
	fi
    else
	if [ $quiet -eq 0 ]; then
	    echo "ERROR: Failed to remove some of the configured rules." >&2
	fi
	exit 1
    fi
fi

if [ $verbose -ne 0 ]; then
    echo "All actions successfully finished." >&2
fi
