osintkit 0.1.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.
- osintkit/__init__.py +3 -0
- osintkit/cli.py +673 -0
- osintkit/collectors/__init__.py +24 -0
- osintkit/collectors/accounts.py +83 -0
- osintkit/collectors/base.py +138 -0
- osintkit/collectors/breaches.py +163 -0
- osintkit/collectors/ct.py +165 -0
- osintkit/collectors/dns.py +130 -0
- osintkit/collectors/github.py +190 -0
- osintkit/collectors/pastes.py +237 -0
- osintkit/collectors/ports.py +91 -0
- osintkit/collectors/subdomains.py +102 -0
- osintkit/collectors/whois_lookup.py +72 -0
- osintkit/config.py +151 -0
- osintkit/diff.py +96 -0
- osintkit/doctor.py +119 -0
- osintkit/errors.py +51 -0
- osintkit/http.py +272 -0
- osintkit/llm.py +91 -0
- osintkit/logging_store.py +206 -0
- osintkit/models.py +228 -0
- osintkit/normalise.py +131 -0
- osintkit/pipeline.py +237 -0
- osintkit/py.typed +0 -0
- osintkit/render.py +235 -0
- osintkit/retention.py +81 -0
- osintkit/synthesis.py +176 -0
- osintkit/targets.py +65 -0
- osintkit-0.1.1.dist-info/METADATA +176 -0
- osintkit-0.1.1.dist-info/RECORD +33 -0
- osintkit-0.1.1.dist-info/WHEEL +4 -0
- osintkit-0.1.1.dist-info/entry_points.txt +2 -0
- osintkit-0.1.1.dist-info/licenses/LICENSE +21 -0
osintkit/llm.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Minimal OpenAI-compatible chat client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any, Self
|
|
7
|
+
|
|
8
|
+
from .config import Settings
|
|
9
|
+
from .errors import MissingAPIKey, SourceUnavailable, SynthesisError
|
|
10
|
+
from .http import HttpClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LLMClient:
|
|
14
|
+
def __init__(self, settings: Settings, http: HttpClient | None = None) -> None:
|
|
15
|
+
self.settings = settings
|
|
16
|
+
self._own_http = http is None
|
|
17
|
+
self._http = http or HttpClient(settings)
|
|
18
|
+
|
|
19
|
+
async def __aenter__(self) -> Self:
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
23
|
+
await self.aclose()
|
|
24
|
+
|
|
25
|
+
async def aclose(self) -> None:
|
|
26
|
+
if self._own_http:
|
|
27
|
+
await self._http.aclose()
|
|
28
|
+
|
|
29
|
+
def endpoint(self) -> str:
|
|
30
|
+
return f"{self.settings.base_url.rstrip('/')}/chat/completions"
|
|
31
|
+
|
|
32
|
+
async def chat(
|
|
33
|
+
self,
|
|
34
|
+
messages: Sequence[Mapping[str, str]],
|
|
35
|
+
*,
|
|
36
|
+
temperature: float | None = None,
|
|
37
|
+
max_tokens: int = 2400,
|
|
38
|
+
response_format: Mapping[str, Any] | None = None,
|
|
39
|
+
) -> str:
|
|
40
|
+
api_key = self.settings.resolved_api_key()
|
|
41
|
+
if not api_key:
|
|
42
|
+
raise MissingAPIKey("No API key. Set API_KEY in the environment or pass --api-key.")
|
|
43
|
+
|
|
44
|
+
payload: dict[str, Any] = {
|
|
45
|
+
"model": self.settings.model,
|
|
46
|
+
"messages": [dict(m) for m in messages],
|
|
47
|
+
"temperature": self.settings.temperature if temperature is None else temperature,
|
|
48
|
+
"max_tokens": max_tokens,
|
|
49
|
+
"stream": False,
|
|
50
|
+
}
|
|
51
|
+
if response_format:
|
|
52
|
+
payload["response_format"] = dict(response_format)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
response = await self._http.post_json(
|
|
56
|
+
self.endpoint(),
|
|
57
|
+
namespace="llm",
|
|
58
|
+
payload=payload,
|
|
59
|
+
headers={
|
|
60
|
+
"Authorization": f"Bearer {api_key}",
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
except SourceUnavailable as exc:
|
|
65
|
+
if "HTTP 401" in str(exc) or "HTTP 403" in str(exc):
|
|
66
|
+
raise SynthesisError("API key rejected; run `osintkit doctor --deep` to confirm it") from exc
|
|
67
|
+
raise
|
|
68
|
+
try:
|
|
69
|
+
body = response.json()
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise SynthesisError("Model endpoint returned non-JSON") from exc
|
|
72
|
+
return extract_content(body)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def extract_content(body: Any) -> str:
|
|
76
|
+
if not isinstance(body, dict):
|
|
77
|
+
raise SynthesisError("Unexpected model response shape")
|
|
78
|
+
choices = body.get("choices")
|
|
79
|
+
if not isinstance(choices, list) or not choices:
|
|
80
|
+
error = body.get("error")
|
|
81
|
+
if isinstance(error, dict) and error.get("message"):
|
|
82
|
+
raise SynthesisError(str(error["message"]))
|
|
83
|
+
raise SynthesisError("Model response had no choices")
|
|
84
|
+
message = choices[0].get("message") or {}
|
|
85
|
+
content = message.get("content")
|
|
86
|
+
if isinstance(content, list):
|
|
87
|
+
parts = [c.get("text", "") for c in content if isinstance(c, dict)]
|
|
88
|
+
content = "".join(parts)
|
|
89
|
+
if not isinstance(content, str) or not content.strip():
|
|
90
|
+
raise SynthesisError("Model returned empty content")
|
|
91
|
+
return content
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Timestamped JSONL run logs and the report archive behind `history`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import ensure_dirs, log_dir, reports_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _now() -> str:
|
|
17
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _slug(target: str) -> str:
|
|
21
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", target).strip("-")
|
|
22
|
+
return slug[:60] or "target"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RunLogger:
|
|
26
|
+
"""Appends JSONL lines. One file per day, shared by all runs."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, run_id: str, target: str = "", target_type: str = "") -> None:
|
|
29
|
+
ensure_dirs()
|
|
30
|
+
self.run_id = run_id
|
|
31
|
+
self.target = target
|
|
32
|
+
self.target_type = target_type
|
|
33
|
+
self.path = log_dir() / f"{datetime.now(UTC):%Y-%m-%d}.jsonl"
|
|
34
|
+
|
|
35
|
+
def event(self, kind: str, **fields: Any) -> None:
|
|
36
|
+
record = {
|
|
37
|
+
"ts": _now(),
|
|
38
|
+
"run_id": self.run_id,
|
|
39
|
+
"kind": kind,
|
|
40
|
+
"target": self.target,
|
|
41
|
+
"target_type": self.target_type,
|
|
42
|
+
**fields,
|
|
43
|
+
}
|
|
44
|
+
line = json.dumps(record, ensure_ascii=False, default=str)
|
|
45
|
+
try:
|
|
46
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
47
|
+
handle.write(line + "\n")
|
|
48
|
+
handle.flush()
|
|
49
|
+
os.fsync(handle.fileno())
|
|
50
|
+
except OSError:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
def collector(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
collector: str,
|
|
57
|
+
ok: bool,
|
|
58
|
+
skipped: bool,
|
|
59
|
+
note: str | None,
|
|
60
|
+
error: str | None,
|
|
61
|
+
elapsed_ms: int,
|
|
62
|
+
size: int,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.event(
|
|
65
|
+
"collector",
|
|
66
|
+
collector=collector,
|
|
67
|
+
ok=ok,
|
|
68
|
+
skipped=skipped,
|
|
69
|
+
note=note,
|
|
70
|
+
error=error,
|
|
71
|
+
elapsed_ms=elapsed_ms,
|
|
72
|
+
payload_size=size,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def request(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
base_url: str,
|
|
79
|
+
model: str,
|
|
80
|
+
attempt: int,
|
|
81
|
+
dry_run: bool,
|
|
82
|
+
messages: list[dict[str, str]],
|
|
83
|
+
max_tokens: int,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.event(
|
|
86
|
+
"model_request",
|
|
87
|
+
base_url=base_url,
|
|
88
|
+
model=model,
|
|
89
|
+
attempt=attempt,
|
|
90
|
+
dry_run=dry_run,
|
|
91
|
+
max_tokens=max_tokens,
|
|
92
|
+
messages=messages,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def response(
|
|
96
|
+
self, *, attempt: int, ok: bool, error: str | None, elapsed_ms: int, content: str | None
|
|
97
|
+
) -> None:
|
|
98
|
+
self.event(
|
|
99
|
+
"model_response",
|
|
100
|
+
attempt=attempt,
|
|
101
|
+
ok=ok,
|
|
102
|
+
error=error,
|
|
103
|
+
elapsed_ms=elapsed_ms,
|
|
104
|
+
content_length=len(content or ""),
|
|
105
|
+
content=content,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def archive_report(report: dict[str, Any]) -> Path | None:
|
|
110
|
+
"""Persist a full report so `history --show` can replay it."""
|
|
111
|
+
try:
|
|
112
|
+
ensure_dirs()
|
|
113
|
+
root = reports_dir()
|
|
114
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S")
|
|
116
|
+
path = (
|
|
117
|
+
root / f"{stamp}-{_slug(str(report.get('target', 'target')))}-{int(time.time() * 1000) % 1000}.json"
|
|
118
|
+
)
|
|
119
|
+
path.write_text(json.dumps(report, indent=2, ensure_ascii=False, default=str), "utf-8")
|
|
120
|
+
return path
|
|
121
|
+
except OSError:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def iter_log_files() -> list[Path]:
|
|
126
|
+
root = log_dir()
|
|
127
|
+
if not root.exists():
|
|
128
|
+
return []
|
|
129
|
+
return sorted(root.glob("*.jsonl"), reverse=True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_runs(limit: int = 20) -> list[dict[str, Any]]:
|
|
133
|
+
"""Collapse log lines into one summary per run, newest first."""
|
|
134
|
+
runs: dict[str, dict[str, Any]] = {}
|
|
135
|
+
order: list[str] = []
|
|
136
|
+
for path in iter_log_files():
|
|
137
|
+
try:
|
|
138
|
+
lines = path.read_text("utf-8").splitlines()
|
|
139
|
+
except OSError:
|
|
140
|
+
continue
|
|
141
|
+
for line in lines:
|
|
142
|
+
try:
|
|
143
|
+
record = json.loads(line)
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
continue
|
|
146
|
+
run_id = record.get("run_id")
|
|
147
|
+
if not run_id:
|
|
148
|
+
continue
|
|
149
|
+
if run_id not in runs:
|
|
150
|
+
runs[run_id] = {
|
|
151
|
+
"run_id": run_id,
|
|
152
|
+
"started": record.get("ts"),
|
|
153
|
+
"target": record.get("target"),
|
|
154
|
+
"target_type": record.get("target_type"),
|
|
155
|
+
"report": None,
|
|
156
|
+
"collectors_ok": 0,
|
|
157
|
+
"collectors_failed": 0,
|
|
158
|
+
"collectors_skipped": 0,
|
|
159
|
+
"risk": None,
|
|
160
|
+
"model": None,
|
|
161
|
+
}
|
|
162
|
+
order.append(run_id)
|
|
163
|
+
entry = runs[run_id]
|
|
164
|
+
kind = record.get("kind")
|
|
165
|
+
if kind == "collector":
|
|
166
|
+
if record.get("skipped"):
|
|
167
|
+
entry["collectors_skipped"] += 1
|
|
168
|
+
elif record.get("ok"):
|
|
169
|
+
entry["collectors_ok"] += 1
|
|
170
|
+
else:
|
|
171
|
+
entry["collectors_failed"] += 1
|
|
172
|
+
elif kind == "run_end":
|
|
173
|
+
entry["report"] = record.get("report_path")
|
|
174
|
+
entry["risk"] = record.get("risk_level")
|
|
175
|
+
entry["model"] = record.get("model")
|
|
176
|
+
entry["ended"] = record.get("ts")
|
|
177
|
+
summaries = list(reversed(order))[:limit]
|
|
178
|
+
return [runs[r] for r in summaries]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def find_report_for_run(run_id: str) -> str | None:
|
|
182
|
+
"""Return the archived report path recorded for a run id, if any."""
|
|
183
|
+
for path in iter_log_files():
|
|
184
|
+
try:
|
|
185
|
+
lines = path.read_text("utf-8").splitlines()
|
|
186
|
+
except OSError:
|
|
187
|
+
continue
|
|
188
|
+
for line in lines:
|
|
189
|
+
try:
|
|
190
|
+
record = json.loads(line)
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
continue
|
|
193
|
+
if record.get("run_id") == run_id and record.get("kind") == "run_end":
|
|
194
|
+
return record.get("report_path")
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_exists(run_id: str) -> bool:
|
|
199
|
+
return any(run["run_id"] == run_id for run in read_runs(limit=500))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def read_report(path: str | Path) -> dict[str, Any] | None:
|
|
203
|
+
try:
|
|
204
|
+
return json.loads(Path(path).read_text("utf-8"))
|
|
205
|
+
except (OSError, json.JSONDecodeError):
|
|
206
|
+
return None
|
osintkit/models.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Pydantic schemas for collector output, model output, and reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
RiskLevel = Literal["low", "medium", "high"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _utcnow_iso() -> str:
|
|
14
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _as_str_list(value: Any) -> list[str]:
|
|
18
|
+
if value is None:
|
|
19
|
+
return []
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
parts = [p.strip(" -•") for p in value.replace(";", "\n").split("\n")]
|
|
22
|
+
return [p for p in parts if p]
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
return [f"{k}: {v}" if v else str(k) for k, v in value.items()]
|
|
25
|
+
if isinstance(value, (list, tuple, set)):
|
|
26
|
+
out: list[str] = []
|
|
27
|
+
for item in value:
|
|
28
|
+
if isinstance(item, str):
|
|
29
|
+
if item.strip():
|
|
30
|
+
out.append(item.strip())
|
|
31
|
+
elif isinstance(item, dict):
|
|
32
|
+
out.extend(_as_str_list(item.get("value") or item.get("name") or item))
|
|
33
|
+
elif item is not None:
|
|
34
|
+
out.append(str(item))
|
|
35
|
+
return out
|
|
36
|
+
return [str(value)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CollectorResult(BaseModel):
|
|
40
|
+
"""One collector's outcome for one target. Never raises."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="allow")
|
|
43
|
+
|
|
44
|
+
collector: str
|
|
45
|
+
target: str
|
|
46
|
+
target_type: str
|
|
47
|
+
ok: bool = True
|
|
48
|
+
skipped: bool = False
|
|
49
|
+
note: str | None = None
|
|
50
|
+
error: str | None = None
|
|
51
|
+
data: Any = None
|
|
52
|
+
elapsed_ms: int = 0
|
|
53
|
+
cached: bool = False
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def has_data(self) -> bool:
|
|
57
|
+
if self.skipped or not self.ok or self.data is None:
|
|
58
|
+
return False
|
|
59
|
+
if isinstance(self.data, (list, dict, str)):
|
|
60
|
+
return len(self.data) > 0
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Breach(BaseModel):
|
|
65
|
+
model_config = ConfigDict(extra="allow")
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
date: str | None = None
|
|
69
|
+
data_types: list[str] = Field(default_factory=list)
|
|
70
|
+
source: str | None = None
|
|
71
|
+
|
|
72
|
+
@field_validator("name", "date", "source", mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _stringify(cls, value: Any) -> Any:
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
if isinstance(value, (list, tuple)):
|
|
78
|
+
return ", ".join(str(v) for v in value)
|
|
79
|
+
return str(value)
|
|
80
|
+
|
|
81
|
+
@field_validator("data_types", mode="before")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _types(cls, value: Any) -> list[str]:
|
|
84
|
+
return _as_str_list(value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AssociatedAccount(BaseModel):
|
|
88
|
+
model_config = ConfigDict(extra="allow")
|
|
89
|
+
|
|
90
|
+
platform: str
|
|
91
|
+
url: str | None = None
|
|
92
|
+
handle: str | None = None
|
|
93
|
+
note: str | None = None
|
|
94
|
+
|
|
95
|
+
@model_validator(mode="before")
|
|
96
|
+
@classmethod
|
|
97
|
+
def _normalize(cls, value: Any) -> Any:
|
|
98
|
+
if isinstance(value, str):
|
|
99
|
+
return {"platform": value}
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
@field_validator("platform", "url", "handle", "note", mode="before")
|
|
103
|
+
@classmethod
|
|
104
|
+
def _stringify(cls, value: Any) -> Any:
|
|
105
|
+
if value is None:
|
|
106
|
+
return None
|
|
107
|
+
if isinstance(value, (list, tuple)):
|
|
108
|
+
return ", ".join(str(v) for v in value)
|
|
109
|
+
return str(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Analysis(BaseModel):
|
|
113
|
+
"""The strict shape we demand back from the model."""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(extra="allow")
|
|
116
|
+
|
|
117
|
+
summary: str = ""
|
|
118
|
+
breaches: list[Breach] = Field(default_factory=list)
|
|
119
|
+
associated_accounts: list[AssociatedAccount] = Field(default_factory=list)
|
|
120
|
+
infrastructure_notes: list[str] = Field(default_factory=list)
|
|
121
|
+
subdomains: list[str] = Field(default_factory=list)
|
|
122
|
+
risk_level: RiskLevel = "low"
|
|
123
|
+
recommendations: list[str] = Field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
@field_validator("summary", mode="before")
|
|
126
|
+
@classmethod
|
|
127
|
+
def _summary(cls, value: Any) -> str:
|
|
128
|
+
if value is None:
|
|
129
|
+
return ""
|
|
130
|
+
if isinstance(value, (list, dict)):
|
|
131
|
+
return "\n".join(_as_str_list(value))
|
|
132
|
+
return str(value).strip()
|
|
133
|
+
|
|
134
|
+
@field_validator("subdomains", "recommendations", "infrastructure_notes", mode="before")
|
|
135
|
+
@classmethod
|
|
136
|
+
def _lists(cls, value: Any) -> list[str]:
|
|
137
|
+
return _as_str_list(value)
|
|
138
|
+
|
|
139
|
+
@field_validator("breaches", mode="before")
|
|
140
|
+
@classmethod
|
|
141
|
+
def _breaches(cls, value: Any) -> Any:
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
return [{"name": value}] if value.strip() else []
|
|
144
|
+
return value or []
|
|
145
|
+
|
|
146
|
+
@field_validator("associated_accounts", mode="before")
|
|
147
|
+
@classmethod
|
|
148
|
+
def _accounts(cls, value: Any) -> Any:
|
|
149
|
+
if isinstance(value, str):
|
|
150
|
+
return [{"platform": value}] if value.strip() else []
|
|
151
|
+
return value or []
|
|
152
|
+
|
|
153
|
+
@field_validator("subdomains")
|
|
154
|
+
@classmethod
|
|
155
|
+
def _dedupe(cls, value: list[str]) -> list[str]:
|
|
156
|
+
seen: set[str] = set()
|
|
157
|
+
out: list[str] = []
|
|
158
|
+
for item in value:
|
|
159
|
+
key = item.strip().lower()
|
|
160
|
+
if key and key not in seen:
|
|
161
|
+
seen.add(key)
|
|
162
|
+
out.append(item.strip())
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
@field_validator("risk_level", mode="before")
|
|
166
|
+
@classmethod
|
|
167
|
+
def _risk(cls, value: Any) -> Any:
|
|
168
|
+
if value is None:
|
|
169
|
+
return "low"
|
|
170
|
+
text = str(value).strip().lower()
|
|
171
|
+
for level in ("high", "medium", "low"):
|
|
172
|
+
if level in text:
|
|
173
|
+
return level
|
|
174
|
+
return "low"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Report(BaseModel):
|
|
178
|
+
"""A rendered-ready record of one target lookup."""
|
|
179
|
+
|
|
180
|
+
schema_version: str = "1"
|
|
181
|
+
target: str
|
|
182
|
+
target_type: str
|
|
183
|
+
generated_at: str = Field(default_factory=_utcnow_iso)
|
|
184
|
+
model: str | None = None
|
|
185
|
+
base_url: str | None = None
|
|
186
|
+
elapsed_ms: int = 0
|
|
187
|
+
collectors: list[CollectorResult] = Field(default_factory=list)
|
|
188
|
+
analysis: Analysis | None = None
|
|
189
|
+
notes: list[str] = Field(default_factory=list)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def ok_collectors(self) -> list[CollectorResult]:
|
|
193
|
+
return [c for c in self.collectors if c.has_data]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class PromptBundle(BaseModel):
|
|
197
|
+
"""The package handed to the model."""
|
|
198
|
+
|
|
199
|
+
target: str
|
|
200
|
+
target_type: str
|
|
201
|
+
collected_at: str = Field(default_factory=_utcnow_iso)
|
|
202
|
+
results: list[CollectorResult] = Field(default_factory=list)
|
|
203
|
+
signals: dict[str, Any] = Field(default_factory=dict)
|
|
204
|
+
|
|
205
|
+
def to_prompt_json(self) -> str:
|
|
206
|
+
payload = {
|
|
207
|
+
"target": self.target,
|
|
208
|
+
"target_type": self.target_type,
|
|
209
|
+
"signals": self.signals,
|
|
210
|
+
"sources": [
|
|
211
|
+
{
|
|
212
|
+
"source": r.collector,
|
|
213
|
+
"status": "ok" if r.ok else ("skipped" if r.skipped else "error"),
|
|
214
|
+
"note": r.note,
|
|
215
|
+
"data": r.data,
|
|
216
|
+
}
|
|
217
|
+
for r in self.results
|
|
218
|
+
if r.ok and not r.skipped
|
|
219
|
+
],
|
|
220
|
+
"unavailable_sources": [
|
|
221
|
+
{"source": r.collector, "reason": r.note or r.error or "unknown"}
|
|
222
|
+
for r in self.results
|
|
223
|
+
if not r.ok or r.skipped
|
|
224
|
+
],
|
|
225
|
+
}
|
|
226
|
+
import json
|
|
227
|
+
|
|
228
|
+
return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
|
osintkit/normalise.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Collapse duplicate breach records across sources before prompting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import CollectorResult
|
|
9
|
+
|
|
10
|
+
BREACH_SOURCES = frozenset({"hibp_breaches", "xposed_or_not", "leakcheck_public"})
|
|
11
|
+
KEY_RE = re.compile(r"[^a-z0-9]+")
|
|
12
|
+
CREDENTIAL_RE = re.compile(r"pass|hash|credential|secret|token|api[ _-]?key", re.I)
|
|
13
|
+
SEVERITY = ("low", "medium", "high")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def signals_for(merged: list[dict[str, Any]]) -> dict[str, Any]:
|
|
17
|
+
"""Compute risk signals the model can check itself against."""
|
|
18
|
+
credential = [
|
|
19
|
+
entry
|
|
20
|
+
for entry in merged
|
|
21
|
+
if any(CREDENTIAL_RE.search(str(kind)) for kind in entry.get("data_types", []))
|
|
22
|
+
]
|
|
23
|
+
count = len(merged)
|
|
24
|
+
return {
|
|
25
|
+
"breach_count": count,
|
|
26
|
+
"credential_bearing": len(credential),
|
|
27
|
+
"risk_floor": _floor(count, len(credential)),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _floor(count: int, credential: int) -> str:
|
|
32
|
+
if credential >= 3 or count >= 20:
|
|
33
|
+
return "high"
|
|
34
|
+
if credential >= 1 or count >= 5:
|
|
35
|
+
return "medium"
|
|
36
|
+
return "low"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def apply_floor(chosen: str, floor: str) -> str:
|
|
40
|
+
"""Never let the model rank below the deterministic floor."""
|
|
41
|
+
rank = {name: index for index, name in enumerate(SEVERITY)}
|
|
42
|
+
return chosen if rank.get(chosen, 0) >= rank.get(floor, 0) else floor
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _key(name: str) -> str:
|
|
46
|
+
return KEY_RE.sub("", name.lower())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _strings(value: Any) -> list[str]:
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
return [part.strip() for part in re.split(r"[;,]", value) if part.strip()]
|
|
52
|
+
if isinstance(value, list):
|
|
53
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _records(result: CollectorResult) -> list[dict[str, Any]]:
|
|
58
|
+
data = result.data
|
|
59
|
+
if isinstance(data, list):
|
|
60
|
+
return [entry for entry in data if isinstance(entry, dict)]
|
|
61
|
+
if isinstance(data, dict):
|
|
62
|
+
for field in ("breaches", "sources"):
|
|
63
|
+
entries = data.get(field)
|
|
64
|
+
if isinstance(entries, list):
|
|
65
|
+
return [entry for entry in entries if isinstance(entry, dict)]
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def merge_breaches(results: list[CollectorResult]) -> tuple[list[CollectorResult], int]:
|
|
70
|
+
"""Merge duplicate breach names across sources.
|
|
71
|
+
|
|
72
|
+
Returns the results to prompt with (breach sources replaced by one index)
|
|
73
|
+
and the number of duplicate records folded away.
|
|
74
|
+
"""
|
|
75
|
+
breach_results = [r for r in results if r.collector in BREACH_SOURCES and r.has_data]
|
|
76
|
+
if not breach_results:
|
|
77
|
+
return results, 0
|
|
78
|
+
|
|
79
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
80
|
+
duplicates = 0
|
|
81
|
+
for result in breach_results:
|
|
82
|
+
records = _records(result)
|
|
83
|
+
if not records:
|
|
84
|
+
continue
|
|
85
|
+
source = str(records[0].get("source") or result.collector)
|
|
86
|
+
for entry in records:
|
|
87
|
+
name = str(entry.get("name") or "").strip()
|
|
88
|
+
if not name:
|
|
89
|
+
continue
|
|
90
|
+
key = _key(name)
|
|
91
|
+
existing = merged.get(key)
|
|
92
|
+
if existing is None:
|
|
93
|
+
merged[key] = {
|
|
94
|
+
"name": name,
|
|
95
|
+
"date": entry.get("date"),
|
|
96
|
+
"data_types": set(_strings(entry.get("data_types"))),
|
|
97
|
+
"sources": {source},
|
|
98
|
+
}
|
|
99
|
+
continue
|
|
100
|
+
duplicates += 1
|
|
101
|
+
if not existing["date"] and entry.get("date"):
|
|
102
|
+
existing["date"] = entry["date"]
|
|
103
|
+
existing["data_types"].update(_strings(entry.get("data_types")))
|
|
104
|
+
existing["sources"].add(source)
|
|
105
|
+
|
|
106
|
+
if not merged:
|
|
107
|
+
return results, duplicates
|
|
108
|
+
|
|
109
|
+
rendered = [
|
|
110
|
+
{
|
|
111
|
+
"name": item["name"],
|
|
112
|
+
"date": item["date"],
|
|
113
|
+
"data_types": sorted(item["data_types"]),
|
|
114
|
+
"sources": sorted(item["sources"]),
|
|
115
|
+
"source": ", ".join(sorted(item["sources"])),
|
|
116
|
+
}
|
|
117
|
+
for item in merged.values()
|
|
118
|
+
]
|
|
119
|
+
rendered.sort(key=lambda item: (-len(item["data_types"]), item["name"].lower()))
|
|
120
|
+
|
|
121
|
+
index = CollectorResult(
|
|
122
|
+
collector="breach_index",
|
|
123
|
+
target=breach_results[0].target,
|
|
124
|
+
target_type=breach_results[0].target_type,
|
|
125
|
+
data=rendered,
|
|
126
|
+
)
|
|
127
|
+
kept = [r for r in results if r.collector not in BREACH_SOURCES]
|
|
128
|
+
kept.extend(r for r in results if r.collector in BREACH_SOURCES and r.skipped)
|
|
129
|
+
position = next((i for i, r in enumerate(results) if r.collector in BREACH_SOURCES), len(kept))
|
|
130
|
+
kept.insert(min(position, len(kept)), index)
|
|
131
|
+
return kept, duplicates
|