osintkit 0.1.2__tar.gz → 0.3.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.
- {osintkit-0.1.2 → osintkit-0.3.0}/CHANGELOG.md +22 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/PKG-INFO +12 -7
- {osintkit-0.1.2 → osintkit-0.3.0}/README.md +11 -6
- {osintkit-0.1.2 → osintkit-0.3.0}/docs/REFERENCE.md +21 -5
- {osintkit-0.1.2 → osintkit-0.3.0}/examples/README.md +7 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/pyproject.toml +1 -1
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/__init__.py +1 -1
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/cli.py +29 -6
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/config.py +47 -2
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/doctor.py +24 -8
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/llm.py +2 -2
- osintkit-0.3.0/src/osintkit/offline.py +205 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/pipeline.py +19 -3
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_cli.py +61 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_collectors.py +7 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_doctor.py +21 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_llm.py +13 -0
- osintkit-0.3.0/tests/test_offline.py +99 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_pipeline.py +9 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.editorconfig +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/CODEOWNERS +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/dependabot.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/pull_request_template.md +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/workflows/ci.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/workflows/codeql.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/workflows/publish.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/workflows/release.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.github/workflows/smoke.yml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.gitignore +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.mailmap +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/.pre-commit-config.yaml +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/CONTRIBUTING.md +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/LICENSE +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/SECURITY.md +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/docs/architecture.md +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/docs/demo.svg +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/docs/sample-output.md +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/docs/social-preview.png +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/examples/targets.csv +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/__init__.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/accounts.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/base.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/breaches.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/ct.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/dns.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/github.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/pastes.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/ports.py +1 -1
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/subdomains.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/collectors/whois_lookup.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/diff.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/errors.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/http.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/logging_store.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/models.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/normalise.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/py.typed +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/render.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/retention.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/synthesis.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/src/osintkit/targets.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/conftest.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/fixtures/model_reply.json +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_accounts.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_breaches.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_config.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_ct.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_diff.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_e2e.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_http.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_normalise.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_pastes.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_render.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_retention.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_synthesis.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_targets.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/tests/test_whois.py +0 -0
- {osintkit-0.1.2 → osintkit-0.3.0}/uv.lock +0 -0
|
@@ -6,6 +6,28 @@ All notable changes are recorded here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.3.0 — 2026-11-02
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- providers for DeepSeek, Mistral, Groq, Together, Anthropic, and local Ollama
|
|
14
|
+
- provider selection from its own environment variable (`DEEPSEEK_API_KEY`, `MISTRAL_API_KEY`, ...)
|
|
15
|
+
- keyless providers (Ollama) work without any key, including in `doctor`
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `doctor` reports the provider and where the key came from
|
|
20
|
+
|
|
21
|
+
## 0.2.0 — 2026-11-02
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `--offline` mode: a full report built from local rules, no model and no key
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- the ports collector was registered as a sync helper, so `--ports` and the registry entry broke
|
|
30
|
+
|
|
9
31
|
## 0.1.2 — 2026-11-02
|
|
10
32
|
|
|
11
33
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: osintkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -74,16 +74,20 @@ checksums, and `pip install -e .` works from a clone. Python 3.12+ only.
|
|
|
74
74
|
## Quick start
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
|
-
export API_KEY=... #
|
|
77
|
+
export API_KEY=... # or DEEPSEEK_API_KEY, MISTRAL_API_KEY, ...
|
|
78
78
|
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 --
|
|
83
|
-
|
|
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
|
-
Any OpenAI-compatible endpoint works
|
|
86
|
-
|
|
86
|
+
Any OpenAI-compatible endpoint works: export the provider's own variable (`DEEPSEEK_API_KEY`,
|
|
87
|
+
`MISTRAL_API_KEY`, `GROQ_API_KEY`, `VENICE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`,
|
|
88
|
+
`TOGETHER_API_KEY`, `ANTHROPIC_API_KEY`) and that provider is used. `--provider` also accepts
|
|
89
|
+
`ollama` for a local server with no key. `--base-url` and `--model` always win, and the full
|
|
90
|
+
preset table is in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
|
|
87
91
|
|
|
88
92
|
Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
|
|
89
93
|
5000 requests/hour. Everything else needs no credentials.
|
|
@@ -102,7 +106,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
|
|
|
102
106
|
| `schema` | The JSON contract the model must satisfy |
|
|
103
107
|
|
|
104
108
|
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
|
|
109
|
+
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
110
|
input.
|
|
107
111
|
|
|
108
112
|
## How it works
|
|
@@ -115,6 +119,7 @@ target -> type detection -> collectors (parallel, cached, retried)
|
|
|
115
119
|
|
|
|
116
120
|
v
|
|
117
121
|
LLM briefing -> pydantic validation -> one repair retry
|
|
122
|
+
| (--offline replaces this with local rules)
|
|
118
123
|
|
|
|
119
124
|
v
|
|
120
125
|
colour report . JSON . Markdown . archive
|
|
@@ -42,16 +42,20 @@ checksums, and `pip install -e .` works from a clone. Python 3.12+ only.
|
|
|
42
42
|
## Quick start
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
export API_KEY=... #
|
|
45
|
+
export API_KEY=... # or DEEPSEEK_API_KEY, MISTRAL_API_KEY, ...
|
|
46
46
|
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 --
|
|
51
|
-
|
|
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
|
-
Any OpenAI-compatible endpoint works
|
|
54
|
-
|
|
54
|
+
Any OpenAI-compatible endpoint works: export the provider's own variable (`DEEPSEEK_API_KEY`,
|
|
55
|
+
`MISTRAL_API_KEY`, `GROQ_API_KEY`, `VENICE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`,
|
|
56
|
+
`TOGETHER_API_KEY`, `ANTHROPIC_API_KEY`) and that provider is used. `--provider` also accepts
|
|
57
|
+
`ollama` for a local server with no key. `--base-url` and `--model` always win, and the full
|
|
58
|
+
preset table is in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
|
|
55
59
|
|
|
56
60
|
Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
|
|
57
61
|
5000 requests/hour. Everything else needs no credentials.
|
|
@@ -70,7 +74,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
|
|
|
70
74
|
| `schema` | The JSON contract the model must satisfy |
|
|
71
75
|
|
|
72
76
|
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
|
|
77
|
+
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
78
|
input.
|
|
75
79
|
|
|
76
80
|
## How it works
|
|
@@ -83,6 +87,7 @@ target -> type detection -> collectors (parallel, cached, retried)
|
|
|
83
87
|
|
|
|
84
88
|
v
|
|
85
89
|
LLM briefing -> pydantic validation -> one repair retry
|
|
90
|
+
| (--offline replaces this with local rules)
|
|
86
91
|
|
|
|
87
92
|
v
|
|
88
93
|
colour report . JSON . Markdown . archive
|
|
@@ -34,11 +34,20 @@ osintkit --provider openrouter ...
|
|
|
34
34
|
osintkit --base-url https://gateway.internal/v1 --model house-model ...
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
| `--provider` | Endpoint | Default model |
|
|
37
|
+
| `--provider` | Endpoint | Default model | Key variable |
|
|
38
38
|
| --- | --- | --- | --- |
|
|
39
|
-
| `venice` (default) | `api.venice.ai/api/v1` | `deepseek-v4-1-flash` |
|
|
40
|
-
| `openrouter` | `openrouter.ai/api/v1` | `deepseek/deepseek-v4.1-flash-1017` | `sk-or-`
|
|
41
|
-
| `openai` | `api.openai.com/v1` | `gpt-4.1-mini` | `sk-proj-`
|
|
39
|
+
| `venice` (default) | `api.venice.ai/api/v1` | `deepseek-v4-1-flash` | `API_KEY`, `VENICE_API_KEY` |
|
|
40
|
+
| `openrouter` | `openrouter.ai/api/v1` | `deepseek/deepseek-v4.1-flash-1017` | `OPENROUTER_API_KEY`, `sk-or-` keys |
|
|
41
|
+
| `openai` | `api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY`, `sk-proj-` keys |
|
|
42
|
+
| `deepseek` | `api.deepseek.com/v1` | `deepseek-chat` | `DEEPSEEK_API_KEY` |
|
|
43
|
+
| `mistral` | `api.mistral.ai/v1` | `mistral-small-latest` | `MISTRAL_API_KEY` |
|
|
44
|
+
| `groq` | `api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY`, `gsk_` keys |
|
|
45
|
+
| `together` | `api.together.xyz/v1` | Llama 3.3 70B | `TOGETHER_API_KEY` |
|
|
46
|
+
| `anthropic` | `api.anthropic.com/v1` | `claude-sonnet-4-5` | `ANTHROPIC_API_KEY`, `sk-ant-` keys |
|
|
47
|
+
| `ollama` | `localhost:11434/v1` | `llama3.2` | none |
|
|
48
|
+
|
|
49
|
+
Set a provider-specific variable and that provider is chosen; `API_KEY` alone falls back to the
|
|
50
|
+
default. Recognisable key prefixes override that inference. `--provider` beats both.
|
|
42
51
|
|
|
43
52
|
Explicit `--base-url` and `--model` beat both the provider and the prefix. Temperature is 0.3,
|
|
44
53
|
`--max-tokens` defaults to 2400.
|
|
@@ -49,9 +58,16 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
|
|
|
49
58
|
`--max-connections`, `--max-attempts`, `--no-cache`, `--cache-ttl`, `--resolver` (repeatable),
|
|
50
59
|
`-v/--verbose`, `--version`.
|
|
51
60
|
|
|
52
|
-
Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--dry-run`, `--only`, `--skip`,
|
|
61
|
+
Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
|
|
53
62
|
`--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
|
|
54
63
|
|
|
64
|
+
## Offline mode
|
|
65
|
+
|
|
66
|
+
`--offline` skips the model: breaches, accounts, hosts, and policy gaps are rendered from the
|
|
67
|
+
collected data, risk comes from the same deterministic floor (plus open risky ports and a
|
|
68
|
+
wildcard with no DMARC), and recommendations come from rule checks. Summary text says what was
|
|
69
|
+
and was not collected. Useful without a key, in CI, and when a provider is down.
|
|
70
|
+
|
|
55
71
|
## Output contract
|
|
56
72
|
|
|
57
73
|
`osintkit schema` prints the JSON Schema. Synthesis must return `summary`, `breaches[]`,
|
|
@@ -21,10 +21,12 @@ from rich.table import Table
|
|
|
21
21
|
from . import __version__, diff, doctor, pipeline, render, retention
|
|
22
22
|
from .collectors import REGISTRY
|
|
23
23
|
from .config import (
|
|
24
|
+
GENERIC_KEY_ENVS,
|
|
24
25
|
PROVIDERS,
|
|
25
26
|
Settings,
|
|
26
27
|
cache_clear,
|
|
27
28
|
cache_stats,
|
|
29
|
+
provider_from_env,
|
|
28
30
|
provider_from_key,
|
|
29
31
|
)
|
|
30
32
|
from .errors import OsintKitError, TargetParseError
|
|
@@ -32,7 +34,7 @@ from .logging_store import RunLogger, find_report_for_run, read_report, read_run
|
|
|
32
34
|
from .targets import VALID_TYPES
|
|
33
35
|
|
|
34
36
|
app = typer.Typer(
|
|
35
|
-
add_completion=
|
|
37
|
+
add_completion=True,
|
|
36
38
|
no_args_is_help=True,
|
|
37
39
|
help="Public-data OSINT aggregator for emails, usernames, domains, and companies.",
|
|
38
40
|
rich_markup_mode="rich",
|
|
@@ -61,7 +63,7 @@ def _version_callback(value: bool) -> None:
|
|
|
61
63
|
@app.callback()
|
|
62
64
|
def root(
|
|
63
65
|
provider: str | None = typer.Option(
|
|
64
|
-
None, "--provider", help="Endpoint preset
|
|
66
|
+
None, "--provider", help="Endpoint preset; see docs/REFERENCE.md#providers."
|
|
65
67
|
),
|
|
66
68
|
base_url: str | None = typer.Option(
|
|
67
69
|
None, "--base-url", help="OpenAI-compatible base URL (overrides the provider)."
|
|
@@ -88,10 +90,17 @@ def root(
|
|
|
88
90
|
) -> None:
|
|
89
91
|
if provider and provider not in PROVIDERS:
|
|
90
92
|
raise typer.BadParameter(f"unknown provider {provider!r}; choose from {', '.join(PROVIDERS)}")
|
|
91
|
-
|
|
92
|
-
chosen =
|
|
93
|
+
generic_env = next((os.environ[name] for name in GENERIC_KEY_ENVS if os.environ.get(name)), None)
|
|
94
|
+
chosen = (
|
|
95
|
+
provider
|
|
96
|
+
or provider_from_key(api_key)
|
|
97
|
+
or provider_from_env()
|
|
98
|
+
or provider_from_key(generic_env)
|
|
99
|
+
or "venice"
|
|
100
|
+
)
|
|
93
101
|
preset = PROVIDERS[chosen]
|
|
94
102
|
settings = Settings(
|
|
103
|
+
provider=chosen,
|
|
95
104
|
base_url=base_url or preset[0],
|
|
96
105
|
model=model or preset[1],
|
|
97
106
|
api_key=api_key,
|
|
@@ -235,13 +244,20 @@ def lookup(
|
|
|
235
244
|
out_path: Path | None = typer.Option(None, "--out", "-o", help="Markdown destination."),
|
|
236
245
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print the prompt, skip the model."),
|
|
237
246
|
raw: bool = typer.Option(False, "--raw", help="Dump collected data as JSON, skip the model."),
|
|
247
|
+
offline: bool = typer.Option(False, "--offline", help="Report from collected data only; no model, no key."),
|
|
238
248
|
only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
|
|
239
249
|
skip: list[str] | None = typer.Option(None, "--skip", help="Skip these collectors."),
|
|
240
250
|
ports: list[str] | None = typer.Option(None, "--ports", help="Ports to probe, e.g. 22,443."),
|
|
241
251
|
) -> None:
|
|
242
252
|
"""Look up one target."""
|
|
243
253
|
out = _console()
|
|
244
|
-
settings = _settings(
|
|
254
|
+
settings = _settings(
|
|
255
|
+
output_json=json_out,
|
|
256
|
+
output_markdown=markdown,
|
|
257
|
+
dry_run=dry_run,
|
|
258
|
+
raw=raw,
|
|
259
|
+
offline=offline,
|
|
260
|
+
)
|
|
245
261
|
try:
|
|
246
262
|
settings.extra["ports"] = _parse_ports(ports)
|
|
247
263
|
except typer.BadParameter as exc:
|
|
@@ -317,6 +333,7 @@ def batch(
|
|
|
317
333
|
out_dir: Path | None = typer.Option(None, "--out", "-o", help="Markdown output directory."),
|
|
318
334
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print prompts, skip the model."),
|
|
319
335
|
raw: bool = typer.Option(False, "--raw", help="JSON array of collected data, skip the model."),
|
|
336
|
+
offline: bool = typer.Option(False, "--offline", help="Report from collected data only; no model, no key."),
|
|
320
337
|
fail_fast: bool = typer.Option(False, "--fail-fast", help="Stop reporting at the first error."),
|
|
321
338
|
concurrency: int = typer.Option(3, "--concurrency", "-c", min=1, max=10, help="Targets to run at once."),
|
|
322
339
|
only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
|
|
@@ -325,7 +342,13 @@ def batch(
|
|
|
325
342
|
) -> None:
|
|
326
343
|
"""Run every target in a CSV."""
|
|
327
344
|
out = _console()
|
|
328
|
-
settings = _settings(
|
|
345
|
+
settings = _settings(
|
|
346
|
+
output_json=json_out,
|
|
347
|
+
output_markdown=markdown,
|
|
348
|
+
dry_run=dry_run,
|
|
349
|
+
raw=raw,
|
|
350
|
+
offline=offline,
|
|
351
|
+
)
|
|
329
352
|
try:
|
|
330
353
|
settings.extra["ports"] = _parse_ports(ports)
|
|
331
354
|
except typer.BadParameter as exc:
|
|
@@ -23,8 +23,31 @@ PROVIDERS: dict[str, tuple[str, str]] = {
|
|
|
23
23
|
"venice": (DEFAULT_BASE_URL, DEFAULT_MODEL),
|
|
24
24
|
"openrouter": ("https://openrouter.ai/api/v1", "deepseek/deepseek-v4.1-flash-1017"),
|
|
25
25
|
"openai": ("https://api.openai.com/v1", "gpt-4.1-mini"),
|
|
26
|
+
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
|
27
|
+
"mistral": ("https://api.mistral.ai/v1", "mistral-small-latest"),
|
|
28
|
+
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
|
|
29
|
+
"together": ("https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
|
30
|
+
"anthropic": ("https://api.anthropic.com/v1", "claude-sonnet-4-5"),
|
|
31
|
+
"ollama": ("http://localhost:11434/v1", "llama3.2"),
|
|
26
32
|
}
|
|
27
|
-
|
|
33
|
+
KEYLESS_PROVIDERS = frozenset({"ollama"})
|
|
34
|
+
KEY_PREFIXES: dict[str, str] = {
|
|
35
|
+
"sk-or-": "openrouter",
|
|
36
|
+
"sk-proj-": "openai",
|
|
37
|
+
"gsk_": "groq",
|
|
38
|
+
"sk-ant-": "anthropic",
|
|
39
|
+
}
|
|
40
|
+
PROVIDER_ENV: dict[str, str] = {
|
|
41
|
+
"VENICE_API_KEY": "venice",
|
|
42
|
+
"OPENROUTER_API_KEY": "openrouter",
|
|
43
|
+
"OPENAI_API_KEY": "openai",
|
|
44
|
+
"DEEPSEEK_API_KEY": "deepseek",
|
|
45
|
+
"MISTRAL_API_KEY": "mistral",
|
|
46
|
+
"GROQ_API_KEY": "groq",
|
|
47
|
+
"TOGETHER_API_KEY": "together",
|
|
48
|
+
"ANTHROPIC_API_KEY": "anthropic",
|
|
49
|
+
}
|
|
50
|
+
GENERIC_KEY_ENVS = ("API_KEY",)
|
|
28
51
|
|
|
29
52
|
|
|
30
53
|
def provider_from_key(api_key: str | None) -> str | None:
|
|
@@ -37,6 +60,14 @@ def provider_from_key(api_key: str | None) -> str | None:
|
|
|
37
60
|
return None
|
|
38
61
|
|
|
39
62
|
|
|
63
|
+
def provider_from_env() -> str | None:
|
|
64
|
+
"""Infer a provider from whichever provider-specific variable is set."""
|
|
65
|
+
for variable, provider in PROVIDER_ENV.items():
|
|
66
|
+
if os.environ.get(variable):
|
|
67
|
+
return provider
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
40
71
|
def app_home() -> Path:
|
|
41
72
|
override = os.environ.get(APP_HOME_ENV)
|
|
42
73
|
if override:
|
|
@@ -67,6 +98,7 @@ class Settings:
|
|
|
67
98
|
|
|
68
99
|
target: str = ""
|
|
69
100
|
target_type: str = ""
|
|
101
|
+
provider: str = "venice"
|
|
70
102
|
base_url: str = DEFAULT_BASE_URL
|
|
71
103
|
model: str = DEFAULT_MODEL
|
|
72
104
|
api_key: str | None = None
|
|
@@ -79,6 +111,7 @@ class Settings:
|
|
|
79
111
|
cache_ttl: int = DEFAULT_CACHE_TTL
|
|
80
112
|
resolvers: list[str] = field(default_factory=lambda: list(DEFAULT_RESOLVERS))
|
|
81
113
|
dry_run: bool = False
|
|
114
|
+
offline: bool = False
|
|
82
115
|
raw: bool = False
|
|
83
116
|
output_json: bool = False
|
|
84
117
|
output_markdown: bool = False
|
|
@@ -94,7 +127,19 @@ class Settings:
|
|
|
94
127
|
return os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or None
|
|
95
128
|
|
|
96
129
|
def resolved_api_key(self) -> str | None:
|
|
97
|
-
|
|
130
|
+
if self.api_key:
|
|
131
|
+
return self.api_key
|
|
132
|
+
for variable, provider in PROVIDER_ENV.items():
|
|
133
|
+
if provider == self.provider and os.environ.get(variable):
|
|
134
|
+
return os.environ[variable]
|
|
135
|
+
for variable in GENERIC_KEY_ENVS:
|
|
136
|
+
if os.environ.get(variable):
|
|
137
|
+
return os.environ[variable]
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def needs_key(self) -> bool:
|
|
142
|
+
return self.provider not in KEYLESS_PROVIDERS
|
|
98
143
|
|
|
99
144
|
|
|
100
145
|
def _cache_key(namespace: str, key: str) -> Path:
|
|
@@ -7,7 +7,7 @@ import tempfile
|
|
|
7
7
|
from dataclasses import dataclass, replace
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from .config import Settings, cache_dir, ensure_dirs, log_dir
|
|
10
|
+
from .config import PROVIDER_ENV, Settings, cache_dir, ensure_dirs, log_dir
|
|
11
11
|
from .http import HttpClient
|
|
12
12
|
|
|
13
13
|
|
|
@@ -30,10 +30,10 @@ def _writable(path: Path) -> bool:
|
|
|
30
30
|
|
|
31
31
|
async def _reach_endpoint(settings: Settings, deep: bool = False) -> Check:
|
|
32
32
|
key = settings.resolved_api_key()
|
|
33
|
-
if not key:
|
|
33
|
+
if settings.needs_key and not key:
|
|
34
34
|
return Check("model endpoint", "warn", "no API key; synthesis will fail, dry runs still work")
|
|
35
35
|
base = settings.base_url.rstrip("/")
|
|
36
|
-
headers = {"Authorization": f"Bearer {key}"}
|
|
36
|
+
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
|
37
37
|
fresh = replace(settings, use_cache=False)
|
|
38
38
|
|
|
39
39
|
try:
|
|
@@ -81,14 +81,30 @@ async def _reach_endpoint(settings: Settings, deep: bool = False) -> Check:
|
|
|
81
81
|
return Check("model endpoint", "ok", f"{base} accepted a completion for {settings.model}")
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def _key_source(settings: Settings) -> str | None:
|
|
85
|
+
if settings.api_key:
|
|
86
|
+
return "--api-key"
|
|
87
|
+
for variable, provider in PROVIDER_ENV.items():
|
|
88
|
+
if provider == settings.provider and os.environ.get(variable):
|
|
89
|
+
return variable
|
|
90
|
+
for variable in ("API_KEY",):
|
|
91
|
+
if os.environ.get(variable):
|
|
92
|
+
return variable
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
84
96
|
def collect_checks(settings: Settings) -> list[Check]:
|
|
85
97
|
key = settings.resolved_api_key()
|
|
98
|
+
source = _key_source(settings)
|
|
99
|
+
if not settings.needs_key:
|
|
100
|
+
key_check = Check("api key", "ok", f"not needed for {settings.provider}")
|
|
101
|
+
elif key:
|
|
102
|
+
key_check = Check("api key", "ok", f"from {source}")
|
|
103
|
+
else:
|
|
104
|
+
key_check = Check("api key", "warn", f"no key for {settings.provider}; set one or use --offline")
|
|
86
105
|
checks = [
|
|
87
|
-
Check(
|
|
88
|
-
|
|
89
|
-
"ok" if key else "warn",
|
|
90
|
-
"set" if key else "API_KEY is not set",
|
|
91
|
-
),
|
|
106
|
+
Check("provider", "ok", settings.provider),
|
|
107
|
+
key_check,
|
|
92
108
|
Check(
|
|
93
109
|
"cache dir",
|
|
94
110
|
"ok" if _writable(cache_dir()) else "fail",
|
|
@@ -38,7 +38,7 @@ class LLMClient:
|
|
|
38
38
|
response_format: Mapping[str, Any] | None = None,
|
|
39
39
|
) -> str:
|
|
40
40
|
api_key = self.settings.resolved_api_key()
|
|
41
|
-
if not api_key:
|
|
41
|
+
if not api_key and self.settings.needs_key:
|
|
42
42
|
raise MissingAPIKey("No API key. Set API_KEY in the environment or pass --api-key.")
|
|
43
43
|
|
|
44
44
|
payload: dict[str, Any] = {
|
|
@@ -57,7 +57,7 @@ class LLMClient:
|
|
|
57
57
|
namespace="llm",
|
|
58
58
|
payload=payload,
|
|
59
59
|
headers={
|
|
60
|
-
"Authorization": f"Bearer {api_key}",
|
|
60
|
+
**({"Authorization": f"Bearer {api_key}"} if api_key else {}),
|
|
61
61
|
"Content-Type": "application/json",
|
|
62
62
|
},
|
|
63
63
|
)
|
|
@@ -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)
|
|
@@ -198,8 +212,10 @@ async def execute(
|
|
|
198
212
|
return Outcome(report=report, extra={"prompt": synthesis_prompt}, archived=archived)
|
|
199
213
|
|
|
200
214
|
try:
|
|
201
|
-
if not settings.resolved_api_key():
|
|
202
|
-
raise MissingAPIKey(
|
|
215
|
+
if settings.needs_key and not settings.resolved_api_key():
|
|
216
|
+
raise MissingAPIKey(
|
|
217
|
+
"No API key. Set API_KEY, pass --api-key, or use --offline for a local report."
|
|
218
|
+
)
|
|
203
219
|
async with LLMClient(settings, http) as client:
|
|
204
220
|
with _status(progress, settings, "[cyan]synthesising briefing…[/cyan]"):
|
|
205
221
|
report.analysis = await synthesis.synthesize(
|
|
@@ -447,3 +447,64 @@ 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"
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_missing_key_mentions_offline(monkeypatch):
|
|
478
|
+
monkeypatch.delenv("API_KEY")
|
|
479
|
+
monkeypatch.setattr(pipeline, "LLMClient", None)
|
|
480
|
+
result = runner.invoke(app, ["lookup", "--domain", "example.com"])
|
|
481
|
+
assert "--offline" in " ".join(result.output.split())
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_deepseek_env_selects_provider(monkeypatch):
|
|
485
|
+
monkeypatch.setenv("DEEPSEEK_API_KEY", "d" * 30)
|
|
486
|
+
monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
|
|
487
|
+
result = runner.invoke(app, ["lookup", "--domain", "a.com", "--json"])
|
|
488
|
+
assert jsonish(result.output)["base_url"] == "https://api.deepseek.com/v1"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def test_mistral_env_selects_provider(monkeypatch):
|
|
492
|
+
monkeypatch.setenv("MISTRAL_API_KEY", "m" * 30)
|
|
493
|
+
monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
|
|
494
|
+
result = runner.invoke(app, ["lookup", "--domain", "a.com", "--json"])
|
|
495
|
+
assert jsonish(result.output)["base_url"] == "https://api.mistral.ai/v1"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_explicit_provider_beats_env(monkeypatch):
|
|
499
|
+
monkeypatch.setenv("DEEPSEEK_API_KEY", "d" * 30)
|
|
500
|
+
monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
|
|
501
|
+
result = runner.invoke(app, ["--provider", "mistral", "lookup", "--domain", "a.com", "--json"])
|
|
502
|
+
assert jsonish(result.output)["base_url"] == "https://api.mistral.ai/v1"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def test_ollama_needs_no_key(monkeypatch):
|
|
506
|
+
monkeypatch.delenv("API_KEY")
|
|
507
|
+
monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
|
|
508
|
+
result = runner.invoke(app, ["--provider", "ollama", "lookup", "--domain", "a.com", "--json"])
|
|
509
|
+
assert result.exit_code == 0
|
|
510
|
+
assert jsonish(result.output)["base_url"] == "http://localhost:11434/v1"
|
|
@@ -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"
|
|
@@ -47,6 +47,19 @@ async def test_no_key_skips_endpoint_call():
|
|
|
47
47
|
assert "no API key" in checks[-1].detail
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
async def test_keyless_provider_needs_no_key():
|
|
51
|
+
checks = await doctor.diagnose(Settings(provider="ollama", base_url="http://localhost:1/v1"))
|
|
52
|
+
key = next(c for c in checks if c.name == "api key")
|
|
53
|
+
assert key.status == "ok"
|
|
54
|
+
assert "not needed" in key.detail
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_key_source_names_the_variable(monkeypatch):
|
|
58
|
+
monkeypatch.setenv("DEEPSEEK_API_KEY", "d" * 25)
|
|
59
|
+
check = next(c for c in doctor.collect_checks(Settings(provider="deepseek")) if c.name == "api key")
|
|
60
|
+
assert check.detail == "from DEEPSEEK_API_KEY"
|
|
61
|
+
|
|
62
|
+
|
|
50
63
|
@respx.mock
|
|
51
64
|
async def test_deep_probe_accepts_completion():
|
|
52
65
|
respx.get("https://api.test/v1/models").mock(return_value=httpx.Response(200, json={}))
|
|
@@ -81,3 +94,11 @@ async def test_shallow_probe_does_not_call_the_model():
|
|
|
81
94
|
respx.get("https://api.test/v1/models").mock(return_value=httpx.Response(200, json={}))
|
|
82
95
|
checks = await doctor.diagnose(Settings(base_url="https://api.test/v1", api_key="k" * 25))
|
|
83
96
|
assert "not exercised" in checks[-1].detail
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@respx.mock
|
|
100
|
+
async def test_keyless_provider_probes_without_auth():
|
|
101
|
+
route = respx.get("http://localhost:1/v1/models").mock(return_value=httpx.Response(200, json={}))
|
|
102
|
+
checks = await doctor.diagnose(Settings(provider="ollama", base_url="http://localhost:1/v1"))
|
|
103
|
+
assert checks[-1].status == "ok"
|
|
104
|
+
assert "authorization" not in route.calls[0].request.headers
|
|
@@ -96,3 +96,16 @@ async def test_other_errors_stay_source_errors(settings):
|
|
|
96
96
|
async with client(settings) as llm:
|
|
97
97
|
with pytest.raises(SourceUnavailable):
|
|
98
98
|
await llm.chat([{"role": "user", "content": "x"}])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@respx.mock
|
|
102
|
+
async def test_keyless_provider_sends_no_auth_header(settings):
|
|
103
|
+
settings.provider = "ollama"
|
|
104
|
+
settings.api_key = None
|
|
105
|
+
settings.base_url = "http://localhost:1/v1"
|
|
106
|
+
route = respx.post("http://localhost:1/v1/chat/completions").mock(
|
|
107
|
+
return_value=httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})
|
|
108
|
+
)
|
|
109
|
+
async with LLMClient(settings) as llm:
|
|
110
|
+
assert await llm.chat([{"role": "user", "content": "x"}]) == "ok"
|
|
111
|
+
assert "authorization" not in route.calls[0].request.headers
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|