spring-runtime-mcp 0.2.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.
Files changed (118) hide show
  1. spring_runtime_mcp/__init__.py +3 -0
  2. spring_runtime_mcp/adapters/__init__.py +1 -0
  3. spring_runtime_mcp/adapters/beans.py +159 -0
  4. spring_runtime_mcp/adapters/conditions.py +333 -0
  5. spring_runtime_mcp/adapters/config.py +295 -0
  6. spring_runtime_mcp/adapters/drift.py +790 -0
  7. spring_runtime_mcp/adapters/health.py +164 -0
  8. spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
  9. spring_runtime_mcp/adapters/mappings.py +294 -0
  10. spring_runtime_mcp/adapters/migrations.py +373 -0
  11. spring_runtime_mcp/adapters/openapi.py +683 -0
  12. spring_runtime_mcp/adapters/source_catalog.py +119 -0
  13. spring_runtime_mcp/adapters/startup.py +202 -0
  14. spring_runtime_mcp/analyzers/__init__.py +1 -0
  15. spring_runtime_mcp/analyzers/bean_graph.py +172 -0
  16. spring_runtime_mcp/analyzers/condition_failures.py +359 -0
  17. spring_runtime_mcp/cli.py +248 -0
  18. spring_runtime_mcp/contracts/__init__.py +1 -0
  19. spring_runtime_mcp/contracts/definitions.py +425 -0
  20. spring_runtime_mcp/diagnostics.py +254 -0
  21. spring_runtime_mcp/models/__init__.py +1 -0
  22. spring_runtime_mcp/models/bean_graph.py +77 -0
  23. spring_runtime_mcp/models/common.py +99 -0
  24. spring_runtime_mcp/models/conditions.py +56 -0
  25. spring_runtime_mcp/models/queries.py +140 -0
  26. spring_runtime_mcp/models/source_catalog.py +35 -0
  27. spring_runtime_mcp/models/timestamps.py +71 -0
  28. spring_runtime_mcp/policy/__init__.py +1 -0
  29. spring_runtime_mcp/policy/configuration.py +32 -0
  30. spring_runtime_mcp/policy/output_guard.py +117 -0
  31. spring_runtime_mcp/policy/sanitization.py +616 -0
  32. spring_runtime_mcp/py.typed +0 -0
  33. spring_runtime_mcp/replay.py +220 -0
  34. spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
  35. spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
  36. spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
  37. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
  38. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
  39. spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
  40. spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
  41. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
  42. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
  43. spring_runtime_mcp/replay_data/conformance/README.md +41 -0
  44. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
  45. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
  46. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
  47. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
  48. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
  49. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
  50. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
  51. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
  52. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
  53. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
  54. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
  55. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
  56. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
  57. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
  58. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
  59. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
  60. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
  61. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
  62. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
  63. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
  64. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
  65. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
  66. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
  67. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
  68. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
  69. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
  70. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
  71. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
  72. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
  73. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
  74. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
  75. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
  76. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
  77. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
  78. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
  79. spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
  80. spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
  81. spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
  82. spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
  83. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
  84. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
  85. spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
  86. spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
  87. spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
  88. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
  89. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
  90. spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
  91. spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
  92. spring_runtime_mcp/schemas/tools-contract.json +1902 -0
  93. spring_runtime_mcp/server.py +322 -0
  94. spring_runtime_mcp/services.py +44 -0
  95. spring_runtime_mcp/snapshots.py +340 -0
  96. spring_runtime_mcp/tools/__init__.py +1 -0
  97. spring_runtime_mcp/tools/analyze_startup.py +79 -0
  98. spring_runtime_mcp/tools/check_migrations.py +99 -0
  99. spring_runtime_mcp/tools/detect_drift.py +323 -0
  100. spring_runtime_mcp/tools/diagnose_health.py +88 -0
  101. spring_runtime_mcp/tools/inspect_beans.py +479 -0
  102. spring_runtime_mcp/tools/inspect_config.py +102 -0
  103. spring_runtime_mcp/tools/registry.py +48 -0
  104. spring_runtime_mcp/tools/search_openapi.py +79 -0
  105. spring_runtime_mcp/tools/support.py +166 -0
  106. spring_runtime_mcp/tools/verify_mappings.py +62 -0
  107. spring_runtime_mcp/transport/__init__.py +1 -0
  108. spring_runtime_mcp/transport/actuator_http.py +459 -0
  109. spring_runtime_mcp/transport/bounded_json.py +144 -0
  110. spring_runtime_mcp/transport/replay_files.py +71 -0
  111. spring_runtime_mcp/transport/response_gate.py +186 -0
  112. spring_runtime_mcp/transport/responses.py +82 -0
  113. spring_runtime_mcp/transport/stdio.py +44 -0
  114. spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
  115. spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
  116. spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
  117. spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  118. spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,616 @@
1
+ """Fail-closed projection and independent recursive output sanitization.
2
+
3
+ This pipeline covers the Bean tool's structured data surface; it does not grant
4
+ permission to expose arbitrary Actuator maps or free runtime prose.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import html
12
+ import json
13
+ import re
14
+ import unicodedata
15
+ from collections.abc import Mapping
16
+ from typing import Any
17
+ from urllib.parse import quote, unquote
18
+
19
+ from ..models.common import NormalizationError, remaining_seconds
20
+ from ..transport.bounded_json import loads_bounded, validate_tree
21
+
22
+ MARKER = "[REDACTED_BY_MCP]"
23
+ OMITTED_TEXT_FIELDS = frozenset(
24
+ {"description", "comments", "stackTrace", "instructions", "system", "role"}
25
+ )
26
+ _TEXT_DESCRIPTOR_NAMES = frozenset(
27
+ {
28
+ "description",
29
+ "comments",
30
+ "stacktrace",
31
+ "instructions",
32
+ "system",
33
+ "role",
34
+ "message",
35
+ "error",
36
+ "exception",
37
+ }
38
+ )
39
+
40
+
41
+ def untrusted_text_descriptor(value: Mapping[str, Any]) -> bool:
42
+ """Do not launder upstream prose through scalar name/value records.
43
+
44
+ Schema property names are not descriptors: their values are Schema objects,
45
+ not these string-valued name/key fields with associated runtime values.
46
+ """
47
+ for field in ("name", "key", "propertyName", "propertyKey"):
48
+ name = value.get(field)
49
+ if isinstance(name, str):
50
+ view = unicodedata.normalize("NFKC", name).casefold()
51
+ view = re.sub(r"[^a-z0-9]", "", view, flags=re.ASCII)
52
+ if view in _TEXT_DESCRIPTOR_NAMES:
53
+ return True
54
+ return False
55
+
56
+
57
+ KEY_RE = re.compile(
58
+ r"password|passwd|pwd|secret|token|apikey|accesskey|authorization|credential|"
59
+ r"cookie|sessionid|privatekey|signingkey|passphrase|keystorepassword|truststorepassword",
60
+ re.IGNORECASE | re.ASCII,
61
+ )
62
+ AUTH_RE = re.compile(
63
+ r"(?<![A-Za-z0-9_])(?:Bearer|Basic)[ \t]+[A-Za-z0-9._~+/=-]+", re.IGNORECASE
64
+ )
65
+ JOSE_RE = re.compile(
66
+ r"(?<![A-Za-z0-9_.-])[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*){2,4}(?![A-Za-z0-9_.-])"
67
+ )
68
+ PEM_RE = re.compile(
69
+ r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY\b",
70
+ re.IGNORECASE | re.ASCII,
71
+ )
72
+ URI_RE = re.compile(
73
+ r"jdbc:[a-z][a-z0-9+.-]*:|(?:https?|rediss?|mongodb(?:\+srv)?|amqps?)://|file:/|jar:",
74
+ re.IGNORECASE,
75
+ )
76
+ ASSIGNMENT_RE = re.compile(r"([A-Za-z][A-Za-z0-9_.-]{0,127})[ \t]*[:=][ \t]*")
77
+ ENCODED_RE = re.compile(
78
+ r"(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16,}={0,2}(?![A-Za-z0-9_+/-])"
79
+ )
80
+ USERINFO_RE = re.compile(r"(?<![A-Za-z0-9])[^\s:@/]{1,128}:[^\s@/]{1,128}@")
81
+ IDENTIFIER_RE = re.compile(r"[A-Za-z0-9_$][A-Za-z0-9_.$#/@:+-]*", re.ASCII)
82
+ # Only fields in this tool's committed output (plus supported scalar descriptors).
83
+ ALLOWED_KEYS = frozenset(
84
+ [
85
+ "schemaVersion",
86
+ "status",
87
+ "source",
88
+ "targetId",
89
+ "snapshotId",
90
+ "observedAt",
91
+ "endpointIds",
92
+ "bootVersion",
93
+ "adapterVersion",
94
+ "policyVersion",
95
+ "data",
96
+ "items",
97
+ "missingBeanAnalysis",
98
+ "totalMatched",
99
+ "truncated",
100
+ "truncationReasons",
101
+ "coverage",
102
+ "state",
103
+ "scope",
104
+ "gaps",
105
+ "warnings",
106
+ "code",
107
+ "recordId",
108
+ "redactions",
109
+ "nextCursor",
110
+ "nextAction",
111
+ "kind",
112
+ "reasonCode",
113
+ "filterSuggestions",
114
+ "parameter",
115
+ "value",
116
+ "errors",
117
+ "endpointId",
118
+ "httpStatus",
119
+ "retryable",
120
+ "message",
121
+ "remediation",
122
+ "recipeId",
123
+ "action",
124
+ "configKeys",
125
+ "steps",
126
+ "requiresApproval",
127
+ "retryAfterMs",
128
+ "id",
129
+ "observationId",
130
+ "contextId",
131
+ "parentContextId",
132
+ "name",
133
+ "type",
134
+ "resource",
135
+ "aliases",
136
+ "producer",
137
+ "configurationClass",
138
+ "methodName",
139
+ "evidenceLevel",
140
+ "dependencies",
141
+ "dependents",
142
+ "fromId",
143
+ "toId",
144
+ "requestedName",
145
+ "targetContextId",
146
+ "targetName",
147
+ "resolution",
148
+ "depth",
149
+ "conditions",
150
+ "conditionType",
151
+ "operator",
152
+ "outcome",
153
+ "childIds",
154
+ "requiredBeanIds",
155
+ "childrenComplete",
156
+ "blockingPaths",
157
+ "origin",
158
+ "propertyKey",
159
+ "key",
160
+ "propertyName",
161
+ "inputs",
162
+ "bindingValue",
163
+ "effectiveValue",
164
+ "overallStatus",
165
+ "activeGroup",
166
+ "componentPath",
167
+ "parentId",
168
+ "details",
169
+ "errorType",
170
+ "remediationCodes",
171
+ "stack",
172
+ "dispatcherName",
173
+ "paths",
174
+ "httpMethods",
175
+ "methodsUnrestricted",
176
+ "controllerClass",
177
+ "handlerMethod",
178
+ "descriptor",
179
+ "handlerKind",
180
+ "matchingComplete",
181
+ "pendingVisibility",
182
+ "engine",
183
+ "migrationBean",
184
+ "identity",
185
+ "script",
186
+ "changeLog",
187
+ "changeSetId",
188
+ "author",
189
+ "version",
190
+ "description",
191
+ "checksum",
192
+ "checksumType",
193
+ "checksumAlgorithm",
194
+ "executionTimeMs",
195
+ "orderExecuted",
196
+ "executedAt",
197
+ "rawState",
198
+ "stateEvidence",
199
+ "attentionRequired",
200
+ "negated",
201
+ "prefix",
202
+ "file",
203
+ "line",
204
+ "column",
205
+ "environmentVariable",
206
+ "activeProfiles",
207
+ "springBootVersion",
208
+ "totalDurationMs",
209
+ "durationBasis",
210
+ "timelineComplete",
211
+ "stepId",
212
+ "parentStepId",
213
+ "startedAt",
214
+ "durationNanos",
215
+ "durationMs",
216
+ "tags",
217
+ "ancestorIds",
218
+ "relationState",
219
+ "bottleneckCode",
220
+ "effectiveValue",
221
+ "valueState",
222
+ "bindingValue",
223
+ "bindingState",
224
+ "configurationBean",
225
+ "sourceKind",
226
+ "effective",
227
+ "openapiVersion",
228
+ "candidateId",
229
+ "parameters",
230
+ "requestBody",
231
+ "responses",
232
+ "schema",
233
+ "expansionComplete",
234
+ "mediaType",
235
+ "in",
236
+ "httpMethod",
237
+ "operationId",
238
+ "schemaName",
239
+ "path",
240
+ "format",
241
+ "nullable",
242
+ "readOnly",
243
+ "writeOnly",
244
+ "minimum",
245
+ "maximum",
246
+ "exclusiveMinimum",
247
+ "exclusiveMaximum",
248
+ "multipleOf",
249
+ "minLength",
250
+ "maxLength",
251
+ "minItems",
252
+ "maxItems",
253
+ "uniqueItems",
254
+ "pattern",
255
+ "required",
256
+ "properties",
257
+ "items",
258
+ "additionalProperties",
259
+ "allOf",
260
+ "oneOf",
261
+ "anyOf",
262
+ "$ref",
263
+ "referenceState",
264
+ "omissionCodes",
265
+ "enum",
266
+ "missingInRuntime",
267
+ "unexpectedInRuntime",
268
+ "profileMismatch",
269
+ "rootCauseInferences",
270
+ "dimension",
271
+ "expected",
272
+ "actual",
273
+ "affectedDependencyIds",
274
+ "confidence",
275
+ "evidenceIds",
276
+ ]
277
+ )
278
+
279
+
280
+ def sensitive_key(key: str) -> bool:
281
+ view = unicodedata.normalize("NFKC", key).casefold()
282
+ view = re.sub(r"[^a-z0-9]", "", view, flags=re.ASCII)
283
+ return KEY_RE.search(view) is not None
284
+
285
+
286
+ class SanitizerPipeline:
287
+ def __init__(self, known_secrets: tuple[str, ...] = ()) -> None:
288
+ variants: set[str] = set()
289
+ for secret in known_secrets:
290
+ if not secret or len(secret.encode("utf-8")) > 8192:
291
+ raise ValueError("INVALID_SECRET_CONFIGURATION")
292
+ variants.update(
293
+ (
294
+ secret,
295
+ quote(secret, safe=""),
296
+ json.dumps(secret)[1:-1],
297
+ base64.b64encode(secret.encode()).decode("ascii"),
298
+ base64.b64encode(secret.encode()).decode("ascii").rstrip("="),
299
+ base64.urlsafe_b64encode(secret.encode())
300
+ .decode("ascii")
301
+ .rstrip("="),
302
+ )
303
+ )
304
+ if len(variants) > 32:
305
+ raise ValueError("SECRET_VARIANT_LIMIT")
306
+ self._secrets = tuple(sorted(variants))
307
+ self._cache_identity = object()
308
+
309
+ @property
310
+ def cache_revision(self) -> tuple[object, bytes]:
311
+ """Opaque cache stamp; never retain credentials in a cached snapshot."""
312
+ return self._cache_identity, hashlib.sha256(
313
+ json.dumps(self._secrets).encode("utf-8")
314
+ ).digest()
315
+
316
+ def high_risk(self, value: str, *, _encoding_depth: int = 0) -> bool:
317
+ remaining_seconds()
318
+ if value == MARKER:
319
+ return False
320
+ if len(value) > 65_536:
321
+ return True
322
+ try:
323
+ if len(value.encode("utf-8")) > 65_536:
324
+ return True
325
+ except UnicodeError:
326
+ return True
327
+ views = [value]
328
+ for _ in range(4):
329
+ decoded = unquote(views[-1])
330
+ if decoded == views[-1]:
331
+ break
332
+ views.append(decoded)
333
+ if re.search(r"%[0-9A-Fa-f]{2}", views[-1]) or re.search(
334
+ r"\\(?:u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|x[0-9A-Fa-f]{2})", value
335
+ ):
336
+ return True
337
+ views += [html.unescape(view) for view in tuple(views)]
338
+ # NFKC also exposes full-width keys, NBSP separators and zero-width
339
+ # formatting used to disguise an otherwise recognizable credential.
340
+ views = list(
341
+ dict.fromkeys(
342
+ views
343
+ + [
344
+ "".join(
345
+ c
346
+ for c in unicodedata.normalize("NFKC", view)
347
+ if unicodedata.category(c) != "Cf"
348
+ )
349
+ for view in views
350
+ ]
351
+ )
352
+ )
353
+ for view in views:
354
+ if any(secret in view for secret in self._secrets):
355
+ return True
356
+ if (
357
+ AUTH_RE.search(view)
358
+ or PEM_RE.search(view)
359
+ or URI_RE.search(view)
360
+ or USERINFO_RE.search(view)
361
+ ):
362
+ return True
363
+ if any(sensitive_key(m.group(1)) for m in ASSIGNMENT_RE.finditer(view)):
364
+ return True
365
+ for candidate in ENCODED_RE.finditer(view):
366
+ text = candidate.group()
367
+ if len(text) > 8192:
368
+ return True
369
+ try:
370
+ raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
371
+ except ValueError:
372
+ continue
373
+ try:
374
+ decoded_text = raw.decode("utf-8")
375
+ except UnicodeError:
376
+ decoded_text = raw.decode("latin-1")
377
+ if _encoding_depth >= 2:
378
+ return True
379
+ if self.high_risk(decoded_text, _encoding_depth=_encoding_depth + 1):
380
+ return True
381
+ for candidate in JOSE_RE.finditer(view):
382
+ parts = candidate.group().split(".")
383
+ if len(parts) not in (3, 5):
384
+ continue
385
+ first = parts[0]
386
+ if len(first) > 4096:
387
+ return True
388
+ try:
389
+ header = loads_bounded(
390
+ base64.urlsafe_b64decode(first + "=" * (-len(first) % 4)),
391
+ max_bytes=4096,
392
+ max_depth=8,
393
+ max_nodes=128,
394
+ )
395
+ except (ValueError, NormalizationError):
396
+ for part in parts:
397
+ try:
398
+ segment_raw = base64.urlsafe_b64decode(
399
+ part + "=" * (-len(part) % 4)
400
+ )
401
+ except ValueError:
402
+ continue
403
+ try:
404
+ segment = segment_raw.decode("utf-8")
405
+ except UnicodeError:
406
+ segment = segment_raw.decode("latin-1")
407
+ if self.high_risk(segment, _encoding_depth=_encoding_depth + 1):
408
+ return True
409
+ continue
410
+ if any(key in header for key in ("alg", "enc", "typ")):
411
+ return True
412
+ return False
413
+
414
+ def identifier(self, value: str | None, *, max_length: int = 256) -> str | None:
415
+ if value is None:
416
+ return None
417
+ if value == MARKER or self.high_risk(value) or sensitive_key(value):
418
+ return MARKER
419
+ if len(value) > max_length or IDENTIFIER_RE.fullmatch(value) is None:
420
+ return MARKER
421
+ return value
422
+
423
+ def resource(self, value: str | None) -> str | None:
424
+ if value is None:
425
+ return None
426
+ if self.high_risk(value):
427
+ return MARKER
428
+ if value == MARKER:
429
+ return MARKER
430
+ match = re.fullmatch(r"class path resource \[([A-Za-z0-9_$/.-]+)\]", value)
431
+ if match is not None:
432
+ relative = match.group(1)
433
+ elif value.startswith("classpath:"):
434
+ relative = value[len("classpath:") :]
435
+ if re.fullmatch(r"[A-Za-z0-9_$/.-]+", relative) is None:
436
+ return None
437
+ else:
438
+ return None
439
+ if (
440
+ relative.startswith("/")
441
+ or ".." in relative.split("/")
442
+ or relative in ("", ".")
443
+ or len(relative) > 240
444
+ ):
445
+ return None
446
+ return self.identifier("classpath:" + relative)
447
+
448
+ def sanitize(self, value: Mapping[str, Any]) -> dict[str, Any]:
449
+ validate_tree(dict(value), max_bytes=32 * 1024 * 1024)
450
+ counter = [0]
451
+
452
+ identifier_fields = frozenset(
453
+ (
454
+ "targetId",
455
+ "snapshotId",
456
+ "observationId",
457
+ "id",
458
+ "contextId",
459
+ "parentContextId",
460
+ "name",
461
+ "type",
462
+ "configurationClass",
463
+ "methodName",
464
+ "requestedName",
465
+ "targetContextId",
466
+ "targetName",
467
+ "conditionType",
468
+ "recordId",
469
+ "propertyKey",
470
+ "propertyName",
471
+ "key",
472
+ )
473
+ )
474
+ identifier_arrays = frozenset(("aliases", "childIds", "requiredBeanIds"))
475
+
476
+ def walk(item: Any, depth: int = 0, embedded: int = 0, field: str = "") -> Any:
477
+ counter[0] += 1
478
+ if depth > 64 or counter[0] > 100_000:
479
+ raise NormalizationError("SANITIZATION_FAILED")
480
+ if isinstance(item, dict):
481
+ descriptor_tainted = any(
482
+ isinstance(item.get(key), str)
483
+ and (
484
+ sensitive_key(item[key]) or self.identifier(item[key]) == MARKER
485
+ )
486
+ for key in ("name", "key", "propertyKey", "propertyName")
487
+ ) or untrusted_text_descriptor(item)
488
+ result: dict[str, Any] = {}
489
+ for key, child in item.items():
490
+ if field == "properties":
491
+ if (
492
+ not isinstance(key, str)
493
+ or not key
494
+ or len(key) > 128
495
+ or any(char.isspace() or ord(char) < 32 for char in key)
496
+ or sensitive_key(key)
497
+ or self.high_risk(key)
498
+ or not isinstance(child, dict)
499
+ ):
500
+ continue
501
+ # Here the key names a business field, and the value is
502
+ # its Schema object. Reset the field context so names
503
+ # such as description/properties cannot change how the
504
+ # Schema's own keywords are sanitized.
505
+ result[key] = walk(child, depth + 1, embedded)
506
+ continue
507
+ elif key not in ALLOWED_KEYS or key == "redactions":
508
+ continue
509
+ # Migration descriptions are required by schema and are
510
+ # never a channel for upstream prose or credentials.
511
+ if key == "description":
512
+ result[key] = None
513
+ continue
514
+ if sensitive_key(key) or (
515
+ descriptor_tainted
516
+ and key
517
+ in (
518
+ "value",
519
+ "origin",
520
+ "inputs",
521
+ "effectiveValue",
522
+ "bindingValue",
523
+ )
524
+ ):
525
+ if "propertyKey" in item and key == "origin":
526
+ result[key] = {
527
+ "kind": "unknown",
528
+ "file": None,
529
+ "line": None,
530
+ "column": None,
531
+ "environmentVariable": None,
532
+ }
533
+ elif "propertyKey" in item and child is None:
534
+ result[key] = None
535
+ else:
536
+ result[key] = MARKER
537
+ else:
538
+ result[key] = walk(child, depth + 1, embedded, key)
539
+ if "propertyKey" in item and descriptor_tainted:
540
+ for value_key, state_key in (
541
+ ("effectiveValue", "valueState"),
542
+ ("bindingValue", "bindingState"),
543
+ ):
544
+ if result.get(value_key) == MARKER:
545
+ result[state_key] = "redacted"
546
+ return result
547
+ if isinstance(item, list):
548
+ return [walk(child, depth + 1, embedded, field) for child in item]
549
+ if isinstance(item, str):
550
+ if item == MARKER:
551
+ return MARKER
552
+ if field == "message":
553
+ from .output_guard import LOCAL_PROSE
554
+
555
+ return item if item in LOCAL_PROSE else MARKER
556
+ if field == "nextAction":
557
+ return MARKER
558
+ if field == "scope":
559
+ from .output_guard import LOCAL_PROSE
560
+
561
+ return item if item in LOCAL_PROSE else self.identifier(item)
562
+ if field in identifier_fields or field in identifier_arrays:
563
+ return self.identifier(item)
564
+ if field == "resource":
565
+ return self.resource(item)
566
+ if field == "origin":
567
+ # Arbitrary file locations or prose are not a public fact.
568
+ return MARKER if self.high_risk(item) else None
569
+ stripped = item.strip()
570
+ if stripped.startswith(("{", "[")):
571
+ if embedded >= 4 or len(item) > 65_536:
572
+ return MARKER
573
+ try:
574
+ nested = loads_bounded(
575
+ ('{"value":' + stripped + "}").encode(),
576
+ max_bytes=65_550,
577
+ max_depth=32,
578
+ max_nodes=10_000,
579
+ )["value"]
580
+ return json.dumps(
581
+ walk(nested, depth + 1, embedded + 1),
582
+ ensure_ascii=False,
583
+ separators=(",", ":"),
584
+ sort_keys=True,
585
+ )
586
+ except (ValueError, NormalizationError):
587
+ return MARKER
588
+ if self.high_risk(item) or any(ord(c) < 32 for c in item):
589
+ return MARKER
590
+ if any(c.isspace() for c in item):
591
+ from .output_guard import LOCAL_PROSE
592
+
593
+ if item not in LOCAL_PROSE:
594
+ return MARKER
595
+ return item
596
+
597
+ result = walk(dict(value))
598
+ if not isinstance(result, dict):
599
+ raise NormalizationError("SANITIZATION_FAILED")
600
+ paths: list[str] = []
601
+
602
+ def markers(item: Any, path: str = "") -> None:
603
+ if item == MARKER and isinstance(item, str):
604
+ if len(paths) < 50:
605
+ paths.append(path)
606
+ elif isinstance(item, dict):
607
+ for key, child in item.items():
608
+ markers(child, path + "/" + key)
609
+ elif isinstance(item, list):
610
+ for index, child in enumerate(item):
611
+ markers(child, path + "/" + str(index))
612
+
613
+ markers(result)
614
+ if "schemaVersion" in result:
615
+ result["redactions"] = sorted(set(paths))
616
+ return result
File without changes