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/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