feat[script]: add restore script generation

if a default_state is defined in a rule, then when generating the scripts, a new restore script will be included that has the code to restore the state for those defined rules.
This commit is contained in:
Dan Brodjieski
2026-01-02 14:38:24 -05:00
parent d99e938c99
commit 46668a2afc
7 changed files with 329 additions and 5 deletions

View File

@@ -1,8 +1,41 @@
#!/bin/zsh --no-rcs
## This script will attempt to audit all of the settings based on the installed profile.
## This script is provided as-is and should be fully tested on a system that is not in a production environment.
#######################################################################################
#
# THIS SCRIPT IS PROVIDED BY THE MACOS SECURITY COMPLIANCE PROJECT (MSCP) "AS IS".
# IN NO EVENT SHALL MSCP BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SCRIPT, EVEN IF ADVISED
# OF THE POSSIBILITY OF SUCH DAMAGE.
#
#######################################################################################
#
# MSCP COMPLIANCE SCRIPT BUILD INFORMATION
#
# MSCP RELEASE: {{ mscp_version }}
# BASELINE: {{ baseline_name }}
# DATE: {{ todays_date }}
#
# The information above represents the version and build of MSCP that this script
# was generated from along with which baseline was used and the date it was generated.
#
#######################################################################################
#
# Purpose and Use
#
# The purpose of this script is to perform compliance check and remediation actions
# against the baseline generated by {{ mscp_version }}.
# Implementation of this script should undergo thorough testing and understanding
# prior to deployment in production environments.
#
# The script can be run interactively or silently.
#
# For additional information on MSCP, please visit the project's homepage at:
# https://github.com/usnistgov/macos_security
#
#######################################################################################
################### Variables ###################

View File

@@ -0,0 +1,13 @@
{% set check_tags = ["permanent", "inherent", "n_a", "not_applicable"] %}
{% if "supplemental" not in rule.rule_id and not (rule.tags | select("in", check_tags) | list) %}
{% if rule.default_state is not none and rule.default_state | length > 0 %}
#####----- Rule: {{ rule.rule_id }} -----#####
## Addresses the following NIST 800-53 controls:
{{ rule.references.nist.nist_800_53r5 | group_ulify if rule.references.nist.nist_800_53r5 is not none else "# * N/A" }}
default_state_code="{{ rule.default_state | replace("\\\\", "\\") | replace('\"', '\\\"') | replace('$', '\$') | trim | safe }}"
rule_default_state "{{ rule.rule_id }}" "$default_state_code"
{% endif %}
{% endif %}

View File

@@ -0,0 +1,205 @@
#!/bin/zsh --no-rcs
#######################################################################################
#
# THIS SCRIPT IS PROVIDED BY THE MACOS SECURITY COMPLIANCE PROJECT (MSCP) "AS IS".
# IN NO EVENT SHALL MSCP BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SCRIPT, EVEN IF ADVISED
# OF THE POSSIBILITY OF SUCH DAMAGE.
#
#######################################################################################
#
# MSCP COMPLIANCE SCRIPT BUILD INFORMATION
#
# MSCP RELEASE: {{ mscp_version }}
# BASELINE: {{ baseline_name }}
# DATE: {{ todays_date }}
#
# The information above represents the version and build of MSCP that this script
# was generated from along with which baseline was used and the date it was generated.
#
#######################################################################################
#
# Purpose and Use
#
# The purpose of this script is to restore the default macOS state for the settings
# applied using the baseline generated by {{ mscp_version }}.
# Implementation of this script should undergo thorough testing and understanding
# prior to deployment in production environments.
#
# The script can be run interactively or silently.
#
# For additional information on MSCP, please visit the project's homepage at:
# https://github.com/usnistgov/macos_security
#
#######################################################################################
################### DEBUG MODE - hold shift when running the script ###################
shiftKeyDown=$(osascript -l JavaScript -e "ObjC.import('Cocoa'); ($.NSEvent.modifierFlags & $.NSEventModifierFlagShift) > 1")
if [[ $shiftKeyDown == "true" ]]; then
echo "-----DEBUG-----"
set -o xtrace -o verbose
fi
################### COMMANDS START BELOW THIS LINE ###################
# Check if the current shell is Zsh
if [[ -z "$ZSH_NAME" ]]; then
echo "ERROR: This script must be run in Zsh."
exit 1
fi
## Must be run as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root"
exit 1
fi
# path to PlistBuddy
plb="/usr/libexec/PlistBuddy"
# get the currently logged in user
CURRENT_USER=$( /usr/sbin/scutil <<< "show State:/Users/ConsoleUser" | /usr/bin/awk '/Name :/ && ! /loginwindow/ { print $3 }')
CURR_USER_UID=$(/usr/bin/id -u $CURRENT_USER)
# get system architecture
arch=$(/usr/bin/arch)
# configure colors for text
RED='\e[31m'
STD='\e[39m'
GREEN='\e[32m'
YELLOW='\e[33m'
audit_log="/Library/Logs/{{ audit_name }}_baseline.log"
baseline_name="{{ baseline_name }}"
# pause function
pause(){
vared -p "Press [Enter] key to continue..." -c fackEnterKey
}
# logging function
logmessage(){
local level="${2:-INFO}"
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local message="$1"
# Format: [LEVEL] [TIMESTAMP] MESSAGE
local log_entry="[$level] [$timestamp] $message"
# Always log to file
echo "$log_entry" >> "$audit_log"
# Handle quiet levels for console output
if [[ ! $quiet ]]; then
echo "$log_entry"
fi
}
ask() {
# if fix flag is passed, assume YES for everything
if [[ $all ]]; then
return 0
fi
while true; do
if [ "${2:-}" = "Y" ]; then
prompt="Y/n"
default=Y
elif [ "${2:-}" = "N" ]; then
prompt="y/N"
default=N
else
prompt="y/n"
default=
fi
# Ask the question - use /dev/tty in case stdin is redirected from somewhere else
printf "${YELLOW} $1 [$prompt] ${STD}"
read REPLY
# Default?
if [ -z "$REPLY" ]; then
REPLY=$default
fi
# Check if the reply is valid
case "$REPLY" in
Y*|y*) return 0 ;;
N*|n*) return 1 ;;
esac
done
}
rule_default_state(){
ask "$1 - Run the command(s)-> $2" N
if [[ $? == 0 ]]; then
logmessage "Running the command to restore the settings for: $1 ..."
eval "$2"
fi
}
run_default_state(){
# append to existing logfile
logmessage "Beginning restoration of default settings"
if [[ ! $all ]]; then
ask 'THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY OF ANY KIND, EITHER EXPRESSED, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY THAT THE SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND FREEDOM FROM INFRINGEMENT, AND ANY WARRANTY THAT THE DOCUMENTATION WILL CONFORM TO THE SOFTWARE, OR ANY WARRANTY THAT THE SOFTWARE WILL BE ERROR FREE. IN NO EVENT SHALL NIST BE LIABLE FOR ANY DAMAGES, INCLUDING, BUT NOT LIMITED TO, DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES, ARISING OUT OF, RESULTING FROM, OR IN ANY WAY CONNECTED WITH THIS SOFTWARE, WHETHER OR NOT BASED UPON WARRANTY, CONTRACT, TORT, OR OTHERWISE, WHETHER OR NOT INJURY WAS SUSTAINED BY PERSONS OR PROPERTY OR OTHERWISE, AND WHETHER OR NOT LOSS WAS SUSTAINED FROM, OR AROSE OUT OF THE RESULTS OF, OR USE OF, THE SOFTWARE OR SERVICES PROVIDED HEREUNDER. WOULD YOU LIKE TO CONTINUE? ' N
if [[ $? != 0 ]]; then
exit 1
fi
fi
{% for profile in baseline.profile %}
{% for rule in profile.rules %}
{% include "restore.jinja" %}
{% endfor %}
{% endfor %}
} 2>/dev/null
usage=(
"Usage: ${CMD:=${0##*/}} [--all] [--quiet=<value>]"
" "
"Optional parameters:"
"--all : run the restore to default state on all rules"
"--quiet : do not display log messages to stdout"
)
set -- "$@" "${EOL:=$(printf '\1\3\3\7')}"
# Look for managed arguments for compliance script
if [[ $# -eq 0 ]];then
compliance_args=$(/usr/bin/osascript -l JavaScript << 'EOS'
var defaults = $.NSUserDefaults.alloc.initWithSuiteName('org.{{ baseline_name }}.audit');
var args = defaults.objectForKey('compliance_args');
if (args && args.count > 0) {
var result = [];
for (var i = 0; i < args.count; i++) {
result.push(ObjC.unwrap(args.objectAtIndex(i)));
}
result.join(' ');
}
EOS
)
if [[ -n "$compliance_args" ]]; then
logmessage "Managed arguments found for compliance script, setting: $compliance_args"
set -- ${(z)compliance_args}
fi
fi
zparseopts -D -E -help=flag_help -all=all -quiet:=quiet || { print -l $usage && return }
[[ -z "$flag_help" ]] || { print -l $usage && return }
run_default_state

View File

@@ -228,6 +228,7 @@ class Macsecurityrule(BaseModelWithAccessors):
check: str | None = None
fix: str | None = None
severity: str | None = None
default_state: str | None = None
@classmethod
def load_rules(
@@ -281,6 +282,7 @@ class Macsecurityrule(BaseModelWithAccessors):
result_value: str | int | bool | None = None
check_value: str | None = None
fix_value: str | None = None
default_state_value: str | None = None
mechanism: str = "Manual"
payloads: list[Mobileconfigpayload] | None = []
severity: str | None = None
@@ -352,6 +354,9 @@ class Macsecurityrule(BaseModelWithAccessors):
check_result = enforcement_info.get("check", {}).get("result")
fix_shell = enforcement_info.get("fix", {}).get("shell")
additional_info = enforcement_info.get("fix", {}).get("additional_info")
default_state_shell = enforcement_info.get("default_state", {}).get(
"shell"
)
if check_result:
for k, v in rule_yaml["platforms"][os_type]["enforcement_info"][
@@ -374,6 +379,9 @@ class Macsecurityrule(BaseModelWithAccessors):
if check_shell:
check_value = check_shell
if default_state_shell:
default_state_value = default_state_shell
if (
not check_shell
or not fix_shell
@@ -530,6 +538,7 @@ class Macsecurityrule(BaseModelWithAccessors):
os_version=os_version,
check=check_value,
fix=fix_value,
default_state=default_state_value,
severity=severity,
)

View File

@@ -26,6 +26,7 @@ from ..generate.guidance_support import (
generate_excel,
generate_profiles,
generate_script,
generate_restore_script,
)
@@ -139,6 +140,9 @@ def generate_guidance(args: argparse.Namespace) -> None:
if args.script and args.os_name == "macos":
logger.info("Generating compliance script")
generate_script(build_path, baseline_name, audit_name, baseline, log_reference)
generate_restore_script(
build_path, baseline_name, audit_name, baseline, log_reference
)
if args.xlsx:
logger.info("Generating Excel document")
@@ -174,7 +178,20 @@ def generate_guidance(args: argparse.Namespace) -> None:
if args.os_name == "macos":
logger.info("Generating compliance script")
generate_script(
build_path, baseline_name, audit_name, baseline, log_reference
build_path,
baseline_name,
audit_name,
baseline,
log_reference,
current_version_data,
)
generate_restore_script(
build_path,
baseline_name,
audit_name,
baseline,
log_reference,
current_version_data,
)
# logger.info("Generating Excel document")

View File

@@ -6,6 +6,7 @@ __all__ = [
"generate_excel",
"generate_profiles",
"generate_script",
"generate_restore_script",
]
@@ -13,4 +14,4 @@ from .ddm import generate_ddm
from .documents import generate_documents
from .excel import generate_excel
from .profiles import generate_profiles
from .script import generate_script
from .script import generate_script, generate_restore_script

View File

@@ -1,6 +1,7 @@
# mscp/generate/script.py
# Standard python modules
from datetime import date
from itertools import groupby
from pathlib import Path
from typing import Any
@@ -140,6 +141,7 @@ def generate_script(
audit_name: str,
baseline: Baseline,
log_reference: str,
current_version_data: dict,
) -> None:
output_file: Path = Path(build_path, f"{baseline_name}_compliance.sh")
env: Environment = Environment(
@@ -160,8 +162,52 @@ def generate_script(
baseline_name=baseline_name,
audit_name=audit_name,
reference_log_id=log_reference,
todays_date=date.today().strftime("%Y-%m-%d"),
mscp_version=current_version_data["compliance_version"],
)
generate_audit_plist(build_path, baseline_name, baseline)
output_file.write_text(rendered_output, encoding="UTF-8")
output_file.chmod(0o755)
def generate_restore_script(
build_path: Path,
baseline_name: str,
audit_name: str,
baseline: Baseline,
log_reference: str,
current_version_data: dict,
) -> None:
output_file: Path = Path(build_path, f"{baseline_name}_restore.sh")
env: Environment = Environment(
loader=FileSystemLoader(config["defaults"]["shell_template_dir"]),
trim_blocks=True,
lstrip_blocks=True,
)
script_template = env.get_template("restore_script.sh.jinja")
env.filters["group_ulify"] = group_ulify
env.filters["log_reference"] = generate_log_reference
env.filters["quotify"] = quotify
baseline_dict: dict[str, Any] = dict(baseline)
any_rendered = any(
rule.get("default_state")
for p in baseline_dict["profile"]
for rule in p["rules"]
)
rendered_output = script_template.render(
baseline=baseline_dict,
baseline_name=baseline_name,
audit_name=audit_name,
reference_log_id=log_reference,
todays_date=date.today().strftime("%Y-%m-%d"),
mscp_version=current_version_data["compliance_version"],
)
if any_rendered:
output_file.write_text(rendered_output, encoding="UTF-8")
output_file.chmod(0o755)