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.
- {reconkit-0.1.0 → reconkit-0.2.0}/CHANGELOG.md +11 -0
- reconkit-0.2.0/PKG-INFO +127 -0
- reconkit-0.2.0/README.md +98 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/pyproject.toml +4 -3
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/__init__.py +1 -1
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/cli.py +29 -0
- reconkit-0.2.0/src/reconkit/evidence/__init__.py +16 -0
- reconkit-0.2.0/src/reconkit/evidence/archives.py +108 -0
- reconkit-0.2.0/src/reconkit/evidence/custody.py +53 -0
- reconkit-0.2.0/src/reconkit/evidence/iris.py +37 -0
- reconkit-0.2.0/src/reconkit/evidence/manifest.py +98 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/noir/public_api.py +6 -2
- reconkit-0.2.0/src/reconkit/scraping/fetch.py +112 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/scraping/robots.py +6 -7
- reconkit-0.2.0/tests/test_evidence_exports.py +48 -0
- reconkit-0.1.0/PKG-INFO +0 -108
- reconkit-0.1.0/README.md +0 -80
- reconkit-0.1.0/src/reconkit/scraping/fetch.py +0 -81
- {reconkit-0.1.0 → reconkit-0.2.0}/.gitignore +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/LICENSE +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/core/__init__.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/core/rate_limit.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/__init__.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/exif.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/hashing.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/identify.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/forensics/strings.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/noir/__init__.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/noir/errors.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/noir/primitives.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/osint/__init__.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/osint/dns.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/osint/ip.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/osint/whois.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/scraping/__init__.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/src/reconkit/scraping/extract.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_extract_links.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_hashing.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_noir_output_contract.py +0 -0
- {reconkit-0.1.0 → reconkit-0.2.0}/tests/test_noir_primitives.py +0 -0
- {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
|
reconkit-0.2.0/PKG-INFO
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
|
reconkit-0.2.0/README.md
ADDED
|
@@ -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.
|
|
8
|
-
description = "
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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=
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|