refactor: update tailoring interaction

This commit is contained in:
Dan Brodjieski
2025-12-23 09:20:16 -05:00
parent 4c69564569
commit 00d86959cc
3 changed files with 206 additions and 25 deletions

View File

@@ -21,6 +21,7 @@ from ..common_utils import (
mscp_data,
open_file,
sanitize_input,
prompt_for_odv,
collect_overrides,
)
from ..common_utils.logger_instance import logger
@@ -385,9 +386,11 @@ class Macsecurityrule(BaseModelWithAccessors):
mechanism = "Configuration Profile"
for entry in rule_yaml["mobileconfig_info"]:
payload_type: str = str(entry.get("PayloadType", ""))
payload_type: str = str(
entry.get("PayloadType", entry.get("payload_type", ""))
)
payload_content: list[dict[str, Any]] = entry.get(
"PayloadContent", []
"PayloadContent", entry.get("payload_content", [])
)
payloads.append(
@@ -504,9 +507,16 @@ class Macsecurityrule(BaseModelWithAccessors):
bsi["indigo"] = [bsi["indigo"]]
# Map custom references
if custom_refs:
# custom_refs["references"] = custom_refs
rule_yaml["references"]["custom_refs"] = {}
rule_yaml["references"]["custom_refs"]["references"] = [custom_refs]
if "custom_refs" in custom_refs and isinstance(
custom_refs["custom_refs"], dict
):
if custom_refs["custom_refs"] is not None and not isinstance(
custom_refs["custom_refs"], list
):
rule_yaml["references"]["custom_refs"] = {}
rule_yaml["references"]["custom_refs"]["references"] = [
custom_refs
]
rule = cls(
**rule_yaml,
@@ -899,7 +909,10 @@ class Macsecurityrule(BaseModelWithAccessors):
return
odv_lookup: dict[str, Any] = self.odv
odv_value: str | int | bool | None = odv_lookup.get(parent_values)
if "odv" in self.customized:
odv_value: str | int | bool | None = odv_lookup.get("custom")
else:
odv_value: str | int | bool | None = odv_lookup.get(parent_values)
if odv_value is None:
return
# Replace $ODV in text fields
@@ -952,12 +965,14 @@ class Macsecurityrule(BaseModelWithAccessors):
None
"""
rule_file_path: Path = Path(
config["custom"]["rules"][self.section], f"{self.rule_id}.yaml"
f"{config['custom']['rules_dir']}", f"{self.rule_id}.yaml"
)
make_dir(rule_file_path.parent)
self["odv"] = {"custom": odv}
self["odv"]["custom"] = odv
self.customized = []
self.to_yaml(rule_file_path)
@@ -975,7 +990,7 @@ class Macsecurityrule(BaseModelWithAccessors):
None
"""
rule_file_path: Path = Path(
config["custom"]["rules"][self.section], f"{self.rule_id}.yaml"
f"{config['custom']['rules_dir']}", f"{self.rule_id}.yaml"
)
if not rule_file_path.exists():
@@ -983,8 +998,10 @@ class Macsecurityrule(BaseModelWithAccessors):
return
if self.odv is not None and "custom" in self.odv:
self.odv.pop("custom")
self["references"].pop("custom")
self.odv.pop("custom", 0)
# if "references" in self.customized:
# self.references.pop("custom_refs")
self.to_yaml(rule_file_path)
@@ -1036,7 +1053,7 @@ class Macsecurityrule(BaseModelWithAccessors):
include = sanitize_input(
f'Would you like to include the rule for "{rule.rule_id}" in your benchmark? [Y/n/all/?]: ',
str,
range_=("y", "n", "all", "?"),
range_=("Y", "y", "n", "all", "?"),
default_="y",
)
if include == "?":
@@ -1044,7 +1061,7 @@ class Macsecurityrule(BaseModelWithAccessors):
include = sanitize_input(
f'Would you like to include the rule for "{rule.rule_id}" in your benchmark? [Y/n/all]: ',
str,
range_=("y", "n", "all"),
range_=("Y", "y", "n", "all"),
default_="y",
)
queried_rule_ids.append(rule.rule_id)
@@ -1062,22 +1079,21 @@ class Macsecurityrule(BaseModelWithAccessors):
odv_hint = rule.odv.get("hint", "")
odv_recommended = rule.odv.get("recommended")
odv_benchmark = rule.odv.get(benchmark)
if benchmark == "recommended":
print(f"{odv_hint}")
odv = sanitize_input(
print(f"\nODV value: {odv_hint['description']}")
odv = prompt_for_odv(
f'Enter the ODV for "{rule.rule_id}" or press Enter for the recommended value ({odv_recommended}): ',
type(odv_recommended),
default_=odv_recommended,
odv_hint=odv_hint,
default=odv_recommended,
)
if odv != odv_recommended:
rule.write_odv_custom_rule(odv)
else:
print(f"\nODV value: {odv_hint}")
odv = sanitize_input(
print(f"\nODV value: {odv_hint['description']}")
odv = prompt_for_odv(
f'Enter the ODV for "{rule.rule_id}" or press Enter for the default value ({odv_benchmark}): ',
type(odv_benchmark),
default_=odv_benchmark,
odv_hint=odv_hint,
default=odv_benchmark,
)
if odv != odv_benchmark:
rule.write_odv_custom_rule(odv)
@@ -1107,7 +1123,7 @@ class Macsecurityrule(BaseModelWithAccessors):
"platforms",
)
rule_file_path: Path = output_path / f"{self.rule_id}.yaml"
rule_file_path: Path = output_path
serialized_data: dict[str, Any] = self.model_dump()
ordered_data = OrderedDict()
@@ -1115,10 +1131,10 @@ class Macsecurityrule(BaseModelWithAccessors):
serialized_data["references"]["nist"]["800-53r5"] = serialized_data[
"references"
].pop("nist_800_53r5")
].pop("nist_800_53r5", 0)
serialized_data["references"]["nist"]["800-171r3"] = serialized_data[
"references"
].pop("nist_800_171")
].pop("nist_800_171", 0)
for key in serialized_data["references"]:
if isinstance(serialized_data["references"][key], list):

View File

@@ -27,6 +27,7 @@ from .logging_config import set_logger
from .mscp_data import get_mscp_data, mscp_data
from .run_command import run_command
from .sanitize_input import sanitize_input
from .prompt_for_odv import prompt_for_odv
from .validate_rules import validate_yaml_file
from .version_data import get_version_data
@@ -49,6 +50,7 @@ __all__ = [
"remove_file",
"run_command",
"sanitize_input",
"prompt_for_odv",
"get_version_data",
"mscp_data",
"get_mscp_data",

View File

@@ -0,0 +1,163 @@
# mscp/common_utils/prompt_for_odv.py
# Standard python modules
from typing import Any, Dict, Optional
import re
# Local python modules
from .logger_instance import logger
import re
from typing import Any, Dict, Optional
def prompt_for_odv(
prompt: str,
odv_hint: Dict[str, Any],
default: Optional[Any] = None,
) -> Any:
"""
Prompt the user for an 'organization defined value' (ODV) using a single hint dict
that defines datatype, description, and validation rules. Reprompts until valid.
Args:
prompt (str): The prompt shown to the user.
odv_hint (dict): Hint metadata containing:
- 'datatype' (str): one of 'number', 'string', 'enum', 'regex'
- 'description' (str): guidance shown next to the prompt
- 'validation' (dict): rule set keyed by datatype:
* number: {'min': <int>, 'max': <int>} (both optional)
* regex: {'regex': <str>} (required for datatype='regex')
* enum: {'enumValues': [<str>, ...]} (required for datatype='enum')
* string: {'regex': <str>} (optional; applied to raw input)
default (Any, optional): Value used when user hits Enter; will be validated.
Returns:
Any: The validated value (coerced according to the datatype).
Behavior:
- Regex rules apply to the raw text first (if present).
- Values are then coerced according to 'datatype'.
- Range/membership rules apply after coercion.
- If default is provided, it is validated before being returned.
"""
# ---- helpers ------------------------------------------------------------
def _apply_regex(text: str, pattern: str) -> Optional[str]:
"""Return error message if text does NOT match the pattern; else None."""
try:
if re.fullmatch(pattern, text) is None:
return f"Value must match the pattern: {pattern}"
return None
except re.error as ex:
return f"Invalid regex pattern in hint: {ex}"
def _coerce(text: str, dt: str) -> Any:
"""Coerce raw text to the target datatype."""
# default to string if no datatype is provided
dt_norm = (dt or "string").strip().lower()
if dt_norm == "number":
return int(text) # will raise ValueError if invalid
elif dt_norm in ("string", "enum", "regex"):
return text # keep as string; enum/regex validated separately
else:
raise ValueError(
f"Unsupported datatype '{dt}'. Use 'number', 'string', 'enum', or 'regex'."
)
def _validate(
value: Any, raw_text: str, dt: str, rules: Dict[str, Any]
) -> Optional[str]:
"""Return error message string if invalid; else None."""
# if rules are not defined, accept any values
if not rules:
return None
dt_norm = (dt or "").strip().lower()
# 1) Regex on raw input if present (for both string and regex modes)
regex = rules.get("regex")
if regex:
err = _apply_regex(raw_text, regex)
if err:
return err
# 2) Datatype-specific checks after coercion
if dt_norm == "number":
min_v = rules.get("min", None)
max_v = rules.get("max", None)
if min_v is not None and value < min_v:
return f"Value must be ≥ {min_v}."
if max_v is not None and value > max_v:
return f"Value must be ≤ {max_v}."
elif dt_norm == "enum":
opts = rules.get("enumValues")
if not opts or not isinstance(opts, (list, tuple, set)):
return "Missing 'enumValues' for datatype='enum'."
# case-insensitive membership convenience
lowered = {str(o).lower() for o in opts}
if str(value).lower() not in lowered:
return f"Value must be one of: {list(opts)}."
elif dt_norm == "regex":
# Already checked via regex above; enforce presence of rule
if "regex" not in rules:
return "Missing 'regex' rule for datatype='regex'."
# string: nothing additional beyond optional regex
return None
# ---- main loop ----------------------------------------------------------
dt = (odv_hint.get("datatype") or "").strip().lower()
desc = odv_hint.get("description", "")
rules = odv_hint.get("validation", {}) or {}
# Build a friendly prompt line
# hint = f" — {desc}" if desc else ""
# default_hint = f" [default: {default}]" if default not in (None, "") else ""
# display = f"{prompt}{hint}{default_hint}"
while True:
raw = input(f"{prompt}").strip()
# Handle default
if raw == "":
if default is not None:
# Validate default too
try:
coerced_default = (
_coerce(str(default), dt) if dt == "number" else default
)
except ValueError as ex:
print(f"Default value cannot be coerced: {ex}")
# continue reprompting
continue
err = _validate(coerced_default, str(default), dt, rules)
if err is None:
return coerced_default
else:
print(f"Default value invalid: {err}")
continue
else:
print("Please enter a value (or provide a default).")
continue
# Coerce user entry
try:
value = _coerce(raw, dt)
except ValueError as ex:
print(f"\nERROR - Value must be a valid integer")
continue
# Validate combined rules
error = _validate(value, raw_text=raw, dt=dt, rules=rules)
if error is None:
return value
print(f"\nERROR - {error}")
# loop continues