Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 68 additions & 4 deletions macOS/Tools/macos_security_intune_mapper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,43 @@ Convert macOS security baselines to Microsoft Intune Settings Catalog policies a
- **GUI Application**: User-friendly interface for generating policies
- **CLI Tool**: Command-line interface for automation and scripting
- **Settings Catalog Mapping**: Automatically maps baseline rules to Intune Settings Catalog format
- **DDM Support**: Supports Declarative Device Management (DDM) settings for modern macOS management
- **Mobileconfig Generation**: Creates mobileconfig files for unmapped rules
- **Multiple Export Formats**: Combined or split by section/type
- **Dependency Analysis**: Identifies and handles rule dependencies
- **ODV Resolution**: Automatically resolves Organizational Default Values (ODV) based on the selected baseline
- **Backward Compatible**: Supports both legacy and modern macOS Security Compliance Project structures

## Baseline Structure Compatibility

This tool has been updated to support the latest macOS Security Compliance Project baseline structure (2.0+) while maintaining backward compatibility with legacy baselines.

**Legacy Structure** (pre-2.0):
- Location: `macos_security/baselines/*.yaml`
- Files: `cis_lvl1.yaml`, `800-53r5_high.yaml`, etc.
- Sections: `auditing`, `macos`, `passwordpolicy`, `systemsettings`
- Rule format: Simple dictionary structure

**New Structure** (2.0+):
- Location: `macos_security/baselines/macos/*.yaml`
- Files: `cis_lvl1_macos_26.0.yaml`, `800-53r5_high_macos_26.0.yaml`, etc.
- Sections: `Auditing`, `Operating System`, `Password Policy`, `System Settings`
- Platform subdirectories: `macos/`, `ios/`, `visionos/`
- Rule format: Enhanced with platform-specific configurations and DDM support
- Enforcement details: Nested under `platforms.macOS.<version>.enforcement_info`

**Automatic Detection**: The tool automatically detects which structure is in use by checking for the `baselines/macos/` subdirectory. It normalizes section names and file naming patterns internally, so you'll see clean baseline names (e.g., `cis_lvl1`) in the interface regardless of the source structure.

## Recent Updates

**v2.0 Compatibility (2026-07)**:
- ✅ Support for macOS Security Compliance Project 2.0+ structure
- ✅ Automatic detection and handling of platform-specific baseline directories (`macos/`, `ios/`, `visionos/`)
- ✅ Enhanced rule parsing for new YAML format with platform-specific configurations
- ✅ DDM (Declarative Device Management) support for modern macOS management
- ✅ Improved mobileconfig generation with new PayloadType/PayloadContent structure
- ✅ Backward compatibility maintained for legacy baselines
- ✅ Section name normalization (automatic mapping of old/new section names)

## Installation

Expand Down Expand Up @@ -164,11 +197,24 @@ macos_security_intune_mapper/

External dependency (required):
```
macos_security/ # downloaded from github.com/usnistgov/macos_security
├── baselines/ # YAML baseline definitions
└── rules/ # Individual rule definitions
macos_security/ # Downloaded from github.com/usnistgov/macos_security
├── baselines/ # YAML baseline definitions
│ ├── macos/ # macOS baselines (v2.0+)
│ │ ├── cis_lvl1_macos_26.0.yaml
│ │ ├── cis_lvl2_macos_26.0.yaml
│ │ └── ...
│ ├── ios/ # iOS baselines (v2.0+)
│ └── visionos/ # visionOS baselines (v2.0+)
└── rules/ # Individual rule definitions
├── audit/
├── auth/
├── os/
├── pwpolicy/
└── system_settings/
```

**Note**: For legacy structure (pre-2.0), baseline files are directly in `baselines/` without platform subdirectories.

## Output Formats

### Settings Catalog JSON
Expand Down Expand Up @@ -244,7 +290,11 @@ Both GUI and CLI generate logs in `macos_security_intune_mapper.log` with detail

### "Baseline not found"

Make sure the `macos_security` folder with baselines is in the correct location. Use `--list` to see available baselines.
Make sure the `macos_security` folder with baselines is in the correct location. The tool supports both structures:
- **New structure**: `macos_security/baselines/macos/*.yaml` (automatically detected)
- **Legacy structure**: `macos_security/baselines/*.yaml`

Check that baseline files exist in the expected location. For the new structure, ensure files are in the `macos/` subdirectory.

### "Settings Catalog file not found"

Expand All @@ -265,6 +315,20 @@ Intune Settings Catalog updates regularly. To refresh `settingsCatalog.json` wit
2. Save the response to `settingsCatalog.json`
3. Re-run the tool with updated settings

### Fewer Policies Mapped Than Expected

If you're seeing fewer policies mapped after switching to the new macos_security structure (2.0+):
1. Ensure you're using the latest version of this tool (updated for 2.0+ compatibility)
2. The new rule format includes DDM settings which may map differently than legacy mobileconfig settings
3. Check the logs (`macos_security_intune_mapper.log`) for detailed mapping information
4. Some rules may now prefer DDM over mobileconfig, affecting the distribution of mapped vs. unmapped rules

### macOS Security Version Compatibility

- **Legacy baselines** (pre-2.0): Fully supported
- **New baselines** (2.0+): Fully supported with automatic detection
- **Mixed usage**: The tool can work with either structure but not both simultaneously (point to one macos_security folder)

## License

See LICENSE file for details.
3 changes: 3 additions & 0 deletions macOS/Tools/macos_security_intune_mapper/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""macOS Intune Mapper - Convert macOS security baselines to Intune policies."""

__version__ = "1.0.0"
94 changes: 82 additions & 12 deletions macOS/Tools/macos_security_intune_mapper/core/baseline_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@
from pathlib import Path
from typing import List, Optional, Dict, Any

from ..models.baseline import Baseline, BaselineSection
from models.baseline import Baseline, BaselineSection

logger = logging.getLogger(__name__)


# Section name mapping: old format -> new format
# The new baseline structure (macos_security 2.0+) uses different section names
SECTION_NAME_MAPPING = {
"auditing": "Auditing",
"macos": "Operating System",
"passwordpolicy": "Password Policy",
"systemsettings": "System Settings",
"Supplemental": "Supplemental", # Unchanged
# Add reverse mappings for normalization
"Auditing": "Auditing",
"Operating System": "Operating System",
"Password Policy": "Password Policy",
"System Settings": "System Settings",
}


class BaselineLoader:
"""Loads macOS security baselines from YAML files."""

Expand All @@ -22,19 +38,32 @@ def __init__(self, macos_security_path: Optional[str] = None):
macos_security_path: Path to macos_security folder. If None, uses default.
"""
if macos_security_path:
self.baselines_path = Path(macos_security_path) / "baselines"
baselines_root = Path(macos_security_path) / "baselines"
# Check for new structure with macos/ subdirectory
macos_baselines = baselines_root / "macos"
if macos_baselines.exists():
self.baselines_path = macos_baselines
else:
# Fall back to old structure (baselines/ directly)
self.baselines_path = baselines_root
else:
# Try to find macos_security folder
current = Path(__file__).parent
while current.parent != current:
macos_security = current / "macos_security"
if macos_security.exists() and (macos_security / "baselines").exists():
self.baselines_path = macos_security / "baselines"
baselines_root = macos_security / "baselines"
# Check for new structure
macos_baselines = baselines_root / "macos"
if macos_baselines.exists():
self.baselines_path = macos_baselines
else:
self.baselines_path = baselines_root
break
current = current.parent
else:
# Default to relative path
self.baselines_path = Path("macos_security/baselines")
# Default to relative path (try new structure first)
self.baselines_path = Path("macos_security/baselines/macos")

self._baselines_cache: Dict[str, Baseline] = {}
logger.info(f"Baseline loader initialized with path: {self.baselines_path}")
Expand All @@ -43,24 +72,36 @@ def list_baselines(self) -> List[str]:
"""List all available baselines.

Returns:
List of baseline names (without .yaml extension)
List of baseline names (without .yaml extension and version suffix)
"""
if not self.baselines_path.exists():
logger.warning(f"Baselines path does not exist: {self.baselines_path}")
return []

baselines = []
for baseline_file in self.baselines_path.glob("*.yaml"):
if baseline_file.name != "all_rules.yaml": # Skip the all_rules baseline
baselines.append(baseline_file.stem)
if baseline_file.name != "all_rules.yaml" and not baseline_file.name.startswith("all_rules_"):
# Strip .yaml extension
basename = baseline_file.stem

# For new structure, strip version suffix (e.g., _macos_26.0)
# Pattern: basename_platform_version (e.g., cis_lvl1_macos_26.0)
if "_macos_" in basename or "_ios_" in basename or "_visionos_" in basename:
# Find the last occurrence of platform suffix and remove it
parts = basename.rsplit("_", 2) # Split from right: ['cis_lvl1', 'macos', '26.0']
if len(parts) == 3 and parts[1] in ["macos", "ios", "visionos"]:
basename = parts[0] # Use the baseline name without platform/version

if basename not in baselines:
baselines.append(basename)

return sorted(baselines)

def load_baseline(self, baseline_name: str) -> Baseline:
"""Load a baseline from YAML file.

Args:
baseline_name: Name of the baseline (without .yaml extension)
baseline_name: Name of the baseline (without .yaml extension and version suffix)

Returns:
Baseline object
Expand All @@ -74,10 +115,27 @@ def load_baseline(self, baseline_name: str) -> Baseline:
logger.debug(f"Returning cached baseline: {baseline_name}")
return self._baselines_cache[baseline_name]

baseline_file = self.baselines_path / f"{baseline_name}.yaml"
# Try to find the baseline file with different naming patterns
baseline_file = None

if not baseline_file.exists():
raise FileNotFoundError(f"Baseline file not found: {baseline_file}")
# Pattern 1: New structure with platform/version (e.g., cis_lvl1_macos_26.0.yaml)
for pattern in [f"{baseline_name}_macos_*.yaml", f"{baseline_name}_ios_*.yaml", f"{baseline_name}_visionos_*.yaml"]:
matching_files = list(self.baselines_path.glob(pattern))
if matching_files:
# If multiple matches, use the first one (typically latest version)
baseline_file = matching_files[0]
logger.debug(f"Found baseline using pattern {pattern}: {baseline_file.name}")
break

# Pattern 2: Old structure (e.g., cis_lvl1.yaml)
if not baseline_file:
old_pattern = self.baselines_path / f"{baseline_name}.yaml"
if old_pattern.exists():
baseline_file = old_pattern
logger.debug(f"Found baseline using old naming: {baseline_file.name}")

if not baseline_file or not baseline_file.exists():
raise FileNotFoundError(f"Baseline file not found: {baseline_name} in {self.baselines_path}")

logger.info(f"Loading baseline from: {baseline_file}")

Expand All @@ -99,3 +157,15 @@ def load_baseline(self, baseline_name: str) -> Baseline:
raise ValueError(f"Invalid YAML in baseline file: {e}")
except Exception as e:
raise ValueError(f"Failed to load baseline: {e}")

@staticmethod
def normalize_section_name(section_name: str) -> str:
"""Normalize section names from old to new format.

Args:
section_name: Original section name (old or new format)

Returns:
Normalized section name in new format
"""
return SECTION_NAME_MAPPING.get(section_name, section_name)
4 changes: 2 additions & 2 deletions macOS/Tools/macos_security_intune_mapper/core/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
from typing import List, Dict, Any, Set, Tuple
from pathlib import Path

from ..models.policy import Policy, PolicySetting, MobileConfigPolicy
from ..models.rule import Rule
from models.policy import Policy, PolicySetting, MobileConfigPolicy
from models.rule import Rule

logger = logging.getLogger(__name__)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import logging
from typing import Optional, Dict, Any

from .settings_catalog import SettingsCatalog
from ..models.rule import Rule
from ..models.policy import Policy, PolicySetting, PolicyType
from core.settings_catalog import SettingsCatalog
from models.rule import Rule
from models.policy import Policy, PolicySetting, PolicyType

logger = logging.getLogger(__name__)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pathlib import Path
from typing import Dict, List, Optional

from ..models.rule import Rule
from models.rule import Rule

logger = logging.getLogger(__name__)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def from_intune(cls, access_token: str) -> "SettingsCatalog":
Returns:
SettingsCatalog instance
"""
from ..utils.intune_api import IntuneAPI
from utils.intune_api import IntuneAPI

logger.info("Fetching Settings Catalog from Intune")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
from pathlib import Path
from typing import List, Optional

from ..core.exporter import IntuneExporter
from ..models.policy import Policy
from ..models.rule import Rule
from ..models.baseline import Baseline
from core.exporter import IntuneExporter
from models.policy import Policy
from models.rule import Rule
from models.baseline import Baseline

logger = logging.getLogger(__name__)

Expand Down
18 changes: 9 additions & 9 deletions macOS/Tools/macos_security_intune_mapper/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
from dataclasses import dataclass
from typing import Optional, Dict, Any, List

from ..core.baseline_loader import BaselineLoader
from ..core.rules_loader import RulesLoader
from ..core.settings_catalog import SettingsCatalog
from ..core.policy_mapper import PolicyMapper
from ..models.baseline import Baseline
from ..models.rule import Rule
from ..models.policy import Policy, PolicySetting
from .export_dialog import ExportDialog
from core.baseline_loader import BaselineLoader
from core.rules_loader import RulesLoader
from core.settings_catalog import SettingsCatalog
from core.policy_mapper import PolicyMapper
from models.baseline import Baseline
from models.rule import Rule
from models.policy import Policy, PolicySetting
from gui.export_dialog import ExportDialog

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -1241,7 +1241,7 @@ def _apply_value_overrides_to_policy(self, policy: Policy, settings: List[Settin
New Policy instance with overrides applied
"""
from copy import deepcopy
from ..models.policy import PolicySetting
from models.policy import PolicySetting

# Create a deep copy of the policy
policy_copy = deepcopy(policy)
Expand Down
Loading