Publish examples and query parameters, and stop the harvest leaking secrets

The spec described 573 operations with a schema each and almost nothing a
reader could copy: 12 operations carried an example, and no operation described
a query parameter at all, because a Flask handler's query string is invisible to
url_map. A route whose real interface is ?code= documented nothing.

Both are now derived from evidence rather than written by hand.

QUERY PARAMETERS come from each view's AST - request.args.get, the subscript
form, getlist, and this repo's own helpers - and a read that does not resolve to
a literal name is not emitted. 0 to 87 operations, 350 parameter objects. The
enumerations some handlers validate (sort=assetnumber|hostname) are deliberately
NOT published: deriving the accepted set reliably needs more than a literal
read, and a wrong enum is exactly the invented contract this work avoids.

EXAMPLES are harvested from the test suite, which already sends real bodies and
asserts real responses, so they are transcripts rather than inventions. 12 to
about 324. The harvest is re-runnable and optional; generation works without it.

THE HARVEST PUBLISHED TWO REAL CREDENTIALS, and that is the important part of
this commit. A bootstrap token and a kiosk setup code reached docs/openapi.json
inside URL values - a file served at /api/docs on production and copied to a
wiki. Neither scrubber layer could see them: the keys are 'scripturl' and 'url',
which name no secret, and the opaque-value rule excludes ':' and '/' on purpose
so that example URLs survive. Nothing was committed.

Four changes came out of it:

- a URL-credential rule that scrubs the parameter VALUE and keeps the URL, so
  ?t= is redacted and ?page=2&perpage=50 still reads as an example
- redaction runs on the way OUT as well as on the way in. Harvested examples
  live in the inventory and a refresh preserves them, so a scrubber fixed after
  a harvest protects only future harvests while the stored copy keeps
  publishing. The published file can no longer carry a secret the current rules
  would catch, whatever the inventory holds
- the leak gate now holds its own patterns instead of importing the generator's.
  Checked with the scrubber's own rules it could only ever assert that redact()
  ran, and it was green while both tokens sat in the file. It now knows the
  product's token prefixes, a URL-credential shape, and a crude entropy
  backstop. Replanting the exact credential that shipped fails it
- a DELETE only declares a body when the HANDLER reads one, derived from its
  AST. A transcript proves the client sent something: the suite posts {} to
  routes that ignore it, and that published "this route reads a JSON body" on
  DELETE /api/endpoints/agentkey, whose handler never calls get_json.

249 operations still carry no example, because no test exercises them with a
JSON body. The honest fix is a test, not a hand-written example, and the next
harvest picks each one up for free.
This commit is contained in:
cproudlock
2026-09-18 20:18:06 -04:00
parent f16b463736
commit 5aa748cab5
6 changed files with 30953 additions and 5301 deletions

View File

@@ -189,3 +189,58 @@ def test_summaries_are_not_cut_mid_word(spec):
if body and not body[-1].isalnum() and body[-1] not in ')]"\'':
offenders.append('%s %s: %r' % (verb.upper(), path, summary[-40:]))
assert not offenders, '\n '.join(offenders[:15])
def test_every_parameter_is_well_formed(spec):
"""A parameter with no name, no location or no schema is not a parameter.
Query parameters are derived from each handler's AST now rather than left
out, which is 87 operations that could not be filtered, searched or paged
from this document before. Derived means a code change can produce a
malformed one, so the shape is checked here rather than assumed.
"""
offenders = []
for path, verb, operation in operations(spec):
for parameter in operation.get('parameters', []):
where = '%s %s: %r' % (verb.upper(), path, parameter.get('name'))
if not parameter.get('name'):
offenders.append('%s %s: a parameter with no name' % (verb.upper(), path))
if parameter.get('in') not in ('path', 'query', 'header', 'cookie'):
offenders.append(where + ' has no valid `in`')
if not isinstance(parameter.get('required'), bool):
offenders.append(where + ' does not say whether it is required')
if not parameter.get('schema'):
offenders.append(where + ' has no schema')
if parameter.get('in') == 'path' and not parameter.get('required'):
offenders.append(where + ' is in the path and optional, which '
'OpenAPI does not allow')
assert not offenders, '\n '.join(offenders[:15])
def test_no_parameter_is_declared_twice(spec):
"""(name, in) is the identity of a parameter. Two entries for one name is
ambiguous to a generated client, which picks one and drops the other."""
offenders = []
for path, verb, operation in operations(spec):
seen = set()
for parameter in operation.get('parameters', []):
key = (parameter.get('name'), parameter.get('in'))
if key in seen:
offenders.append('%s %s: %s in %s'
% (verb.upper(), path, key[0], key[1]))
seen.add(key)
assert not offenders, '\n '.join(offenders[:15])
def test_a_query_parameter_is_not_the_path_argument_under_another_name(spec):
"""`/api/assets/{assetid}?assetid=` is a contradiction: the same value in
two places, and a generated client has to guess which one the route reads."""
offenders = []
for path, verb, operation in operations(spec):
intemplate = set(re.findall(r'\{([^}]+)\}', path))
query = {p['name'] for p in operation.get('parameters', [])
if p.get('in') == 'query'}
clash = sorted(intemplate & query)
if clash:
offenders.append('%s %s: %s' % (verb.upper(), path, clash))
assert not offenders, '\n '.join(offenders[:15])