os-normalizer 0.4.2__py3-none-any.whl → 0.5.0__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.
Potentially problematic release.
This version of os-normalizer might be problematic. Click here for more details.
- os_normalizer/constants.py +139 -2
- os_normalizer/cpe.py +57 -12
- os_normalizer/helpers.py +26 -15
- os_normalizer/models.py +19 -9
- os_normalizer/os_normalizer.py +76 -49
- os_normalizer/parsers/__init__.py +7 -3
- os_normalizer/parsers/bsd.py +2 -1
- os_normalizer/parsers/esxi.py +83 -0
- os_normalizer/parsers/linux.py +6 -5
- os_normalizer/parsers/macos.py +12 -14
- os_normalizer/parsers/mobile.py +23 -5
- os_normalizer/parsers/network/__init__.py +5 -1
- os_normalizer/parsers/network/cisco.py +13 -7
- os_normalizer/parsers/network/fortinet.py +15 -5
- os_normalizer/parsers/network/huawei.py +9 -3
- os_normalizer/parsers/network/juniper.py +10 -4
- os_normalizer/parsers/network/netgear.py +10 -3
- os_normalizer/parsers/solaris.py +100 -0
- os_normalizer/parsers/windows.py +39 -23
- {os_normalizer-0.4.2.dist-info → os_normalizer-0.5.0.dist-info}/METADATA +2 -1
- os_normalizer-0.5.0.dist-info/RECORD +24 -0
- os_normalizer-0.4.2.dist-info/RECORD +0 -22
- {os_normalizer-0.4.2.dist-info → os_normalizer-0.5.0.dist-info}/WHEEL +0 -0
- {os_normalizer-0.4.2.dist-info → os_normalizer-0.5.0.dist-info}/licenses/LICENSE +0 -0
os_normalizer/constants.py
CHANGED
|
@@ -1,19 +1,156 @@
|
|
|
1
1
|
"""Constants and static lookup tables for the OS fingerprinting package."""
|
|
2
2
|
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OSFamily(StrEnum):
|
|
7
|
+
"""Canonical OS family identifiers used throughout the project."""
|
|
8
|
+
|
|
9
|
+
ANDROID = "android"
|
|
10
|
+
BSD = "bsd"
|
|
11
|
+
ESXI = "esxi"
|
|
12
|
+
HARMONYOS = "harmonyos"
|
|
13
|
+
IOS = "ios"
|
|
14
|
+
LINUX = "linux"
|
|
15
|
+
MACOS = "macos"
|
|
16
|
+
NETWORK = "network-os"
|
|
17
|
+
SOLARIS = "solaris"
|
|
18
|
+
WINDOWS = "windows"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PrecisionLevel(StrEnum):
|
|
22
|
+
"""Granularity levels for parsed OS version information."""
|
|
23
|
+
|
|
24
|
+
UNKNOWN = "unknown"
|
|
25
|
+
FAMILY = "family"
|
|
26
|
+
PRODUCT = "product"
|
|
27
|
+
MAJOR = "major"
|
|
28
|
+
MINOR = "minor"
|
|
29
|
+
PATCH = "patch"
|
|
30
|
+
BUILD = "build"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PRECISION_ORDER = {
|
|
34
|
+
PrecisionLevel.BUILD: 6,
|
|
35
|
+
PrecisionLevel.PATCH: 5,
|
|
36
|
+
PrecisionLevel.MINOR: 4,
|
|
37
|
+
PrecisionLevel.MAJOR: 3,
|
|
38
|
+
PrecisionLevel.PRODUCT: 2,
|
|
39
|
+
PrecisionLevel.FAMILY: 1,
|
|
40
|
+
PrecisionLevel.UNKNOWN: 0,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Architecture tokens recognised across parsers.
|
|
45
|
+
ARCHITECTURE_TOKENS = (
|
|
46
|
+
"x86_64",
|
|
47
|
+
"amd64",
|
|
48
|
+
"x64",
|
|
49
|
+
"x86",
|
|
50
|
+
"ia32",
|
|
51
|
+
"i386",
|
|
52
|
+
"i486",
|
|
53
|
+
"i586",
|
|
54
|
+
"i686",
|
|
55
|
+
"arm64",
|
|
56
|
+
"aarch64",
|
|
57
|
+
"arm64e",
|
|
58
|
+
"armv8",
|
|
59
|
+
"armv8l",
|
|
60
|
+
"arm",
|
|
61
|
+
"armv7",
|
|
62
|
+
"armv7l",
|
|
63
|
+
"armv7hf",
|
|
64
|
+
"armv6",
|
|
65
|
+
"armhf",
|
|
66
|
+
"armel",
|
|
67
|
+
"ppc64le",
|
|
68
|
+
"powerpc64le",
|
|
69
|
+
"ppc64",
|
|
70
|
+
"powerpc64",
|
|
71
|
+
"ppc",
|
|
72
|
+
"ppc32",
|
|
73
|
+
"ppcle",
|
|
74
|
+
"powerpc",
|
|
75
|
+
"sparc",
|
|
76
|
+
"sparc64",
|
|
77
|
+
"sparcv9",
|
|
78
|
+
"sun4u",
|
|
79
|
+
"sun4v",
|
|
80
|
+
"mips",
|
|
81
|
+
"mips32",
|
|
82
|
+
"mipsel",
|
|
83
|
+
"mips64",
|
|
84
|
+
"mips64el",
|
|
85
|
+
"mips64le",
|
|
86
|
+
"s390x",
|
|
87
|
+
"s390",
|
|
88
|
+
"riscv64",
|
|
89
|
+
"riscv",
|
|
90
|
+
"loongarch64",
|
|
91
|
+
"tilegx",
|
|
92
|
+
"alpha",
|
|
93
|
+
"ia64",
|
|
94
|
+
"itanium",
|
|
95
|
+
"hppa",
|
|
96
|
+
"parisc",
|
|
97
|
+
"m68k",
|
|
98
|
+
)
|
|
99
|
+
|
|
3
100
|
# Architecture synonyms
|
|
4
101
|
ARCH_SYNONYMS = {
|
|
5
|
-
"x64": "x86_64",
|
|
6
102
|
"x86_64": "x86_64",
|
|
7
103
|
"amd64": "x86_64",
|
|
104
|
+
"x64": "x86_64",
|
|
8
105
|
"x86": "x86",
|
|
106
|
+
"ia32": "x86",
|
|
9
107
|
"i386": "x86",
|
|
108
|
+
"i486": "x86",
|
|
109
|
+
"i586": "x86",
|
|
10
110
|
"i686": "x86",
|
|
11
|
-
"aarch64": "arm64",
|
|
12
111
|
"arm64": "arm64",
|
|
112
|
+
"aarch64": "arm64",
|
|
113
|
+
"arm64e": "arm64",
|
|
13
114
|
"armv8": "arm64",
|
|
115
|
+
"armv8l": "arm64",
|
|
116
|
+
"arm": "arm",
|
|
14
117
|
"armv7": "arm",
|
|
15
118
|
"armv7l": "arm",
|
|
119
|
+
"armv7hf": "arm",
|
|
120
|
+
"armv6": "arm",
|
|
121
|
+
"armhf": "arm",
|
|
122
|
+
"armel": "arm",
|
|
16
123
|
"ppc64le": "ppc64le",
|
|
124
|
+
"powerpc64le": "ppc64le",
|
|
125
|
+
"ppc64": "ppc64",
|
|
126
|
+
"powerpc64": "ppc64",
|
|
127
|
+
"ppc": "ppc",
|
|
128
|
+
"ppc32": "ppc",
|
|
129
|
+
"ppcle": "ppc",
|
|
130
|
+
"powerpc": "ppc",
|
|
131
|
+
"sparc": "sparc",
|
|
132
|
+
"sparc64": "sparc",
|
|
133
|
+
"sparcv9": "sparc",
|
|
134
|
+
"sun4u": "sparc",
|
|
135
|
+
"sun4v": "sparc",
|
|
136
|
+
"mips": "mips",
|
|
137
|
+
"mips32": "mips",
|
|
138
|
+
"mipsel": "mips",
|
|
139
|
+
"mips64": "mips64",
|
|
140
|
+
"mips64el": "mips64",
|
|
141
|
+
"mips64le": "mips64",
|
|
142
|
+
"s390x": "s390x",
|
|
143
|
+
"s390": "s390x",
|
|
144
|
+
"riscv64": "riscv64",
|
|
145
|
+
"riscv": "riscv64",
|
|
146
|
+
"loongarch64": "loongarch64",
|
|
147
|
+
"tilegx": "tilegx",
|
|
148
|
+
"alpha": "alpha",
|
|
149
|
+
"ia64": "ia64",
|
|
150
|
+
"itanium": "ia64",
|
|
151
|
+
"hppa": "parisc",
|
|
152
|
+
"parisc": "parisc",
|
|
153
|
+
"m68k": "m68k",
|
|
17
154
|
}
|
|
18
155
|
|
|
19
156
|
# Windows build map (build number range -> product name, marketing channel)
|
os_normalizer/cpe.py
CHANGED
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from typing import TYPE_CHECKING
|
|
11
11
|
|
|
12
|
-
from os_normalizer.constants import WINDOWS_BUILD_MAP
|
|
12
|
+
from os_normalizer.constants import OSFamily, WINDOWS_BUILD_MAP
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from .models import OSData
|
|
@@ -39,12 +39,19 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
|
|
|
39
39
|
|
|
40
40
|
strategy controls version/update selection rules.
|
|
41
41
|
"""
|
|
42
|
-
|
|
42
|
+
raw_family = p.family
|
|
43
|
+
family = (
|
|
44
|
+
raw_family
|
|
45
|
+
if isinstance(raw_family, OSFamily)
|
|
46
|
+
else OSFamily._value2member_map_.get(str(raw_family).lower())
|
|
47
|
+
if raw_family
|
|
48
|
+
else None
|
|
49
|
+
)
|
|
43
50
|
vendor = (p.vendor or "").lower() if p.vendor else None
|
|
44
51
|
product = (p.product or "").lower() if p.product else ""
|
|
45
52
|
|
|
46
53
|
# Windows
|
|
47
|
-
if
|
|
54
|
+
if family == OSFamily.WINDOWS:
|
|
48
55
|
vtok = "microsoft"
|
|
49
56
|
prod_map = {
|
|
50
57
|
"windows 7": "windows_7",
|
|
@@ -68,11 +75,11 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
|
|
|
68
75
|
return vtok, base, "windows"
|
|
69
76
|
|
|
70
77
|
# macOS
|
|
71
|
-
if
|
|
78
|
+
if family == OSFamily.MACOS:
|
|
72
79
|
return "apple", "macos", "macos"
|
|
73
80
|
|
|
74
81
|
# Linux distros (use distro when present)
|
|
75
|
-
if
|
|
82
|
+
if family == OSFamily.LINUX:
|
|
76
83
|
d = (p.distro or "").lower()
|
|
77
84
|
if d == "ubuntu":
|
|
78
85
|
return "canonical", "ubuntu_linux", "ubuntu"
|
|
@@ -93,7 +100,7 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
|
|
|
93
100
|
return vendor or "linux", product or "linux", "linux"
|
|
94
101
|
|
|
95
102
|
# BSDs
|
|
96
|
-
if
|
|
103
|
+
if family == OSFamily.BSD:
|
|
97
104
|
if product and "freebsd" in product:
|
|
98
105
|
return "freebsd", "freebsd", "freebsd"
|
|
99
106
|
if product and "openbsd" in product:
|
|
@@ -102,8 +109,14 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
|
|
|
102
109
|
return "netbsd", "netbsd", "netbsd"
|
|
103
110
|
return vendor or "bsd", product or "bsd", "bsd"
|
|
104
111
|
|
|
112
|
+
if family == OSFamily.SOLARIS:
|
|
113
|
+
return "oracle", "solaris", "solaris"
|
|
114
|
+
|
|
115
|
+
if family == OSFamily.ESXI:
|
|
116
|
+
return "vmware", "esxi", "esxi"
|
|
117
|
+
|
|
105
118
|
# Network OS
|
|
106
|
-
if
|
|
119
|
+
if family == OSFamily.NETWORK:
|
|
107
120
|
if vendor == "cisco":
|
|
108
121
|
if product and ("ios xe" in product or "ios-xe" in product):
|
|
109
122
|
return "cisco", "ios_xe", "ios_xe"
|
|
@@ -120,13 +133,20 @@ def _map_vendor_product(p: OSData) -> tuple[str, str, str]:
|
|
|
120
133
|
return vendor or "network", (product or "firmware").replace(" ", "_"), "firmware"
|
|
121
134
|
|
|
122
135
|
# Mobile
|
|
123
|
-
if
|
|
124
|
-
return "google", "android",
|
|
125
|
-
if
|
|
126
|
-
return "apple", "iphone_os",
|
|
136
|
+
if family == OSFamily.ANDROID:
|
|
137
|
+
return "google", "android", OSFamily.ANDROID.value
|
|
138
|
+
if family == OSFamily.IOS:
|
|
139
|
+
return "apple", "iphone_os", OSFamily.IOS.value
|
|
140
|
+
if family == OSFamily.HARMONYOS:
|
|
141
|
+
return "huawei", "harmonyos", OSFamily.HARMONYOS.value
|
|
127
142
|
|
|
128
143
|
# Fallback
|
|
129
|
-
|
|
144
|
+
family_value = family.value if family else (str(raw_family).lower() if raw_family else "unknown")
|
|
145
|
+
return (
|
|
146
|
+
vendor or family_value or "unknown",
|
|
147
|
+
(product or family_value).replace(" ", "_"),
|
|
148
|
+
family_value,
|
|
149
|
+
)
|
|
130
150
|
|
|
131
151
|
|
|
132
152
|
def _fmt_version(p: OSData, strategy: str) -> tuple[str, str, str]:
|
|
@@ -192,6 +212,18 @@ def _fmt_version(p: OSData, strategy: str) -> tuple[str, str, str]:
|
|
|
192
212
|
ver = "*"
|
|
193
213
|
return ver, "*", (edition or "*")
|
|
194
214
|
|
|
215
|
+
if strategy in ("solaris", "esxi"):
|
|
216
|
+
if maj is not None:
|
|
217
|
+
ver = str(maj)
|
|
218
|
+
if minr is not None:
|
|
219
|
+
ver = f"{ver}.{minr}"
|
|
220
|
+
if pat is not None:
|
|
221
|
+
ver = f"{ver}.{pat}"
|
|
222
|
+
else:
|
|
223
|
+
ver = "*"
|
|
224
|
+
update = build or "*"
|
|
225
|
+
return ver, update, "*"
|
|
226
|
+
|
|
195
227
|
if strategy == "fortios":
|
|
196
228
|
if maj is not None and minr is not None and pat is not None:
|
|
197
229
|
ver = f"{maj}.{minr}.{pat}"
|
|
@@ -216,6 +248,19 @@ def _fmt_version(p: OSData, strategy: str) -> tuple[str, str, str]:
|
|
|
216
248
|
ver = "*"
|
|
217
249
|
return ver, "*", "*"
|
|
218
250
|
|
|
251
|
+
if strategy == "harmonyos":
|
|
252
|
+
if maj is not None:
|
|
253
|
+
if minr is not None and pat is not None:
|
|
254
|
+
ver = f"{maj}.{minr}.{pat}"
|
|
255
|
+
elif minr is not None:
|
|
256
|
+
ver = f"{maj}.{minr}"
|
|
257
|
+
else:
|
|
258
|
+
ver = f"{maj}"
|
|
259
|
+
else:
|
|
260
|
+
ver = "*"
|
|
261
|
+
update = build or "*"
|
|
262
|
+
return ver, update, "*"
|
|
263
|
+
|
|
219
264
|
# Generic fallback
|
|
220
265
|
if build:
|
|
221
266
|
ver = build
|
os_normalizer/helpers.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import re
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
from .constants import ARCH_SYNONYMS
|
|
6
|
+
from .constants import ARCH_SYNONYMS, ARCHITECTURE_TOKENS, PrecisionLevel
|
|
7
7
|
from .models import OSData
|
|
8
8
|
|
|
9
9
|
|
|
@@ -34,17 +34,17 @@ def precision_from_parts(
|
|
|
34
34
|
minor: int | None,
|
|
35
35
|
patch: int | None,
|
|
36
36
|
build: str | None,
|
|
37
|
-
) ->
|
|
37
|
+
) -> PrecisionLevel:
|
|
38
38
|
"""Derive a precision label from version components."""
|
|
39
39
|
if build:
|
|
40
|
-
return
|
|
40
|
+
return PrecisionLevel.BUILD
|
|
41
41
|
if patch is not None:
|
|
42
|
-
return
|
|
42
|
+
return PrecisionLevel.PATCH
|
|
43
43
|
if minor is not None:
|
|
44
|
-
return
|
|
44
|
+
return PrecisionLevel.MINOR
|
|
45
45
|
if major is not None:
|
|
46
|
-
return
|
|
47
|
-
return
|
|
46
|
+
return PrecisionLevel.MAJOR
|
|
47
|
+
return PrecisionLevel.PRODUCT
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def canonical_key(p: OSData) -> str:
|
|
@@ -61,7 +61,10 @@ def canonical_key(p: OSData) -> str:
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
# Regex for extracting an architecture token from free-form text
|
|
64
|
-
|
|
64
|
+
_ARCH_PATTERN = "|".join(
|
|
65
|
+
sorted((re.escape(token) for token in ARCHITECTURE_TOKENS), key=len, reverse=True)
|
|
66
|
+
)
|
|
67
|
+
ARCH_TEXT_RE = re.compile(rf"\b({_ARCH_PATTERN})\b", re.IGNORECASE)
|
|
65
68
|
|
|
66
69
|
|
|
67
70
|
def extract_arch_from_text(text: str) -> str | None:
|
|
@@ -92,16 +95,24 @@ def parse_os_release(blob_text: str) -> dict[str, Any]:
|
|
|
92
95
|
return out
|
|
93
96
|
|
|
94
97
|
|
|
95
|
-
def update_confidence(p: OSData, precision: str) -> None:
|
|
98
|
+
def update_confidence(p: OSData, precision: PrecisionLevel | str) -> None:
|
|
96
99
|
"""Boost confidence based on the determined precision level.
|
|
97
100
|
|
|
98
101
|
The mapping mirrors the original ad-hoc values used throughout the monolithic file.
|
|
99
102
|
"""
|
|
103
|
+
if isinstance(precision, PrecisionLevel):
|
|
104
|
+
level = precision
|
|
105
|
+
else:
|
|
106
|
+
try:
|
|
107
|
+
level = PrecisionLevel(str(precision))
|
|
108
|
+
except ValueError:
|
|
109
|
+
level = PrecisionLevel.UNKNOWN
|
|
110
|
+
|
|
100
111
|
boost_map = {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
PrecisionLevel.BUILD: 0.85,
|
|
113
|
+
PrecisionLevel.PATCH: 0.80,
|
|
114
|
+
PrecisionLevel.MINOR: 0.75,
|
|
115
|
+
PrecisionLevel.MAJOR: 0.70,
|
|
116
|
+
PrecisionLevel.PRODUCT: 0.60,
|
|
106
117
|
}
|
|
107
|
-
p.confidence = max(p.confidence, boost_map.get(
|
|
118
|
+
p.confidence = max(p.confidence, boost_map.get(level, 0.5))
|
os_normalizer/models.py
CHANGED
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from dataclasses import dataclass, field
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from dataclasses import fields as dataclass_fields
|
|
7
|
+
from enum import Enum
|
|
6
8
|
from typing import Any
|
|
7
9
|
|
|
10
|
+
from .constants import OSFamily, PrecisionLevel
|
|
11
|
+
|
|
8
12
|
|
|
9
13
|
@dataclass
|
|
10
14
|
class OSData:
|
|
11
15
|
"""Structured representation of a parsed operating system."""
|
|
12
16
|
|
|
13
17
|
# Core identity
|
|
14
|
-
family:
|
|
18
|
+
family: OSFamily | None = None # windows, linux, macos, ios, android, bsd, solaris, esxi, network-os
|
|
15
19
|
vendor: str | None = None # Microsoft, Apple, Canonical, Cisco, Juniper, Fortinet, Huawei, Netgear…
|
|
16
20
|
product: str | None = None # Windows 11, Ubuntu, macOS, IOS XE, Junos, FortiOS, VRP, Firmware
|
|
17
21
|
edition: str | None = None # Pro/Enterprise/LTSC; universalk9/ipbase; etc.
|
|
@@ -37,7 +41,7 @@ class OSData:
|
|
|
37
41
|
build_id: str | None = None
|
|
38
42
|
|
|
39
43
|
# Meta information
|
|
40
|
-
precision:
|
|
44
|
+
precision: PrecisionLevel = PrecisionLevel.UNKNOWN # family|product|major|minor|patch|build
|
|
41
45
|
confidence: float = 0.0
|
|
42
46
|
evidence: dict[str, Any] = field(default_factory=dict)
|
|
43
47
|
|
|
@@ -47,7 +51,7 @@ class OSData:
|
|
|
47
51
|
def __str__(self) -> str: # pragma: no cover - formatting helper
|
|
48
52
|
parts: list[str] = []
|
|
49
53
|
|
|
50
|
-
if self.family ==
|
|
54
|
+
if self.family == OSFamily.WINDOWS:
|
|
51
55
|
return _format_windows(self)
|
|
52
56
|
|
|
53
57
|
# Prefer vendor + product; fallback to pretty_name; then family
|
|
@@ -57,7 +61,10 @@ class OSData:
|
|
|
57
61
|
elif self.pretty_name:
|
|
58
62
|
parts.append(self.pretty_name)
|
|
59
63
|
else:
|
|
60
|
-
|
|
64
|
+
if isinstance(self.family, OSFamily):
|
|
65
|
+
parts.append(self.family.value)
|
|
66
|
+
else:
|
|
67
|
+
parts.append(self.family or "Unknown OS")
|
|
61
68
|
|
|
62
69
|
# Version string (major[.minor[.patch]]) and optional build
|
|
63
70
|
ver_chunks: list[str] = []
|
|
@@ -100,8 +107,9 @@ class OSData:
|
|
|
100
107
|
parts.append(f"[build: {self.build_id}]")
|
|
101
108
|
|
|
102
109
|
# Precision/confidence summary
|
|
103
|
-
if self.precision and self.precision !=
|
|
104
|
-
|
|
110
|
+
if self.precision and self.precision != PrecisionLevel.UNKNOWN:
|
|
111
|
+
label = self.precision.value if isinstance(self.precision, PrecisionLevel) else str(self.precision)
|
|
112
|
+
parts.append(f"{{{label}:{self.confidence:.2f}}}")
|
|
105
113
|
elif self.confidence:
|
|
106
114
|
parts.append(f"{{{self.confidence:.2f}}}")
|
|
107
115
|
|
|
@@ -129,6 +137,8 @@ class OSData:
|
|
|
129
137
|
sval = none_str
|
|
130
138
|
elif name == "confidence" and isinstance(val, (int, float)):
|
|
131
139
|
sval = f"{float(val):.2f}"
|
|
140
|
+
elif isinstance(val, Enum):
|
|
141
|
+
sval = val.value
|
|
132
142
|
elif isinstance(val, list):
|
|
133
143
|
sval = ", ".join(str(x) for x in val)
|
|
134
144
|
elif isinstance(val, dict):
|
|
@@ -159,7 +169,7 @@ def _format_windows(p: OSData) -> str:
|
|
|
159
169
|
|
|
160
170
|
if __name__ == "__main__":
|
|
161
171
|
x = OSData(
|
|
162
|
-
family=
|
|
172
|
+
family=OSFamily.LINUX,
|
|
163
173
|
vendor="Fedora Project",
|
|
164
174
|
product="Fedora Linux",
|
|
165
175
|
version_major=33,
|
|
@@ -168,7 +178,7 @@ if __name__ == "__main__":
|
|
|
168
178
|
distro="fedora",
|
|
169
179
|
like_distros=[],
|
|
170
180
|
pretty_name="Fedora Linux",
|
|
171
|
-
precision=
|
|
181
|
+
precision=PrecisionLevel.MAJOR,
|
|
172
182
|
confidence=0.7,
|
|
173
183
|
evidence={"hit": "linux"},
|
|
174
184
|
)
|
os_normalizer/os_normalizer.py
CHANGED
|
@@ -1,35 +1,32 @@
|
|
|
1
|
-
from datetime import UTC, datetime
|
|
2
|
-
from typing import Any, Iterable
|
|
3
|
-
from dataclasses import replace, fields
|
|
4
1
|
import copy
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
from dataclasses import fields, replace
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
|
-
from os_normalizer.
|
|
7
|
+
from os_normalizer.constants import PRECISION_ORDER, OSFamily, PrecisionLevel
|
|
7
8
|
from os_normalizer.cpe import build_cpe23
|
|
9
|
+
from os_normalizer.helpers import extract_arch_from_text, precision_from_parts, update_confidence
|
|
8
10
|
from os_normalizer.models import OSData
|
|
9
11
|
from os_normalizer.parsers.bsd import parse_bsd
|
|
12
|
+
from os_normalizer.parsers.esxi import parse_esxi
|
|
10
13
|
from os_normalizer.parsers.linux import parse_linux
|
|
11
14
|
from os_normalizer.parsers.macos import parse_macos
|
|
12
15
|
from os_normalizer.parsers.mobile import parse_mobile
|
|
13
16
|
from os_normalizer.parsers.network import parse_network
|
|
17
|
+
from os_normalizer.parsers.solaris import parse_solaris
|
|
14
18
|
from os_normalizer.parsers.windows import parse_windows
|
|
15
19
|
|
|
16
|
-
PRECISION_ORDER = {
|
|
17
|
-
"build": 6,
|
|
18
|
-
"patch": 5,
|
|
19
|
-
"minor": 4,
|
|
20
|
-
"major": 3,
|
|
21
|
-
"product": 2,
|
|
22
|
-
"family": 1,
|
|
23
|
-
"unknown": 0,
|
|
24
|
-
}
|
|
25
|
-
|
|
26
20
|
|
|
27
21
|
# ============================================================
|
|
28
22
|
# Family detection (orchestrator logic)
|
|
29
23
|
# ============================================================
|
|
30
|
-
def detect_family(text: str, data: dict[str, Any]) -> tuple[
|
|
24
|
+
def detect_family(text: str, data: dict[str, Any]) -> tuple[OSFamily | None, float, dict[str, Any]]:
|
|
31
25
|
t = text.lower()
|
|
32
26
|
ev = {}
|
|
27
|
+
if OSFamily.HARMONYOS.value in t:
|
|
28
|
+
ev["hit"] = OSFamily.HARMONYOS
|
|
29
|
+
return OSFamily.HARMONYOS, 0.6, ev
|
|
33
30
|
# Obvious network signals first
|
|
34
31
|
if any(
|
|
35
32
|
x in t
|
|
@@ -48,35 +45,50 @@ def detect_family(text: str, data: dict[str, Any]) -> tuple[str | None, float, d
|
|
|
48
45
|
]
|
|
49
46
|
):
|
|
50
47
|
# Special handling for 'ios' - if it's just 'ios' without 'cisco', treat as mobile, not network
|
|
51
|
-
if "
|
|
52
|
-
ev["hit"] =
|
|
53
|
-
return
|
|
54
|
-
|
|
55
|
-
ev["hit"] =
|
|
56
|
-
return
|
|
48
|
+
if f"{OSFamily.IOS.value} " in t and "cisco" not in t:
|
|
49
|
+
ev["hit"] = OSFamily.IOS
|
|
50
|
+
return OSFamily.IOS, 0.6, ev
|
|
51
|
+
|
|
52
|
+
ev["hit"] = OSFamily.NETWORK
|
|
53
|
+
return OSFamily.NETWORK, 0.7, ev
|
|
54
|
+
# VMware ESXi
|
|
55
|
+
if "vmkernel" in t or "vmware esxi" in t or " esxi" in t or t.startswith("esxi"):
|
|
56
|
+
ev["hit"] = OSFamily.ESXI
|
|
57
|
+
return OSFamily.ESXI, 0.65, ev
|
|
58
|
+
# Solaris / SunOS
|
|
59
|
+
if "sunos" in t or "solaris" in t:
|
|
60
|
+
ev["hit"] = OSFamily.SOLARIS
|
|
61
|
+
return OSFamily.SOLARIS, 0.65, ev
|
|
57
62
|
# Linux
|
|
58
|
-
if
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
if OSFamily.LINUX.value in t or any(
|
|
64
|
+
k in data for k in ("ID", "ID_LIKE", "PRETTY_NAME", "VERSION_ID", "VERSION_CODENAME")
|
|
65
|
+
):
|
|
66
|
+
ev["hit"] = OSFamily.LINUX
|
|
67
|
+
return OSFamily.LINUX, 0.6, ev
|
|
61
68
|
# Windows
|
|
62
|
-
if
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
if (
|
|
70
|
+
OSFamily.WINDOWS.value in t
|
|
71
|
+
or "nt " in t
|
|
72
|
+
or t.startswith("win")
|
|
73
|
+
or data.get("os", "").lower() == OSFamily.WINDOWS.value
|
|
74
|
+
):
|
|
75
|
+
ev["hit"] = OSFamily.WINDOWS
|
|
76
|
+
return OSFamily.WINDOWS, 0.6, ev
|
|
65
77
|
# Apple
|
|
66
|
-
if
|
|
67
|
-
ev["hit"] =
|
|
68
|
-
return
|
|
69
|
-
if
|
|
70
|
-
ev["hit"] =
|
|
71
|
-
return
|
|
78
|
+
if OSFamily.MACOS.value in t or "os x" in t or "darwin" in t:
|
|
79
|
+
ev["hit"] = OSFamily.MACOS
|
|
80
|
+
return OSFamily.MACOS, 0.6, ev
|
|
81
|
+
if OSFamily.IOS.value in t or "ipados" in t:
|
|
82
|
+
ev["hit"] = OSFamily.IOS
|
|
83
|
+
return OSFamily.IOS, 0.6, ev
|
|
72
84
|
# Android
|
|
73
|
-
if
|
|
74
|
-
ev["hit"] =
|
|
75
|
-
return
|
|
85
|
+
if OSFamily.ANDROID.value in t:
|
|
86
|
+
ev["hit"] = OSFamily.ANDROID
|
|
87
|
+
return OSFamily.ANDROID, 0.6, ev
|
|
76
88
|
# BSD
|
|
77
89
|
if "freebsd" in t or "openbsd" in t or "netbsd" in t:
|
|
78
|
-
ev["hit"] =
|
|
79
|
-
return
|
|
90
|
+
ev["hit"] = OSFamily.BSD
|
|
91
|
+
return OSFamily.BSD, 0.6, ev
|
|
80
92
|
return None, 0.0, ev
|
|
81
93
|
|
|
82
94
|
|
|
@@ -93,20 +105,24 @@ def normalize_os(text: str, data: dict | None = None) -> OSData:
|
|
|
93
105
|
p.confidence = max(p.confidence, base_conf)
|
|
94
106
|
p.evidence.update(ev)
|
|
95
107
|
|
|
96
|
-
if fam ==
|
|
108
|
+
if fam == OSFamily.NETWORK:
|
|
97
109
|
p = parse_network(text, data, p)
|
|
98
|
-
elif fam ==
|
|
110
|
+
elif fam == OSFamily.WINDOWS:
|
|
99
111
|
p = parse_windows(text, data, p)
|
|
100
|
-
elif fam ==
|
|
112
|
+
elif fam == OSFamily.MACOS:
|
|
101
113
|
p = parse_macos(text, data, p)
|
|
102
|
-
elif fam ==
|
|
114
|
+
elif fam == OSFamily.LINUX:
|
|
103
115
|
p = parse_linux(text, data, p)
|
|
104
|
-
elif fam
|
|
116
|
+
elif fam == OSFamily.SOLARIS:
|
|
117
|
+
p = parse_solaris(text, data, p)
|
|
118
|
+
elif fam == OSFamily.ESXI:
|
|
119
|
+
p = parse_esxi(text, data, p)
|
|
120
|
+
elif fam in (OSFamily.ANDROID, OSFamily.IOS, OSFamily.HARMONYOS):
|
|
105
121
|
p = parse_mobile(text, data, p)
|
|
106
|
-
elif fam ==
|
|
122
|
+
elif fam == OSFamily.BSD:
|
|
107
123
|
p = parse_bsd(text, data, p)
|
|
108
124
|
else:
|
|
109
|
-
p.precision =
|
|
125
|
+
p.precision = PrecisionLevel.UNKNOWN
|
|
110
126
|
|
|
111
127
|
# Fallback arch from text if not already set elsewhere
|
|
112
128
|
if not p.arch:
|
|
@@ -127,7 +143,7 @@ def choose_best_fact(candidates: list[OSData]) -> OSData:
|
|
|
127
143
|
raise ValueError("No candidates")
|
|
128
144
|
return sorted(
|
|
129
145
|
candidates,
|
|
130
|
-
key=lambda c: (PRECISION_ORDER.get(c.precision, 0), c.confidence),
|
|
146
|
+
key=lambda c: (PRECISION_ORDER.get(_ensure_precision_enum(c.precision), 0), c.confidence),
|
|
131
147
|
reverse=True,
|
|
132
148
|
)[0]
|
|
133
149
|
|
|
@@ -138,7 +154,18 @@ def choose_best_fact(candidates: list[OSData]) -> OSData:
|
|
|
138
154
|
|
|
139
155
|
|
|
140
156
|
def _score(p: OSData) -> tuple[int, float]:
|
|
141
|
-
return (PRECISION_ORDER.get(p.precision, 0), p.confidence)
|
|
157
|
+
return (PRECISION_ORDER.get(_ensure_precision_enum(p.precision), 0), p.confidence)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _ensure_precision_enum(value: PrecisionLevel | str | None) -> PrecisionLevel:
|
|
161
|
+
if isinstance(value, PrecisionLevel):
|
|
162
|
+
return value
|
|
163
|
+
if value is None:
|
|
164
|
+
return PrecisionLevel.UNKNOWN
|
|
165
|
+
try:
|
|
166
|
+
return PrecisionLevel(str(value))
|
|
167
|
+
except ValueError:
|
|
168
|
+
return PrecisionLevel.UNKNOWN
|
|
142
169
|
|
|
143
170
|
|
|
144
171
|
def _union_unique(values: Iterable[str]) -> list[str]:
|
|
@@ -227,8 +254,8 @@ def merge_os(a: OSData, b: OSData, policy: str = "auto") -> OSData:
|
|
|
227
254
|
|
|
228
255
|
# Precision & confidence: recompute based on version parts
|
|
229
256
|
new_prec = precision_from_parts(r.version_major, r.version_minor, r.version_patch, r.version_build)
|
|
230
|
-
if new_prec ==
|
|
231
|
-
new_prec =
|
|
257
|
+
if new_prec == PrecisionLevel.PRODUCT and not r.product:
|
|
258
|
+
new_prec = PrecisionLevel.FAMILY if r.family else PrecisionLevel.UNKNOWN
|
|
232
259
|
r.precision = new_prec
|
|
233
260
|
r.confidence = max(a.confidence, b.confidence)
|
|
234
261
|
update_confidence(r, r.precision)
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
from .
|
|
2
|
-
from .
|
|
1
|
+
from .bsd import parse_bsd
|
|
2
|
+
from .esxi import parse_esxi
|
|
3
3
|
from .linux import parse_linux
|
|
4
|
+
from .macos import parse_macos
|
|
4
5
|
from .mobile import parse_mobile
|
|
5
|
-
from .bsd import parse_bsd
|
|
6
6
|
from .network import parse_network
|
|
7
|
+
from .solaris import parse_solaris
|
|
8
|
+
from .windows import parse_windows
|
|
7
9
|
|
|
8
10
|
__all__ = [
|
|
9
11
|
"parse_bsd",
|
|
12
|
+
"parse_esxi",
|
|
10
13
|
"parse_linux",
|
|
11
14
|
"parse_macos",
|
|
12
15
|
"parse_mobile",
|
|
13
16
|
"parse_network",
|
|
17
|
+
"parse_solaris",
|
|
14
18
|
"parse_windows",
|
|
15
19
|
]
|