#!/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.1.3

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 [ $verbose -ne 0 ]; then
    if [ -n "$ARCH_LIST" ]; then
	echo "Configured architectures: $ARCH_LIST" >&2
    else
	echo "Configured architectures: (all platform architectures)" >&2
    fi
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
	setarch --list | grep -v -e '^uname' -e '^linux' -e '^athlon'
    else
	echo "$ARCH_LIST" | tr ' ' '\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"

    echo "always,exit -F arch=\"$arch\" -F uid=\"$user\" -S \"$syscall\""
}

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

    for user in $actual_builders; do
	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
		"$handler" "$user" "$syscall" "$arch" "$@" || \
		    return $?
	    done
	done
    done
}

insert_rule() {
    local user="$1"
    local syscall="$2"
    local arch="$3"

    if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
	echo "  * $(compose_rule "$user" "$syscall" "$arch")" >&2
    fi
    if [ $dry_run -eq 0 ]; then
	run auditctl -a always,exit -F uid="$user" -F arch="$arch" -S "$syscall"
    fi
}

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

    if generate_rules insert_rule; 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
}

get_named_field() {
    local rule="$1"
    local option="$2"
    local field_name="$3"

    echo "$rule" | sed -n -e "s/^.*[[:space:]]$option[[:space:]]\\+$field_name=\"\\?\\([^\"[:space:]]\\+\\)\"\\?\\([[:space:]].*\\)\\?\$/\\1/p"
}

get_option_field() {
    local rule="$1"
    local option="$2"

    echo "$rule" | sed -n -e "s/^.*[[:space:]]$option[[:space:]]\\+\"\\?\\([^\"[:space:]]\\+\\)\"\\?\\([[:space:]].*\\)\\?\$/\\1/p"
}

list_rules() {
    run auditctl -l | grep -e '-a always,exit' -e '-a exit,always' | (
	while read -r rule; do
	    arch="$(get_named_field "$rule" '-F' 'arch')"
	    uid="$(get_named_field "$rule" '-F' 'uid')"
	    syscall="$(get_option_field "$rule" '-S')"
	    echo "$uid:$syscall:$arch"
	done
    )
}

check_syscall() {
    local arch="$1"
    local syscall="$2"
    local rule_arch="$3"
    local rule_syscall="$4"

    if [ -z "$syscall" ]; then
	return 0
    fi

    if [ $debug -ne 0 ]; then
	echo -n "Comparing $arch:$syscall with $rule_arch:$rule_syscall: " >&2
    fi

    if [ "$arch" = "$rule_arch" -a "$syscall" = "$rule_syscall" ]
    then
	if [ $debug -ne 0 ]; then
	    echo "exact match" >&2
	fi
	return 0
    fi

    if [ -n "${TEST_PLATFORM:-}" ]; then
	if [ "$rule_arch" = "${TEST_PLATFORM#*:}" ]; then
	    rule_arch="${TEST_PLATFORM%%:*}"
	fi
    fi

    local num=
    local rule_num=
    if num=$(run ausyscall ${arch:+"$arch"} "$syscall" --exact) && \
       rule_num=$(run ausyscall ${rule_arch:+"$rule_arch"} "$syscall" --exact)
    then
	if [ $num -eq $rule_num ]; then
	    if [ "$syscall" = "$rule_syscall" -o \
		 "$num" = "$rule_syscall" ]
	    then
		if [ $debug -ne 0 ]; then
		    echo "syscall number match" >&2
		fi
		return 0
	    fi
	fi
    fi

    if [ $debug -ne 0 ]; then
	echo "no match" >&2
    fi

    return 1
}

check_user() {
    local user="$1"
    local rule_user="$2"

    if [ $debug -ne 0 ]; then
	echo -n "Comparing UIDs $user and $rule_user: " >&2
    fi

    if [ -z "$rule_user" ]; then
	if [ $debug -ne 0 ]; then
	    echo "empty user" >&2
	fi

	return 1
    fi

    if [ "$user" = "$rule_user" ]
    then
	if [ $debug -ne 0 ]; then
	    echo "exact match" >&2
	fi
	return 0
    fi

    local uid=
    if uid=$(run id -u "$user"); then
	if [ $uid -eq "$rule_user" ]; then
	    if [ $debug -ne 0 ]; then
		echo "UID number match" >&2
	    fi
	    return 0
	fi
    fi

    if [ $debug -ne 0 ]; then
	echo "no match" >&2
    fi

    return 1
}

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

    [ -n "$rule_list" ] || return 1

    echo "$rule_list" | (
	ret=1
	IFS=:
	while read -r rule_user rule_syscall rule_arch; do
	    if check_user "$user" "$rule_user" && \
	       check_syscall "$arch" "$syscall" \
			     "$rule_arch" "$rule_syscall"
	    then
		echo "$rule_user:$rule_syscall:$rule_arch"
		ret=0
	    fi
	done

	exit $ret
    )
}

check_rule() {
    local user="$1"
    local syscall="$2"
    local arch="$3"
    local rule_list="$4"

    if [ $verbose -ne 0 ]; then
	echo -n "  * $(compose_rule "$user" "$syscall" "$arch") ... " >&2
    fi

    if find_rules "$rule_list" "$user" "$syscall" "$arch" >/dev/null
    then
	if [ $verbose -ne 0 ]; then
	    echo "found" >&2
	fi
	return 0
    else
	if [ $verbose -ne 0 ]; then
	    echo "missing" >&2
	fi
	return 1
    fi
}

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

    if generate_rules check_rule "$(list_rules)"; then
	if [ $verbose -ne 0 ]; then
	    echo "Rule check OK." >&2
	fi
    else
	if [ $verbose -ne 0 ]; then
    	    echo "Rule check FAILED." >&2
	fi
	return 1
    fi
}

remove_rule() {
    local user="$1"
    local syscall="$2"
    local arch="$3"
    local rule_list="$4"

    if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
	echo -n "  * $(compose_rule "$user" "$syscall" "$arch") ... " >&2
    fi

    if find_rules "$rule_list" "$user" "$syscall" "$arch" >/dev/null
    then
	if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
	    echo -n "found" >&2
	fi
	if [ $dry_run -eq 0 ]; then
	    if run auditctl -d always,exit -F uid="$user" -F arch="$arch" -S "$syscall"; then
		if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
		    echo " ... removed" >&2
		fi
	    else
		if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
		    echo " ... error" >&2
		fi
		return 1
	    fi
	else
	    echo >&2
	fi
    else
	if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
	    echo "missing" >&2
	    echo "$user:$syscall:$arch"
	fi
    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

    local missing_rules=
    if missing_rules="$(generate_rules remove_rule "$(list_rules)")"
    then
	if [ -n "$missing_rules" ]; then
	    if [ $verbose -ne 0 -o $dry_run -ne 0 ]; then
		echo "Some rules aren't found." >&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

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
