From 36fa08d711541f381c8053d466d6fe4df42f1e41 Mon Sep 17 00:00:00 2001 From: pettai Date: Sun, 1 Oct 2023 00:26:03 +0200 Subject: [PATCH] Add ISC 'host' support (#450) * Add ISC 'host' support Add ISC 'host' command support * Update host.py remove leading tab from string * Add integer conversion Per request, fix integer conversion * Cleanup Cleanup strip()'s * Add tests Add two tests for the 'host' parser * Update test_host.py nit --------- Co-authored-by: Kelly Brazil --- jc/lib.py | 1 + jc/parsers/host.py | 248 ++++++++++++++++++++++++ tests/fixtures/generic/host-google.json | 1 + tests/fixtures/generic/host-google.out | 3 + tests/fixtures/generic/host-sunet.json | 1 + tests/fixtures/generic/host-sunet.out | 16 ++ tests/test_host.py | 58 ++++++ 7 files changed, 328 insertions(+) create mode 100644 jc/parsers/host.py create mode 100644 tests/fixtures/generic/host-google.json create mode 100644 tests/fixtures/generic/host-google.out create mode 100644 tests/fixtures/generic/host-sunet.json create mode 100644 tests/fixtures/generic/host-sunet.out create mode 100644 tests/test_host.py diff --git a/jc/lib.py b/jc/lib.py index 8db37280..35a023c5 100644 --- a/jc/lib.py +++ b/jc/lib.py @@ -58,6 +58,7 @@ parsers: List[str] = [ 'hashsum', 'hciconfig', 'history', + 'host', 'hosts', 'id', 'ifconfig', diff --git a/jc/parsers/host.py b/jc/parsers/host.py new file mode 100644 index 00000000..c1a1a106 --- /dev/null +++ b/jc/parsers/host.py @@ -0,0 +1,248 @@ +"""jc - JSON Convert `host` command output parser + +Supports parsing of the most commonly used RR types (A, AAAA, MX, TXT) + +Usage (cli): + + $ host google.com | jc --host + +or + + $ jc host google.com + +Usage (module): + + import jc + result = jc.parse('host', host_command_output) + +Schema: + + [ + { + "hostname": string, + "address": [ + string + ], + "v6-address": [ + string + ], + "mail": [ + string + ] + } + ] + + [ + { + "nameserver": string, + "zone": string, + "mname": string, + "rname": string, + "serial": integer, + "refresh": integer, + "retry": integer, + "expire": integer, + "minimum": integer + } + ] + +Examples: + + $ host google.com | jc --host + [ + { + "hostname": "google.com", + "address": [ + "142.251.39.110" + ], + "v6-address": [ + "2a00:1450:400e:811::200e" + ], + "mail": [ + "smtp.google.com." + ] + } + ] + + $ jc host -C sunet.se + [ + { + "nameserver": "2001:6b0:7::2", + "zone": "sunet.se", + "mname": "sunic.sunet.se.", + "rname": "hostmaster.sunet.se.", + "serial": "2023090401", + "refresh": "28800", + "retry": "7200", + "expire": "604800", + "minimum": "300" + }, + { + ... + } + ] +""" +from typing import Dict, List +import jc.utils + + +class info(): + """Provides parser metadata (version, author, etc.)""" + version = '1.0' + description = '`host` command parser' + author = 'Pettai' + author_email = 'pettai@sunet.se' + # details = 'enter any other details here' + + # compatible options: linux, darwin, cygwin, win32, aix, freebsd + compatible = ['linux', 'darwin', 'cygwin', 'win32', 'aix', 'freebsd'] + + # tags options: generic, standard, file, string, binary, command + tags = ['command'] + magic_commands = ['host'] + + +__version__ = info.version + + +def _process(proc_data): + """ + Final processing to conform to the schema. + + Parameters: + + proc_data: (List of Dictionaries) raw structured data to process + + Returns: + + List of Dictionaries. Structured to conform to the schema. + """ + + int_list = {'serial', 'refresh', 'retry', 'expire', 'minimum'} + + for entry in proc_data: + for key in entry: + if key in int_list: + entry[key] = jc.utils.convert_to_int(entry[key]) + + return proc_data + + +def parse(data: str, raw: bool = False, quiet: bool = False): + """ + Main text parsing function + + Parameters: + + data: (string) text data to parse + raw: (boolean) unprocessed output if True + quiet: (boolean) suppress warning messages if True + + Returns: + + List of Dictionaries. Raw or processed structured data. + """ + jc.utils.compatibility(__name__, info.compatible, quiet) + jc.utils.input_type_check(data) + + raw_output: List[Dict] = [] + + warned = False + + if jc.utils.has_data(data): + + addresses = [] + v6addresses = [] + mail = [] + text = [] + rrdata = {} + soaparse = False + + for line in filter(None, data.splitlines()): + line = line.strip() + + # default + if ' has address ' in line: + linedata = line.split(' ', maxsplit=3) + hostname = linedata[0] + address = linedata[3] + addresses.append(address) + rrdata.update({'hostname': hostname}) + rrdata.update({'address': addresses}) + continue + + if ' has IPv6 address ' in line: + linedata = line.split(' ', maxsplit=4) + hostname = linedata[0] + v6address = linedata[4] + v6addresses.append(v6address) + rrdata.update({'hostname': hostname}) + rrdata.update({'v6-address': v6addresses}) + continue + + if ' mail is handled by ' in line: + linedata = line.split(' ', maxsplit=6) + hostname = linedata[0] + mx = linedata[6] + mail.append(mx) + rrdata.update({'hostname': hostname}) + rrdata.update({'mail': mail}) + continue + + + # TXT parsing + if ' descriptive text ' in line: + linedata = line.split('descriptive text "', maxsplit=1) + hostname = linedata[0] + txt = linedata[1].strip('"') + text.append(txt) + rrdata.update({'hostname': hostname}) + rrdata.update({'text': text}) + continue + + + # -C / SOA parsing + if line.startswith('Nameserver '): + soaparse = True + rrdata = {} + linedata = line.split(' ', maxsplit=1) + nameserverip = linedata[1].rstrip(':') + rrdata.update({'nameserver': nameserverip}) + continue + + if ' has SOA record ' in line: + linedata = line.split(' ', maxsplit=10) + + zone = linedata[0] + mname = linedata[4] + rname = linedata[5] + serial = linedata[6] + refresh = linedata[7] + retry = linedata[8] + expire = linedata[9] + minimum = linedata[10] + + try: + rrdata.update( + { + 'zone': zone, + 'mname': mname, + 'rname': rname, + 'serial': serial, + 'refresh': refresh, + 'retry': retry, + 'expire': expire, + 'minimum': minimum + }, + ) + raw_output.append(rrdata) + + except IndexError: + if not warned: + jc.utils.warning_message(['Unknown format detected.']) + warned = True + + if not soaparse: + raw_output.append(rrdata) + + return raw_output if raw else _process(raw_output) diff --git a/tests/fixtures/generic/host-google.json b/tests/fixtures/generic/host-google.json new file mode 100644 index 00000000..149bf4f9 --- /dev/null +++ b/tests/fixtures/generic/host-google.json @@ -0,0 +1 @@ +[{"hostname":"google.com","address":["142.250.179.206"],"v6-address":["2a00:1450:400e:811::200e"],"mail":["smtp.google.com."]}] diff --git a/tests/fixtures/generic/host-google.out b/tests/fixtures/generic/host-google.out new file mode 100644 index 00000000..009113f7 --- /dev/null +++ b/tests/fixtures/generic/host-google.out @@ -0,0 +1,3 @@ +google.com has address 142.250.179.206 +google.com has IPv6 address 2a00:1450:400e:811::200e +google.com mail is handled by 10 smtp.google.com. diff --git a/tests/fixtures/generic/host-sunet.json b/tests/fixtures/generic/host-sunet.json new file mode 100644 index 00000000..49f14529 --- /dev/null +++ b/tests/fixtures/generic/host-sunet.json @@ -0,0 +1 @@ +[{"nameserver":"192.36.125.2","zone":"sunet.se","mname":"hidden-master.sunet.se.","rname":"hostmaster.sunet.se.","serial":2023091102,"refresh":28800,"retry":7200,"expire":604800,"minimum":300},{"nameserver":"193.10.252.19","zone":"sunet.se","mname":"hidden-master.sunet.se.","rname":"hostmaster.sunet.se.","serial":2023091102,"refresh":28800,"retry":7200,"expire":604800,"minimum":300},{"nameserver":"2001:6b0:1::250","zone":"sunet.se","mname":"hidden-master.sunet.se.","rname":"hostmaster.sunet.se.","serial":2023091102,"refresh":28800,"retry":7200,"expire":604800,"minimum":300},{"nameserver":"2001:948:4:2::19","zone":"sunet.se","mname":"hidden-master.sunet.se.","rname":"hostmaster.sunet.se.","serial":2023091102,"refresh":28800,"retry":7200,"expire":604800,"minimum":300},{"nameserver":"2001:6b0:5a:4020::384","zone":"sunet.se","mname":"hidden-master.sunet.se.","rname":"hostmaster.sunet.se.","serial":2023091102,"refresh":28800,"retry":7200,"expire":604800,"minimum":300},{"nameserver":"130.237.72.250","zone":"sunet.se","mname":"hidden-master.sunet.se.","rname":"hostmaster.sunet.se.","serial":2023091102,"refresh":28800,"retry":7200,"expire":604800,"minimum":300},{"nameserver":"2001:6b0:7::2","zone":"sunet.se","mname":"hidden-master.sunet.se.","rname":"hostmaster.sunet.se.","serial":2023091102,"refresh":28800,"retry":7200,"expire":604800,"minimum":300},{"nameserver":"89.47.185.240","zone":"sunet.se","mname":"hidden-master.sunet.se.","rname":"hostmaster.sunet.se.","serial":2023091102,"refresh":28800,"retry":7200,"expire":604800,"minimum":300}] diff --git a/tests/fixtures/generic/host-sunet.out b/tests/fixtures/generic/host-sunet.out new file mode 100644 index 00000000..e8f6e178 --- /dev/null +++ b/tests/fixtures/generic/host-sunet.out @@ -0,0 +1,16 @@ +Nameserver 192.36.125.2: + sunet.se has SOA record hidden-master.sunet.se. hostmaster.sunet.se. 2023091102 28800 7200 604800 300 +Nameserver 193.10.252.19: + sunet.se has SOA record hidden-master.sunet.se. hostmaster.sunet.se. 2023091102 28800 7200 604800 300 +Nameserver 2001:6b0:1::250: + sunet.se has SOA record hidden-master.sunet.se. hostmaster.sunet.se. 2023091102 28800 7200 604800 300 +Nameserver 2001:948:4:2::19: + sunet.se has SOA record hidden-master.sunet.se. hostmaster.sunet.se. 2023091102 28800 7200 604800 300 +Nameserver 2001:6b0:5a:4020::384: + sunet.se has SOA record hidden-master.sunet.se. hostmaster.sunet.se. 2023091102 28800 7200 604800 300 +Nameserver 130.237.72.250: + sunet.se has SOA record hidden-master.sunet.se. hostmaster.sunet.se. 2023091102 28800 7200 604800 300 +Nameserver 2001:6b0:7::2: + sunet.se has SOA record hidden-master.sunet.se. hostmaster.sunet.se. 2023091102 28800 7200 604800 300 +Nameserver 89.47.185.240: + sunet.se has SOA record hidden-master.sunet.se. hostmaster.sunet.se. 2023091102 28800 7200 604800 300 diff --git a/tests/test_host.py b/tests/test_host.py new file mode 100644 index 00000000..272fb981 --- /dev/null +++ b/tests/test_host.py @@ -0,0 +1,58 @@ +import os +import unittest +import json +from typing import Dict +from jc.parsers.host import parse + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class MyTests(unittest.TestCase): + f_in: Dict = {} + f_json: Dict = {} + + @classmethod + def setUpClass(cls): + fixtures = { + 'google': ( + 'fixtures/generic/host-google.out', + 'fixtures/generic/host-google.json'), + 'sunet': ( + 'fixtures/generic/host-sunet.out', + 'fixtures/generic/host-sunet.json') + } + + for file, filepaths in fixtures.items(): + with open(os.path.join(THIS_DIR, filepaths[0]), 'r', encoding='utf-8') as a, \ + open(os.path.join(THIS_DIR, filepaths[1]), 'r', encoding='utf-8') as b: + cls.f_in[file] = a.read() + cls.f_json[file] = json.loads(b.read()) + +# host cannot run without input (will only display help) +# def test_host_nodata(self): +# """ +# Test 'host' with no data +# """ +# self.assertEqual(parse('', quiet=True), {}) + + + def test_host_google(self): + """ + Test 'host google' + """ + self.assertEqual( + parse(self.f_in['google'], quiet=True), + self.f_json['google'] + ) + + def test_host_sunet(self): + """ + Test 'host sunet' + """ + self.assertEqual( + parse(self.f_in['sunet'], quiet=True), + self.f_json['sunet'] + ) + +if __name__ == '__main__': + unittest.main()