main
py 192 lines 6.03 KB
Raw
1 #!/usr/bin/env python3
2 """Sanitize user-controlled repository content before LLM prompt rendering."""
3
4 from __future__ import annotations
5
6 import argparse
7 import json
8 import logging
9 from collections.abc import Mapping, Sequence
10 from pathlib import Path
11 from typing import Any
12
13 LOGGER = logging.getLogger(__name__)
14
15 MAX_DESCRIPTION_LENGTH = 500
16 SUSPICIOUS_DESCRIPTION_LENGTH = 200
17 BOUNDARY_OPEN = "<untrusted-content>"
18 BOUNDARY_CLOSE = "</untrusted-content>"
19 BOUNDARY_CLOSE_ESCAPED = "[boundary-close-removed]"
20 BOUNDARY_OPEN_ESCAPED = "[boundary-open-removed]"
21 INJECTION_PHRASES = (
22 "ignore previous",
23 "ignore all previous",
24 "ignore the above",
25 "ignore instructions",
26 "ignore restrictions",
27 "disregard",
28 "you are now",
29 "you are a",
30 "pretend to be",
31 "act as if",
32 "roleplay",
33 "new instructions",
34 "system:",
35 "system prompt",
36 "user:",
37 "assistant:",
38 "</untrusted-content>",
39 "<untrusted-content>",
40 "do not follow",
41 "override",
42 BOUNDARY_CLOSE,
43 )
44
45
46 def _repo_label(repo: Mapping[str, Any] | None) -> str:
47 if not repo:
48 return "unknown repo"
49 for key in ("full_name", "name", "url"):
50 value = repo.get(key)
51 if isinstance(value, str) and value.strip():
52 return value.strip()
53 return "unknown repo"
54
55
56 def _truncate(value: str, max_length: int) -> str:
57 if len(value) <= max_length:
58 return value
59 return value[: max_length - 1].rstrip() + ""
60
61
62 def _escape_untrusted_boundaries(value: str) -> str:
63 result = value.replace(BOUNDARY_CLOSE, BOUNDARY_CLOSE_ESCAPED)
64 result = result.replace(BOUNDARY_OPEN, BOUNDARY_OPEN_ESCAPED)
65 return result
66
67
68 def sanitize_text(
69 text: Any,
70 *,
71 max_length: int = MAX_DESCRIPTION_LENGTH,
72 label: str = "text",
73 ) -> str:
74 """Sanitize arbitrary untrusted text for safe prompt injection.
75
76 Unlike sanitize_description (which targets repo description fields), this
77 function works on any free-form text (article titles, topic descriptions,
78 scorecard summaries, etc.).
79 """
80 if max_length <= 0:
81 max_length = MAX_DESCRIPTION_LENGTH
82 if text is None:
83 return ""
84 if not isinstance(text, str):
85 text = str(text)
86 stripped = text.lstrip()
87 has_boundary = BOUNDARY_CLOSE in stripped or BOUNDARY_OPEN in stripped
88 sanitized = _escape_untrusted_boundaries(stripped)
89 lowered = sanitized.lower()
90 suspicious_matches = [phrase for phrase in INJECTION_PHRASES if phrase in lowered]
91
92 is_suspicious = bool(suspicious_matches) or has_boundary
93 limit = min(SUSPICIOUS_DESCRIPTION_LENGTH, max_length) if is_suspicious else max_length
94 truncated = _truncate(sanitized, limit)
95
96 if suspicious_matches:
97 LOGGER.warning(
98 "Suspicious %s contained possible prompt-injection phrase(s): %s",
99 label,
100 ", ".join(suspicious_matches),
101 )
102 if has_boundary:
103 LOGGER.warning(
104 "Suspicious %s contained [boundary marker(s)] that were escaped",
105 label,
106 )
107 return truncated
108
109
110 def sanitize_description(
111 description: Any,
112 *,
113 repo: Mapping[str, Any] | None = None,
114 max_length: int = MAX_DESCRIPTION_LENGTH,
115 suspicious_length: int = SUSPICIOUS_DESCRIPTION_LENGTH,
116 ) -> Any:
117 """Return a prompt-safe repository description while preserving normal text."""
118 if description is None or not isinstance(description, str):
119 return description
120
121 original = description
122 sanitized = _escape_untrusted_boundaries(description.lstrip())
123 lowered = sanitized.lower()
124 suspicious_matches = [phrase for phrase in INJECTION_PHRASES if phrase in lowered]
125
126 if original != sanitized:
127 LOGGER.warning(
128 "Sanitized leading whitespace or boundary marker in description for %s",
129 _repo_label(repo),
130 )
131
132 limit = min(suspicious_length, max_length) if suspicious_matches else max_length
133 truncated = _truncate(sanitized, limit)
134
135 if suspicious_matches:
136 LOGGER.warning(
137 "Suspicious repo description for %s contained possible prompt-injection phrase(s): %s",
138 _repo_label(repo),
139 ", ".join(suspicious_matches),
140 )
141 if truncated != sanitized:
142 LOGGER.warning(
143 "Truncated repo description for %s to %d characters", _repo_label(repo), limit
144 )
145
146 return truncated
147
148
149 def sanitize_repo_payload(payload: Any) -> Any:
150 """Recursively sanitize `description` fields in a raw crawl payload."""
151 if isinstance(payload, Mapping):
152 result: dict[str, Any] = {}
153 for key, value in payload.items():
154 if key == "description":
155 result[key] = sanitize_description(value, repo=payload)
156 else:
157 result[key] = sanitize_repo_payload(value)
158 return result
159 if isinstance(payload, list):
160 return [sanitize_repo_payload(item) for item in payload]
161 if isinstance(payload, tuple):
162 return tuple(sanitize_repo_payload(item) for item in payload)
163 return payload
164
165
166 def sanitize_json_file(input_path: Path, output_path: Path | None = None) -> Path:
167 payload = json.loads(input_path.read_text(encoding="utf-8"))
168 sanitized = sanitize_repo_payload(payload)
169 destination = output_path or input_path
170 destination.parent.mkdir(parents=True, exist_ok=True)
171 destination.write_text(
172 json.dumps(sanitized, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
173 )
174 return destination
175
176
177 def main(argv: Sequence[str] | None = None) -> int:
178 parser = argparse.ArgumentParser(
179 description="Sanitize repo descriptions in a raw crawl JSON payload."
180 )
181 parser.add_argument("--input", required=True, type=Path, help="Raw JSON payload to sanitize")
182 parser.add_argument(
183 "--output", type=Path, help="Destination path; defaults to modifying input in place"
184 )
185 args = parser.parse_args(argv)
186 logging.basicConfig(level=logging.WARNING, format="%(levelname)s:%(name)s:%(message)s")
187 sanitize_json_file(args.input, args.output)
188 return 0
189
190
191 if __name__ == "__main__":
192 raise SystemExit(main())