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