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.
- spring_runtime_mcp/__init__.py +3 -0
- spring_runtime_mcp/adapters/__init__.py +1 -0
- spring_runtime_mcp/adapters/beans.py +159 -0
- spring_runtime_mcp/adapters/conditions.py +333 -0
- spring_runtime_mcp/adapters/config.py +295 -0
- spring_runtime_mcp/adapters/drift.py +790 -0
- spring_runtime_mcp/adapters/health.py +164 -0
- spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
- spring_runtime_mcp/adapters/mappings.py +294 -0
- spring_runtime_mcp/adapters/migrations.py +373 -0
- spring_runtime_mcp/adapters/openapi.py +683 -0
- spring_runtime_mcp/adapters/source_catalog.py +119 -0
- spring_runtime_mcp/adapters/startup.py +202 -0
- spring_runtime_mcp/analyzers/__init__.py +1 -0
- spring_runtime_mcp/analyzers/bean_graph.py +172 -0
- spring_runtime_mcp/analyzers/condition_failures.py +359 -0
- spring_runtime_mcp/cli.py +248 -0
- spring_runtime_mcp/contracts/__init__.py +1 -0
- spring_runtime_mcp/contracts/definitions.py +425 -0
- spring_runtime_mcp/diagnostics.py +254 -0
- spring_runtime_mcp/models/__init__.py +1 -0
- spring_runtime_mcp/models/bean_graph.py +77 -0
- spring_runtime_mcp/models/common.py +99 -0
- spring_runtime_mcp/models/conditions.py +56 -0
- spring_runtime_mcp/models/queries.py +140 -0
- spring_runtime_mcp/models/source_catalog.py +35 -0
- spring_runtime_mcp/models/timestamps.py +71 -0
- spring_runtime_mcp/policy/__init__.py +1 -0
- spring_runtime_mcp/policy/configuration.py +32 -0
- spring_runtime_mcp/policy/output_guard.py +117 -0
- spring_runtime_mcp/policy/sanitization.py +616 -0
- spring_runtime_mcp/py.typed +0 -0
- spring_runtime_mcp/replay.py +220 -0
- spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
- spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
- spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/conformance/README.md +41 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
- spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
- spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
- spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
- spring_runtime_mcp/schemas/tools-contract.json +1902 -0
- spring_runtime_mcp/server.py +322 -0
- spring_runtime_mcp/services.py +44 -0
- spring_runtime_mcp/snapshots.py +340 -0
- spring_runtime_mcp/tools/__init__.py +1 -0
- spring_runtime_mcp/tools/analyze_startup.py +79 -0
- spring_runtime_mcp/tools/check_migrations.py +99 -0
- spring_runtime_mcp/tools/detect_drift.py +323 -0
- spring_runtime_mcp/tools/diagnose_health.py +88 -0
- spring_runtime_mcp/tools/inspect_beans.py +479 -0
- spring_runtime_mcp/tools/inspect_config.py +102 -0
- spring_runtime_mcp/tools/registry.py +48 -0
- spring_runtime_mcp/tools/search_openapi.py +79 -0
- spring_runtime_mcp/tools/support.py +166 -0
- spring_runtime_mcp/tools/verify_mappings.py +62 -0
- spring_runtime_mcp/transport/__init__.py +1 -0
- spring_runtime_mcp/transport/actuator_http.py +459 -0
- spring_runtime_mcp/transport/bounded_json.py +144 -0
- spring_runtime_mcp/transport/replay_files.py +71 -0
- spring_runtime_mcp/transport/response_gate.py +186 -0
- spring_runtime_mcp/transport/responses.py +82 -0
- spring_runtime_mcp/transport/stdio.py +44 -0
- spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
- spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
- spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
- spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|