parse-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parse_sdk/__init__.py +57 -0
- parse_sdk/_labels.py +51 -0
- parse_sdk/_oauth.py +301 -0
- parse_sdk/_project.py +184 -0
- parse_sdk/_runtime.py +2142 -0
- parse_sdk/_sanitize.py +15 -0
- parse_sdk/_sync.py +887 -0
- parse_sdk/checks.py +601 -0
- parse_sdk/cli.py +1667 -0
- parse_sdk/cli_help.py +110 -0
- parse_sdk/codegen_reconcile.py +157 -0
- parse_sdk/codegen_v2.py +3361 -0
- parse_sdk/config.py +349 -0
- parse_sdk/docgen.py +587 -0
- parse_sdk/doctor.py +348 -0
- parse_sdk/migrate.py +212 -0
- parse_sdk/preview.py +588 -0
- parse_sdk/py.typed +0 -0
- parse_sdk/resource_surface.py +196 -0
- parse_sdk/sample_gate.py +245 -0
- parse_sdk/scaffold.py +273 -0
- parse_sdk-0.1.0.dist-info/METADATA +177 -0
- parse_sdk-0.1.0.dist-info/RECORD +26 -0
- parse_sdk-0.1.0.dist-info/WHEEL +4 -0
- parse_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- parse_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
parse_sdk/checks.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""Staged-artifact promotion gates.
|
|
2
|
+
|
|
3
|
+
`parse sync` builds the next `parse_apis` tree in a staging directory and must
|
|
4
|
+
prove three invariants before it overwrites the user's live tree, because a bad
|
|
5
|
+
promotion is hard to undo and ships straight into the user's repo:
|
|
6
|
+
|
|
7
|
+
1. ``scan_secrets`` — no vendor key / JWT / high-entropy blob leaked into a
|
|
8
|
+
generated file (the agent writes free-text docstrings + examples, so a
|
|
9
|
+
leaked credential lands in prose, not a quoted literal).
|
|
10
|
+
2. ``check_generated_client_imports`` — generated code imports ONLY the
|
|
11
|
+
runtime (``parse_sdk._runtime`` / bare ``from parse_sdk import ...``), never
|
|
12
|
+
``parse_sdk.config`` (resolution is internal to ``_runtime.BaseAPI``) and
|
|
13
|
+
never a generated client from ``parse_sdk.<slug>`` (the deleted v1
|
|
14
|
+
dynamic-model vestige, C-1).
|
|
15
|
+
3. ``check_docs_import_model`` — generated prose teaches the installed-package
|
|
16
|
+
model (``from parse_apis.<slug> import`` + "installed editable package"),
|
|
17
|
+
not the stale flat on-disk model (C-4).
|
|
18
|
+
|
|
19
|
+
Each gate returns a flat ``list[tuple[Path, str]]`` of findings; an empty list
|
|
20
|
+
means the tree passed. The caller (sync promotion) treats any non-empty list as
|
|
21
|
+
a hard block. Functions are pure: they read the tree and return findings, they
|
|
22
|
+
never mutate it.
|
|
23
|
+
|
|
24
|
+
The secret detectors here are the SINGLE SOURCE of the pattern families —
|
|
25
|
+
``tests/_secret_patterns.py`` re-exports them (the shipped package must not
|
|
26
|
+
depend on its own test tree, so tests → package is the only legal direction;
|
|
27
|
+
the prior hand-mirrored copy drifted once and the name-only parity test
|
|
28
|
+
could not see regex-body drift). The families are DERIVED from
|
|
29
|
+
``parse_core.security.code_sanitizer._SUSPICIOUS_PATTERNS`` and reshaped for
|
|
30
|
+
generated-output scanning; family coverage against that backend upstream is
|
|
31
|
+
pinned by ``test_no_secrets_codegen.py::test_covers_known_secret_families``.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import ast
|
|
36
|
+
import re
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import List, Optional, Tuple
|
|
40
|
+
|
|
41
|
+
# --- secret-shape detectors ------------------------------------------------
|
|
42
|
+
#
|
|
43
|
+
# SINGLE SOURCE (tests/_secret_patterns.py re-exports these). PREFIXED are
|
|
44
|
+
# re.search'd over the whole file text — de-anchored so a token in docstring
|
|
45
|
+
# PROSE still matches; ENTROPY are re.fullmatch'd against string-literal
|
|
46
|
+
# VALUES only so CamelCase forward-refs like 'PostCommentsCollection' on
|
|
47
|
+
# every generated module do not false-positive.
|
|
48
|
+
|
|
49
|
+
_PREFIXED_SIGNATURES: List[Tuple[str, "re.Pattern[str]"]] = [
|
|
50
|
+
("jwt", re.compile(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")),
|
|
51
|
+
("bearer_token", re.compile(r"Bearer\s+[A-Za-z0-9._\-+/=]{20,}")),
|
|
52
|
+
("vendor_key", re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}")),
|
|
53
|
+
("github_pat", re.compile(r"ghp_[A-Za-z0-9]{30,}")),
|
|
54
|
+
("github_oauth", re.compile(r"gho_[A-Za-z0-9]{30,}")),
|
|
55
|
+
("aws_access_key", re.compile(r"AKIA[A-Z0-9]{12,}")),
|
|
56
|
+
("slack_token", re.compile(r"xox[bpsa]-[A-Za-z0-9\-]{10,}")),
|
|
57
|
+
("credential_assignment", re.compile(r'(?:password|passwd|secret|token|api_key)\s*=\s*["\']([^"\']{6,})["\']', re.IGNORECASE)),
|
|
58
|
+
("session_cookie", re.compile(r'(?:cookie|session_id|csrf|xsrf)\s*[:=]\s*["\']([^"\']{10,})["\']', re.IGNORECASE)),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Placeholder shapes a credential-assignment VALUE may take BY CONSTRUCTION:
|
|
62
|
+
# an angle-bracket template (`<paste-token-here>`) or an UPPERCASE_UNDERSCORE
|
|
63
|
+
# name (`YOUR_API_KEY`). Letters + underscores only, at least one underscore —
|
|
64
|
+
# NO digit class: admitting digits would exempt real key alphabets (AWS access
|
|
65
|
+
# keys, base32 TOTP seeds, uppercase-alnum vendor keys), and on `.md` surfaces
|
|
66
|
+
# this family is the only net (the entropy scan is `.py`-gated).
|
|
67
|
+
_PLACEHOLDER_VALUE_RE = re.compile(r"^(?:<[^<>]{1,40}>|[A-Z][A-Z_]*_[A-Z_]{1,38})$")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_placeholder_value(value: str) -> bool:
|
|
71
|
+
"""Whether a ``credential_assignment`` VALUE is placeholder-shaped —
|
|
72
|
+
``api_key="YOUR_API_KEY"`` teaches, it doesn't leak. Applies ONLY to that
|
|
73
|
+
family's value position; every other family scans unconditionally."""
|
|
74
|
+
return _PLACEHOLDER_VALUE_RE.fullmatch(value) is not None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_ENTROPY_PATTERNS: List[Tuple[str, "re.Pattern[str]"]] = [
|
|
78
|
+
("long_base64", re.compile(r"[A-Za-z0-9+/=]{30,}")),
|
|
79
|
+
("long_hex", re.compile(r"[a-fA-F0-9]{32,}")),
|
|
80
|
+
("short_base64", re.compile(r"[A-Za-z0-9+/=]{20,29}")),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _iter_text_files(tree: Path, *, recursive: bool = True):
|
|
85
|
+
"""Yield ``(path, text)`` for every readable UTF-8 file under ``tree``.
|
|
86
|
+
|
|
87
|
+
Binary / non-UTF-8 files (a stray ``.png``, a corrupt artifact) are SKIPPED
|
|
88
|
+
rather than crashing the gate: a promotion check must survive whatever the
|
|
89
|
+
codegen wrote, and a non-text file cannot carry a text-shaped secret or a
|
|
90
|
+
Python/Markdown import we care about. Directories and unreadable entries are
|
|
91
|
+
skipped for the same reason.
|
|
92
|
+
|
|
93
|
+
``recursive=False`` scans only the entries directly in ``tree`` (not its
|
|
94
|
+
subdirectories) — used by the sync engine's whole-tree pre-promotion pass,
|
|
95
|
+
which gates only the top-level artifacts because each slug dir was already
|
|
96
|
+
gated individually (see ``_sync.stage_check_swap`` step 4).
|
|
97
|
+
"""
|
|
98
|
+
entries = tree.rglob("*") if recursive else tree.iterdir()
|
|
99
|
+
for path in sorted(entries):
|
|
100
|
+
if not path.is_file():
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
yield path, path.read_text(encoding="utf-8")
|
|
104
|
+
except (UnicodeDecodeError, OSError):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Structural codegen literal sinks, split by HOW MUCH of the node is
|
|
109
|
+
# name-position by construction. An alphanumeric-only schema name of 20+ chars
|
|
110
|
+
# full-matches the entropy band (the same false-positive class that once
|
|
111
|
+
# blocked every module's sync on the re-exported 'PaginationLimitError', and
|
|
112
|
+
# again on long camelCase `_FIELD_ALIASES` values) — but only NAME positions
|
|
113
|
+
# may be exempted: a request dict's VALUES are agent/user data (a pin literal,
|
|
114
|
+
# `_params = {"api_key": "<blob>"}` in an agent-authored example) and must
|
|
115
|
+
# stay scanned.
|
|
116
|
+
_STRUCTURAL_SUBSCRIPT_TARGETS = frozenset({
|
|
117
|
+
"_params", "_p", # request dicts: only the SUBSCRIPT KEY is a wire name
|
|
118
|
+
})
|
|
119
|
+
_STRUCTURAL_MAP_TARGETS = frozenset({
|
|
120
|
+
"_CARRY_PARAMS", # error carry map: {attr ident: wire param}
|
|
121
|
+
"_FIELD_ALIASES", # resource alias map: {field ident: wire field}
|
|
122
|
+
"_KEY_FIELD", # resource key field ident (plain string)
|
|
123
|
+
"_ERROR_CLASSES", # {wire kind: class}
|
|
124
|
+
"_RESOURCE_CLASSES", # {label: class}
|
|
125
|
+
})
|
|
126
|
+
# Receiver roots of the codegen-emitted `.get(...)` / `in` shapes whose first
|
|
127
|
+
# arg / left operand is a response-path or field NAME (`_resp.get(...)`,
|
|
128
|
+
# `items[-1].get(...)`, `_last._FIELD_ALIASES.get(...)`, `'<items_path>' not
|
|
129
|
+
# in _resp`). Constrained to these roots so `anything.get('<blob>')` in an
|
|
130
|
+
# agent-authored example stays scanned; the all-name-slots invariant test
|
|
131
|
+
# proves the set covers every emission shape.
|
|
132
|
+
_STRUCTURAL_RECEIVER_ROOTS = frozenset({"_resp", "items", "_last", "_coerced"})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_emitted_str_enum(node: "ast.ClassDef") -> bool:
|
|
136
|
+
"""Whether a class statement is codegen's exact enum emission shape —
|
|
137
|
+
``class X(str, enum.Enum)``. Both bases are required: the closed shape
|
|
138
|
+
keeps a hand-written ``class X(Enum)`` in an agent-authored example
|
|
139
|
+
outside the exemption."""
|
|
140
|
+
has_str = any(isinstance(b, ast.Name) and b.id == "str" for b in node.bases)
|
|
141
|
+
has_enum = any(
|
|
142
|
+
isinstance(b, ast.Attribute) and b.attr == "Enum"
|
|
143
|
+
and isinstance(b.value, ast.Name) and b.value.id == "enum"
|
|
144
|
+
for b in node.bases
|
|
145
|
+
)
|
|
146
|
+
return has_str and has_enum
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _expr_root_name(expr: ast.expr) -> Optional[str]:
|
|
150
|
+
"""The root ``Name`` of an attribute/subscript/call chain (``items[-1]`` →
|
|
151
|
+
``items``; ``_last._FIELD_ALIASES`` → ``_last``), or None."""
|
|
152
|
+
node = expr
|
|
153
|
+
while isinstance(node, (ast.Attribute, ast.Subscript, ast.Call)):
|
|
154
|
+
node = node.func if isinstance(node, ast.Call) else node.value
|
|
155
|
+
return node.id if isinstance(node, ast.Name) else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_structural_receiver(expr: ast.expr) -> bool:
|
|
159
|
+
"""The two receiver shapes codegen emits as the 1st positional arg of an
|
|
160
|
+
items-extraction call: a bare structural receiver (``_resp``), or the
|
|
161
|
+
``_walk(_resp, '<parent>')`` wrapper of a DOTTED ``items_path`` (whose own
|
|
162
|
+
receiver is structural). The wrapper case lets the entropy exemption reach
|
|
163
|
+
the LEAF arg of ``_extract_items(_walk(_resp, '<parent>'), '<leaf>')``.
|
|
164
|
+
A CLOSED set — codegen never nests ``_walk`` deeper, so this does NOT
|
|
165
|
+
recurse: a genuinely new shape stays scanned (fails closed) rather than
|
|
166
|
+
being silently exempt."""
|
|
167
|
+
if isinstance(expr, ast.Name):
|
|
168
|
+
return expr.id in _STRUCTURAL_RECEIVER_ROOTS
|
|
169
|
+
return (
|
|
170
|
+
isinstance(expr, ast.Call)
|
|
171
|
+
and isinstance(expr.func, ast.Name)
|
|
172
|
+
and expr.func.id == "_walk"
|
|
173
|
+
and len(expr.args) >= 1
|
|
174
|
+
and isinstance(expr.args[0], ast.Name)
|
|
175
|
+
and expr.args[0].id in _STRUCTURAL_RECEIVER_ROOTS
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class _ModuleFacts:
|
|
181
|
+
"""Everything the secret/import gates need from a parsed module, collected
|
|
182
|
+
in ONE ``ast.walk``. ``_emitted_names`` / ``_structural_literal_values`` /
|
|
183
|
+
``_string_literal_values`` and the import gate were four independent walks
|
|
184
|
+
over the same tree (the dominant cost of a fleet sync's gate pass); this
|
|
185
|
+
fuses them. Each field carries exactly what the corresponding old walk
|
|
186
|
+
produced — moved verbatim, not "simplified" — so the gate verdicts are
|
|
187
|
+
byte-identical (proven on the corpus). ``literals`` is UNFILTERED and in
|
|
188
|
+
``ast.walk`` order (first-entropy-hit-per-family depends on that order);
|
|
189
|
+
``import_modules`` is in walk order so import findings keep their sequence.
|
|
190
|
+
"""
|
|
191
|
+
names: set[str] # identifiers BOUND in the module
|
|
192
|
+
structural: set[str] # schema-derived codegen-emission literals
|
|
193
|
+
literals: List[Tuple[str, int]] # (value, lineno) per str Constant, unfiltered
|
|
194
|
+
import_modules: List[str] # absolute dotted import names (level==0)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _module_facts(module: ast.AST) -> _ModuleFacts:
|
|
198
|
+
"""Single-pass collector — see :class:`_ModuleFacts`. One ``ast.walk``
|
|
199
|
+
produces four fact sets used by the staged-promotion gates: bound
|
|
200
|
+
identifiers (``names``), schema-derived structural literals (``structural``),
|
|
201
|
+
every string literal in walk order (``literals``), and absolute import
|
|
202
|
+
module names (``import_modules``).
|
|
203
|
+
|
|
204
|
+
``names`` and ``structural`` are exempted from the ENTROPY secret families
|
|
205
|
+
ONLY (prefixed signatures still search raw text), and only NAME positions are
|
|
206
|
+
exempt. ``names`` covers a generated module's bound identifiers — class/func
|
|
207
|
+
names AND re-exported runtime errors (``PaginationLimitError``'s 20-char
|
|
208
|
+
spelling would otherwise full-match the entropy band and block every sync).
|
|
209
|
+
``structural`` covers the schema NAMES quoted in codegen's closed emission
|
|
210
|
+
shapes: request subscript keys, ``_STRUCTURAL_MAP_TARGETS`` / ``_DISPLAYS_*``
|
|
211
|
+
map entries, ``str``-enum member values, ``Literal[...]`` codes, and the
|
|
212
|
+
``_request`` / ``.get`` / membership-test name slots. A pin's literal VALUE,
|
|
213
|
+
request-dict values, and param defaults all stay scanned. The closed
|
|
214
|
+
shape-set is pinned from both sides in ``tests/test_staging_checks.py``
|
|
215
|
+
(all-name-slots invariant + recall fixtures); ``test_no_secrets_codegen.py``
|
|
216
|
+
drives the same path via ``_string_literal_values`` so the gate is
|
|
217
|
+
single-source.
|
|
218
|
+
|
|
219
|
+
The nested ``ast.walk`` calls in the structural block walk a SUBTREE (an
|
|
220
|
+
assigned value / a ``Literal[...]`` slice), not the module, so they are not
|
|
221
|
+
extra module passes."""
|
|
222
|
+
names: set[str] = set()
|
|
223
|
+
structural: set[str] = set()
|
|
224
|
+
literals: List[Tuple[str, int]] = []
|
|
225
|
+
import_modules: List[str] = []
|
|
226
|
+
for node in ast.walk(module):
|
|
227
|
+
# 1) bound identifiers: class/func names, assign targets, imports
|
|
228
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
229
|
+
names.add(node.name)
|
|
230
|
+
elif isinstance(node, ast.Assign):
|
|
231
|
+
for target in node.targets:
|
|
232
|
+
if isinstance(target, ast.Name):
|
|
233
|
+
names.add(target.id)
|
|
234
|
+
elif isinstance(node, ast.AnnAssign):
|
|
235
|
+
if isinstance(node.target, ast.Name):
|
|
236
|
+
names.add(node.target.id)
|
|
237
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
238
|
+
for alias in node.names:
|
|
239
|
+
names.add(alias.asname or alias.name)
|
|
240
|
+
|
|
241
|
+
# 2) schema-derived structural literals (closed codegen emission shapes)
|
|
242
|
+
targets: List[ast.expr] = []
|
|
243
|
+
if isinstance(node, ast.Assign):
|
|
244
|
+
targets = list(node.targets)
|
|
245
|
+
elif isinstance(node, ast.AnnAssign):
|
|
246
|
+
targets = [node.target]
|
|
247
|
+
for target in targets:
|
|
248
|
+
if (
|
|
249
|
+
isinstance(target, ast.Subscript)
|
|
250
|
+
and isinstance(target.value, ast.Name)
|
|
251
|
+
and target.value.id in _STRUCTURAL_SUBSCRIPT_TARGETS
|
|
252
|
+
and isinstance(target.slice, ast.Constant)
|
|
253
|
+
and isinstance(target.slice.value, str)
|
|
254
|
+
):
|
|
255
|
+
structural.add(target.slice.value)
|
|
256
|
+
elif (
|
|
257
|
+
isinstance(target, ast.Name)
|
|
258
|
+
and (target.id in _STRUCTURAL_MAP_TARGETS
|
|
259
|
+
or target.id.startswith("_DISPLAYS_"))
|
|
260
|
+
and getattr(node, "value", None) is not None
|
|
261
|
+
):
|
|
262
|
+
for sub in ast.walk(node.value): # type: ignore[union-attr]
|
|
263
|
+
if isinstance(sub, ast.Constant) and isinstance(sub.value, str):
|
|
264
|
+
structural.add(sub.value)
|
|
265
|
+
if isinstance(node, ast.ClassDef) and _is_emitted_str_enum(node):
|
|
266
|
+
for stmt in node.body:
|
|
267
|
+
if (
|
|
268
|
+
isinstance(stmt, ast.Assign)
|
|
269
|
+
and isinstance(stmt.value, ast.Constant)
|
|
270
|
+
and isinstance(stmt.value.value, str)
|
|
271
|
+
):
|
|
272
|
+
structural.add(stmt.value.value)
|
|
273
|
+
if (
|
|
274
|
+
isinstance(node, ast.Subscript)
|
|
275
|
+
and isinstance(node.value, ast.Name)
|
|
276
|
+
and node.value.id == "Literal"
|
|
277
|
+
):
|
|
278
|
+
for sub in ast.walk(node.slice):
|
|
279
|
+
if isinstance(sub, ast.Constant) and isinstance(sub.value, str):
|
|
280
|
+
structural.add(sub.value)
|
|
281
|
+
# The items-extraction helper ``_extract_items(_resp, '<items_path>')``
|
|
282
|
+
# and the dotted-path resolver ``_walk(_resp, '<a.b>')`` — bare-Name calls
|
|
283
|
+
# (not attribute calls). The 2nd positional arg is a declared wire path /
|
|
284
|
+
# NAME by construction (the same wire name the legacy
|
|
285
|
+
# ``_resp.get('<items_path>')`` / ``'<items_path>' not in _resp`` shapes
|
|
286
|
+
# already exempt). The 1st arg is a structural receiver: ``_resp``, OR —
|
|
287
|
+
# for a DOTTED items_path — the ``_walk(_resp, '<parent>')`` wrapper of
|
|
288
|
+
# ``_extract_items(_walk(_resp, '<parent>'), '<leaf>')``, so the LEAF wire
|
|
289
|
+
# name is exempt too. Exempt the wire-name arg from the entropy families.
|
|
290
|
+
if (
|
|
291
|
+
isinstance(node, ast.Call)
|
|
292
|
+
and isinstance(node.func, ast.Name)
|
|
293
|
+
and node.func.id in ("_extract_items", "_walk")
|
|
294
|
+
and len(node.args) >= 2
|
|
295
|
+
and _is_structural_receiver(node.args[0])
|
|
296
|
+
):
|
|
297
|
+
second = node.args[1]
|
|
298
|
+
if isinstance(second, ast.Constant) and isinstance(second.value, str):
|
|
299
|
+
structural.add(second.value)
|
|
300
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
301
|
+
if node.func.attr == "_request":
|
|
302
|
+
for arg in node.args[:2]:
|
|
303
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
304
|
+
structural.add(arg.value)
|
|
305
|
+
elif (
|
|
306
|
+
node.func.attr == "get"
|
|
307
|
+
and node.args
|
|
308
|
+
and _expr_root_name(node.func.value) in _STRUCTURAL_RECEIVER_ROOTS
|
|
309
|
+
):
|
|
310
|
+
first = node.args[0]
|
|
311
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
312
|
+
structural.add(first.value)
|
|
313
|
+
if isinstance(node, ast.Compare):
|
|
314
|
+
if (
|
|
315
|
+
isinstance(node.left, ast.Constant)
|
|
316
|
+
and isinstance(node.left.value, str)
|
|
317
|
+
and any(isinstance(op, (ast.In, ast.NotIn)) for op in node.ops)
|
|
318
|
+
and len(node.comparators) == 1
|
|
319
|
+
and _expr_root_name(node.comparators[0]) in _STRUCTURAL_RECEIVER_ROOTS
|
|
320
|
+
):
|
|
321
|
+
structural.add(node.left.value)
|
|
322
|
+
|
|
323
|
+
# 3) every string literal, unfiltered + in walk order (was the final
|
|
324
|
+
# loop in _string_literal_values; the caller filters by excluded set)
|
|
325
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
326
|
+
literals.append((node.value, node.lineno))
|
|
327
|
+
|
|
328
|
+
# 4) absolute import module names (was check_generated_client_imports'
|
|
329
|
+
# walk); relative imports (level>0) and module-less forms are skipped,
|
|
330
|
+
# exactly as the gate does, so the caller's allow-check is unchanged.
|
|
331
|
+
if isinstance(node, ast.Import):
|
|
332
|
+
for alias in node.names:
|
|
333
|
+
import_modules.append(alias.name)
|
|
334
|
+
elif isinstance(node, ast.ImportFrom):
|
|
335
|
+
if not node.level and node.module is not None:
|
|
336
|
+
import_modules.append(node.module)
|
|
337
|
+
|
|
338
|
+
return _ModuleFacts(names, structural, literals, import_modules)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _string_literal_values(
|
|
342
|
+
text: str, *, structural_exempt: bool = True
|
|
343
|
+
) -> List[Tuple[str, int]]:
|
|
344
|
+
"""Extract entropy-eligible ``(value, lineno)`` string-literal pairs from
|
|
345
|
+
Python source.
|
|
346
|
+
|
|
347
|
+
Entropy families would false-positive on long identifiers (class names,
|
|
348
|
+
dotted module paths) if matched over raw text, so we restrict them to actual
|
|
349
|
+
string-literal values via ``ast`` AND drop any value that equals an
|
|
350
|
+
identifier defined in this module (a forward-ref annotation / ``__all__``
|
|
351
|
+
entry — the documented CamelCase false-positive class) or — when
|
|
352
|
+
``structural_exempt`` is on — a schema-derived structural literal from the
|
|
353
|
+
known codegen sinks (the ``structural`` set computed by :func:`_module_facts`).
|
|
354
|
+
|
|
355
|
+
``structural_exempt`` is the provenance gate: the structural shapes are
|
|
356
|
+
things CODEGEN emits, so only the file class codegen emits (the generated
|
|
357
|
+
client module) gets their exemption — an agent-authored ``example.py``
|
|
358
|
+
re-creating one of those shapes (a ``Literal['<blob>']`` annotation, a
|
|
359
|
+
``_DISPLAYS_*`` map) stays fully scanned. Exclusion is by value equality
|
|
360
|
+
over the file: inside an emitted module a literal equal to an emitted
|
|
361
|
+
name/code is that name by construction (e.g. a closed param's default is
|
|
362
|
+
one of its codes).
|
|
363
|
+
|
|
364
|
+
Non-parseable text yields no literals — the prefixed group still scans the
|
|
365
|
+
raw text, so prefixed secrets in a syntax-broken file are not missed. The
|
|
366
|
+
lineno rides along so a finding can point at the offending line.
|
|
367
|
+
|
|
368
|
+
Parses once and reads :func:`_module_facts` (one walk) for the names,
|
|
369
|
+
structural literals, and unfiltered literal list; the filter (drop any value
|
|
370
|
+
equal to a bound name, plus structural literals when ``structural_exempt``)
|
|
371
|
+
is applied here. ``scan_tree`` does not route through this function — it
|
|
372
|
+
holds the parsed module already and reads ``_module_facts`` directly — so a
|
|
373
|
+
staged ``.py`` file is parsed exactly once per scan. This standalone form
|
|
374
|
+
exists for direct test consumers (``tests/test_no_secrets_codegen.py``).
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
module = ast.parse(text)
|
|
378
|
+
except (SyntaxError, ValueError):
|
|
379
|
+
return []
|
|
380
|
+
facts = _module_facts(module)
|
|
381
|
+
excluded = facts.names | (facts.structural if structural_exempt else set())
|
|
382
|
+
return [(v, ln) for v, ln in facts.literals if v not in excluded]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _mask(value: str) -> str:
|
|
386
|
+
"""A finding's value hint: first 4 chars + length — never more of the
|
|
387
|
+
value (the gate's whole point is that it might be a real secret)."""
|
|
388
|
+
return f"'{value[:4]}…', {len(value)} chars"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@dataclass
|
|
392
|
+
class TreeFindings:
|
|
393
|
+
"""All gate findings from ONE pass over a staged tree — read each file once,
|
|
394
|
+
parse each ``.py`` once, run every check off the cached text/AST. The three
|
|
395
|
+
public gates are thin projections of this; ``_sync``'s promotion gate reads
|
|
396
|
+
all four lists. ``syntax_errors`` is engine-internal (the public
|
|
397
|
+
``scan_secrets`` never compiled) — only the sync gate consumes it, replacing
|
|
398
|
+
its old separate ``rglob('__init__.py')`` compile loop."""
|
|
399
|
+
secrets: List[Tuple[Path, str, int, str]]
|
|
400
|
+
forbidden_imports: List[Tuple[Path, str]]
|
|
401
|
+
stale_docs: List[Tuple[Path, str]]
|
|
402
|
+
syntax_errors: List[Tuple[Path, str]]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def scan_tree(tree: Path, *, recursive: bool = True) -> TreeFindings:
|
|
406
|
+
"""Single-pass promotion scanner: read each file once and run every gate.
|
|
407
|
+
|
|
408
|
+
Per file: the prefixed secret signatures over raw text; for ``.py`` files
|
|
409
|
+
one ``ast.parse`` feeding :func:`_module_facts` (entropy families + the
|
|
410
|
+
generated-client import allow-check); a ``compile`` syntax gate on each
|
|
411
|
+
``__init__.py``; the stale-doc needle scan on doc files. Findings are
|
|
412
|
+
appended in ``_iter_text_files`` (sorted-path) order, so each list matches
|
|
413
|
+
exactly what the old per-gate functions produced — verdicts are unchanged
|
|
414
|
+
(pinned by the equivalence test in ``tests/test_staging_checks.py``). This
|
|
415
|
+
replaces three independent ``_iter_text_files`` walks + up to three parses
|
|
416
|
+
per ``.py`` with one read and one parse per file."""
|
|
417
|
+
secrets: List[Tuple[Path, str, int, str]] = []
|
|
418
|
+
forbidden_imports: List[Tuple[Path, str]] = []
|
|
419
|
+
stale_docs: List[Tuple[Path, str]] = []
|
|
420
|
+
syntax_errors: List[Tuple[Path, str]] = []
|
|
421
|
+
for path, text in _iter_text_files(tree, recursive=recursive):
|
|
422
|
+
seen: set[str] = set()
|
|
423
|
+
# Prefixed signatures — raw text (catches tokens in docstring prose).
|
|
424
|
+
for family, pattern in _PREFIXED_SIGNATURES:
|
|
425
|
+
if family in seen:
|
|
426
|
+
continue
|
|
427
|
+
if family == "credential_assignment":
|
|
428
|
+
# Placeholder carve-out: flag only when some matched VALUE is
|
|
429
|
+
# not placeholder-shaped (group 1 is the quoted value).
|
|
430
|
+
m = next((m for m in pattern.finditer(text)
|
|
431
|
+
if not is_placeholder_value(m.group(1))), None)
|
|
432
|
+
else:
|
|
433
|
+
m = pattern.search(text)
|
|
434
|
+
if m is not None:
|
|
435
|
+
lineno = text.count("\n", 0, m.start()) + 1
|
|
436
|
+
# Mask the captured VALUE when the family has one
|
|
437
|
+
# (credential_assignment/session_cookie); else the whole match.
|
|
438
|
+
masked_src = m.group(1) if m.groups() else m.group(0)
|
|
439
|
+
secrets.append((path, family, lineno, _mask(masked_src)))
|
|
440
|
+
seen.add(family)
|
|
441
|
+
if path.suffix == ".py":
|
|
442
|
+
try:
|
|
443
|
+
module: Optional[ast.AST] = ast.parse(text)
|
|
444
|
+
except (SyntaxError, ValueError):
|
|
445
|
+
module = None
|
|
446
|
+
if module is not None:
|
|
447
|
+
facts = _module_facts(module)
|
|
448
|
+
# Entropy families — string-literal values only. The structural
|
|
449
|
+
# (emission-shape) exemption applies ONLY to the generated
|
|
450
|
+
# client module (codegen emits exactly ``__init__.py``); an
|
|
451
|
+
# agent-authored example re-creating an emission shape stays
|
|
452
|
+
# scanned.
|
|
453
|
+
excluded = facts.names | (
|
|
454
|
+
facts.structural if path.name == "__init__.py" else set())
|
|
455
|
+
literals = [(v, ln) for v, ln in facts.literals if v not in excluded]
|
|
456
|
+
for family, pattern in _ENTROPY_PATTERNS:
|
|
457
|
+
if family in seen:
|
|
458
|
+
continue
|
|
459
|
+
hit = next(((v, ln) for v, ln in literals
|
|
460
|
+
if pattern.fullmatch(v)), None)
|
|
461
|
+
if hit is not None:
|
|
462
|
+
value, lineno = hit
|
|
463
|
+
secrets.append((path, family, lineno, _mask(value)))
|
|
464
|
+
seen.add(family)
|
|
465
|
+
# Generated-client import allow-check (every .py, incl.
|
|
466
|
+
# example.py — a forbidden import there must not slip through).
|
|
467
|
+
for mod in facts.import_modules:
|
|
468
|
+
offending = _is_offending_parse_sdk_module(mod)
|
|
469
|
+
if offending is not None:
|
|
470
|
+
forbidden_imports.append((path, offending))
|
|
471
|
+
# Syntax gate: compile() catches post-AST errors ast.parse accepts
|
|
472
|
+
# (return/yield outside a function, duplicate args); run on every
|
|
473
|
+
# __init__.py even when ast.parse above failed.
|
|
474
|
+
if path.name == "__init__.py":
|
|
475
|
+
try:
|
|
476
|
+
compile(module if module is not None else text, str(path), "exec")
|
|
477
|
+
except SyntaxError as e:
|
|
478
|
+
syntax_errors.append((path, str(e)))
|
|
479
|
+
if _is_doc_file(path):
|
|
480
|
+
for needle in _STALE_DOC_NEEDLES:
|
|
481
|
+
if needle in text:
|
|
482
|
+
stale_docs.append((path, needle))
|
|
483
|
+
return TreeFindings(secrets, forbidden_imports, stale_docs, syntax_errors)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def scan_secrets(tree: Path, *, recursive: bool = True) -> List[Tuple[Path, str, int, str]]:
|
|
487
|
+
"""Flag secret-shaped content in every text file under ``tree``.
|
|
488
|
+
|
|
489
|
+
Returns ``(path, family, lineno, masked)`` per finding — ``masked`` shows
|
|
490
|
+
the first 4 chars and the length, never more of the value, so a finding is
|
|
491
|
+
actionable (which literal, on which line) without re-leaking what the gate
|
|
492
|
+
just caught. Prefixed signatures are searched over the whole file text
|
|
493
|
+
(catching tokens that land in docstring prose, where they are not their
|
|
494
|
+
own quoted literal). Entropy families are full-matched against Python
|
|
495
|
+
string-literal values only, so generated CamelCase forward-refs
|
|
496
|
+
(``PostCommentsCollection``) never trip them.
|
|
497
|
+
|
|
498
|
+
A file with multiple distinct families flagged yields one finding per
|
|
499
|
+
family; the same family is reported once per file, pointing at its first
|
|
500
|
+
occurrence (the caller blocks on any finding, so per-occurrence noise adds
|
|
501
|
+
nothing). A thin projection of :func:`scan_tree`."""
|
|
502
|
+
return scan_tree(tree, recursive=recursive).secrets
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# --- generated-client import gate (C-1) ------------------------------------
|
|
506
|
+
|
|
507
|
+
# A generated module may import the runtime surface and re-export from the
|
|
508
|
+
# package root, nothing else under ``parse_sdk``. The allow-list is exactly
|
|
509
|
+
# ``parse_sdk._runtime``: credential resolution is an INTERNAL detail
|
|
510
|
+
# of ``_runtime.BaseAPI`` (which delegates to ``parse_sdk.config``), so generated
|
|
511
|
+
# code never imports ``parse_sdk.config`` directly and the gate does not whitelist
|
|
512
|
+
# it — keeping ``config`` (which exposes ``save_credentials``) off generated
|
|
513
|
+
# code's import surface. A bare ``from parse_sdk import X`` pulls package-level
|
|
514
|
+
# names (``__version__``, errors) and is allowed. Anything else —
|
|
515
|
+
# ``parse_sdk.config``, ``parse_sdk.<slug>``, ``parse_sdk.codegen_v2``, etc. — is
|
|
516
|
+
# flagged.
|
|
517
|
+
_ALLOWED_PARSE_SDK_SUBMODULES = frozenset({"_runtime"})
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _is_offending_parse_sdk_module(module: str) -> str | None:
|
|
521
|
+
"""Return the offending dotted module if ``module`` is a banned parse_sdk import.
|
|
522
|
+
|
|
523
|
+
``module`` is a fully-qualified module name (``parse_sdk``,
|
|
524
|
+
``parse_sdk._runtime``, ``parse_sdk.reddit``, …). Bare ``parse_sdk`` and the
|
|
525
|
+
runtime submodules are allowed; any other ``parse_sdk.<x>`` is offending and
|
|
526
|
+
returned verbatim so the caller can surface exactly what to fix.
|
|
527
|
+
|
|
528
|
+
A slug literally named like a runtime module (e.g. a ``parse_sdk._runtime``
|
|
529
|
+
import) is allowed by membership — the allow-set is checked on the FIRST
|
|
530
|
+
path segment after ``parse_sdk``, so deeper paths like
|
|
531
|
+
``parse_sdk._runtime.foo`` are still allowed (they resolve into the runtime).
|
|
532
|
+
"""
|
|
533
|
+
if module == "parse_sdk":
|
|
534
|
+
return None
|
|
535
|
+
if not module.startswith("parse_sdk."):
|
|
536
|
+
return None
|
|
537
|
+
first_segment = module[len("parse_sdk."):].split(".", 1)[0]
|
|
538
|
+
if first_segment in _ALLOWED_PARSE_SDK_SUBMODULES:
|
|
539
|
+
return None
|
|
540
|
+
return module
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def check_generated_client_imports(tree: Path, *, recursive: bool = True) -> List[Tuple[Path, str]]:
|
|
544
|
+
"""Flag generated ``.py`` files that import a non-runtime ``parse_sdk`` module.
|
|
545
|
+
|
|
546
|
+
Uses ``ast`` (not regex) so aliased (``import parse_sdk.reddit as r``),
|
|
547
|
+
parenthesized, multiline, and whitespace-padded imports are all normalized
|
|
548
|
+
to their dotted module name before the allow-check. Relative imports
|
|
549
|
+
(``from . import x``) are ignored — they are package-internal and cannot
|
|
550
|
+
name ``parse_sdk.<slug>``. Returns ``(path, offending_module)``.
|
|
551
|
+
|
|
552
|
+
A file that fails to parse is skipped (a broken generated module is a codegen
|
|
553
|
+
bug surfaced elsewhere; this gate concerns only well-formed import shapes).
|
|
554
|
+
|
|
555
|
+
A thin projection of :func:`scan_tree` (the import allow-check runs off the
|
|
556
|
+
same single per-file parse as the secret scan)."""
|
|
557
|
+
return scan_tree(tree, recursive=recursive).forbidden_imports
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# --- docs import-model gate (C-4) ------------------------------------------
|
|
561
|
+
|
|
562
|
+
# Stale import-model prose to flag in generated docs (.md / .py). Each needle is
|
|
563
|
+
# a removed/old teaching: the deleted dynamic-model client import
|
|
564
|
+
# (``parse_sdk.<slug>``, ``parse.api(``) or the flat on-disk model
|
|
565
|
+
# (run-from-root + "no import magic" + project-local-package prose). The
|
|
566
|
+
# current, correct teaching is the installed editable package, which contains
|
|
567
|
+
# none of these needles, so it passes cleanly.
|
|
568
|
+
_STALE_DOC_NEEDLES: Tuple[str, ...] = (
|
|
569
|
+
"parse_sdk.", # generated-client import from parse_sdk.<slug>
|
|
570
|
+
"parse.api(", # deleted dynamic-model call spelling
|
|
571
|
+
"no import magic", # flat on-disk model prose
|
|
572
|
+
"from the project root", # "Run scripts from the project root" prose
|
|
573
|
+
"project-local package", # flat on-disk path prose
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _is_doc_file(path: Path) -> bool:
|
|
578
|
+
"""A human/agent-facing DOC: any ``.md`` (README + AGENTS/CLAUDE index) or the
|
|
579
|
+
generated ``example.py``. The generated client module (``__init__.py``)
|
|
580
|
+
is NOT a doc — it legitimately imports ``parse_sdk._runtime`` (substring
|
|
581
|
+
``parse_sdk.``), so scanning it here would false-positive on every module;
|
|
582
|
+
its imports are gated by :func:`check_generated_client_imports` instead.
|
|
583
|
+
"""
|
|
584
|
+
return path.suffix == ".md" or path.name == "example.py"
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def check_docs_import_model(tree: Path, *, recursive: bool = True) -> List[Tuple[Path, str]]:
|
|
588
|
+
"""Flag generated docs that teach a removed/stale SDK import model.
|
|
589
|
+
|
|
590
|
+
Scans doc files (``.md`` + ``example.py``) for any stale-needle substring and
|
|
591
|
+
returns ``(path, needle)`` per hit. The installed-package model
|
|
592
|
+
(``from parse_apis.<slug> import`` + "installed editable package") contains
|
|
593
|
+
none of the needles, so a correct doc returns no findings.
|
|
594
|
+
|
|
595
|
+
Substring matching is intentional — prose can wrap or reflow, but the stale
|
|
596
|
+
teachings ("no import magic", "from the project root") are stable phrases,
|
|
597
|
+
and ``parse_sdk.`` / ``parse.api(`` are exact code spellings that must never
|
|
598
|
+
reappear in shipped docs.
|
|
599
|
+
|
|
600
|
+
A thin projection of :func:`scan_tree`."""
|
|
601
|
+
return scan_tree(tree, recursive=recursive).stale_docs
|