From 4f03132d864ab771317c8753a0cfdb5f4f40a500 Mon Sep 17 00:00:00 2001 From: Gabriel Becker Date: Thu, 23 Oct 2025 14:33:54 +0200 Subject: [PATCH 1/2] build-scripts: extract rule-variable mapping from built OVAL checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds extract_rule_variable_mapping.py which scans per-rule OVAL XML files in build//checks/oval/ and checks_from_templates/oval/ to produce build//rule_variable_mapping.json — a mapping of each rule ID to the XCCDF external variables it depends on. The script is wired into the CMake build via ssg_extract_rule_variable_mapping() and runs as part of every product build after OVAL compilation. Adds test_profile_variables.py which validates that every profile selects all variables required by the rules it includes, warning when a rule's variable dependency would silently fall back to the .var file default. --- .../extract_rule_variable_mapping.py | 265 ++++++++++++++++++ cmake/SSGCommon.cmake | 18 ++ tests/CMakeLists.txt | 6 + tests/test_profile_variables.py | 261 +++++++++++++++++ 4 files changed, 550 insertions(+) create mode 100755 build-scripts/extract_rule_variable_mapping.py create mode 100755 tests/test_profile_variables.py diff --git a/build-scripts/extract_rule_variable_mapping.py b/build-scripts/extract_rule_variable_mapping.py new file mode 100755 index 000000000000..6dadb07b0912 --- /dev/null +++ b/build-scripts/extract_rule_variable_mapping.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 + +""" +Extract Rule-Variable Mappings from OVAL Checks + +This script extracts rule-variable mappings from built OVAL content during +the build process. It scans both regular OVAL checks and template-generated +OVAL checks to build a complete mapping of which rules use which variables. + +The output is a JSON file that maps rule IDs to lists of variable IDs. + +Usage: + python3 extract_rule_variable_mapping.py +""" + +import sys +import json +import re +from pathlib import Path +from typing import Dict, Set + + +def extract_variable_id(var_ref: str) -> str: + """ + Extract the variable ID from a var_ref attribute value. + + Examples: + "oval:ssg-var_password_pam_dcredit:var:1" -> "var_password_pam_dcredit" + "var_password_pam_dcredit" -> "var_password_pam_dcredit" + + Args: + var_ref: The var_ref attribute value + + Returns: + The extracted variable ID + """ + if ':' in var_ref: + # Format: oval:ssg-var_name:var:1 + parts = var_ref.split(':') + if len(parts) >= 2: + var_id = parts[1] + if var_id.startswith('ssg-'): + var_id = var_id[4:] # Remove 'ssg-' prefix + return var_id + # Already in simple format + return var_ref + + + +def extract_internal_variables(content: str) -> Set[str]: + """ + Extract all internal variable IDs from OVAL content. + + Internal variables include: + - local_variable: computed/derived variables + - constant_variable: hardcoded constants (like regex patterns) + + These should be excluded from the external variable dependencies. + + Args: + content: The OVAL XML content as a string + + Returns: + Set of internal variable IDs + """ + internal_vars = set() + + # Pattern: ]+id="([^"]+)"' + matches = re.findall(local_var_pattern, content) + for var_ref in matches: + var_id = extract_variable_id(var_ref) + internal_vars.add(var_id) + + # Pattern: ]+id="([^"]+)"' + matches = re.findall(constant_var_pattern, content) + for var_ref in matches: + var_id = extract_variable_id(var_ref) + internal_vars.add(var_id) + + return internal_vars + + +def extract_external_variables(content: str) -> Set[str]: + """ + Extract all external variable IDs from OVAL content. + + External variables are user-configurable variables that profiles should select. + + Args: + content: The OVAL XML content as a string + + Returns: + Set of external variable IDs + """ + external_vars = set() + # Pattern: ]+id="([^"]+)"' + matches = re.findall(external_var_pattern, content) + + for var_ref in matches: + var_id = extract_variable_id(var_ref) + external_vars.add(var_id) + + return external_vars + + +def extract_variables_from_oval_content(content: str) -> Set[str]: + """ + Extract all variable references from OVAL content, excluding internal variables. + + This function finds all var_ref attributes and filters out internal variables + (local_variable and constant_variable), keeping only external (user-configurable) + variables. + + Args: + content: The OVAL XML content as a string + + Returns: + Set of external variable IDs referenced in the content + """ + # Find all variable references via var_ref attributes + var_refs = set() + var_ref_pattern = r'var_ref="([^"]+)"' + matches = re.findall(var_ref_pattern, content) + + for var_ref in matches: + var_id = extract_variable_id(var_ref) + var_refs.add(var_id) + + # Identify internal variables (local + constant) + internal_vars = extract_internal_variables(content) + + # Identify external variables (user-configurable) + external_vars = extract_external_variables(content) + + # Only keep variables that are: + # 1. Either explicitly declared as external variables, OR + # 2. Referenced but not declared as internal variables + # This handles cases where external variables might be defined elsewhere + result = set() + for var_id in var_refs: + if var_id in external_vars: + # Explicitly declared as external + result.add(var_id) + elif var_id not in internal_vars: + # Not declared as internal, assume it's external (defined elsewhere) + result.add(var_id) + # else: it's an internal variable (local or constant), skip it + + return result + + +def process_oval_file(oval_file: Path) -> Dict[str, Set[str]]: + """ + Process a single OVAL file and extract rule-variable mappings. + + The rule ID is derived from the filename stem — both check formats + (checks/oval/ with OVAL namespace and checks_from_templates/oval/ with + bare def-group) name their files after the rule they belong to. + + Args: + oval_file: Path to the OVAL XML file + + Returns: + Dictionary mapping the rule ID to the set of variable IDs it depends on + """ + # The filename stem is the rule ID for both OVAL file formats. Using the + # filename avoids the need to parse definition IDs, which differ between + # checks/oval/ (oval:ssg-:def:1 with oval-def: namespace prefix) and + # checks_from_templates/oval/ (bare ) formats. + rule_id = oval_file.stem + + try: + with open(oval_file, 'r', encoding='utf-8') as f: + content = f.read() + + var_refs = extract_variables_from_oval_content(content) + if var_refs: + return {rule_id: var_refs} + + except (IOError, UnicodeDecodeError) as e: + print(f"Warning: Could not process {oval_file}: {e}", file=sys.stderr) + + return {} + + +def build_rule_variable_mapping(product: str, build_dir: Path) -> Dict[str, list]: + """ + Build complete rule-variable mapping for a product. + + Args: + product: Product name (e.g., 'rhel10') + build_dir: Path to the build directory + + Returns: + Dictionary mapping rule IDs to lists of variable IDs + """ + product_dir = build_dir / product + checks_dir = product_dir / "checks" / "oval" + templates_dir = product_dir / "checks_from_templates" / "oval" + + # Aggregate all rule-variable mappings + all_mappings: Dict[str, Set[str]] = {} + + # Process regular OVAL checks + if checks_dir.exists(): + # print(f"Processing OVAL checks in {checks_dir}") + for oval_file in checks_dir.glob("*.xml"): + rule_vars = process_oval_file(oval_file) + for rule_id, var_ids in rule_vars.items(): + if rule_id not in all_mappings: + all_mappings[rule_id] = set() + all_mappings[rule_id].update(var_ids) + + # Process template-generated OVAL checks + if templates_dir.exists(): + # print(f"Processing template OVAL checks in {templates_dir}") + for oval_file in templates_dir.glob("*.xml"): + rule_vars = process_oval_file(oval_file) + for rule_id, var_ids in rule_vars.items(): + if rule_id not in all_mappings: + all_mappings[rule_id] = set() + all_mappings[rule_id].update(var_ids) + + # Convert sets to sorted lists for JSON serialization + result = {rule_id: sorted(list(var_ids)) for rule_id, var_ids in all_mappings.items()} + + # print(f"Found {len(result)} rules with variable dependencies") + + return result + + +def main(): + if len(sys.argv) != 4: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + print(f"Example: {sys.argv[0]} rhel10 build build/rhel10/rule_variable_mapping.json", file=sys.stderr) + return 1 + + product = sys.argv[1] + build_dir = Path(sys.argv[2]) + output_file = Path(sys.argv[3]) + + if not build_dir.exists(): + print(f"Error: Build directory {build_dir} does not exist", file=sys.stderr) + return 1 + + # Build the mapping + mapping = build_rule_variable_mapping(product, build_dir) + + # Ensure output directory exists + output_file.parent.mkdir(parents=True, exist_ok=True) + + # Write to JSON file + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(mapping, f, indent=2, sort_keys=True) + + # print(f"Rule-variable mapping written to {output_file}") + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/cmake/SSGCommon.cmake b/cmake/SSGCommon.cmake index c3c439ddf9bc..fe935545e1a1 100644 --- a/cmake/SSGCommon.cmake +++ b/cmake/SSGCommon.cmake @@ -183,6 +183,22 @@ macro(ssg_build_templated_content PRODUCT) ) endmacro() +# Extract rule-variable mapping from built OVAL content +# This creates a JSON file mapping each rule to the variables it uses +macro(ssg_extract_rule_variable_mapping PRODUCT) + add_custom_command( + OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/rule_variable_mapping.json" + COMMAND env "PYTHONPATH=$ENV{PYTHONPATH}" "${Python_EXECUTABLE}" "${SSG_BUILD_SCRIPTS}/extract_rule_variable_mapping.py" "${PRODUCT}" "${CMAKE_BINARY_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/rule_variable_mapping.json" + DEPENDS generate-internal-templated-content-${PRODUCT} "${CMAKE_CURRENT_BINARY_DIR}/templated-content-${PRODUCT}" + DEPENDS ${PRODUCT}-compile-all "${CMAKE_CURRENT_BINARY_DIR}/ssg_build_compile_all-${PRODUCT}" + COMMENT "[${PRODUCT}-content] extracting rule-variable mapping" + ) + add_custom_target( + generate-${PRODUCT}-rule-variable-mapping + DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/rule_variable_mapping.json" + ) +endmacro() + macro(ssg_collect_remediations PRODUCT LANGUAGES) set(REMEDIATION_TYPE_OPTIONS "") foreach(LANGUAGE ${LANGUAGES}) @@ -818,6 +834,7 @@ macro(ssg_build_product PRODUCT) ssg_build_xccdf_oval_ocil(${PRODUCT}) ssg_make_all_tables(${PRODUCT}) ssg_build_templated_content(${PRODUCT}) + ssg_extract_rule_variable_mapping(${PRODUCT}) ssg_build_remediations(${PRODUCT}) if("${PRODUCT_ANSIBLE_REMEDIATION_ENABLED}" AND SSG_ANSIBLE_PLAYBOOKS_PER_RULE_ENABLED) @@ -861,6 +878,7 @@ macro(ssg_build_product PRODUCT) generate-ssg-${PRODUCT}-cpe-dictionary.xml generate-ssg-${PRODUCT}-ds.xml generate-ssg-tables-${PRODUCT}-all + generate-${PRODUCT}-rule-variable-mapping ) add_dependencies(zipfile generate-ssg-${PRODUCT}-ds.xml) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ec204d42b5a7..3e1e676c4181 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -383,6 +383,12 @@ macro(check_for_rule_removal PRODUCT) endif() endmacro() +add_test( + NAME "profile-variables" + COMMAND env "PYTHONPATH=$ENV{PYTHONPATH}" "${Python_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/test_profile_variables.py" "--build-dir" "${CMAKE_BINARY_DIR}" +) +set_tests_properties("profile-variables" PROPERTIES LABELS quick) + if(SSG_PRODUCT_RHEL8) check_for_rule_removal("rhel8") endif() diff --git a/tests/test_profile_variables.py b/tests/test_profile_variables.py new file mode 100755 index 000000000000..c0382c868bdf --- /dev/null +++ b/tests/test_profile_variables.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 + +""" +Test Profile Variable Selection + +This test validates that profiles properly select variables for the rules they include. +When a rule uses a variable (as indicated in the rule-variable mapping), the profile +should also select that variable. If not, the rule will use the variable's default value, +which may not be the intended behavior. + +The test emits warnings when a rule is selected but its variables are not. +""" + +import sys +import json +import argparse +from pathlib import Path +from typing import Dict, Set, List, Tuple + + +class ProfileVariableChecker: + """Check that profiles select appropriate variables for their rules.""" + + def __init__(self, product: str, build_dir: Path): + """ + Initialize the checker. + + Args: + product: Product name (e.g., 'rhel10') + build_dir: Path to the build directory + """ + self.product = product + self.build_dir = build_dir + self.product_dir = build_dir / product + + # Load the rule-variable mapping + self.mapping_file = self.product_dir / "rule_variable_mapping.json" + self.rule_to_variables = self.load_mapping() + + def load_mapping(self) -> Dict[str, List[str]]: + """ + Load the rule-variable mapping from JSON file. + + Returns: + Dictionary mapping rule IDs to lists of variable IDs + + Raises: + FileNotFoundError: If the mapping file doesn't exist + """ + if not self.mapping_file.exists(): + raise FileNotFoundError( + f"Rule-variable mapping file not found: {self.mapping_file}\n" + f"This file should be generated during the build process." + ) + + with open(self.mapping_file, 'r', encoding='utf-8') as f: + return json.load(f) + + def load_profile(self, profile_name: str) -> Tuple[Set[str], Set[str]]: + """ + Load a profile and extract selected rules and variables. + + Args: + profile_name: Profile name (without .profile extension) + + Returns: + Tuple of (selected_rules, selected_variables) + + Raises: + FileNotFoundError: If the profile file doesn't exist + """ + profile_file = self.product_dir / "profiles" / f"{profile_name}.profile" + + if not profile_file.exists(): + raise FileNotFoundError(f"Profile not found: {profile_file}") + + with open(profile_file, 'r', encoding='utf-8') as f: + profile_data = json.load(f) + + selections = profile_data.get('selections', []) + + selected_rules = set() + selected_variables = set() + + for selection in selections: + if '=' in selection: + base = selection.split('=')[0] + if '.' in base: + # Rule property refinement: rule_id.property=value + # e.g. "some_rule.role=unscored" — the rule is selected, not a variable + selected_rules.add(base.split('.')[0]) + else: + # Variable selection: var_name=value or sysctl_name=value + selected_variables.add(base) + else: + # Plain rule ID with no assignment + selected_rules.add(selection) + + return selected_rules, selected_variables + + def check_profile(self, profile_name: str) -> List[Dict]: + """ + Check a profile for rules with unselected variables. + + Args: + profile_name: Profile name to check + + Returns: + List of warning dictionaries containing: + - profile: Profile name + - rule: Rule ID + - variable: Variable ID + - message: Warning message + """ + selected_rules, selected_variables = self.load_profile(profile_name) + + warnings = [] + + for rule_id in selected_rules: + if rule_id not in self.rule_to_variables: + continue + + required_vars = self.rule_to_variables[rule_id] + + for var_id in required_vars: + if var_id not in selected_variables: + warnings.append({ + 'profile': profile_name, + 'rule': rule_id, + 'variable': var_id, + 'message': ( + f"Rule '{rule_id}' uses variable '{var_id}' but " + f"the variable is not selected in profile '{profile_name}'. " + f"Default value will be used." + ) + }) + + return warnings + + def check_all_profiles(self) -> Dict[str, List[Dict]]: + """ + Check all profiles in the product. + + Returns: + Dictionary mapping profile names to lists of warnings + """ + profiles_dir = self.product_dir / "profiles" + + if not profiles_dir.exists(): + raise FileNotFoundError(f"Profiles directory not found: {profiles_dir}") + + all_warnings = {} + + for profile_file in sorted(profiles_dir.glob("*.profile")): + profile_name = profile_file.stem + warnings = self.check_profile(profile_name) + if warnings: + all_warnings[profile_name] = warnings + + return all_warnings + + +def find_built_products(build_dir: Path) -> List[str]: + """ + Find all products that have been built by looking for rule_variable_mapping.json files. + + Args: + build_dir: Path to the build directory + + Returns: + List of product names + """ + products = [] + for mapping_file in build_dir.glob("*/rule_variable_mapping.json"): + product = mapping_file.parent.name + products.append(product) + return sorted(products) + + +def main(): + parser = argparse.ArgumentParser( + description='Test that profiles select appropriate variables for their rules', + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument('--product', help='Product name (e.g., rhel10). If not specified, checks all built products.') + parser.add_argument('--build-dir', required=True, help='Build directory path') + parser.add_argument('--profile', help='Specific profile to check (optional, only valid with --product)') + + args = parser.parse_args() + + build_dir = Path(args.build_dir) + + # Determine which products to check + if args.product: + products_to_check = [args.product] + else: + products_to_check = find_built_products(build_dir) + if not products_to_check: + raise RuntimeError("No built products found (no rule_variable_mapping.json files)") + + # If checking a specific profile, we need a specific product + if args.profile and not args.product: + raise RuntimeError("--profile requires --product to be specified") + + # Check all products + total_warnings = 0 + failed_products = [] + + for product in products_to_check: + try: + checker = ProfileVariableChecker(product, build_dir) + except FileNotFoundError as e: + failed_products.append(product) + print(f"Error for {product}: {e}", file=sys.stderr) + continue + + if args.profile: + # Check specific profile for this product + try: + warnings = checker.check_profile(args.profile) + except FileNotFoundError as e: + raise RuntimeError(f"Profile check failed: {e}") from e + + if warnings: + print(f"[{product}] Profile '{args.profile}' has {len(warnings)} warning(s):") + for warning in warnings: + print(f" {warning['message']}") + return 0 + else: + # Check all profiles for this product + try: + all_warnings = checker.check_all_profiles() + except FileNotFoundError as e: + failed_products.append(product) + print(f"Error for {product}: {e}", file=sys.stderr) + continue + + if all_warnings: + product_warnings = sum(len(w) for w in all_warnings.values()) + total_warnings += product_warnings + print(f"[{product}] Found {product_warnings} warning(s) across {len(all_warnings)} profile(s):") + for profile_name, warnings in sorted(all_warnings.items()): + print(f" {profile_name}: {len(warnings)} warning(s)") + for warning in warnings: + print(f" {warning['message']}") + + # Summary + if failed_products: + raise RuntimeError(f"Failed to check products: {', '.join(failed_products)}") + + if total_warnings > 0: + print(f"\nTotal: {total_warnings} warning(s) across {len(products_to_check)} product(s)") + else: + print("All profiles passed: all rules have their variables selected") + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) From 8d058bb572c61746f45b2c2b088db207a8e7b7cd Mon Sep 17 00:00:00 2001 From: Gabriel Becker Date: Wed, 24 Jun 2026 17:42:05 +0200 Subject: [PATCH 2/2] Add new skill to work on NIST-800-53 control completion. --- .claude/skills/assess-nist-control/SKILL.md | 492 ++++++++++++++++++ .../skills/resolve-rule-variables/SKILL.md | 145 ++++++ 2 files changed, 637 insertions(+) create mode 100644 .claude/skills/assess-nist-control/SKILL.md create mode 100644 .claude/skills/resolve-rule-variables/SKILL.md diff --git a/.claude/skills/assess-nist-control/SKILL.md b/.claude/skills/assess-nist-control/SKILL.md new file mode 100644 index 000000000000..faea31cef213 --- /dev/null +++ b/.claude/skills/assess-nist-control/SKILL.md @@ -0,0 +1,492 @@ +--- +name: assess-nist-control +description: Assess a pending NIST 800-53 control with OSCAL enrichment, CIS reverse lookup, and Linux hardening prioritization. Guides authors through understanding, automatability analysis, rule mapping, and validation. +--- + +# Assess NIST Control + +Assess pending NIST 800-53 Rev 5 controls for a product. This skill adds NIST-specific intelligence on top of the generic mapping workflow: full OSCAL control text (statement, guidance, assessment objectives), CIS-to-NIST reverse lookup for pre-existing rule associations, baseline awareness (low/moderate/high), and a prioritization strategy focused on Linux hardening. + +Base controls are the primary work unit — each base control is assessed together with its enhancements as a single session. + +**Arguments**: $ARGUMENTS — format: `[] [--product ]` + +Examples: +- `/assess-nist-control ac-7 --product rhel9` — assess base control AC-7 and its enhancements +- `/assess-nist-control ac --product rhel9` — triage the AC (Access Control) family +- `/assess-nist-control --product rhel9` — full prioritized triage across all families +- `/assess-nist-control cm-6 --product rhel10` — assess CM-6 for RHEL 10 + +## Tool Strategy + +This skill uses `mcp__content-agent__*` tools when available (preferred — deterministic, structured results). When the MCP server is not configured, fall back to filesystem-based alternatives noted as **Fallback** in each step. See `.claude/skills/shared/mcp_fallbacks.md` for detailed fallback procedures. The skill must complete successfully either way. + +**Without MCP server**: Cross-framework similarity search is unavailable. Candidate rules will be found by CIS reverse lookup, `nist:` reference grep, and keyword search only. + +**NIST-specific data** is always read from the filesystem (no MCP equivalent): +- OSCAL catalog: `utils/nist_sync/data/nist_800_53_rev5_catalog.json` +- CIS-NIST mappings: `utils/nist_sync/data/cis_nist_mappings.json` +- Baselines: `utils/nist_sync/data/nist_800_53_rev5_{low,moderate,high}_baseline.json` +- Rule-variable mapping: handled by the `resolve-rule-variables` sub-skill, which reads `build//rule_variable_mapping.json` and `.var` files to collect variable value selections after rule selection in Phase 3. + +## Phase 0: Parse and Resolve Target + +1. **Parse arguments**: Extract optional `control_id_or_family` and `--product` from `$ARGUMENTS`. + +2. **Detect mode**: + - If argument matches `^[a-z]{2}$` (e.g., `ac`, `cm`) → **family triage mode** (Phase 1A) + - If argument matches `^[a-z]{2}-\d+(\.\d+)?$` (e.g., `ac-7`, `ac-2.5`) → **single control mode** (Phase 1B). If an enhancement ID is given (has `.`), resolve to its base control (e.g., `ac-2.5` → assess `ac-2` including enhancement `ac-2.5`). + - If no argument → **full triage mode** (Phase 1A across all families) + +3. **Product validation**: Check that `products//controls/nist_800_53.yml` exists. + - If `--product` not specified, ask via `AskUserQuestion`: + - "Which product are you assessing NIST 800-53 controls for?" + - Options: "rhel9", "rhel10", "rhel8", "Other" + - If the NIST 800-53 control file does not exist for the product, inform the user and stop. + +4. **Build check**: Call `mcp__content-agent__list_built_products`. + - If the target product is NOT built, ask via `AskUserQuestion`: + - "Product '{product}' has not been built yet. Rule search works best with build artifacts. Build now?" + - Options: + - "Yes, build now (Recommended)" — runs `/build-product {product} -d` + - "No, continue without build" — rule search uses raw source files + - If user chooses to build, invoke: `Skill(skill="build-product", args="{product} -d")`. Wait for completion. + - **Fallback**: Check if `build/{product}/rules/` directory exists. + +## Phase 1A: Prioritized Triage + +When no specific control ID is given, present a prioritized view of pending work. + +### Family prioritization order + +Use this hardcoded priority order based on CIS mapping density and Linux hardening relevance: + +| Priority | Family | Full Name | Rationale | +|----------|--------|-----------|-----------| +| 1 | cm | Configuration Management | 176 CIS-mapped rules, core hardening | +| 2 | ac | Access Control | 161 rules, access control fundamentals | +| 3 | au | Audit and Accountability | 106 rules, audit infrastructure | +| 4 | ia | Identification and Authentication | 26 rules, identity/auth | +| 5 | si | System and Information Integrity | Integrity checks, patching | +| 6 | sc | System and Communications Protection | Crypto, network protection | +| 7 | ca | Assessment, Authorization, Monitoring | Firewall, monitoring | +| 8+ | at, cp, ir, ma, mp, pe, pl, pm, ps, pt, ra, sa, sr | Organizational/procedural | Mostly non-automatable | + +### Steps + +1. **Read family files**: Read `products//controls/nist_800_53/.yml` for each family in scope (single family or all 20). + +2. **Count pending base controls**: For each family, count controls where `status == "pending"` and the ID does NOT contain a dot (base controls only). Enhancements are counted separately but shown as context — they will be assessed alongside their parent. + +3. **Load CIS-NIST mappings**: Run: + ```bash + python3 -c " + import json + with open('utils/nist_sync/data/cis_nist_mappings.json') as f: + data = json.load(f) + from collections import Counter + fam_rules = Counter() + for rule_id, nist_ids in data['rules'].items(): + for nid in nist_ids: + fam_rules[nid.split('-')[0]] += 1 + for var_sel, nist_ids in data['variables'].items(): + for nid in nist_ids: + fam_rules[nid.split('-')[0]] += 1 + for fam in sorted(fam_rules, key=fam_rules.get, reverse=True): + print(f'{fam} {fam_rules[fam]}') + " + ``` + **Fallback if cis_nist_mappings.json missing**: Skip CIS column, note it's unavailable. + +4. **Present prioritized overview**: + + ``` + ## NIST 800-53 Assessment Triage ({product}) + + ### High-Priority Families (Linux hardening) + + | # | Family | Name | Pending Base | CIS Rules | Automated | + |---|--------|-----------------------------|-------------|-----------|-----------| + | 1 | CM | Configuration Management | 10 | 176 | 4 | + | 2 | AC | Access Control | 18 | 161 | 7 | + | 3 | AU | Audit and Accountability | 9 | 106 | 7 | + | 4 | IA | Identification and Auth | 8 | 26 | 5 | + + ### Medium Priority + | 5 | SI | System/Info Integrity | 19 | 11 | 4 | + | 6 | SC | System/Comms Protection | 45 | 8 | 6 | + | 7 | CA | Assessment/Monitoring | 8 | 5 | 1 | + + ### Lower Priority (mostly organizational) + | 8+ | AT, CP, IR, MA, MP, PE, PL, PM, PS, PT, RA, SA, SR — {N} pending base total | + ``` + +5. **Ask which control to assess** via `AskUserQuestion`: + - "Which control would you like to assess?" + - Options: show 3 pending base controls from the highest-priority family that still has work, prioritized by most CIS-mapped rules for that specific control ID + "Pick a different family or control" + - For each option, show: "cm-6: Configuration Change Control (CIS: 42 rules)" style description + +6. Proceed to Phase 1B with the selected control. + +## Phase 1B: Single Base Control — OSCAL Enrichment + +Load and present the full NIST context for the selected base control AND all its enhancements. + +### Step 1: Load control from control file + +1. Derive family from control ID: `ac-7` → family `ac` → file `products//controls/nist_800_53/ac.yml`. +2. Read the family file and find the control entry matching the base ID. +3. Also collect all nested enhancements under the base control's `controls:` list. +4. Record current `status`, `rules`, and `levels` for the base and each enhancement. + +### Step 2: Load OSCAL catalog data + +Extract the control from the OSCAL catalog using a targeted Python command: + +```bash +python3 -c " +import json, sys, re +cid = sys.argv[1] +family = cid.split('-')[0] +with open('utils/nist_sync/data/nist_800_53_rev5_catalog.json') as f: + cat = json.load(f)['catalog'] +for g in cat['groups']: + if g['id'] == family: + for c in g.get('controls', []): + if c['id'] == cid: + def render_parts(ctrl): + params = {p['id']: p.get('label', p['id']) for p in ctrl.get('params', [])} + result = {'id': ctrl['id'], 'title': ctrl['title'], 'params': ctrl.get('params', [])} + for part in ctrl.get('parts', []): + text = part.get('prose', '') + for pid, label in params.items(): + text = text.replace('{{ insert: param, ' + pid + ' }}', '[' + label + ']') + result[part['name']] = text + subs = [] + for sp in part.get('parts', []): + st = sp.get('prose', '') + for pid, label in params.items(): + st = st.replace('{{ insert: param, ' + pid + ' }}', '[' + label + ']') + subs.append({'id': sp.get('id',''), 'name': sp.get('name',''), 'prose': st}) + if subs: + result[part['name'] + '_parts'] = subs + result['enhancements'] = [] + for ec in ctrl.get('controls', []): + result['enhancements'].append(render_parts(ec)) + return result + print(json.dumps(render_parts(c), indent=2)) + sys.exit(0) +print(json.dumps({'error': 'Control not found'})) +" "$BASE_CONTROL_ID" +``` + +If `utils/nist_sync/data/nist_800_53_rev5_catalog.json` does not exist: +> **Warning**: OSCAL catalog not found. Run `python3 utils/nist_sync/download_oscal.py` to download it. Proceeding with control file titles only. + +### Step 3: Load baseline membership + +```bash +python3 -c " +import json, sys +cid = sys.argv[1] +baselines = [] +for level in ['low', 'moderate', 'high']: + with open(f'utils/nist_sync/data/nist_800_53_rev5_{level}_baseline.json') as f: + data = json.load(f) + ids = set() + for imp in data['profile']['imports']: + for inc in imp.get('include-controls', []): + ids.update(str(x) for x in inc.get('with-ids', [])) + if cid in ids: + baselines.append(level.upper()) +print(' '.join(baselines) if baselines else 'NONE') +" "$BASE_CONTROL_ID" +``` + +### Step 4: Present the enriched view + +``` +## NIST 800-53: {ID} — {Title} + +**Baselines**: {LOW, MODERATE, HIGH} +**Current status**: {status} +**Current rules**: {rules or "none"} + +### Statement +{Rendered OSCAL statement text with parameters replaced by [label]} +{Sub-parts as lettered clauses: a., b., c., ...} + +### Guidance +{OSCAL guidance prose} + +### Assessment Objectives +{Rendered assessment-objective parts as checklist items} + +### Parameters +{List each parameter with its label and any guidelines} + +### Enhancements ({N} total) +| ID | Title | Baseline | Status | Rules | +|---------|-------------------------------|----------|---------|-------| +| {id}.1 | {title} | {level} | {status}| {n} | +| {id}.2 | {title} | {level} | {status}| {n} | +``` + +If OSCAL data is unavailable, show only the control file title and skip Statement/Guidance/Assessment Objectives sections. + +## Phase 2: Automatability Assessment + +Gather intelligence from multiple sources to determine whether the control can be automated on a Linux system and which rules are candidates. + +### Step 2a: CIS Reverse Lookup + +Build a reverse index from `cis_nist_mappings.json` to find rules already associated with this NIST control: + +```bash +python3 -c " +import json, sys +cid = sys.argv[1] +with open('utils/nist_sync/data/cis_nist_mappings.json') as f: + data = json.load(f) +rules = sorted(r for r, nids in data['rules'].items() if cid in nids) +variables = sorted(v for v, nids in data['variables'].items() if cid in nids) +# Also check enhancements +import re +base = cid.split('.')[0] +enh_rules = {} +for r, nids in data['rules'].items(): + for nid in nids: + if nid.startswith(base + '.'): + enh_rules.setdefault(nid, []).append(r) +enh_vars = {} +for v, nids in data['variables'].items(): + for nid in nids: + if nid.startswith(base + '.'): + enh_vars.setdefault(nid, []).append(v) +print(json.dumps({'base_rules': rules, 'base_variables': variables, + 'enhancement_rules': enh_rules, 'enhancement_variables': enh_vars}, indent=2)) +" "$BASE_CONTROL_ID" +``` + +**Fallback if file missing**: Skip this step, note: "CIS-NIST mapping data not available." + +### Step 2b: `nist:` Reference Grep + +Search for rules that already reference this NIST control in their `rule.yml` `references: nist:` field. + +Normalize the control ID for grep: +- Base control `ac-7` → grep for `AC-7` (uppercase) +- Enhancement `ac-2.5` → grep for `AC-2(5)` (dot becomes parenthetical) + +```bash +# For the base control: +grep -rl "nist:.*AC-7" linux_os/guide/ applications/ 2>/dev/null | head -30 + +# Extract just rule IDs from the paths: +grep -rl "nist:.*AC-7" linux_os/guide/ applications/ 2>/dev/null | \ + xargs -I{} dirname {} | xargs -I{} basename {} | sort -u +``` + +Also search for each enhancement ID in parenthetical format. + +### Step 2c: Cross-Framework Search + +Follow the same cross-framework search as `map-requirement` Phase 2 Step 2a, substituting the OSCAL statement text (or control file title if OSCAL is unavailable) as the `requirement_text`. Use `exclude_control_id: nist_800_53`. This finds SRG, STIG, CIS, ANSSI, BSI requirements covering similar topics that already have rules mapped. + +### Step 2d: Build Artifact Search + +Follow the same build artifact search as `map-requirement` Phase 2 Step 2b, extracting key terms from the OSCAL statement text. + +Deduplicate all results across all search steps (2a-2d). + +### Step 2e: Automatability Analysis + +Based on the OSCAL text, classify the control: + +- **Automatable**: describes a technical configuration, system setting, audit rule, file permission, service state, cryptographic setting, or software behavior verifiable by scanning the OS. Look for words like: configure, enable, disable, set, enforce, implement, verify, ensure, limit. +- **Manual/organizational**: describes a policy, procedure, organizational process, physical security measure, personnel action, planning activity, or training requirement. Look for: develop, document, define, establish (policies), train, approve, review (periodic), coordinate, designate. +- **Mixed**: multi-clause controls where some clauses are automatable and others are organizational. + +### Step 2f: Present Combined Assessment + +``` +### Automatability Assessment for {ID}: {Title} + +**Classification**: {AUTOMATABLE / MANUAL / MIXED} +{Brief reasoning based on the OSCAL text analysis} + +#### Candidate Rules ({N} total, deduplicated) + +| Rule ID | Title | Sources | +|---------|-------|---------| +| {rule_id} | {title} | CIS, nist-ref | +| {rule_id} | {title} | cross-framework | +| {rule_id} | {title} | search | + +#### Variable Selections from CIS +| Variable Selection | Mapped To | +|--------------------|-----------| +| {var=value} | {control_id} | +``` + +If enhancements have their own candidates, show them separately: +``` +#### Enhancement Candidates +| Enhancement | Rule ID | Title | Sources | +|-------------|---------|-------|---------| +| {enh_id} | {rule} | {title} | CIS | +``` + +Ask via `AskUserQuestion`: +- "How do you want to proceed with {ID}: {Title}?" +- Options: + - "Map rules (Automated)" — description: "{N} candidate rules found" + - "Mark as manual" — description: "Organizational/procedural control" + - "Mark as not applicable" — description: "Does not apply to {product}" + - "Skip for now" — description: "Leave as pending" + +If "Mark as manual", "Mark as not applicable", or "Skip": update the control file accordingly (or skip) and jump to Phase 4. + +## Phase 3: Rule Mapping (Base Control + Enhancements) + +Assess the base control and all its enhancements as a single work unit. + +### Step 3a: Map the Base Control + +1–3. **Candidate discovery, validation, and selection**: Follow the same product availability check, rule detail retrieval, and candidate presentation as `map-requirement` Phase 2 Step 2c + Phase 3 Steps 1–2, using the NIST pre-loaded candidates from Step 2a (CIS) and Step 2b (nist: grep) as the primary candidate set — present these first, ranked above generic search results. The multiSelect question should read: "Select rules to map to {ID}: {Title}". + +4. **Variable resolution**: After the user selects rules, resolve all variables those rules depend on. + + Invoke `Skill(skill="resolve-rule-variables", args="{product} {rule_id1} {rule_id2} ... [--cis-vars {var1=key1} ...]")`, passing the selected rule IDs and any CIS variable pre-selections from Step 2a. The skill guides the author through selecting a value key for each required variable and returns a list of `var_name=key` entries to include in the rules list alongside the rule IDs. If the skill reports no variable dependencies, skip to Step 5. + +5. **Status selection** via `AskUserQuestion`: + - "How should {ID} be marked?" + - Options: + - "automated" — rules fully cover the assessment objectives + - "partial" — rules cover some but not all objectives + - "manual" — organizational aspects remain + - "Skip status change" + +6. **Write to control file**: Call `mcp__content-agent__update_requirement_rules` with `control_id=nist_800_53`, `requirement_id`, selected rules (including variable selections), and status. + - **Fallback**: Read `products//controls/nist_800_53/.yml`, find the control entry by ID, update `rules:` and `status:` using the `Edit` tool. Preserve existing YAML formatting. + +### Step 3b: Map Enhancements + +For each enhancement of the base control: + +1. **Show the enhancement context**: Display its OSCAL text (statement, guidance) and current status. + +2. **Assess automatability**: Using the same classification as Phase 2 Step 2e, determine if the enhancement is automatable. Consider: + - Some enhancements refine the base control and may use the same rules + - Some enhancements address specific scenarios (e.g., mobile devices, biometrics) that may not apply to a Linux server + - Some enhancements are purely organizational + +3. **Present candidates**: Show rules specific to this enhancement (from CIS reverse lookup and cross-framework search) plus any base control rules that are also relevant. + +4. **Ask the author** via `AskUserQuestion`: + - "How should enhancement {enh_id}: {title} be handled?" + - Options: + - "Map rules" — if candidates exist, present multiSelect for rule selection + - "Same rules as base control" — copy the base control's rules (including their variable selections) + - "Manual" — organizational/procedural + - "Not applicable" — doesn't apply (e.g., mobile device enhancement on a server) + - "Skip for now" + +5. **Variable resolution for enhancement rules**: If the author selected "Map rules" and chose specific rules, invoke `Skill(skill="resolve-rule-variables", args="{product} {rule_id1} ...")` for those rules. If "Same rules as base control", reuse the base control's rules list verbatim (variables already resolved). + +6. **Write each enhancement**: Same write mechanism as Step 3a Step 6, but targeting the nested enhancement entry in the YAML. + +### Step 3c: Write Summary + +After all enhancements are processed, show what was written: + +``` +### Changes Written + +| Control | Status | Rules Added | +|------------|------------|-------------| +| {base_id} | automated | 5 rules | +| {enh_id}.1 | automated | 3 rules | +| {enh_id}.2 | not applicable | — | +| {enh_id}.3 | manual | — | + +File modified: products/{product}/controls/nist_800_53/{family}.yml +``` + +## Phase 4: Validate and Continue + +### Step 1: Optional Build Validation + +Ask via `AskUserQuestion`: +- "Build {product} to validate the control file changes?" +- Options: + - "Yes, build now (Recommended)" — runs `Skill(skill="build-product", args="{product} -d")` + - "No, skip validation" + +If build fails, display the error. Common cause: YAML syntax error from the edit. Suggest: `git diff products/{product}/controls/nist_800_53/` + +### Step 2: Updated Stats + +Re-read the family file and present before/after: + +``` +### Updated Stats for {Family} Family + +| Metric | Before | After | +|-----------|--------|-------| +| Pending base controls | {N} | {N-1} | +| Automated base controls | {N} | {N+1} | +| Pending enhancements | {N} | {N-X} | +``` + +### Step 3: Suggest Next Control + +From remaining pending base controls in the same family, suggest the next one. Prioritize by: +1. Controls with the most CIS-mapped rules (highest chance of finding existing rules) +2. Controls in higher-priority baselines (LOW before MODERATE before HIGH) + +Ask via `AskUserQuestion`: +- "Assess another control?" +- Options: + - "{next_id}: {title}" — description: "Baseline: {level} | CIS: {N} rules | {M} enhancements" + - "Pick a different control or family" + - "Done for now" + +If user selects a control, loop back to Phase 1B. + +### Step 4: Next Steps + +``` +### Next Steps +- Assess next control: `/assess-nist-control --product {product}` +- Assess entire family: `/assess-nist-control {family} --product {product}` +- Map controls from other frameworks: `/map-controls --product {product}` +- Review changes: `git diff products/{product}/controls/nist_800_53/` +- Build product: `/build-product {product}` +- Draft PR: `/draft-pr` +``` + +## Error Handling + +- **OSCAL catalog missing** (`utils/nist_sync/data/nist_800_53_rev5_catalog.json`): Warn, proceed without OSCAL enrichment (use control file titles only). Suggest: `python3 utils/nist_sync/download_oscal.py`. +- **CIS mappings missing** (`utils/nist_sync/data/cis_nist_mappings.json`): Skip CIS reverse lookup. Rely on cross-framework search and `nist:` reference grep only. +- **Baseline files missing**: Skip baseline column in displays. Note: "Baseline data unavailable." +- **Control not found in family file**: List available base control IDs in the family, let user pick via `AskUserQuestion`. +- **Control not found in OSCAL catalog**: Proceed with control file title only. Note the discrepancy (may be a withdrawn control). +- **No candidate rules found from any source**: Present via `AskUserQuestion`: + - "No automated rules found for this control." + - Options: "Mark as manual", "Mark as not applicable", "Enter rule IDs manually", "Create new rule (use `/create-rule`)", "Skip for now" +- **YAML write failure**: Display error, show file path and the changes that need to be made manually. +- **Build failure after mapping**: Display build error, suggest reviewing `git diff`. +- **Variable resolution errors** (missing mapping file, missing `.var` file, rule not in mapping): Handled by the `resolve-rule-variables` skill. If the skill cannot proceed, it reports the issue and allows the author to continue without variable selections or add them manually. + +## Important Notes + +- **Base controls first**: Always resolve enhancement IDs to their base control and assess them together. An author asking about `ac-2.5` should be guided through `ac-2` and all its enhancements. +- **CIS mapping ID format**: The `cis_nist_mappings.json` uses lowercase IDs (`ac-7`, `ac-2.5`) matching control file IDs exactly. +- **`nist:` reference format**: Rule YAML uses uppercase with parenthetical enhancements: `AC-7`, `AC-2(5)`. Normalize when grepping. +- **The `update_requirement_rules` tool replaces existing rules** — if a control already has rules and the author wants to add more, include the existing rules in the selection. +- **Large families** (AC has 25 base controls, SC has 51): After the base control + enhancements session, always offer to continue with the next control rather than requiring the author to re-invoke the skill. +- **Don't overwhelm**: If candidate sources return many results, focus on the top 10-15 rules ranked by number of sources they appear in (CIS + nist-ref + cross-framework + search). +- **Variable selections**: `resolve-rule-variables` handles all variable logic — deduplication, key-vs-value distinction, default handling, and the "mandatory when a rule has variables" invariant. The caller (this skill) simply passes selected rule IDs to the sub-skill and includes the returned `var_name=key` entries alongside rule IDs in the `rules:` list write-back. +- **Planned: variables will move to a dedicated file**: Variable selections (`var_name=key` entries) are currently written inline alongside rule IDs in the NIST family control files. The long-term plan is to consolidate all variable selections into a separate per-product file so authors can review and tune values without touching rule mappings. Until that migration lands, continue writing variables inline. diff --git a/.claude/skills/resolve-rule-variables/SKILL.md b/.claude/skills/resolve-rule-variables/SKILL.md new file mode 100644 index 000000000000..a1c5b32101f0 --- /dev/null +++ b/.claude/skills/resolve-rule-variables/SKILL.md @@ -0,0 +1,145 @@ +--- +name: resolve-rule-variables +description: Resolve XCCDF variable selections for a set of rules. Looks up which variables each rule depends on, reads their .var files, and guides the author to select a value key for each variable. Returns a list of var_name=key selections ready to add to a control file's rules list. +--- + +# Resolve Rule Variables + +Given a product and a list of rule IDs that have just been selected for a control mapping, determine which XCCDF variables each rule depends on and interactively collect a value selection for each. + +This skill is a sub-skill called by other skills (e.g., `assess-nist-control`) after rule selection. It can also be invoked directly when an author wants to inspect or change variable selections for rules already in a control file. + +**Arguments**: `$ARGUMENTS` — format: ` [ ...] [--cis-vars [...]]` + +Examples: +- Used as a sub-skill after rule selection in `assess-nist-control` Phase 3 +- `/resolve-rule-variables rhel9 accounts_password_pam_dcredit accounts_password_pam_minlen` +- `/resolve-rule-variables rhel9 accounts_tmout --cis-vars var_accounts_tmout=600` + +The `--cis-vars` flag passes pre-existing variable selections from CIS mappings (or other sources) as +default suggestions. Each entry is `var_name=key` matching the `.var` file option key. + +## Phase 1: Load Variable Mapping + +1. **Parse arguments**: Extract `product` and `rule_ids` from `$ARGUMENTS`. Also extract any `--cis-vars` entries (format: `var_name=key`). + +2. **Check the mapping file**: + ```bash + ls build/$PRODUCT/rule_variable_mapping.json + ``` + If the file does not exist: + > **Note**: `build//rule_variable_mapping.json` not found. Build the product first to enable automatic variable detection: + > ``` + > ./build_product -d + > ``` + > Proceeding without variable resolution. Add variable selections manually as `var_name=key` entries in the rules list if needed. + + Stop and return no selections if the file is missing. + +3. **Look up variables for each rule**: + ```bash + python3 -c " + import json, sys, os, glob + + product = sys.argv[1] + rule_ids = sys.argv[2:] + + with open(f'build/{product}/rule_variable_mapping.json') as f: + rule_var_map = json.load(f) + + # Collect all variables across the selected rules, deduplicating + seen_vars = {} + for rule_id in rule_ids: + for var_name in rule_var_map.get(rule_id, []): + if var_name not in seen_vars: + seen_vars[var_name] = [] + seen_vars[var_name].append(rule_id) + + # For each variable, find the .var file and read options + result = {} + for var_name, used_by in seen_vars.items(): + var_files = (glob.glob(f'linux_os/**/{var_name}.var', recursive=True) + + glob.glob(f'shared/**/{var_name}.var', recursive=True) + + glob.glob(f'applications/**/{var_name}.var', recursive=True)) + if not var_files: + result[var_name] = {'title': var_name, 'type': 'unknown', 'options': {}, 'used_by': used_by} + continue + import yaml + with open(var_files[0]) as f: + var_data = yaml.safe_load(f) + result[var_name] = { + 'title': var_data.get('title', var_name), + 'type': var_data.get('type', 'string'), + 'options': var_data.get('options', {}), + 'used_by': used_by, + } + + print(json.dumps(result, indent=2)) + " "$PRODUCT" $RULE_IDS + ``` + + If no variables are found for any of the rules, output an empty `{}` and stop — no variable selections needed. Report: "No variable dependencies found for the selected rules." + +## Phase 2: Present Variable Selection + +For each variable found in Phase 1 (process in alphabetical order): + +1. **Determine the default key**: The default key is the key named `"default"` in the options dict. If no key is named `"default"`, look for the key whose value matches the majority/common case. Note the default value for display. + +2. **Check for a CIS pre-selection**: If `--cis-vars` included `{var_name}=`, pre-mark that option as the suggested default in the question description. + +3. **Ask via `AskUserQuestion`**: + - Question: `"Select value for \`{var_name}\` — {title}"` + - Description on the question itself: `"Used by: {rule_id1}, {rule_id2}..."` + - Options (limit to 4 total including the "Use default" option; if more keys exist, show the most semantically meaningful ones): + + For each key in `options` (up to 3 non-default keys): + - `label`: the key string (e.g., `1`, `15_min`, `never`) + - `description`: `"actual value: {value}"` — add `"(CIS suggested)"` if this key matches the `--cis-vars` selection, or `"(Default)"` if this key is the default key + + Always add a final option: + - `label`: `"Use default (omit variable)"` + - `description`: `"actual value: {default_value} — variable omitted from rules list, scanner uses the .var file default automatically"` + + **Example** for `var_password_pam_dcredit` with `options: {"0": "0", 1: -1, 2: -2, default: -1}`: + ``` + label: "1" description: "actual value: -1 (CIS suggested)" + label: "2" description: "actual value: -2" + label: "0" description: "actual value: 0" + label: "Use default (omit variable)" description: "actual value: -1 — variable omitted from rules list, scanner uses the .var file default automatically" + ``` + → If key `1` chosen: written as `var_password_pam_dcredit=1` + → If "Use default" chosen: variable NOT added to the rules list + +4. **Record the selection**: + - If a key was chosen: add `{var_name}={key}` to the output selections list + - If "Use default" was chosen: do not add anything for this variable + +## Phase 3: Report Selections + +After all variables are processed, display a summary: + +``` +### Variable Selections + +| Variable | Key | Actual Value | Status | +|----------|-----|--------------|--------| +| var_password_pam_dcredit | 1 | -1 | selected | +| var_accounts_tmout | default | 900 | omitted (using .var default) | +``` + +Return the selections as a list of `var_name=key` entries. These are ready to be included in the control file's `rules:` list alongside the rule IDs. + +## Important Notes + +- **Key, not value**: The `rules:` list stores the **key** from the `.var` file's `options` dict — not the actual value the key resolves to. `var_password_pam_dcredit=1` means key `1`, which resolves to actual value `-1`. +- **Deduplicate shared variables**: If two rules both use `var_accounts_tmout`, ask for the value once and apply it once to the rules list. +- **Variable is mandatory if a rule uses it**: Do not finalize a mapping without a variable selection when `rule_variable_mapping.json` shows the rule requires one. A rule written without its variable produces an incomplete control mapping. +- **Planned migration**: Variable selections are currently written inline in control files alongside rule IDs. The long-term plan is to extract all variable selections into a dedicated per-product file so authors can review and customize values in one place. Until that migration lands, continue writing `var_name=key` inline. + +## Error Handling + +- **`rule_variable_mapping.json` missing**: Stop and report — build the product first. +- **`.var` file not found for a variable**: Ask the author to enter the key manually via `AskUserQuestion` with an "Other" option. Show the variable name and note the `.var` file was not found. +- **Rule not in `rule_variable_mapping.json`**: The rule either has no XCCDF variables or was not present in the last build. Treat as having no variables — no warning. +- **Options dict is empty**: Show the variable name as informational. Offer "Use default (omit variable)" as the only option.