pentr-verify 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pentr_verify-0.1.0/.github/workflows/release.yml +39 -0
- pentr_verify-0.1.0/.gitignore +9 -0
- pentr_verify-0.1.0/LICENSE +21 -0
- pentr_verify-0.1.0/PKG-INFO +114 -0
- pentr_verify-0.1.0/README.md +99 -0
- pentr_verify-0.1.0/pentr_verify/__init__.py +3 -0
- pentr_verify-0.1.0/pentr_verify/cli.py +130 -0
- pentr_verify-0.1.0/pentr_verify/hash_chain.py +56 -0
- pentr_verify-0.1.0/pentr_verify/locker.py +556 -0
- pentr_verify-0.1.0/pentr_verify/manifest.py +56 -0
- pentr_verify-0.1.0/pentr_verify/signing.py +132 -0
- pentr_verify-0.1.0/pyproject.toml +49 -0
- pentr_verify-0.1.0/tests/__init__.py +0 -0
- pentr_verify-0.1.0/tests/conftest.py +141 -0
- pentr_verify-0.1.0/tests/test_hash_chain.py +49 -0
- pentr_verify-0.1.0/tests/test_locker.py +532 -0
- pentr_verify-0.1.0/tests/test_manifest.py +53 -0
- pentr_verify-0.1.0/tests/test_signing.py +166 -0
- pentr_verify-0.1.0/tests/test_verify_cli.py +86 -0
- pentr_verify-0.1.0/uv.lock +269 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v6
|
|
13
|
+
- name: Check the tag matches the package version
|
|
14
|
+
run: |
|
|
15
|
+
version=$(uv version --short)
|
|
16
|
+
test "${GITHUB_REF_NAME}" = "v${version}" || {
|
|
17
|
+
echo "release tag ${GITHUB_REF_NAME} does not match pyproject version ${version}"; exit 1; }
|
|
18
|
+
- run: uv run ruff check .
|
|
19
|
+
- run: uv run pytest -q
|
|
20
|
+
- run: uv build
|
|
21
|
+
- uses: actions/upload-artifact@v4
|
|
22
|
+
with:
|
|
23
|
+
name: dist
|
|
24
|
+
path: dist/
|
|
25
|
+
|
|
26
|
+
publish:
|
|
27
|
+
needs: build
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
environment:
|
|
30
|
+
name: pypi
|
|
31
|
+
url: https://pypi.org/p/pentr-verify
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/download-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: dist
|
|
38
|
+
path: dist/
|
|
39
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PentrAI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pentr-verify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline verifier for Pentr evidence lockers
|
|
5
|
+
Project-URL: Homepage, https://github.com/PentrAI/pentr-verify
|
|
6
|
+
Author: PentrAI
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Security :: Cryptography
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: cryptography>=42
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# pentr-verify
|
|
17
|
+
|
|
18
|
+
Offline verifier for [Pentr](https://github.com/PentrAI) evidence lockers.
|
|
19
|
+
|
|
20
|
+
When Pentr finishes a penetration test, it seals all the evidence it captured
|
|
21
|
+
(command logs, HTTP requests and responses, screenshots) into a hash chain and
|
|
22
|
+
signs the result with the organization's Ed25519 key. The exported locker is a
|
|
23
|
+
directory or `.zip` that anyone can check with this tool. You don't need
|
|
24
|
+
network access, a database or a Pentr account.
|
|
25
|
+
|
|
26
|
+
The platform seals lockers with this same code, so a PASS here uses exactly the
|
|
27
|
+
checks the platform's own seal is built on.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install git+https://github.com/PentrAI/pentr-verify
|
|
33
|
+
# or
|
|
34
|
+
uv tool install git+https://github.com/PentrAI/pentr-verify
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The only runtime dependency is `cryptography`. Python 3.12 or newer is required.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pentr-verify locker.zip # verify against the key inside the locker
|
|
43
|
+
pentr-verify locker.zip --key org-key.pem # pin the signer's public key
|
|
44
|
+
pentr-verify locker/ --json # machine-readable report
|
|
45
|
+
pentr-verify locker.zip --quiet # skip the per-artifact listing
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--key` accepts a PEM public key, 64 hex characters, or 32 raw bytes.
|
|
49
|
+
|
|
50
|
+
| Exit code | Meaning |
|
|
51
|
+
|-----------|---------|
|
|
52
|
+
| `0` | PASS: the locker verified |
|
|
53
|
+
| `1` | FAIL: the locker did not verify (tampered, incomplete, or signed by a different key) |
|
|
54
|
+
| `2` | Usage error: the input is not a readable locker, or `--key` is unreadable |
|
|
55
|
+
|
|
56
|
+
### Pinned and unpinned verification
|
|
57
|
+
|
|
58
|
+
Without `--key`, the locker is checked against the public key it carries. That
|
|
59
|
+
proves the locker is internally consistent and has not been modified since it
|
|
60
|
+
was signed. It does **not** prove who signed it. To prove provenance, get the
|
|
61
|
+
organization's public key through a channel you trust and pass it with `--key`.
|
|
62
|
+
|
|
63
|
+
## Locker layout
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
manifest.json # canonical run manifest: every entry, its hash and chain hash
|
|
67
|
+
signature_block.json # the signed bytes: chain head, manifest hash, key id, signed_at
|
|
68
|
+
signature.bin # detached Ed25519 signature over signature_block.json
|
|
69
|
+
public_key.pem # the signer's public key
|
|
70
|
+
seal.json # run id, counts and claims, cross-checked against the above
|
|
71
|
+
README.txt # human instructions
|
|
72
|
+
artifacts/<sha256> # one file per manifest entry, named by its SHA-256
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## What is checked
|
|
76
|
+
|
|
77
|
+
1. `seal.json` names a known locker format and algorithms.
|
|
78
|
+
2. The signature block binds the exact bytes of `manifest.json`.
|
|
79
|
+
3. The key id is the SHA-256 of the public key, and matches the pinned key when `--key` is given.
|
|
80
|
+
4. The Ed25519 signature over the signature block verifies.
|
|
81
|
+
5. The chain's genesis matches the run id.
|
|
82
|
+
6. Every entry hash and the chain head recompute from the entry metadata.
|
|
83
|
+
7. Every artifact exists, has the listed size, and hashes to its name.
|
|
84
|
+
8. No artifact is present that the manifest does not list.
|
|
85
|
+
9. Every claim in `seal.json` matches the signed manifest.
|
|
86
|
+
|
|
87
|
+
Every failure is reported with a stable code (`signature_invalid`,
|
|
88
|
+
`chain_head_mismatch`, `blob_hash_mismatch`, `unlisted_blob`, ...). The full set
|
|
89
|
+
is `FAILURE_CODES` in `pentr_verify/locker.py`.
|
|
90
|
+
|
|
91
|
+
## Limits
|
|
92
|
+
|
|
93
|
+
- **Key custody.** The Pentr platform holds each organization's private key,
|
|
94
|
+
encrypted at rest.
|
|
95
|
+
- **Timestamp.** `signed_at` is recorded by the platform's own clock.
|
|
96
|
+
- **Revocation check.** A locker signed with a key that was later retired
|
|
97
|
+
still verifies.
|
|
98
|
+
|
|
99
|
+
## Releasing
|
|
100
|
+
|
|
101
|
+
Publishing a GitHub release builds the package, runs the tests and uploads it
|
|
102
|
+
to PyPI (`.github/workflows/release.yml`, PyPI trusted publishing). The release
|
|
103
|
+
tag must match the version in `pyproject.toml`, e.g. `v0.1.0`.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv run pytest
|
|
109
|
+
uv run ruff check .
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# pentr-verify
|
|
2
|
+
|
|
3
|
+
Offline verifier for [Pentr](https://github.com/PentrAI) evidence lockers.
|
|
4
|
+
|
|
5
|
+
When Pentr finishes a penetration test, it seals all the evidence it captured
|
|
6
|
+
(command logs, HTTP requests and responses, screenshots) into a hash chain and
|
|
7
|
+
signs the result with the organization's Ed25519 key. The exported locker is a
|
|
8
|
+
directory or `.zip` that anyone can check with this tool. You don't need
|
|
9
|
+
network access, a database or a Pentr account.
|
|
10
|
+
|
|
11
|
+
The platform seals lockers with this same code, so a PASS here uses exactly the
|
|
12
|
+
checks the platform's own seal is built on.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pipx install git+https://github.com/PentrAI/pentr-verify
|
|
18
|
+
# or
|
|
19
|
+
uv tool install git+https://github.com/PentrAI/pentr-verify
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The only runtime dependency is `cryptography`. Python 3.12 or newer is required.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pentr-verify locker.zip # verify against the key inside the locker
|
|
28
|
+
pentr-verify locker.zip --key org-key.pem # pin the signer's public key
|
|
29
|
+
pentr-verify locker/ --json # machine-readable report
|
|
30
|
+
pentr-verify locker.zip --quiet # skip the per-artifact listing
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`--key` accepts a PEM public key, 64 hex characters, or 32 raw bytes.
|
|
34
|
+
|
|
35
|
+
| Exit code | Meaning |
|
|
36
|
+
|-----------|---------|
|
|
37
|
+
| `0` | PASS: the locker verified |
|
|
38
|
+
| `1` | FAIL: the locker did not verify (tampered, incomplete, or signed by a different key) |
|
|
39
|
+
| `2` | Usage error: the input is not a readable locker, or `--key` is unreadable |
|
|
40
|
+
|
|
41
|
+
### Pinned and unpinned verification
|
|
42
|
+
|
|
43
|
+
Without `--key`, the locker is checked against the public key it carries. That
|
|
44
|
+
proves the locker is internally consistent and has not been modified since it
|
|
45
|
+
was signed. It does **not** prove who signed it. To prove provenance, get the
|
|
46
|
+
organization's public key through a channel you trust and pass it with `--key`.
|
|
47
|
+
|
|
48
|
+
## Locker layout
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
manifest.json # canonical run manifest: every entry, its hash and chain hash
|
|
52
|
+
signature_block.json # the signed bytes: chain head, manifest hash, key id, signed_at
|
|
53
|
+
signature.bin # detached Ed25519 signature over signature_block.json
|
|
54
|
+
public_key.pem # the signer's public key
|
|
55
|
+
seal.json # run id, counts and claims, cross-checked against the above
|
|
56
|
+
README.txt # human instructions
|
|
57
|
+
artifacts/<sha256> # one file per manifest entry, named by its SHA-256
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What is checked
|
|
61
|
+
|
|
62
|
+
1. `seal.json` names a known locker format and algorithms.
|
|
63
|
+
2. The signature block binds the exact bytes of `manifest.json`.
|
|
64
|
+
3. The key id is the SHA-256 of the public key, and matches the pinned key when `--key` is given.
|
|
65
|
+
4. The Ed25519 signature over the signature block verifies.
|
|
66
|
+
5. The chain's genesis matches the run id.
|
|
67
|
+
6. Every entry hash and the chain head recompute from the entry metadata.
|
|
68
|
+
7. Every artifact exists, has the listed size, and hashes to its name.
|
|
69
|
+
8. No artifact is present that the manifest does not list.
|
|
70
|
+
9. Every claim in `seal.json` matches the signed manifest.
|
|
71
|
+
|
|
72
|
+
Every failure is reported with a stable code (`signature_invalid`,
|
|
73
|
+
`chain_head_mismatch`, `blob_hash_mismatch`, `unlisted_blob`, ...). The full set
|
|
74
|
+
is `FAILURE_CODES` in `pentr_verify/locker.py`.
|
|
75
|
+
|
|
76
|
+
## Limits
|
|
77
|
+
|
|
78
|
+
- **Key custody.** The Pentr platform holds each organization's private key,
|
|
79
|
+
encrypted at rest.
|
|
80
|
+
- **Timestamp.** `signed_at` is recorded by the platform's own clock.
|
|
81
|
+
- **Revocation check.** A locker signed with a key that was later retired
|
|
82
|
+
still verifies.
|
|
83
|
+
|
|
84
|
+
## Releasing
|
|
85
|
+
|
|
86
|
+
Publishing a GitHub release builds the package, runs the tests and uploads it
|
|
87
|
+
to PyPI (`.github/workflows/release.yml`, PyPI trusted publishing). The release
|
|
88
|
+
tag must match the version in `pyproject.toml`, e.g. `v0.1.0`.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv run pytest
|
|
94
|
+
uv run ruff check .
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""`pentr-verify` — verify an exported Pentr evidence locker, offline.
|
|
2
|
+
|
|
3
|
+
This command needs no network, no database, and no Pentr credentials. It is the
|
|
4
|
+
whole point of the evidence locker: an auditor who has never heard of us can
|
|
5
|
+
check the evidence with the same code that sealed it.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pentr_verify.locker import LockerFormatError, VerifyReport, open_locker, verify_locker
|
|
15
|
+
|
|
16
|
+
EXIT_PASS = 0
|
|
17
|
+
EXIT_FAIL = 1
|
|
18
|
+
EXIT_USAGE = 2
|
|
19
|
+
|
|
20
|
+
_UNPINNED_WARNING = """
|
|
21
|
+
UNPINNED: this locker was verified against the public key inside it. That proves
|
|
22
|
+
the locker is internally consistent and unmodified since it was signed — it does
|
|
23
|
+
NOT prove who signed it. To prove provenance, obtain the organization's public
|
|
24
|
+
key through a channel you trust and re-run with --key <file>.
|
|
25
|
+
""".strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_public_key_file(path: str) -> bytes:
|
|
29
|
+
"""Read a public key as PEM, 64 hex chars, or 32 raw bytes -> raw 32 bytes."""
|
|
30
|
+
raw = Path(path).read_bytes()
|
|
31
|
+
text = raw.strip()
|
|
32
|
+
if text.startswith(b"-----BEGIN"):
|
|
33
|
+
from cryptography.hazmat.primitives import serialization
|
|
34
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
35
|
+
|
|
36
|
+
key = serialization.load_pem_public_key(text)
|
|
37
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
38
|
+
raise ValueError("only Ed25519 public keys are supported")
|
|
39
|
+
return key.public_bytes(
|
|
40
|
+
encoding=serialization.Encoding.Raw,
|
|
41
|
+
format=serialization.PublicFormat.Raw,
|
|
42
|
+
)
|
|
43
|
+
if len(text) == 64:
|
|
44
|
+
try:
|
|
45
|
+
return bytes.fromhex(text.decode("ascii"))
|
|
46
|
+
except (ValueError, UnicodeDecodeError):
|
|
47
|
+
pass
|
|
48
|
+
if len(raw) == 32:
|
|
49
|
+
return raw
|
|
50
|
+
raise ValueError(f"{path} is not an Ed25519 public key (PEM, 64 hex, or 32 bytes)")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render_text(report: VerifyReport, quiet: bool = False) -> str:
|
|
54
|
+
lines: list[str] = []
|
|
55
|
+
if not quiet:
|
|
56
|
+
for e in report.entries:
|
|
57
|
+
status = "OK " if e.ok else "BAD "
|
|
58
|
+
name = e.filename or "-"
|
|
59
|
+
lines.append(f"{status} {e.seq:>4} {e.kind:<16} {e.sha256[:12]} {name}")
|
|
60
|
+
if report.entries:
|
|
61
|
+
lines.append("")
|
|
62
|
+
|
|
63
|
+
if report.ok:
|
|
64
|
+
lines.append("PASS — evidence locker verified")
|
|
65
|
+
else:
|
|
66
|
+
lines.append("FAIL — evidence locker did NOT verify")
|
|
67
|
+
for f in report.failures:
|
|
68
|
+
subject = f" [{f.subject}]" if f.subject else ""
|
|
69
|
+
lines.append(f" FAIL {f.code}{subject}: {f.message}")
|
|
70
|
+
|
|
71
|
+
lines.append("")
|
|
72
|
+
lines.append(
|
|
73
|
+
f" run {report.run_id}\n"
|
|
74
|
+
f" artifacts {report.entry_count}\n"
|
|
75
|
+
f" chain head {report.chain_head}\n"
|
|
76
|
+
f" signed by {report.key_id}\n"
|
|
77
|
+
f" signed at {report.signed_at}"
|
|
78
|
+
)
|
|
79
|
+
if report.ok and not report.pinned:
|
|
80
|
+
lines.append("")
|
|
81
|
+
lines.append(_UNPINNED_WARNING)
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run(argv: list[str] | None = None) -> int:
|
|
86
|
+
parser = argparse.ArgumentParser(
|
|
87
|
+
prog="pentr-verify",
|
|
88
|
+
description="Verify an exported Pentr evidence locker offline.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument("locker", help="Path to a locker directory or .zip")
|
|
91
|
+
parser.add_argument("--key", default=None,
|
|
92
|
+
help="Public key to pin (PEM, hex, or raw). Without it, "
|
|
93
|
+
"verification proves consistency but not provenance.")
|
|
94
|
+
parser.add_argument("--json", action="store_true", dest="as_json",
|
|
95
|
+
help="Emit the verification report as JSON")
|
|
96
|
+
parser.add_argument("--quiet", action="store_true",
|
|
97
|
+
help="Suppress the per-artifact listing")
|
|
98
|
+
args = parser.parse_args(argv)
|
|
99
|
+
|
|
100
|
+
pinned: bytes | None = None
|
|
101
|
+
if args.key is not None:
|
|
102
|
+
try:
|
|
103
|
+
pinned = load_public_key_file(args.key)
|
|
104
|
+
except (OSError, ValueError) as exc:
|
|
105
|
+
print(f"pentr-verify: cannot read --key: {exc}", file=sys.stderr)
|
|
106
|
+
return EXIT_USAGE
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
locker = open_locker(args.locker)
|
|
110
|
+
report = verify_locker(locker, pinned_public_key=pinned)
|
|
111
|
+
except LockerFormatError as exc:
|
|
112
|
+
# Not a verification failure: the input is not a locker at all. Reporting
|
|
113
|
+
# this as exit 1 would tell an auditor their evidence was tampered with
|
|
114
|
+
# when they actually just mistyped a path.
|
|
115
|
+
print(f"pentr-verify: {exc}", file=sys.stderr)
|
|
116
|
+
return EXIT_USAGE
|
|
117
|
+
|
|
118
|
+
if args.as_json:
|
|
119
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
120
|
+
else:
|
|
121
|
+
print(render_text(report, quiet=args.quiet))
|
|
122
|
+
return EXIT_PASS if report.ok else EXIT_FAIL
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main() -> None:
|
|
126
|
+
sys.exit(run())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
GENESIS_PREFIX = "pentr-evidence-locker-v1|"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class EntryInput:
|
|
12
|
+
seq: int
|
|
13
|
+
kind: str
|
|
14
|
+
phase: str
|
|
15
|
+
size: int
|
|
16
|
+
sha256: str
|
|
17
|
+
control_ids: list[str]
|
|
18
|
+
finding_ids: list[str]
|
|
19
|
+
filename: str | None
|
|
20
|
+
created_at: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _canon(obj) -> str:
|
|
24
|
+
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def genesis(run_id: str) -> str:
|
|
28
|
+
return hashlib.sha256((GENESIS_PREFIX + run_id).encode("utf-8")).hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def entry_metadata(entry: EntryInput) -> str:
|
|
32
|
+
return _canon({
|
|
33
|
+
"seq": entry.seq,
|
|
34
|
+
"kind": entry.kind,
|
|
35
|
+
"phase": entry.phase,
|
|
36
|
+
"size": entry.size,
|
|
37
|
+
"control_ids": sorted(entry.control_ids),
|
|
38
|
+
"finding_ids": sorted(entry.finding_ids),
|
|
39
|
+
"filename": entry.filename,
|
|
40
|
+
"created_at": entry.created_at,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def entry_hash(prev_hex: str, artifact_sha256: str, metadata_json: str) -> str:
|
|
45
|
+
payload = prev_hex + artifact_sha256 + metadata_json
|
|
46
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compute_chain(run_id: str, entries: list[EntryInput]) -> tuple[list[str], str]:
|
|
50
|
+
prev = genesis(run_id)
|
|
51
|
+
hashes: list[str] = []
|
|
52
|
+
for entry in entries:
|
|
53
|
+
prev = entry_hash(prev, entry.sha256, entry_metadata(entry))
|
|
54
|
+
hashes.append(prev)
|
|
55
|
+
head = hashes[-1] if hashes else genesis(run_id)
|
|
56
|
+
return hashes, head
|