Inflate every receive-pack object under strict pack, size, and checksum bounds, resolve deltas against in-pack bases only, and scan the real decompressed payloads for runtime-token forms, private keys, SSH key material, and known provider token formats. Thin packs are rejected so no pushed content escapes the scan, and upstream Git exchanges now run under one absolute stream deadline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
8.3 KiB
Python
217 lines
8.3 KiB
Python
"""Quarantine content-scanning contracts for brokered Git pushes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import io
|
|
import zlib
|
|
|
|
import pytest
|
|
|
|
from testing.tests.test_hermes_scm_broker_support import (
|
|
_blob_pack,
|
|
_load,
|
|
_object_entry,
|
|
_pack_of,
|
|
_receive_command,
|
|
)
|
|
|
|
ZERO = b"0" * 40
|
|
COMMIT = b"1" * 40
|
|
TOKEN = "runtime-sentinel"
|
|
FORMS = (b"runtime-sentinel",)
|
|
|
|
|
|
def _request(pack: bytes, ref: bytes = b"refs/heads/hermes/scan") -> bytes:
|
|
return _receive_command(ZERO, COMMIT, ref, pack)
|
|
|
|
|
|
def test_clean_push_with_real_content_passes():
|
|
scan = _load("receive_pack_scan")
|
|
pack = _blob_pack(
|
|
b"# services/example/deployment.yaml\napiVersion: apps/v1\n",
|
|
b"tree 5b3a\nparent none\n",
|
|
)
|
|
scan.validate_receive_pack(_request(pack), TOKEN, FORMS)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
b"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaA==\n",
|
|
b"-----BEGIN RSA PRIVATE KEY-----\nMIIE\n",
|
|
b"key = ghp_" + b"a1" * 12,
|
|
b"aws_access_key_id = AKIAABCDEFGHIJKLMNOP",
|
|
b"jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJlLXNlZ21lbnQ",
|
|
b"hook https://hooks.slack.com/services/T000/B000/tokentokentokentoken",
|
|
b"AGE-SECRET-KEY-1QQPCXR7QQPCXR7QQPCXR7QQPCXR7",
|
|
b"PuTTY-User-Key-File-3: ssh-ed25519",
|
|
b"Authorization: Bearer abc123-tokenvalue",
|
|
b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF2c hermes@atlas",
|
|
b"gitea gta_" + b"z9" * 12,
|
|
],
|
|
)
|
|
def test_credential_shaped_object_content_is_rejected(payload):
|
|
scan = _load("receive_pack_scan")
|
|
with pytest.raises(scan.PolicyError, match="credential-shaped"):
|
|
scan.validate_receive_pack(_request(_blob_pack(payload)), TOKEN, FORMS)
|
|
|
|
|
|
def test_runtime_token_hidden_by_compression_is_still_caught():
|
|
scan = _load("receive_pack_scan")
|
|
pack = _blob_pack(b"config value: runtime-sentinel\n")
|
|
# The compressed wire bytes do not contain the token; only the
|
|
# decompressed payload does.
|
|
assert b"runtime-sentinel" not in pack
|
|
with pytest.raises(scan.PolicyError, match="runtime credential material"):
|
|
scan.validate_receive_pack(_request(pack), TOKEN, FORMS)
|
|
|
|
|
|
def _size_varint(value: int) -> bytes:
|
|
out = bytearray()
|
|
while True:
|
|
byte = value & 0x7F
|
|
value >>= 7
|
|
if value:
|
|
out.append(byte | 0x80)
|
|
else:
|
|
out.append(byte)
|
|
return bytes(out)
|
|
|
|
|
|
def _typed_header(type_code: int, size: int) -> bytes:
|
|
byte = (type_code << 4) | (size & 0x0F)
|
|
size >>= 4
|
|
header = bytearray()
|
|
while size:
|
|
header.append(byte | 0x80)
|
|
byte = size & 0x7F
|
|
size >>= 7
|
|
header.append(byte)
|
|
return bytes(header)
|
|
|
|
|
|
def test_secret_assembled_only_by_delta_resolution_is_caught():
|
|
scan = _load("receive_pack_scan")
|
|
base = b"docs: AKIAABCD"
|
|
completion = b"EFGHIJKLMNOP"
|
|
delta = (
|
|
_size_varint(len(base))
|
|
+ _size_varint(len(base) + len(completion))
|
|
+ bytes([0x80 | 0x10, len(base)])
|
|
+ bytes([len(completion)])
|
|
+ completion
|
|
)
|
|
base_sha = hashlib.sha1(b"blob %d\x00" % len(base) + base).digest()
|
|
delta_entry = _typed_header(7, len(delta)) + base_sha + zlib.compress(delta)
|
|
pack = _pack_of([_object_entry(3, base), delta_entry])
|
|
# Neither the base nor the delta insert alone matches; the resolved
|
|
# payload is the only place the credential exists.
|
|
with pytest.raises(scan.PolicyError, match="runtime credential|credential-shaped"):
|
|
scan.validate_receive_pack(_request(pack), TOKEN, FORMS)
|
|
|
|
|
|
def test_thin_pack_deltas_are_rejected_outright():
|
|
scan = _load("receive_pack_scan")
|
|
delta = _size_varint(4) + _size_varint(1) + bytes([1]) + b"x"
|
|
entry = _typed_header(7, len(delta)) + b"\xbb" * 20 + zlib.compress(delta)
|
|
with pytest.raises(scan.PolicyError, match="push full packs"):
|
|
scan.validate_receive_pack(_request(_pack_of([entry])), TOKEN, FORMS)
|
|
|
|
|
|
def test_trailing_bytes_after_the_pack_are_rejected():
|
|
scan = _load("receive_pack_scan")
|
|
body = _request(_pack_of([])) + b"extra"
|
|
with pytest.raises(scan.PolicyError, match="bytes after its pack"):
|
|
scan.validate_receive_pack(body, TOKEN, FORMS)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("line", "match"),
|
|
[
|
|
(b"shallow " + COMMIT, "ref command is invalid"),
|
|
(COMMIT + b" " + COMMIT + b" refs/heads/hermes/x", "feature-branch creation"),
|
|
(ZERO + b" " + ZERO + b" refs/heads/hermes/x", "feature-branch creation"),
|
|
(ZERO + b" " + COMMIT + b" refs/heads/main", "namespaced feature branches"),
|
|
(ZERO + b" " + COMMIT + b" refs/tags/v1", "namespaced feature branches"),
|
|
(ZERO + b" " + COMMIT + b" refs/heads/hermes/\xff", "canonical ASCII"),
|
|
(b"zz" * 20 + b" " + COMMIT + b" refs/heads/hermes/x", "ref command is invalid"),
|
|
],
|
|
)
|
|
def test_push_commands_outside_the_narrow_grant_are_rejected(line, match):
|
|
scan = _load("receive_pack_scan")
|
|
framed = f"{len(line) + 5:04x}".encode() + line + b"\n" + b"0000"
|
|
with pytest.raises(scan.PolicyError, match=match):
|
|
scan.validate_receive_pack(framed + _pack_of([]), TOKEN, FORMS)
|
|
|
|
|
|
def test_command_stream_bounds_are_enforced():
|
|
scan = _load("receive_pack_scan")
|
|
with pytest.raises(scan.PolicyError, match="no ref command"):
|
|
scan.validate_receive_pack(b"0000" + _pack_of([]), TOKEN, FORMS)
|
|
with pytest.raises(scan.PolicyError, match="omitted its command terminator"):
|
|
scan.validate_receive_pack(b"", TOKEN, FORMS)
|
|
line = ZERO + b" " + COMMIT + b" refs/heads/hermes/x\n"
|
|
framed = f"{len(line) + 4:04x}".encode() + line
|
|
many = framed * (scan.MAX_PUSH_COMMANDS + 1) + b"0000" + _pack_of([])
|
|
with pytest.raises(scan.PolicyError, match="too many ref commands"):
|
|
scan.validate_receive_pack(many, TOKEN, FORMS)
|
|
with pytest.raises(scan.PolicyError, match="framing is invalid"):
|
|
scan.validate_receive_pack(b"zzzz", TOKEN, FORMS)
|
|
with pytest.raises(scan.PolicyError, match="framing is invalid"):
|
|
scan.validate_receive_pack(b"0003", TOKEN, FORMS)
|
|
with pytest.raises(scan.PolicyError, match="framing is invalid"):
|
|
scan.validate_receive_pack(b"0009abc", TOKEN, FORMS)
|
|
|
|
|
|
def test_credential_forms_in_command_lines_are_rejected():
|
|
scan = _load("receive_pack_scan")
|
|
line = ZERO + b" " + COMMIT + b" refs/heads/hermes/x runtime-sentinel\n"
|
|
framed = f"{len(line) + 4:04x}".encode() + line + b"0000"
|
|
with pytest.raises(scan.PolicyError, match="runtime credential material"):
|
|
scan.validate_receive_pack(framed + _pack_of([]), TOKEN, FORMS)
|
|
|
|
|
|
def test_scan_crosses_chunk_boundaries_with_carry(monkeypatch):
|
|
scan = _load("receive_pack_scan")
|
|
monkeypatch.setattr(scan, "SCAN_CHUNK", 8)
|
|
monkeypatch.setattr(scan, "SCAN_CARRY", 48)
|
|
payload = b"x" * 20 + b" AKIAABCDEFGHIJKLMNOP " + b"y" * 20
|
|
with pytest.raises(scan.PolicyError, match="credential-shaped"):
|
|
scan._scan_payload(io.BytesIO(payload), FORMS)
|
|
scan._scan_payload(io.BytesIO(b"clean " * 40), FORMS)
|
|
scan._scan_payload(io.BytesIO(b""), FORMS)
|
|
|
|
|
|
def test_scan_loose_object_reports_type_and_scans_content():
|
|
scan = _load("receive_pack_scan")
|
|
clean = zlib.compress(b"blob 5\x00clean")
|
|
assert scan.scan_loose_object(clean, FORMS) == 3
|
|
secret = b"-----BEGIN EC PRIVATE KEY-----"
|
|
dirty = zlib.compress(b"blob %d\x00" % len(secret) + secret)
|
|
with pytest.raises(scan.PolicyError, match="credential-shaped"):
|
|
scan.scan_loose_object(dirty, FORMS)
|
|
leaked = b"the runtime-sentinel value"
|
|
with pytest.raises(scan.PolicyError, match="runtime credential material"):
|
|
scan.scan_loose_object(
|
|
zlib.compress(b"blob %d\x00" % len(leaked) + leaked), FORMS
|
|
)
|
|
|
|
|
|
def test_content_patterns_extend_the_shared_standalone_corpus():
|
|
scan = _load("receive_pack_scan")
|
|
policy = _load("gitea_api_policy")
|
|
assert len(scan.CONTENT_SECRET_PATTERNS) == len(policy.STANDALONE_SECRET_PATTERNS) + 3
|
|
assert all(
|
|
isinstance(pattern.pattern, bytes) for pattern in scan.CONTENT_SECRET_PATTERNS
|
|
)
|
|
|
|
|
|
def test_validation_rewinds_the_stream_for_upstream_forwarding():
|
|
scan = _load("receive_pack_scan")
|
|
body = _request(_blob_pack(b"safe content"))
|
|
stream = io.BytesIO(body)
|
|
scan.validate_receive_pack(stream, TOKEN, FORMS)
|
|
assert stream.tell() == 0
|
|
with pytest.raises(scan.PolicyError):
|
|
scan.validate_receive_pack(io.BytesIO(b"zzzz"), TOKEN, FORMS) |