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 +5 -0
- rekos/adapters/__init__.py +18 -0
- rekos/adapters/base.py +104 -0
- rekos/adapters/http_snapshot.py +104 -0
- rekos/adapters/maigret.py +99 -0
- rekos/adapters/registry.py +72 -0
- rekos/adapters/sherlock.py +70 -0
- rekos/adapters/web_osint.py +894 -0
- rekos/adapters/wmn.py +162 -0
- rekos/adapters/wmn_sources.json +38 -0
- rekos/banner.py +161 -0
- rekos/case_export.py +135 -0
- rekos/cli.py +742 -0
- rekos/errors.py +29 -0
- rekos/exporting.py +65 -0
- rekos/hashfile.py +19 -0
- rekos/investigation.py +351 -0
- rekos/ioc.py +116 -0
- rekos/models.py +218 -0
- rekos/osint.py +126 -0
- rekos/paths.py +42 -0
- rekos/public_targets.py +56 -0
- rekos/py.typed +1 -0
- rekos/reporting.py +233 -0
- rekos/snapshots.py +167 -0
- rekos/storage.py +2547 -0
- rekos/usernames.py +38 -0
- rekos/validation.py +115 -0
- rekos-1.3.0.dist-info/METADATA +223 -0
- rekos-1.3.0.dist-info/RECORD +34 -0
- rekos-1.3.0.dist-info/WHEEL +5 -0
- rekos-1.3.0.dist-info/entry_points.txt +2 -0
- rekos-1.3.0.dist-info/licenses/LICENSE +21 -0
- rekos-1.3.0.dist-info/top_level.txt +1 -0
rekos/__init__.py
ADDED
|
@@ -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"
|