#!/usr/bin/env python3 """By Didier Aeschimann 2026-10-03 Takes a SonicWall .exp config file and, next to it, produces: .html styled standalone HTML version of the report Usage: python3 process_exp.py """ import argparse import base64 import collections import html import os import re import sys from pathlib import Path from urllib.parse import unquote # ======================================================= step 1: exp -> text def exp_to_txt(exp_path: Path) -> str: content = exp_path.read_text() content = content.rstrip("\n") if content.endswith("&&"): content = content[:-2] decoded = base64.b64decode(content).decode("latin-1") decoded = decoded.replace("&", "\n") return unquote(decoded) # =========================================================== step 2: text -> md def gen_report(src_text: str, source_name: str) -> str: idx = collections.defaultdict(dict) # base -> {i: value} flat = {} # key -> value for line in src_text.splitlines(): if '=' not in line: continue k, v = line.split('=', 1) m = re.match(r'^(.*)_(\d+)$', k) if m: idx[m.group(1)][int(m.group(2))] = v else: flat[k] = v def G(base, i, default=''): return idx.get(base, {}).get(i, default) def F(key, default=''): return flat.get(key, default) def maxi(base): d = idx.get(base) return max(d) if d else -1 def bits(n, names): out = [] for i, nm in enumerate(names): if n & (1 << i): out.append(nm) return out def mask2prefix(mask): if not mask or mask == '0.0.0.0': return None try: b = sum(bin(int(o)).count('1') for o in mask.split('.')) return b except ValueError: return None def ipstr(o): t = o['type'] if t == 1: return o['ip1'] if t == 2: return '%s - %s' % (o['ip1'], o['ip2']) if t == 4: p = mask2prefix(o['ip2']) return '%s/%d' % (o['ip1'], p) if p else '%s (%s)' % (o['ip1'], o['ip2']) return None L = [] def w(s=''): L.append(s) # ---------- build object maps ---------- ADDR_TYPES = {1: 'IP', 2: 'IP Range', 4: 'Subnet', 8: 'Object Group', 0: 'empty'} zones = [] for i in range(maxi('zoneObjId') + 1): zones.append({'id': i, 'name': G('zoneObjId', i), 'type': int(G('zoneObjZoneType', i) or 0)}) ZONE_TYPES = {0: 'WAN', 1: 'LAN', 2: 'DMZ', 3: 'Reserved', 4: 'WLAN', 5: 'VPN', 6: 'Multicast'} addr_objs = {} addr_order = [] for i in range(maxi('addrObjId') + 1): name = G('addrObjId', i) if not name: continue o = {'id': i, 'name': name, 'disp': G('addrObjIdDisp', i), 'type': int(G('addrObjType', i) or 0), 'zone': G('addrObjZone', i), 'ip1': G('addrObjIp1', i), 'ip2': G('addrObjIp2', i)} if name not in addr_objs: addr_objs[name] = o addr_order.append(name) fqdn_objs = {} for i in range(maxi('addrObjFqdnId') + 1): name = G('addrObjFqdnId', i) if name: fqdn_objs[name] = {'id': i, 'name': name, 'val': G('addrObjFqdn', i), 'zone': G('addrObjFqdnZone', i), 'type': G('addrObjFqdnType', i)} addr_groups = collections.OrderedDict() for i in range(maxi('addro_grpToGrp') + 1): grp = G('addro_grpToGrp', i) mem = G('addro_atomToGrp', i) if grp and mem: addr_groups.setdefault(grp, []).append(mem) IPPROTO = {0: 'any', 1: 'ICMP', 2: 'IGMP', 6: 'TCP', 17: 'UDP', 47: 'GRE', 50: 'ESP', 51: 'AH', 108: 'IPComp', 58: 'ICMPv6'} svcs = {} for i in range(maxi('svcObjId') + 1): name = G('svcObjId', i) if not name: continue s = {'id': i, 'name': name, 'type': int(G('svcObjType', i) or 0), 'proto': int(G('svcObjIpType', i) or 0), 'p1': int(G('svcObjPort1', i) or 0), 'p2': int(G('svcObjPort2', i) or 0), 'props': int(G('svcObjProperties', i) or 0), 'mgmt': int(G('svcObjManagement', i) or 0)} svcs.setdefault(name, s) svc_groups = collections.OrderedDict() for i in range(maxi('so_grpToGrp') + 1): grp = G('so_grpToGrp', i) mem = G('so_atomToGrp', i) if grp and mem: svc_groups.setdefault(grp, []).append(mem) def svc_desc(name): s = svcs.get(name) if not s: return name if s['type'] == 2: proto = IPPROTO.get(s['proto'], str(s['proto'])) if s['proto'] == 0: return '%s [combined, raw %d-%d]' % (name, s['p1'], s['p2']) return '%s [%s %d-%d]' % (name, proto, s['p1'], s['p2']) proto = IPPROTO.get(s['proto'], str(s['proto'])) if s['proto'] == 1: return '%s [ICMP type %d]' % (name, s['p1']) if s['p1'] == s['p2']: return '%s [%s/%d]' % (name, proto, s['p1']) return '%s [%s %d-%d]' % (name, proto, s['p1'], s['p2']) sched_atoms = [] for i in range(maxi('schedObjId') + 1): nm = G('schedObjId', i) if not nm: continue t = int(G('schedObjType', i) or 0) dow = int(G('schedObjDaysOfWeek', i) or 0) b = int(G('schedObjTimeBegin', i) or 0) e = int(G('schedObjTimeEnd', i) or 0) days = [] if t == 1: names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] days = [names[j] for j in range(7) if dow & (1 << j)] desc = ', '.join(days) + ' %02d:%02d-%02d:%02d' % (b // 60, b % 60, e // 60, e % 60) else: desc = 'group' sched_atoms.append({'id': i, 'name': nm, 'type': t, 'desc': desc}) sched_groups = collections.OrderedDict() for i in range(maxi('sched_atomToGrp') + 1): atom = G('sched_atomToGrp', i) grp = G('sched_grpToGrp', i) if atom and grp: sched_groups.setdefault(grp, []).append(atom) users = [] for i in range(maxi('userObjId') + 1): nm = G('userObjId', i) if nm: users.append({'id': i, 'name': nm, 'type': int(G('userObjType', i) or 0), 'comment': G('userObjComment', i), 'priv': int(G('userObjPrivMask', i) or 0)}) user_groups = [] for i in range(maxi('userGroupObjId') + 1): nm = G('userGroupObjId', i) if nm: user_groups.append({'id': i, 'name': nm, 'type': int(G('userGroupObjType', i) or 0), 'comment': G('userGroupObjComment', i), 'cfl': G('userGroupObjCfspId', i), 'priv': int(G('userGroupObjPrivMask', i) or 0)}) user_members = [] for i in range(maxi('uo_atomToGrp') + 1): u = G('uo_atomToGrp', i) g = G('uo_grpToGrp', i) if u and g: user_members.append((u, g)) ldap_users = [G('ldapUsrsTree', i) for i in range(maxi('ldapUsrsTree') + 1) if G('ldapUsrsTree', i)] ldap_groups = [G('ldapUsrGrpsTree', i) for i in range(maxi('ldapUsrGrpsTree') + 1) if G('ldapUsrGrpsTree', i)] rules = [] for i in range(maxi('policyAction') + 1): r = {f: G('policy' + f, i) for f in ['Action', 'SrcZone', 'DstZone', 'SrcNet', 'DstNet', 'DstSvc', 'Whom', 'Time', 'Comment', 'Log', 'Enabled', 'Frag', 'Props', 'PriType', 'ConnPercent', 'Inactivity', 'InactivityUdp', 'Management', 'BwEnabled', 'BwGuar', 'BwMax', 'BwPri', 'IbwEnabled', 'IbwGuar', 'IbwMax', 'IbwPri', 'QosDscpAction', 'QosDscpTagValue']} r['id'] = i rules.append(r) nats = [] for i in range(maxi('natPolicyOrigSrc') + 1): nats.append({k: G('natPolicy' + k, i) for k in ['OrigSrc', 'OrigDst', 'OrigSvc', 'TransSrc', 'TransDst', 'TransSvc', 'SrcIface', 'DstIface', 'Enabled', 'Reflexive', 'Comment', 'NonDeletable']} | {'id': i}) # ---------- write report ---------- w('# SonicWall PRO 5060 — Readable Configuration Report') w() w('*Source file:* `%s` (%d key/value lines)' % (os.path.basename(source_name), len(idx) and sum(len(v) for v in idx.values()) + len(flat))) w() w('## 1. Device Overview') w() w('| Setting | Value |') w('|---|---|') w('| Firewall name | %s |' % F('firewallName')) w('| Model | PRO 5060 (from filename) |') w('| Firmware | %s (version id %s) |' % (F('buildNum'), F('currentFirmwareVers'))) w('| Previous firmware | %s |' % (F('oldFirmwareVers') if F('oldFirmwareVers') not in ('0', '') else '—')) w('| Serial number | %s |' % F('serialNumber')) w('| Hardware ID | %s |' % (F('hardwareId') if F('hardwareId') not in ('0', '') else '—')) w('| FireWall ID (IKE) | %s |' % F('firewallId')) w('| Timezone | %s |' % F('timezone')) w('| Admin domain | %s |' % F('fwinfoDomain')) w('| Domain (DHCP) | %s |' % F('dhcp_domainname')) w('| Periodic reboot | %s (every %s min) |' % (F('enablePeriodicReboot'), F('periodicRebootMinutes'))) w('| HA enabled | %s |' % F('enableHA')) w() w('> Note: `avEncryptKey`, `ciaAuthEncKey`, `ldapServerBindPwd` and similar fields contain') w('> credential/secret values. They have been **omitted** from this report on purpose.') w() w('## 2. Network Interfaces') w() w('| If | Name | Type | IP | Mask | Gateway | Zone | Mgmt (HTTP/HTTPS/SNMP/SSH) |') w('|---|---|---|---|---|---|---|---|') IF_TYPES = {0: 'LAN', 1: 'WAN', 2: 'DMZ', 3: 'VLAN', 4: 'PPPoE', 5: 'Bridged', 6: 'LAN (X0/X5 style)'} for i in range(maxi('iface_name') + 1): name = G('iface_name', i) itype = G('iface_type', i) lan_ip, lan_mask = G('iface_lan_ip', i), G('iface_lan_mask', i) st_ip, st_mask = G('iface_static_ip', i), G('iface_static_mask', i) gw = G('iface_static_gateway', i) if gw == '0.0.0.0': gw = '' ip = lan_ip if lan_ip not in ('0.0.0.0', '') else st_ip mask = lan_mask if lan_ip not in ('0.0.0.0', '') else st_mask zone = G('interface_Zone', i) mgmt = [] if G('iface_http_mgmt', i) == '1': mgmt.append('http') if G('iface_https_mgmt', i) == '1': mgmt.append('https') if G('iface_snmp_mgmt', i) == '1': mgmt.append('snmp') if G('iface_ssh_mgmt', i) == '1': mgmt.append('ssh') if G('iface_ping_mgmt', i) == '1': mgmt.append('ping') w('| X%d | %s | %s | %s | %s | %s | %s | %s |' % (i, name, itype, ip, mask, gw or '—', zone or '—', ','.join(mgmt) or '—')) w() sec_wan = F('secondary_wan_if') w('Primary WAN: **X%s** · Secondary WAN: %s' % (F('primary_wan_if'), 'X%s' % sec_wan if sec_wan not in ('4294967295', '') else 'none')) w() w('Interface extras:') w('- X4 (Layer 2 uplink): static %s/%s, gw %s' % (G('iface_static_ip', 4), mask2prefix(G('iface_static_mask', 4)), G('iface_static_gateway', 4))) w('- Bridged interfaces: %s' % (G('iface_bridged_other', 0) or 'none')) w() w('## 3. Zones') w() w('| Zone | Type | Intra-zone comms | CFL profile | Group VPN | SonicPoint | WPA allowed | Max guests |') w('|---|---|---|---|---|---|---|---|') for z in zones: i = z['id'] w('| %s | %s | %s | %s | %s | %s | %s | %s |' % ( z['name'], ZONE_TYPES.get(z['type'], z['type']), 'yes' if G('zoneObjIntraZoneCom', i) == '1' else 'no', G('zoneObjCfspId', i) or 'Default', 'yes' if G('zoneObjGroupVpn', i) == '1' else 'no', G('zoneObjSonicPointProfHandle', i) or '—', 'yes' if G('zoneObjAllowWPA', i) == '1' else 'no', G('zoneObjMaxGuests', i) or '—')) w() w('WLAN zone: SonicPoint-only = %s, VPN travel restriction = %s, guest services = %s' % ( G('zoneObjSonicPointOnly', 5), G('zoneObjRestrictVpnTrav', 5), G('zoneObjEnableGuestServices', 5))) w() w('## 4. Schedules') w() w('Atomic schedules:') w() w('| Name | Days / Time |') w('|---|---|') for s in sched_atoms: w('| %s | %s |' % (s['name'], s['desc'])) w() w('Schedule groups:') w() w('| Group | Members |') w('|---|---|') for g, ms in sched_groups.items(): w('| %s | %s |' % (g, ', '.join(ms))) w() w('## 5. Authentication & Users') w() w('### LDAP') w() w('| Setting | Value |') w('|---|---|') for k in ['ldapServerName', 'ldapServerPort', 'ldapUsrDomain', 'ldapUsrTree', 'ldapUsrGrpAttr', 'ldapUsrObjClass', 'ldapUsrGrpObjClass', 'ldapProtocolVer', 'ldapUseTls', 'ldapServerBindName', 'ldapServerBindTree']: if F(k): w('| %s | %s |' % (k, F(k))) w() w('LDAP user tree entries: %d (%s)' % (len(ldap_users), ', '.join(ldap_users) if ldap_users else 'none')) w('LDAP group tree entries: %d (%s)' % (len(ldap_groups), ', '.join(ldap_groups) if ldap_groups else 'none')) w() w('### RADIUS') w() w('| Setting | Value |') w('|---|---|') for k in ['Rad_prm_IP', 'Rad_prm_port', 'Rad_sec_IP', 'Rad_sec_port', 'Radius_timeout', 'Radius_retries', 'radiusDfltUserGroup', 'radiusUserGroupMethod']: v = F(k) if k.endswith('_secret'): continue w('| %s | %s |' % (k, v or '—')) w() w('### Local users & groups') w() w('| User | Type | Privilege mask | Comment |') w('|---|---|---|---|') for u in users: w('| %s | %s | %s | %s |' % (u['name'], u['type'], u['priv'], u['comment'])) w() w('| Group | Type | CFL profile | Privilege mask | Comment |') w('|---|---|---|---|---|') for g in user_groups: w('| %s | %s | %s | %s | %s |' % (g['name'], g['type'], g['cfl'], g['priv'], g['comment'])) w() w('User → group membership: ' + (', '.join('%s → %s' % (a, b) for a, b in user_members) or 'none')) w() w('## 6. DHCP') w() w('| Setting | Value |') w('|---|---|') for k in ['enableDHCP', 'dhcp_lease', 'centralDhcpIp', 'dhcp_domainname', 'dhcp_propagateSettingsToLan', 'dhcp_dns0', 'dhcp_dns1', 'dhcp_dns2', 'dhcp_wins0', 'dhcp_wins1', 'dhcpIpHelper', 'dhcpPersEnable']: w('| %s | %s |' % (k, F(k) or '—')) w() w('DHCP options served:') w() w('| Option | Tag | Value |') w('|---|---|---|') OPT_NAMES = {1: 'Subnet mask', 3: 'Router', 6: 'DNS servers', 15: 'Domain name', 44: 'WINS', 66: 'TFTP server'} for i in range(maxi('dhcpOptionObjTagNumber') + 1): tag = G('dhcpOptionObjTagNumber', i) w('| dhcpOption%d | %s | %s |' % (i, OPT_NAMES.get(int(tag or 0), tag), G('dhcpOptionObjTagValue', i))) w() w('Remote (VPN client) DHCP: spoof=%s, mgmt IP=%s, group-VPN IP=%s, static IP=%s, temp lease=%s' % ( F('dhcprSpoof'), F('dhcprMgmtIp'), F('dhcprGroupVpnIp'), F('dhcprStaticIp'), F('dhcprTempLease'))) w() w('## 7. DNS') w() w('| Setting | Value |') w('|---|---|') for k in ['dns_use_static_servers', 'dns_server_one', 'dns_server_two', 'dns_server_three', 'dnsSrvAddr', 'dnsServer2', 'dnsServer3', 'dns_use_static_servers']: if F(k) and k not in ('dns_use_static_servers',): w('| %s | %s |' % (k, F(k))) w('| dns_use_static_servers | %s |' % F('dns_use_static_servers')) w() w('## 8. NAT Policies') w() w('| # | Enabled | Source | Dest | Service | Translated src/dst/svc | Src→Dst iface | Comment |') w('|---|---|---|---|---|---|---|---|') for n in nats: w('| %d | %s | %s | %s | %s | %s / %s / %s | %s→%s | %s |' % ( n['id'], 'yes' if n['Enabled'] == '1' else 'no', n['OrigSrc'] or 'any', n['OrigDst'] or 'any', n['OrigSvc'] or 'any', n['TransSrc'] or '—', n['TransDst'] or '—', n['TransSvc'] or '—', n['SrcIface'], n['DstIface'], n['Comment'] or '—')) w() w('## 9. Policy-Based Routing') w() w('| Field | Value |') w('|---|---|') for i in range(maxi('pbrObjId') + 1): if not G('pbrObjId', i): continue for k in ['pbrObjId', 'pbrObjSrc', 'pbrObjSvc', 'pbrObjDst', 'pbrObjGw', 'pbrObjIface', 'pbrObjMetric', 'pbrObjPriority']: v = G(k, i) w('| %s | %s |' % (k, v or '—')) w() w('## 10. VPN / IPSec Tunnels') w() PH1 = {1: 'DES', 2: '3DES', 3: 'AES-128', 4: 'AES-192', 5: 'AES-256'} PH2A = {1: 'DES', 2: '3DES', 3: 'AES-128', 4: 'AES-256'} PH2P = {50: 'ESP', 51: 'AH', 0: 'ESP+AH'} DHGR = {1: '768', 2: '1024', 5: '1536', 14: '2048'} for i in range(maxi('ipsecName') + 1): nm = G('ipsecName', i) if not nm: continue w('### %s' % nm) w() w('| Field | Value |') w('|---|---|') sa_disabled = G('ipsecSaDisabled', i) w('| Enabled | %s |' % ('no (SA disabled)' if sa_disabled == 'on' else 'yes' if F('ipsecEnable') == 'on' else 'no (IPSec globally off)')) w('| Gateway | %s |' % (G('ipsecGwAddr', i) or '—')) w('| Local network | %s |' % (G('ipsecLocalNetwork', i) or 'any/remote clients')) w('| Remote network | %s |' % (G('ipsecRemoteNetwork', i) or '—')) w('| Remote clients | %s |' % G('ipsecRemoteClients', i)) w('| Phase 1 | %s, DH %s, auth %s, lifetime %ss |' % ( PH1.get(int(G('ipsecPh1CryptAlg', i) or 0), G('ipsecPh1CryptAlg', i)), DHGR.get(int(G('ipsecP1DHGrp', i) or 0), G('ipsecP1DHGrp', i)), G('ipsecPh1AuthAlg', i), G('ipsecP1LifeSecs', i))) w('| Phase 2 | %s, %s, lifetime %ss |' % ( PH2P.get(int(G('ipsecPh2Protocol', i) or 0), G('ipsecPh2Protocol', i)), PH2A.get(int(G('ipsecPh2CryptAlg', i) or 0), G('ipsecPh2CryptAlg', i)), G('ipsecLifeSecs', i))) w('| Keepalive / DPD | %s / %s |' % (G('ipsecKeepAlive', i), F('vpnDpdInterval'))) w() w('## 11. Access Control Rules (%d rules)' % len(rules)) w() ACTION = {0: 'Permit', 1: 'Permit (no NAT)', 2: 'Deny', 4: 'Reject'} w() w('| # | Action | Src Zone | Src Net | Dst Zone | Dst Net | Service | User | Time | Log | On | Comment |') w('|---|---|---|---|---|---|---|---|---|---|---|---|') for r in rules: w('| %d | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s |' % ( r['id'], ACTION.get(int(r['Action'] or 0), r['Action']), r['SrcZone'] or 'any', r['SrcNet'] or 'any', r['DstZone'] or 'any', r['DstNet'] or 'any', r['DstSvc'] or 'any', 'Anyone' if r['Whom'] in ('0', '') else r['Whom'], r['Time'] or 'always', 'yes' if r['Log'] == '1' else 'no', 'yes' if r['Enabled'] == '1' else 'no', r['Comment'] or '—')) w() np = collections.Counter(ACTION.get(int(r['Action'] or 0), r['Action']) for r in rules) w('Totals: %s' % ', '.join('%s: %d' % kv for kv in np.most_common())) w() w('## 12. Address Objects (%d + %d FQDN)' % (len(addr_objs), len(fqdn_objs))) w() w('### FQDN objects') w() w('| Name | Value | Zone |') w('|---|---|---|') for n, o in fqdn_objs.items(): w('| %s | %s | %s |' % (o['name'], o['val'] or '—', o['zone'] or '—')) w() w('### IP objects (non-group)') w() w('| Name | Zone | Type | Address |') w('|---|---|---|---|') for n in addr_order: o = addr_objs[n] if o['type'] == 8: continue w('| %s | %s | %s | %s |' % (o['name'], o['zone'] or '—', ADDR_TYPES.get(o['type'], o['type']), ipstr(o) or '—')) w() w('### Object groups') w() w('| Group | Zone | Members |') w('|---|---|---|') for n in addr_order: o = addr_objs[n] if o['type'] != 8: continue members = addr_groups.get(n, []) w('| %s | %s | %s |' % (o['name'], o['zone'] or '—', ', '.join(members) or '—')) w() extra = [g for g in addr_groups if g not in addr_objs] if extra: w() w('Referenced groups not defined as objects: %s' % ', '.join(extra)) w() w('## 13. Service Objects (%d)' % len(svcs)) w() w('### Groups of services') w() w('| Group | Members |') w('|---|---|') for g, ms in svc_groups.items(): w('| %s | %s |' % (g, ', '.join(ms))) w() w('### Individual services') w() w('| Name | Protocols / Ports | Notes |') w('|---|---|---|') for s in sorted(svcs.values(), key=lambda x: x['id']): name = s['name'] if s['type'] == 2 and s['proto'] == 0: desc = 'combined (TCP+UDP, encoded %d-%d)' % (s['p1'], s['p2']) elif s['proto'] == 1: desc = 'ICMP type %d' % s['p1'] elif s['p1'] == s['p2']: desc = '%s/%d' % (IPPROTO.get(s['proto'], str(s['proto'])), s['p1']) else: desc = '%s %d-%d' % (IPPROTO.get(s['proto'], str(s['proto'])), s['p1'], s['p2']) notes = [] if s['mgmt']: notes.append('mgmt') if s['props'] & 512: notes.append('mgmt-flag') w('| %s | %s | %s |' % (name, desc, ', '.join(notes))) w() w('## 14. Content Filtering & Security Services') w() w('### Content Filter Profile (applied to zones)') w() w('| Profile | In use | Options mask | Category mask |') w('|---|---|---|---|') for i in range(maxi('cfspName') + 1): w('| %s | %s | %s | %s |' % (G('cfspName', i), G('cfspInUse', i), G('cfspOptionMask', i), G('cfspCatMask', i))) w() w('### WebCFS (Web Category Filtering)') w() w('| Setting | Value |') w('|---|---|') for k in ['webcfsSrvAddr', 'webcfsSrvPort', 'webcfsUserName', 'webcfsBlockOnFail', 'webcfsBlockBlockedSites', 'webcfsLogBlockedSites', 'webcfsUseDRTR', 'webcfsFilterHTTPSSites']: w('| %s | %s |' % (k, F(k) or '—')) w() w('### N2H2 (URL Reputational Filtering)') w() for k in ['n2h2SrvAddr', 'n2h2SrvPort', 'n2h2UserName', 'n2h2BlockOnFail', 'n2h2LogBlockedSites']: w('- %s = %s' % (k, F(k) or '—')) w() w('### Web Security (WSE)') w() for k in ['wseSrvAddr', 'wseSrvPort', 'wseUserName', 'wseProbeEnabled', 'wseBlockOnFail']: w('- %s = %s' % (k, F(k) or '—')) w() w('### Site Blocking (SBI / keyword & URL block)') w() w('- Block on fail: %s, log blocked: %s' % (F('sbi_blockOnlyLog') or '—', 'see raw')) for i in range(maxi('sbi_urlBlockMask') + 1): v = G('sbi_urlBlockMask', i) if v: w('- urlBlockMask_%d = %s' % (i, v)) w('- Block ActiveX: %s, Java: %s, Cookies: %s, Custom: %s' % ( F('sbi_blockActiveX'), F('sbi_blockJava'), F('sbi_blockCookies'), F('sbi_blockCustom') or 'none')) if F('sbi_blockURLKeywords') in ('on', '1'): w('- URL keyword blocking: on (no keyword list present in this config)') if F('TOD_useTOD') == '1': w('- Time-of-day URL filtering: on, %s:%s to %s:%s, days %s-%s' % ( F('TOD_startHour'), F('TOD_startMin'), F('TOD_endHour'), F('TOD_endMin'), F('TOD_startDay'), F('TOD_endDay'))) w() w('### RBL (DNS-based block lists)') w() for k in ['rblMode', 'rblSpecifyServers', 'rblDnsServer1', 'rblDnsServer2', 'rblDnsServer3']: w('- %s = %s' % (k, F(k) or '—')) for i in range(maxi('rblDomainName') + 1): if G('rblDomainName', i): w('- domain %s (enabled %s)' % (G('rblDomainName', i), G('rblDomainEnabled', i))) w() w('### Antivirus / Anti-Spyware / GAV / IDP') w() w('| Engine | Enabled | Notes |') w('|---|---|---|') w('| Antivirus | %s | scanning: %s, HTTP: %s, SMTP: %s, FTP: %s |' % ( F('avEnable'), F('avEnableScanning'), F('avEnableHTTP'), F('avEnableSMTP'), F('avEnableFTP'))) w('| Anti-spyware | %s | HTTP: %s, SMTP: %s |' % (F('asEnableScanning'), F('asEnableHTTP'), F('asEnableSMTP'))) w('| GAV | %s | HTTP: %s, SMTP: %s |' % (F('gavEnableScanning'), F('gavEnableHTTP'), F('gavEnableSMTP'))) w('| IDP (Intrusion Prevention) | %s | signatures DB: %s entries in file |' % (F('isIDPEnabled'), len(idx.get('idpSigId', {})))) w() w('## 15. WLAN / SonicPoint') w() SP_AUTH = {0: 'Open', 1: 'WEP', 2: 'WPA-PSK', 3: 'WPA-Enterprise', 4: 'WPA2-PSK', 5: 'WPA2-Enterprise', 6: 'WPA/WPA2-PSK', 7: 'WPA/WPA2-Enterprise'} sp_max = maxi('sonicPointObjId') if sp_max < 0: w('No SonicPoint objects defined.') else: for i in range(sp_max + 1): nm = G('sonicPointObjId', i) if not nm: continue w('### SonicPoint: %s' % nm) w() w('| Field | Value |') w('|---|---|') w('| Enabled | %s |' % ('yes' if G('sonicPointEnable', i) == '1' else 'no')) w('| Interface | X%s |' % (G('sonicPointIfNum', i) or '—')) w('| MAC | %s |' % (G('sonicPointMac', i) or '—')) w('| IP / Mask | %s / %s |' % (G('sonicPointIp', i) or '—', G('sonicPointSubnetMask', i) or '—')) w('| Country | %s |' % (G('sonicPointCountryCode', i) or '—')) w() w('| Radio | SSID | Enabled | Radio mode | Channel | Auth | Hide SSID | Max clients | PSK |') w('|---|---|---|---|---|---|---|---|---|') for band in ('A', 'G', 'N'): psk = G('sonicPoint' + band + 'WpaPsk', i) w('| 802.11%s | %s | %s | %s | %s | %s | %s | %s | %s |' % ( band, G('sonicPoint' + band + 'Ssid', i) or '—', 'yes' if G('sonicPoint' + band + 'Enable', i) == '1' else 'no', G('sonicPoint' + band + 'RadioMode', i) or '—', G('sonicPoint' + band + 'Channel', i) or 'auto', SP_AUTH.get(int(G('sonicPoint' + band + 'AuthType', i) or 0), G('sonicPoint' + band + 'AuthType', i)), 'yes' if G('sonicPoint' + band + 'HideSsid', i) == '1' else 'no', G('sonicPoint' + band + 'MaxClients', i) or '—', '*** (set)' if psk else '—')) w() w('## 16. Routing') w() w('- Advanced routing (OSPF/RIP) enabled: **%s**' % F('useAdvancedRouting')) w('- WAN link: %s, LAN link: %s, DMZ link: %s' % (F('wanLinkAbility'), F('lanLinkAbility'), F('dmzLinkAbility'))) w() w('### OSPF (per zone)') w() w('| Zone if | Mode | Router ID | Hello | Dead | Metric |') w('|---|---|---|---|---|---|') for i in range(6): m = G('ZOspfMode', i) if m not in ('', '0'): w('| %s | %s | %s | %s | %s | %s |' % (G('swZOspfIfnum', i), m, G('ZOspfRouterId', i), G('ZOspfHelloInterval', i), G('ZOspfDeadInterval', i), G('ZOspfIfCost', i))) w() w('### RIP (zone + SW)') w() w('| Zone if | Mode | Password used |') w('|---|---|---|') for i in range(6): m = G('ZRipMode', i) if m not in ('', '0'): w('| %s | %s | %s |' % (G('swZRipIfnum', i), m, 'yes' if G('ZRipUsePassword', i) == '1' else 'no')) w() w('## 17. Logging') w() w('| Setting | Value |') w('|---|---|') for k in ['syslogEnable' , 'syslogServerName', 'syslogServerPort', 'syslogStandbyServerName', 'syslogFacility', 'logNameResolution', 'logResolutionSpecifyServers', 'logResolutionServer1', 'logResolutionServer2', 'logResolutionServer3', 'logSmtpPort', 'smtpServerName', 'mailAuthName', 'prefs_reportsOn']: v = F(k) if k == 'mailAuthPassword': continue if v: w('| %s | %s |' % (k, v)) w() w('## 18. System Administration & Password Policy') w() w('| Setting | Value |') w('|---|---|') for k in ['adminLoginTimeout', 'adminPreemptAction', 'adminPreemptInactivity', 'loginFailLockoutEnable', 'loginFailLockoutTime', 'loginFailMaxRate', 'minPasswordLength', 'passwordComplexity', 'passwordUpdtPeriod', 'passwordUniqueNum', 'userLoginEnforceUnique', 'userInactivity', 'userMaxTime', 'userNamesCaseSensitive', 'userDisconnectDetectEnable', 'httpsCertName', 'userRedirectToHttp']: v = F(k) if v != '': w('| %s | %s |' % (k, v)) w() w('AUP (acceptable use policy): enabled=%s, shown on LAN/WAN/WLAN/VPN/DMZ=%s/%s/%s/%s/%s' % ( F('aupEnable'), F('userAUPShowLAN'), F('userAUPShowWAN'), F('userAUPShowWLAN'), F('userAUPShowVPN'), F('userAUPShowDMZ'))) w() w('## 19. Security Hardening Flags') w() w('| Setting | Value |') w('|---|---|') hard = ['DropFtpBounceAttacks', 'dropSrcRoute', 'strictTcpStateful', 'enforceTcpHandshake', 'EnforceICMPChkV', 'EnforceIPChkV', 'EnforceUDPChkV', 'IPSpoofChecking', 'tcpChecksumValidation', 'tcpHeaderFixup', 'tcpPacketTagging', 'randomizeIds', 'randomizeTcpSeq', 'DecrementTTL', 'clearFragBit', 'sockstressProtection', 'synFloodBListOn', 'synFloodBListExMgmt', 'synFloodBListExWan', 'synFloodMSS', 'synFloodPMode', 'synFloodPSACK', 'noPortScanDetection', 'EnforceUDPChkV', 'EnforceIPChkV', 'EnforceICMPChkV', 'enableStealthMode', 'enableSnapHead', 'watchdogOn', 'combufSanity', 'bootTimePcapStart', 'dropPktAtDbaseLoad', 'failedArpCacheEnable', 'oldArpBehavior', 'arpBridge', 'arpTimeout', 'enablePeriodicReboot', 'enablePeriodicGratArps', 'DecrementTTL'] seen = set() for k in hard: if k in seen: continue seen.add(k) w('| %s | %s |' % (k, F(k) or '—')) w() w('## 20. Other Settings (raw, grouped)') w() COVERED_PREFIXES = ['zoneObj', 'addrObj', 'addro_', 'svcObj', 'so_', 'policy', 'natPolicy', 'schedObj', 'sched_', 'cfsp', 'ipsec', 'pbrObj', 'dhcpOption', 'userObj', 'userGroupObj', 'guestProfileObj', 'ldapUsrsTree', 'ldapUsrGrpsTree', 'idpSig', 'uo_', 'if_', 'eth_', 'iface_', 'ha', 'ZOspf', 'ZRip', 'swRip', 'swZOspf', 'swZRip', 'qos', 'rbl', 'sbi_', 'UnBlockedUrl', 'applyPwdConstraints', 'ikePreemptSecGw', 'ipsecpeerCertID', 'extension_add', 'forbiddenURLs_add', 'upgradeKey', 'bridged_iface', 'LRI', 'TOD'] other = collections.OrderedDict() covered = set() for k in list(flat.keys()): if any(k.startswith(p.rstrip('_')) or k == p.rstrip('_') for p in COVERED_PREFIXES): continue if k in covered: continue other.setdefault('Misc', []).append(k) covered.add(k) GROUPS = [ ('Content filter / CFS', ['cfs', 'cf_method', 'cfMinHttpHeaderSize', 'cfTablePageSize', 'cflSubExpNoticeVers']), ('WebCFS', ['webcfs']), ('N2H2', ['n2h2']), ('WSE', ['wse']), ('SBI / site blocking', ['sbi_']), ('Antivirus', ['av']), ('Anti-spyware', ['as']), ('GAV', ['gav']), ('IDP', ['idp', 'isIDP', 'isDPIDisabled', 'rfm']), ('LDAP / auth', ['ldap', 'Rad_', 'Radius', 'radius', 'ciaAuth', 'xauth', 'autoLogn']), ('SMTP / mail', ['smtp', 'mailAuth', 'logSmtpPort', 'popServerName']), ('Syslog / logging', ['syslog', 'logPrefs', 'logRes', 'auxSyslog', 'alertCategoryFilterBitmask', 'guiCategoryFilterBitmask', 'eventRateLimitMode', 'maxEventsPerSecondThreshold', 'maxSyslogBytesPerSecond']), ('WLAN / SonicPoint / RF', ['wlan', 'wlb', 'wseProbe', 'spUpdate', 'spnSupported', 'sonicPoint']), ('DHCP', ['dhcp', 'dhcpr', 'useDhcps', 'DoTempLease', 'doTempLease', 'iph']), ('DNS', ['dns', 'rbl']), ('Routing / HA', ['electionDelay', 'routeHolddown', 'enableHA', 'ha', 'enableSync', 'OutboundLb', 'RatioLb', 'useAdvancedRouting']), ('VPN / IPSec global', ['ike', 'ipsec', 'vpnDpd', 'vpnKeepAlive', 'L2TP', 'L2tps', 'L2tpsUserGroup', 'L2tpsLocalIpPool', 'xauth', 'NatTraversal', 'spnSupported', 'h323', 'sip', 'voipEndpoint', 'ras', 'enableRTSPSupport', 'enableSipSupport', 'scanMsnStreams', 'scanOracleStreams', 'sdpSupportedVer']), ('Multicast', ['enableMcast', 'enableMulticastFiltering', 'igmp', 'mcast']), ('SNMP / admin', ['snmp', 'admin', 'httpsCertName', 'useRemoteMgtName', 'useInternational', 'onlineHelpUrl', 'guiStartingPage', 'firmwareNotice', 'hfwauFw', 'cflSubExpNoticeVers', 'isEvalUpgradeViewed', 'isWarningPageViewed', 'isUseCustomerDownloadUrl', 'isCustomerDownloadUrlSp11N', 'showGuestLoginStatusWin', 'showLoginStatusWin', 'statusMsgOnly', 'prefs_', 'tooltip', 'sfid', 'userIV', 'passwordUniqueNum', 'local_registerCode', 'registrationCode', 'cryptoLevelTag', 'serial', 'hardwareId', 'firewallId', 'firewallName', 'fwinfoDomain', 'timezone', 'buildNum', 'currentFirmwareVers', 'oldFirmwareVers', 'updateRtVersion', 'checksum']), ('Firewall behavior', ['afPolicy', 'policeDmz', 'proxyIncludesDmz', 'proxyPcMacOnWan', 'pcMacAddressForWan', 'bypassFailedProxy', 'webProxy', 'consistentNat', 'dfltTrafficBW', 'wanBwMgmt', 'policeDmz', 'cacheTimeout', 'cnntThreshold', 'enableCnntLmt', 'blockPartialMsg', 'useRuleWizard', 'useSaWizard', 'UsePIForPassThru', 'AllowStaticIPToVA', 'Vpn1ArmedEnable', 'VpnNoTcpMssAdjust', 'preserveIkePorts', 'ipsecDHInteropMode', 'ipsecDnsTtlSaClean', 'ipsecFragmentAfterEsp', 'ipsecIgnoreDfBit', 'ipsecSendTriggerPacket', 'ipsecTryAllKeepAlive', 'ipsecAllowMcast', 'ipsecAllowNetBIOS', 'ipsec_apply', 'ftpAlgSvcHandle', 'ftpTransformations', 'nbt_enable', 'DHCP_CONFLICT_DETECT', 'DecrementTTL', 'flushOnRtEnable', 'Mcast', 'efFindShortNametag', 'hostTagSearchEnable', 'isNakWrongNetwork', 'isRemoteGw', 'globalMgt', 'gms', 'paramGmsMode', 'sgmsLoginOverrideAdmin', 'standby_', 'MAFiA', 'fipsMode', 'OcspResponderUrl', 'EnableOcspCheck', 'certblock']), ('WLAN guest / AUP', ['guestProfile', 'userAUP', 'aup', 'pendingGuestCleanupFreq', 'wlanGuestRedirectInterval', 'wlanVpnDownload', 'wlanVids', 'wlanWids', 'wlanVapBssid', 'spSetup']), ] buckets = collections.OrderedDict() for title, prefixes in GROUPS: buckets[title] = [] for p in prefixes: for k in list(other.get('Misc', [])): if k.startswith(p): buckets[title].append(k) other['Misc'].remove(k) for k in other.get('Misc', []): buckets.setdefault('Everything else', []).append(k) for title, keys in buckets.items(): if not keys: continue w('### %s' % title) w() w('| Key | Value |') w('|---|---|') for k in sorted(keys): v = F(k) if re.search(r'(?i)(psk|wepkey|secret|_pwd|password|encryptkey|bindpwd)', k): continue w('| %s | %s |' % (k, v or '—')) w() return '\n'.join(L) + '\n' # ============================================================== step 3: md -> html CSS = """ :root { --bg: #0f1117; --panel: #161a23; --panel-2: #1c212e; --text: #d7dce5; --muted: #8b93a7; --accent: #4da3ff; --accent-2: #7ee0a3; --border: #2a3142; --code-bg: #11141c; --row-alt: #191e29; --quote-bg: #1a2233; } * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 15px; line-height: 1.65; } .page { max-width: 1080px; margin: 0 auto; padding: 40px 28px 80px; } header.doc-header { background: linear-gradient(135deg, #1b2334 0%, #141824 100%); border: 1px solid var(--border); border-radius: 12px; padding: 28px 32px; margin-bottom: 32px; } header.doc-header h1 { margin: 0 0 8px; font-size: 30px; letter-spacing: 0.2px; color: #fff; } header.doc-header .subtitle { color: var(--muted); font-size: 14px; } h1, h2, h3, h4, h5, h6 { color: #fff; line-height: 1.3; margin: 1.6em 0 0.6em; scroll-margin-top: 24px; } h1 { font-size: 28px; } h2 { font-size: 22px; padding-bottom: 8px; border-bottom: 1px solid var(--border); } h3 { font-size: 18px; color: var(--accent-2); } h4 { font-size: 16px; } p { margin: 0.7em 0; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } code { font-family: "SF Mono", SFMono-Regular, ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace; font-size: 13px; background: var(--code-bg); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; color: #ffd9a0; } pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; overflow-x: auto; } pre code { background: none; border: none; padding: 0; color: var(--text); font-size: 13px; } table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 13.5px; background: var(--panel); border-radius: 10px; overflow: hidden; box-shadow: 0 0 0 1px var(--border); } th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } th { background: var(--panel-2); color: #fff; font-weight: 600; white-space: nowrap; } tbody tr:nth-child(even) { background: var(--row-alt); } tbody tr:hover { background: #22304a; } tr:last-child td { border-bottom: none; } blockquote { margin: 1em 0; padding: 10px 16px; background: var(--quote-bg); border-left: 4px solid var(--accent); border-radius: 0 8px 8px 0; color: var(--text); } blockquote p { margin: 0.3em 0; } ul, ol { padding-left: 24px; margin: 0.7em 0; } li { margin: 0.25em 0; } hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; } nav.toc { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px 28px; margin-bottom: 36px; } nav.toc .toc-title { font-size: 13px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); margin: 0 0 10px; } nav.toc ul { list-style: none; padding: 0; margin: 0; columns: 2; column-gap: 32px; } nav.toc li { margin: 4px 0; break-inside: avoid; font-size: 14px; } nav.toc li.toc-l3 { padding-left: 18px; font-size: 13px; color: var(--muted); } nav.toc a { color: var(--text); } nav.toc a:hover { color: var(--accent); text-decoration: none; } footer.doc-footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12.5px; text-align: center; } @media print { :root { --bg: #fff; --panel: #fff; --panel-2: #f0f2f5; --text: #222; --muted: #666; --border: #ccc; --code-bg: #f5f5f5; --row-alt: #f7f8fa; --quote-bg: #f0f6ff; } body { font-size: 12px; } .page { padding: 0; max-width: none; } header.doc-header, nav.toc { box-shadow: none; } pre, table { box-shadow: none; } } @media (max-width: 720px) { nav.toc ul { columns: 1; } .page { padding: 24px 14px 60px; } } """ # ---------------------------------------------------------------- inline ---- def inline(text: str) -> str: """Render inline markdown (code, bold, italic, links, strikethrough).""" text = html.escape(text, quote=False) # protect inline code spans first parts: list[str] = [] pos = 0 for m in re.finditer(r"`([^`]+)`", text): parts.append(text[pos:m.start()]) parts.append("%s" % m.group(1)) pos = m.end() parts.append(text[pos:]) text = "".join(parts) # links: [label](url) text = re.sub( r"\[([^\]]+)\]\(([^)\s]+)\)", lambda m: '%s' % (m.group(2), m.group(1)), text, ) # bold text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) text = re.sub(r"__(.+?)__", r"\1", text) # italic text = re.sub(r"(?\1", text) text = re.sub(r"(?\1", text) # strikethrough text = re.sub(r"~~(.+?)~~", r"\1", text) return text # ------------------------------------------------------------- headings ----- def slugify(text: str) -> str: s = re.sub(r"[`*_]", "", text) s = re.sub(r"[^\w\s-]", "", s).strip().lower() return re.sub(r"[\s]+", "-", s) def split_row(line: str) -> list[str]: line = line.strip() if line.startswith("|"): line = line[1:] if line.endswith("|"): line = line[:-1] return [c.strip() for c in line.split("|")] def is_table_sep(line: str) -> bool: return bool(re.fullmatch(r"\s*\|?\s*:?-{2,}:?(?:\s*\|\s*:?-{2,}:?)*\|?\s*", line)) # ------------------------------------------------------------- parse -------- def parse(markdown: str) -> tuple[str, list[tuple[int, str, str]]]: """Return (body_html, toc_items[(level, slug, text)]).""" lines = markdown.replace("\r\n", "\n").split("\n") out: list[str] = [] toc: list[tuple[int, str, str]] = [] i = 0 n = len(lines) while i < n: line = lines[i] # fenced code block fence = re.match(r"^```(\w*)\s*$", line) if fence: lang = fence.group(1) code = [] i += 1 while i < n and not re.match(r"^```\s*$", lines[i]): code.append(lines[i]) i += 1 i += 1 # closing fence cls = ' class="language-%s"' % html.escape(lang) if lang else "" out.append( "
%s
" % (cls, html.escape("\n".join(code), quote=False)) ) continue # heading h = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", line) if h: level = len(h.group(1)) text = h.group(2) slug = slugify(text) toc.append((level, slug, html.unescape(inline(text)))) out.append('%s' % (level, slug, inline(text), level)) i += 1 continue # horizontal rule if re.fullmatch(r"\s*([-*_])\s*(\1\s*){2,}", line): out.append("
") i += 1 continue # blockquote if line.lstrip().startswith(">"): quote = [] while i < n and lines[i].lstrip().startswith(">"): quote.append(re.sub(r"^\s*>\s?", "", lines[i])) i += 1 inner = parse("\n".join(quote))[0] out.append("
%s
" % inner) continue # table if ( line.lstrip().startswith("|") and i + 1 < n and lines[i + 1].lstrip().startswith("|") and is_table_sep(lines[i + 1]) ): header = split_row(line) i += 2 rows = [] while i < n and lines[i].lstrip().startswith("|"): rows.append(split_row(lines[i])) i += 1 thead = "".join( "%s" % inline(c) for c in header ) tbody_rows = [] for row in rows: tds = "".join( "%s" % inline(c) for c in (row + [""] * len(header))[: len(header)] ) tbody_rows.append("%s" % tds) out.append( '%s%s
' % (thead, "".join(tbody_rows)) ) continue # unordered list if re.match(r"^\s*[-*+]\s+", line): items = [] while i < n and re.match(r"^\s*[-*+]\s+", lines[i]): items.append(re.sub(r"^\s*[-*+]\s+", "", lines[i])) i += 1 out.append( "" % "".join("
  • %s
  • " % inline(x) for x in items) ) continue # ordered list if re.match(r"^\s*\d+[.)]\s+", line): items = [] while i < n and re.match(r"^\s*\d+[.)]\s+", lines[i]): items.append(re.sub(r"^\s*\d+[.)]\s+", "", lines[i])) i += 1 out.append( "
      %s
    " % "".join("
  • %s
  • " % inline(x) for x in items) ) continue # blank line if not line.strip(): i += 1 continue # paragraph: gather consecutive plain lines para = [line] i += 1 while i < n and lines[i].strip() and not ( re.match(r"^(#{1,6})\s", lines[i]) or lines[i].lstrip().startswith(("|", ">")) or re.match(r"^\s*[-*+]\s+", lines[i]) or re.match(r"^\s*\d+[.)]\s+", lines[i]) or re.match(r"^```", lines[i]) or re.fullmatch(r"\s*([-*_])\s*(\1\s*){2,}", lines[i]) ): para.append(lines[i]) i += 1 out.append("

    %s

    " % "
    ".join(inline(p) for p in para)) return "\n".join(out), toc # --------------------------------------------------------------- render ----- def render(markdown: str, source_name: str) -> str: body, toc = parse(markdown) title_m = re.search(r"^#\s+(.+)$", markdown, re.M) title = html.escape(title_m.group(1).strip()) if title_m else source_name toc_html = "" if toc: items = [] for level, slug, text in toc: cls = ' class="toc-l%d"' % level if level >= 3 else "" items.append('%s' % (cls, slug, text)) toc_html = '' % "".join(items) return """ {title}

    {title}

    Generated from {source}
    {toc}
    {body}
    """.format( title=title, css=CSS, source=html.escape(source_name, quote=False), toc=toc_html, body=body, ) # -------------------------------------------------------------------- main -- def convert(exp_path: Path) -> Path: txt_text = exp_to_txt(exp_path) md_text = gen_report(txt_text, str(exp_path)) html_doc = render(md_text, str(exp_path)) html_path = exp_path.with_suffix(".html") html_path.write_text(html_doc, encoding="utf-8") return html_path def main() -> int: ap = argparse.ArgumentParser( description="Convert a SonicWall .exp file to a styled HTML report.") ap.add_argument("exp", help="path to the .exp file") args = ap.parse_args() exp_path = Path(args.exp) if not exp_path.is_file(): print("error: no such file: %s" % exp_path, file=sys.stderr) return 1 html_path = convert(exp_path) print("wrote %s" % html_path) return 0 if __name__ == "__main__": sys.exit(main())