etherlyzer 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
etherlyzer/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("etherlyzer")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0+unknown"
etherlyzer/exporter.py ADDED
@@ -0,0 +1,31 @@
1
+ from etherlyzer.util.settings import config
2
+ from etherlyzer.formatters import MACFormatter
3
+
4
+ from etherlyzer.ieee.registry import IEEEEntry
5
+
6
+
7
+ class RegistryMatchExporter:
8
+ @staticmethod
9
+ def export_csv_to_console(entries: list[IEEEEntry]):
10
+ for entry in entries:
11
+ try:
12
+ print(
13
+ f"{entry.registry}{config.field_separator}"
14
+ f"{MACFormatter.format_default(entry.assignment)}{config.field_separator}"
15
+ f"{entry.organization_name}{config.field_separator}"
16
+ f"{entry.organization_address}"
17
+ f""
18
+ )
19
+ except AttributeError():
20
+ # Lookup not found.
21
+ pass
22
+
23
+ @staticmethod
24
+ def export_humanized_to_console(entries: list[IEEEEntry]):
25
+ for entry in entries:
26
+ mac = MACFormatter.format_default(entry.assignment)
27
+ try:
28
+ print(f"{entry.registry}: {mac} {entry.organization_name}")
29
+ except AttributeError():
30
+ # Lookup not found.
31
+ pass
@@ -0,0 +1,154 @@
1
+ from enum import StrEnum
2
+ from typing import Literal
3
+
4
+ from etherlyzer.util.settings import config
5
+ from string import punctuation
6
+
7
+
8
+ class MacMaskLetter(StrEnum):
9
+ EXCESS_INVALID = "I"
10
+ INVALID = "i"
11
+ EXCESS_HEX = "V"
12
+ HEX = "v"
13
+ EXCESS_RELAXED_TYPO = "R"
14
+ RELAXED_TYPO = "r"
15
+ EXCESS_LENIENT_TYPO = "L"
16
+ LENIENT_TYPO = "l"
17
+ PUNCTUATION = "p"
18
+ SEPERATOR = "-"
19
+
20
+
21
+ class MACFormatter:
22
+ HEX_DIGITS = frozenset("0123456789ABCDEFabcdef")
23
+ VALID_SEPERATORS = frozenset(" -.:")
24
+ STRICT_TYPOS: dict[str, str] = {" ": "", "\t": ""}
25
+ RELAXED_TYPOS: dict[str, str] = { # noqa: RUF012
26
+ **STRICT_TYPOS,
27
+ "o": "0",
28
+ "O": "0",
29
+ }
30
+
31
+ LENIENT_TYPOS: dict[str, str] = { # noqa: RUF012
32
+ **RELAXED_TYPOS,
33
+ "i": "1",
34
+ "I": "1",
35
+ "l": "1",
36
+ "L": "1",
37
+ "s": "5",
38
+ "S": "5",
39
+ "ø": "0",
40
+ "Ø": "0",
41
+ }
42
+
43
+ @classmethod
44
+ def format_default(cls, mac: str) -> str:
45
+ mac = cls.normalize(mac)
46
+ mac = config.mac_separator.join(
47
+ mac[i: i + config.mac_block_size] for i in range(0, len(mac), config.mac_block_size)
48
+ )
49
+
50
+ return getattr(mac, config.mac_case.value)()
51
+
52
+ @classmethod
53
+ def format_with_stuffed_hex(cls, mac: str, stuffed_hex: str) -> str:
54
+ mac = (cls.normalize(mac) + 12 * stuffed_hex)[:12]
55
+ return cls.format_default(mac)
56
+
57
+ @classmethod
58
+ def format_custom(
59
+ cls,
60
+ mac: str,
61
+ block_size: int,
62
+ separator: Literal[".", ":", "-"],
63
+ case: Literal["upper", "lower"],
64
+ fix_typos: bool,
65
+ ) -> str:
66
+ if fix_typos:
67
+ mac = cls.fix_typos(mac)
68
+ mac = cls.normalize(mac)
69
+ if len(mac) > 12:
70
+ raise ValueError(f"Malformed MAC address is of length: {len(mac)}!")
71
+ mac = separator.join(mac[i: i + block_size] for i in range(0, len(mac), block_size))
72
+ if case == "upper":
73
+ return mac.upper()
74
+ return mac.lower()
75
+
76
+ @classmethod
77
+ def validate(cls, in_addr: str) -> tuple[str, str]:
78
+ normalized_mac = ""
79
+ validation_mask = ""
80
+ valid_letter_count = 0
81
+ typo_count = 0
82
+
83
+ def valid_norm_mac_check():
84
+ return valid_letter_count + typo_count < 12
85
+
86
+ for idx, ch in enumerate(in_addr):
87
+ if ch in cls.HEX_DIGITS:
88
+ if valid_norm_mac_check():
89
+ normalized_mac += ch.lower()
90
+ validation_mask += MacMaskLetter.HEX
91
+ else:
92
+ normalized_mac += ch.lower()
93
+ validation_mask += MacMaskLetter.EXCESS_HEX
94
+ valid_letter_count += 1
95
+ elif ch in cls.VALID_SEPERATORS:
96
+ pass
97
+ elif ch in punctuation:
98
+ pass
99
+ elif ch in cls.RELAXED_TYPOS:
100
+ if valid_norm_mac_check():
101
+ normalized_mac += ch
102
+ validation_mask += MacMaskLetter.RELAXED_TYPO
103
+ else:
104
+ normalized_mac += ch
105
+ validation_mask += MacMaskLetter.EXCESS_RELAXED_TYPO
106
+ typo_count += 1
107
+ elif ch in cls.LENIENT_TYPOS:
108
+ if valid_norm_mac_check():
109
+ normalized_mac += ch
110
+ validation_mask += MacMaskLetter.LENIENT_TYPO
111
+ else:
112
+ normalized_mac += ch
113
+ validation_mask += MacMaskLetter.EXCESS_LENIENT_TYPO
114
+ typo_count += 1
115
+ else:
116
+ if valid_norm_mac_check():
117
+ normalized_mac += ch
118
+ validation_mask += MacMaskLetter.INVALID
119
+ else:
120
+ normalized_mac += ch
121
+ validation_mask += MacMaskLetter.EXCESS_INVALID
122
+ if len(validation_mask) < 12:
123
+ validation_mask = validation_mask.ljust(12, "M")
124
+ return normalized_mac, validation_mask
125
+
126
+ @classmethod
127
+ def mask_is_valid(cls, mask: str) -> bool:
128
+ return len(mask) == 12 and all([ch == "v" for ch in mask])
129
+
130
+ @classmethod
131
+ def normalize(cls, mac: str) -> str:
132
+ mac = "".join(c for c in mac.lower() if c in cls.HEX_DIGITS)
133
+ return mac
134
+
135
+ @classmethod
136
+ def fix_typos(cls, mac: str) -> str:
137
+ for c in mac:
138
+ if c in cls.LENIENT_TYPOS:
139
+ mac = mac.replace(c, cls.LENIENT_TYPOS[c])
140
+ elif c not in cls.HEX_DIGITS:
141
+ mac = mac.replace(c, "#")
142
+ return mac
143
+
144
+
145
+ if __name__ == "__main__":
146
+ print(
147
+ MACFormatter().format_custom(
148
+ mac="ab:cd:ef:fg:de:dd",
149
+ separator=":",
150
+ block_size=2,
151
+ case="upper",
152
+ fix_typos=False,
153
+ )
154
+ )
File without changes
@@ -0,0 +1,130 @@
1
+ from etherlyzer.ieee.registry import RegCategory, IEEEEntry, EtherTypeEntry, IEEERegistry
2
+
3
+
4
+ class Catalog:
5
+ IEEE_REGISTRIES = { # noqa
6
+ "mal": IEEERegistry(
7
+ enabled=True,
8
+ name="MA-L",
9
+ model=IEEEEntry,
10
+ full_name="MAC Address Block Large",
11
+ legacy_name="OUI",
12
+ category=RegCategory.MAC,
13
+ prefix_bits=24,
14
+ address_bits=24,
15
+ address_count=16_777_216,
16
+ legacy=False,
17
+ description=(
18
+ "Large IEEE MAC address allocation. Formerly known as the "
19
+ "Organizationally Unique Identifier (OUI). Used by vendors "
20
+ "requiring large address spaces."
21
+ ),
22
+ url="https://standards-oui.ieee.org/oui/oui.csv",
23
+ ),
24
+ "mam": IEEERegistry(
25
+ enabled=True,
26
+ name="MA-M",
27
+ model=IEEEEntry,
28
+ full_name="MAC Address Block Medium",
29
+ legacy_name="OUI-28",
30
+ category=RegCategory.MAC,
31
+ prefix_bits=28,
32
+ address_bits=20,
33
+ address_count=1_048_576,
34
+ legacy=False,
35
+ description=(
36
+ "Medium-sized IEEE MAC address allocation intended for "
37
+ "organizations requiring fewer addresses than MA-L."
38
+ ),
39
+ url="https://standards-oui.ieee.org/oui28/mam.csv",
40
+ ),
41
+ "mas": IEEERegistry(
42
+ enabled=True,
43
+ name="MA-S",
44
+ model=IEEEEntry,
45
+ full_name="MAC Address Block Small",
46
+ legacy_name="OUI-36",
47
+ category=RegCategory.MAC,
48
+ prefix_bits=36,
49
+ address_bits=12,
50
+ address_count=4_096,
51
+ legacy=False,
52
+ description=(
53
+ "Small IEEE MAC address allocation for embedded devices, "
54
+ "IoT, industrial equipment, and smaller manufacturers."
55
+ ),
56
+ url="https://standards-oui.ieee.org/oui36/oui36.csv",
57
+ ),
58
+ "cid": IEEERegistry(
59
+ enabled=True,
60
+ name="CID",
61
+ model=IEEEEntry,
62
+ full_name="Company Identifier",
63
+ legacy_name=None,
64
+ category=RegCategory.IDENTIFIER,
65
+ legacy=False,
66
+ description=(
67
+ "Unique company identifiers assigned by IEEE. Identifies "
68
+ "organizations independently of MAC address allocations."
69
+ ),
70
+ url="https://standards-oui.ieee.org/cid/cid.csv",
71
+ ),
72
+ "iab": IEEERegistry(
73
+ enabled=True,
74
+ name="IAB",
75
+ model=IEEEEntry,
76
+ full_name="Individual Address Block",
77
+ legacy_name=None,
78
+ category=RegCategory.MAC,
79
+ prefix_bits=36,
80
+ address_bits=12,
81
+ address_count=4_096,
82
+ legacy=True,
83
+ description=(
84
+ "Legacy IEEE MAC address allocation scheme superseded by "
85
+ "MA-S. Retained for compatibility with older hardware."
86
+ ),
87
+ url="https://standards-oui.ieee.org/iab/iab.csv",
88
+ ),
89
+ "ethertype": IEEERegistry(
90
+ enabled=True,
91
+ name="EtherType",
92
+ model=EtherTypeEntry,
93
+ full_name="EtherType Registry",
94
+ legacy_name=None,
95
+ category=RegCategory.PROTOCOL,
96
+ legacy=False,
97
+ description=(
98
+ "Registry mapping EtherType values to Ethernet protocols, "
99
+ "including IPv4, IPv6, ARP, VLAN tagging, LLDP, MPLS, "
100
+ "802.1X, and many vendor-specific protocols."
101
+ ),
102
+ url="https://standards-oui.ieee.org/ethertype/eth.csv",
103
+ ),
104
+ }
105
+
106
+ @classmethod
107
+ def db_is_initialized(cls):
108
+ db_init_checks: list[bool] = []
109
+ for registry in cls.IEEE_REGISTRIES.values():
110
+ db_init_checks.append(registry.filepath.exists())
111
+ if not all([check for check in db_init_checks]):
112
+ return False
113
+ return True
114
+
115
+ @staticmethod
116
+ def get_registry(registry_name: str) -> IEEERegistry | None:
117
+ return Catalog.IEEE_REGISTRIES.get(registry_name, None)
118
+
119
+ def get_all_registries(self, check_for_updates=True) -> list[IEEERegistry]:
120
+ for registry in self.IEEE_REGISTRIES.values():
121
+ # If not checking for updates, we still need a present dataset.
122
+ if (check_for_updates and registry.need_updates()) or not self.db_is_initialized():
123
+
124
+ status = registry.save()
125
+ if status is False:
126
+ break
127
+ return list(self.IEEE_REGISTRIES.values())
128
+
129
+ def __getitem__(self, key):
130
+ return self.IEEE_REGISTRIES[key]
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from dataclasses import MISSING, fields
5
+ from pathlib import Path
6
+ from typing import Any, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class IEEERegistryReader:
12
+
13
+ @staticmethod
14
+ def normalize(name: str) -> str:
15
+ return (
16
+ name.strip()
17
+ .lower()
18
+ .replace("-", "_")
19
+ .replace(" ", "_")
20
+ )
21
+
22
+ @staticmethod
23
+ def convert(value: str, typ: type) -> Any:
24
+ if value == "":
25
+ return None
26
+
27
+ if typ is int:
28
+ return int(value)
29
+
30
+ if typ is float:
31
+ return float(value)
32
+
33
+ if typ is bool:
34
+ return value.lower() in {
35
+ "true",
36
+ "1",
37
+ "yes",
38
+ }
39
+
40
+ return value
41
+
42
+ @classmethod
43
+ def load(cls, path: str | Path, model: type[T]) -> list[T]:
44
+ model_fields = {
45
+ cls.normalize(field.name): field
46
+ for field in fields(model)
47
+ }
48
+
49
+ objects: list[T] = []
50
+
51
+ with open(path, newline="", encoding="utf-8") as f:
52
+ reader = csv.DictReader(f)
53
+
54
+ for row in reader:
55
+ kwargs = {}
56
+ for header, value in row.items():
57
+ field_name = cls.normalize(header)
58
+ field = model_fields.get(field_name)
59
+
60
+ if field is None:
61
+ continue
62
+ kwargs[field.name] = cls.convert(
63
+ value,
64
+ field.type,
65
+ )
66
+
67
+ cls.validate(model_fields, kwargs)
68
+ objects.append(model(**kwargs))
69
+
70
+ return objects
71
+
72
+ @staticmethod
73
+ def validate(model_fields, kwargs):
74
+ missing = [
75
+ field.name
76
+ for field in model_fields.values()
77
+ if (field.name not in kwargs and field.default is MISSING and field.default_factory is MISSING)
78
+ ]
79
+
80
+ if missing: raise ValueError(f"Missing required fields: {', '.join(missing)}")
@@ -0,0 +1,199 @@
1
+ import re
2
+ from collections.abc import Iterable
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from time import sleep
6
+ from typing import TypeVar
7
+
8
+ from etherlyzer.ieee.registry import RegCategory, IEEEEntry, EtherTypeEntry, IEEERegistry
9
+
10
+ HEX_DIGITS = frozenset("0123456789ABCDEF")
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class MACIndex:
17
+ prefixes_36: dict[str, IEEEEntry] = field(default_factory=dict)
18
+ prefixes_28: dict[str, IEEEEntry] = field(default_factory=dict)
19
+ prefixes_24: dict[str, IEEEEntry] = field(default_factory=dict)
20
+
21
+ def insert(self, registry: IEEERegistry, entries: Iterable[IEEEEntry]) -> None:
22
+ match registry.prefix_bits:
23
+ case 36:
24
+ target = self.prefixes_36
25
+ case 28:
26
+ target = self.prefixes_28
27
+ case 24:
28
+ target = self.prefixes_24
29
+ case _:
30
+ raise ValueError(f"Unsupported prefix length: {registry.prefix_bits}")
31
+
32
+ target.update({entry.assignment.upper(): entry for entry in entries})
33
+
34
+ def lookup(self, mac: str) -> IEEEEntry | None:
35
+ mac = "".join(c for c in mac.upper() if c in HEX_DIGITS)
36
+
37
+ # Order is important - longest prefix match
38
+ return (
39
+ self.prefixes_36.get(mac[:9])
40
+ or self.prefixes_28.get(mac[:7])
41
+ or self.prefixes_24.get(mac[:6])
42
+ )
43
+
44
+ def walk(self):
45
+ yield from self.prefixes_36
46
+ yield from self.prefixes_28
47
+ yield from self.prefixes_24
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class VendorIndex:
52
+ vendors: dict[str, list[IEEEEntry]] = field(default_factory=dict)
53
+
54
+ @staticmethod
55
+ def normalize_vendor_name(name: str) -> str:
56
+ name = name.casefold()
57
+ name = re.sub(r"[.,/\\()\-_'\"&]+", " ", name)
58
+ name = re.sub(r"\s+", " ", name)
59
+ return name.strip()
60
+
61
+ def insert(self, entry: IEEEEntry) -> None:
62
+ key = self.normalize_vendor_name(entry.organization_name)
63
+ self.vendors.setdefault(key, []).append(entry)
64
+
65
+ def lookup(self, vendor: str) -> list[IEEEEntry] | None:
66
+ ieee_mac_entries = self.vendors.get(self.normalize_vendor_name(vendor))
67
+ ieee_mac_entries.sort(
68
+ key=lambda entry: int(
69
+ "".join(c for c in entry.assignment if c in "0123456789ABCDEFabcdef"),
70
+ 16,
71
+ )
72
+ )
73
+ return ieee_mac_entries
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class ProtocolIndex:
78
+ ethertype: dict[str, EtherTypeEntry] = field(default_factory=dict)
79
+
80
+ def insert(
81
+ self,
82
+ registry: IEEERegistry,
83
+ entries: Iterable[EtherTypeEntry],
84
+ ) -> None:
85
+ self.ethertype.update({entry.assignment.upper(): entry for entry in entries})
86
+
87
+ def lookup(self, ethertype: str) -> EtherTypeEntry | None:
88
+ ethertype = "".join(c for c in ethertype.upper() if c in HEX_DIGITS)
89
+ return self.ethertype.get(ethertype)
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class IdentifierIndex:
94
+ cid: dict[str, IEEEEntry] = field(default_factory=dict)
95
+ opid: dict[str, IEEEEntry] = field(default_factory=dict)
96
+ manid: dict[str, IEEEEntry] = field(default_factory=dict)
97
+
98
+ def insert(self, registry: IEEERegistry, entries: Iterable[IEEEEntry]) -> None:
99
+ match registry.name:
100
+ case "CID":
101
+ target = self.cid
102
+ case "OPID":
103
+ target = self.opid
104
+ case "MANID":
105
+ target = self.manid
106
+ case _:
107
+ raise ValueError(f"Unsupported identifier registry: {registry.name}")
108
+
109
+ target.update({entry.assignment.upper(): entry for entry in entries})
110
+
111
+ def lookup(self, identifier: str) -> IEEEEntry | None:
112
+ identifier = identifier.upper()
113
+
114
+ return self.cid.get(identifier) or self.opid.get(identifier) or self.manid.get(identifier)
115
+
116
+
117
+ @dataclass(slots=True)
118
+ class IEEEIndex:
119
+ mac_index: MACIndex = field(default_factory=MACIndex)
120
+ protocol_index: ProtocolIndex = field(default_factory=ProtocolIndex)
121
+ identifier_index: IdentifierIndex = field(default_factory=IdentifierIndex)
122
+ vendor_index: VendorIndex = field(default_factory=VendorIndex)
123
+
124
+ @classmethod
125
+ def from_registries(
126
+ cls,
127
+ registries: Iterable[IEEERegistry],
128
+ ) -> IEEEIndex:
129
+
130
+ index = cls()
131
+
132
+ for registry in registries:
133
+ entries = registry.load()
134
+
135
+ match registry.category:
136
+ case RegCategory.MAC:
137
+ index.mac_index.insert(registry, entries)
138
+
139
+ case RegCategory.PROTOCOL:
140
+ index.protocol_index.insert(registry, entries)
141
+
142
+ case RegCategory.IDENTIFIER:
143
+ index.identifier_index.insert(registry, entries)
144
+
145
+ case _:
146
+ raise ValueError(f"Unsupported registry category: {registry.category}")
147
+ mac_walker = index.mac_index.walk()
148
+ v_index = VendorIndex()
149
+
150
+ while True:
151
+ try:
152
+ entry = index.get_from_mac_index(next(mac_walker))
153
+ if entry is not None:
154
+ v_index.insert(entry)
155
+ except StopIteration:
156
+ break
157
+ index.vendor_index = v_index
158
+ return index
159
+
160
+ def get_from_mac_index(self, mac: str) -> IEEEEntry | None:
161
+ return self.mac_index.lookup(mac)
162
+
163
+ def get_from_ethertype_index(self, ethertype: str) -> EtherTypeEntry | None:
164
+ return self.protocol_index.lookup(ethertype)
165
+
166
+ def get_identifier(self, identifier: str) -> IEEEEntry | None:
167
+ return self.identifier_index.lookup(identifier)
168
+
169
+ @staticmethod
170
+ def get_bulk(
171
+ ieee_index: MACIndex | IdentifierIndex | ProtocolIndex,
172
+ path_or_text: Path | list[str],
173
+ ) -> list[IEEEEntry]:
174
+ # Pass a list[str] from interactive or pass a Path
175
+ if isinstance(path_or_text, Path):
176
+ with open(path_or_text) as f:
177
+ text_data = f.readlines()
178
+ else:
179
+ text_data = path_or_text
180
+ ieee_entries: set[IEEEEntry] = set()
181
+ for line in text_data:
182
+ ieee_entry = ieee_index.lookup(line)
183
+ if ieee_entry:
184
+ ieee_entries.add(ieee_entry)
185
+
186
+ ieee_entries: list[IEEEEntry] = list(ieee_entries)
187
+ # Sort by name, and then by registry-name reversed
188
+ ieee_entries = sorted(ieee_entries, key=lambda e: e.organization_name)
189
+ ieee_entries = sorted(ieee_entries, key=lambda e: e.registry, reverse=True)
190
+
191
+ return ieee_entries
192
+
193
+ @staticmethod
194
+ def get_single(indextype: VendorIndex | MACIndex | IdentifierIndex | ProtocolIndex, mac: str):
195
+ ieee_entry = indextype.lookup(mac)
196
+ if ieee_entry:
197
+ return ieee_entry
198
+ else:
199
+ return None