osintkit 0.1.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (82) hide show
  1. {osintkit-0.1.2 → osintkit-0.2.0}/CHANGELOG.md +10 -0
  2. {osintkit-0.1.2 → osintkit-0.2.0}/PKG-INFO +6 -4
  3. {osintkit-0.1.2 → osintkit-0.2.0}/README.md +5 -3
  4. {osintkit-0.1.2 → osintkit-0.2.0}/docs/REFERENCE.md +8 -1
  5. {osintkit-0.1.2 → osintkit-0.2.0}/examples/README.md +7 -0
  6. {osintkit-0.1.2 → osintkit-0.2.0}/pyproject.toml +1 -1
  7. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/__init__.py +1 -1
  8. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/cli.py +16 -2
  9. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/config.py +1 -0
  10. osintkit-0.2.0/src/osintkit/offline.py +205 -0
  11. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/pipeline.py +15 -1
  12. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_cli.py +25 -0
  13. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_collectors.py +7 -0
  14. osintkit-0.2.0/tests/test_offline.py +99 -0
  15. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_pipeline.py +9 -0
  16. {osintkit-0.1.2 → osintkit-0.2.0}/.editorconfig +0 -0
  17. {osintkit-0.1.2 → osintkit-0.2.0}/.github/CODEOWNERS +0 -0
  18. {osintkit-0.1.2 → osintkit-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  19. {osintkit-0.1.2 → osintkit-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  20. {osintkit-0.1.2 → osintkit-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  21. {osintkit-0.1.2 → osintkit-0.2.0}/.github/dependabot.yml +0 -0
  22. {osintkit-0.1.2 → osintkit-0.2.0}/.github/pull_request_template.md +0 -0
  23. {osintkit-0.1.2 → osintkit-0.2.0}/.github/workflows/ci.yml +0 -0
  24. {osintkit-0.1.2 → osintkit-0.2.0}/.github/workflows/codeql.yml +0 -0
  25. {osintkit-0.1.2 → osintkit-0.2.0}/.github/workflows/publish.yml +0 -0
  26. {osintkit-0.1.2 → osintkit-0.2.0}/.github/workflows/release.yml +0 -0
  27. {osintkit-0.1.2 → osintkit-0.2.0}/.github/workflows/smoke.yml +0 -0
  28. {osintkit-0.1.2 → osintkit-0.2.0}/.gitignore +0 -0
  29. {osintkit-0.1.2 → osintkit-0.2.0}/.mailmap +0 -0
  30. {osintkit-0.1.2 → osintkit-0.2.0}/.pre-commit-config.yaml +0 -0
  31. {osintkit-0.1.2 → osintkit-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  32. {osintkit-0.1.2 → osintkit-0.2.0}/CONTRIBUTING.md +0 -0
  33. {osintkit-0.1.2 → osintkit-0.2.0}/LICENSE +0 -0
  34. {osintkit-0.1.2 → osintkit-0.2.0}/SECURITY.md +0 -0
  35. {osintkit-0.1.2 → osintkit-0.2.0}/docs/architecture.md +0 -0
  36. {osintkit-0.1.2 → osintkit-0.2.0}/docs/demo.svg +0 -0
  37. {osintkit-0.1.2 → osintkit-0.2.0}/docs/sample-output.md +0 -0
  38. {osintkit-0.1.2 → osintkit-0.2.0}/docs/social-preview.png +0 -0
  39. {osintkit-0.1.2 → osintkit-0.2.0}/examples/targets.csv +0 -0
  40. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/__init__.py +0 -0
  41. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/accounts.py +0 -0
  42. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/base.py +0 -0
  43. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/breaches.py +0 -0
  44. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/ct.py +0 -0
  45. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/dns.py +0 -0
  46. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/github.py +0 -0
  47. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/pastes.py +0 -0
  48. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/ports.py +1 -1
  49. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/subdomains.py +0 -0
  50. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  51. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/diff.py +0 -0
  52. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/doctor.py +0 -0
  53. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/errors.py +0 -0
  54. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/http.py +0 -0
  55. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/llm.py +0 -0
  56. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/logging_store.py +0 -0
  57. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/models.py +0 -0
  58. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/normalise.py +0 -0
  59. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/py.typed +0 -0
  60. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/render.py +0 -0
  61. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/retention.py +0 -0
  62. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/synthesis.py +0 -0
  63. {osintkit-0.1.2 → osintkit-0.2.0}/src/osintkit/targets.py +0 -0
  64. {osintkit-0.1.2 → osintkit-0.2.0}/tests/conftest.py +0 -0
  65. {osintkit-0.1.2 → osintkit-0.2.0}/tests/fixtures/model_reply.json +0 -0
  66. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_accounts.py +0 -0
  67. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_breaches.py +0 -0
  68. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_config.py +0 -0
  69. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_ct.py +0 -0
  70. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_diff.py +0 -0
  71. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_doctor.py +0 -0
  72. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_e2e.py +0 -0
  73. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_http.py +0 -0
  74. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_llm.py +0 -0
  75. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_normalise.py +0 -0
  76. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_pastes.py +0 -0
  77. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_render.py +0 -0
  78. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_retention.py +0 -0
  79. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_synthesis.py +0 -0
  80. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_targets.py +0 -0
  81. {osintkit-0.1.2 → osintkit-0.2.0}/tests/test_whois.py +0 -0
  82. {osintkit-0.1.2 → osintkit-0.2.0}/uv.lock +0 -0
@@ -6,6 +6,16 @@ All notable changes are recorded here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.2.0 — 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `--offline` mode: a full report built from local rules, no model and no key
14
+
15
+ ### Fixed
16
+
17
+ - the ports collector was registered as a sync helper, so `--ports` and the registry entry broke
18
+
9
19
  ## 0.1.2 — 2026-11-02
10
20
 
11
21
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osintkit
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Public-data OSINT aggregator for emails, usernames, domains, and companies
5
5
  Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/osintkit
6
6
  Project-URL: Issues, https://github.com/sjdsjadjajdjsa/osintkit/issues
@@ -79,8 +79,9 @@ osintkit doctor --deep # prove the key and endpoint work
79
79
  osintkit lookup alice@example.com
80
80
  ```
81
81
 
82
- No key? `osintkit lookup example.com --raw` still collects everything; `--dry-run` shows the
83
- exact synthesis prompt.
82
+ No key? `osintkit lookup example.com --offline` builds the whole report locally from rule checks,
83
+ no model involved. `--raw` gives the collected JSON and `--dry-run` prints the prompt a model
84
+ would receive.
84
85
 
85
86
  Any OpenAI-compatible endpoint works. `--provider venice|openrouter|openai` picks a preset, and
86
87
  a recognisable key prefix picks one for you; `--base-url` and `--model` always win.
@@ -102,7 +103,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
102
103
  | `schema` | The JSON contract the model must satisfy |
103
104
 
104
105
  Output: default colour report, `--json`, `--markdown` (they combine), `--raw` for collected
105
- data, `--dry-run` for the prompt. Exit codes: `0` ok, `1` synthesis or partial failure, `2` bad
106
+ data, `--offline` for a local report with no model, `--dry-run` for the prompt. Exit codes: `0` ok, `1` synthesis or partial failure, `2` bad
106
107
  input.
107
108
 
108
109
  ## How it works
@@ -115,6 +116,7 @@ target -> type detection -> collectors (parallel, cached, retried)
115
116
  |
116
117
  v
117
118
  LLM briefing -> pydantic validation -> one repair retry
119
+ | (--offline replaces this with local rules)
118
120
  |
119
121
  v
120
122
  colour report . JSON . Markdown . archive
@@ -47,8 +47,9 @@ osintkit doctor --deep # prove the key and endpoint work
47
47
  osintkit lookup alice@example.com
48
48
  ```
49
49
 
50
- No key? `osintkit lookup example.com --raw` still collects everything; `--dry-run` shows the
51
- exact synthesis prompt.
50
+ No key? `osintkit lookup example.com --offline` builds the whole report locally from rule checks,
51
+ no model involved. `--raw` gives the collected JSON and `--dry-run` prints the prompt a model
52
+ would receive.
52
53
 
53
54
  Any OpenAI-compatible endpoint works. `--provider venice|openrouter|openai` picks a preset, and
54
55
  a recognisable key prefix picks one for you; `--base-url` and `--model` always win.
@@ -70,7 +71,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
70
71
  | `schema` | The JSON contract the model must satisfy |
71
72
 
72
73
  Output: default colour report, `--json`, `--markdown` (they combine), `--raw` for collected
73
- data, `--dry-run` for the prompt. Exit codes: `0` ok, `1` synthesis or partial failure, `2` bad
74
+ data, `--offline` for a local report with no model, `--dry-run` for the prompt. Exit codes: `0` ok, `1` synthesis or partial failure, `2` bad
74
75
  input.
75
76
 
76
77
  ## How it works
@@ -83,6 +84,7 @@ target -> type detection -> collectors (parallel, cached, retried)
83
84
  |
84
85
  v
85
86
  LLM briefing -> pydantic validation -> one repair retry
87
+ | (--offline replaces this with local rules)
86
88
  |
87
89
  v
88
90
  colour report . JSON . Markdown . archive
@@ -49,9 +49,16 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
49
49
  `--max-connections`, `--max-attempts`, `--no-cache`, `--cache-ttl`, `--resolver` (repeatable),
50
50
  `-v/--verbose`, `--version`.
51
51
 
52
- Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--dry-run`, `--only`, `--skip`,
52
+ Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
53
53
  `--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
54
54
 
55
+ ## Offline mode
56
+
57
+ `--offline` skips the model: breaches, accounts, hosts, and policy gaps are rendered from the
58
+ collected data, risk comes from the same deterministic floor (plus open risky ports and a
59
+ wildcard with no DMARC), and recommendations come from rule checks. Summary text says what was
60
+ and was not collected. Useful without a key, in CI, and when a provider is down.
61
+
55
62
  ## Output contract
56
63
 
57
64
  `osintkit schema` prints the JSON Schema. Synthesis must return `summary`, `breaches[]`,
@@ -36,3 +36,10 @@ Narrow a port scan:
36
36
  ```bash
37
37
  osintkit lookup --domain example.com --only tcp_ports --ports 22,443
38
38
  ```
39
+
40
+ No key at all:
41
+
42
+ ```bash
43
+ osintkit lookup --domain example.com --offline
44
+ osintkit batch --file targets.csv --offline --json
45
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Public-data OSINT aggregator for emails, usernames, domains, and companies"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """osintkit: a public-data OSINT aggregator."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -235,13 +235,20 @@ def lookup(
235
235
  out_path: Path | None = typer.Option(None, "--out", "-o", help="Markdown destination."),
236
236
  dry_run: bool = typer.Option(False, "--dry-run", help="Print the prompt, skip the model."),
237
237
  raw: bool = typer.Option(False, "--raw", help="Dump collected data as JSON, skip the model."),
238
+ offline: bool = typer.Option(False, "--offline", help="Report from collected data only; no model, no key."),
238
239
  only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
239
240
  skip: list[str] | None = typer.Option(None, "--skip", help="Skip these collectors."),
240
241
  ports: list[str] | None = typer.Option(None, "--ports", help="Ports to probe, e.g. 22,443."),
241
242
  ) -> None:
242
243
  """Look up one target."""
243
244
  out = _console()
244
- settings = _settings(output_json=json_out, output_markdown=markdown, dry_run=dry_run, raw=raw)
245
+ settings = _settings(
246
+ output_json=json_out,
247
+ output_markdown=markdown,
248
+ dry_run=dry_run,
249
+ raw=raw,
250
+ offline=offline,
251
+ )
245
252
  try:
246
253
  settings.extra["ports"] = _parse_ports(ports)
247
254
  except typer.BadParameter as exc:
@@ -317,6 +324,7 @@ def batch(
317
324
  out_dir: Path | None = typer.Option(None, "--out", "-o", help="Markdown output directory."),
318
325
  dry_run: bool = typer.Option(False, "--dry-run", help="Print prompts, skip the model."),
319
326
  raw: bool = typer.Option(False, "--raw", help="JSON array of collected data, skip the model."),
327
+ offline: bool = typer.Option(False, "--offline", help="Report from collected data only; no model, no key."),
320
328
  fail_fast: bool = typer.Option(False, "--fail-fast", help="Stop reporting at the first error."),
321
329
  concurrency: int = typer.Option(3, "--concurrency", "-c", min=1, max=10, help="Targets to run at once."),
322
330
  only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
@@ -325,7 +333,13 @@ def batch(
325
333
  ) -> None:
326
334
  """Run every target in a CSV."""
327
335
  out = _console()
328
- settings = _settings(output_json=json_out, output_markdown=markdown, dry_run=dry_run, raw=raw)
336
+ settings = _settings(
337
+ output_json=json_out,
338
+ output_markdown=markdown,
339
+ dry_run=dry_run,
340
+ raw=raw,
341
+ offline=offline,
342
+ )
329
343
  try:
330
344
  settings.extra["ports"] = _parse_ports(ports)
331
345
  except typer.BadParameter as exc:
@@ -79,6 +79,7 @@ class Settings:
79
79
  cache_ttl: int = DEFAULT_CACHE_TTL
80
80
  resolvers: list[str] = field(default_factory=lambda: list(DEFAULT_RESOLVERS))
81
81
  dry_run: bool = False
82
+ offline: bool = False
82
83
  raw: bool = False
83
84
  output_json: bool = False
84
85
  output_markdown: bool = False
@@ -0,0 +1,205 @@
1
+ """Build a report from collected data alone, with no model call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .models import Analysis, AssociatedAccount, Breach, CollectorResult
8
+ from .normalise import apply_floor, signals_for
9
+
10
+ RISKY_PORTS = {
11
+ 21: "FTP is plaintext",
12
+ 23: "Telnet is plaintext",
13
+ 445: "SMB should not face the internet",
14
+ 3306: "MySQL is exposed",
15
+ 5432: "PostgreSQL is exposed",
16
+ 6379: "Redis is often unauthenticated",
17
+ 9200: "Elasticsearch is often unauthenticated",
18
+ 27017: "MongoDB is often unauthenticated",
19
+ }
20
+ MAX_SUBDOMAINS = 25
21
+
22
+
23
+ def _data(results: list[CollectorResult], name: str) -> Any:
24
+ for result in results:
25
+ if result.collector == name and result.has_data:
26
+ return result.data
27
+ return None
28
+
29
+
30
+ def _found_accounts(results: list[CollectorResult]) -> list[AssociatedAccount]:
31
+ accounts: list[AssociatedAccount] = []
32
+ presence = _data(results, "username_presence")
33
+ if isinstance(presence, dict):
34
+ for hit in presence.get("found") or []:
35
+ accounts.append(
36
+ AssociatedAccount(
37
+ platform=str(hit.get("site")),
38
+ url=hit.get("url"),
39
+ handle=str(presence.get("handle") or ""),
40
+ note="profile URL answers 200; ownership unconfirmed",
41
+ )
42
+ )
43
+ profile = _data(results, "github_profile")
44
+ if isinstance(profile, dict):
45
+ user = profile.get("profile") or {}
46
+ if user.get("login"):
47
+ accounts.append(
48
+ AssociatedAccount(
49
+ platform="GitHub",
50
+ url=user.get("url"),
51
+ handle=user.get("login"),
52
+ note="returned by the GitHub API",
53
+ )
54
+ )
55
+ return accounts
56
+
57
+
58
+ def _subdomains(results: list[CollectorResult]) -> list[str]:
59
+ names: list[str] = []
60
+ passive = _data(results, "passive_subdomains")
61
+ if isinstance(passive, dict):
62
+ names.extend(str(row.get("host")) for row in passive.get("live_hosts") or [])
63
+ names.extend(str(row.get("host")) for row in passive.get("common_prefix_hits") or [])
64
+ certs = _data(results, "crt_sh_subdomains")
65
+ if isinstance(certs, list):
66
+ names.extend(str(name) for name in certs)
67
+ seen: list[str] = []
68
+ for name in names:
69
+ if name not in seen:
70
+ seen.append(name)
71
+ return seen[:MAX_SUBDOMAINS]
72
+
73
+
74
+ def _infrastructure(results: list[CollectorResult], report_notes: list[str]) -> list[str]:
75
+ notes: list[str] = []
76
+ mail = _data(results, "dns_mail_posture")
77
+ if isinstance(mail, dict):
78
+ if mail.get("spf_present"):
79
+ notes.append(f"SPF present, all-qualifier {mail.get('spf_all') or 'unknown'}")
80
+ else:
81
+ notes.append("No SPF record: sender enforcement is missing")
82
+ if mail.get("dmarc_present"):
83
+ notes.append(f"DMARC present with policy {mail.get('dmarc_policy') or 'unknown'}")
84
+ else:
85
+ notes.append("No DMARC record: spoofing is not instructed")
86
+ selectors = mail.get("dkim_selectors_found") or []
87
+ if selectors:
88
+ notes.append(f"{len(selectors)} DKIM selectors answer")
89
+
90
+ wildcard = _data(results, "dns_wildcard")
91
+ if isinstance(wildcard, dict) and wildcard.get("wildcard"):
92
+ notes.append("Wildcard DNS is enabled, so unknown labels resolve")
93
+
94
+ whois = _data(results, "whois_registration")
95
+ if isinstance(whois, dict) and whois.get("registrar"):
96
+ parts = [f"registrar {whois['registrar']}"]
97
+ if whois.get("creation_date"):
98
+ parts.append(f"registered {whois['creation_date']}")
99
+ if whois.get("expiration_date"):
100
+ parts.append(f"expires {whois['expiration_date']}")
101
+ notes.append(", ".join(parts))
102
+
103
+ ports = _data(results, "tcp_ports")
104
+ if isinstance(ports, dict):
105
+ for entry in ports.get("open") or []:
106
+ port = entry.get("port")
107
+ if port in RISKY_PORTS:
108
+ notes.append(f"port {port} open: {RISKY_PORTS[port]}")
109
+ if not ports.get("open"):
110
+ notes.append("No probed port accepted a connection")
111
+
112
+ dns = _data(results, "dns_records")
113
+ if isinstance(dns, dict) and dns.get("MX") == ["0 ."]:
114
+ notes.append("Null MX: the domain accepts no mail")
115
+ notes.extend(report_notes)
116
+ return notes
117
+
118
+
119
+ def _recommendations(
120
+ creds: int, mail: dict | None, wildcard: bool, ports: list[int], breach_count: int
121
+ ) -> list[str]:
122
+ recs: list[str] = []
123
+ if creds:
124
+ recs.append("Rotate passwords for the affected services and enable MFA where offered")
125
+ elif breach_count:
126
+ recs.append("Confirm whether the breach set carries credentials before rotating")
127
+ if mail is not None:
128
+ if not mail.get("dmarc_present"):
129
+ recs.append("Publish a DMARC record, starting at p=none with reporting")
130
+ elif mail.get("dmarc_policy") in {"none", "quarantine"}:
131
+ recs.append(f"Move DMARC from p={mail.get('dmarc_policy')} to p=reject once reporting is clean")
132
+ if not mail.get("spf_present"):
133
+ recs.append("Publish an SPF record ending in -all")
134
+ elif mail.get("spf_all") != "-all":
135
+ recs.append("Tighten the SPF all-qualifier to -all")
136
+ if wildcard:
137
+ recs.append("Inventory wildcard-backed hostnames and remove ones no longer served")
138
+ if ports:
139
+ recs.append(f"Close or firewall public ports {', '.join(str(p) for p in ports)}")
140
+ if not recs:
141
+ recs.append("Re-run with a model key for a narrated briefing, or widen the collector set")
142
+ return recs
143
+
144
+
145
+ def build(results: list[CollectorResult], report_notes: list[str], target: str) -> Analysis:
146
+ breaches_raw = next((r.data for r in results if r.collector == "breach_index" and r.has_data), [])
147
+ signals = signals_for(breaches_raw)
148
+ accounts = _found_accounts(results)
149
+ subdomains = _subdomains(results)
150
+ mail = _data(results, "dns_mail_posture")
151
+ wildcard = bool((_data(results, "dns_wildcard") or {}).get("wildcard"))
152
+ ports_data = _data(results, "tcp_ports") or {}
153
+ risky = [entry.get("port") for entry in ports_data.get("open") or [] if entry.get("port") in RISKY_PORTS]
154
+ infrastructure = _infrastructure(results, report_notes)
155
+
156
+ collected = [r.collector for r in results if r.has_data]
157
+ skipped = [r.collector for r in results if r.skipped]
158
+ failed = [r.collector for r in results if not r.ok and not r.skipped]
159
+
160
+ summary_parts = [
161
+ f"Collected public data for {target} from {len(collected)} of "
162
+ f"{len(results)} sources, with no model call."
163
+ ]
164
+ if signals["breach_count"]:
165
+ summary_parts.append(
166
+ f"{signals['breach_count']} breach records mention the target, "
167
+ f"{signals['credential_bearing']} of them credential-bearing."
168
+ )
169
+ if accounts:
170
+ summary_parts.append(f"{len(accounts)} public profiles answered.")
171
+ if subdomains:
172
+ summary_parts.append(f"{len(subdomains)} hostnames were discovered.")
173
+ if failed:
174
+ summary_parts.append(f"Failed sources: {', '.join(failed)}.")
175
+ if skipped:
176
+ summary_parts.append(f"Skipped sources: {', '.join(skipped)}.")
177
+ summary_parts.append("Risk is computed from counts and policy checks, not narrated.")
178
+
179
+ risk = apply_floor("low", signals["risk_floor"])
180
+ if risky or (wildcard and isinstance(mail, dict) and not mail.get("dmarc_present")):
181
+ risk = apply_floor(risk, "medium")
182
+
183
+ return Analysis(
184
+ summary=" ".join(summary_parts),
185
+ breaches=[
186
+ Breach(
187
+ name=str(row.get("name")),
188
+ date=row.get("date"),
189
+ data_types=list(row.get("data_types") or []),
190
+ source=row.get("source"),
191
+ )
192
+ for row in breaches_raw[:15]
193
+ ],
194
+ associated_accounts=accounts,
195
+ infrastructure_notes=infrastructure,
196
+ subdomains=subdomains,
197
+ risk_level=risk,
198
+ recommendations=_recommendations(
199
+ signals["credential_bearing"],
200
+ mail if isinstance(mail, dict) else None,
201
+ wildcard,
202
+ risky,
203
+ signals["breach_count"],
204
+ ),
205
+ )
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
 
15
15
  from rich.console import Console
16
16
 
17
- from . import collectors, normalise, render, retention, synthesis
17
+ from . import collectors, normalise, offline, render, retention, synthesis
18
18
  from .config import Settings
19
19
  from .errors import MissingAPIKey, OsintKitError, SynthesisError, TargetParseError
20
20
  from .http import HttpClient
@@ -182,6 +182,20 @@ async def execute(
182
182
  logger.event("run_end", raw=True, report_path=str(archived) if archived else None)
183
183
  return Outcome(report=report, extra={"bundle": bundle}, archived=archived)
184
184
 
185
+ if settings.offline:
186
+ report.model = None
187
+ report.analysis = offline.build(prompt_results, report.notes, target)
188
+ report.notes.append("offline mode: risk computed locally, no model call")
189
+ report.elapsed_ms = int((time.perf_counter() - started) * 1000)
190
+ archived = archive_report(report.model_dump(mode="json"))
191
+ logger.event(
192
+ "run_end",
193
+ offline=True,
194
+ report_path=str(archived) if archived else None,
195
+ risk_level=report.analysis.risk_level,
196
+ )
197
+ return Outcome(report=report, archived=archived)
198
+
185
199
  if settings.dry_run:
186
200
  report.notes.append("dry run: model not called")
187
201
  report.elapsed_ms = int((time.perf_counter() - started) * 1000)
@@ -447,3 +447,28 @@ def test_collectors_json():
447
447
 
448
448
  def fake_name():
449
449
  return "fake_source"
450
+
451
+
452
+ def test_offline_mode_needs_no_key(monkeypatch):
453
+ monkeypatch.delenv("API_KEY")
454
+ monkeypatch.setattr(pipeline, "LLMClient", None)
455
+ result = runner.invoke(app, ["lookup", "--domain", "example.com", "--offline"])
456
+ assert result.exit_code == 0
457
+ assert "no model call" in result.output
458
+
459
+
460
+ def test_offline_json_is_a_normal_report(monkeypatch):
461
+ monkeypatch.delenv("API_KEY")
462
+ result = runner.invoke(app, ["lookup", "--domain", "example.com", "--offline", "--json"])
463
+ payload = jsonish(result.output)
464
+ assert payload["analysis"]["risk_level"] == "low"
465
+ assert payload["target"] == "example.com"
466
+
467
+
468
+ def test_offline_batch(monkeypatch, tmp_path):
469
+ monkeypatch.delenv("API_KEY")
470
+ csv_file = tmp_path / "t.csv"
471
+ csv_file.write_text("a.com\n")
472
+ result = runner.invoke(app, ["batch", "--file", str(csv_file), "--offline", "--json"])
473
+ assert result.exit_code == 0
474
+ assert jsonish(result.output)[0]["analysis"]["risk_level"] == "low"
@@ -477,3 +477,10 @@ async def test_ports_override_limits_the_scan(make_context, monkeypatch):
477
477
  def test_selected_ports_keeps_order_and_marks_unknown():
478
478
  assert ports.selected_ports(["443", "9999"]) == {443: "https", 9999: "unknown"}
479
479
  assert ports.selected_ports(None)[22] == "ssh"
480
+
481
+
482
+ def test_every_registered_collector_is_coroutine():
483
+ import inspect
484
+
485
+ for spec in base.REGISTRY:
486
+ assert inspect.iscoroutinefunction(spec.func), f"{spec.name} is not async"
@@ -0,0 +1,99 @@
1
+ from osintkit import offline
2
+ from osintkit.models import CollectorResult
3
+
4
+
5
+ def result(name, data, **kwargs):
6
+ return CollectorResult(collector=name, target="example.com", target_type="domain", data=data, **kwargs)
7
+
8
+
9
+ def test_breaches_drive_risk_and_rotation():
10
+ results = [
11
+ result("breach_index", [{"name": "Acme", "data_types": ["Passwords"], "source": "x"}]),
12
+ result("breach_index", []),
13
+ ]
14
+ analysis = offline.build(results, [], "a@b.co")
15
+ assert analysis.risk_level == "medium"
16
+ assert analysis.breaches[0].name == "Acme"
17
+ assert "Rotate" in analysis.recommendations[0]
18
+
19
+
20
+ def test_missing_dmarc_is_recommended():
21
+ results = [result("dns_mail_posture", {"spf_present": True, "spf_all": "-all", "dmarc_present": False})]
22
+ analysis = offline.build(results, [], "example.com")
23
+ assert any("DMARC" in note for note in analysis.infrastructure_notes)
24
+ assert any("DMARC" in rec for rec in analysis.recommendations)
25
+
26
+
27
+ def test_loose_spf_is_recommended():
28
+ results = [
29
+ result(
30
+ "dns_mail_posture",
31
+ {"spf_present": True, "spf_all": "~all", "dmarc_present": True, "dmarc_policy": "reject"},
32
+ )
33
+ ]
34
+ analysis = offline.build(results, [], "example.com")
35
+ assert any("-all" in rec for rec in analysis.recommendations)
36
+
37
+
38
+ def test_risky_port_raises_risk_and_is_noted():
39
+ results = [result("tcp_ports", {"open": [{"port": 6379, "service": "redis"}]})]
40
+ analysis = offline.build(results, [], "example.com")
41
+ assert analysis.risk_level == "medium"
42
+ assert any("6379" in note for note in analysis.infrastructure_notes)
43
+ assert any("firewall" in rec for rec in analysis.recommendations)
44
+
45
+
46
+ def test_accounts_come_from_presence_and_profile():
47
+ results = [
48
+ result(
49
+ "username_presence",
50
+ {"handle": "alice", "found": [{"site": "GitHub", "url": "https://github.com/alice"}]},
51
+ ),
52
+ result("github_profile", {"profile": {"login": "alice", "url": "https://github.com/alice"}}),
53
+ ]
54
+ analysis = offline.build(results, [], "alice")
55
+ assert [account.platform for account in analysis.associated_accounts] == ["GitHub", "GitHub"]
56
+ assert "unconfirmed" in analysis.associated_accounts[0].note
57
+
58
+
59
+ def test_subdomains_are_deduped_and_capped():
60
+ hosts = [{"host": f"h{index}.example.com"} for index in range(40)]
61
+ results = [
62
+ result("passive_subdomains", {"live_hosts": hosts}),
63
+ result("crt_sh_subdomains", ["h0.example.com"]),
64
+ ]
65
+ analysis = offline.build(results, [], "example.com")
66
+ assert len(analysis.subdomains) == offline.MAX_SUBDOMAINS
67
+ assert analysis.subdomains[0] == "h0.example.com"
68
+
69
+
70
+ def test_wildcard_without_dmarc_is_medium():
71
+ results = [
72
+ result("dns_wildcard", {"wildcard": True}),
73
+ result(
74
+ "dns_mail_posture",
75
+ {"dmarc_present": True, "spf_present": True, "spf_all": "-all", "dmarc_policy": "reject"},
76
+ ),
77
+ ]
78
+ analysis = offline.build(results, [], "example.com")
79
+ assert any("Wildcard" in note for note in analysis.infrastructure_notes)
80
+ assert any("wildcard" in rec for rec in analysis.recommendations)
81
+
82
+
83
+ def test_gaps_are_reported_in_summary():
84
+ results = [
85
+ CollectorResult(collector="hibp_breaches", target="t", target_type="email", ok=False, skipped=True),
86
+ CollectorResult(collector="dns_records", target="t", target_type="email", ok=False, error="boom"),
87
+ result("dns_records", {"A": ["1.2.3.4"]}),
88
+ ]
89
+ analysis = offline.build(results, ["carried note"], "t")
90
+ assert "Failed sources: dns_records" in analysis.summary
91
+ assert "Skipped sources: hibp_breaches" in analysis.summary
92
+ assert "carried note" in analysis.infrastructure_notes
93
+
94
+
95
+ def test_empty_collection_still_yields_a_report():
96
+ analysis = offline.build([], [], "example.com")
97
+ assert analysis.risk_level == "low"
98
+ assert analysis.breaches == []
99
+ assert analysis.recommendations
@@ -185,3 +185,12 @@ async def test_no_breaches_keep_the_model_choice(settings, monkeypatch, console)
185
185
  monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
186
186
  outcome = await pipeline.execute("a@b.co", settings, RunLogger("floor2"), console)
187
187
  assert outcome.report.analysis.risk_level == "low"
188
+
189
+
190
+ async def test_offline_builds_analysis_without_model(settings, fake_collector, monkeypatch, console):
191
+ monkeypatch.setattr(pipeline, "LLMClient", None)
192
+ settings.offline = True
193
+ outcome = await pipeline.execute("example.com", settings, RunLogger("off"), console)
194
+ assert outcome.report.analysis is not None
195
+ assert outcome.report.model is None
196
+ assert any("offline" in note for note in outcome.report.notes)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -59,7 +59,6 @@ async def _probe(host: str, port: int, service: str, semaphore: asyncio.Semaphor
59
59
  return {"port": port, "service": service, "banner": banner or None}
60
60
 
61
61
 
62
- @collector("tcp_ports", ("domain",), "Open TCP ports on common services", order=80, ttl=3600)
63
62
  def selected_ports(override: list[str] | None) -> dict[int, str]:
64
63
  if not override:
65
64
  return dict(PORTS)
@@ -70,6 +69,7 @@ def selected_ports(override: list[str] | None) -> dict[int, str]:
70
69
  return dict(sorted(chosen.items()))
71
70
 
72
71
 
72
+ @collector("tcp_ports", ("domain",), "Open TCP ports on common services", order=80, ttl=3600)
73
73
  async def tcp_ports(ctx: Context) -> dict:
74
74
  ports = selected_ports(ctx.settings.extra.get("ports"))
75
75
  host = ctx.target.lower()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes