Show the shape of a rejected wiki token in the error
Validate workflows / validate (pull_request) Successful in 14s
Validate workflows / validate (pull_request) Successful in 14s
When Gitea answers 401, add the token's length and likely storage mistakes (masked text, NAME=value, quotes, whitespace) to the message, without any character of the token, so a wrongly stored secret can be recognised from the log. Task: MIL-005#8
This commit is contained in:
@@ -191,6 +191,57 @@ class TestMain:
|
|||||||
assert gitea.wiki == {"Home": b64("keep me")}
|
assert gitea.wiki == {"Home": b64("keep me")}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescribeToken:
|
||||||
|
def test_recognises_a_gitea_token_shape(self):
|
||||||
|
description = publish_wiki.describe_token("a1b2c3d4e5" * 4)
|
||||||
|
assert "40 characters" in description
|
||||||
|
assert "looks like a Gitea token" in description
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("token", "expected"),
|
||||||
|
[
|
||||||
|
("*******", "only mask characters"),
|
||||||
|
("TOKEN_FOR_GITEA_WIKI=" + "a" * 40, "contains '='"),
|
||||||
|
('"' + "a" * 40 + '"', "quote character"),
|
||||||
|
("abc def", "whitespace"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_names_the_likely_mistake_without_echoing_the_token(self, token, expected):
|
||||||
|
description = publish_wiki.describe_token(token)
|
||||||
|
assert expected in description
|
||||||
|
assert token not in description
|
||||||
|
|
||||||
|
|
||||||
|
class TestRejectedToken:
|
||||||
|
def test_rejection_adds_the_token_shape_but_not_the_token(self, monkeypatch, environment):
|
||||||
|
monkeypatch.setenv("WIKI_CREDENTIALS", "*******")
|
||||||
|
|
||||||
|
def reject(url, method="GET", headers=None, body=None):
|
||||||
|
raise publish_wiki.WorkflowError("credentials", "Gitea rejected the token.")
|
||||||
|
|
||||||
|
monkeypatch.setattr(publish_wiki, "request_json", reject)
|
||||||
|
|
||||||
|
with pytest.raises(publish_wiki.WorkflowError) as caught:
|
||||||
|
publish_wiki.main()
|
||||||
|
|
||||||
|
message = str(caught.value)
|
||||||
|
assert caught.value.category == "credentials"
|
||||||
|
assert "Token shape: 7 characters" in message
|
||||||
|
assert "only mask characters" in message
|
||||||
|
assert "*******" not in message.split("Token shape:")[0]
|
||||||
|
|
||||||
|
def test_other_categories_are_not_changed(self, monkeypatch, environment):
|
||||||
|
def forbid(url, method="GET", headers=None, body=None):
|
||||||
|
raise publish_wiki.WorkflowError("permissions", "Forbidden.")
|
||||||
|
|
||||||
|
monkeypatch.setattr(publish_wiki, "request_json", forbid)
|
||||||
|
|
||||||
|
with pytest.raises(publish_wiki.WorkflowError) as caught:
|
||||||
|
publish_wiki.main()
|
||||||
|
|
||||||
|
assert str(caught.value) == "[permissions] Forbidden."
|
||||||
|
|
||||||
|
|
||||||
class TestRequestJson:
|
class TestRequestJson:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def raise_http_error(monkeypatch, code, reason):
|
def raise_http_error(monkeypatch, code, reason):
|
||||||
|
|||||||
+39
-8
@@ -16,6 +16,7 @@ Exit codes: 0 published or nothing to do, 1 failed (message names a category).
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -43,6 +44,7 @@ class WorkflowError(RuntimeError):
|
|||||||
def __init__(self, category: str, message: str) -> None:
|
def __init__(self, category: str, message: str) -> None:
|
||||||
super().__init__(f"[{category}] {message}")
|
super().__init__(f"[{category}] {message}")
|
||||||
self.category = category
|
self.category = category
|
||||||
|
self.detail = message
|
||||||
|
|
||||||
|
|
||||||
def request_json(
|
def request_json(
|
||||||
@@ -110,6 +112,28 @@ def parse_gitea_token(raw_credentials: str | None) -> str:
|
|||||||
return token.strip()
|
return token.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def describe_token(token: str) -> str:
|
||||||
|
"""Describe the shape of a token without revealing any of its characters.
|
||||||
|
|
||||||
|
Used when Gitea rejects the token, so a wrongly stored secret (the masked
|
||||||
|
text, ``NAME=value``, quotes) can be recognised from the log.
|
||||||
|
"""
|
||||||
|
facts = [f"{len(token)} characters"]
|
||||||
|
if re.fullmatch(r"[0-9a-f]{40}", token):
|
||||||
|
facts.append("looks like a Gitea token (40 hexadecimal characters)")
|
||||||
|
return ", ".join(facts)
|
||||||
|
facts.append("a Gitea token has 40 hexadecimal characters")
|
||||||
|
if set(token) <= {"*", "x", "X", "•"}:
|
||||||
|
facts.append("only mask characters: the masked text was stored")
|
||||||
|
if "=" in token:
|
||||||
|
facts.append("contains '=': the secret name may be part of the value")
|
||||||
|
if re.search(r"[\"'`]", token):
|
||||||
|
facts.append("contains a quote character")
|
||||||
|
if re.search(r"\s", token):
|
||||||
|
facts.append("contains whitespace")
|
||||||
|
return ", ".join(facts)
|
||||||
|
|
||||||
|
|
||||||
def split_repository(full_name: str | None) -> tuple[str, str]:
|
def split_repository(full_name: str | None) -> tuple[str, str]:
|
||||||
"""Split ``owner/repo`` into its two parts."""
|
"""Split ``owner/repo`` into its two parts."""
|
||||||
owner, separator, repo = (full_name or "").partition("/")
|
owner, separator, repo = (full_name or "").partition("/")
|
||||||
@@ -218,15 +242,22 @@ def main() -> int:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
repository = request_json(repository_url, headers=headers)
|
try:
|
||||||
if isinstance(repository, dict) and repository.get("has_wiki") is False:
|
repository = request_json(repository_url, headers=headers)
|
||||||
raise WorkflowError(
|
if isinstance(repository, dict) and repository.get("has_wiki") is False:
|
||||||
"configuration",
|
raise WorkflowError(
|
||||||
f"The wiki is not enabled on {owner}/{repo}. Enable it in the repository settings.",
|
"configuration",
|
||||||
)
|
f"The wiki is not enabled on {owner}/{repo}. Enable it in the repository settings.",
|
||||||
print("Preflight passed: Gitea accepted the token and the wiki is enabled.")
|
)
|
||||||
|
print("Preflight passed: Gitea accepted the token and the wiki is enabled.")
|
||||||
|
|
||||||
result = publish(pages, f"{repository_url}/wiki", headers, request_json)
|
result = publish(pages, f"{repository_url}/wiki", headers, request_json)
|
||||||
|
except WorkflowError as error:
|
||||||
|
if error.category == "credentials":
|
||||||
|
raise WorkflowError(
|
||||||
|
"credentials", f"{error.detail} Token shape: {describe_token(token)}."
|
||||||
|
) from None
|
||||||
|
raise
|
||||||
print(
|
print(
|
||||||
f"Published {len(pages)} page(s) to the wiki of {owner}/{repo}: "
|
f"Published {len(pages)} page(s) to the wiki of {owner}/{repo}: "
|
||||||
f"{len(result['created'])} created, {len(result['updated'])} updated, "
|
f"{len(result['created'])} created, {len(result['updated'])} updated, "
|
||||||
|
|||||||
Reference in New Issue
Block a user