diff --git a/common-apparmor.sh b/common-apparmor.sh
new file mode 100644
index 0000000000000000000000000000000000000000..f6a94977038e82708ec2f870dde7df9f396828f6
--- /dev/null
+++ b/common-apparmor.sh
@@ -0,0 +1,43 @@
+# Need to source this file.
+
+# Pass in audit string, returns formatted block as `aa_log_extract_tokens.pl`
+# would have provided. Need to return:
+#
+# profile:/usr/bin/busctl
+# sdmode:REJECTING
+# denied_mask:r
+# operation:open
+# name:/proc/879/stat
+# request_mask:r
+#
+# Note: apparmor uses the term "DENIED" rather than "REJECTING", but our
+# expected values want "REJECTING", so tweak for now.
+apparmor_parse_journal() {
+	awk -v event=$2 '
+		{
+			for (i = 1; i <= NF; i++) {
+				n = index($i, "=");
+				if(n) {
+					vars[substr($i, 1, n - 1)] = substr($i, n + 1)
+					# Strip quotes
+					gsub("\"","",vars[substr($i, 1, n - 1)])
+				}
+			}
+		}
+		{
+			if (vars["apparmor"] == event) {
+				print "===="
+				print "profile:"vars["profile"]
+				# Match old nomenclature
+				if (vars["apparmor"] == "DENIED") {
+					vars["apparmor"] = "REJECTING"
+				}
+				print "sdmode:"vars["apparmor"]
+				print "denied_mask:"vars["denied_mask"]
+				print "operation:"vars["operation"]
+				print "name:"vars["name"]
+				print "request_mask:"vars["requested_mask"]
+			}
+		}
+	' $1
+}
diff --git a/run-aa-test b/run-aa-test
new file mode 100755
index 0000000000000000000000000000000000000000..ad6ec39bd77d976c4606991e96913e9b71141800
--- /dev/null
+++ b/run-aa-test
@@ -0,0 +1,180 @@
+#!/bin/sh
+#
+# Copyright © 2018 Collabora Ltd.
+#
+# Based on python version of run-aa-test
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+. common/common-apparmor.sh
+
+ALTERNATIVE_SEPARATOR="## alternative ##"
+END=2
+
+case $(echo ${LAUNCH_DBUS} | tr [A-Z] [a-z]) in
+0 | no | false)
+	LAUNCH_DBUS="False"
+	;;
+*)
+	LAUNCH_DBUS="True"
+esac
+
+case $(echo ${RUN_AS_USER} | tr [A-Z] [a-z]) in
+0 | no | false)
+	RUN_AS_USER="False"
+	;;
+*)
+	RUN_AS_USER="True"
+esac
+
+CHAIWALA_UID=1000
+CHAIWALA_USER="user"
+
+# Check parameters
+if [ $# -lt 2 ]; then
+
+	echo "Usage: run-aa-test <expectation-file> <command> <argument-1> <argument-2> …"
+	echo "\"export LAUNCH_DBUS=no\" in the test script to not launch a dbus session."
+	echo "\"export RUN_AS_USER=no\" in the test script to not run as ${CHAIWALA_USER}"
+	exit 1
+fi
+
+EXPECT_FILE=$1
+shift
+
+if [ ! -r ${EXPECT_FILE} ]; then
+	echo "Cannot read specified expectation file: ${EXPECT_FILE}"
+	exit 1
+fi
+
+if [ ! -x $1 ]; then
+	echo "Cannot execute specified test executable: $1"
+	exit 1
+fi
+
+# typically "normal.expected" or "malicious.expected"
+TEST_TITLE=$( basename ${EXPECT_FILE} )
+
+# Touch .bash_history, which we use in some tests, if it's not there.
+bash_history="/home/${CHAIWALA_USER}/.bash_history"
+if [ ! -r ${bash_history} ]; then
+	RET=$( sudo -u ${CHAIWALA_USER} touch ${bash_history} )
+	if [ $RET != 0 ]; then
+		echo "Failed to create .bash_history: $RET"
+		exit 1
+	fi
+fi
+
+# Create a temporary directory for files
+TMP_DIR=$(mktemp -d)
+
+# Log start time
+START_TIME=$(date +"%F %T")
+
+if [ "${LAUNCH_DBUS}" = "True" ]; then
+	# Start a new D-Bus session for this test
+	CMD="dbus-run-session -- $*"
+else
+	CMD="$*"
+fi
+
+CMDLINE="common/run-test-in-systemd"
+if [ ! -x $CMDLINE ]; then
+	echo "common/run-test-in-systemd not found"
+	exit 1
+fi
+
+CMDLINE="${CMDLINE} --no-lava"
+
+if [ "${RUN_AA_TEST_TIMEOUT}" != "" ]; then
+	CMDLINE="${CMDLINE} --timeout=${RUN_AA_TEST_TIMEOUT}"
+fi
+
+if [ "${RUN_AS_USER}" = "True" ]; then
+	CMDLINE="${CMDLINE} --user=${CHAIWALA_UID}"
+else
+	CMDLINE="${CMDLINE} --system"
+fi
+
+CMDLINE="${CMDLINE} ${CMD}"
+
+echo "#=== running test script: ${CMDLINE} ==="
+
+setsid ${CMDLINE}
+RET=$?
+
+echo "#--- end of test script, status: ${RET}"
+
+if [ "${RET}" = "0" ]; then
+	echo "${TEST_TITLE}_underlying_tests: pass"
+else
+	echo "# ${CMDLINE} exited ${RET}"
+	# typically "normal.expected_underlying_tests: fail"
+	echo "${TEST_TITLE}_underlying_tests: fail"
+
+	exit 1
+fi
+
+# Give journal time to log the entries.
+sleep 3
+
+# Get audit information from journal
+AUDIT_FILE=${TMP_DIR}/AUDIT
+journalctl -S "${START_TIME}" -t audit -o cat > ${AUDIT_FILE}
+
+echo "#=== ${TEST_TITLE} ==="
+
+echo "#---8<--- raw apparmor output from journal"
+cat ${AUDIT_FILE} | sed 's/^/# /'
+echo "#--->8---"
+
+echo "#---8<--- expected parsed apparmor output from journal"
+cat ${EXPECT_FILE} | sed 's/^/# /'
+echo "#--->8---"
+
+csplit ${EXPECT_FILE} -f ${TMP_DIR}/EXPECT -b "%d" "/^${ALTERNATIVE_SEPARATOR}$/" {*}
+
+# Old versions of csplit don't provide "--suppress-matched", strip separator separately
+for FILE in ${TMP_DIR}/EXPECT*; do
+	sed -i "/^${ALTERNATIVE_SEPARATOR}$/d" ${FILE}
+done
+
+PARSE_FILE="${TMP_DIR}/PARSE"
+
+# TODO: There is potential for other processes to cause messages to appear in
+#       the journal that may lead to false failures. If this is found to be an
+#       issue in practice, then additional filtering of results may be required
+apparmor_parse_journal ${AUDIT_FILE} DENIED > ${PARSE_FILE}
+
+echo "#---8<--- actual parsed apparmor output from journal"
+cat ${PARSE_FILE} | sed 's/^/# /'
+echo "#--->8---"
+
+MATCH_EXPECTATION="False"
+
+# We might have alternative expectations, take that into consideration.
+OUTPUT_MD5=$( cat ${PARSE_FILE} | md5sum )
+COUNT=$( ls -1 ${TMP_DIR}/EXPECT* | wc -l )
+NUM=0
+while [ $((${NUM} < ${COUNT})) = 1 ]; do
+	EXPECTED_MD5=$( cat ${TMP_DIR}/EXPECT${NUM} | md5sum )
+	if [ "${OUTPUT_MD5}" = "${EXPECTED_MD5}" ]; then
+		echo "# audit log matches alternative expectation ${NUM}/${COUNT}"
+		MATCH_EXPECTATION="True"
+	fi
+	NUM=$((${NUM}+1))
+done
+
+if [ "${MATCH_EXPECTATION}" = "True" ]; then
+	echo "${TEST_TITLE}: pass"
+else
+	echo "#---8<--- diff"
+	diff -urN ${TMP_DIR}/EXPECT${NUM} ${PARSE_FILE}
+	echo "#--->8---"
+	echo "${TEST_TITLE}: fail"
+	exit 1
+fi
+
+exit 0