reconkit 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {reconkit-0.1.0 → reconkit-0.2.0}/CHANGELOG.md +11 -0
  2. reconkit-0.2.0/PKG-INFO +127 -0
  3. reconkit-0.2.0/README.md +98 -0
  4. {reconkit-0.1.0 → reconkit-0.2.0}/pyproject.toml +4 -3
  5. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/__init__.py +1 -1
  6. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/cli.py +29 -0
  7. reconkit-0.2.0/src/reconkit/evidence/__init__.py +16 -0
  8. reconkit-0.2.0/src/reconkit/evidence/archives.py +108 -0
  9. reconkit-0.2.0/src/reconkit/evidence/custody.py +53 -0
  10. reconkit-0.2.0/src/reconkit/evidence/iris.py +37 -0
  11. reconkit-0.2.0/src/reconkit/evidence/manifest.py +98 -0
  12. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/noir/public_api.py +6 -2
  13. reconkit-0.2.0/src/reconkit/scraping/fetch.py +112 -0
  14. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/scraping/robots.py +6 -7
  15. reconkit-0.2.0/tests/test_evidence_exports.py +48 -0
  16. reconkit-0.1.0/PKG-INFO +0 -108
  17. reconkit-0.1.0/README.md +0 -80
  18. reconkit-0.1.0/src/reconkit/scraping/fetch.py +0 -81
  19. {reconkit-0.1.0 → reconkit-0.2.0}/.gitignore +0 -0
  20. {reconkit-0.1.0 → reconkit-0.2.0}/LICENSE +0 -0
  21. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/core/__init__.py +0 -0
  22. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/core/rate_limit.py +0 -0
  23. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/__init__.py +0 -0
  24. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/exif.py +0 -0
  25. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/hashing.py +0 -0
  26. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/identify.py +0 -0
  27. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/strings.py +0 -0
  28. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/noir/__init__.py +0 -0
  29. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/noir/errors.py +0 -0
  30. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/noir/primitives.py +0 -0
  31. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/osint/__init__.py +0 -0
  32. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/osint/dns.py +0 -0
  33. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/osint/ip.py +0 -0
  34. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/osint/whois.py +0 -0
  35. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/scraping/__init__.py +0 -0
  36. {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/scraping/extract.py +0 -0
  37. {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_extract_links.py +0 -0
  38. {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_hashing.py +0 -0
  39. {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_noir_output_contract.py +0 -0
  40. {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_noir_primitives.py +0 -0
  41. {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_strings.py +0 -0
@@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  Format: loosely based on *Keep a Changelog*. Versioning: SemVer where practical.
6
6
 
7
+ ## [0.2.0] - 2026-11-04
8
+
9
+ ### Added
10
+ - WARC 1.1 and WACZ 1.2 exports with an index, page list, SHA-256 fixity, and data-package digest.
11
+ - Hash-linked chain-of-custody ledgers and Ed25519-signed manifests.
12
+ - DFIR-IRIS v2 evidence-registration adapter.
13
+ - Redirect-chain recording and conservative public-destination checks for web capture.
14
+
15
+ ### Changed
16
+ - Robots rules are checked at every redirect destination and fail closed by default.
17
+
7
18
  ## [0.1.0] - 2026-02-09
8
19
 
9
20
  ### Added
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: reconkit
3
+ Version: 0.2.0
4
+ Summary: Evidence collection, provenance, and preservation utilities for authorized investigations.
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: cryptography>=42
12
+ Requires-Dist: dnspython>=2.6
13
+ Requires-Dist: exifread>=3.0
14
+ Requires-Dist: filetype>=1.2
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: ipwhois>=1.2
17
+ Requires-Dist: lxml>=5.1
18
+ Requires-Dist: python-whois>=0.9
19
+ Requires-Dist: rich>=13.7
20
+ Requires-Dist: typer>=0.12
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2; extra == 'dev'
23
+ Requires-Dist: mypy>=1.10; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.4; extra == 'dev'
27
+ Requires-Dist: twine>=5.1; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # ReconKit
31
+
32
+ ReconKit is a small Python toolkit for preserving authorized web observations and local file findings as reviewable evidence records. It is a collection layer, not a scanner, case-management system, or source of truth.
33
+
34
+ It complements reconnaissance tools such as SpiderFoot and BBOT, web archiving tools such as Webrecorder, and case-management platforms such as DFIR-IRIS. ReconKit focuses on the handoff between collection and review: what was collected, where it came from, what redirects occurred, what limits apply, and whether exported files have changed.
35
+
36
+ ## What it provides
37
+
38
+ - Public-web capture with bounded size, rate limiting, redirect-chain recording, robots checks at every destination, and blocking of private, loopback, and reserved destinations by default.
39
+ - Standards-oriented WARC 1.1 and WACZ 1.2 exports. WACZ files contain a WARC, CDXJ index, page list, file-fixity manifest, and data-package digest.
40
+ - Append-only, hash-linked chain-of-custody ledgers.
41
+ - Ed25519-signed manifests that can be verified offline.
42
+ - A DFIR-IRIS v2 adapter that registers an exported evidence file with an existing case.
43
+ - Local file hashes, magic-byte identification, EXIF extraction, printable-string extraction, and small DNS/WHOIS/RDAP observations.
44
+
45
+ ## What it does not claim
46
+
47
+ ReconKit records a transport observation. It does not prove the identity of a publisher, the truth of content, legal chain of custody, or attribution. A SHA-256 digest proves that bytes have not changed relative to the recorded digest; it does not prove where they originated.
48
+
49
+ Web collection is only for systems and data you own or are authorized to assess. The default public-destination restriction helps prevent accidental collection from local or private infrastructure; it is not a substitute for operational authorization.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ python -m pip install reconkit
55
+ ```
56
+
57
+ For development:
58
+
59
+ ```bash
60
+ python -m pip install -e ".[dev]"
61
+ ```
62
+
63
+ ## Capture and preserve a page
64
+
65
+ ```bash
66
+ reconkit evidence archive-warc "https://example.com" --output evidence/example.warc
67
+ reconkit evidence archive-wacz "https://example.com" --output evidence/example.wacz --title "Example capture"
68
+ ```
69
+
70
+ The WACZ export follows the [WACZ 1.2 layout](https://specs.webrecorder.net/wacz/1.2.0/), so it can be validated and replayed with compatible web-archive tooling.
71
+ Robots rules are respected by default; use `--no-obey-robots` only when you have clear authorization for the capture.
72
+
73
+ ## Sign a manifest and record custody
74
+
75
+ ```python
76
+ from pathlib import Path
77
+ from reconkit.evidence import CustodyLedger, ManifestSigner, verify_manifest, write_manifest
78
+
79
+ evidence = Path("evidence/example.wacz")
80
+ ledger = CustodyLedger("evidence/custody.jsonl")
81
+ ledger.append(action="acquired", actor="analyst@example.org", artifact_path=evidence)
82
+ ledger.append(action="exported", actor="analyst@example.org", artifact_path=evidence)
83
+ assert ledger.verify()
84
+
85
+ signer = ManifestSigner.generate()
86
+ Path("keys/evidence-signing-key.pem").write_bytes(signer.private_pem())
87
+ manifest = write_manifest([evidence], "evidence/manifest.json", signer, custody_ledger=ledger.path)
88
+ assert verify_manifest(manifest)
89
+ ```
90
+
91
+ Keep the private key outside the evidence directory and protect it with your organization’s key-management practices. The manifest embeds the corresponding public key so a reviewer can verify it without the private key.
92
+
93
+ ## Register exported evidence in DFIR-IRIS
94
+
95
+ ```python
96
+ from reconkit.evidence import DFIRIRISAdapter
97
+
98
+ iris = DFIRIRISAdapter("https://iris.example.org", api_key="your-api-key")
99
+ iris.register(
100
+ case_identifier=42,
101
+ evidence_path="evidence/example.wacz",
102
+ description="Public web capture; signature and custody ledger stored alongside export.",
103
+ )
104
+ ```
105
+
106
+ The adapter registers file metadata, digest, and description through DFIR-IRIS’s documented v2 evidence endpoint. Upload and retention of the evidence bytes remain controlled by the case system’s own workflow.
107
+
108
+ ## Existing local commands
109
+
110
+ ```bash
111
+ reconkit evidence acquire-web "https://example.com" --trust-boundary public-internet
112
+ reconkit evidence derive-links "https://example.com"
113
+ reconkit boundary dns example.com
114
+ reconkit verify file-hash .\sample.bin --algo sha256
115
+ ```
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ pytest
121
+ python -m build
122
+ twine check dist/*
123
+ ```
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,98 @@
1
+ # ReconKit
2
+
3
+ ReconKit is a small Python toolkit for preserving authorized web observations and local file findings as reviewable evidence records. It is a collection layer, not a scanner, case-management system, or source of truth.
4
+
5
+ It complements reconnaissance tools such as SpiderFoot and BBOT, web archiving tools such as Webrecorder, and case-management platforms such as DFIR-IRIS. ReconKit focuses on the handoff between collection and review: what was collected, where it came from, what redirects occurred, what limits apply, and whether exported files have changed.
6
+
7
+ ## What it provides
8
+
9
+ - Public-web capture with bounded size, rate limiting, redirect-chain recording, robots checks at every destination, and blocking of private, loopback, and reserved destinations by default.
10
+ - Standards-oriented WARC 1.1 and WACZ 1.2 exports. WACZ files contain a WARC, CDXJ index, page list, file-fixity manifest, and data-package digest.
11
+ - Append-only, hash-linked chain-of-custody ledgers.
12
+ - Ed25519-signed manifests that can be verified offline.
13
+ - A DFIR-IRIS v2 adapter that registers an exported evidence file with an existing case.
14
+ - Local file hashes, magic-byte identification, EXIF extraction, printable-string extraction, and small DNS/WHOIS/RDAP observations.
15
+
16
+ ## What it does not claim
17
+
18
+ ReconKit records a transport observation. It does not prove the identity of a publisher, the truth of content, legal chain of custody, or attribution. A SHA-256 digest proves that bytes have not changed relative to the recorded digest; it does not prove where they originated.
19
+
20
+ Web collection is only for systems and data you own or are authorized to assess. The default public-destination restriction helps prevent accidental collection from local or private infrastructure; it is not a substitute for operational authorization.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ python -m pip install reconkit
26
+ ```
27
+
28
+ For development:
29
+
30
+ ```bash
31
+ python -m pip install -e ".[dev]"
32
+ ```
33
+
34
+ ## Capture and preserve a page
35
+
36
+ ```bash
37
+ reconkit evidence archive-warc "https://example.com" --output evidence/example.warc
38
+ reconkit evidence archive-wacz "https://example.com" --output evidence/example.wacz --title "Example capture"
39
+ ```
40
+
41
+ The WACZ export follows the [WACZ 1.2 layout](https://specs.webrecorder.net/wacz/1.2.0/), so it can be validated and replayed with compatible web-archive tooling.
42
+ Robots rules are respected by default; use `--no-obey-robots` only when you have clear authorization for the capture.
43
+
44
+ ## Sign a manifest and record custody
45
+
46
+ ```python
47
+ from pathlib import Path
48
+ from reconkit.evidence import CustodyLedger, ManifestSigner, verify_manifest, write_manifest
49
+
50
+ evidence = Path("evidence/example.wacz")
51
+ ledger = CustodyLedger("evidence/custody.jsonl")
52
+ ledger.append(action="acquired", actor="analyst@example.org", artifact_path=evidence)
53
+ ledger.append(action="exported", actor="analyst@example.org", artifact_path=evidence)
54
+ assert ledger.verify()
55
+
56
+ signer = ManifestSigner.generate()
57
+ Path("keys/evidence-signing-key.pem").write_bytes(signer.private_pem())
58
+ manifest = write_manifest([evidence], "evidence/manifest.json", signer, custody_ledger=ledger.path)
59
+ assert verify_manifest(manifest)
60
+ ```
61
+
62
+ Keep the private key outside the evidence directory and protect it with your organization’s key-management practices. The manifest embeds the corresponding public key so a reviewer can verify it without the private key.
63
+
64
+ ## Register exported evidence in DFIR-IRIS
65
+
66
+ ```python
67
+ from reconkit.evidence import DFIRIRISAdapter
68
+
69
+ iris = DFIRIRISAdapter("https://iris.example.org", api_key="your-api-key")
70
+ iris.register(
71
+ case_identifier=42,
72
+ evidence_path="evidence/example.wacz",
73
+ description="Public web capture; signature and custody ledger stored alongside export.",
74
+ )
75
+ ```
76
+
77
+ The adapter registers file metadata, digest, and description through DFIR-IRIS’s documented v2 evidence endpoint. Upload and retention of the evidence bytes remain controlled by the case system’s own workflow.
78
+
79
+ ## Existing local commands
80
+
81
+ ```bash
82
+ reconkit evidence acquire-web "https://example.com" --trust-boundary public-internet
83
+ reconkit evidence derive-links "https://example.com"
84
+ reconkit boundary dns example.com
85
+ reconkit verify file-hash .\sample.bin --algo sha256
86
+ ```
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ pytest
92
+ python -m build
93
+ twine check dist/*
94
+ ```
95
+
96
+ ## License
97
+
98
+ MIT
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "reconkit"
7
- version = "0.1.0"
8
- description = "A small, safe-by-default toolkit for scraping, OSINT, and file triage."
7
+ version = "0.2.0"
8
+ description = "Evidence collection, provenance, and preservation utilities for authorized investigations."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { text = "MIT" }
@@ -22,6 +22,7 @@ dependencies = [
22
22
  "ipwhois>=1.2",
23
23
  "exifread>=3.0",
24
24
  "filetype>=1.2",
25
+ "cryptography>=42",
25
26
  ]
26
27
 
27
28
  [project.optional-dependencies]
@@ -42,7 +43,7 @@ packages = ["src/reconkit"]
42
43
 
43
44
  [tool.pytest.ini_options]
44
45
  addopts = "-q"
45
- asyncio_mode = "auto"
46
+ testpaths = ["tests"]
46
47
 
47
48
  [tool.ruff]
48
49
  line-length = 100
@@ -4,7 +4,7 @@ Public API: Noir Stack / Hexarch / Judicium surface lives under `reconkit.noir`.
4
4
  Internal modules remain available but are not the supported public interface.
5
5
  """
6
6
 
7
- __version__ = "0.1.0"
7
+ __version__ = "0.2.0"
8
8
 
9
9
  from reconkit.noir.primitives import ( # noqa: E402
10
10
  EvidenceArtifact,
@@ -6,8 +6,10 @@ from typing import Optional
6
6
  import typer
7
7
  from rich.console import Console
8
8
 
9
+ from reconkit.evidence.archives import export_wacz, export_warc
9
10
  from reconkit.noir import public_api
10
11
  from reconkit.noir.primitives import VerificationResult, json_dumps_audit
12
+ from reconkit.scraping.fetch import fetch_url
11
13
 
12
14
  app = typer.Typer(add_completion=False, no_args_is_help=True)
13
15
  console = Console()
@@ -68,6 +70,33 @@ def evidence_acquire_web(
68
70
  )
69
71
 
70
72
 
73
+ @evidence_app.command("archive-warc")
74
+ def evidence_archive_warc(
75
+ url: str,
76
+ output: Path = typer.Option(..., "--output", "-o"),
77
+ max_bytes: int = typer.Option(2_000_000),
78
+ obey_robots: bool = typer.Option(True, "--obey-robots/--no-obey-robots"),
79
+ ):
80
+ """Capture one public URL and preserve its response in WARC 1.1."""
81
+ capture = fetch_url(url, max_bytes=max_bytes, obey_robots=obey_robots)
82
+ path = export_warc([capture], output)
83
+ typer.echo(json_dumps_audit({"format": "WARC 1.1", "path": str(path)}))
84
+
85
+
86
+ @evidence_app.command("archive-wacz")
87
+ def evidence_archive_wacz(
88
+ url: str,
89
+ output: Path = typer.Option(..., "--output", "-o"),
90
+ title: str = typer.Option("ReconKit collection"),
91
+ max_bytes: int = typer.Option(2_000_000),
92
+ obey_robots: bool = typer.Option(True, "--obey-robots/--no-obey-robots"),
93
+ ):
94
+ """Capture one public URL and create a portable WACZ 1.2 archive."""
95
+ capture = fetch_url(url, max_bytes=max_bytes, obey_robots=obey_robots)
96
+ path = export_wacz([capture], output, title=title)
97
+ typer.echo(json_dumps_audit({"format": "WACZ 1.2", "path": str(path)}))
98
+
99
+
71
100
  @evidence_app.command("derive-links")
72
101
  def evidence_derive_links(
73
102
  url: str,
@@ -0,0 +1,16 @@
1
+ """Portable evidence preservation, custody, signing, and case adapters."""
2
+
3
+ from .archives import export_wacz, export_warc
4
+ from .custody import CustodyLedger
5
+ from .iris import DFIRIRISAdapter
6
+ from .manifest import ManifestSigner, verify_manifest, write_manifest
7
+
8
+ __all__ = [
9
+ "CustodyLedger",
10
+ "DFIRIRISAdapter",
11
+ "ManifestSigner",
12
+ "export_wacz",
13
+ "export_warc",
14
+ "verify_manifest",
15
+ "write_manifest",
16
+ ]
@@ -0,0 +1,108 @@
1
+ """Standards-oriented WARC and WACZ exports for captured HTTP responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import zipfile
8
+ from datetime import timezone
9
+ from pathlib import Path
10
+ from urllib.parse import urlsplit
11
+ from uuid import uuid4
12
+
13
+ from reconkit.scraping.fetch import FetchResult
14
+
15
+
16
+ def _json(value: object) -> bytes:
17
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
18
+
19
+
20
+ def _sha256(data: bytes) -> str:
21
+ return hashlib.sha256(data).hexdigest()
22
+
23
+
24
+ def _surt(url: str) -> str:
25
+ """Return a CDXJ-compatible SURT key for a captured URL."""
26
+ parsed = urlsplit(url)
27
+ hostname = (parsed.hostname or "").lower().strip(".")
28
+ authority = ",".join(reversed(hostname.split(".")))
29
+ if parsed.port and parsed.port not in {80, 443}:
30
+ authority = f"{authority}:{parsed.port}"
31
+ path = parsed.path or "/"
32
+ if parsed.query:
33
+ path = f"{path}?{parsed.query}"
34
+ return f"{authority}){path}"
35
+
36
+
37
+ def _warc_record(headers: dict[str, str], body: bytes) -> bytes:
38
+ head = "WARC/1.1\r\n" + "".join(f"{key}: {value}\r\n" for key, value in headers.items()) + "\r\n"
39
+ return head.encode("utf-8") + body + b"\r\n\r\n"
40
+
41
+
42
+ def _http_response(capture: FetchResult) -> bytes:
43
+ headers = {key: value for key, value in capture.headers.items() if key.lower() not in {"transfer-encoding", "content-length"}}
44
+ headers["Content-Length"] = str(len(capture.body))
45
+ status = f"HTTP/1.1 {capture.status_code}\r\n"
46
+ return status.encode("utf-8") + "".join(f"{key}: {value}\r\n" for key, value in headers.items()).encode("utf-8") + b"\r\n" + capture.body
47
+
48
+
49
+ def warc_bytes(captures: list[FetchResult]) -> tuple[bytes, list[dict[str, object]]]:
50
+ """Create a WARC 1.1 stream and the information needed for a CDXJ index."""
51
+ output = bytearray()
52
+ info = _json({"software": "reconkit", "format": "WARC File Format 1.1"})
53
+ output.extend(_warc_record({
54
+ "WARC-Type": "warcinfo", "WARC-Date": captures[0].observed_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if captures else "1970-01-01T00:00:00Z",
55
+ "WARC-Record-ID": f"<urn:uuid:{uuid4()}>", "Content-Type": "application/warc-fields", "Content-Length": str(len(info)),
56
+ }, info))
57
+ index: list[dict[str, object]] = []
58
+ for capture in captures:
59
+ offset = len(output)
60
+ payload = _http_response(capture)
61
+ digest = _sha256(payload)
62
+ output.extend(_warc_record({
63
+ "WARC-Type": "response", "WARC-Target-URI": capture.url,
64
+ "WARC-Date": capture.observed_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
65
+ "WARC-Record-ID": f"<urn:uuid:{uuid4()}>", "WARC-Block-Digest": f"sha256:{digest}",
66
+ "Content-Type": "application/http; msgtype=response", "Content-Length": str(len(payload)),
67
+ }, payload))
68
+ index.append({"url": capture.url, "timestamp": capture.observed_at.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S"), "status": str(capture.status_code), "digest": f"sha256:{digest}", "offset": offset, "length": len(output) - offset})
69
+ return bytes(output), index
70
+
71
+
72
+ def export_warc(captures: list[FetchResult], destination: str | Path) -> Path:
73
+ """Write a WARC 1.1 file for one or more captures."""
74
+ path = Path(destination)
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ data, _ = warc_bytes(captures)
77
+ path.write_bytes(data)
78
+ return path
79
+
80
+
81
+ def export_wacz(captures: list[FetchResult], destination: str | Path, *, title: str = "ReconKit collection") -> Path:
82
+ """Write a WACZ 1.2 archive with WARC, CDXJ, pages, and fixity manifests."""
83
+ if not captures:
84
+ raise ValueError("At least one capture is required for a WACZ export.")
85
+ path = Path(destination)
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ warc, index = warc_bytes(captures)
88
+ cdxj = b"".join(
89
+ f"{_surt(str(entry['url']))} {entry['timestamp']} ".encode() + _json({
90
+ "url": entry["url"], "status": entry["status"], "digest": entry["digest"],
91
+ "offset": entry["offset"], "length": entry["length"], "filename": "data.warc",
92
+ }) + b"\n" for entry in index
93
+ )
94
+ pages = b"".join(_json({"url": capture.url, "ts": capture.observed_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")}) + b"\n" for capture in captures)
95
+ resources = [
96
+ {"name": "data.warc", "path": "archive/data.warc", "hash": f"sha256:{_sha256(warc)}", "bytes": len(warc)},
97
+ {"name": "index.cdxj", "path": "indexes/index.cdxj", "hash": f"sha256:{_sha256(cdxj)}", "bytes": len(cdxj)},
98
+ {"name": "pages.jsonl", "path": "pages/pages.jsonl", "hash": f"sha256:{_sha256(pages)}", "bytes": len(pages)},
99
+ ]
100
+ datapackage = _json({"profile": "wacz", "title": title, "resources": resources})
101
+ digest = _json({"path": "datapackage.json", "hash": f"sha256:{_sha256(datapackage)}"})
102
+ with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
103
+ archive.writestr("archive/data.warc", warc)
104
+ archive.writestr("indexes/index.cdxj", cdxj)
105
+ archive.writestr("pages/pages.jsonl", pages)
106
+ archive.writestr("datapackage.json", datapackage)
107
+ archive.writestr("datapackage-digest.json", digest)
108
+ return path
@@ -0,0 +1,53 @@
1
+ """Append-only, hash-linked chain-of-custody ledgers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+ from uuid import uuid4
11
+
12
+
13
+ def _canonical(value: Any) -> bytes:
14
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
15
+
16
+
17
+ class CustodyLedger:
18
+ """A JSONL ledger whose event hash commits to the previous event hash."""
19
+
20
+ def __init__(self, path: str | Path):
21
+ self.path = Path(path)
22
+
23
+ def append(self, *, action: str, actor: str, artifact_path: str | Path, notes: str = "") -> dict[str, Any]:
24
+ artifact = Path(artifact_path)
25
+ digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
26
+ previous = self._last_hash()
27
+ event = {
28
+ "event_id": str(uuid4()), "recorded_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
29
+ "action": action, "actor": actor, "artifact": {"path": artifact.name, "sha256": digest, "bytes": artifact.stat().st_size},
30
+ "notes": notes, "previous_event_sha256": previous,
31
+ }
32
+ event["event_sha256"] = hashlib.sha256(_canonical(event)).hexdigest()
33
+ self.path.parent.mkdir(parents=True, exist_ok=True)
34
+ with self.path.open("a", encoding="utf-8", newline="\n") as handle:
35
+ handle.write(_canonical(event).decode("utf-8") + "\n")
36
+ return event
37
+
38
+ def verify(self) -> bool:
39
+ previous: str | None = None
40
+ if not self.path.exists():
41
+ return True
42
+ for line in self.path.read_text(encoding="utf-8").splitlines():
43
+ event = json.loads(line)
44
+ claimed = event.pop("event_sha256", None)
45
+ if claimed != hashlib.sha256(_canonical(event)).hexdigest() or event.get("previous_event_sha256") != previous:
46
+ return False
47
+ previous = claimed
48
+ return True
49
+
50
+ def _last_hash(self) -> str | None:
51
+ if not self.path.exists() or not self.path.stat().st_size:
52
+ return None
53
+ return json.loads(self.path.read_text(encoding="utf-8").splitlines()[-1])["event_sha256"]
@@ -0,0 +1,37 @@
1
+ """Optional DFIR-IRIS v2 evidence registration adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+
10
+
11
+ class DFIRIRISAdapter:
12
+ """Register a preserved file as evidence in an existing DFIR-IRIS case.
13
+
14
+ The adapter registers evidence metadata through the documented v2 endpoint.
15
+ Uploading the evidence bytes is intentionally left to the organization's IRIS
16
+ retention workflow.
17
+ """
18
+
19
+ def __init__(self, base_url: str, api_key: str, *, timeout: float = 20.0):
20
+ self.base_url = base_url.rstrip("/")
21
+ self.api_key = api_key
22
+ self.timeout = timeout
23
+
24
+ def register(self, case_identifier: int, evidence_path: str | Path, *, description: str = "", type_id: int | None = None) -> dict:
25
+ path = Path(evidence_path)
26
+ payload: dict[str, object] = {
27
+ "filename": path.name, "file_size": path.stat().st_size,
28
+ "file_hash": hashlib.sha256(path.read_bytes()).hexdigest(), "file_description": description,
29
+ }
30
+ if type_id is not None:
31
+ payload["type_id"] = type_id
32
+ response = httpx.post(
33
+ f"{self.base_url}/api/v2/cases/{case_identifier}/evidences", json=payload,
34
+ headers={"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"}, timeout=self.timeout,
35
+ )
36
+ response.raise_for_status()
37
+ return response.json()
@@ -0,0 +1,98 @@
1
+ """Ed25519-signed manifests for evidence files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cryptography.exceptions import InvalidSignature
13
+ from cryptography.hazmat.primitives import serialization
14
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
15
+
16
+
17
+ def _canonical(value: Any) -> bytes:
18
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
19
+
20
+
21
+ class ManifestSigner:
22
+ """A local Ed25519 signing key. Keep the private PEM outside evidence exports."""
23
+
24
+ def __init__(self, private_key: Ed25519PrivateKey):
25
+ self._private_key = private_key
26
+
27
+ @classmethod
28
+ def generate(cls) -> ManifestSigner:
29
+ return cls(Ed25519PrivateKey.generate())
30
+
31
+ @classmethod
32
+ def load(cls, pem: bytes, password: bytes | None = None) -> ManifestSigner:
33
+ key = serialization.load_pem_private_key(pem, password=password)
34
+ if not isinstance(key, Ed25519PrivateKey):
35
+ raise TypeError("An Ed25519 private key is required.")
36
+ return cls(key)
37
+
38
+ def private_pem(self, password: bytes | None = None) -> bytes:
39
+ encryption = serialization.NoEncryption() if password is None else serialization.BestAvailableEncryption(password)
40
+ return self._private_key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, encryption)
41
+
42
+ def public_pem(self) -> bytes:
43
+ return self._private_key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
44
+
45
+ def sign(self, data: bytes) -> str:
46
+ return base64.b64encode(self._private_key.sign(data)).decode("ascii")
47
+
48
+
49
+ def write_manifest(files: list[str | Path], destination: str | Path, signer: ManifestSigner, *, custody_ledger: str | Path | None = None) -> Path:
50
+ items = []
51
+ for item in files:
52
+ path = Path(item)
53
+ contents = path.read_bytes()
54
+ items.append({"path": path.name, "bytes": len(contents), "sha256": hashlib.sha256(contents).hexdigest()})
55
+ manifest: dict[str, Any] = {"version": 1, "created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "files": items}
56
+ if custody_ledger is not None:
57
+ ledger = Path(custody_ledger)
58
+ manifest["custody_ledger"] = {"path": ledger.name, "sha256": hashlib.sha256(ledger.read_bytes()).hexdigest()}
59
+ payload = _canonical(manifest)
60
+ envelope = {"manifest": manifest, "algorithm": "Ed25519", "public_key_pem": signer.public_pem().decode("ascii"), "signature": signer.sign(payload)}
61
+ path = Path(destination)
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_bytes(_canonical(envelope) + b"\n")
64
+ return path
65
+
66
+
67
+ def verify_manifest(path: str | Path, *, evidence_dir: str | Path | None = None) -> bool:
68
+ """Verify a manifest signature and, optionally, the referenced evidence files.
69
+
70
+ Supplying ``evidence_dir`` verifies each listed file and custody ledger against
71
+ its recorded SHA-256. Without it, this function verifies the signed manifest
72
+ envelope only.
73
+ """
74
+ envelope = json.loads(Path(path).read_text(encoding="utf-8"))
75
+ public_key = serialization.load_pem_public_key(envelope["public_key_pem"].encode("ascii"))
76
+ if not isinstance(public_key, Ed25519PublicKey) or envelope.get("algorithm") != "Ed25519":
77
+ return False
78
+ try:
79
+ public_key.verify(base64.b64decode(envelope["signature"]), _canonical(envelope["manifest"]))
80
+ except (InvalidSignature, KeyError, TypeError, ValueError):
81
+ return False
82
+
83
+ if evidence_dir is None:
84
+ return True
85
+ root = Path(evidence_dir)
86
+ try:
87
+ for item in envelope["manifest"]["files"]:
88
+ artifact = root / item["path"]
89
+ if not artifact.is_file() or hashlib.sha256(artifact.read_bytes()).hexdigest() != item["sha256"]:
90
+ return False
91
+ ledger = envelope["manifest"].get("custody_ledger")
92
+ if ledger:
93
+ artifact = root / ledger["path"]
94
+ if not artifact.is_file() or hashlib.sha256(artifact.read_bytes()).hexdigest() != ledger["sha256"]:
95
+ return False
96
+ return True
97
+ except (KeyError, OSError, TypeError):
98
+ return False
@@ -23,7 +23,7 @@ def acquire_web_artifact(
23
23
  timeout_s: float = 20.0,
24
24
  max_bytes: int = 2_000_000,
25
25
  obey_robots: bool = True,
26
- user_agent: str = "reconkit/0.1 (+https://localhost)",
26
+ user_agent: str = "reconkit/0.2 (+https://github.com/no1rstack/reconkit)",
27
27
  ) -> EvidenceArtifact:
28
28
  """Acquire a web artifact as evidence.
29
29
 
@@ -57,7 +57,11 @@ def acquire_web_artifact(
57
57
  payload=result.body,
58
58
  media_type=media_type,
59
59
  encoding=result.encoding,
60
- provenance=prov,
60
+ provenance=ProvenanceRecord(
61
+ source=result.url, acquisition_method="http-get", trust_boundary=trust_boundary,
62
+ observed_at=result.observed_at,
63
+ notes=prov.notes + [f"redirect_chain={list(result.redirect_chain)!r}"],
64
+ ),
61
65
  )
62
66
 
63
67
 
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ import ipaddress
6
+ import socket
7
+ from typing import Optional
8
+ from urllib.parse import urljoin, urlparse
9
+
10
+ import httpx
11
+
12
+ from reconkit.core.rate_limit import RateLimiter
13
+ from reconkit.scraping import robots
14
+ from reconkit.noir.errors import AcquisitionFailureError, EvidenceSizeLimitError, RobotsDisallowedError
15
+
16
+
17
+ @dataclass
18
+ class FetchResult:
19
+ requested_url: str
20
+ url: str
21
+ status_code: int
22
+ headers: dict[str, str]
23
+ body: bytes
24
+ encoding: Optional[str]
25
+ observed_at: datetime
26
+ redirect_chain: tuple[dict[str, object], ...] = ()
27
+
28
+
29
+ def _validate_url(url: str, *, allow_private_hosts: bool) -> None:
30
+ parsed = urlparse(url)
31
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
32
+ raise AcquisitionFailureError("Only absolute, credential-free http(s) URLs are permitted.")
33
+ if allow_private_hosts:
34
+ return
35
+ try:
36
+ addresses = {item[4][0] for item in socket.getaddrinfo(parsed.hostname, None)}
37
+ except OSError as exc:
38
+ raise AcquisitionFailureError(f"Could not resolve destination host: {parsed.hostname}") from exc
39
+ for address in addresses:
40
+ value = ipaddress.ip_address(address)
41
+ if not value.is_global:
42
+ raise AcquisitionFailureError("Private, loopback, link-local, and reserved destinations are blocked by default.")
43
+
44
+
45
+ def fetch_url(
46
+ url: str,
47
+ *,
48
+ max_bytes: int = 2_000_000,
49
+ timeout_s: float = 20.0,
50
+ rps: float = 1.0,
51
+ obey_robots: bool = True,
52
+ user_agent: str = "reconkit/0.2 (+https://github.com/no1rstack/reconkit)",
53
+ retries: int = 2,
54
+ max_redirects: int = 10,
55
+ allow_private_hosts: bool = False,
56
+ robots_fail_closed: bool = True,
57
+ ) -> FetchResult:
58
+ """Fetch a URL with conservative defaults.
59
+
60
+ No anti-bot bypass behavior is implemented (no proxy rotation, no fingerprinting evasion).
61
+ """
62
+
63
+ _validate_url(url, allow_private_hosts=allow_private_hosts)
64
+ limiter = RateLimiter(requests_per_second=rps)
65
+
66
+ headers = {"User-Agent": user_agent, "Accept": "text/html,application/xhtml+xml,*/*;q=0.8"}
67
+ timeout = httpx.Timeout(timeout_s)
68
+
69
+ redirects: list[dict[str, object]] = []
70
+ current_url = url
71
+ with httpx.Client(follow_redirects=False, timeout=timeout, headers=headers) as client:
72
+ for _ in range(max_redirects + 1):
73
+ _validate_url(current_url, allow_private_hosts=allow_private_hosts)
74
+ limiter.wait()
75
+ if obey_robots and not robots.can_fetch(
76
+ current_url, user_agent=user_agent, timeout_s=min(10.0, timeout_s), client=client,
77
+ fail_closed=robots_fail_closed,
78
+ ):
79
+ raise RobotsDisallowedError("Acquisition denied because robots.txt does not authorize this URL.")
80
+ for attempt in range(retries + 1):
81
+ try:
82
+ with client.stream("GET", current_url) as resp:
83
+ location = resp.headers.get("location")
84
+ if resp.status_code in {301, 302, 303, 307, 308} and location:
85
+ next_url = urljoin(current_url, location)
86
+ redirects.append({"from": current_url, "to": next_url, "status_code": resp.status_code})
87
+ current_url = next_url
88
+ break
89
+ if resp.status_code >= 400:
90
+ raise AcquisitionFailureError(f"Collection failed with HTTP status {resp.status_code}.")
91
+ chunks: list[bytes] = []
92
+ size = 0
93
+ for chunk in resp.iter_bytes():
94
+ size += len(chunk)
95
+ if max_bytes and size > max_bytes:
96
+ raise EvidenceSizeLimitError(f"Evidence size limit exceeded (max_bytes={max_bytes}).")
97
+ chunks.append(chunk)
98
+ return FetchResult(
99
+ requested_url=url, url=str(resp.url), status_code=resp.status_code,
100
+ headers={k: v for k, v in resp.headers.items()}, body=b"".join(chunks),
101
+ encoding=resp.encoding, observed_at=datetime.now(timezone.utc),
102
+ redirect_chain=tuple(redirects),
103
+ )
104
+ except AcquisitionFailureError:
105
+ raise
106
+ except (httpx.TimeoutException, httpx.NetworkError) as exc:
107
+ if attempt >= retries:
108
+ raise AcquisitionFailureError("Collection failed because the destination was unavailable.") from exc
109
+ else:
110
+ raise AcquisitionFailureError("Collection retry loop ended unexpectedly.")
111
+ continue
112
+ raise AcquisitionFailureError(f"Redirect limit exceeded ({max_redirects}).")
@@ -19,12 +19,12 @@ def can_fetch(
19
19
  user_agent: str,
20
20
  timeout_s: float = 10.0,
21
21
  client: httpx.Client | None = None,
22
+ fail_closed: bool = True,
22
23
  ) -> bool:
23
24
  """Return whether `user_agent` is allowed to fetch `url` per robots.txt.
24
25
 
25
- Safe defaults:
26
- - If robots.txt cannot be fetched/parsed, default to allow.
27
- - Caches robots parsers per (scheme, netloc).
26
+ When ``fail_closed`` is true, an unavailable or malformed robots.txt denies
27
+ collection. That is the conservative default for evidence collection.
28
28
  """
29
29
 
30
30
  parsed = urlparse(url)
@@ -43,18 +43,17 @@ def can_fetch(
43
43
  resp = client.get(robots_txt)
44
44
 
45
45
  if resp.status_code >= 400:
46
- # treat missing/blocked robots as allow
47
46
  _CACHE[key] = rp
48
- return True
47
+ return not fail_closed
49
48
 
50
49
  rp.parse(resp.text.splitlines())
51
50
  except Exception:
52
51
  _CACHE[key] = rp
53
- return True
52
+ return not fail_closed
54
53
 
55
54
  _CACHE[key] = rp
56
55
 
57
56
  try:
58
57
  return bool(rp.can_fetch(user_agent, url))
59
58
  except Exception:
60
- return True
59
+ return not fail_closed
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import zipfile
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from reconkit.evidence.archives import export_wacz, export_warc
9
+ from reconkit.evidence.custody import CustodyLedger
10
+ from reconkit.evidence.manifest import ManifestSigner, verify_manifest, write_manifest
11
+ from reconkit.scraping.fetch import FetchResult
12
+
13
+
14
+ def capture() -> FetchResult:
15
+ return FetchResult(
16
+ requested_url="https://example.com", url="https://example.com/final", status_code=200,
17
+ headers={"content-type": "text/plain"}, body=b"evidence", encoding="utf-8",
18
+ observed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
19
+ redirect_chain=({"from": "https://example.com", "to": "https://example.com/final", "status_code": 302},),
20
+ )
21
+
22
+
23
+ def test_warc_and_wacz_include_standard_manifests(tmp_path: Path):
24
+ warc = export_warc([capture()], tmp_path / "capture.warc")
25
+ assert b"WARC/1.1" in warc.read_bytes()
26
+ wacz = export_wacz([capture()], tmp_path / "capture.wacz", title="Test evidence")
27
+ with zipfile.ZipFile(wacz) as archive:
28
+ assert set(archive.namelist()) == {"archive/data.warc", "indexes/index.cdxj", "pages/pages.jsonl", "datapackage.json", "datapackage-digest.json"}
29
+ manifest = json.loads(archive.read("datapackage.json"))
30
+ assert manifest["profile"] == "wacz"
31
+ assert len(manifest["resources"]) == 3
32
+
33
+
34
+ def test_custody_hash_chain_and_signed_manifest_detect_tampering(tmp_path: Path):
35
+ evidence = tmp_path / "capture.warc"
36
+ evidence.write_bytes(b"captured bytes")
37
+ ledger = CustodyLedger(tmp_path / "custody.jsonl")
38
+ ledger.append(action="acquired", actor="analyst@example.test", artifact_path=evidence)
39
+ ledger.append(action="exported", actor="analyst@example.test", artifact_path=evidence)
40
+ assert ledger.verify()
41
+ signer = ManifestSigner.generate()
42
+ manifest = write_manifest([evidence], tmp_path / "manifest.json", signer, custody_ledger=ledger.path)
43
+ assert verify_manifest(manifest)
44
+ assert verify_manifest(manifest, evidence_dir=tmp_path)
45
+ contents = json.loads(manifest.read_text(encoding="utf-8"))
46
+ contents["manifest"]["files"][0]["bytes"] = 1
47
+ manifest.write_text(json.dumps(contents), encoding="utf-8")
48
+ assert not verify_manifest(manifest)
reconkit-0.1.0/PKG-INFO DELETED
@@ -1,108 +0,0 @@
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.
reconkit-0.1.0/README.md DELETED
@@ -1,80 +0,0 @@
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.
@@ -1,81 +0,0 @@
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")
File without changes
File without changes
File without changes
File without changes