Compare commits

..

1 Commits

Author SHA1 Message Date
jenkins
c8ccc8f249 hermes: harden draft PR input boundary 2026-08-16 20:56:17 -03:00
3 changed files with 236 additions and 7 deletions

View File

@ -18,6 +18,8 @@ from gitea_api_policy import (
PolicyError,
_draft_title,
_validate_body,
_validate_pr_number,
_validate_pr_number_segment,
_validate_query,
_validate_ref,
_validate_repo,
@ -30,6 +32,8 @@ DEFAULT_TOKEN_FILE = Path("/runtime-access/gitea-token")
CREATE_PATH_RE = re.compile(r"/api/v1/repos/atlas/([^/]+)/pulls\Z")
MAX_ERROR_BYTES = 8192
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
MAX_API_TARGET_LENGTH = 768
MAX_API_PATH_LENGTH = 512
class RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
@ -64,11 +68,15 @@ def configured_base_url() -> str:
def _split_api_path(path: str) -> urllib.parse.SplitResult:
if not isinstance(path, str) or len(path) > MAX_API_TARGET_LENGTH:
raise PolicyError("API target exceeds the safe size limit")
target = urllib.parse.urlsplit(path)
if target.scheme or target.netloc or target.fragment:
raise PolicyError("API path must be relative to the Atlas SCM origin")
if not target.path.startswith("/api/v1/"):
raise PolicyError("API path must start with /api/v1/")
if len(target.path) > MAX_API_PATH_LENGTH:
raise PolicyError("API path exceeds the safe size limit")
if "%" in target.path or "//" in target.path or "/../" in f"{target.path}/":
raise PolicyError("encoded or non-canonical API paths are not allowed")
return target
@ -89,10 +97,18 @@ def _authorize_read(target: urllib.parse.SplitResult) -> str:
if suffix == "pulls":
_validate_query(target, {"page", "limit", "state"})
return "pull-list"
if re.fullmatch(r"pulls/[1-9][0-9]*", suffix):
content_match = re.fullmatch(r"pulls/([^/]+)\.(?:patch|diff)", suffix)
if content_match:
_validate_pr_number_segment(content_match.group(1))
raise PolicyError("repository API route is outside the metadata read allowlist")
pull_match = re.fullmatch(r"pulls/([^/]+)", suffix)
if pull_match:
_validate_pr_number_segment(pull_match.group(1))
_validate_query(target, set())
return "pull"
if re.fullmatch(r"pulls/[1-9][0-9]*/(?:commits|files)", suffix):
evidence_match = re.fullmatch(r"pulls/([^/]+)/(?:commits|files)", suffix)
if evidence_match:
_validate_pr_number_segment(evidence_match.group(1))
_validate_query(target, {"page", "limit"})
return "pull-evidence"
if suffix == "branches":
@ -245,9 +261,7 @@ def _require_create_response(
raise PolicyError("Forgejo returned invalid pull-request metadata") from exc
if not isinstance(document, dict):
raise PolicyError("Forgejo returned invalid pull-request metadata")
number = document.get("number")
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise PolicyError("Forgejo omitted a valid pull-request number")
number = _validate_pr_number(document.get("number"))
full_name = f"{ALLOWED_OWNER}/{repo}"
expected_html = f"{CANONICAL_BASE_URL}/{full_name}/pulls/{number}"
checks = (

View File

@ -16,16 +16,44 @@ MAX_QUERY_KEY_LENGTH = 16
MAX_QUERY_VALUE_LENGTH = 16
MAX_PAGE = 10_000
MAX_LIMIT = 50
MAX_PR_NUMBER = 2_147_483_647
MAX_PR_NUMBER_DIGITS = 10
CANONICAL_QUERY_RE = re.compile(r"[A-Za-z0-9_=&-]*\Z")
SENSITIVE_TEXT_RE = re.compile(
r"""(?ix)
(?:
(?<![A-Za-z0-9])
(?:client[_-]?secret|access[_-]?token|refresh[_-]?token|private[_-]?key|
api[_-]?key|password|passwd|secret|token|authorization|bearer|basic)
private[_-]?key[_-]?id|client[_-]?email|api[_-]?key|password|passwd|
secret|token|authorization|bearer|basic|personal[_-]?access[_-]?token|
pat|github[_-]?token|gitlab[_-]?token|gitea[_-]?token|forgejo[_-]?token|
aws[_-]?(?:access[_-]?key(?:[_-]?id)?|secret[_-]?(?:access[_-]?)?key|
session[_-]?token|security[_-]?token)|
access[_-]?key[_-]?id|secret[_-]?access[_-]?key|session[_-]?token|
account[_-]?key|shared[_-]?access[_-]?signature|sas[_-]?token|
azure[_-]?(?:storage[_-]?)?connection[_-]?string|connection[_-]?string|
docker[_-]?(?:auth[_-]?config|config[_-]?json)|dockerconfigjson|
registry[_-]?(?:auth|password|token)|identity[_-]?token|
google[_-]?(?:credentials|oauth[_-]?token)|service[_-]?account[_-]?key|
webhook[_-]?(?:url|token|secret)|slack[_-]?(?:token|webhook)|
twilio[_-]?auth[_-]?token|sendgrid[_-]?api[_-]?key)
[\"']?\s*[:=]\s*[\"']?
|
\b(?:bearer|basic)\s+[A-Za-z0-9+/_=.:-]{8,}
\b(?:bearer|basic)\s+
(?=[A-Za-z0-9+/_=.:-]{16,}\b)(?=[A-Za-z0-9+/_=.:-]*[0-9+/_=.-])
[A-Za-z0-9+/_=.:-]{16,}
|
[\"'](?:auth|identitytoken|registrytoken)[\"']\s*:\s*
[\"'][A-Za-z0-9+/_=.-]{8,}[\"']
|
[\"']auths[\"']\s*:\s*\{
|
[\"']type[\"']\s*:\s*[\"']service_account[\"']
|
\bDefaultEndpointsProtocol\s*=\s*https?;[^\r\n]{0,1024}
AccountKey\s*=
|
(?:[?;&]|\b)sv=20[0-9]{2}-[0-9]{2}-[0-9]{2}&[^\s]{0,1024}&sig=
|
-----BEGIN\s+[A-Z0-9 ][A-Z0-9 -]{1,62}-----
|
@ -35,8 +63,21 @@ SENSITIVE_TEXT_RE = re.compile(
glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|
sk-ant-[A-Za-z0-9_-]{20,}|sk-(?:proj-|live_)?[A-Za-z0-9_-]{20,}|
[sr]k_live_[A-Za-z0-9]{20,}|
whsec_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|
pypi-[A-Za-z0-9_-]{30,}|hf_[A-Za-z0-9]{20,}|
(?:gta|gto|gitea|forgejo)_[A-Za-z0-9_-]{20,}|
ya29\.[A-Za-z0-9_-]{20,})\b
|
\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{20,}\b
|
\bSK[0-9a-fA-F]{32}\b
|
https://hooks\.slack\.com/services/[A-Za-z0-9/_-]{20,}
|
https://(?:discord(?:app)?\.com)/api/webhooks/[0-9]+/[A-Za-z0-9._-]{20,}
|
https://[^\s/]*webhook\.office\.com/[^\s]{20,}
|
\b(?:AKIA|ASIA)[0-9A-Z]{16}\b
|
\bAIza[0-9A-Za-z_-]{35}\b
@ -81,6 +122,31 @@ def _validate_sha(value: object, name: str = "head SHA") -> str:
return value.lower()
def _validate_pr_number_segment(value: object) -> int:
"""Validate a canonical bounded pull-request number from an API path."""
if (
not isinstance(value, str)
or len(value) > MAX_PR_NUMBER_DIGITS
or not re.fullmatch(r"[1-9][0-9]*", value)
):
raise PolicyError("pull-request number must use bounded canonical ASCII digits")
number = int(value)
if number > MAX_PR_NUMBER:
raise PolicyError("pull-request number is outside its allowed range")
return number
def _validate_pr_number(value: object) -> int:
"""Validate a bounded pull-request number returned by Forgejo."""
if (
not isinstance(value, int)
or isinstance(value, bool)
or not 1 <= value <= MAX_PR_NUMBER
):
raise PolicyError("Forgejo omitted a valid pull-request number")
return value
def _validate_text(value: object, name: str, maximum: int, *, required: bool) -> str:
if not isinstance(value, str):
raise PolicyError(f"{name} must be text")

View File

@ -210,6 +210,80 @@ def test_read_query_requires_canonical_bounded_ascii(query: str):
)
@pytest.mark.parametrize(
"suffix",
[
"pulls/{number}",
"pulls/{number}.patch",
"pulls/{number}.diff",
"pulls/{number}/commits",
"pulls/{number}/files",
"commits/{number}/status",
"commits/{number}/statuses",
"statuses/{number}",
"branches/{number}",
],
)
def test_oversized_numeric_or_captured_path_never_reaches_opener(suffix: str):
client = _load()
called = False
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(b"{}")
path = "/api/v1/repos/atlas/cassandra/" + suffix.format(number="9" * 4000)
with pytest.raises(client.PolicyError):
client.read(path, token="runtime", opener=opener)
assert called is False
@pytest.mark.parametrize(
"number",
["0", "01", "2147483648", "12", "%31", "12345678901"],
)
@pytest.mark.parametrize("tail", ["", ".patch", ".diff", "/commits", "/files"])
def test_noncanonical_or_out_of_range_pr_number_never_reaches_opener(
number: str, tail: str
):
client = _load()
called = False
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(b"{}")
with pytest.raises(client.PolicyError):
client.read(
f"/api/v1/repos/atlas/cassandra/pulls/{number}{tail}",
token="runtime",
opener=opener,
)
assert called is False
def test_maximum_bounded_pr_number_is_readable():
client = _load()
called = False
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(b"{}")
assert (
client.read(
"/api/v1/repos/atlas/cassandra/pulls/2147483647",
token="runtime",
opener=opener,
)
== b"{}"
)
assert called is True
@pytest.mark.parametrize(
("method", "path", "data"),
[
@ -309,6 +383,30 @@ def test_runtime_token_is_only_an_authorization_header():
"ACCESS_" + "TOKEN = 'not-a-real-value'",
'{"refresh_' + 'token": "not-a-real-value"}',
"private_" + "key: not-a-real-value",
"AWS_SECRET_ACCESS_" + "KEY=not-a-real-value",
"aws_access_key_" + "id: not-a-real-value",
"AWS_SESSION_" + "TOKEN = 'not-a-real-value'",
'{"AccessKey' + 'Id":"not-a-real-value"}',
'{"SecretAccess' + 'Key":"not-a-real-value"}',
'{"Session' + 'Token":"not-a-real-value"}',
"aws-security-" + "token: not-a-real-value",
"Account" + "Key=not-a-real-value",
"SharedAccess" + "Signature: not-a-real-value",
"AZURE_STORAGE_CONNECTION_" + "STRING='not-a-real-value'",
"DefaultEndpointsProtocol=https;AccountName=fake;Account"
+ "Key=not-a-real-value;EndpointSuffix=example",
"?sv=2024-11-04&ss=b&srt=sco&sp=rwdlac&se=2099-01-01&sig=" + "not-a-real-value",
"DOCKER_AUTH_" + "CONFIG='not-a-real-value'",
'{"auths":{"registry.example":{"auth":"bm90LXJlYWw="}}}',
'{"identity' + 'token":"not-a-real-value"}',
'{"type":"service_' + 'account","client_email":"fake@example.test"}',
'{"private_key_' + 'id":"not-a-real-value"}',
'{"client_' + 'email":"fake@example.test"}',
"GOOGLE_CREDENTIALS" + "=not-a-real-value",
"personal_access_" + "token: not-a-real-value",
"GITEA_" + "TOKEN=not-a-real-value",
"gitlab-token" + ": not-a-real-value",
"pat" + "=not-a-real-value",
"Authorization: " + "Bearer not-a-real-credential-value",
"authorization = " + '"Basic not-a-real-credential-value"',
"Bearer" + "=not-a-real-credential-value",
@ -322,6 +420,17 @@ def test_runtime_token_is_only_an_authorization_header():
"sk-proj-" + "notarealcredentialvalue123456",
"sk_live_" + "notarealcredentialvalue123456",
"ya29." + "notarealcredentialvalue123456",
"gta_" + "notarealcredentialvalue123456",
"whsec_" + "notarealcredentialvalue123456",
"npm_" + "notarealcredentialvalue123456",
"pypi-" + "notarealcredentialvalue123456789012345",
"hf_" + "notarealcredentialvalue123456",
"SG." + "notarealvalue1234" + ".notarealcredentialvalue123456",
"SK" + "a" * 32,
"https://hooks.slack.com/services/" + "T000/B000/notarealvalue123456",
"https://discord.com/api/webhooks/123456789/" + "notarealcredentialvalue123456",
"https://fake.webhook.office.com/" + "notarealcredentialvalue123456",
"webhook_" + "url=https://example.test/not-real",
"AKIA" + "A" * 16,
"AIza" + "a" * 35,
"-----BEGIN OPENSSH " + "PRIVATE KEY-----",
@ -335,12 +444,21 @@ def test_create_rejects_expanded_credential_shapes_before_network(
):
client = _load()
called = False
request_built = False
original_build_request = client.build_request
def build_request(*args, **kwargs):
nonlocal request_built
request_built = True
return original_build_request(*args, **kwargs)
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(_draft_response())
client.build_request = build_request
values = {"title": "Focused fix", "body": "Review evidence"}
values[field] = sensitive
@ -355,19 +473,48 @@ def test_create_rejects_expanded_credential_shapes_before_network(
token="runtime-sentinel",
opener=opener,
)
assert request_built is False
assert called is False
@pytest.mark.parametrize(
"safe_text",
[
"AWS_SECRET_ACCESS_KEY is injected at runtime",
"Review the Authorization header behavior",
"Bearer authentication is required for this route",
"Document Docker auths payload rejection",
"The client_email field belongs to service accounts",
"AccountKey assignments must be blocked",
"This patch changes token validation without including a value",
],
)
def test_credential_policy_keeps_normal_engineering_prose_usable(safe_text: str):
client = _load()
assert client._validate_body(safe_text) == safe_text
assert client._draft_title(safe_text) == f"WIP: {safe_text}"
@pytest.mark.parametrize("field", ["title", "body"])
def test_create_rejects_exact_runtime_token_before_network(field: str):
client = _load()
called = False
request_built = False
original_build_request = client.build_request
def build_request(*args, **kwargs):
nonlocal request_built
request_built = True
return original_build_request(*args, **kwargs)
def opener(*_args, **_kwargs):
nonlocal called
called = True
return Response(_draft_response())
client.build_request = build_request
runtime_token = "exact-random-runtime-sentinel-7b73ac61"
values = {"title": "Focused fix", "body": "Review evidence"}
values[field] = f"Accidental {runtime_token} value"
@ -382,6 +529,7 @@ def test_create_rejects_exact_runtime_token_before_network(field: str):
token=runtime_token,
opener=opener,
)
assert request_built is False
assert called is False
@ -419,6 +567,7 @@ def test_create_postcondition_rejects_every_material_mismatch():
client = _load()
mutations = [
("number", 0),
("number", 2_147_483_648),
("state", "closed"),
("draft", False),
("merged", True),