osintkit 0.7.0__tar.gz → 0.8.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.7.0 → osintkit-0.8.0}/CHANGELOG.md +7 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/PKG-INFO +14 -1
- {osintkit-0.7.0 → osintkit-0.8.0}/README.md +13 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/docs/REFERENCE.md +7 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/pyproject.toml +1 -1
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/__init__.py +1 -1
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/cli.py +52 -1
- osintkit-0.8.0/src/osintkit/watch.py +97 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_cli.py +10 -0
- osintkit-0.8.0/tests/test_watch.py +81 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.editorconfig +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/CODEOWNERS +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/dependabot.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/pull_request_template.md +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/ci.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/codeql.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/publish.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/release.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.github/workflows/smoke.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.gitignore +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.mailmap +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/.pre-commit-config.yaml +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/CODE_OF_CONDUCT.md +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/CONTRIBUTING.md +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/LICENSE +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/SECURITY.md +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/docs/architecture.md +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/docs/demo.svg +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/docs/sample-output.md +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/docs/social-preview.png +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/examples/README.md +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/examples/targets.csv +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/catalogue.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/__init__.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/accounts.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/base.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/breaches.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/ct.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/dns.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/github.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/infostealer.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/pastes.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/ports.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/subdomains.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/whois_lookup.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/collectors/xposed_analytics.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/config.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/diff.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/doctor.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/errors.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/http.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/llm.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/logging_store.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/models.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/normalise.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/offline.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/passwords.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/pipeline.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/py.typed +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/render.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/retention.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/synthesis.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/targets.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/userconfig.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/src/osintkit/verify.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/conftest.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/fixtures/model_reply.json +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_accounts.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_breaches.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_catalogue.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_collectors.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_config.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_ct.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_diff.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_doctor.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_e2e.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_http.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_infostealer.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_llm.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_normalise.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_offline.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_passwords.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_pastes.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_pipeline.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_render.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_retention.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_synthesis.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_targets.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_userconfig.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_verify.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_whois.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/tests/test_xposed_analytics.py +0 -0
- {osintkit-0.7.0 → osintkit-0.8.0}/uv.lock +0 -0
|
@@ -6,6 +6,13 @@ All notable changes are recorded here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.8.0 — 2026-11-02
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `watch`: interval re-runs that stay silent unless a diff shows change, with optional desktop
|
|
14
|
+
notification and bounded cycles
|
|
15
|
+
|
|
9
16
|
## 0.7.0 — 2026-11-02
|
|
10
17
|
|
|
11
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: osintkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -106,6 +106,7 @@ Everything else needs no credentials.
|
|
|
106
106
|
| `batch` | A CSV of targets, run concurrently, reported in order |
|
|
107
107
|
| `diff` | What changed between two archived runs |
|
|
108
108
|
| `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
|
|
109
|
+
| `watch` | Re-check on an interval, print only changes, optionally notify |
|
|
109
110
|
| `history` | Past runs, replayable by id |
|
|
110
111
|
| `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
|
|
111
112
|
| `collectors` | Every collector and what it touches |
|
|
@@ -172,6 +173,18 @@ Passwords are checked separately with `osintkit password`: Pwned Passwords cover
|
|
|
172
173
|
millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
|
|
173
174
|
machine.
|
|
174
175
|
|
|
176
|
+
## Monitoring
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
osintkit watch --domain example.com --every 1d
|
|
180
|
+
osintkit watch --email you@example.com --every 6h --no-notify
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The first run prints a baseline. Later runs print nothing unless `diff` sees movement — a new
|
|
184
|
+
breach, a new host, a source failing, or a risk change — and optionally raise a desktop
|
|
185
|
+
notification through `notify-send`. `--cycles` bounds the loop, `ctrl-c` stops it, and every run
|
|
186
|
+
is archived so you can inspect it afterwards. Pair with cron or a systemd timer for unattended use.
|
|
187
|
+
|
|
175
188
|
## Safety and legality
|
|
176
189
|
|
|
177
190
|
- Public data only. No logins, no scraping behind auth, no paid brokers.
|
|
@@ -74,6 +74,7 @@ Everything else needs no credentials.
|
|
|
74
74
|
| `batch` | A CSV of targets, run concurrently, reported in order |
|
|
75
75
|
| `diff` | What changed between two archived runs |
|
|
76
76
|
| `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
|
|
77
|
+
| `watch` | Re-check on an interval, print only changes, optionally notify |
|
|
77
78
|
| `history` | Past runs, replayable by id |
|
|
78
79
|
| `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
|
|
79
80
|
| `collectors` | Every collector and what it touches |
|
|
@@ -140,6 +141,18 @@ Passwords are checked separately with `osintkit password`: Pwned Passwords cover
|
|
|
140
141
|
millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
|
|
141
142
|
machine.
|
|
142
143
|
|
|
144
|
+
## Monitoring
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
osintkit watch --domain example.com --every 1d
|
|
148
|
+
osintkit watch --email you@example.com --every 6h --no-notify
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The first run prints a baseline. Later runs print nothing unless `diff` sees movement — a new
|
|
152
|
+
breach, a new host, a source failing, or a risk change — and optionally raise a desktop
|
|
153
|
+
notification through `notify-send`. `--cycles` bounds the loop, `ctrl-c` stops it, and every run
|
|
154
|
+
is archived so you can inspect it afterwards. Pair with cron or a systemd timer for unattended use.
|
|
155
|
+
|
|
143
156
|
## Safety and legality
|
|
144
157
|
|
|
145
158
|
- Public data only. No logins, no scraping behind auth, no paid brokers.
|
|
@@ -62,6 +62,13 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
|
|
|
62
62
|
Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
|
|
63
63
|
`--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
|
|
64
64
|
|
|
65
|
+
## Watching
|
|
66
|
+
|
|
67
|
+
`watch` repeats a lookup on an interval (`--every`, minimum 60s), prints a baseline, and then
|
|
68
|
+
only reports diffs: added breaches or hosts, risk direction, source failure and recovery.
|
|
69
|
+
`--notify` (default) calls `notify-send` when available; `--full` prints the whole report on a
|
|
70
|
+
change; `--cycles N` bounds the loop. Every cycle is archived and logged like a normal run.
|
|
71
|
+
|
|
65
72
|
## Self-check
|
|
66
73
|
|
|
67
74
|
After synthesis every breach name, date, subdomain, and account URL is compared with the supplied
|
|
@@ -18,7 +18,7 @@ from rich.console import Console
|
|
|
18
18
|
from rich.panel import Panel
|
|
19
19
|
from rich.table import Table
|
|
20
20
|
|
|
21
|
-
from . import __version__, diff, doctor, passwords, pipeline, render, retention, userconfig
|
|
21
|
+
from . import __version__, diff, doctor, passwords, pipeline, render, retention, userconfig, watch
|
|
22
22
|
from .collectors import REGISTRY
|
|
23
23
|
from .config import (
|
|
24
24
|
DEFAULT_RESOLVERS,
|
|
@@ -639,6 +639,57 @@ def init(
|
|
|
639
639
|
out.print("[dim]try: osintkit lookup --domain example.com[/dim]")
|
|
640
640
|
|
|
641
641
|
|
|
642
|
+
@app.command("watch")
|
|
643
|
+
def watch_cmd(
|
|
644
|
+
target: str | None = typer.Argument(None, help="Email, username, domain, or company."),
|
|
645
|
+
email: str | None = typer.Option(None, "--email", help="Email target."),
|
|
646
|
+
username: str | None = typer.Option(None, "--username", help="Username target."),
|
|
647
|
+
domain: str | None = typer.Option(None, "--domain", help="Domain target."),
|
|
648
|
+
company: str | None = typer.Option(None, "--company", help="Company target."),
|
|
649
|
+
every: str = typer.Option("1d", "--every", help="Interval: 30m, 6h, 1d."),
|
|
650
|
+
cycles: int = typer.Option(0, "--cycles", min=0, help="Stop after N runs; 0 means forever."),
|
|
651
|
+
full: bool = typer.Option(False, "--full", help="Print the whole report on each change."),
|
|
652
|
+
notify_changes: bool = typer.Option(True, "--notify/--no-notify", help="Desktop notification."),
|
|
653
|
+
offline: bool = typer.Option(False, "--offline", help="Local report only; no model, no key."),
|
|
654
|
+
only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
|
|
655
|
+
skip: list[str] | None = typer.Option(None, "--skip", help="Skip these collectors."),
|
|
656
|
+
) -> None:
|
|
657
|
+
"""Re-check a target on a schedule and report only changes."""
|
|
658
|
+
out = _console()
|
|
659
|
+
try:
|
|
660
|
+
interval = watch.parse_duration(every)
|
|
661
|
+
raw, declared = _pick_target(target, email, username, domain, company)
|
|
662
|
+
except (ValueError, TargetParseError) as exc:
|
|
663
|
+
out.print(f"[red]{exc}[/red]")
|
|
664
|
+
raise typer.Exit(2) from exc
|
|
665
|
+
|
|
666
|
+
settings = _settings(offline=offline, output_json=False, output_markdown=False, raw=False)
|
|
667
|
+
|
|
668
|
+
async def run_once():
|
|
669
|
+
outcome = await pipeline.execute(
|
|
670
|
+
raw, settings, RunLogger(pipeline.new_run_id()), out, declared_type=declared
|
|
671
|
+
)
|
|
672
|
+
return outcome.report
|
|
673
|
+
|
|
674
|
+
def on_report(report, lines, baseline):
|
|
675
|
+
watch.render_event(report, lines, baseline, out, full=full)
|
|
676
|
+
if lines and notify_changes:
|
|
677
|
+
body = "; ".join(lines)[:180]
|
|
678
|
+
watch.notify(f"change for {report.target}", body)
|
|
679
|
+
|
|
680
|
+
out.print(
|
|
681
|
+
f"[dim]watching {render.escape(raw)} every {every}"
|
|
682
|
+
+ (f", {cycles} runs" if cycles else "")
|
|
683
|
+
+ " · ctrl-c stops[/dim]"
|
|
684
|
+
)
|
|
685
|
+
try:
|
|
686
|
+
changes = asyncio.run(watch.run(raw, run_once, on_report, every=interval, cycles=cycles))
|
|
687
|
+
except KeyboardInterrupt:
|
|
688
|
+
out.print("[yellow]stopped[/yellow]")
|
|
689
|
+
return
|
|
690
|
+
out.print(f"[dim]{changes} change event(s)[/dim]")
|
|
691
|
+
|
|
692
|
+
|
|
642
693
|
@app.command()
|
|
643
694
|
def password(
|
|
644
695
|
value: str | None = typer.Argument(None, help="Password to check; prompt if omitted."),
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Repeat a lookup on an interval and speak only when something changed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
|
|
11
|
+
from . import diff, render
|
|
12
|
+
from .models import Report
|
|
13
|
+
|
|
14
|
+
UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
15
|
+
DURATION_RE = re.compile(r"^(\d+)([smhd])$")
|
|
16
|
+
MIN_INTERVAL = 60
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_duration(text: str) -> int:
|
|
20
|
+
match = DURATION_RE.match(text.strip().lower())
|
|
21
|
+
if not match:
|
|
22
|
+
raise ValueError(f"{text!r} is not a duration like 30m, 6h, or 1d")
|
|
23
|
+
value = int(match.group(1)) * UNITS[match.group(2)]
|
|
24
|
+
if value < MIN_INTERVAL:
|
|
25
|
+
raise ValueError(f"interval must be at least {MIN_INTERVAL}s")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def describe(change: dict) -> list[str]:
|
|
30
|
+
lines: list[str] = []
|
|
31
|
+
if change["risk_rose"]:
|
|
32
|
+
lines.append(f"risk rose {change['risk']['old']} -> {change['risk']['new']}")
|
|
33
|
+
if change["risk_fell"]:
|
|
34
|
+
lines.append(f"risk fell {change['risk']['old']} -> {change['risk']['new']}")
|
|
35
|
+
for name in change["breaches"]["added"]:
|
|
36
|
+
lines.append(f"new breach: {name}")
|
|
37
|
+
for name in change["subdomains"]["added"]:
|
|
38
|
+
lines.append(f"new host: {name}")
|
|
39
|
+
for name in change["collectors"]["started_failing"]:
|
|
40
|
+
lines.append(f"source failing: {name}")
|
|
41
|
+
for name in change["collectors"]["started_working"]:
|
|
42
|
+
lines.append(f"source recovered: {name}")
|
|
43
|
+
return lines
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def notify(title: str, body: str) -> bool:
|
|
47
|
+
binary = shutil.which("notify-send")
|
|
48
|
+
if not binary:
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
51
|
+
subprocess.run([binary, "osintkit", title, body], check=False, timeout=10)
|
|
52
|
+
return True
|
|
53
|
+
except (OSError, subprocess.SubprocessError):
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def run(
|
|
58
|
+
target: str,
|
|
59
|
+
run_once: Callable[[], Awaitable[Report]],
|
|
60
|
+
on_report: Callable[[Report, list[str], bool], None],
|
|
61
|
+
*,
|
|
62
|
+
every: int,
|
|
63
|
+
cycles: int = 0,
|
|
64
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
65
|
+
) -> int:
|
|
66
|
+
"""Run `run_once` on an interval. Returns the number of change events."""
|
|
67
|
+
previous: Report | None = None
|
|
68
|
+
changes = 0
|
|
69
|
+
done = 0
|
|
70
|
+
while True:
|
|
71
|
+
report = await run_once()
|
|
72
|
+
done += 1
|
|
73
|
+
lines: list[str] = []
|
|
74
|
+
if previous is not None:
|
|
75
|
+
comparison = diff.compare(previous, report)
|
|
76
|
+
if not diff.is_empty(comparison):
|
|
77
|
+
changes += 1
|
|
78
|
+
lines = describe(comparison)
|
|
79
|
+
if previous is None or lines:
|
|
80
|
+
on_report(report, lines, previous is None)
|
|
81
|
+
previous = report
|
|
82
|
+
if cycles and done >= cycles:
|
|
83
|
+
return changes
|
|
84
|
+
await sleep(every)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_event(report: Report, lines: list[str], baseline: bool, console, *, full: bool) -> None:
|
|
88
|
+
stamp = render.escape(report.generated_at)
|
|
89
|
+
if baseline:
|
|
90
|
+
console.print(f"[dim]{stamp} baseline for {render.escape(report.target)}[/dim]")
|
|
91
|
+
render.render_report(report, console)
|
|
92
|
+
return
|
|
93
|
+
console.print(f"[yellow]change[/yellow] {stamp} {render.escape(report.target)}")
|
|
94
|
+
for line in lines:
|
|
95
|
+
console.print(f" - {render.escape(line)}")
|
|
96
|
+
if full:
|
|
97
|
+
render.render_report(report, console)
|
|
@@ -688,3 +688,13 @@ def test_env_hibp_key_beats_stored(xdg, monkeypatch):
|
|
|
688
688
|
monkeypatch.setattr(pipeline, "execute", spy)
|
|
689
689
|
runner.invoke(app, ["lookup", "--domain", "a.com"])
|
|
690
690
|
assert captured["hibp"] == "env-hibp"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def test_watch_runs_a_single_cycle(monkeypatch):
|
|
694
|
+
monkeypatch.setattr(pipeline, "LLMClient", None)
|
|
695
|
+
result = runner.invoke(
|
|
696
|
+
app, ["watch", "--domain", "example.com", "--every", "60s", "--cycles", "1", "--offline"]
|
|
697
|
+
)
|
|
698
|
+
assert result.exit_code == 0
|
|
699
|
+
assert "baseline" in result.output
|
|
700
|
+
assert "0 change event" in result.output
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from osintkit import watch
|
|
4
|
+
from osintkit.models import Analysis, Report
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def report(risk="low", breaches=()):
|
|
8
|
+
return Report(
|
|
9
|
+
target="example.com",
|
|
10
|
+
target_type="domain",
|
|
11
|
+
analysis=Analysis(summary="s", risk_level=risk, breaches=[{"name": b} for b in breaches]),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_parse_duration():
|
|
16
|
+
assert watch.parse_duration("30m") == 1800
|
|
17
|
+
assert watch.parse_duration("2h") == 7200
|
|
18
|
+
assert watch.parse_duration("1d") == 86400
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize("text", ["10s", "soon", "5x", ""])
|
|
22
|
+
def test_parse_duration_rejects(text):
|
|
23
|
+
with pytest.raises(ValueError):
|
|
24
|
+
watch.parse_duration(text)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_describe_lists_changes():
|
|
28
|
+
change = {
|
|
29
|
+
"risk": {"old": "low", "new": "high"},
|
|
30
|
+
"risk_rose": True,
|
|
31
|
+
"risk_fell": False,
|
|
32
|
+
"breaches": {"added": ["Acme"], "removed": []},
|
|
33
|
+
"subdomains": {"added": ["x.example.com"], "removed": []},
|
|
34
|
+
"collectors": {"started_failing": ["crt_sh_subdomains"], "started_working": []},
|
|
35
|
+
}
|
|
36
|
+
lines = watch.describe(change)
|
|
37
|
+
assert "risk rose low -> high" in lines
|
|
38
|
+
assert "new breach: Acme" in lines
|
|
39
|
+
assert "new host: x.example.com" in lines
|
|
40
|
+
assert "source failing: crt_sh_subdomains" in lines
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_run_reports_baseline_then_only_changes():
|
|
44
|
+
sequence = [report(), report(), report(risk="high"), report(risk="high")]
|
|
45
|
+
events = []
|
|
46
|
+
slept = []
|
|
47
|
+
|
|
48
|
+
async def run_once():
|
|
49
|
+
return sequence.pop(0)
|
|
50
|
+
|
|
51
|
+
async def sleeper(seconds):
|
|
52
|
+
slept.append(seconds)
|
|
53
|
+
|
|
54
|
+
changes = await watch.run(
|
|
55
|
+
"example.com",
|
|
56
|
+
run_once,
|
|
57
|
+
lambda rep, lines, baseline: events.append((baseline, list(lines))),
|
|
58
|
+
every=60,
|
|
59
|
+
cycles=4,
|
|
60
|
+
sleep=sleeper,
|
|
61
|
+
)
|
|
62
|
+
assert changes == 1
|
|
63
|
+
assert events[0] == (True, [])
|
|
64
|
+
assert events[1][0] is False
|
|
65
|
+
assert any("risk rose" in line for line in events[1][1])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def test_run_respects_cycles():
|
|
69
|
+
events = []
|
|
70
|
+
|
|
71
|
+
async def run_once():
|
|
72
|
+
return report()
|
|
73
|
+
|
|
74
|
+
async def sleeper(seconds):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
changes = await watch.run(
|
|
78
|
+
"example.com", run_once, lambda *a: events.append(a), every=60, cycles=3, sleep=sleeper
|
|
79
|
+
)
|
|
80
|
+
assert changes == 0
|
|
81
|
+
assert len(events) == 1
|
|
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
|
|
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
|
|
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
|