diff --git a/intune.py b/intune.py new file mode 100644 index 0000000..f65c458 --- /dev/null +++ b/intune.py @@ -0,0 +1,216 @@ +import csv +from bs4 import BeautifulSoup +import argparse +import os + +def parse_tables_from_markdown(md_file_path): + """ + Parses a Markdown/HTML file containing

headings and 'table-settings' tables. + + Assumes each policy: + - starts with an

tag that has the policy name + - is followed by two elements: + 1) 'Basics' + 2) 'Settings' + + Returns a list of dicts, each with: + { + 'basic_info': { key -> value, ... }, + 'settings': { key -> value, ... } + } + """ + with open(md_file_path, 'r', encoding='utf-8') as f: + html = f.read() + + soup = BeautifulSoup(html, 'lxml') + policies = [] + + # Find all

tags, each is a policy heading + h3_tags = soup.find_all('h3') + + for h3 in h3_tags: + policy_name = h3.get_text(strip=True) + + # Look for the next two 'table-settings' tables (Basics and Settings) + policy_tables = [] + sibling = h3.next_sibling + while sibling and len(policy_tables) < 2: + if ( + sibling.name == 'table' and + 'table-settings' in sibling.get('class', []) + ): + policy_tables.append(sibling) + sibling = sibling.next_sibling + + # If fewer than 2 tables, skip this policy + if len(policy_tables) < 2: + continue + + basics_table = policy_tables[0] + settings_table = policy_tables[1] + + # Parse out the Basic Info and Settings + basic_info = parse_key_value_table(basics_table) + settings_info = parse_key_value_table(settings_table) + + # Put policy name into basic_info if not already present + basic_info.setdefault("PolicyName", policy_name) + + policies.append({ + 'basic_info': basic_info, + 'settings': settings_info, + }) + + return policies + +def parse_key_value_table(table_tag): + """ + Given a

with class 'table-settings', parse each row (excluding + headers) into a { key: value } dict, where each row is . + """ + data = {} + rows = table_tag.find_all('tr', recursive=False) + + for row in rows: + # Skip table header and category rows + row_classes = row.get('class', []) + if 'table-header1' in row_classes or 'category-level1' in row_classes: + continue + + cols = row.find_all('td', recursive=False) + if len(cols) < 2: + continue # can't parse a key-value from this row + + key_text = cols[0].get_text(strip=True) + val_text = cols[1].get_text(strip=True) + data[key_text] = val_text + + return data + +def write_single_csv(policies, output_csv='policies.csv'): + """ + Writes a single CSV with columns in this order: + 1) PolicyName + 2) Description + 3) SettingKey + 4) SettingValue + 5) Policy type (mapped from 'Profile type') + 6) Platform supported + 7) Created + 8) Last modified + + Each row corresponds to one Setting. + """ + # The exact order we want: + columns = [ + "PolicyName", + "Description", + "SettingKey", + "SettingValue", + "Policy type", + "Platform supported", + "Created", + "Last modified" + ] + + with open(output_csv, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + # Write header + writer.writerow(columns) + + for policy in policies: + basic_info = policy['basic_info'] + settings = policy['settings'] + + # Extract the relevant basic info fields + policy_name = basic_info.get("PolicyName", "") + description = basic_info.get("Description", "") + # The user wants "Policy type" in CSV, but it's "Profile type" in the data + policy_type = basic_info.get("Profile type", "") + platform_supported = basic_info.get("Platform supported", "") + created = basic_info.get("Created", "") + last_modified = basic_info.get("Last modified", "") + + # If a policy has no settings, we could still write one row with empty SettingKey/Value + if not settings: + row = [ + policy_name, + description, + "", # SettingKey + "", # SettingValue + policy_type, + platform_supported, + created, + last_modified + ] + writer.writerow(row) + continue + + # Otherwise, write one row per setting + for setting_key, setting_value in settings.items(): + row = [ + policy_name, + description, + setting_key, + setting_value, + policy_type, + platform_supported, + created, + last_modified + ] + writer.writerow(row) + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Parse an Intune Markdown/HTML export with

headings and two " + "

KeyValue
sections (Basics + Settings) into a flat CSV." + ) + ) + parser.add_argument( + "input", + nargs="?", + help="Path to the Markdown/HTML file to parse (default: cqre.md)", + default=None, + ) + parser.add_argument( + "-o", "--output", + help=( + "Path to output CSV file. If not provided, derives from input name " + "(e.g., input.md -> input.csv). If no input is given, defaults to policies-cqre.csv." + ), + default=None, + ) + + args = parser.parse_args() + + # Determine input path (keeps previous default behavior if none provided) + input_path = args.input or "cqre.md" + + # Determine output path + if args.output: + output_csv = args.output + else: + if args.input: + base = os.path.splitext(os.path.basename(input_path))[0] + output_csv = f"{base}.csv" + else: + output_csv = "policies-cqre.csv" + + policies = parse_tables_from_markdown(input_path) + + # Count rows that will be written (one per setting, or one if no settings) + row_count = 0 + for p in policies: + settings = p.get("settings") or {} + row_count += max(len(settings), 1) + + write_single_csv(policies, output_csv) + + print( + f"Done! Parsed {len(policies)} policies and wrote {row_count} rows to '{output_csv}'." + ) + +if __name__ == "__main__": + main()