reconkit 0.1.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 (32) hide show
  1. reconkit-0.1.0/.gitignore +30 -0
  2. reconkit-0.1.0/CHANGELOG.md +21 -0
  3. reconkit-0.1.0/LICENSE +21 -0
  4. reconkit-0.1.0/PKG-INFO +108 -0
  5. reconkit-0.1.0/README.md +80 -0
  6. reconkit-0.1.0/pyproject.toml +56 -0
  7. reconkit-0.1.0/src/reconkit/__init__.py +22 -0
  8. reconkit-0.1.0/src/reconkit/cli.py +218 -0
  9. reconkit-0.1.0/src/reconkit/core/__init__.py +0 -0
  10. reconkit-0.1.0/src/reconkit/core/rate_limit.py +24 -0
  11. reconkit-0.1.0/src/reconkit/forensics/__init__.py +0 -0
  12. reconkit-0.1.0/src/reconkit/forensics/exif.py +16 -0
  13. reconkit-0.1.0/src/reconkit/forensics/hashing.py +22 -0
  14. reconkit-0.1.0/src/reconkit/forensics/identify.py +12 -0
  15. reconkit-0.1.0/src/reconkit/forensics/strings.py +47 -0
  16. reconkit-0.1.0/src/reconkit/noir/__init__.py +54 -0
  17. reconkit-0.1.0/src/reconkit/noir/errors.py +29 -0
  18. reconkit-0.1.0/src/reconkit/noir/primitives.py +154 -0
  19. reconkit-0.1.0/src/reconkit/noir/public_api.py +221 -0
  20. reconkit-0.1.0/src/reconkit/osint/__init__.py +0 -0
  21. reconkit-0.1.0/src/reconkit/osint/dns.py +18 -0
  22. reconkit-0.1.0/src/reconkit/osint/ip.py +25 -0
  23. reconkit-0.1.0/src/reconkit/osint/whois.py +14 -0
  24. reconkit-0.1.0/src/reconkit/scraping/__init__.py +0 -0
  25. reconkit-0.1.0/src/reconkit/scraping/extract.py +36 -0
  26. reconkit-0.1.0/src/reconkit/scraping/fetch.py +81 -0
  27. reconkit-0.1.0/src/reconkit/scraping/robots.py +60 -0
  28. reconkit-0.1.0/tests/test_extract_links.py +13 -0
  29. reconkit-0.1.0/tests/test_hashing.py +9 -0
  30. reconkit-0.1.0/tests/test_noir_output_contract.py +23 -0
  31. reconkit-0.1.0/tests/test_noir_primitives.py +12 -0
  32. reconkit-0.1.0/tests/test_strings.py +11 -0
@@ -0,0 +1,30 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ build/
11
+ dist/
12
+ *.egg-info/
13
+ .eggs/
14
+
15
+ # Virtual environments
16
+ .venv/
17
+ venv/
18
+ ENV/
19
+
20
+ # Test / tooling caches
21
+ .pytest_cache/
22
+ .mypy_cache/
23
+ .ruff_cache/
24
+ .coverage
25
+ htmlcov/
26
+
27
+ # OS / editor
28
+ .DS_Store
29
+ Thumbs.db
30
+ .vscode/
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ Format: loosely based on *Keep a Changelog*. Versioning: SemVer where practical.
6
+
7
+ ## [0.1.0] - 2026-02-09
8
+
9
+ ### Added
10
+ - Noir Stack public primitives: `EvidenceArtifact`, `EvidenceBundle`, `ProvenanceRecord`, `VerificationResult`.
11
+ - Domain-specific errors that describe trust boundaries and provenance constraints.
12
+ - Audit-grade CLI vocabulary: `evidence`, `boundary`, `verify`.
13
+ - Structured JSON outputs include: `decision_basis`, `verification_status`, `provenance_confidence`.
14
+ - `robots.txt` compliance (default) and rate limiting for web acquisition.
15
+
16
+ ### Changed
17
+ - Public-facing surface and CLI terminology refactored to evidence/verification framing.
18
+
19
+ ### Notes
20
+ - This release is intentionally conservative.
21
+ - The system is capable of refusing to speculate. This is a feature.
reconkit-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noir Stack LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: reconkit
3
+ Version: 0.1.0
4
+ Summary: A small, safe-by-default toolkit for scraping, OSINT, and file triage.
5
+ Author: reconkit
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: forensics,osint,scraping,triage
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: beautifulsoup4>=4.12
11
+ Requires-Dist: dnspython>=2.6
12
+ Requires-Dist: exifread>=3.0
13
+ Requires-Dist: filetype>=1.2
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: ipwhois>=1.2
16
+ Requires-Dist: lxml>=5.1
17
+ Requires-Dist: python-whois>=0.9
18
+ Requires-Dist: rich>=13.7
19
+ Requires-Dist: typer>=0.12
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.2; extra == 'dev'
22
+ Requires-Dist: mypy>=1.10; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.4; extra == 'dev'
26
+ Requires-Dist: twine>=5.1; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # reconkit (Judicium surface)
30
+
31
+ `reconkit` is a **conservative, safe-by-default** toolkit that exposes a Noir Stack / Hexarch / Judicium-flavored public API.
32
+
33
+ It is designed to produce **audit-grade structured outputs** that explicitly state:
34
+ - what was observed (evidence)
35
+ - how it was observed (provenance)
36
+ - what is and is not being asserted (verification limits)
37
+
38
+ This project is intended for lawful use on systems/data you own or have permission to assess.
39
+
40
+ ## Noir Stack primitives
41
+
42
+ Public primitives:
43
+ - `EvidenceArtifact`: immutable bytes + provenance
44
+ - `EvidenceBundle`: a set of artifacts for joint assessment
45
+ - `ProvenanceRecord`: acquisition/derivation metadata + trust boundary
46
+ - `VerificationResult`: structured decision output
47
+
48
+ All structured outputs include:
49
+ - `decision_basis`
50
+ - `verification_status`
51
+ - `provenance_confidence`
52
+
53
+ Because this is Judicium, not divination.
54
+
55
+ ## Guarantees
56
+
57
+ What the system will reliably do:
58
+ - Preserve evidence payload bytes as acquired/derived, and compute stable SHA-256 for artifacts.
59
+ - Emit structured, JSON-serializable outputs suitable for logging and later review.
60
+ - Respect declared acquisition constraints (e.g., `max_bytes`) and treat them as policy boundaries.
61
+ - Respect `robots.txt` by default for web acquisition.
62
+
63
+ ## Assumptions
64
+
65
+ What the system assumes unless you override it:
66
+ - `trust_boundary` is declared explicitly for acquisitions. The tool will not invent one.
67
+ - Web acquisition is transport-level observation, not identity/authenticity proof.
68
+ - Public-internet boundary data (DNS/WHOIS/RDAP) is administrative metadata.
69
+
70
+ ## Non-goals
71
+
72
+ What the system explicitly does not do:
73
+ - No anti-bot bypassing, fingerprint evasion, proxy-rotation orchestration, or credentialed scraping.
74
+ - No claims of attribution, intent, or identity. (You can do that in your report; the tool will not do it for you.)
75
+ - No “automatic truth.” The system is already busy being correct.
76
+
77
+ ## Install
78
+
79
+ ```bash
80
+ python -m pip install -e ".[dev]"
81
+ ```
82
+
83
+ ## CLI quickstart (Noir terminology)
84
+
85
+ All commands emit audit-grade JSON.
86
+
87
+ ```bash
88
+ # Evidence acquisition / derivation
89
+ reconkit evidence acquire-web "https://example.com" --trust-boundary public-internet
90
+ reconkit evidence derive-links "https://example.com"
91
+ reconkit evidence derive-text "https://example.com"
92
+
93
+ # Boundary observations (OSINT-ish, but we say the quiet part out loud)
94
+ reconkit boundary dns example.com
95
+ reconkit boundary whois example.com
96
+ reconkit boundary ip 8.8.8.8
97
+
98
+ # Verification (informational)
99
+ reconkit verify file-hash .\somefile.bin --algo sha256
100
+ reconkit verify file-type .\somefile.bin
101
+ reconkit verify strings .\somefile.bin --min-len 6
102
+ reconkit verify exif .\photo.jpg
103
+ ```
104
+
105
+ ## Notes
106
+
107
+ - If acquisition is blocked by `robots.txt`, that is not a “bug.” It is a boundary condition.
108
+ - If you need features that reduce accountability, you are in the wrong repository. Convenient.
@@ -0,0 +1,80 @@
1
+ # reconkit (Judicium surface)
2
+
3
+ `reconkit` is a **conservative, safe-by-default** toolkit that exposes a Noir Stack / Hexarch / Judicium-flavored public API.
4
+
5
+ It is designed to produce **audit-grade structured outputs** that explicitly state:
6
+ - what was observed (evidence)
7
+ - how it was observed (provenance)
8
+ - what is and is not being asserted (verification limits)
9
+
10
+ This project is intended for lawful use on systems/data you own or have permission to assess.
11
+
12
+ ## Noir Stack primitives
13
+
14
+ Public primitives:
15
+ - `EvidenceArtifact`: immutable bytes + provenance
16
+ - `EvidenceBundle`: a set of artifacts for joint assessment
17
+ - `ProvenanceRecord`: acquisition/derivation metadata + trust boundary
18
+ - `VerificationResult`: structured decision output
19
+
20
+ All structured outputs include:
21
+ - `decision_basis`
22
+ - `verification_status`
23
+ - `provenance_confidence`
24
+
25
+ Because this is Judicium, not divination.
26
+
27
+ ## Guarantees
28
+
29
+ What the system will reliably do:
30
+ - Preserve evidence payload bytes as acquired/derived, and compute stable SHA-256 for artifacts.
31
+ - Emit structured, JSON-serializable outputs suitable for logging and later review.
32
+ - Respect declared acquisition constraints (e.g., `max_bytes`) and treat them as policy boundaries.
33
+ - Respect `robots.txt` by default for web acquisition.
34
+
35
+ ## Assumptions
36
+
37
+ What the system assumes unless you override it:
38
+ - `trust_boundary` is declared explicitly for acquisitions. The tool will not invent one.
39
+ - Web acquisition is transport-level observation, not identity/authenticity proof.
40
+ - Public-internet boundary data (DNS/WHOIS/RDAP) is administrative metadata.
41
+
42
+ ## Non-goals
43
+
44
+ What the system explicitly does not do:
45
+ - No anti-bot bypassing, fingerprint evasion, proxy-rotation orchestration, or credentialed scraping.
46
+ - No claims of attribution, intent, or identity. (You can do that in your report; the tool will not do it for you.)
47
+ - No “automatic truth.” The system is already busy being correct.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ python -m pip install -e ".[dev]"
53
+ ```
54
+
55
+ ## CLI quickstart (Noir terminology)
56
+
57
+ All commands emit audit-grade JSON.
58
+
59
+ ```bash
60
+ # Evidence acquisition / derivation
61
+ reconkit evidence acquire-web "https://example.com" --trust-boundary public-internet
62
+ reconkit evidence derive-links "https://example.com"
63
+ reconkit evidence derive-text "https://example.com"
64
+
65
+ # Boundary observations (OSINT-ish, but we say the quiet part out loud)
66
+ reconkit boundary dns example.com
67
+ reconkit boundary whois example.com
68
+ reconkit boundary ip 8.8.8.8
69
+
70
+ # Verification (informational)
71
+ reconkit verify file-hash .\somefile.bin --algo sha256
72
+ reconkit verify file-type .\somefile.bin
73
+ reconkit verify strings .\somefile.bin --min-len 6
74
+ reconkit verify exif .\photo.jpg
75
+ ```
76
+
77
+ ## Notes
78
+
79
+ - If acquisition is blocked by `robots.txt`, that is not a “bug.” It is a boundary condition.
80
+ - If you need features that reduce accountability, you are in the wrong repository. Convenient.
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reconkit"
7
+ version = "0.1.0"
8
+ description = "A small, safe-by-default toolkit for scraping, OSINT, and file triage."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "reconkit" }]
13
+ keywords = ["osint", "forensics", "scraping", "triage"]
14
+ dependencies = [
15
+ "httpx>=0.27",
16
+ "typer>=0.12",
17
+ "rich>=13.7",
18
+ "beautifulsoup4>=4.12",
19
+ "lxml>=5.1",
20
+ "dnspython>=2.6",
21
+ "python-whois>=0.9",
22
+ "ipwhois>=1.2",
23
+ "exifread>=3.0",
24
+ "filetype>=1.2",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "build>=1.2",
30
+ "twine>=5.1",
31
+ "pytest>=8.0",
32
+ "pytest-asyncio>=0.23",
33
+ "ruff>=0.4",
34
+ "mypy>=1.10",
35
+ ]
36
+
37
+ [project.scripts]
38
+ reconkit = "reconkit.cli:app"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/reconkit"]
42
+
43
+ [tool.pytest.ini_options]
44
+ addopts = "-q"
45
+ asyncio_mode = "auto"
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+
50
+ [tool.mypy]
51
+ python_version = "3.10"
52
+ warn_return_any = true
53
+ warn_unused_ignores = true
54
+ no_implicit_optional = true
55
+ strict_optional = true
56
+ ignore_missing_imports = true
@@ -0,0 +1,22 @@
1
+ """reconkit.
2
+
3
+ Public API: Noir Stack / Hexarch / Judicium surface lives under `reconkit.noir`.
4
+ Internal modules remain available but are not the supported public interface.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from reconkit.noir.primitives import ( # noqa: E402
10
+ EvidenceArtifact,
11
+ EvidenceBundle,
12
+ ProvenanceRecord,
13
+ VerificationResult,
14
+ )
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "EvidenceArtifact",
19
+ "EvidenceBundle",
20
+ "ProvenanceRecord",
21
+ "VerificationResult",
22
+ ]
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from reconkit.noir import public_api
10
+ from reconkit.noir.primitives import VerificationResult, json_dumps_audit
11
+
12
+ app = typer.Typer(add_completion=False, no_args_is_help=True)
13
+ console = Console()
14
+
15
+ evidence_app = typer.Typer(no_args_is_help=True)
16
+ boundary_app = typer.Typer(no_args_is_help=True)
17
+ verify_app = typer.Typer(no_args_is_help=True)
18
+ app.add_typer(evidence_app, name="evidence")
19
+ app.add_typer(boundary_app, name="boundary")
20
+ app.add_typer(verify_app, name="verify")
21
+
22
+
23
+ def _emit(result: VerificationResult) -> None:
24
+ # Audit-grade requirement: stdout must be machine-parseable JSON.
25
+ # Rich is excellent at presentation and equally excellent at line-wrapping.
26
+ # We only need one of those properties.
27
+ typer.echo(json_dumps_audit(result.to_json_dict()))
28
+
29
+
30
+ @evidence_app.command("acquire-web")
31
+ def evidence_acquire_web(
32
+ url: str,
33
+ trust_boundary: str = typer.Option(
34
+ "public-internet",
35
+ help="Declared trust boundary for acquisition (audited, not inferred).",
36
+ ),
37
+ max_bytes: int = typer.Option(2_000_000, help="Max download size in bytes"),
38
+ timeout_s: float = typer.Option(20.0, help="Timeout in seconds"),
39
+ rps: float = typer.Option(1.0, help="Max requests per second"),
40
+ obey_robots: bool = typer.Option(True, help="Respect robots.txt"),
41
+ include_payload: bool = typer.Option(False, help="Include payload in output (base64)."),
42
+ ):
43
+ """Acquire a web response as evidence.
44
+
45
+ Conservative note: this is acquisition, not authentication. The system is many things.
46
+ Omniscient is not currently one of them.
47
+ """
48
+
49
+ artifact = public_api.acquire_web_artifact(
50
+ url,
51
+ trust_boundary=trust_boundary,
52
+ max_bytes=max_bytes,
53
+ timeout_s=timeout_s,
54
+ rps=rps,
55
+ obey_robots=obey_robots,
56
+ )
57
+ _emit(
58
+ VerificationResult(
59
+ verification_status="informational",
60
+ decision_basis="http-get acquisition within declared trust boundary",
61
+ provenance_confidence="medium",
62
+ subject={"url": url, "trust_boundary": trust_boundary},
63
+ findings={"artifact": artifact.to_json_dict(include_payload=include_payload)},
64
+ limitations=[
65
+ "Transport acquisition does not assert publisher identity or content authenticity.",
66
+ ],
67
+ )
68
+ )
69
+
70
+
71
+ @evidence_app.command("derive-links")
72
+ def evidence_derive_links(
73
+ url: str,
74
+ timeout_s: float = typer.Option(20.0, help="Timeout in seconds"),
75
+ rps: float = typer.Option(1.0, help="Max requests per second"),
76
+ obey_robots: bool = typer.Option(True, help="Respect robots.txt"),
77
+ include_payload: bool = typer.Option(False, help="Include payload in output (base64)."),
78
+ ):
79
+ """Acquire a web response and derive a link-list artifact."""
80
+
81
+ web = public_api.acquire_web_artifact(url, timeout_s=timeout_s, rps=rps, obey_robots=obey_robots)
82
+ derived = public_api.derive_links(web, base_url=url)
83
+ _emit(
84
+ VerificationResult(
85
+ verification_status="informational",
86
+ decision_basis="derived link extraction from acquired HTML",
87
+ provenance_confidence="medium",
88
+ subject={"url": url},
89
+ findings={
90
+ "source_artifact": web.to_json_dict(include_payload=False),
91
+ "derived_artifact": derived.to_json_dict(include_payload=include_payload),
92
+ },
93
+ limitations=[
94
+ "Link extraction is heuristic and may miss dynamically generated URLs.",
95
+ ],
96
+ )
97
+ )
98
+
99
+
100
+ @evidence_app.command("derive-text")
101
+ def evidence_derive_text(
102
+ url: str,
103
+ timeout_s: float = typer.Option(20.0, help="Timeout in seconds"),
104
+ rps: float = typer.Option(1.0, help="Max requests per second"),
105
+ obey_robots: bool = typer.Option(True, help="Respect robots.txt"),
106
+ include_payload: bool = typer.Option(False, help="Include payload in output (base64)."),
107
+ ):
108
+ """Acquire a web response and derive a readable-text artifact."""
109
+
110
+ web = public_api.acquire_web_artifact(url, timeout_s=timeout_s, rps=rps, obey_robots=obey_robots)
111
+ derived = public_api.derive_readable_text(web, base_url=url)
112
+ _emit(
113
+ VerificationResult(
114
+ verification_status="informational",
115
+ decision_basis="derived readable text from acquired HTML",
116
+ provenance_confidence="medium",
117
+ subject={"url": url},
118
+ findings={
119
+ "source_artifact": web.to_json_dict(include_payload=False),
120
+ "derived_artifact": derived.to_json_dict(include_payload=include_payload),
121
+ },
122
+ limitations=[
123
+ "Readability extraction removes scripts/styles and may omit critical context.",
124
+ ],
125
+ )
126
+ )
127
+
128
+
129
+ @boundary_app.command("dns")
130
+ def boundary_dns(domain: str, include_payload: bool = typer.Option(False, help="Include payload in output (base64).")):
131
+ """Observe DNS answers across the public-internet boundary."""
132
+
133
+ artifact = public_api.boundary_dns(domain)
134
+ _emit(
135
+ VerificationResult(
136
+ verification_status="informational",
137
+ decision_basis="DNS resolution observation",
138
+ provenance_confidence="medium",
139
+ subject={"domain": domain, "trust_boundary": "public-internet"},
140
+ findings={"artifact": artifact.to_json_dict(include_payload=include_payload)},
141
+ limitations=[
142
+ "DNS is an administrative naming system; answers are not identity proofs.",
143
+ ],
144
+ )
145
+ )
146
+
147
+
148
+ @boundary_app.command("whois")
149
+ def boundary_whois(domain: str, include_payload: bool = typer.Option(False, help="Include payload in output (base64).")):
150
+ """Observe WHOIS/RDAP administrative metadata across the public-internet boundary."""
151
+
152
+ artifact = public_api.boundary_whois(domain)
153
+ _emit(
154
+ VerificationResult(
155
+ verification_status="informational",
156
+ decision_basis="WHOIS observation",
157
+ provenance_confidence="low",
158
+ subject={"domain": domain, "trust_boundary": "public-internet"},
159
+ findings={"artifact": artifact.to_json_dict(include_payload=include_payload)},
160
+ limitations=[
161
+ "WHOIS fields vary by registrar/TLD and may be redacted or stale.",
162
+ ],
163
+ )
164
+ )
165
+
166
+
167
+ @boundary_app.command("ip")
168
+ def boundary_ip(ip: str, include_payload: bool = typer.Option(False, help="Include payload in output (base64).")):
169
+ """Observe RDAP/ASN administrative metadata across the public-internet boundary."""
170
+
171
+ artifact = public_api.boundary_ip(ip)
172
+ _emit(
173
+ VerificationResult(
174
+ verification_status="informational",
175
+ decision_basis="RDAP/ASN observation",
176
+ provenance_confidence="medium",
177
+ subject={"ip": ip, "trust_boundary": "public-internet"},
178
+ findings={"artifact": artifact.to_json_dict(include_payload=include_payload)},
179
+ limitations=[
180
+ "Administrative allocations do not imply actor attribution.",
181
+ ],
182
+ )
183
+ )
184
+
185
+
186
+ @verify_app.command("file-hash")
187
+ def verify_file_hash(
188
+ path: Path,
189
+ algo: str = typer.Option("sha256", help="md5|sha1|sha256"),
190
+ ):
191
+ """Verify (informational): compute a cryptographic digest over file bytes."""
192
+
193
+ _emit(public_api.verify_file_hash(path, algo=algo))
194
+
195
+
196
+ @verify_app.command("file-type")
197
+ def verify_file_type(path: Path):
198
+ """Verify (informational): identify file type by signature."""
199
+
200
+ _emit(public_api.verify_file_type(path))
201
+
202
+
203
+ @verify_app.command("strings")
204
+ def verify_strings(
205
+ path: Path,
206
+ min_len: int = typer.Option(6, "--min-len"),
207
+ max_strings: Optional[int] = typer.Option(None, "--max"),
208
+ ):
209
+ """Verify (informational): extract printable strings as a triage aid."""
210
+
211
+ _emit(public_api.verify_strings(path, min_len=min_len, max_strings=max_strings))
212
+
213
+
214
+ @verify_app.command("exif")
215
+ def verify_exif(path: Path):
216
+ """Verify (informational): extract EXIF tags."""
217
+
218
+ _emit(public_api.verify_exif(path))
File without changes
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class RateLimiter:
9
+ """Simple, process-local rate limiter.
10
+
11
+ This intentionally stays basic (no jitter, no token bucket) to be easy to understand and
12
+ replicate.
13
+ """
14
+
15
+ requests_per_second: float = 1.0
16
+ _next_allowed_at: float = 0.0
17
+
18
+ def wait(self) -> None:
19
+ if self.requests_per_second <= 0:
20
+ return
21
+ now = time.monotonic()
22
+ if now < self._next_allowed_at:
23
+ time.sleep(self._next_allowed_at - now)
24
+ self._next_allowed_at = time.monotonic() + (1.0 / self.requests_per_second)
File without changes
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import exifread
6
+
7
+
8
+ def extract_exif(path: Path) -> dict[str, str]:
9
+ with path.open("rb") as f:
10
+ tags = exifread.process_file(f, details=False)
11
+
12
+ # normalize to plain strings
13
+ out: dict[str, str] = {}
14
+ for k, v in tags.items():
15
+ out[str(k)] = str(v)
16
+ return out
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+
6
+
7
+ _SUPPORTED = {"md5", "sha1", "sha256"}
8
+
9
+
10
+ def hash_file(path: Path, *, algo: str = "sha256", chunk_size: int = 1024 * 1024) -> str:
11
+ algo = algo.lower()
12
+ if algo not in _SUPPORTED:
13
+ raise ValueError(f"Unsupported algo: {algo}. Choose one of: {sorted(_SUPPORTED)}")
14
+
15
+ h = hashlib.new(algo)
16
+ with path.open("rb") as f:
17
+ while True:
18
+ chunk = f.read(chunk_size)
19
+ if not chunk:
20
+ break
21
+ h.update(chunk)
22
+ return h.hexdigest()
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import filetype
6
+
7
+
8
+ def identify_file(path: Path) -> dict[str, str | None]:
9
+ kind = filetype.guess(str(path))
10
+ if kind is None:
11
+ return {"path": str(path), "ext": None, "mime": None}
12
+ return {"path": str(path), "ext": kind.extension, "mime": kind.mime}