rekos 1.3.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.
rekos/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """REKOS core package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "1.3.0"
@@ -0,0 +1,18 @@
1
+ """Passive OSINT source adapters."""
2
+
3
+ from .base import AdapterResult, BaseSourceAdapter, SourceRunResult
4
+ from .maigret import MaigretAdapter
5
+ from .sherlock import SherlockAdapter, SherlockUsernameAdapter
6
+ from .web_osint import DnsDomainAdapter
7
+ from .wmn import WmnUsernameAdapter
8
+
9
+ __all__ = [
10
+ "AdapterResult",
11
+ "BaseSourceAdapter",
12
+ "DnsDomainAdapter",
13
+ "MaigretAdapter",
14
+ "SherlockAdapter",
15
+ "SherlockUsernameAdapter",
16
+ "SourceRunResult",
17
+ "WmnUsernameAdapter",
18
+ ]
rekos/adapters/base.py ADDED
@@ -0,0 +1,104 @@
1
+ """Base interface for passive OSINT source adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from rekos.storage import CaseStore
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AdapterResult:
18
+ source: str
19
+ target: str
20
+ url: str
21
+ platform: str
22
+ confidence: str
23
+ raw_reference: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SourceRunResult:
28
+ source: str
29
+ target: str
30
+ raw_output: str
31
+ results: list[AdapterResult]
32
+ artifacts: list[Path]
33
+ skipped: bool = False
34
+
35
+
36
+ class BaseSourceAdapter:
37
+ name: str = ""
38
+ description: str = ""
39
+ supported_target_types: tuple[str, ...] = ()
40
+ passive_only: bool = True
41
+ external_dependencies: tuple[str, ...] = ()
42
+
43
+ def dependency_status(self) -> dict[str, bool]:
44
+ return {
45
+ dependency: shutil.which(dependency) is not None
46
+ for dependency in self.external_dependencies
47
+ }
48
+
49
+ def missing_dependencies(self) -> list[str]:
50
+ return [
51
+ dependency
52
+ for dependency, available in self.dependency_status().items()
53
+ if not available
54
+ ]
55
+
56
+ def execute(self, case: str, target: str, store: CaseStore) -> SourceRunResult:
57
+ missing = self.missing_dependencies()
58
+ if missing:
59
+ from rekos.errors import ExternalToolMissingError
60
+
61
+ raise ExternalToolMissingError(
62
+ f"Missing dependencies for {self.name}: {', '.join(missing)}."
63
+ )
64
+ raw_output = self.run(case, target)
65
+ artifact_path = self._write_source_output(case, target, store, raw_output)
66
+ results = self.parse_results(target, raw_output)
67
+ store.add_adapter_results(case, results)
68
+ store.add_timeline_event(case, "source.run", f"Ran source {self.name} for {target}")
69
+ return SourceRunResult(
70
+ source=self.name,
71
+ target=target,
72
+ raw_output=raw_output,
73
+ results=results,
74
+ artifacts=[artifact_path],
75
+ )
76
+
77
+ def run(self, case: str, target: str) -> str:
78
+ raise NotImplementedError
79
+
80
+ def parse_results(self, target: str, raw_output: str) -> list[AdapterResult]:
81
+ raise NotImplementedError
82
+
83
+ def _write_source_output(
84
+ self,
85
+ case: str,
86
+ target: str,
87
+ store: CaseStore,
88
+ raw_output: str,
89
+ ) -> Path:
90
+ sources_folder = store.exports_folder(case) / "sources"
91
+ sources_folder.mkdir(exist_ok=True)
92
+ stem = f"{int(time.time())}-{self.name}-{_safe_export_name(target)}"
93
+ path = sources_folder / f"{stem}.txt"
94
+ counter = 2
95
+ while path.exists():
96
+ path = sources_folder / f"{stem}-{counter}.txt"
97
+ counter += 1
98
+ path.write_text(raw_output, encoding="utf-8")
99
+ return path
100
+
101
+
102
+ def _safe_export_name(value: str) -> str:
103
+ cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip()).strip(".-")
104
+ return (cleaned or "target")[:80]
@@ -0,0 +1,104 @@
1
+ """HTTP snapshot passive URL adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from urllib.parse import urlparse
7
+
8
+ from rekos.snapshots import fetch_public_url, normalize_public_url, snapshot_url
9
+
10
+ from .base import AdapterResult, BaseSourceAdapter, SourceRunResult
11
+
12
+
13
+ class HttpSnapshotAdapter(BaseSourceAdapter):
14
+ name = "http_snapshot"
15
+ description = "Capture a public HTTP(S) URL snapshot as a local evidence artifact."
16
+ supported_target_types = ("url",)
17
+ passive_only = True
18
+ external_dependencies: tuple[str, ...] = ()
19
+
20
+ def execute(self, case: str, target: str, store) -> SourceRunResult:
21
+ result = snapshot_url(case, target, store)
22
+ raw_output = json.dumps(
23
+ {
24
+ "url": result.url,
25
+ "skipped": result.skipped,
26
+ "headers_path": str(result.headers_path or ""),
27
+ "body_path": str(result.body_path or ""),
28
+ "screenshot_path": str(result.screenshot_path or ""),
29
+ "error": result.error or "",
30
+ },
31
+ indent=2,
32
+ sort_keys=True,
33
+ )
34
+ adapter_result = AdapterResult(
35
+ source=self.name,
36
+ target=result.url,
37
+ url=result.url,
38
+ platform=_platform_from_url(result.url),
39
+ confidence="high",
40
+ raw_reference=raw_output,
41
+ )
42
+ source_artifact = self._write_source_output(case, result.url, store, raw_output)
43
+ store.add_adapter_results(case, [adapter_result])
44
+ store.add_timeline_event(case, "source.run", f"Ran source {self.name} for {result.url}")
45
+ artifacts = [
46
+ path
47
+ for path in (
48
+ source_artifact,
49
+ result.headers_path,
50
+ result.body_path,
51
+ result.screenshot_path,
52
+ )
53
+ if path is not None
54
+ ]
55
+ return SourceRunResult(
56
+ source=self.name,
57
+ target=result.url,
58
+ raw_output=raw_output,
59
+ results=[adapter_result],
60
+ artifacts=artifacts,
61
+ skipped=result.skipped,
62
+ )
63
+
64
+ def run(self, case: str, target: str) -> str:
65
+ normalized_url = normalize_public_url(target)
66
+ capture = fetch_public_url(normalized_url)
67
+ return json.dumps(
68
+ {
69
+ "url": normalized_url,
70
+ "status_code": capture.status_code,
71
+ "headers": capture.headers,
72
+ "body": capture.body,
73
+ },
74
+ indent=2,
75
+ sort_keys=True,
76
+ )
77
+
78
+ def parse_results(self, target: str, raw_output: str) -> list[AdapterResult]:
79
+ try:
80
+ parsed = json.loads(raw_output)
81
+ except json.JSONDecodeError:
82
+ parsed = {}
83
+ url = str(parsed.get("url") or normalize_public_url(target))
84
+ status = parsed.get("status_code", "unknown")
85
+ return [
86
+ AdapterResult(
87
+ source=self.name,
88
+ target=url,
89
+ url=url,
90
+ platform=_platform_from_url(url),
91
+ confidence="high",
92
+ raw_reference=f"HTTP status: {status}",
93
+ )
94
+ ]
95
+
96
+
97
+ def _platform_from_url(url: str) -> str:
98
+ host = urlparse(url).hostname or ""
99
+ if not host:
100
+ return "unknown"
101
+ parts = host.split(".")
102
+ if len(parts) >= 2:
103
+ return parts[-2]
104
+ return host
@@ -0,0 +1,99 @@
1
+ """Maigret passive username adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import os
7
+ import re
8
+ import shutil
9
+ import sys
10
+ from pathlib import Path
11
+ from urllib.parse import urlparse
12
+
13
+ from rekos.errors import ExternalToolMissingError
14
+ from rekos.osint import _run_command
15
+
16
+ from .base import AdapterResult, BaseSourceAdapter
17
+
18
+
19
+ URL_RE = re.compile(r"https?://[^\s<>'\"]+")
20
+ MAIGRET_EXECUTABLE_NAMES = ("maigret", "maigret.py")
21
+
22
+
23
+ class MaigretAdapter(BaseSourceAdapter):
24
+ name = "maigret_username"
25
+ description = "Run Maigret against a username and parse public profile URLs."
26
+ supported_target_types = ("username",)
27
+ passive_only = True
28
+ external_dependencies = ("maigret",)
29
+
30
+ def dependency_status(self) -> dict[str, bool]:
31
+ return {"maigret": _resolve_maigret_command() is not None}
32
+
33
+ def run(self, case: str, target: str) -> str:
34
+ command = _resolve_maigret_command()
35
+ if command is None:
36
+ raise ExternalToolMissingError("Missing username investigation tool: install maigret.")
37
+ output = _run_command([*command, "--print-found", "--", target], "maigret")
38
+ return _raw_output(output.stdout, output.stderr)
39
+
40
+ def parse_results(self, target: str, raw_output: str) -> list[AdapterResult]:
41
+ results: list[AdapterResult] = []
42
+ seen: set[str] = set()
43
+ for match in URL_RE.finditer(raw_output):
44
+ url = match.group(0).rstrip(").,];")
45
+ if url in seen:
46
+ continue
47
+ seen.add(url)
48
+ results.append(
49
+ AdapterResult(
50
+ source=self.name,
51
+ target=target,
52
+ url=url,
53
+ platform=_platform_from_url(url),
54
+ confidence="medium",
55
+ raw_reference=url,
56
+ )
57
+ )
58
+ return results
59
+
60
+
61
+ def _platform_from_url(url: str) -> str:
62
+ host = urlparse(url).hostname or ""
63
+ if not host:
64
+ return "unknown"
65
+ parts = host.split(".")
66
+ if len(parts) >= 2:
67
+ return parts[-2]
68
+ return host
69
+
70
+
71
+ def _raw_output(stdout: str, stderr: str) -> str:
72
+ parts = [stdout.strip()]
73
+ if stderr.strip():
74
+ parts.extend(["", "[stderr]", stderr.strip()])
75
+ return "\n".join(part for part in parts if part).rstrip() + "\n"
76
+
77
+
78
+ def _resolve_maigret_command() -> list[str] | None:
79
+ for executable_name in MAIGRET_EXECUTABLE_NAMES:
80
+ executable = shutil.which(executable_name)
81
+ if executable:
82
+ return [executable]
83
+
84
+ python_bin = Path(sys.executable).resolve().parent
85
+ for executable_name in MAIGRET_EXECUTABLE_NAMES:
86
+ candidate = python_bin / executable_name
87
+ if candidate.is_file() and os.access(candidate, os.X_OK):
88
+ return [str(candidate)]
89
+
90
+ if _module_runner_available("maigret"):
91
+ return [sys.executable, "-m", "maigret"]
92
+ return None
93
+
94
+
95
+ def _module_runner_available(module_name: str) -> bool:
96
+ try:
97
+ return importlib.util.find_spec(f"{module_name}.__main__") is not None
98
+ except (ImportError, AttributeError, ValueError):
99
+ return False
@@ -0,0 +1,72 @@
1
+ """Registry for passive OSINT source adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from rekos.errors import RekosError
8
+
9
+ from .base import BaseSourceAdapter
10
+ from .http_snapshot import HttpSnapshotAdapter
11
+ from .maigret import MaigretAdapter
12
+ from .sherlock import SherlockUsernameAdapter
13
+ from .web_osint import (
14
+ CrtshDomainAdapter,
15
+ DnsDomainAdapter,
16
+ RdapDomainAdapter,
17
+ WaybackUrlAdapter,
18
+ WebDomainAdapter,
19
+ )
20
+ from .wmn import WmnUsernameAdapter
21
+
22
+
23
+ class SourceNotFoundError(RekosError):
24
+ pass
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SourceDependencyCheck:
29
+ source: str
30
+ dependencies: dict[str, bool]
31
+
32
+
33
+ class SourceAdapterRegistry:
34
+ def __init__(self, adapters: list[BaseSourceAdapter]) -> None:
35
+ self._adapters = {adapter.name: adapter for adapter in adapters}
36
+
37
+ def list(self) -> list[BaseSourceAdapter]:
38
+ return [self._adapters[name] for name in sorted(self._adapters)]
39
+
40
+ def get(self, name: str) -> BaseSourceAdapter:
41
+ try:
42
+ return self._adapters[name]
43
+ except KeyError as exc:
44
+ available = ", ".join(sorted(self._adapters))
45
+ raise SourceNotFoundError(
46
+ f"Unknown source '{name}'. Available sources: {available}."
47
+ ) from exc
48
+
49
+ def check_dependencies(self) -> list[SourceDependencyCheck]:
50
+ return [
51
+ SourceDependencyCheck(
52
+ source=adapter.name,
53
+ dependencies=adapter.dependency_status(),
54
+ )
55
+ for adapter in self.list()
56
+ ]
57
+
58
+
59
+ def default_registry() -> SourceAdapterRegistry:
60
+ return SourceAdapterRegistry(
61
+ [
62
+ CrtshDomainAdapter(),
63
+ DnsDomainAdapter(),
64
+ HttpSnapshotAdapter(),
65
+ MaigretAdapter(),
66
+ RdapDomainAdapter(),
67
+ SherlockUsernameAdapter(),
68
+ WebDomainAdapter(),
69
+ WaybackUrlAdapter(),
70
+ WmnUsernameAdapter(),
71
+ ]
72
+ )
@@ -0,0 +1,70 @@
1
+ """Sherlock passive username adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from urllib.parse import urlparse
8
+
9
+ from rekos.errors import ExternalToolMissingError
10
+ from rekos.osint import _run_tool
11
+
12
+ from .base import AdapterResult, BaseSourceAdapter
13
+
14
+
15
+ URL_RE = re.compile(r"https?://[^\s<>'\"]+")
16
+
17
+
18
+ class SherlockAdapter(BaseSourceAdapter):
19
+ name = "sherlock"
20
+ description = "Run Sherlock against a username and parse public profile URLs."
21
+ supported_target_types = ("username",)
22
+ passive_only = True
23
+ external_dependencies = ("sherlock",)
24
+
25
+ def run(self, case: str, target: str) -> str:
26
+ if not shutil.which("sherlock"):
27
+ raise ExternalToolMissingError("Missing username investigation tool: install sherlock.")
28
+ output = _run_tool("sherlock", ["--print-found", "--", target])
29
+ return _raw_output(output.stdout, output.stderr)
30
+
31
+ def parse_results(self, target: str, raw_output: str) -> list[AdapterResult]:
32
+ results: list[AdapterResult] = []
33
+ seen: set[str] = set()
34
+ for match in URL_RE.finditer(raw_output):
35
+ url = match.group(0).rstrip(").,];")
36
+ if url in seen:
37
+ continue
38
+ seen.add(url)
39
+ results.append(
40
+ AdapterResult(
41
+ source=self.name,
42
+ target=target,
43
+ url=url,
44
+ platform=_platform_from_url(url),
45
+ confidence="medium",
46
+ raw_reference=url,
47
+ )
48
+ )
49
+ return results
50
+
51
+
52
+ def _platform_from_url(url: str) -> str:
53
+ host = urlparse(url).hostname or ""
54
+ if not host:
55
+ return "unknown"
56
+ parts = host.split(".")
57
+ if len(parts) >= 2:
58
+ return parts[-2]
59
+ return host
60
+
61
+
62
+ def _raw_output(stdout: str, stderr: str) -> str:
63
+ parts = [stdout.strip()]
64
+ if stderr.strip():
65
+ parts.extend(["", "[stderr]", stderr.strip()])
66
+ return "\n".join(part for part in parts if part).rstrip() + "\n"
67
+
68
+
69
+ class SherlockUsernameAdapter(SherlockAdapter):
70
+ name = "sherlock_username"