Initial release: offline NetScaler triage with Sigma rules and MIT license

This commit is contained in:
StasonJatham committed 2026-10-01 23:08:50 +02:00
commit 711c42b270
52 files changed
+61222

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
"""Extract observables, not verdicts. Original values remain suitable for exact matching."""
import ipaddress
import re
from typing import Any
from urllib.parse import urlsplit
def defang(value: str, indicator_type: str) -> str:
if indicator_type in {"url", "domain", "ipv4", "ipv6", "ip"}:
value = re.sub(r"^https:", "hxxps:", value, flags=re.I)
value = re.sub(r"^http:", "hxxp:", value, flags=re.I)
value = value.replace(".", "[.]").replace(":", "[:]")
return value
def extract_indicators(text: str) -> list[dict[str, Any]]:
if len(text) > 16777216:
raise ValueError("indicator input exceeds 16 MiB; process log lines individually")
found: dict[tuple[str, str], dict[str, Any]] = {}
def add(kind: str, value: str, role: str = "observable", start: int | None = None) -> None:
key = (kind, value)
if key not in found:
found[key] = dict(
type=kind,
value=value,
defanged=defang(value, kind),
role=role,
observation="Text occurrence; not evidence of a connection or execution.",
start=start,
)
urls = list(re.finditer(r"\bhttps?://[^\s<>\"'`]+", text, re.I))
for match in urls:
value = match.group().rstrip("),;}")
add("url", value, "url_in_text", match.start())
try:
hostname = urlsplit(value).hostname
if hostname:
try:
parsed = ipaddress.ip_address(hostname)
add(
"ipv4" if parsed.version == 4 else "ipv6",
hostname,
"url_host",
match.start(),
)
except ValueError:
if "." in hostname and re.fullmatch(r"[A-Za-z0-9.-]+", hostname):
add("domain", hostname, "url_host", match.start())
except ValueError:
pass
for match in re.finditer(
r"(?<![\w.])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![\w.])|(?<![\w:])(?:[A-Fa-f0-9]{0,4}:){2,}[A-Fa-f0-9:.]*(?![\w:])",
text,
):
value = match.group()
try:
parsed = ipaddress.ip_address(value)
add("ipv4" if parsed.version == 4 else "ipv6", value, "address_in_text", match.start())
except ValueError:
continue
for match in re.finditer(
r"(?<![A-Za-z0-9])(?:[a-fA-F0-9]{64}|[a-fA-F0-9]{40}|[a-fA-F0-9]{32})(?![A-Za-z0-9])", text
):
value = match.group()
add(
{32: "md5", 40: "sha1", 64: "sha256"}[len(value)],
value,
"hash_shaped_text",
match.start(),
)
# Domain-shaped filenames are ambiguous: require URL, DNS/Host context or an email address.
domain_pattern = r"(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}"
for match in re.finditer(
r"(?:\b(?:host|hostname|dns|domain|server_name)\s*[:=]\s*|@)("
+ domain_pattern
+ r")(?![\w.-])",
text,
re.I,
):
add("domain", match.group(1), "named_host_in_text", match.start(1))
url_index = 0
for match in re.finditer(r"(?<![\w/:])/(?!/)[^\s\"'`<>|;{}()]+", text):
while url_index < len(urls) and urls[url_index].end() <= match.start():
url_index += 1
if (
url_index < len(urls)
and urls[url_index].start() <= match.start() < urls[url_index].end()
):
continue
add("path", match.group(), "path_in_text", match.start())
return list(found.values())