Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions gittensor/synapses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
from typing import Optional

import bittensor as bt
from pydantic import Field


def _mask_pat(token: str) -> str:
"""Mask a GitHub PAT for display, keeping the last 4 chars for log correlation."""
if not token:
return '***'
return f'***{token[-4:]}' if len(token) >= 4 else '***'


class PatBroadcastSynapse(bt.Synapse):
Expand All @@ -10,15 +18,26 @@ class PatBroadcastSynapse(bt.Synapse):
The miner sets github_access_token on the request. The validator validates the PAT
(checks it works, extracts GitHub ID, verifies account age, runs a test query)
and responds with accepted/rejection_reason.

The PAT is excluded from repr/str output to prevent log leaks. The wire
format (model_dump_json) is unchanged — the token still transmits to validators.
"""

# Miner request
github_access_token: str
# Miner request — repr=False so logging the synapse never leaks the PAT
github_access_token: str = Field(repr=False)

# Validator response
accepted: Optional[bool] = None
rejection_reason: Optional[str] = None

def __repr__(self) -> str:
return (
f'PatBroadcastSynapse(github_access_token={_mask_pat(self.github_access_token)}, '
f'accepted={self.accepted!r}, rejection_reason={self.rejection_reason!r})'
)

__str__ = __repr__


class PatCheckSynapse(bt.Synapse):
"""Probe for miners to check if a validator has their PAT stored and valid.
Expand Down
89 changes: 89 additions & 0 deletions tests/test_synapses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# The MIT License (MIT)
# Copyright © 2025 Entrius

"""Tests for synapse PAT redaction in repr/str (issue #850)."""

import pytest

from gittensor.synapses import PatBroadcastSynapse


SAMPLE_PAT = 'ghp_SUPERSECRET12345'


# ==========================================================================
# TestPatRedaction
# ==========================================================================


class TestPatRedaction:
"""The raw PAT must never appear in repr/str output."""

def test_repr_does_not_contain_raw_token(self):
syn = PatBroadcastSynapse(github_access_token=SAMPLE_PAT)
assert SAMPLE_PAT not in repr(syn)

def test_str_does_not_contain_raw_token(self):
syn = PatBroadcastSynapse(github_access_token=SAMPLE_PAT)
assert SAMPLE_PAT not in str(syn)

def test_repr_keeps_last_four_chars(self):
"""Operators correlate masked log lines with rotated tokens via the last 4 chars."""
syn = PatBroadcastSynapse(github_access_token=SAMPLE_PAT)
assert '***2345' in repr(syn)

def test_repr_includes_other_fields(self):
syn = PatBroadcastSynapse(
github_access_token=SAMPLE_PAT,
accepted=True,
rejection_reason='something',
)
r = repr(syn)
assert 'accepted=True' in r
assert "rejection_reason='something'" in r


# ==========================================================================
# TestEdgeCases
# ==========================================================================


class TestEdgeCases:
"""Short/empty tokens must not crash repr."""

def test_short_token_fully_masked(self):
"""Tokens shorter than 4 chars are fully masked — no IndexError, no leak."""
syn = PatBroadcastSynapse(github_access_token='abc')
r = repr(syn)
assert 'abc' not in r
assert '***' in r

def test_empty_token_does_not_crash(self):
syn = PatBroadcastSynapse(github_access_token='')
r = repr(syn)
assert '***' in r

def test_exactly_four_char_token_masked(self):
"""Boundary case: exactly 4 chars — currently shown as last 4."""
syn = PatBroadcastSynapse(github_access_token='abcd')
r = repr(syn)
assert '***abcd' in r


# ==========================================================================
# TestWireFormatUnchanged
# ==========================================================================


class TestWireFormatUnchanged:
"""The wire format must still carry the full token — only repr/str redact."""

def test_model_dump_json_includes_full_token(self):
syn = PatBroadcastSynapse(github_access_token=SAMPLE_PAT)
payload = syn.model_dump_json()
assert SAMPLE_PAT in payload

def test_attribute_access_returns_full_token(self):
"""Code that needs the token (validator handlers) still gets it via attribute access."""
syn = PatBroadcastSynapse(github_access_token=SAMPLE_PAT)
assert syn.github_access_token == SAMPLE_PAT
Loading