Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Security

- Naruon interchange refuses `x-apikey`, `x-api_key`, and hyphenated `api-key` credential-header aliases, not only `x-api-key`.
- GitHub HTTPS fleet transport maps request, response, and close-path network exceptions to `upstream_unavailable` without leaking raw provider exception text.
- Disabled-state classification and live disable confirmation now accept GitHub's official `disabled_manually`, `disabled_fork`, `disabled_inactivity`, and `deleted` registry states so orphan bootstrap/repair identities can be retired without name-only heuristics.
- Prohibited `COPILOT_GITHUB_TOKEN` and reserved `NVIDIA_NIM_API_KEY` for approved LLM test and development workflows.
- Defined purpose-bound PII access, opaque analytical identifiers, separately protected identity mapping, selective model-provider disclosure, retention/deletion, and privileged audit controls instead of destructive blanket masking.
Expand Down
11 changes: 10 additions & 1 deletion crates/tepp_api/src/naruon_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ pub(crate) fn header_is_credential(name: &str) -> bool {
lowered == "authorization"
|| lowered == "proxy-authorization"
|| lowered == "cookie"
|| lowered == "x-api-key"
|| lowered.contains("api-key")
|| lowered.contains("api_key")
|| lowered.contains("apikey")
Comment on lines +157 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Substring credential matching widens refusals

header_is_credential at naruon_http.rs uses broad substring matches for api-key/api_key/apikey. Any benign caller header whose name contains these substrings is refused with AuthorizationDenied. This is intentional fail-closed widening, not a bug, but it can reject legitimate custom headers that happen to contain the substrings.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

|| lowered.contains("token")
|| lowered.contains("copilot")
|| lowered.contains("github")
Expand Down Expand Up @@ -296,6 +298,13 @@ mod tests {
refuse_credential_headers(&[("x-api-key", "k")]),
Err(ApiError::AuthorizationDenied)
);
for name in ["x-apikey", "x-api_key", "X-ApiKey", "x-vendor-api-key"] {
assert_eq!(
refuse_credential_headers(&[(name, "k")]),
Err(ApiError::AuthorizationDenied),
"header={name}"
);
}
assert_eq!(
refuse_credential_headers(&[("x-github-token", "t")]),
Err(ApiError::AuthorizationDenied)
Expand Down
4 changes: 4 additions & 0 deletions crates/tepp_api/tests/naruon_live_http_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,10 @@ fn handle_http_refuses_credential_headers_and_reserved_overrides() {
),
("cookie", "a=b", 403, "authorization_denied"),
("x-api-key", "k", 403, "authorization_denied"),
("x-apikey", "k", 403, "authorization_denied"),
("x-api_key", "k", 403, "authorization_denied"),
("X-ApiKey", "k", 403, "authorization_denied"),
("x-vendor-api-key", "k", 403, "authorization_denied"),
("x-github-token", "t", 403, "authorization_denied"),
("x-copilot-session", "s", 403, "authorization_denied"),
(
Expand Down
29 changes: 22 additions & 7 deletions scripts/actions_workflow_fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,20 +159,35 @@ def request(self, method: str, path: str) -> HttpResponse:
timeout=60,
context=ssl.create_default_context(),
)
request_error: FleetAuditError | None = None
try:
connection.request(method, path, headers=headers)
response = connection.getresponse()
body = response.read()
response_headers = {
str(key): str(value) for key, value in response.getheaders()
}
try:
connection.request(method, path, headers=headers)
response = connection.getresponse()
body = response.read()
response_headers = {
str(key): str(value) for key, value in response.getheaders()
}
except (OSError, http.client.HTTPException):
request_error = FleetAuditError(
"upstream_unavailable",
"GitHub API transport failed",
)
raise request_error from None
return HttpResponse(
status=int(response.status),
headers=response_headers,
Comment thread
seonghobae marked this conversation as resolved.
body=body,
)
finally:
connection.close()
try:
connection.close()
except (OSError, http.client.HTTPException):
if request_error is None:
raise FleetAuditError(
"upstream_unavailable",
"GitHub API transport failed",
) from None
Comment thread
seonghobae marked this conversation as resolved.


def _utc_now() -> str:
Expand Down
169 changes: 169 additions & 0 deletions tests/quality/test_actions_workflow_fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import http.client
import io
import json
import os
Expand Down Expand Up @@ -215,6 +216,8 @@ def test_permission_not_found_and_upstream_errors_are_distinct(self) -> None:
"""403, 404, and 5xx stay distinct so operators cannot treat them as empty."""

def expect(status: int, reason: str) -> None:
"""Assert one HTTP status maps to its stable fleet-audit reason."""

transport = FakeTransport(
{
(
Expand Down Expand Up @@ -757,19 +760,29 @@ class _FakeResponse:
status = 403

def read(self) -> bytes:
"""Return the body used by the status-mapping response."""

return b'{"message":"no"}'

def getheaders(self) -> list[tuple[str, str]]:
"""Return the headers used by the status-mapping response."""

return [("X-GitHub", "yes")]

class _FakeConnection:
def request(self, *args: object, **kwargs: object) -> None:
"""Accept the request without changing the fake response."""

return None

def getresponse(self) -> _FakeResponse:
"""Return the status-mapping fake response."""

return _FakeResponse()

def close(self) -> None:
"""Close the status-mapping fake connection."""

return None

transport = fleet.GithubHttpsTransport("secret-token")
Expand All @@ -787,19 +800,29 @@ class _FakeResponse:
status = 200

def read(self) -> bytes:
"""Return the body used by the normal response test."""

return b'{"ok":true}'

def getheaders(self) -> list[tuple[str, str]]:
"""Return the headers used by the normal response test."""

return [("Content-Type", "application/json")]

class _FakeConnection:
def request(self, *args: object, **kwargs: object) -> None:
"""Accept the request without changing the fake response."""

return None

def getresponse(self) -> _FakeResponse:
"""Return the normal fake response."""

return _FakeResponse()

def close(self) -> None:
"""Close the normal fake connection."""

return None

transport = fleet.GithubHttpsTransport("secret-token")
Expand All @@ -818,6 +841,144 @@ def test_https_transport_rejects_relative_paths(self) -> None:
transport.request("GET", "repos/o/r")
self.assertEqual(raised.exception.reason, "invalid_path")

def test_https_transport_hides_raw_network_errors(self) -> None:
"""Provider transport failures use a stable reason without raw exception text."""

cases: tuple[tuple[str, BaseException], ...] = (
("request", OSError("provider request and token must not escape")),
(
"request",
http.client.HTTPException("provider request http must not escape"),
),
("getresponse", OSError("provider response and token must not escape")),
(
"getresponse",
http.client.HTTPException("provider response http must not escape"),
),
("getheaders", OSError("provider headers and token must not escape")),
(
"getheaders",
http.client.HTTPException("provider headers http must not escape"),
),
("read", TimeoutError("provider body and token must not escape")),
("read", http.client.IncompleteRead(b"partial")),
)
for stage, error in cases:
with self.subTest(stage=stage, error_type=type(error).__name__):
closed = {"called": False}

class _FakeResponse:
def read(self) -> bytes:
"""Raise the stage-specific body error or return valid JSON."""

if stage == "read":
raise error
return b'{"ok":true}'

def getheaders(self) -> list[tuple[str, str]]:
"""Raise the stage-specific header error or return no headers."""

if stage == "getheaders":
raise error
return []

class _FakeConnection:
def request(self, *args: object, **kwargs: object) -> None:
"""Raise the stage-specific request error when selected."""

if stage == "request":
raise error

def getresponse(self) -> _FakeResponse:
"""Raise the stage-specific response error or return a response."""

if stage == "getresponse":
raise error
return _FakeResponse()

def close(self) -> None:
"""Record that the transport always closes the connection."""

closed["called"] = True

transport = fleet.GithubHttpsTransport("secret-token")
with unittest.mock.patch(
"http.client.HTTPSConnection", return_value=_FakeConnection()
):
with self.assertRaises(fleet.FleetAuditError) as raised:
transport.request("GET", "/repos/o/r")
self.assertEqual(raised.exception.reason, "upstream_unavailable")
self.assertEqual(
raised.exception.message, "GitHub API transport failed"
)
self.assertNotIn(str(error), str(raised.exception))
self.assertTrue(closed["called"])

def test_https_transport_hides_close_errors_without_overwriting_request_errors(
self,
) -> None:
"""Close-path OSError maps to the stable reason and cannot replace it."""

class _FakeResponse:
status = 200

def read(self) -> bytes:
"""Return a valid response body for the close-path test."""

return b'{"ok":true}'

def getheaders(self) -> list[tuple[str, str]]:
"""Return a valid response header for the close-path test."""

return [("Content-Type", "application/json")]

class _CloseFails:
def request(self, *args: object, **kwargs: object) -> None:
"""Complete the request before close raises the transport error."""

return None

def getresponse(self) -> _FakeResponse:
"""Return a valid response before close raises the transport error."""

return _FakeResponse()

def close(self) -> None:
"""Raise a close error whose provider text must be redacted."""

raise OSError("provider close and token must not escape")

class _RequestAndCloseFail:
def request(self, *args: object, **kwargs: object) -> None:
"""Raise the request error before the close error also occurs."""

raise OSError("provider request and token must not escape")

def getresponse(self) -> _FakeResponse:
"""Fail the test if transport continues after request failure."""

raise AssertionError("getresponse must not run after request failure")

def close(self) -> None:
"""Raise a close error without replacing the request failure."""

raise OSError("provider close and token must not escape")

transport = fleet.GithubHttpsTransport("secret-token")
for connection in (_CloseFails(), _RequestAndCloseFail()):
with self.subTest(connection=type(connection).__name__):
with unittest.mock.patch(
"http.client.HTTPSConnection", return_value=connection
):
with self.assertRaises(fleet.FleetAuditError) as raised:
transport.request("GET", "/repos/o/r")
self.assertEqual(raised.exception.reason, "upstream_unavailable")
self.assertEqual(
raised.exception.message, "GitHub API transport failed"
)
self.assertNotIn("provider close", str(raised.exception))
self.assertNotIn("provider request", str(raised.exception))

def test_build_transport_uses_standard_token(self) -> None:
"""build_transport reads GITHUB_TOKEN and does not invent a TEPP PAT name."""

Expand Down Expand Up @@ -1003,10 +1164,14 @@ class _AlternatingWorkflowTransport(FakeTransport):
"""Return active then disabled for the same workflow GET during apply."""

def __init__(self, responses: dict[tuple[str, str], fleet.HttpResponse]) -> None:
"""Store responses and initialize per-path observation counts."""

super().__init__(responses)
self._get_counts: dict[str, int] = {}

def request(self, method: str, path: str) -> fleet.HttpResponse:
"""Return active then disabled state for each workflow confirmation."""

if method == "GET" and path.endswith("/actions/workflows/10"):
count = self._get_counts.get(path, 0)
self._get_counts[path] = count + 1
Expand All @@ -1032,10 +1197,14 @@ class _ConfirmFailsTransport(FakeTransport):
"""Leave workflow 10 active after a successful disable PUT."""

def __init__(self, responses: dict[tuple[str, str], fleet.HttpResponse]) -> None:
"""Store responses and initialize the confirmation read counter."""

super().__init__(responses)
self._gets = 0

def request(self, method: str, path: str) -> fleet.HttpResponse:
"""Keep workflow 10 active so post-disable confirmation fails closed."""

if method == "GET" and path.endswith("/actions/workflows/10"):
self.calls.append((method, path))
self._gets += 1
Expand Down
Loading