reconkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reconkit/__init__.py +22 -0
- reconkit/cli.py +218 -0
- reconkit/core/__init__.py +0 -0
- reconkit/core/rate_limit.py +24 -0
- reconkit/forensics/__init__.py +0 -0
- reconkit/forensics/exif.py +16 -0
- reconkit/forensics/hashing.py +22 -0
- reconkit/forensics/identify.py +12 -0
- reconkit/forensics/strings.py +47 -0
- reconkit/noir/__init__.py +54 -0
- reconkit/noir/errors.py +29 -0
- reconkit/noir/primitives.py +154 -0
- reconkit/noir/public_api.py +221 -0
- reconkit/osint/__init__.py +0 -0
- reconkit/osint/dns.py +18 -0
- reconkit/osint/ip.py +25 -0
- reconkit/osint/whois.py +14 -0
- reconkit/scraping/__init__.py +0 -0
- reconkit/scraping/extract.py +36 -0
- reconkit/scraping/fetch.py +81 -0
- reconkit/scraping/robots.py +60 -0
- reconkit-0.1.0.dist-info/METADATA +108 -0
- reconkit-0.1.0.dist-info/RECORD +26 -0
- reconkit-0.1.0.dist-info/WHEEL +4 -0
- reconkit-0.1.0.dist-info/entry_points.txt +2 -0
- reconkit-0.1.0.dist-info/licenses/LICENSE +21 -0
reconkit/__init__.py
ADDED
|
@@ -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
|
+
]
|
reconkit/cli.py
ADDED
|
@@ -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}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterator, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_PRINTABLE = set(range(0x20, 0x7F)) # basic ASCII printable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_strings(
|
|
11
|
+
path: Path,
|
|
12
|
+
*,
|
|
13
|
+
min_len: int = 6,
|
|
14
|
+
max_strings: Optional[int] = None,
|
|
15
|
+
chunk_size: int = 1024 * 1024,
|
|
16
|
+
) -> Iterator[str]:
|
|
17
|
+
"""Extract basic ASCII printable strings.
|
|
18
|
+
|
|
19
|
+
Intentionally simple (easy to replicate). For deeper analysis, consider specialized tools.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
buf = bytearray()
|
|
23
|
+
yielded = 0
|
|
24
|
+
|
|
25
|
+
def flush() -> Iterator[str]:
|
|
26
|
+
nonlocal buf, yielded
|
|
27
|
+
if len(buf) >= min_len:
|
|
28
|
+
yielded += 1
|
|
29
|
+
yield buf.decode("ascii", errors="ignore")
|
|
30
|
+
buf = bytearray()
|
|
31
|
+
|
|
32
|
+
with path.open("rb") as f:
|
|
33
|
+
while True:
|
|
34
|
+
chunk = f.read(chunk_size)
|
|
35
|
+
if not chunk:
|
|
36
|
+
break
|
|
37
|
+
for b in chunk:
|
|
38
|
+
if b in _PRINTABLE:
|
|
39
|
+
buf.append(b)
|
|
40
|
+
else:
|
|
41
|
+
for s in flush():
|
|
42
|
+
yield s
|
|
43
|
+
if max_strings is not None and yielded >= max_strings:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
for s in flush():
|
|
47
|
+
yield s
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Noir Stack / Hexarch / Judicium public interface.
|
|
2
|
+
|
|
3
|
+
Deliberately conservative: explicit provenance over convenience.
|
|
4
|
+
If you want a tool that invents facts, there are easier ways to do that.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .errors import (
|
|
8
|
+
AcquisitionFailureError,
|
|
9
|
+
EvidencePolicyError,
|
|
10
|
+
EvidenceSizeLimitError,
|
|
11
|
+
NoirStackError,
|
|
12
|
+
ProvenanceRequiredError,
|
|
13
|
+
RobotsDisallowedError,
|
|
14
|
+
TrustBoundaryError,
|
|
15
|
+
)
|
|
16
|
+
from .primitives import EvidenceArtifact, EvidenceBundle, ProvenanceRecord, VerificationResult
|
|
17
|
+
from .public_api import (
|
|
18
|
+
acquire_web_artifact,
|
|
19
|
+
boundary_dns,
|
|
20
|
+
boundary_ip,
|
|
21
|
+
boundary_whois,
|
|
22
|
+
bundle,
|
|
23
|
+
derive_links,
|
|
24
|
+
derive_readable_text,
|
|
25
|
+
verify_exif,
|
|
26
|
+
verify_file_hash,
|
|
27
|
+
verify_file_type,
|
|
28
|
+
verify_strings,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"EvidenceArtifact",
|
|
33
|
+
"EvidenceBundle",
|
|
34
|
+
"ProvenanceRecord",
|
|
35
|
+
"VerificationResult",
|
|
36
|
+
"NoirStackError",
|
|
37
|
+
"TrustBoundaryError",
|
|
38
|
+
"ProvenanceRequiredError",
|
|
39
|
+
"EvidencePolicyError",
|
|
40
|
+
"RobotsDisallowedError",
|
|
41
|
+
"EvidenceSizeLimitError",
|
|
42
|
+
"AcquisitionFailureError",
|
|
43
|
+
"acquire_web_artifact",
|
|
44
|
+
"derive_links",
|
|
45
|
+
"derive_readable_text",
|
|
46
|
+
"boundary_dns",
|
|
47
|
+
"boundary_whois",
|
|
48
|
+
"boundary_ip",
|
|
49
|
+
"verify_file_hash",
|
|
50
|
+
"verify_file_type",
|
|
51
|
+
"verify_strings",
|
|
52
|
+
"verify_exif",
|
|
53
|
+
"bundle",
|
|
54
|
+
]
|
reconkit/noir/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NoirStackError(Exception):
|
|
5
|
+
"""Base class for Noir Stack domain errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TrustBoundaryError(NoirStackError):
|
|
9
|
+
"""Operation attempted across an unapproved or undefined trust boundary."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProvenanceRequiredError(NoirStackError):
|
|
13
|
+
"""Raised when an operation requires provenance and none is provided."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EvidencePolicyError(NoirStackError):
|
|
17
|
+
"""Raised when evidence acquisition violates a declared policy."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RobotsDisallowedError(EvidencePolicyError):
|
|
21
|
+
"""Raised when robots.txt forbids acquisition for the declared agent."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EvidenceSizeLimitError(EvidencePolicyError):
|
|
25
|
+
"""Raised when an artifact exceeds declared acquisition limits."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AcquisitionFailureError(NoirStackError):
|
|
29
|
+
"""Raised when acquisition fails within the declared boundary."""
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Literal, Optional
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
VerificationStatus = Literal[
|
|
12
|
+
"verified",
|
|
13
|
+
"unverified",
|
|
14
|
+
"inconclusive",
|
|
15
|
+
"blocked",
|
|
16
|
+
"informational",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
ProvenanceConfidence = Literal[
|
|
20
|
+
"high",
|
|
21
|
+
"medium",
|
|
22
|
+
"low",
|
|
23
|
+
"unknown",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _utc_now() -> datetime:
|
|
28
|
+
return datetime.now(timezone.utc)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ProvenanceRecord:
|
|
33
|
+
"""Audit-grade statement of how an artifact was obtained.
|
|
34
|
+
|
|
35
|
+
This is not a promise of truth. It is a statement of method, boundary, and limits.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
source: str
|
|
39
|
+
acquisition_method: str
|
|
40
|
+
trust_boundary: str
|
|
41
|
+
observed_at: datetime = field(default_factory=_utc_now)
|
|
42
|
+
observer: str = "reconkit.judicium"
|
|
43
|
+
notes: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class EvidenceArtifact:
|
|
48
|
+
"""Opaque artifact plus provenance.
|
|
49
|
+
|
|
50
|
+
Treat payload as immutable evidence; derive additional artifacts rather than mutating.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
artifact_id: str
|
|
54
|
+
kind: str
|
|
55
|
+
payload: bytes
|
|
56
|
+
media_type: Optional[str]
|
|
57
|
+
encoding: Optional[str]
|
|
58
|
+
provenance: ProvenanceRecord
|
|
59
|
+
sha256: str
|
|
60
|
+
size_bytes: int
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def from_bytes(
|
|
64
|
+
*,
|
|
65
|
+
kind: str,
|
|
66
|
+
payload: bytes,
|
|
67
|
+
provenance: ProvenanceRecord,
|
|
68
|
+
media_type: Optional[str] = None,
|
|
69
|
+
encoding: Optional[str] = None,
|
|
70
|
+
artifact_id: Optional[str] = None,
|
|
71
|
+
) -> "EvidenceArtifact":
|
|
72
|
+
digest = hashlib.sha256(payload).hexdigest()
|
|
73
|
+
return EvidenceArtifact(
|
|
74
|
+
artifact_id=artifact_id or str(uuid4()),
|
|
75
|
+
kind=kind,
|
|
76
|
+
payload=payload,
|
|
77
|
+
media_type=media_type,
|
|
78
|
+
encoding=encoding,
|
|
79
|
+
provenance=provenance,
|
|
80
|
+
sha256=digest,
|
|
81
|
+
size_bytes=len(payload),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def to_json_dict(self, *, include_payload: bool = False) -> dict[str, Any]:
|
|
85
|
+
out: dict[str, Any] = {
|
|
86
|
+
"artifact_id": self.artifact_id,
|
|
87
|
+
"kind": self.kind,
|
|
88
|
+
"sha256": self.sha256,
|
|
89
|
+
"size_bytes": self.size_bytes,
|
|
90
|
+
"media_type": self.media_type,
|
|
91
|
+
"encoding": self.encoding,
|
|
92
|
+
"provenance": {
|
|
93
|
+
"source": self.provenance.source,
|
|
94
|
+
"acquisition_method": self.provenance.acquisition_method,
|
|
95
|
+
"trust_boundary": self.provenance.trust_boundary,
|
|
96
|
+
"observed_at": self.provenance.observed_at.isoformat(),
|
|
97
|
+
"observer": self.provenance.observer,
|
|
98
|
+
"notes": list(self.provenance.notes),
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
if include_payload:
|
|
102
|
+
out["payload_b64"] = base64.b64encode(self.payload).decode("ascii")
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class EvidenceBundle:
|
|
108
|
+
"""A set of artifacts intended to be assessed together."""
|
|
109
|
+
|
|
110
|
+
bundle_id: str
|
|
111
|
+
artifacts: list[EvidenceArtifact]
|
|
112
|
+
created_at: datetime = field(default_factory=_utc_now)
|
|
113
|
+
notes: list[str] = field(default_factory=list)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def of(*artifacts: EvidenceArtifact, notes: Optional[list[str]] = None) -> "EvidenceBundle":
|
|
117
|
+
return EvidenceBundle(
|
|
118
|
+
bundle_id=str(uuid4()),
|
|
119
|
+
artifacts=list(artifacts),
|
|
120
|
+
notes=list(notes or []),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class VerificationResult:
|
|
126
|
+
"""Result of a verification action.
|
|
127
|
+
|
|
128
|
+
Required fields for structured output:
|
|
129
|
+
- decision_basis
|
|
130
|
+
- verification_status
|
|
131
|
+
- provenance_confidence
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
verification_status: VerificationStatus
|
|
135
|
+
decision_basis: str
|
|
136
|
+
provenance_confidence: ProvenanceConfidence
|
|
137
|
+
|
|
138
|
+
subject: dict[str, Any] = field(default_factory=dict)
|
|
139
|
+
findings: dict[str, Any] = field(default_factory=dict)
|
|
140
|
+
limitations: list[str] = field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
def to_json_dict(self) -> dict[str, Any]:
|
|
143
|
+
return {
|
|
144
|
+
"verification_status": self.verification_status,
|
|
145
|
+
"decision_basis": self.decision_basis,
|
|
146
|
+
"provenance_confidence": self.provenance_confidence,
|
|
147
|
+
"subject": self.subject,
|
|
148
|
+
"findings": self.findings,
|
|
149
|
+
"limitations": list(self.limitations),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def json_dumps_audit(obj: Any) -> str:
|
|
154
|
+
return json.dumps(obj, indent=2, sort_keys=True, default=str)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from reconkit.forensics import exif as exif_mod
|
|
8
|
+
from reconkit.forensics import hashing, identify, strings as strings_mod
|
|
9
|
+
from reconkit.osint import dns as dns_mod
|
|
10
|
+
from reconkit.osint import ip as ip_mod
|
|
11
|
+
from reconkit.osint import whois as whois_mod
|
|
12
|
+
from reconkit.scraping import extract, fetch
|
|
13
|
+
|
|
14
|
+
from .errors import NoirStackError
|
|
15
|
+
from .primitives import EvidenceArtifact, EvidenceBundle, ProvenanceRecord, VerificationResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def acquire_web_artifact(
|
|
19
|
+
url: str,
|
|
20
|
+
*,
|
|
21
|
+
trust_boundary: str = "public-internet",
|
|
22
|
+
rps: float = 1.0,
|
|
23
|
+
timeout_s: float = 20.0,
|
|
24
|
+
max_bytes: int = 2_000_000,
|
|
25
|
+
obey_robots: bool = True,
|
|
26
|
+
user_agent: str = "reconkit/0.1 (+https://localhost)",
|
|
27
|
+
) -> EvidenceArtifact:
|
|
28
|
+
"""Acquire a web artifact as evidence.
|
|
29
|
+
|
|
30
|
+
Conservatism note: this only asserts transport-level acquisition. It does not assert truth.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
prov = ProvenanceRecord(
|
|
34
|
+
source=url,
|
|
35
|
+
acquisition_method="http-get",
|
|
36
|
+
trust_boundary=trust_boundary,
|
|
37
|
+
notes=[
|
|
38
|
+
"Transport acquisition only; content authenticity is not asserted.",
|
|
39
|
+
],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
result = fetch.fetch_url(
|
|
44
|
+
url,
|
|
45
|
+
max_bytes=max_bytes,
|
|
46
|
+
timeout_s=timeout_s,
|
|
47
|
+
rps=rps,
|
|
48
|
+
obey_robots=obey_robots,
|
|
49
|
+
user_agent=user_agent,
|
|
50
|
+
)
|
|
51
|
+
except NoirStackError:
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
media_type = result.headers.get("content-type")
|
|
55
|
+
return EvidenceArtifact.from_bytes(
|
|
56
|
+
kind="web-response",
|
|
57
|
+
payload=result.body,
|
|
58
|
+
media_type=media_type,
|
|
59
|
+
encoding=result.encoding,
|
|
60
|
+
provenance=prov,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def derive_links(artifact: EvidenceArtifact, *, base_url: str) -> EvidenceArtifact:
|
|
65
|
+
links = extract.extract_links(artifact.payload, base_url=base_url)
|
|
66
|
+
payload = ("\n".join(links) + "\n").encode("utf-8")
|
|
67
|
+
prov = ProvenanceRecord(
|
|
68
|
+
source=artifact.artifact_id,
|
|
69
|
+
acquisition_method="derived-links",
|
|
70
|
+
trust_boundary=artifact.provenance.trust_boundary,
|
|
71
|
+
notes=[
|
|
72
|
+
"Derived artifact; link extraction is heuristic and HTML-dependent.",
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
return EvidenceArtifact.from_bytes(
|
|
76
|
+
kind="derived-links",
|
|
77
|
+
payload=payload,
|
|
78
|
+
media_type="text/plain",
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
provenance=prov,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def derive_readable_text(artifact: EvidenceArtifact, *, base_url: str) -> EvidenceArtifact:
|
|
85
|
+
text = extract.readable_text(artifact.payload, base_url=base_url)
|
|
86
|
+
payload = (text + "\n").encode("utf-8")
|
|
87
|
+
prov = ProvenanceRecord(
|
|
88
|
+
source=artifact.artifact_id,
|
|
89
|
+
acquisition_method="derived-readable-text",
|
|
90
|
+
trust_boundary=artifact.provenance.trust_boundary,
|
|
91
|
+
notes=[
|
|
92
|
+
"Derived artifact; readability extraction removes scripts/styles and may omit context.",
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
return EvidenceArtifact.from_bytes(
|
|
96
|
+
kind="derived-readable-text",
|
|
97
|
+
payload=payload,
|
|
98
|
+
media_type="text/plain",
|
|
99
|
+
encoding="utf-8",
|
|
100
|
+
provenance=prov,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def boundary_dns(domain: str) -> EvidenceArtifact:
|
|
105
|
+
data = dns_mod.lookup(domain)
|
|
106
|
+
prov = ProvenanceRecord(
|
|
107
|
+
source=domain,
|
|
108
|
+
acquisition_method="dns-resolve",
|
|
109
|
+
trust_boundary="public-internet",
|
|
110
|
+
notes=[
|
|
111
|
+
"DNS answers are boundary observations, not identity proofs.",
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
payload = json.dumps(data, indent=2, sort_keys=True).encode("utf-8")
|
|
115
|
+
return EvidenceArtifact.from_bytes(
|
|
116
|
+
kind="boundary-dns",
|
|
117
|
+
payload=payload,
|
|
118
|
+
media_type="application/json",
|
|
119
|
+
encoding="utf-8",
|
|
120
|
+
provenance=prov,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def boundary_whois(domain: str) -> EvidenceArtifact:
|
|
125
|
+
data = whois_mod.lookup(domain)
|
|
126
|
+
prov = ProvenanceRecord(
|
|
127
|
+
source=domain,
|
|
128
|
+
acquisition_method="whois",
|
|
129
|
+
trust_boundary="public-internet",
|
|
130
|
+
notes=[
|
|
131
|
+
"WHOIS data is registrar-provided and may be redacted or stale.",
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
payload = json.dumps(data, indent=2, sort_keys=True, default=str).encode("utf-8")
|
|
135
|
+
return EvidenceArtifact.from_bytes(
|
|
136
|
+
kind="boundary-whois",
|
|
137
|
+
payload=payload,
|
|
138
|
+
media_type="application/json",
|
|
139
|
+
encoding="utf-8",
|
|
140
|
+
provenance=prov,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def boundary_ip(ip: str) -> EvidenceArtifact:
|
|
145
|
+
data = ip_mod.lookup(ip)
|
|
146
|
+
prov = ProvenanceRecord(
|
|
147
|
+
source=ip,
|
|
148
|
+
acquisition_method="ip-whois-rdap",
|
|
149
|
+
trust_boundary="public-internet",
|
|
150
|
+
notes=[
|
|
151
|
+
"RDAP/WHOIS is administrative metadata; attribution remains a separate claim.",
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
payload = json.dumps(data, indent=2, sort_keys=True, default=str).encode("utf-8")
|
|
155
|
+
return EvidenceArtifact.from_bytes(
|
|
156
|
+
kind="boundary-ip",
|
|
157
|
+
payload=payload,
|
|
158
|
+
media_type="application/json",
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
provenance=prov,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def verify_file_hash(path: Path, *, algo: str = "sha256") -> VerificationResult:
|
|
165
|
+
digest = hashing.hash_file(path, algo=algo)
|
|
166
|
+
return VerificationResult(
|
|
167
|
+
verification_status="informational",
|
|
168
|
+
decision_basis=f"computed {algo} over file bytes",
|
|
169
|
+
provenance_confidence="high",
|
|
170
|
+
subject={"path": str(path), "algo": algo},
|
|
171
|
+
findings={"digest": digest},
|
|
172
|
+
limitations=[
|
|
173
|
+
"Hash computation verifies byte stability, not file origin.",
|
|
174
|
+
],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def verify_file_type(path: Path) -> VerificationResult:
|
|
179
|
+
info = identify.identify_file(path)
|
|
180
|
+
return VerificationResult(
|
|
181
|
+
verification_status="informational",
|
|
182
|
+
decision_basis="magic-byte signature guess",
|
|
183
|
+
provenance_confidence="high",
|
|
184
|
+
subject={"path": str(path)},
|
|
185
|
+
findings=info,
|
|
186
|
+
limitations=[
|
|
187
|
+
"File type guess is heuristic; containers and polyglots may evade identification.",
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def verify_exif(path: Path) -> VerificationResult:
|
|
193
|
+
data = exif_mod.extract_exif(path)
|
|
194
|
+
return VerificationResult(
|
|
195
|
+
verification_status="informational",
|
|
196
|
+
decision_basis="EXIF tag extraction",
|
|
197
|
+
provenance_confidence="high",
|
|
198
|
+
subject={"path": str(path)},
|
|
199
|
+
findings={"exif": data},
|
|
200
|
+
limitations=[
|
|
201
|
+
"EXIF may be absent, altered, or stripped; authenticity is not asserted.",
|
|
202
|
+
],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def verify_strings(path: Path, *, min_len: int = 6, max_strings: Optional[int] = None) -> VerificationResult:
|
|
207
|
+
strings = list(strings_mod.extract_strings(path, min_len=min_len, max_strings=max_strings))
|
|
208
|
+
return VerificationResult(
|
|
209
|
+
verification_status="informational",
|
|
210
|
+
decision_basis=f"ASCII printable strings, min_len={min_len}",
|
|
211
|
+
provenance_confidence="high",
|
|
212
|
+
subject={"path": str(path), "min_len": min_len, "max_strings": max_strings},
|
|
213
|
+
findings={"strings": strings},
|
|
214
|
+
limitations=[
|
|
215
|
+
"This extractor is ASCII-only and will miss many encodings.",
|
|
216
|
+
],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def bundle(*artifacts: EvidenceArtifact, notes: Optional[list[str]] = None) -> EvidenceBundle:
|
|
221
|
+
return EvidenceBundle.of(*artifacts, notes=notes)
|
|
File without changes
|
reconkit/osint/dns.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dns.resolver
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def lookup(domain: str) -> dict[str, list[str]]:
|
|
7
|
+
resolver = dns.resolver.Resolver()
|
|
8
|
+
record_types = ["A", "AAAA", "MX", "TXT", "NS", "CNAME", "SOA"]
|
|
9
|
+
result: dict[str, list[str]] = {}
|
|
10
|
+
|
|
11
|
+
for rtype in record_types:
|
|
12
|
+
try:
|
|
13
|
+
answers = resolver.resolve(domain, rtype)
|
|
14
|
+
result[rtype] = [str(a).strip() for a in answers]
|
|
15
|
+
except Exception:
|
|
16
|
+
result[rtype] = []
|
|
17
|
+
|
|
18
|
+
return result
|
reconkit/osint/ip.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ipwhois import IPWhois
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def lookup(ip: str) -> dict[str, Any]:
|
|
9
|
+
obj = IPWhois(ip)
|
|
10
|
+
data = obj.lookup_rdap(depth=1)
|
|
11
|
+
|
|
12
|
+
# Keep a small, stable subset for display
|
|
13
|
+
return {
|
|
14
|
+
"query": ip,
|
|
15
|
+
"asn": data.get("asn"),
|
|
16
|
+
"asn_cidr": data.get("asn_cidr"),
|
|
17
|
+
"asn_country_code": data.get("asn_country_code"),
|
|
18
|
+
"asn_description": data.get("asn_description"),
|
|
19
|
+
"network": {
|
|
20
|
+
"name": (data.get("network") or {}).get("name"),
|
|
21
|
+
"country": (data.get("network") or {}).get("country"),
|
|
22
|
+
"cidr": (data.get("network") or {}).get("cidr"),
|
|
23
|
+
},
|
|
24
|
+
"objects": list((data.get("objects") or {}).keys()),
|
|
25
|
+
}
|
reconkit/osint/whois.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import whois
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def lookup(domain: str) -> dict[str, Any]:
|
|
9
|
+
"""Perform a WHOIS lookup and normalize to a JSON-friendly dict."""
|
|
10
|
+
|
|
11
|
+
w = whois.whois(domain)
|
|
12
|
+
# python-whois returns a dict-like object with some date fields.
|
|
13
|
+
data: dict[str, Any] = dict(w)
|
|
14
|
+
return data
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
|
|
5
|
+
from bs4 import BeautifulSoup
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_links(html: bytes, *, base_url: str) -> list[str]:
|
|
9
|
+
soup = BeautifulSoup(html, "lxml")
|
|
10
|
+
out: list[str] = []
|
|
11
|
+
for a in soup.select("a[href]"):
|
|
12
|
+
href = a.get("href")
|
|
13
|
+
if not href or not isinstance(href, str):
|
|
14
|
+
continue
|
|
15
|
+
out.append(urljoin(base_url, href))
|
|
16
|
+
# de-dup while preserving order
|
|
17
|
+
seen: set[str] = set()
|
|
18
|
+
unique: list[str] = []
|
|
19
|
+
for u in out:
|
|
20
|
+
if u in seen:
|
|
21
|
+
continue
|
|
22
|
+
seen.add(u)
|
|
23
|
+
unique.append(u)
|
|
24
|
+
return unique
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def readable_text(html: bytes, *, base_url: str | None = None) -> str:
|
|
28
|
+
soup = BeautifulSoup(html, "lxml")
|
|
29
|
+
|
|
30
|
+
for tag in soup(["script", "style", "noscript"]):
|
|
31
|
+
tag.decompose()
|
|
32
|
+
|
|
33
|
+
text = soup.get_text("\n")
|
|
34
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
35
|
+
lines = [line for line in lines if line]
|
|
36
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from reconkit.core.rate_limit import RateLimiter
|
|
9
|
+
from reconkit.scraping import robots
|
|
10
|
+
from reconkit.noir.errors import AcquisitionFailureError, EvidenceSizeLimitError, RobotsDisallowedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class FetchResult:
|
|
15
|
+
url: str
|
|
16
|
+
status_code: int
|
|
17
|
+
headers: dict[str, str]
|
|
18
|
+
body: bytes
|
|
19
|
+
encoding: Optional[str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch_url(
|
|
23
|
+
url: str,
|
|
24
|
+
*,
|
|
25
|
+
max_bytes: int = 2_000_000,
|
|
26
|
+
timeout_s: float = 20.0,
|
|
27
|
+
rps: float = 1.0,
|
|
28
|
+
obey_robots: bool = True,
|
|
29
|
+
user_agent: str = "reconkit/0.1 (+https://localhost)",
|
|
30
|
+
retries: int = 2,
|
|
31
|
+
) -> FetchResult:
|
|
32
|
+
"""Fetch a URL with conservative defaults.
|
|
33
|
+
|
|
34
|
+
No anti-bot bypass behavior is implemented (no proxy rotation, no fingerprinting evasion).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
limiter = RateLimiter(requests_per_second=rps)
|
|
38
|
+
limiter.wait()
|
|
39
|
+
|
|
40
|
+
headers = {"User-Agent": user_agent, "Accept": "text/html,application/xhtml+xml,*/*;q=0.8"}
|
|
41
|
+
timeout = httpx.Timeout(timeout_s)
|
|
42
|
+
|
|
43
|
+
with httpx.Client(follow_redirects=True, timeout=timeout, headers=headers) as client:
|
|
44
|
+
if obey_robots and not robots.can_fetch(url, user_agent=user_agent, timeout_s=min(10.0, timeout_s), client=client):
|
|
45
|
+
raise RobotsDisallowedError(
|
|
46
|
+
"Acquisition denied by robots.txt policy. "
|
|
47
|
+
"Trust boundary: remote publisher policy; provenance cannot be improved by bypass."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
for attempt in range(retries + 1):
|
|
51
|
+
try:
|
|
52
|
+
with client.stream("GET", url) as resp:
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
chunks: list[bytes] = []
|
|
55
|
+
size = 0
|
|
56
|
+
for chunk in resp.iter_bytes():
|
|
57
|
+
chunks.append(chunk)
|
|
58
|
+
size += len(chunk)
|
|
59
|
+
if max_bytes and size > max_bytes:
|
|
60
|
+
raise EvidenceSizeLimitError(
|
|
61
|
+
f"Evidence size limit exceeded (max_bytes={max_bytes}). "
|
|
62
|
+
"Trust boundary: acquisition constraints; partial evidence is not emitted by default."
|
|
63
|
+
)
|
|
64
|
+
body = b"".join(chunks)
|
|
65
|
+
|
|
66
|
+
return FetchResult(
|
|
67
|
+
url=str(resp.url),
|
|
68
|
+
status_code=resp.status_code,
|
|
69
|
+
headers={k: v for k, v in resp.headers.items()},
|
|
70
|
+
body=body,
|
|
71
|
+
encoding=resp.encoding,
|
|
72
|
+
)
|
|
73
|
+
except (httpx.TimeoutException, httpx.NetworkError):
|
|
74
|
+
if attempt >= retries:
|
|
75
|
+
raise AcquisitionFailureError(
|
|
76
|
+
"Acquisition failed within the declared trust boundary (network/timeout). "
|
|
77
|
+
"Decision basis: transport failure; provenance confidence unchanged."
|
|
78
|
+
)
|
|
79
|
+
limiter.wait()
|
|
80
|
+
|
|
81
|
+
raise RuntimeError("Unreachable")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
from urllib.robotparser import RobotFileParser
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
_CACHE: dict[str, RobotFileParser] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def robots_url(url: str) -> str:
|
|
12
|
+
parsed = urlparse(url)
|
|
13
|
+
return f"{parsed.scheme}://{parsed.netloc}/robots.txt"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def can_fetch(
|
|
17
|
+
url: str,
|
|
18
|
+
*,
|
|
19
|
+
user_agent: str,
|
|
20
|
+
timeout_s: float = 10.0,
|
|
21
|
+
client: httpx.Client | None = None,
|
|
22
|
+
) -> bool:
|
|
23
|
+
"""Return whether `user_agent` is allowed to fetch `url` per robots.txt.
|
|
24
|
+
|
|
25
|
+
Safe defaults:
|
|
26
|
+
- If robots.txt cannot be fetched/parsed, default to allow.
|
|
27
|
+
- Caches robots parsers per (scheme, netloc).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
parsed = urlparse(url)
|
|
31
|
+
key = f"{parsed.scheme}://{parsed.netloc}"
|
|
32
|
+
rp = _CACHE.get(key)
|
|
33
|
+
if rp is None:
|
|
34
|
+
rp = RobotFileParser()
|
|
35
|
+
robots_txt = f"{key}/robots.txt"
|
|
36
|
+
rp.set_url(robots_txt)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
if client is None:
|
|
40
|
+
with httpx.Client(timeout=httpx.Timeout(timeout_s), follow_redirects=True) as c:
|
|
41
|
+
resp = c.get(robots_txt)
|
|
42
|
+
else:
|
|
43
|
+
resp = client.get(robots_txt)
|
|
44
|
+
|
|
45
|
+
if resp.status_code >= 400:
|
|
46
|
+
# treat missing/blocked robots as allow
|
|
47
|
+
_CACHE[key] = rp
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
rp.parse(resp.text.splitlines())
|
|
51
|
+
except Exception:
|
|
52
|
+
_CACHE[key] = rp
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
_CACHE[key] = rp
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
return bool(rp.can_fetch(user_agent, url))
|
|
59
|
+
except Exception:
|
|
60
|
+
return True
|
|
@@ -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,26 @@
|
|
|
1
|
+
reconkit/__init__.py,sha256=-640z75cTTFaQnqNm43lxM_F9_gQVywAnp6ouC5ZVp8,496
|
|
2
|
+
reconkit/cli.py,sha256=KZXT07CtZTvxEsbdRGIFPZOP0aBFnktyZ4YH-87MDY8,8365
|
|
3
|
+
reconkit/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
reconkit/core/rate_limit.py,sha256=fOsav7k-yIFVJNeijSf8Fh2p4Cd-hpLkXmmnws-sCPQ,667
|
|
5
|
+
reconkit/forensics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
reconkit/forensics/exif.py,sha256=jwXoxy437iCF9SWWqhzUexR3MGL8iCVMMBwuqS-EewQ,367
|
|
7
|
+
reconkit/forensics/hashing.py,sha256=09YNWEYNLSong9l9-qm8chzFy22t65FOnzaI2pkP-sI,595
|
|
8
|
+
reconkit/forensics/identify.py,sha256=N_R8n5tMTuOkLvAl95aSqUEliAXtGULQZDC8hmSP-6k,341
|
|
9
|
+
reconkit/forensics/strings.py,sha256=RU_n61URl2-9dA0Hj1NVi8mjRT87ma4tHDFnCB2F4ZQ,1245
|
|
10
|
+
reconkit/noir/__init__.py,sha256=s-Ey2F6gV1a7bbqb-ObcKDAB_oyANRtalZKO2hVWrpI,1225
|
|
11
|
+
reconkit/noir/errors.py,sha256=qvs_790TQuxtSs8DPi97C2hYZtu7bTCH2LJfWjR0Hjs,888
|
|
12
|
+
reconkit/noir/primitives.py,sha256=PKcvs-EoZUO6yXm3AYFt_0wl7w3wnroTG0bwkfv7tlE,4549
|
|
13
|
+
reconkit/noir/public_api.py,sha256=Wdp-WotZyZXktz9lw5nPKC6CNW4vX2hO5TfgMbkYVsc,7347
|
|
14
|
+
reconkit/osint/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
reconkit/osint/dns.py,sha256=4qMKgeDOxbz2AJLLSWClLPUWfCWldz-OUNFhuffwj1c,507
|
|
16
|
+
reconkit/osint/ip.py,sha256=OBSRX8chGrYZKtJrWS0fbLL-iQuqzjDCwzWw4XZw1us,769
|
|
17
|
+
reconkit/osint/whois.py,sha256=_sNM5kGdo_ems2q80Elzrxh3BnOMfv815bomFno4CfM,353
|
|
18
|
+
reconkit/scraping/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
reconkit/scraping/extract.py,sha256=IOqdum232kTnrap0ibcMSbsfBPT5-PXpTb8pWVVp9v8,1012
|
|
20
|
+
reconkit/scraping/fetch.py,sha256=uGz8EYpkQzm8DIlqePXdH8T5gGOigKCdAq9x7PBqdgY,3096
|
|
21
|
+
reconkit/scraping/robots.py,sha256=xOfAoc3ZhGGqpqPzHFxIid9wFYdYmANTMME-0T5-yR8,1596
|
|
22
|
+
reconkit-0.1.0.dist-info/METADATA,sha256=XRqdONqZ_AplrigKOOCNg4iNcM7usXAc0SuWDb17JC4,3754
|
|
23
|
+
reconkit-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
24
|
+
reconkit-0.1.0.dist-info/entry_points.txt,sha256=QmRcskGj_VbAP3HyCDGbm5uQ8XxYcRl9VSp-0Rfq5Rw,46
|
|
25
|
+
reconkit-0.1.0.dist-info/licenses/LICENSE,sha256=1brMukln6lIrsZyBqbIiwIWTOMMgX1yWQwdamL-MJKs,1092
|
|
26
|
+
reconkit-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|