atlas-iac/testing/tests/test_hermes_receive_pack_scan.py

260 lines
9.8 KiB
Python
Raw Normal View History

"""Quarantine content-scanning contracts for brokered Git pushes."""
from __future__ import annotations
import hashlib
import io
import subprocess
import sys
import zlib
import pytest
from testing.tests.test_hermes_scm_broker_support import (
ROOT,
_blob_pack,
_load,
_object_entry,
_pack_of,
_receive_command,
)
sys.path.insert(0, str(ROOT / "services/hermes/scripts"))
import execution_pool_scm as scm # noqa: E402
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_native_git_delta_free_update_pack_is_self_contained(tmp_path):
"""The worker's scoped push settings produce a broker-scannable pack."""
scan = _load("receive_pack_scan")
def git(*args, input=None):
completed = subprocess.run(
("git", *args), cwd=tmp_path, input=input, capture_output=True,
check=False,
)
assert completed.returncode == 0
return completed.stdout
git("init")
git("config", "user.email", "hermes@atlas.invalid")
git("config", "user.name", "Hermes")
tracked = tmp_path / "tracked.txt"
tracked.write_text("base\n" + "same text\n" * 1024)
git("add", "tracked.txt")
git("commit", "-m", "base")
old = git("rev-parse", "HEAD").strip()
tracked.write_text("updated\n" + "same text\n" * 1024)
git("commit", "-am", "update")
head = git("rev-parse", "HEAD").strip()
push_args = scm._broker_push_args("grant", "hermes/scan")
config_args = push_args[:push_args.index("push")]
pack = git(
*config_args, "pack-objects",
"--stdout", "--revs", input=head + b"\n^" + old + b"\n",
)
request = _receive_command(old, head, b"refs/heads/hermes/scan", pack)
assert scan.validate_receive_pack(
request, TOKEN, FORMS,
expected=(old.decode(), head.decode(), "hermes/scan"),
) == (old.decode(), head.decode(), "hermes/scan")
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)