blastgate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blastgate/__init__.py +12 -0
- blastgate/allowlists/cargo-resolve.yaml +30 -0
- blastgate/allowlists/cargo.yaml +40 -0
- blastgate/allowlists/npm-resolve.yaml +30 -0
- blastgate/allowlists/npm.yaml +40 -0
- blastgate/allowlists/pypi-resolve.yaml +30 -0
- blastgate/allowlists/pypi.yaml +38 -0
- blastgate/audit.py +377 -0
- blastgate/cli.py +628 -0
- blastgate/credentials.py +149 -0
- blastgate/docker/cargo-git.Dockerfile +13 -0
- blastgate/docker/npm-git.Dockerfile +19 -0
- blastgate/docker/proxy.Dockerfile +19 -0
- blastgate/docker/pypi-git.Dockerfile +13 -0
- blastgate/policy.py +450 -0
- blastgate/proxy.py +500 -0
- blastgate/resolve.py +541 -0
- blastgate/runner.py +1314 -0
- blastgate-0.1.0.dist-info/METADATA +555 -0
- blastgate-0.1.0.dist-info/RECORD +24 -0
- blastgate-0.1.0.dist-info/WHEEL +5 -0
- blastgate-0.1.0.dist-info/entry_points.txt +2 -0
- blastgate-0.1.0.dist-info/licenses/LICENSE +202 -0
- blastgate-0.1.0.dist-info/top_level.txt +1 -0
blastgate/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Blastgate: Egress control and isolation for package managers."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BlastgateError(Exception):
|
|
7
|
+
"""Base for every error blastgate raises deliberately.
|
|
8
|
+
|
|
9
|
+
Exists so modules can define their own errors without importing each
|
|
10
|
+
other's. Every one of these means blastgate stopped on purpose; nothing here
|
|
11
|
+
is raised to signal that an install may proceed unprotected.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Resolve-phase policy for cargo.
|
|
2
|
+
#
|
|
3
|
+
# This allowlist is used by ONE thing: the phase that fetches declared git
|
|
4
|
+
# dependencies before an install runs. No package lifecycle scripts execute
|
|
5
|
+
# under this policy and no credentials are present, so reaching a code forge
|
|
6
|
+
# here is a much smaller grant than reaching one during an install.
|
|
7
|
+
#
|
|
8
|
+
# The registry is deliberately absent. Resolution fetches git refs and nothing
|
|
9
|
+
# else; if something under this policy tries to reach crates.io and its CDN,
|
|
10
|
+
# that is not resolution and it is denied.
|
|
11
|
+
#
|
|
12
|
+
# The install phase uses cargo.yaml, where these same forge hosts are NOT
|
|
13
|
+
# reachable. That separation is the point: the forge is available while no
|
|
14
|
+
# untrusted code runs, and unavailable once it does.
|
|
15
|
+
ecosystem: cargo-resolve
|
|
16
|
+
version: 1
|
|
17
|
+
|
|
18
|
+
exact:
|
|
19
|
+
- host: github.com
|
|
20
|
+
reason: "git clone of a declared dependency"
|
|
21
|
+
- host: gitlab.com
|
|
22
|
+
reason: "git clone of a declared dependency"
|
|
23
|
+
- host: bitbucket.org
|
|
24
|
+
reason: "git clone of a declared dependency"
|
|
25
|
+
- host: codeload.github.com
|
|
26
|
+
reason: "GitHub archive download for a declared dependency"
|
|
27
|
+
|
|
28
|
+
wildcard: []
|
|
29
|
+
|
|
30
|
+
conditional: []
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
ecosystem: cargo
|
|
2
|
+
version: 1
|
|
3
|
+
|
|
4
|
+
exact:
|
|
5
|
+
- host: crates.io
|
|
6
|
+
reason: "Primary Rust crate registry metadata index"
|
|
7
|
+
- host: index.crates.io
|
|
8
|
+
reason: "Sparse index service for crates.io"
|
|
9
|
+
- host: static.crates.io
|
|
10
|
+
reason: "Static download CDN for crate archives"
|
|
11
|
+
|
|
12
|
+
wildcard:
|
|
13
|
+
- pattern: "*.crates.io"
|
|
14
|
+
reason: "Crates.io registry and CDN endpoints"
|
|
15
|
+
|
|
16
|
+
# Conditional hosts are NOT reachable during an install.
|
|
17
|
+
#
|
|
18
|
+
# The git-dependencies condition no longer opens these hosts to the install
|
|
19
|
+
# phase. It permits the separate resolve phase to run, which fetches declared
|
|
20
|
+
# git dependencies under cargo-resolve.yaml while no package code is executing.
|
|
21
|
+
# By the time an install runs, these entries are dead: the payload has no route
|
|
22
|
+
# to a forge and no rule that would allow one.
|
|
23
|
+
#
|
|
24
|
+
# They stay listed because policy still has to name them to deny them.
|
|
25
|
+
conditional:
|
|
26
|
+
- host: github.com
|
|
27
|
+
condition: git-dependencies
|
|
28
|
+
reason: "Direct git repository dependencies and release assets"
|
|
29
|
+
- host: raw.githubusercontent.com
|
|
30
|
+
condition: git-dependencies
|
|
31
|
+
reason: "Raw repository file dependencies"
|
|
32
|
+
- host: codeload.github.com
|
|
33
|
+
condition: git-dependencies
|
|
34
|
+
reason: "GitHub repository archive downloads"
|
|
35
|
+
- host: gitlab.com
|
|
36
|
+
condition: git-dependencies
|
|
37
|
+
reason: "GitLab repository dependencies"
|
|
38
|
+
- host: bitbucket.org
|
|
39
|
+
condition: git-dependencies
|
|
40
|
+
reason: "Bitbucket repository dependencies"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Resolve-phase policy for npm.
|
|
2
|
+
#
|
|
3
|
+
# This allowlist is used by ONE thing: the phase that fetches declared git
|
|
4
|
+
# dependencies before an install runs. No package lifecycle scripts execute
|
|
5
|
+
# under this policy and no credentials are present, so reaching a code forge
|
|
6
|
+
# here is a much smaller grant than reaching one during an install.
|
|
7
|
+
#
|
|
8
|
+
# The registry is deliberately absent. Resolution fetches git refs and nothing
|
|
9
|
+
# else; if something under this policy tries to reach registry.npmjs.org, that
|
|
10
|
+
# is not resolution and it is denied.
|
|
11
|
+
#
|
|
12
|
+
# The install phase uses npm.yaml, where these same forge hosts are NOT
|
|
13
|
+
# reachable. That separation is the point: the forge is available while no
|
|
14
|
+
# untrusted code runs, and unavailable once it does.
|
|
15
|
+
ecosystem: npm-resolve
|
|
16
|
+
version: 1
|
|
17
|
+
|
|
18
|
+
exact:
|
|
19
|
+
- host: github.com
|
|
20
|
+
reason: "git clone of a declared dependency"
|
|
21
|
+
- host: gitlab.com
|
|
22
|
+
reason: "git clone of a declared dependency"
|
|
23
|
+
- host: bitbucket.org
|
|
24
|
+
reason: "git clone of a declared dependency"
|
|
25
|
+
- host: codeload.github.com
|
|
26
|
+
reason: "GitHub archive download for a declared dependency"
|
|
27
|
+
|
|
28
|
+
wildcard: []
|
|
29
|
+
|
|
30
|
+
conditional: []
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
ecosystem: npm
|
|
2
|
+
version: 1
|
|
3
|
+
|
|
4
|
+
exact:
|
|
5
|
+
- host: registry.npmjs.org
|
|
6
|
+
reason: "Primary package registry metadata and tarball host"
|
|
7
|
+
- host: registry.yarnpkg.com
|
|
8
|
+
reason: "Official Yarn registry mirror"
|
|
9
|
+
|
|
10
|
+
wildcard:
|
|
11
|
+
- pattern: "*.npmjs.org"
|
|
12
|
+
reason: "Registry CDN edge nodes and asset endpoints"
|
|
13
|
+
|
|
14
|
+
# Conditional hosts are NOT reachable during an install.
|
|
15
|
+
#
|
|
16
|
+
# The git-dependencies condition no longer opens these hosts to the install
|
|
17
|
+
# phase. It permits the separate resolve phase to run, which fetches declared
|
|
18
|
+
# git dependencies under npm-resolve.yaml while no package code is executing.
|
|
19
|
+
# By the time an install runs, these entries are dead: the payload has no route
|
|
20
|
+
# to a forge and no rule that would allow one.
|
|
21
|
+
#
|
|
22
|
+
# They stay listed because policy still has to name them to deny them, and
|
|
23
|
+
# because `blast check npm github.com --allow git-dependencies` should keep
|
|
24
|
+
# answering the question it has always answered: is this host in the allowlist.
|
|
25
|
+
conditional:
|
|
26
|
+
- host: github.com
|
|
27
|
+
condition: git-dependencies
|
|
28
|
+
reason: "Resolve phase only: git clone of a declared dependency"
|
|
29
|
+
- host: raw.githubusercontent.com
|
|
30
|
+
condition: git-dependencies
|
|
31
|
+
reason: "Raw repository file dependencies"
|
|
32
|
+
- host: codeload.github.com
|
|
33
|
+
condition: git-dependencies
|
|
34
|
+
reason: "GitHub repository archive downloads"
|
|
35
|
+
- host: gitlab.com
|
|
36
|
+
condition: git-dependencies
|
|
37
|
+
reason: "GitLab repository dependencies"
|
|
38
|
+
- host: bitbucket.org
|
|
39
|
+
condition: git-dependencies
|
|
40
|
+
reason: "Bitbucket repository dependencies"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Resolve-phase policy for pypi.
|
|
2
|
+
#
|
|
3
|
+
# This allowlist is used by ONE thing: the phase that fetches declared git
|
|
4
|
+
# dependencies before an install runs. No package lifecycle scripts execute
|
|
5
|
+
# under this policy and no credentials are present, so reaching a code forge
|
|
6
|
+
# here is a much smaller grant than reaching one during an install.
|
|
7
|
+
#
|
|
8
|
+
# The registry is deliberately absent. Resolution fetches git refs and nothing
|
|
9
|
+
# else; if something under this policy tries to reach pypi.org and files.pythonhosted.org,
|
|
10
|
+
# that is not resolution and it is denied.
|
|
11
|
+
#
|
|
12
|
+
# The install phase uses pypi.yaml, where these same forge hosts are NOT
|
|
13
|
+
# reachable. That separation is the point: the forge is available while no
|
|
14
|
+
# untrusted code runs, and unavailable once it does.
|
|
15
|
+
ecosystem: pypi-resolve
|
|
16
|
+
version: 1
|
|
17
|
+
|
|
18
|
+
exact:
|
|
19
|
+
- host: github.com
|
|
20
|
+
reason: "git clone of a declared dependency"
|
|
21
|
+
- host: gitlab.com
|
|
22
|
+
reason: "git clone of a declared dependency"
|
|
23
|
+
- host: bitbucket.org
|
|
24
|
+
reason: "git clone of a declared dependency"
|
|
25
|
+
- host: codeload.github.com
|
|
26
|
+
reason: "GitHub archive download for a declared dependency"
|
|
27
|
+
|
|
28
|
+
wildcard: []
|
|
29
|
+
|
|
30
|
+
conditional: []
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
ecosystem: pypi
|
|
2
|
+
version: 1
|
|
3
|
+
|
|
4
|
+
exact:
|
|
5
|
+
- host: pypi.org
|
|
6
|
+
reason: "Primary Python Package Index metadata"
|
|
7
|
+
- host: files.pythonhosted.org
|
|
8
|
+
reason: "Python package wheels and source tarball distributions"
|
|
9
|
+
|
|
10
|
+
wildcard:
|
|
11
|
+
- pattern: "*.pythonhosted.org"
|
|
12
|
+
reason: "Python package distribution CDN endpoints"
|
|
13
|
+
|
|
14
|
+
# Conditional hosts are NOT reachable during an install.
|
|
15
|
+
#
|
|
16
|
+
# The git-dependencies condition no longer opens these hosts to the install
|
|
17
|
+
# phase. It permits the separate resolve phase to run, which fetches declared
|
|
18
|
+
# git dependencies under pypi-resolve.yaml while no package code is executing.
|
|
19
|
+
# By the time an install runs, these entries are dead: the payload has no route
|
|
20
|
+
# to a forge and no rule that would allow one.
|
|
21
|
+
#
|
|
22
|
+
# They stay listed because policy still has to name them to deny them.
|
|
23
|
+
conditional:
|
|
24
|
+
- host: github.com
|
|
25
|
+
condition: git-dependencies
|
|
26
|
+
reason: "Direct git repository dependencies and release assets"
|
|
27
|
+
- host: raw.githubusercontent.com
|
|
28
|
+
condition: git-dependencies
|
|
29
|
+
reason: "Raw repository file dependencies"
|
|
30
|
+
- host: codeload.github.com
|
|
31
|
+
condition: git-dependencies
|
|
32
|
+
reason: "GitHub repository archive downloads"
|
|
33
|
+
- host: gitlab.com
|
|
34
|
+
condition: git-dependencies
|
|
35
|
+
reason: "GitLab repository dependencies"
|
|
36
|
+
- host: bitbucket.org
|
|
37
|
+
condition: git-dependencies
|
|
38
|
+
reason: "Bitbucket repository dependencies"
|
blastgate/audit.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Append-only, hash-chained log of egress decisions.
|
|
2
|
+
|
|
3
|
+
Each entry carries the hash of the entry before it, so altering or removing an
|
|
4
|
+
entry breaks every hash after it. This makes edits detectable. It does not make
|
|
5
|
+
them impossible, and the difference matters — see the limits below and
|
|
6
|
+
docs/threat-model.md section 8.2.
|
|
7
|
+
|
|
8
|
+
What the chain detects:
|
|
9
|
+
- a modified entry
|
|
10
|
+
- a removed entry from anywhere but the end
|
|
11
|
+
- reordered entries
|
|
12
|
+
- a forged entry spliced into the middle
|
|
13
|
+
|
|
14
|
+
What the chain does not detect on its own:
|
|
15
|
+
- truncation of the most recent entries. Removing the tail leaves a shorter
|
|
16
|
+
chain that still verifies.
|
|
17
|
+
- wholesale replacement of the log with a valid chain the attacker built.
|
|
18
|
+
The chain proves internal consistency, not provenance.
|
|
19
|
+
|
|
20
|
+
Truncation is closed by the anchor store below. After each run the host-side
|
|
21
|
+
runner - a different process from the proxy that writes the log, with no shared
|
|
22
|
+
mount - records the head hash and entry count. A truncated log no longer matches
|
|
23
|
+
its anchor. Anchors are themselves chained, so dropping a whole run is as
|
|
24
|
+
visible as dropping an entry.
|
|
25
|
+
|
|
26
|
+
Wholesale replacement remains open and remains disclosed. Anchoring raises the
|
|
27
|
+
bar from "write one file" to "write two files consistently in two locations",
|
|
28
|
+
which is a real improvement and is not provenance. An attacker with write access
|
|
29
|
+
to both stores can forge both. Genuine provenance needs an anchor this machine
|
|
30
|
+
cannot alter, which blastgate does not have. See docs/threat-model.md section 8.2.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from dataclasses import asdict, dataclass
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Iterator, List, Optional, Sequence
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
GENESIS_HASH = "0" * 64
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuditError(Exception):
|
|
45
|
+
"""Base error for audit log failures."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TamperError(AuditError):
|
|
49
|
+
"""Raised when the chain does not verify.
|
|
50
|
+
|
|
51
|
+
Carries the sequence number of the first entry that failed, which is the
|
|
52
|
+
entry at or before the alteration.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, seq: Optional[int] = None) -> None:
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.seq = seq
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class AuditEntry:
|
|
62
|
+
"""One egress decision.
|
|
63
|
+
|
|
64
|
+
Records the destination hostname and the rule that decided it. It does not
|
|
65
|
+
record request contents, because blastgate does not intercept TLS and has
|
|
66
|
+
none to record.
|
|
67
|
+
"""
|
|
68
|
+
seq: int
|
|
69
|
+
timestamp: str
|
|
70
|
+
ecosystem: str
|
|
71
|
+
host: str
|
|
72
|
+
allowed: bool
|
|
73
|
+
rule: Optional[str]
|
|
74
|
+
reason: str
|
|
75
|
+
prev_hash: str
|
|
76
|
+
entry_hash: str
|
|
77
|
+
|
|
78
|
+
def payload(self) -> dict:
|
|
79
|
+
"""The fields the hash covers. Everything except the hash itself."""
|
|
80
|
+
return {
|
|
81
|
+
"seq": self.seq,
|
|
82
|
+
"timestamp": self.timestamp,
|
|
83
|
+
"ecosystem": self.ecosystem,
|
|
84
|
+
"host": self.host,
|
|
85
|
+
"allowed": self.allowed,
|
|
86
|
+
"rule": self.rule,
|
|
87
|
+
"reason": self.reason,
|
|
88
|
+
"prev_hash": self.prev_hash,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compute_hash(payload: dict) -> str:
|
|
93
|
+
"""Hash an entry payload.
|
|
94
|
+
|
|
95
|
+
Serialisation is canonical - sorted keys, no insignificant whitespace - so
|
|
96
|
+
the same logical entry always produces the same hash.
|
|
97
|
+
"""
|
|
98
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
99
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def format_entry_count(count: int) -> str:
|
|
103
|
+
"""'1 entry', '2 entries'. Audit output is read when something is wrong;
|
|
104
|
+
it should not also read as sloppy."""
|
|
105
|
+
return f"{count} entry" if count == 1 else f"{count} entries"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _now() -> str:
|
|
109
|
+
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AuditLog:
|
|
113
|
+
"""A hash-chained decision log backed by a JSON Lines file."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, path: Path) -> None:
|
|
116
|
+
self.path = Path(path)
|
|
117
|
+
|
|
118
|
+
def append(
|
|
119
|
+
self,
|
|
120
|
+
ecosystem: str,
|
|
121
|
+
host: str,
|
|
122
|
+
allowed: bool,
|
|
123
|
+
rule: Optional[str],
|
|
124
|
+
reason: str,
|
|
125
|
+
timestamp: Optional[str] = None,
|
|
126
|
+
) -> AuditEntry:
|
|
127
|
+
"""Append a decision and return the entry written.
|
|
128
|
+
|
|
129
|
+
The chain is extended from the current tail, which is read and verified
|
|
130
|
+
first: appending to a log that does not verify would launder a tampered
|
|
131
|
+
chain into a valid-looking one.
|
|
132
|
+
"""
|
|
133
|
+
entries = self.read_all()
|
|
134
|
+
if entries:
|
|
135
|
+
self.verify(entries)
|
|
136
|
+
prev = entries[-1]
|
|
137
|
+
seq = prev.seq + 1
|
|
138
|
+
prev_hash = prev.entry_hash
|
|
139
|
+
else:
|
|
140
|
+
seq = 0
|
|
141
|
+
prev_hash = GENESIS_HASH
|
|
142
|
+
|
|
143
|
+
payload = {
|
|
144
|
+
"seq": seq,
|
|
145
|
+
"timestamp": timestamp or _now(),
|
|
146
|
+
"ecosystem": ecosystem,
|
|
147
|
+
"host": host,
|
|
148
|
+
"allowed": allowed,
|
|
149
|
+
"rule": rule,
|
|
150
|
+
"reason": reason,
|
|
151
|
+
"prev_hash": prev_hash,
|
|
152
|
+
}
|
|
153
|
+
entry = AuditEntry(**payload, entry_hash=compute_hash(payload))
|
|
154
|
+
|
|
155
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
157
|
+
f.write(json.dumps(asdict(entry), sort_keys=True, separators=(",", ":")) + "\n")
|
|
158
|
+
return entry
|
|
159
|
+
|
|
160
|
+
def read_all(self) -> List[AuditEntry]:
|
|
161
|
+
"""Read every entry. Does not verify - call verify() for that."""
|
|
162
|
+
if not self.path.is_file():
|
|
163
|
+
return []
|
|
164
|
+
entries: List[AuditEntry] = []
|
|
165
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
|
166
|
+
for lineno, line in enumerate(f, start=1):
|
|
167
|
+
line = line.strip()
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
data = json.loads(line)
|
|
172
|
+
except json.JSONDecodeError as e:
|
|
173
|
+
raise TamperError(f"line {lineno} is not valid JSON: {e}") from e
|
|
174
|
+
try:
|
|
175
|
+
entries.append(AuditEntry(**data))
|
|
176
|
+
except TypeError as e:
|
|
177
|
+
raise TamperError(f"line {lineno} has unexpected fields: {e}") from e
|
|
178
|
+
return entries
|
|
179
|
+
|
|
180
|
+
def verify(self, entries: Optional[Sequence[AuditEntry]] = None) -> bool:
|
|
181
|
+
"""Verify the chain. Returns True or raises TamperError.
|
|
182
|
+
|
|
183
|
+
Deliberately raises rather than returning False. A caller that ignores
|
|
184
|
+
a boolean is the failure mode this whole module exists to prevent.
|
|
185
|
+
"""
|
|
186
|
+
if entries is None:
|
|
187
|
+
entries = self.read_all()
|
|
188
|
+
if not entries:
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
expected_prev = GENESIS_HASH
|
|
192
|
+
for index, entry in enumerate(entries):
|
|
193
|
+
if entry.seq != index:
|
|
194
|
+
raise TamperError(
|
|
195
|
+
f"sequence break: entry at position {index} claims seq {entry.seq}",
|
|
196
|
+
seq=entry.seq,
|
|
197
|
+
)
|
|
198
|
+
if entry.prev_hash != expected_prev:
|
|
199
|
+
raise TamperError(
|
|
200
|
+
f"chain break at seq {entry.seq}: prev_hash does not match the "
|
|
201
|
+
f"preceding entry",
|
|
202
|
+
seq=entry.seq,
|
|
203
|
+
)
|
|
204
|
+
recomputed = compute_hash(entry.payload())
|
|
205
|
+
if recomputed != entry.entry_hash:
|
|
206
|
+
raise TamperError(
|
|
207
|
+
f"entry {entry.seq} was altered: recorded hash does not match "
|
|
208
|
+
f"its contents",
|
|
209
|
+
seq=entry.seq,
|
|
210
|
+
)
|
|
211
|
+
expected_prev = entry.entry_hash
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
def verify_against_anchor(
|
|
215
|
+
self,
|
|
216
|
+
anchor: "Anchor",
|
|
217
|
+
entries: Optional[Sequence[AuditEntry]] = None,
|
|
218
|
+
) -> bool:
|
|
219
|
+
"""Verify the chain and check it still matches its anchor.
|
|
220
|
+
|
|
221
|
+
A log may legitimately be longer than its last anchor: entries are
|
|
222
|
+
appended during a run and the anchor is written when that run ends. So
|
|
223
|
+
the check is not equality of length. It is that the entry the anchor
|
|
224
|
+
pointed at is still there, still in that position, and still has the
|
|
225
|
+
hash the anchor recorded.
|
|
226
|
+
|
|
227
|
+
Shorter than the anchor means entries were removed from the end, which
|
|
228
|
+
is the case a hash chain alone cannot see.
|
|
229
|
+
"""
|
|
230
|
+
if entries is None:
|
|
231
|
+
entries = self.read_all()
|
|
232
|
+
self.verify(entries)
|
|
233
|
+
|
|
234
|
+
if len(entries) < anchor.entry_count:
|
|
235
|
+
raise TamperError(
|
|
236
|
+
f"log is truncated: anchor recorded {format_entry_count(anchor.entry_count)}, "
|
|
237
|
+
f"found {len(entries)}. "
|
|
238
|
+
f"{format_entry_count(anchor.entry_count - len(entries))} "
|
|
239
|
+
f"removed from the end."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if anchor.entry_count == 0:
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
anchored = entries[anchor.entry_count - 1]
|
|
246
|
+
if anchored.entry_hash != anchor.head_hash:
|
|
247
|
+
raise TamperError(
|
|
248
|
+
f"log does not match its anchor at entry {anchor.entry_count - 1}: "
|
|
249
|
+
f"the log was replaced or rewritten since it was anchored.",
|
|
250
|
+
seq=anchored.seq,
|
|
251
|
+
)
|
|
252
|
+
return True
|
|
253
|
+
|
|
254
|
+
def __iter__(self) -> Iterator[AuditEntry]:
|
|
255
|
+
return iter(self.read_all())
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# --- Anchoring -------------------------------------------------------------
|
|
259
|
+
#
|
|
260
|
+
# The proxy container writes the audit log; it is the only process that can,
|
|
261
|
+
# because the audit directory is mounted there and nowhere else. The anchor is
|
|
262
|
+
# written by the host-side runner instead: a different process, with a
|
|
263
|
+
# different view of the filesystem, that the sandbox cannot reach at all.
|
|
264
|
+
#
|
|
265
|
+
# Two stores, two writers. Truncating the log now requires tampering with a
|
|
266
|
+
# file the log's own writer never sees.
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class AnchorError(AuditError):
|
|
270
|
+
"""The anchor store is unusable."""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True)
|
|
274
|
+
class Anchor:
|
|
275
|
+
"""A record of where an audit log stood at the end of one run."""
|
|
276
|
+
|
|
277
|
+
run_id: str
|
|
278
|
+
timestamp: str
|
|
279
|
+
audit_path: str
|
|
280
|
+
entry_count: int
|
|
281
|
+
head_hash: str
|
|
282
|
+
prev_hash: str
|
|
283
|
+
anchor_hash: str
|
|
284
|
+
|
|
285
|
+
def payload(self) -> dict:
|
|
286
|
+
return {
|
|
287
|
+
"run_id": self.run_id,
|
|
288
|
+
"timestamp": self.timestamp,
|
|
289
|
+
"audit_path": self.audit_path,
|
|
290
|
+
"entry_count": self.entry_count,
|
|
291
|
+
"head_hash": self.head_hash,
|
|
292
|
+
"prev_hash": self.prev_hash,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class AnchorStore:
|
|
297
|
+
"""A chained record of audit log heads, one entry per run."""
|
|
298
|
+
|
|
299
|
+
def __init__(self, path: Path) -> None:
|
|
300
|
+
self.path = Path(path)
|
|
301
|
+
|
|
302
|
+
def append(
|
|
303
|
+
self,
|
|
304
|
+
run_id: str,
|
|
305
|
+
audit_path: Path,
|
|
306
|
+
entries: Sequence[AuditEntry],
|
|
307
|
+
timestamp: Optional[str] = None,
|
|
308
|
+
) -> Anchor:
|
|
309
|
+
"""Record where an audit log stands now.
|
|
310
|
+
|
|
311
|
+
The existing anchor chain is verified before extension, for the same
|
|
312
|
+
reason the audit log verifies before appending: extending a broken
|
|
313
|
+
chain would launder it into a valid-looking one.
|
|
314
|
+
"""
|
|
315
|
+
existing = self.read_all()
|
|
316
|
+
if existing:
|
|
317
|
+
self.verify(existing)
|
|
318
|
+
prev_hash = existing[-1].anchor_hash
|
|
319
|
+
else:
|
|
320
|
+
prev_hash = GENESIS_HASH
|
|
321
|
+
|
|
322
|
+
payload = {
|
|
323
|
+
"run_id": run_id,
|
|
324
|
+
"timestamp": timestamp or _now(),
|
|
325
|
+
"audit_path": str(audit_path),
|
|
326
|
+
"entry_count": len(entries),
|
|
327
|
+
"head_hash": entries[-1].entry_hash if entries else GENESIS_HASH,
|
|
328
|
+
"prev_hash": prev_hash,
|
|
329
|
+
}
|
|
330
|
+
anchor = Anchor(**payload, anchor_hash=compute_hash(payload))
|
|
331
|
+
|
|
332
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
334
|
+
f.write(json.dumps(asdict(anchor), sort_keys=True, separators=(",", ":")) + "\n")
|
|
335
|
+
return anchor
|
|
336
|
+
|
|
337
|
+
def read_all(self) -> List[Anchor]:
|
|
338
|
+
if not self.path.is_file():
|
|
339
|
+
return []
|
|
340
|
+
anchors: List[Anchor] = []
|
|
341
|
+
with open(self.path, encoding="utf-8") as f:
|
|
342
|
+
for number, line in enumerate(f, start=1):
|
|
343
|
+
line = line.strip()
|
|
344
|
+
if not line:
|
|
345
|
+
continue
|
|
346
|
+
try:
|
|
347
|
+
anchors.append(Anchor(**json.loads(line)))
|
|
348
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
349
|
+
raise AnchorError(f"{self.path}:{number} is not a valid anchor: {e}")
|
|
350
|
+
return anchors
|
|
351
|
+
|
|
352
|
+
def verify(self, anchors: Optional[Sequence[Anchor]] = None) -> bool:
|
|
353
|
+
"""Verify the anchor chain itself. Raises TamperError."""
|
|
354
|
+
if anchors is None:
|
|
355
|
+
anchors = self.read_all()
|
|
356
|
+
if not anchors:
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
expected_prev = GENESIS_HASH
|
|
360
|
+
for index, anchor in enumerate(anchors):
|
|
361
|
+
if anchor.prev_hash != expected_prev:
|
|
362
|
+
raise TamperError(
|
|
363
|
+
f"anchor chain break at position {index}: a run was removed "
|
|
364
|
+
f"or replaced"
|
|
365
|
+
)
|
|
366
|
+
if compute_hash(anchor.payload()) != anchor.anchor_hash:
|
|
367
|
+
raise TamperError(f"anchor at position {index} was altered")
|
|
368
|
+
expected_prev = anchor.anchor_hash
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
def latest_for(self, audit_path: Path) -> Optional[Anchor]:
|
|
372
|
+
"""The most recent anchor for one log, or None."""
|
|
373
|
+
target = str(Path(audit_path))
|
|
374
|
+
for anchor in reversed(self.read_all()):
|
|
375
|
+
if anchor.audit_path == target:
|
|
376
|
+
return anchor
|
|
377
|
+
return None
|