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 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