release: v4.1.0 — restructure entry points, add CIS baselines, reporting tools and fzf hints

- Restructure launchers: Start-IntuneToolkit.ps1 moves to repo root;
  Start-HeadlessIntune.ps1 moves to Scripts/; TUI helper moves to Scripts/Private/
- Add AGENTS.md with project architecture, entry points, and security notes
- Add CIS M365 baseline assets (CISM365-v7, M365-CIS-Rapid) and reporting scripts
- Add Python reporting utilities (Export-SettingsReport, Export-AssignmentReport,
  Export-ObjectInventoryReport) and CA wizard helpers
- Update Deploy-IntuneBaseline.ps1 with Merge conflict resolution, ReportPath,
  and optimized group loading
- Update Initialize-IntuneAuth.ps1 with -RotateSecret and configurable secret expiry
- Update Extensions for Settings Catalog definition auto-export
- Update README with v4.1.0, new entry points and script catalog
- Bump VERSION to 4.1.0
- Harden .gitignore against .DS_Store, __pycache__, .venv-pdf/, local exports,
  Settings.json and IntuneManagement.log
This commit is contained in:
2026-06-14 15:24:42 +02:00
parent e333af978c
commit d3e0769799
30 changed files with 8711 additions and 175 deletions
+778
View File
@@ -0,0 +1,778 @@
#!/usr/bin/env python3
"""
Convert CIS M365 v7.0.0 draft PDF to YAML baseline manifest.
Called by ConvertFrom-CISPDF.ps1
"""
import sys
import re
from pathlib import Path
from pypdf import PdfReader
def parse_profiles(pa_text: str | None) -> set[tuple[str, str]]:
"""Extract (level, license) tuples from Profile Applicability text.
Example: '• E3 Level 1 • E5 Level 2'{('L1','E3'), ('L2','E5')}
"""
if not pa_text:
return set()
profiles = set()
# Split by bullet to avoid cross-bullet matching
bullets = re.split(r'\s*•\s*', pa_text)
for bullet in bullets:
bullet = bullet.strip()
if not bullet:
continue
# Look for patterns like "E3 Level 1" or "Level 1 E3" within a single bullet
m = re.search(r'\b(E3|E5)\b.*\bLevel\s+(1|2)\b', bullet, re.IGNORECASE)
if not m:
m = re.search(r'\bLevel\s+(1|2)\b.*\b(E3|E5)\b', bullet, re.IGNORECASE)
if m:
level = f"L{m.group(1)}"
license = m.group(2).upper()
profiles.add((level, license))
else:
level = f"L{m.group(2)}"
license = m.group(1).upper()
profiles.add((level, license))
return profiles
def format_profiles(profiles: set[tuple[str, str]]) -> str:
"""Format profile set as compact badge string."""
if not profiles:
return ""
return "[" + ", ".join(f"{lvl}·{lic}" for lvl, lic in sorted(profiles)) + "]"
def matches_filter(profiles: set[tuple[str, str]], level_filter: str, license_filter: str) -> bool:
"""Check if a control's profiles match the requested level/license filters.
A control matches if at least one of its (level, license) tuples matches both filters.
"""
if not profiles:
return True # If we can't parse profiles, include by default
for lvl, lic in profiles:
level_ok = level_filter == 'Both' or level_filter == lvl
license_ok = license_filter == 'Both' or license_filter == lic
if level_ok and license_ok:
return True
return False
def parse_pdf(pdf_path: str) -> list[dict]:
"""Extract and parse all recommendations from the PDF."""
reader = PdfReader(pdf_path)
full_text = ""
for page in reader.pages:
full_text += "\n" + (page.extract_text() or "")
m = re.search(r'Profile Applicability:\s*\n\s*•\s*E3', full_text)
content_start = m.start() if m else 0
content = full_text[content_start:]
content = re.sub(r'\nPage \d+\s*\n', '\n', content)
section_headers = {
'Overview', 'Groups', 'Devices', 'Enterprise apps', 'External Identities',
'User experiences', 'Authentication Methods', 'Password reset', 'Identity Protection',
'Conditional Access', 'Protection', 'Hybrid management', 'Audit', 'Mail flow',
'Roles', 'Mobile Device Management', 'Application Permissions', 'Settings',
'Teams & groups', 'Users', 'External sharing', 'Guest access', 'Device access',
'User risk', 'Sign-in risk', 'Access reviews', 'Privileged Identity Management',
'Administration center', 'Email and collaboration', 'Tenant settings',
'Meetings', 'Messaging', 'Teams and channels', 'App permissions',
'External access', 'Data sharing', 'File sharing', 'Site settings',
'Service principals', 'Workspaces', 'External domains', 'External emails',
'Meeting policies', 'Calling policies', 'Teams policies', 'Channel policies',
'App setup policies', 'Permission policies', 'Update policies',
'Compliance policies', 'Retention policies', 'Sensitivity labels',
'Data loss prevention', 'Information barriers', 'Communication compliance',
'Insider risk management', 'Records management', 'eDiscovery',
'Customer Lockbox', 'Audit log', 'Reports', 'Alerts',
'Anti-spam', 'Anti-malware', 'Anti-phishing', 'Safe Attachments',
'Safe Links', 'Outbound spam', 'Connection filter', 'Mail flow rules',
'Transport rules', 'Journal rules', 'Data connectors',
'Sensitivity label policies', 'Auto-labeling policies',
'Information protection', 'Data governance', 'Compliance Manager',
'Service assurance', 'Health', 'Message center', 'Adoption Score',
'Usage reports', 'Productivity Score', 'Org settings',
'Security & Privacy', 'Organization profile', 'Partner relationships',
'Billing', 'Purchase services', 'Subscriptions', 'Licenses',
'Payment methods', 'Billing notifications', 'Invoice',
'Active users', 'Deleted users', 'Guest users',
'Contacts', 'Sign-in options',
'Custom domain names', 'DNS records', 'Domain settings',
'Shared mailboxes', 'Resource mailboxes', 'Distribution groups',
'Dynamic distribution groups', 'Mail-enabled security groups',
'Office 365 groups', 'Security groups', 'Mail contacts',
'Migration', 'Data migration', 'IMAP migration',
'Cutover migration', 'Staged migration', 'Minimal hybrid',
'Express migration', 'Cross-tenant migration',
'Setup', 'Connectors',
'Azure AD', 'Support',
'Training', 'Policies', 'Resources', 'Mail',
'Sites', 'Apps', 'Power Platform',
'Dynamics 365', 'Azure', 'Microsoft 365',
'Intune', 'Entra', 'Exchange', 'SharePoint',
'OneDrive', 'Power BI', 'Power Apps',
'Power Automate', 'Power Virtual Agents', 'Copilot',
}
pa_positions = [m.start() for m in re.finditer(r'Profile Applicability:', content)]
recommendations = []
for i, pa_pos in enumerate(pa_positions):
window_start = max(0, pa_pos - 800)
window = content[window_start:pa_pos]
title_match = None
for m in re.finditer(r'(\d+\.\d+\.\d+\.\d+)\s+(.+?)\s*\((Automated|Manual)\)', window, re.DOTALL):
title_match = m
if not title_match:
for m in re.finditer(r'(\d+\.\d+\.\d+)\s+(.+?)\s*\((Automated|Manual)\)', window, re.DOTALL):
title_match = m
if not title_match:
continue
control_num = title_match.group(1)
title = title_match.group(2).replace('\n', ' ').strip()
title = re.sub(r'\s+', ' ', title)
status = title_match.group(3)
if title in section_headers:
continue
rec_start = title_match.start() + window_start
rec_end = pa_positions[i + 1] if i + 1 < len(pa_positions) else len(content)
chunk = content[rec_start:rec_end]
def extract_field(field_name: str, chunk_text: str) -> str | None:
pattern = re.compile(
re.escape(field_name) + r':\s*\n?\s*(.*?)(?=\n\s*[A-Z][a-zA-Z\s]+:\s*\n|\Z)',
re.DOTALL
)
m = pattern.search(chunk_text)
if m:
val = m.group(1).strip()
val = re.sub(r'\s+', ' ', val)
return val
return None
rec = {
'control': control_num,
'title': title,
'status': status,
'profile_applicability': extract_field('Profile Applicability', chunk),
'description': extract_field('Description', chunk),
'rationale': extract_field('Rationale', chunk),
'impact': extract_field('Impact', chunk),
'default_value': extract_field('Default Value', chunk),
}
rem_match = re.search(r'Remediation:\s*(.*?)(?=Audit:|Default Value:|References:|CIS Controls:|\Z)', chunk, re.DOTALL)
if rem_match:
rec['remediation'] = re.sub(r'\s+', ' ', rem_match.group(1))[:1000]
audit_match = re.search(r'Audit:\s*(.*?)(?=Remediation:|Default Value:|References:|CIS Controls:|\Z)', chunk, re.DOTALL)
if audit_match:
rec['audit'] = re.sub(r'\s+', ' ', audit_match.group(1))[:1000]
recommendations.append(rec)
seen = set()
unique = []
for r in recommendations:
if r['control'] not in seen:
seen.add(r['control'])
unique.append(r)
return unique
def generate_yaml(recommendations: list[dict], prefix: str, level_filter: str = 'Both', license_filter: str = 'Both') -> str:
"""Generate YAML baseline from parsed recommendations."""
lines = []
lines.append("# =====================================================================")
lines.append("# CIS Microsoft 365 Foundations Benchmark v7.0.0 (Draft)")
lines.append("# GENERATED from PDF — review before deploying")
lines.append("# =====================================================================")
lines.append("")
lines.append("baseline:")
lines.append(f' name: CIS-M365-v7-Generated')
lines.append(' conflictResolution: Skip')
lines.append(' whatIf: false')
lines.append("")
lines.append(' tenantMutation:')
lines.append(f' prefix: "{prefix}"')
lines.append("")
lines.append(' groups:')
lines.append(' - displayName: "CIS-BreakGlass"')
lines.append(' mailNickname: "CISBreakGlass"')
lines.append(' securityEnabled: true')
lines.append(' - displayName: "CIS-Pilot-Users"')
lines.append(' mailNickname: "CISPilotUsers"')
lines.append(' securityEnabled: true')
lines.append("")
lines.append(' tenantConfig:')
section_names = {
'1': 'adminCenter',
'2': 'defender',
'3': 'purview',
'5': 'entraId',
'6': 'exchange',
'7': 'sharePoint',
'8': 'teams',
'9': 'powerBI',
}
# =====================================================================
# COMPREHENSIVE CONTROL MAPPINGS
# =====================================================================
# Simple scalar/boolean mappings: control -> (yaml_section, yaml_key, value)
simple_mappings = {
# --- Section 1: Admin Center ---
'1.3.1': ('adminCenter', 'passwordExpiration', 'NeverExpire'),
'1.3.2': ('adminCenter', 'idleSessionTimeoutHours', 3),
'1.3.4': ('adminCenter', 'restrictUserOwnedApps', True),
'1.3.5': ('adminCenter', 'formsPhishingProtection', True),
'1.3.6': ('adminCenter', 'customerLockbox', True),
'1.3.7': ('adminCenter', 'restrictThirdPartyStorage', True),
'1.3.9': ('adminCenter', 'restrictSharedBookings', True),
'1.3.3': ('adminCenter', 'externalCalendarSharing', 'Disabled'),
# --- Section 5: Entra ID ---
'5.1.2.2': ('entraId', 'blockUserConsent', True),
'5.1.2.3': ('entraId', 'blockTenantCreation', True),
'5.1.2.4': ('entraId', 'restrictAdminCenterAccess', True),
'5.1.2.6': ('entraId', 'disableLinkedIn', True),
'5.1.3.1': ('entraId', 'blockSecurityGroupCreation', True),
'5.1.3.4': ('entraId', 'blockM365GroupCreation', True),
'5.1.4.1': ('entraId', 'restrictDeviceJoin', True),
'5.1.4.2': ('entraId', 'maxDevicesPerUser', 5),
'5.1.4.3': ('entraId', 'gaLocalAdminDisabled', True),
'5.1.4.4': ('entraId', 'limitLocalAdminAssignment', True),
'5.1.4.5': ('entraId', 'enableLAPS', True),
'5.1.4.6': ('entraId', 'restrictBitLockerRecovery', True),
'5.1.5.1': ('entraId', 'blockUserConsent', True),
'5.1.5.2': ('entraId', 'enableAdminConsentWorkflow', True),
'5.1.5.3': ('entraId', 'blockPasswordCredentials', True),
'5.1.5.4': ('entraId', 'maxPasswordLifetimeDays', 180),
'5.1.5.5': ('entraId', 'systemGeneratedPasswords', True),
'5.1.5.6': ('entraId', 'maxCertificateLifetimeDays', 180),
'5.1.6.1': ('entraId', 'restrictCollaborationDomains', True),
'5.1.6.2': ('entraId', 'restrictGuestAccess', True),
'5.1.6.3': ('entraId', 'limitGuestInvitations', True),
'5.1.8.1': ('entraId', 'enablePasswordHashSync', True),
'5.2.3.1': ('entraId', 'authenticatorNumberMatching', True),
'5.2.3.4': ('entraId', 'mfaCapableUsers', True),
'5.2.3.5': ('entraId', 'disableWeakAuthMethods', True),
'5.2.3.6': ('entraId', 'systemPreferredMFA', True),
'5.2.3.7': ('entraId', 'disableEmailOTP', True),
'5.2.3.8': ('entraId', 'lockoutThreshold', 10),
'5.2.3.9': ('entraId', 'lockoutDurationSeconds', 60),
'5.2.3.10': ('entraId', 'disableAuthenticatorCompanionApps', True),
'5.3.1': ('entraId', 'pimRoleActivationRequired', True),
'5.3.2': ('entraId', 'accessReviewsForGuests', True),
'5.3.3': ('entraId', 'accessReviewsForPrivilegedRoles', True),
'5.3.4': ('entraId', 'requireApprovalForGAActivation', True),
'5.3.5': ('entraId', 'requireApprovalForPRAActivation', True),
# --- Section 6: Exchange ---
'6.1.1': ('exchange', 'enableMailboxAuditOrgWide', True),
'6.1.2': ('exchange', 'configureMailboxAuditActions', True),
'6.1.3': ('exchange', 'disableAuditBypass', True),
'6.2.1': ('exchange', 'blockExternalForwarding', True),
'6.2.2': ('exchange', 'noDomainWhitelistTransportRules', True),
'6.2.3': ('exchange', 'enableExternalSenderBanner', True),
'6.3.1': ('exchange', 'blockOutlookAddIns', True),
'6.3.2': ('exchange', 'disablePersonalEmailAccounts', True),
'6.5.1': ('exchange', 'enableModernAuthExchange', True),
'6.5.2': ('exchange', 'enableMailTips', True),
'6.5.3': ('exchange', 'restrictAdditionalStorageProviders', True),
'6.5.4': ('exchange', 'disableSMTPAuth', True),
'6.5.5': ('exchange', 'rejectDirectSend', True),
'1.2.2': ('exchange', 'blockSharedMailboxSignIn', True),
'2.1.12': ('exchange', 'connectionFilterIPAllowListEmpty', True),
'2.1.13': ('exchange', 'connectionFilterSafeListOff', True),
'2.1.14': ('exchange', 'inboundAntiSpamNoAllowedDomains', True),
'2.1.15': ('exchange', 'outboundAntiSpamLimits', True),
# --- Section 7: SharePoint ---
'7.2.1': ('sharePoint', 'requireModernAuthSharePoint', True),
'7.2.2': ('sharePoint', 'enableAADB2BIntegration', True),
'7.2.3': ('sharePoint', 'sharePointExternalSharing', 'Disabled'),
'7.2.4': ('sharePoint', 'oneDriveExternalSharing', 'Disabled'),
'7.2.5': ('sharePoint', 'preventGuestResharing', True),
'7.2.6': ('sharePoint', 'restrictSharePointExternalSharing', True),
'7.2.7': ('sharePoint', 'restrictLinkSharing', True),
'7.2.8': ('sharePoint', 'restrictSharingBySecurityGroup', True),
'7.2.9': ('sharePoint', 'guestAccessExpirationDays', 30),
'7.2.10': ('sharePoint', 'restrictReauthenticationVerificationCode', True),
'7.2.11': ('sharePoint', 'defaultSharingLinkPermission', 'View'),
'7.3.1': ('sharePoint', 'disallowInfectedFileDownload', True),
# --- Section 8: Teams ---
'8.1.1': ('teams', 'restrictExternalFileSharing', True),
'8.1.2': ('teams', 'blockChannelEmail', True),
'8.2.1': ('teams', 'restrictExternalDomains', True),
'8.2.2': ('teams', 'disableUnmanagedUserCommunication', True),
'8.2.3': ('teams', 'blockExternalUserInitiation', True),
'8.2.4': ('teams', 'blockTrialTenantCommunication', True),
'8.5.1': ('teams', 'allowAnonymousUsersToJoinMeeting', False),
'8.5.2': ('teams', 'allowAnonymousUsersToStartMeeting', False),
'8.5.3': ('teams', 'orgOnlyBypassLobby', True),
'8.5.4': ('teams', 'dialInCantBypassLobby', True),
'8.5.5': ('teams', 'noAnonymousMeetingChat', True),
'8.5.6': ('teams', 'onlyOrganizersCanPresent', True),
'8.5.7': ('teams', 'noExternalControl', True),
'8.5.8': ('teams', 'externalMeetingChatOff', True),
'8.5.9': ('teams', 'meetingRecordingOffByDefault', True),
'8.6.1': ('teams', 'enableSecurityConcernsReporting', True),
# --- Section 9: Power BI ---
'9.1.1': ('powerBI', 'restrictGuestAccess', True),
'9.1.2': ('powerBI', 'restrictExternalInvitations', True),
'9.1.3': ('powerBI', 'restrictGuestContentAccess', True),
'9.1.4': ('powerBI', 'restrictPublishToWeb', True),
'9.1.5': ('powerBI', 'disableRPythonVisuals', True),
'9.1.6': ('powerBI', 'enableSensitivityLabels', True),
'9.1.7': ('powerBI', 'restrictShareableLinks', True),
'9.1.8': ('powerBI', 'restrictExternalDataSharing', True),
'9.1.9': ('powerBI', 'blockResourceKeyAuth', True),
'9.1.10': ('powerBI', 'restrictServicePrincipalAPIAccess', True),
'9.1.11': ('powerBI', 'blockServicePrincipalProfiles', True),
'9.1.12': ('powerBI', 'restrictServicePrincipalWorkspaceCreation', True),
# --- Section 3: Purview ---
'3.1.1': ('purview', 'enableAuditLogSearch', True),
}
# Defender policy mappings
defender_policies = {
'2.1.1': ('safeLinks', {
'name': 'SafeLinks-Default',
'enabled': True,
'trackClicks': True,
'allowClickThrough': False,
'scanUrls': True,
'enableForInternalSenders': True,
}),
'2.1.2': ('antiMalware', {
'name': 'AntiMalware-Default',
'enabled': True,
'enableInternalNotifications': True,
'fileTypes': ['ace', 'ani', 'app', 'docm', 'exe', 'jar', 'jnlp', 'msi', 'ps1', 'scr', 'vbs', 'wsf'],
}),
'2.1.3': ('antiMalware', {
'name': 'AntiMalware-InternalNotify',
'enabled': True,
'enableInternalNotifications': True,
}),
'2.1.4': ('safeAttachments', {
'name': 'SafeAttachments-Default',
'enabled': True,
'action': 'Block',
'quarantineMessages': True,
}),
'2.1.5': ('safeAttachments', {
'name': 'SafeAttachments-SPO-Teams',
'enabled': True,
'action': 'Block',
'enableForSharePoint': True,
'enableForTeams': True,
}),
'2.1.6': ('antiSpam', {
'name': 'AntiSpam-Notify-Admins',
'enabled': True,
'notifyAdmins': True,
}),
'2.1.7': ('antiPhish', {
'name': 'AntiPhish-Default',
'enabled': True,
'enableMailboxIntelligence': True,
'enableSpoofIntelligence': True,
'mailboxIntelligenceProtectionAction': 'Quarantine',
}),
'2.1.11': ('antiMalware', {
'name': 'AntiMalware-Comprehensive',
'enabled': True,
'enableFileFilter': True,
}),
'2.4.1': ('priorityAccount', {'enabled': True}),
'2.4.2': ('priorityAccount', {'strictProtection': True}),
'2.4.4': ('zap', {'enabledForTeams': True}),
}
# Draft YAML blocks for tenant-specific controls (commented out)
draft_blocks = {
'1.1.3': [
" # ASSESSMENT-ONLY: Report current global admin count; cannot auto-remediate",
" # assessment:",
" # control: \"1.1.3\"",
" # name: \"GlobalAdminCount\"",
" # minAdmins: 2",
" # maxAdmins: 4",
],
'1.1.4': [
" # ASSESSMENT-ONLY: Report admin license footprint; cannot auto-remediate",
" # assessment:",
" # control: \"1.1.4\"",
" # name: \"AdminLicenseFootprint\"",
" # allowedSkus: [\"AAD_PREMIUM_P2\", \"ENTERPRISEPACK\", \"SPE_E5\"]",
],
'1.2.1': [
" # ASSESSMENT-ONLY: Review public groups; cannot auto-remediate",
" # assessment:",
" # control: \"1.2.1\"",
" # name: \"PublicGroupReview\"",
" # visibilityFilter: \"Public\"",
],
'3.2.1': [
" # DRAFT: Uncomment and customize DLP policies for your environment",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Financial-Data\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"Exchange\"",
" # - type: \"SharePoint\"",
" # - type: \"OneDrive\"",
" # rules:",
" # - name: \"Detect-Credit-Cards\"",
" # sensitiveInfoTypes: [\"Credit Card Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
" # - name: \"CIS-DLP-PII\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Detect-SSN\"",
" # sensitiveInfoTypes: [\"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.2.2': [
" # DRAFT: Uncomment and customize Teams DLP policy",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Teams\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Teams-Detect-PII\"",
" # sensitiveInfoTypes: [\"Credit Card Number\", \"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.2.3': [
" # DRAFT: Uncomment and customize Copilot DLP policy",
" # dlpPolicies:",
" # - name: \"CIS-DLP-Copilot\"",
" # enabled: true",
" # mode: \"Enable\"",
" # locations:",
" # - type: \"TeamsChat\"",
" # - type: \"TeamsChannel\"",
" # rules:",
" # - name: \"Copilot-Detect-Sensitive\"",
" # sensitiveInfoTypes: [\"Credit Card Number\", \"U.S. Social Security Number\"]",
" # actions: [\"BlockWithOverride\"]",
" # userNotification: true",
],
'3.3.1': [
" # DRAFT: Uncomment and customize sensitivity labels for your organization",
" # sensitivityLabels:",
" # - name: \"Internal\"",
" # displayName: \"Internal\"",
" # priority: 1",
" # enabled: true",
" # labelAction: \"Encrypt\"",
" # - name: \"Confidential\"",
" # displayName: \"Confidential\"",
" # priority: 2",
" # enabled: true",
" # labelAction: \"Encrypt\"",
" # sensitivityLabelPolicies:",
" # - name: \"CIS-Label-Policy-Default\"",
" # enabled: true",
" # labels: [\"Internal\", \"Confidential\"]",
" # defaultLabel: \"Internal\"",
],
}
def format_val(val):
if isinstance(val, str):
return f'"{val}"'
elif isinstance(val, bool):
return str(val).lower()
elif isinstance(val, list):
return '[' + ', '.join(f'"{v}"' for v in val) + ']'
return str(val)
def write_simple_section(sec_num, sec_name, sec_recs):
if not sec_recs:
return
lines.append("")
lines.append(f" # ===============================================================")
lines.append(f" # Section {sec_num}: {sec_name}")
lines.append(f" # ===============================================================")
lines.append(f" {sec_name}:")
for r in sec_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
# Filter by level/license
if not matches_filter(profiles, level_filter, license_filter):
continue
# Skip CA policies — they are handled in the conditionalAccess section
if ctrl.startswith('5.2.2.'):
continue
# Skip on-prem AD password protection — hybrid only
if ctrl == '5.2.3.3':
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # NOTE: Hybrid-only control — requires on-premises Active Directory")
continue
# Banned passwords — add inline with external file support
if ctrl == '5.2.3.2':
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" # Option A: Inline list")
lines.append(f" bannedPasswords:")
lines.append(f" - \"Contoso\"")
lines.append(f" - \"Password\"")
lines.append(f" - \"Welcome\"")
lines.append(f" - \"Admin\"")
lines.append(f" - \"Login\"")
lines.append(f" - \"Microsoft\"")
lines.append(f" - \"Office365\"")
lines.append(f" # Option B: External file (one password per line)")
lines.append(f" # bannedPasswordsFile: \"./banned-passwords.txt\"")
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
rem = r.get('remediation', '')
hint = rem[:120] + '...' if len(rem) > 120 else rem
if hint:
lines.append(f" # HINT: {hint}")
lines.append(f" # TODO: Implement manually per PDF instructions")
continue
if ctrl in simple_mappings:
sec, key, val = simple_mappings[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {key}: {format_val(val)}")
elif ctrl in draft_blocks:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
for line in draft_blocks[ctrl]:
lines.append(line)
else:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # TODO: Map this control to YAML — see PDF for details")
# Write non-defender, non-CA sections
for sec_num in ['1', '5', '6', '7', '8', '9', '3']:
sec_name = section_names[sec_num]
sec_recs = [r for r in recommendations if r['control'].split('.')[0] == sec_num]
write_simple_section(sec_num, sec_name, sec_recs)
# Defender section (with proper policy structures)
def_recs = [r for r in recommendations if r['control'].split('.')[0] == '2']
if def_recs:
lines.append("")
lines.append(" # ===============================================================")
lines.append(" # Section 2: Defender for Office 365")
lines.append(" # ===============================================================")
lines.append(" defender:")
for r in def_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
if not matches_filter(profiles, level_filter, license_filter):
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
continue
if ctrl in defender_policies:
policy_type, policy_def = defender_policies[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {policy_type}:")
for k, v in policy_def.items():
lines.append(f" {k}: {format_val(v)}")
elif ctrl in ['2.1.8', '2.1.9', '2.1.10']:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # NOTE: DNS-level control — configure via DNS provider, not M365 tenant")
elif ctrl in simple_mappings:
sec, key, val = simple_mappings[ctrl]
lines.append(f" # {ctrl} {profile_badge}: {title}")
lines.append(f" {key}: {format_val(val)}")
else:
lines.append(f" # {ctrl} {profile_badge}({status}): {title}")
lines.append(f" # TODO: Map this control to YAML — see PDF for details")
# Conditional Access section
ca_recs = [r for r in recommendations if r['control'].startswith('5.2.2.')]
if ca_recs:
lines.append("")
lines.append(" # ===============================================================")
lines.append(" # Section 5.2.2: Conditional Access")
lines.append(" # ===============================================================")
lines.append(" conditionalAccess:")
lines.append(" reportOnly: true")
lines.append(" breakGlassGroup: \"CIS-BreakGlass\"")
lines.append(" policies:")
for r in ca_recs:
ctrl = r['control']
title = r['title']
status = r['status']
profiles = parse_profiles(r.get('profile_applicability'))
profile_badge = format_profiles(profiles)
if not matches_filter(profiles, level_filter, license_filter):
continue
if status == 'Manual':
lines.append(f" # {ctrl} {profile_badge}(Manual): {title}")
continue
name = re.sub(r'[^a-zA-Z0-9\s]', '', title)
name = re.sub(r'\s+', '-', name)
name = re.sub(r'-+', '-', name)
name = name[:55].strip('-')
lines.append(f" - name: \"{name}\"")
lines.append(f" cisControl: \"{ctrl}\"")
lines.append(f" description: \"{title}\"")
lines.append(f" state: enabledForReportingButNotEnforced")
lines.append(f" conditions:")
lines.append(f" applications:")
t = title.lower()
if 'intune enrollment' in t:
lines.append(f" includeApplications: [\"0000000a-0000-0000-c000-000000000000\"]")
elif 'register security' in t:
lines.append(f" includeUserActions: [\"urn:user:registersecurityinfo\"]")
else:
lines.append(f" includeApplications: [\"All\"]")
lines.append(f" users:")
if 'admin' in t or 'administrator' in t:
lines.append(f" includeRoles:")
lines.append(f" - \"Global Administrator\"")
lines.append(f" - \"Privileged Role Administrator\"")
lines.append(f" - \"Security Administrator\"")
lines.append(f" - \"Exchange Administrator\"")
lines.append(f" - \"SharePoint Administrator\"")
lines.append(f" - \"Conditional Access Administrator\"")
lines.append(f" - \"Application Administrator\"")
lines.append(f" - \"Cloud Application Administrator\"")
lines.append(f" - \"User Administrator\"")
lines.append(f" - \"Helpdesk Administrator\"")
lines.append(f" - \"Billing Administrator\"")
lines.append(f" - \"Authentication Administrator\"")
lines.append(f" - \"Password Administrator\"")
lines.append(f" - \"Global Reader\"")
else:
lines.append(f" includeUsers: [\"All\"]")
if 'legacy' in t:
lines.append(f" clientAppTypes: [\"exchangeActiveSync\", \"other\"]")
elif 'device code' in t:
lines.append(f" authenticationFlows:")
lines.append(f" deviceCodeFlow:")
lines.append(f" isEnabled: true")
elif 'sign-in risk' in t or 'risk' in t:
lines.append(f" signInRiskLevels: [\"medium\", \"high\"]")
elif 'named location' in t or 'geographic' in t:
lines.append(f" # TODO: Define named locations in Entra admin center")
lines.append(f" grantControls:")
if 'block' in t and ('legacy' in t or 'device code' in t or 'risk' in t or 'authentication transfer' in t):
lines.append(f" builtInControls: [\"block\"]")
lines.append(f" operator: \"OR\"")
elif 'mfa' in t and 'phishing-resistant' in t:
lines.append(f" builtInControls: [\"authenticationStrength\"]")
lines.append(f" authenticationStrength:")
lines.append(f" id: \"00000000-0000-0000-0000-000000000004\"")
lines.append(f" operator: \"OR\"")
elif 'mfa' in t or 'multifactor' in t or 'reauthentication' in t or 're-authentication' in t:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
elif 'managed device' in t:
lines.append(f" builtInControls: [\"compliantDevice\", \"domainJoinedDevice\"]")
lines.append(f" operator: \"OR\"")
elif 'token protection' in t:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
lines.append(f" # TODO: Enable Token Protection via Authentication Strength policy")
else:
lines.append(f" builtInControls: [\"mfa\"]")
lines.append(f" operator: \"OR\"")
if 'sign-in frequency' in t or 'browser' in t or 'persistent' in t:
lines.append(f" sessionControls:")
lines.append(f" signInFrequency:")
lines.append(f" value: 12")
lines.append(f" type: hours")
lines.append(f" isEnabled: true")
lines.append(f" persistentBrowser:")
lines.append(f" mode: never")
lines.append(f" isEnabled: true")
return '\n'.join(lines) + '\n'
def main():
if len(sys.argv) < 3:
print("Usage: _ConvertFrom-CISPDF.py <pdf_path> <output_path> [prefix] [level] [license]")
print(" level: L1 | L2 | Both (default)")
print(" license: E3 | E5 | Both (default)")
sys.exit(1)
pdf_path = sys.argv[1]
output_path = sys.argv[2]
prefix = sys.argv[3] if len(sys.argv) > 3 else "CIS-v7-"
level_filter = sys.argv[4] if len(sys.argv) > 4 else "Both"
license_filter = sys.argv[5] if len(sys.argv) > 5 else "Both"
print(f"Parsing PDF: {pdf_path}")
recommendations = parse_pdf(pdf_path)
auto = sum(1 for r in recommendations if r['status'] == 'Automated')
manual = sum(1 for r in recommendations if r['status'] == 'Manual')
print(f"Found {len(recommendations)} unique recommendations")
print(f" Automated: {auto}")
print(f" Manual: {manual}")
print(f"\nGenerating YAML (level={level_filter}, license={license_filter})...")
yaml = generate_yaml(recommendations, prefix, level_filter, license_filter)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(yaml)
print(f"Written: {output_path}")
print(f"Total lines: {len(yaml.splitlines())}")
if __name__ == '__main__':
main()