Skip to content

Commit 4487814

Browse files
authored
feat: add safe gate privacy checks
Adds minimal deterministic privacy and secret boundary checks to safe_pr_gate, including risky path detection, text-only marker checks, binary/deleted file skipping, and focused tests.
1 parent 413b6f4 commit 4487814

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

scripts/safe_pr_gate.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414
if str(REPO_ROOT) not in sys.path:
1515
sys.path.insert(0, str(REPO_ROOT))
1616

17+
RISKY_PATH_NAMES = frozenset({".env", "id_ed25519", "id_rsa"})
18+
RISKY_PATH_SUFFIXES = (".key", ".pem")
19+
PRIVATE_MARKERS = (
20+
"BEGIN PRIVATE KEY",
21+
"GITHUB_TOKEN=",
22+
"OPENAI_API_KEY=",
23+
"GEMINI_API_KEY=",
24+
)
25+
1726

1827
@dataclass(frozen=True, slots=True)
1928
class GateState:
@@ -101,6 +110,51 @@ def _path_in_prefix(path: str, prefix: str) -> bool:
101110
return path == normalized or path.startswith(normalized + "/")
102111

103112

113+
def _repo_relative_path(path: str) -> Path | None:
114+
candidate = (REPO_ROOT / path).resolve()
115+
try:
116+
candidate.relative_to(REPO_ROOT)
117+
except ValueError:
118+
return None
119+
return candidate
120+
121+
122+
def _is_risky_path(path: str) -> bool:
123+
name = Path(path).name
124+
return name in RISKY_PATH_NAMES or name.endswith(RISKY_PATH_SUFFIXES)
125+
126+
127+
def _read_changed_text(path: str) -> str | None:
128+
candidate = _repo_relative_path(path)
129+
if candidate is None or not candidate.is_file():
130+
return None
131+
try:
132+
data = candidate.read_bytes()
133+
except OSError:
134+
return None
135+
if b"\0" in data:
136+
return None
137+
try:
138+
return data.decode("utf-8")
139+
except UnicodeDecodeError:
140+
return None
141+
142+
143+
def _privacy_problems(changed_paths: tuple[str, ...]) -> tuple[str, ...]:
144+
problems: list[str] = []
145+
for path in sorted(changed_paths):
146+
if _is_risky_path(path):
147+
problems.append(f"privacy_risky_path:{path}")
148+
149+
text = _read_changed_text(path)
150+
if text is None:
151+
continue
152+
for marker in PRIVATE_MARKERS:
153+
if marker in text:
154+
problems.append(f"privacy_marker:{marker}:{path}")
155+
return tuple(problems)
156+
157+
104158
def evaluate_gate(
105159
state: GateState,
106160
*,
@@ -125,6 +179,8 @@ def evaluate_gate(
125179
problems.append("changed_files_outside_allowed_prefixes")
126180
problems.extend(f"outside_prefix:{path}" for path in disallowed_paths)
127181

182+
problems.extend(_privacy_problems(state.changed_paths))
183+
128184
return GateResult(
129185
ok=not problems,
130186
branch=state.branch,

tests/test_safe_pr_gate.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,55 @@ def test_evaluate_gate_flags_paths_outside_allowed_prefixes() -> None:
9393
assert result.problems == ("changed_files_outside_allowed_prefixes", "outside_prefix:docs/example.md")
9494

9595

96+
def test_evaluate_gate_flags_risky_privacy_paths_in_stable_order() -> None:
97+
result = evaluate_gate(
98+
GateState(
99+
branch="feat/safe-pr-gate",
100+
status_short=(),
101+
changed_paths=(
102+
"secrets/id_rsa",
103+
"config/.env",
104+
"keys/service.key",
105+
"certs/client.pem",
106+
),
107+
)
108+
)
109+
110+
assert result.ok is False
111+
assert result.problems == (
112+
"privacy_risky_path:certs/client.pem",
113+
"privacy_risky_path:config/.env",
114+
"privacy_risky_path:keys/service.key",
115+
"privacy_risky_path:secrets/id_rsa",
116+
)
117+
118+
119+
def test_evaluate_gate_flags_private_markers_in_changed_text_files(
120+
monkeypatch: pytest.MonkeyPatch,
121+
tmp_path: Path,
122+
) -> None:
123+
text_path = tmp_path / "docs" / "example.md"
124+
binary_path = tmp_path / "docs" / "binary.bin"
125+
text_path.parent.mkdir()
126+
text_path.write_text("GITHUB_TOKEN=example\nOPENAI_API_KEY=example\n", encoding="utf-8")
127+
binary_path.write_bytes(b"\0OPENAI_API_KEY=example")
128+
monkeypatch.setattr(safe_pr_gate, "REPO_ROOT", tmp_path)
129+
130+
result = safe_pr_gate.evaluate_gate(
131+
GateState(
132+
branch="feat/safe-pr-gate",
133+
status_short=(),
134+
changed_paths=("docs/example.md", "docs/binary.bin"),
135+
)
136+
)
137+
138+
assert result.ok is False
139+
assert result.problems == (
140+
"privacy_marker:GITHUB_TOKEN=:docs/example.md",
141+
"privacy_marker:OPENAI_API_KEY=:docs/example.md",
142+
)
143+
144+
96145
def test_parse_porcelain_paths_handles_rename_status_in_second_position() -> None:
97146
assert _parse_porcelain_paths(" R old-name.txt\0new-name.txt\0") == ("new-name.txt",)
98147

0 commit comments

Comments
 (0)