@reclaimprotocol/agent 0.1.5 → 0.1.7
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.
- package/lib/authoring/backend.d.ts +0 -24
- package/lib/authoring/find.d.ts +3 -0
- package/lib/authoring/find.js +54 -0
- package/lib/authoring/index.d.ts +3 -3
- package/lib/authoring/index.js +4 -3
- package/lib/authoring/propose.d.ts +3 -0
- package/lib/authoring/propose.js +109 -0
- package/lib/authoring/replay.d.ts +10 -0
- package/lib/authoring/replay.js +34 -0
- package/lib/authoring/tools.d.ts +13 -6
- package/lib/authoring/tools.js +108 -293
- package/lib/authoring/util.d.ts +27 -0
- package/lib/authoring/util.js +21 -0
- package/lib/consts.d.ts +6 -1
- package/lib/consts.js +9 -2
- package/lib/mcp/server.d.ts +2 -41
- package/lib/mcp/server.js +6 -34
- package/lib/mcp/start.js +4 -2
- package/lib/mcp/tools/agent/analyze.js +3 -3
- package/lib/mcp/tools/agent/build.d.ts +4 -4
- package/lib/mcp/tools/agent/build.js +13 -15
- package/lib/mcp/tools/agent/capture.d.ts +13 -1
- package/lib/mcp/tools/agent/capture.js +18 -85
- package/lib/mcp/tools/agent/eval.d.ts +5 -2
- package/lib/mcp/tools/agent/eval.js +11 -121
- package/lib/mcp/tools/agent/find.d.ts +10 -3
- package/lib/mcp/tools/agent/find.js +25 -31
- package/lib/mcp/tools/agent/proof.d.ts +13 -3
- package/lib/mcp/tools/agent/proof.js +81 -132
- package/lib/mcp/tools/agent/propose.d.ts +10 -9
- package/lib/mcp/tools/agent/propose.js +87 -64
- package/lib/mcp/tools/agent/replay.d.ts +10 -3
- package/lib/mcp/tools/agent/replay.js +29 -36
- package/lib/mcp/tools/agent/session.d.ts +1 -3
- package/lib/mcp/tools/agent/session.js +5 -5
- package/lib/mcp/tools/agent/shared.d.ts +5 -3
- package/lib/mcp/tools/agent/shared.js +29 -19
- package/lib/old/client.d.ts +39 -0
- package/lib/old/client.js +18 -0
- package/lib/old/login-cdp.d.ts +24 -5
- package/lib/old/login-cdp.js +29 -15
- package/lib/old/sdk-client.d.ts +39 -0
- package/lib/old/sdk-client.js +85 -0
- package/lib/old/to-register.d.ts +69 -0
- package/lib/old/to-register.js +55 -2
- package/lib/old/tools/authenticate.js +3 -7
- package/lib/old/tools/build.d.ts +6 -1
- package/lib/old/tools/build.js +11 -1
- package/lib/old/tools/get-provider-info.d.ts +16 -0
- package/lib/old/tools/get-provider-info.js +73 -0
- package/lib/old/tools/get-version-config.d.ts +17 -0
- package/lib/old/tools/get-version-config.js +47 -0
- package/lib/old/tools/publish.js +208 -2
- package/lib/old/tools/update-provider-metadata.d.ts +16 -0
- package/lib/old/tools/update-provider-metadata.js +61 -0
- package/lib/proof/attestor.d.ts +6 -3
- package/lib/proof/attestor.js +10 -1
- package/lib/proof/debug-response-dump.d.ts +4 -0
- package/lib/proof/debug-response-dump.js +55 -0
- package/lib/proof/patch-domhandler.d.ts +1 -0
- package/lib/proof/patch-domhandler.js +59 -0
- package/lib/proof/zk-files.js +7 -6
- package/lib/provider/dom-xpath.d.ts +17 -0
- package/lib/provider/dom-xpath.js +187 -0
- package/lib/provider/draft.d.ts +30 -2
- package/lib/provider/draft.js +117 -28
- package/lib/provider/index.d.ts +4 -0
- package/lib/provider/index.js +7 -0
- package/lib/provider/injection-doc.js +6 -0
- package/lib/provider/matcher.d.ts +17 -20
- package/lib/provider/matcher.js +394 -280
- package/lib/provider/schema.d.ts +7 -5
- package/lib/tools/registry.d.ts +14 -0
- package/lib/tools/registry.js +32 -0
- package/package.json +9 -1
- package/skill/SKILL.builder.md +42 -23
- package/skill/SKILL.old.md +6 -2
- package/skill/builder-mode.md +1 -1
- package/skill/injection-api.md +117 -0
- package/src/authoring/backend.ts +98 -0
- package/src/authoring/index.ts +20 -0
- package/src/authoring/tools.ts +273 -0
- package/src/authoring/util.ts +42 -0
- package/src/consts.ts +15 -2
- package/src/mcp/server.ts +11 -78
- package/src/mcp/start.ts +4 -1
- package/src/mcp/tools/agent/analyze.ts +3 -3
- package/src/mcp/tools/agent/build.ts +13 -21
- package/src/mcp/tools/agent/capture.ts +31 -115
- package/src/mcp/tools/agent/eval.ts +19 -155
- package/src/mcp/tools/agent/find.ts +33 -36
- package/src/mcp/tools/agent/proof.ts +87 -160
- package/src/mcp/tools/agent/propose.ts +93 -85
- package/src/mcp/tools/agent/replay.ts +32 -42
- package/src/mcp/tools/agent/session.ts +4 -5
- package/src/mcp/tools/agent/shared.ts +152 -0
- package/src/old/client.ts +60 -0
- package/src/old/login-cdp.ts +29 -23
- package/src/old/sdk-client.ts +105 -0
- package/src/old/to-register.ts +102 -6
- package/src/old/tools/authenticate.ts +3 -11
- package/src/old/tools/build.ts +12 -0
- package/src/old/tools/get-provider-info.ts +91 -0
- package/src/old/tools/get-version-config.ts +56 -0
- package/src/old/tools/publish.ts +264 -1
- package/src/old/tools/update-provider-metadata.ts +72 -0
- package/src/proof/attestor.ts +16 -4
- package/src/proof/debug-response-dump.ts +70 -0
- package/src/proof/patch-domhandler.ts +68 -0
- package/src/proof/zk-files.ts +7 -6
- package/src/provider/dom-xpath.ts +225 -0
- package/src/provider/draft.ts +168 -35
- package/src/provider/index.ts +8 -0
- package/src/provider/injection-doc.ts +6 -0
- package/src/provider/matcher.ts +477 -357
- package/src/provider/schema.ts +7 -5
- package/src/tools/registry.ts +92 -0
package/src/provider/matcher.ts
CHANGED
|
@@ -1,34 +1,7 @@
|
|
|
1
|
+
import { elementAnchor } from './dom-xpath.ts'
|
|
1
2
|
import { normalizeCandidates } from './normalize.ts'
|
|
2
3
|
import type { OprfMode, ResponseMatch, ResponseRedaction } from './schema.ts'
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
-
* Minimum bytes per response redaction. The Reclaim zkTLS protocol uses
|
|
6
|
-
* AES-CTR; revealing fewer than one AES block (16 bytes) opens the slice
|
|
7
|
-
* to brute-force guessing. Matters only for the text/plain padded-regex
|
|
8
|
-
* fallback — xPath / jsonPath redactions reveal a whole TLS record, which
|
|
9
|
-
* is always larger.
|
|
10
|
-
*/
|
|
11
|
-
const MIN_REVEAL_BYTES = 16
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Budget for climbing past the smallest enclosing element in search of an
|
|
15
|
-
* ancestor with an `id` or `class` anchor. Capped so we don't end up
|
|
16
|
-
* pointing at `<body>` or `<html>` (which would defeat redaction by
|
|
17
|
-
* revealing most of the page).
|
|
18
|
-
*/
|
|
19
|
-
const MAX_ANCHOR_CLIMB_BYTES = 512
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Hard cap on how many bytes the redaction is allowed to reveal. When
|
|
23
|
-
* the picked xPath/jsonPath element exceeds this, the redaction is
|
|
24
|
-
* extended with a `regex` that narrows to the surrounding key/value
|
|
25
|
-
* pattern — so the attestor only ZKs ~50-100 bytes instead of multi-KB
|
|
26
|
-
* blobs. Without this, a perfectly anchorable element like
|
|
27
|
-
* `<script id="client-env">{...7KB of state...}</script>` would push
|
|
28
|
-
* the attestor through many MB of ZK work and OOM Node.
|
|
29
|
-
*/
|
|
30
|
-
const MAX_REVEAL_BYTES = 512
|
|
31
|
-
|
|
32
5
|
/** Reclaim attestor caps OPRF-hashed values at 62 bytes (TOPRF marker
|
|
33
6
|
* encoding). Surfaces as a clean error here rather than a confusing
|
|
34
7
|
* attestor-side failure at proof time. */
|
|
@@ -37,33 +10,30 @@ export const OPRF_MAX_BYTES = 62
|
|
|
37
10
|
export interface MatcherSynthesis {
|
|
38
11
|
match: ResponseMatch
|
|
39
12
|
redaction?: ResponseRedaction
|
|
40
|
-
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
13
|
+
/** The captured value, keyed by `paramName`. NOT baked into the shipped
|
|
14
|
+
* recipe — the caller uses it for the author's own proof; each verifying
|
|
15
|
+
* user's value is re-extracted live (see verify/run.ts `extractBareParams`).
|
|
16
|
+
*/
|
|
44
17
|
paramValues?: Record<string, string>
|
|
45
18
|
}
|
|
46
19
|
|
|
47
20
|
/**
|
|
48
|
-
* Build the matcher pieces (
|
|
49
|
-
*
|
|
21
|
+
* Build the matcher pieces (one responseMatch + one responseRedaction) for a
|
|
22
|
+
* target value seen in a captured response. The design, in one line:
|
|
50
23
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* - paramValues: `{ [paramName]: target }` — the prover's real value
|
|
55
|
-
* - responseRedactions: one entry that narrows the revealed slice
|
|
56
|
-
* (xPath / jsonPath / regex), and — when `hash` is requested — also
|
|
57
|
-
* carries a `regex` with a named capture group + a `hash` field for
|
|
58
|
-
* OPRF
|
|
24
|
+
* the REDACTION selects with xPath/jsonPath and names the value with a
|
|
25
|
+
* capture group; the MATCH is a `contains` of the surrounding structure with
|
|
26
|
+
* the value replaced by `{{paramName}}`.
|
|
59
27
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
28
|
+
* The match is what the attestor enforces (against the prover's revealed
|
|
29
|
+
* bytes), so it must anchor the value in context the prover can't relocate — a
|
|
30
|
+
* JSON key (`"login":"{{v}}"`) or the value's HTML element (`<span …>{{v}}`).
|
|
31
|
+
* The redaction's regex only extracts/narrows; xPath/jsonPath do the selecting.
|
|
62
32
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
33
|
+
* `hash` (OPRF) is opt-in — enable it only when the request asks to protect
|
|
34
|
+
* PII. It rides the `contains {{v}}` path: the redaction carries `hash`, match
|
|
35
|
+
* keeps `{{v}}`, and the attestor substitutes the nullifier from paramValues at
|
|
36
|
+
* proof time. Throws when `hash` is set on a free-form value (no safe class).
|
|
67
37
|
*/
|
|
68
38
|
export function synthesizeMatcher(
|
|
69
39
|
body: string,
|
|
@@ -81,116 +51,181 @@ export function synthesizeMatcher(
|
|
|
81
51
|
|
|
82
52
|
if(hash && !inferValueClass(target)) {
|
|
83
53
|
throw new Error(
|
|
84
|
-
'OPRF hashing requires a target with a known-safe character class '
|
|
85
|
-
'(digits, slug, or email). '
|
|
86
|
-
`Got "${target}" which is free-form. Use a tighter target`
|
|
87
|
-
'(e.g. just the alphanumeric portion of the value)'
|
|
88
|
-
'
|
|
54
|
+
'OPRF hashing requires a target with a known-safe character class '
|
|
55
|
+
+ '(digits, slug, or email). '
|
|
56
|
+
+ `Got "${target}" which is free-form. Use a tighter target `
|
|
57
|
+
+ '(e.g. just the alphanumeric portion of the value) '
|
|
58
|
+
+ 'or call propose_provider without `hash`.',
|
|
89
59
|
)
|
|
90
60
|
}
|
|
91
61
|
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const needsRegex = hash !== undefined || isOversized
|
|
101
|
-
|
|
102
|
-
if(pick && needsRegex) {
|
|
103
|
-
// Free-form values can use a permissive class when only narrowing;
|
|
104
|
-
// `hash` already had the strict check above.
|
|
105
|
-
const valueClass = inferValueClass(target) ?? '[^"<>]+'
|
|
106
|
-
const captureRegex = buildCaptureRegex(body, target, paramName, valueClass)
|
|
107
|
-
const redaction: ResponseRedaction = {
|
|
108
|
-
...pick.redaction,
|
|
109
|
-
regex: captureRegex,
|
|
110
|
-
}
|
|
111
|
-
if(hash) {
|
|
112
|
-
redaction.hash = hash
|
|
62
|
+
const valueClass = inferValueClass(target) ?? '[^"<>\\n]+'
|
|
63
|
+
|
|
64
|
+
// JSON body → the value lives in a `"key": value` pair. jsonPath pins the
|
|
65
|
+
// exact property; the `contains` match anchors on the key.
|
|
66
|
+
if(/json/i.test(contentType) || isJsonBody(body)) {
|
|
67
|
+
const r = jsonMatcher(body, target, paramName, valueClass, hash)
|
|
68
|
+
if(r) {
|
|
69
|
+
return r
|
|
113
70
|
}
|
|
71
|
+
}
|
|
114
72
|
|
|
115
|
-
|
|
73
|
+
// HTML/XML → the value lives in an element. xPath selects it (an id/class
|
|
74
|
+
// ancestor, else the value's own tag); the `contains` match is the element
|
|
75
|
+
// markup with the value swapped for the placeholder. A `<script>` holding
|
|
76
|
+
// JSON routes through jsonPath instead.
|
|
77
|
+
if(/html|xml/i.test(contentType) || looksLikeHtml(body)) {
|
|
78
|
+
const r = htmlMatcher(body, target, paramName, valueClass, hash)
|
|
79
|
+
if(r) {
|
|
80
|
+
return r
|
|
81
|
+
}
|
|
116
82
|
}
|
|
117
83
|
|
|
118
|
-
|
|
119
|
-
|
|
84
|
+
// No JSON key and no stable xPath anchor: a content-anchored regex, matched
|
|
85
|
+
// by CONTENT so it reproduces regardless of DOM position at proof time (a
|
|
86
|
+
// positional `(//tag)[N]` resolves to the wrong node at proof — see 9fd7770).
|
|
87
|
+
return contentMatcher(body, target, paramName, valueClass, hash)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when `body` is a JSON document (starts `{`/`[` and parses). */
|
|
91
|
+
function isJsonBody(body: string): boolean {
|
|
92
|
+
const head = body.trimStart()[0]
|
|
93
|
+
if(head !== '{' && head !== '[') {
|
|
94
|
+
return false
|
|
120
95
|
}
|
|
121
96
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
paramName,
|
|
128
|
-
inferValueClass(target)!,
|
|
129
|
-
)
|
|
130
|
-
return makeResult(body, target, paramName, { regex: captureRegex, hash })
|
|
97
|
+
try {
|
|
98
|
+
JSON.parse(body)
|
|
99
|
+
return true
|
|
100
|
+
} catch{
|
|
101
|
+
return false
|
|
131
102
|
}
|
|
103
|
+
}
|
|
132
104
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
105
|
+
/** Index of `target` (or an encoding variant) in `text`, with the matched
|
|
106
|
+
* variant. `{ idx: -1 }` when absent. */
|
|
107
|
+
function firstIndex(
|
|
108
|
+
text: string,
|
|
109
|
+
target: string,
|
|
110
|
+
): { idx: number, value: string } {
|
|
111
|
+
for(const value of [target, ...normalizeCandidates(target)]) {
|
|
112
|
+
const idx = text.indexOf(value)
|
|
113
|
+
if(idx >= 0) {
|
|
114
|
+
return { idx, value }
|
|
115
|
+
}
|
|
136
116
|
}
|
|
137
117
|
|
|
138
|
-
return
|
|
118
|
+
return { idx: -1, value: target }
|
|
139
119
|
}
|
|
140
120
|
|
|
141
|
-
|
|
142
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Assemble the result for a value that sits after a literal `prefix` and
|
|
123
|
+
* before a `close` delimiter — the shared shape for JSON `"key":"…"` and
|
|
124
|
+
* JSON-in-HTML. The redaction pairs the selector with a capture regex
|
|
125
|
+
* (whitespace in the prefix flexed); the match `contains` `prefix{{v}}close`.
|
|
126
|
+
*/
|
|
127
|
+
function keyValueResult(
|
|
128
|
+
prefix: string,
|
|
129
|
+
close: string,
|
|
143
130
|
target: string,
|
|
144
131
|
paramName: string,
|
|
145
|
-
|
|
132
|
+
valueClass: string,
|
|
133
|
+
selector: { jsonPath?: string, xPath?: string },
|
|
134
|
+
hash?: OprfMode,
|
|
146
135
|
): MatcherSynthesis {
|
|
136
|
+
const redaction: ResponseRedaction = {
|
|
137
|
+
...selector,
|
|
138
|
+
regex:
|
|
139
|
+
`${escapeRegexFlexWs(prefix)}(?<${paramName}>${valueClass})`
|
|
140
|
+
+ escapeRegex(close),
|
|
141
|
+
}
|
|
142
|
+
if(hash) {
|
|
143
|
+
redaction.hash = hash
|
|
144
|
+
}
|
|
145
|
+
|
|
147
146
|
return {
|
|
148
|
-
match: {
|
|
149
|
-
type: 'contains',
|
|
150
|
-
value: buildContextualTemplate(body, target, paramName),
|
|
151
|
-
},
|
|
147
|
+
match: { type: 'contains', value: `${prefix}{{${paramName}}}${close}` },
|
|
152
148
|
redaction,
|
|
153
149
|
paramValues: { [paramName]: target },
|
|
154
150
|
}
|
|
155
151
|
}
|
|
156
152
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
* primitives (see `walk()`), so size isn't tracked there. */
|
|
161
|
-
interface AnchorPick {
|
|
162
|
-
redaction: ResponseRedaction
|
|
163
|
-
elementBytes?: number
|
|
164
|
-
}
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// JSON: "key": value → jsonPath redaction + `contains "key":"{{v}}"`
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
165
156
|
|
|
166
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Matcher for a value inside a JSON `"key": value` pair. jsonPath pins the
|
|
159
|
+
* exact property (so the redaction extracts THIS one, not a same-named key
|
|
160
|
+
* elsewhere); the `contains` match anchors on `"key":`.
|
|
161
|
+
*/
|
|
162
|
+
function jsonMatcher(
|
|
167
163
|
body: string,
|
|
168
|
-
contentType: string,
|
|
169
164
|
target: string,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
165
|
+
paramName: string,
|
|
166
|
+
valueClass: string,
|
|
167
|
+
hash?: OprfMode,
|
|
168
|
+
): MatcherSynthesis | undefined {
|
|
169
|
+
const jsonPath = findJsonPath(body, target)
|
|
170
|
+
if(!jsonPath) {
|
|
171
|
+
return undefined
|
|
176
172
|
}
|
|
177
173
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
redaction: { xPath: xPath.xPath },
|
|
183
|
-
elementBytes: xPath.elementBytes,
|
|
184
|
-
}
|
|
185
|
-
}
|
|
174
|
+
const anchor = jsonKeyPrefix(body, target)
|
|
175
|
+
if(!anchor) {
|
|
176
|
+
return undefined
|
|
186
177
|
}
|
|
187
178
|
|
|
188
|
-
return
|
|
179
|
+
return keyValueResult(
|
|
180
|
+
anchor.prefix, anchor.close, target, paramName, valueClass,
|
|
181
|
+
{ jsonPath }, hash,
|
|
182
|
+
)
|
|
189
183
|
}
|
|
190
184
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
185
|
+
/**
|
|
186
|
+
* The literal `"key":"` (or `"key":` for a raw number/bool) immediately before
|
|
187
|
+
* `target` in `text`, plus the closing quote (`"` for a string value, `''`
|
|
188
|
+
* otherwise). Returns undefined when the value isn't preceded by a JSON key.
|
|
189
|
+
*/
|
|
190
|
+
function jsonKeyPrefix(
|
|
191
|
+
text: string,
|
|
192
|
+
target: string,
|
|
193
|
+
): { prefix: string, close: string } | undefined {
|
|
194
|
+
const { idx } = firstIndex(text, target)
|
|
195
|
+
if(idx < 0) {
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let i = idx - 1
|
|
200
|
+
const quoted = text[i] === '"'
|
|
201
|
+
if(quoted) {
|
|
202
|
+
i--
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
while(i >= 0 && /\s/.test(text[i] ?? '')) {
|
|
206
|
+
i--
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if(text[i] !== ':') {
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
i--
|
|
214
|
+
while(i >= 0 && /\s/.test(text[i] ?? '')) {
|
|
215
|
+
i--
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if(text[i] !== '"') {
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const keyStart = text.lastIndexOf('"', i - 1)
|
|
223
|
+
if(keyStart < 0) {
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { prefix: text.slice(keyStart, idx), close: quoted ? '"' : '' }
|
|
228
|
+
}
|
|
194
229
|
|
|
195
230
|
function findJsonPath(body: string, target: string): string | undefined {
|
|
196
231
|
let parsed: unknown
|
|
@@ -253,295 +288,338 @@ function walk(
|
|
|
253
288
|
}
|
|
254
289
|
|
|
255
290
|
// ---------------------------------------------------------------------------
|
|
256
|
-
// HTML
|
|
291
|
+
// HTML: element → xPath redaction + `contains "<tag …>{{v}}</tag>"`
|
|
257
292
|
// ---------------------------------------------------------------------------
|
|
258
293
|
|
|
259
|
-
interface EnclosingElement {
|
|
260
|
-
tag: string
|
|
261
|
-
openStart: number
|
|
262
|
-
closeEnd: number
|
|
263
|
-
id?: string
|
|
264
|
-
classes: string[]
|
|
265
|
-
}
|
|
266
|
-
|
|
267
294
|
function looksLikeHtml(body: string): boolean {
|
|
268
295
|
return /<[a-zA-Z][a-zA-Z0-9-]*[\s>]/.test(body.slice(0, 2000))
|
|
269
296
|
}
|
|
270
297
|
|
|
271
|
-
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Matcher for a value inside an HTML element. The element and a verified xPath
|
|
300
|
+
* come from a real parse5 parse (see dom-xpath.ts), then either:
|
|
301
|
+
* - `<script>` JSON: dig the value out with jsonPath (`"key":"{{v}}"` match);
|
|
302
|
+
* - regular element: `contains` the element markup with the value → `{{v}}`.
|
|
303
|
+
*/
|
|
304
|
+
function htmlMatcher(
|
|
277
305
|
body: string,
|
|
278
306
|
target: string,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
307
|
+
paramName: string,
|
|
308
|
+
valueClass: string,
|
|
309
|
+
hash?: OprfMode,
|
|
310
|
+
): MatcherSynthesis | undefined {
|
|
311
|
+
const anchor = elementAnchor(body, [target, ...normalizeCandidates(target)])
|
|
312
|
+
if(!anchor) {
|
|
313
|
+
return undefined
|
|
314
|
+
}
|
|
285
315
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
316
|
+
const revealSlice = body.slice(anchor.start, anchor.end)
|
|
317
|
+
|
|
318
|
+
// JSON embedded in a <script>: xPath selects the script, jsonPath digs out
|
|
319
|
+
// the value, the regex captures it — immune to a same-named key elsewhere.
|
|
320
|
+
const scriptJson = scriptJsonContent(revealSlice)
|
|
321
|
+
if(scriptJson) {
|
|
322
|
+
const jsonPath = findJsonPath(scriptJson, target)
|
|
323
|
+
const kp = jsonPath ? jsonKeyPrefix(scriptJson, target) : undefined
|
|
324
|
+
if(jsonPath && kp) {
|
|
325
|
+
return keyValueResult(
|
|
326
|
+
kp.prefix, kp.close, target, paramName, valueClass,
|
|
327
|
+
{ xPath: anchor.xPath, jsonPath }, hash,
|
|
292
328
|
)
|
|
293
|
-
if(!outer || outer.openStart >= el.openStart) {
|
|
294
|
-
break
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
el = outer
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if(!el) {
|
|
301
|
-
continue
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const anchored = climbForAnchor(body, el)
|
|
305
|
-
const final = anchored ?? el
|
|
306
|
-
return {
|
|
307
|
-
xPath: buildXPath(final, body),
|
|
308
|
-
elementBytes: final.closeEnd - final.openStart,
|
|
309
329
|
}
|
|
310
330
|
}
|
|
311
331
|
|
|
312
|
-
return
|
|
332
|
+
return elementResult(
|
|
333
|
+
anchor.xPath, revealSlice, target, paramName, valueClass, hash,
|
|
334
|
+
)
|
|
313
335
|
}
|
|
314
336
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
337
|
+
/**
|
|
338
|
+
* `contains` a PORTABLE window of the element around the value, with the value
|
|
339
|
+
* swapped for `{{paramName}}`. "Portable" = it anchors only on stable structure
|
|
340
|
+
* (allow-listed attributes + tag boundaries) and excludes per-user / volatile
|
|
341
|
+
* bytes (`href`/`value`/`id`/`data-*` attribute values, sibling elements), so a
|
|
342
|
+
* published provider reproduces for ANY user, not just the author. The
|
|
343
|
+
* redaction reveals + captures the value over the same window (xPath + regex,
|
|
344
|
+
* whitespace flexed); `contains` is required for extractBareParams + OPRF.
|
|
345
|
+
*/
|
|
346
|
+
function elementResult(
|
|
347
|
+
xPath: string,
|
|
348
|
+
markup: string,
|
|
349
|
+
target: string,
|
|
350
|
+
paramName: string,
|
|
351
|
+
valueClass: string,
|
|
352
|
+
hash?: OprfMode,
|
|
353
|
+
): MatcherSynthesis | undefined {
|
|
354
|
+
const { idx, value } = firstIndex(markup, target)
|
|
355
|
+
if(idx < 0) {
|
|
356
|
+
return undefined
|
|
357
|
+
}
|
|
333
358
|
|
|
334
|
-
|
|
335
|
-
return undefined
|
|
336
|
-
}
|
|
359
|
+
const { left, right } = portableWindow(markup, idx, value.length)
|
|
337
360
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
361
|
+
const redaction: ResponseRedaction = {
|
|
362
|
+
xPath,
|
|
363
|
+
regex:
|
|
364
|
+
`${escapeRegexFlexWs(left)}(?<${paramName}>${valueClass})`
|
|
365
|
+
+ escapeRegexFlexWs(right),
|
|
366
|
+
}
|
|
367
|
+
if(hash) {
|
|
368
|
+
redaction.hash = hash
|
|
369
|
+
}
|
|
341
370
|
|
|
342
|
-
|
|
371
|
+
return {
|
|
372
|
+
match: { type: 'contains', value: `${left}{{${paramName}}}${right}` },
|
|
373
|
+
redaction,
|
|
374
|
+
paramValues: { [paramName]: target },
|
|
343
375
|
}
|
|
344
376
|
}
|
|
345
377
|
|
|
346
|
-
|
|
347
|
-
|
|
378
|
+
/** Attribute NAMES safe to bake into a match — stable across users. Everything
|
|
379
|
+
* else (`href`, `src`, `value`, `id`, `data-*`, `style`, `content`, nonces, …)
|
|
380
|
+
* carries per-user / per-render data and is excluded from the anchor. */
|
|
381
|
+
const STABLE_ATTRS = new Set([
|
|
382
|
+
'class', 'type', 'rel', 'role', 'name', 'property', 'itemprop', 'for',
|
|
383
|
+
'scope', 'lang', 'dir', 'selected', 'checked', 'disabled', 'readonly',
|
|
384
|
+
'required', 'multiple', 'open', 'headers',
|
|
385
|
+
])
|
|
386
|
+
|
|
387
|
+
/** A portable literal window `{ left, right }` around the value in `markup`:
|
|
388
|
+
* anchored on stable structure, stripped of volatile/per-user bytes. */
|
|
389
|
+
function portableWindow(
|
|
390
|
+
markup: string,
|
|
348
391
|
valueIdx: number,
|
|
349
392
|
valueLen: number,
|
|
350
|
-
):
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const tagStart = body.lastIndexOf('<', tagEnd)
|
|
360
|
-
if(tagStart < 0) {
|
|
361
|
-
return undefined
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const openTag = body.slice(tagStart, tagEnd + 1)
|
|
365
|
-
if(
|
|
366
|
-
openTag.startsWith('</') ||
|
|
367
|
-
openTag.startsWith('<!') ||
|
|
368
|
-
openTag.startsWith('<?') ||
|
|
369
|
-
openTag.trimEnd().endsWith('/>')
|
|
370
|
-
) {
|
|
371
|
-
i = tagStart - 1
|
|
372
|
-
continue
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const tagNameMatch = openTag.match(/^<([a-zA-Z][a-zA-Z0-9-]*)/)
|
|
376
|
-
if(!tagNameMatch) {
|
|
377
|
-
i = tagStart - 1
|
|
378
|
-
continue
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const tagName = tagNameMatch[1].toLowerCase()
|
|
382
|
-
const closePattern = new RegExp(`</${tagName}\\s*>`, 'i')
|
|
383
|
-
const afterValue = body.slice(valueIdx + valueLen)
|
|
384
|
-
const closeMatch = afterValue.match(closePattern)
|
|
385
|
-
if(closeMatch?.index === undefined) {
|
|
386
|
-
i = tagStart - 1
|
|
387
|
-
continue
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const closeEnd =
|
|
391
|
-
valueIdx + valueLen + closeMatch.index + closeMatch[0].length
|
|
392
|
-
const idMatch = openTag.match(/\bid\s*=\s*["']([^"']+)["']/)
|
|
393
|
-
const classMatch = openTag.match(/\bclass\s*=\s*["']([^"']+)["']/)
|
|
394
|
-
const classes = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : []
|
|
395
|
-
const el: EnclosingElement = {
|
|
396
|
-
tag: tagName,
|
|
397
|
-
openStart: tagStart,
|
|
398
|
-
closeEnd,
|
|
399
|
-
classes,
|
|
400
|
-
}
|
|
401
|
-
if(idMatch?.[1] !== undefined) {
|
|
402
|
-
el.id = idMatch[1]
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return el
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
return undefined
|
|
393
|
+
): { left: string, right: string } {
|
|
394
|
+
const valueEnd = valueIdx + valueLen
|
|
395
|
+
const lastLt = markup.lastIndexOf('<', valueIdx - 1)
|
|
396
|
+
const lastGt = markup.lastIndexOf('>', valueIdx - 1)
|
|
397
|
+
|
|
398
|
+
// A `<` with no `>` after it before the value ⇒ value sits in an attribute.
|
|
399
|
+
return lastLt > lastGt
|
|
400
|
+
? attributeWindow(markup, valueIdx, valueEnd, lastLt)
|
|
401
|
+
: textWindow(markup, valueIdx, valueEnd, lastGt)
|
|
409
402
|
}
|
|
410
403
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
404
|
+
/** Value is element text content. Left anchor: if only punctuation/whitespace
|
|
405
|
+
* sits between the opening tag and the value, fold the stable opening tag in;
|
|
406
|
+
* but if WORDS precede the value in the same text node they may be per-user
|
|
407
|
+
* (e.g. `<li>Full Name ( {{id}} )` — the name), so drop through the last
|
|
408
|
+
* alphanumeric and keep only the trailing punctuation as the anchor. Right:
|
|
409
|
+
* include a closing tag, but stop at a sibling. */
|
|
410
|
+
function textWindow(
|
|
411
|
+
markup: string,
|
|
412
|
+
valueIdx: number,
|
|
413
|
+
valueEnd: number,
|
|
414
|
+
openTagGt: number,
|
|
415
|
+
): { left: string, right: string } {
|
|
416
|
+
const openTagStart = markup.lastIndexOf('<', openTagGt)
|
|
417
|
+
const openTag = openTagStart >= 0
|
|
418
|
+
? markup.slice(openTagStart, openTagGt + 1)
|
|
419
|
+
: ''
|
|
420
|
+
const between = markup.slice(openTagGt + 1, valueIdx)
|
|
421
|
+
const left = /[A-Za-z0-9]/.test(between)
|
|
422
|
+
// Words before the value could be per-user — anchor on the punctuation
|
|
423
|
+
// after the last one (drops the words AND the tag they'd drag in).
|
|
424
|
+
? between.replace(/[\s\S]*[A-Za-z0-9]/, '')
|
|
425
|
+
// Only punctuation/whitespace — safe to fold the stable opening tag.
|
|
426
|
+
: stableOpeningTail(openTag) + between
|
|
427
|
+
|
|
428
|
+
const nextLt = markup.indexOf('<', valueEnd)
|
|
429
|
+
const inter = nextLt < 0
|
|
430
|
+
? markup.slice(valueEnd)
|
|
431
|
+
: markup.slice(valueEnd, nextLt)
|
|
432
|
+
|
|
433
|
+
// Trailing words after the value may be per-user too — keep only the
|
|
434
|
+
// punctuation up to the first alphanumeric, and drop the closing tag with
|
|
435
|
+
// them (contains is contiguous, so it can't be reached past a word).
|
|
436
|
+
if(/[A-Za-z0-9]/.test(inter)) {
|
|
437
|
+
return { left, right: inter.replace(/[A-Za-z0-9][\s\S]*/, '') }
|
|
414
438
|
}
|
|
415
439
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
440
|
+
// Punctuation/whitespace only: fold the stable closing tag if present.
|
|
441
|
+
if(nextLt >= 0 && markup.startsWith('</', nextLt)) {
|
|
442
|
+
const close = markup.indexOf('>', nextLt)
|
|
443
|
+
const end = close < 0 ? nextLt : close + 1
|
|
444
|
+
return { left, right: markup.slice(valueEnd, end) }
|
|
419
445
|
}
|
|
420
446
|
|
|
421
|
-
|
|
447
|
+
// Sibling opening tag (or end of markup) — stop at it.
|
|
448
|
+
return { left, right: inter }
|
|
422
449
|
}
|
|
423
450
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
451
|
+
/** Value is an attribute value: anchor on the nearest STABLE identifying
|
|
452
|
+
* attribute (`name`/`property`/…) plus the value attribute's `name="`. */
|
|
453
|
+
function attributeWindow(
|
|
454
|
+
markup: string,
|
|
455
|
+
valueIdx: number,
|
|
456
|
+
valueEnd: number,
|
|
457
|
+
openTagStart: number,
|
|
458
|
+
): { left: string, right: string } {
|
|
459
|
+
const gt = markup.indexOf('>', valueEnd)
|
|
460
|
+
const openTag = markup.slice(openTagStart, gt < 0 ? markup.length : gt + 1)
|
|
461
|
+
const rel = valueIdx - openTagStart
|
|
462
|
+
|
|
463
|
+
const attrRe = /\s+([a-zA-Z][\w:-]*)(\s*=\s*("[^"]*"|'[^']*'|[^\s>]*))?/g
|
|
464
|
+
let anchorStart = -1
|
|
465
|
+
let valueAttrStart = -1
|
|
427
466
|
let m: RegExpExecArray | null
|
|
428
|
-
while((m =
|
|
429
|
-
|
|
467
|
+
while((m = attrRe.exec(openTag)) !== null) {
|
|
468
|
+
const start = m.index
|
|
469
|
+
if(start < rel && rel < start + m[0].length) {
|
|
470
|
+
valueAttrStart = start
|
|
430
471
|
break
|
|
431
472
|
}
|
|
432
473
|
|
|
433
|
-
|
|
474
|
+
if(STABLE_ATTRS.has(m[1].toLowerCase())) {
|
|
475
|
+
anchorStart = start
|
|
476
|
+
}
|
|
434
477
|
}
|
|
435
478
|
|
|
436
|
-
|
|
479
|
+
const from = anchorStart >= 0 ? anchorStart : valueAttrStart
|
|
480
|
+
const left = from >= 0 ? openTag.slice(from, rel).replace(/^\s+/, '') : ''
|
|
481
|
+
const quote = markup[valueEnd]
|
|
482
|
+
return { left, right: quote === '"' || quote === "'" ? quote : '' }
|
|
437
483
|
}
|
|
438
484
|
|
|
439
|
-
/**
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
function
|
|
443
|
-
|
|
444
|
-
|
|
485
|
+
/** The portable suffix of an opening tag (ending at `>`): the trailing run of
|
|
486
|
+
* allow-listed attributes. Everything up to and including the last volatile
|
|
487
|
+
* attribute is dropped, so `<a href="/user" class="x">` → `class="x">`. */
|
|
488
|
+
function stableOpeningTail(openTag: string): string {
|
|
489
|
+
const attrRe = /\s+([a-zA-Z][\w:-]*)(\s*=\s*("[^"]*"|'[^']*'|[^\s>]*))?/g
|
|
490
|
+
let cut = -1
|
|
491
|
+
let m: RegExpExecArray | null
|
|
492
|
+
while((m = attrRe.exec(openTag)) !== null) {
|
|
493
|
+
if(!STABLE_ATTRS.has(m[1].toLowerCase())) {
|
|
494
|
+
cut = m.index + m[0].length
|
|
495
|
+
}
|
|
445
496
|
}
|
|
446
497
|
|
|
447
|
-
|
|
448
|
-
|
|
498
|
+
return (cut < 0 ? openTag : openTag.slice(cut)).replace(/^\s+/, '')
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** If `markup` is a single `<script>…</script>` element, return its text
|
|
502
|
+
* content (the JSON body the attestor exposes when a jsonPath is paired with
|
|
503
|
+
* the script's xPath); otherwise undefined. */
|
|
504
|
+
function scriptJsonContent(markup: string): string | undefined {
|
|
505
|
+
const match = /^<script\b[^>]*>([\s\S]*)<\/script>\s*$/i.exec(markup)
|
|
506
|
+
if(!match) {
|
|
507
|
+
return undefined
|
|
449
508
|
}
|
|
450
509
|
|
|
451
|
-
const
|
|
452
|
-
return
|
|
510
|
+
const content = match[1].trim()
|
|
511
|
+
return content.length > 0 ? content : undefined
|
|
453
512
|
}
|
|
454
513
|
|
|
455
514
|
// ---------------------------------------------------------------------------
|
|
456
|
-
//
|
|
515
|
+
// Content-anchored fallback (no stable structural anchor)
|
|
457
516
|
// ---------------------------------------------------------------------------
|
|
458
517
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const idx = body.indexOf(cand)
|
|
462
|
-
if(idx < 0) {
|
|
463
|
-
continue
|
|
464
|
-
}
|
|
518
|
+
/** Longest opening/closing HTML tag folded into the match context. */
|
|
519
|
+
const MAX_TAG_BYTES = 256
|
|
465
520
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const need = MIN_REVEAL_BYTES - cand.length
|
|
469
|
-
if(need > 0) {
|
|
470
|
-
const leftPad = Math.floor(need / 2)
|
|
471
|
-
const rightPad = need - leftPad
|
|
472
|
-
start = Math.max(0, idx - leftPad)
|
|
473
|
-
end = Math.min(body.length, idx + cand.length + rightPad)
|
|
474
|
-
if(start === 0) {
|
|
475
|
-
end = Math.min(body.length, idx + cand.length + need - (idx - 0))
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if(end === body.length) {
|
|
479
|
-
start = Math.max(0, body.length - MIN_REVEAL_BYTES)
|
|
480
|
-
}
|
|
481
|
-
}
|
|
521
|
+
/** Bytes the left context grows per step while searching for uniqueness. */
|
|
522
|
+
const CONTEXT_STEP = 4
|
|
482
523
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
524
|
+
/**
|
|
525
|
+
* Matcher for a value with no JSON key and no stable xPath anchor: a
|
|
526
|
+
* content-anchored regex redaction (position-independent) paired with a match
|
|
527
|
+
* carrying surrounding literal context, so it is never a bare — hence
|
|
528
|
+
* spoofable — `{{value}}`. When there is genuinely no literal context, the
|
|
529
|
+
* value-isolating regex itself becomes the (regex-type) match.
|
|
530
|
+
*/
|
|
531
|
+
function contentMatcher(
|
|
532
|
+
body: string,
|
|
533
|
+
target: string,
|
|
534
|
+
paramName: string,
|
|
535
|
+
valueClass: string,
|
|
536
|
+
hash?: OprfMode,
|
|
537
|
+
): MatcherSynthesis | undefined {
|
|
538
|
+
const regex = buildContentRegex(body, target, paramName, valueClass)
|
|
539
|
+
if(!regex) {
|
|
540
|
+
return undefined
|
|
541
|
+
}
|
|
491
542
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
i++
|
|
496
|
-
) {
|
|
497
|
-
end++
|
|
498
|
-
}
|
|
543
|
+
const redaction: ResponseRedaction = { regex }
|
|
544
|
+
if(hash) {
|
|
545
|
+
redaction.hash = hash
|
|
499
546
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
547
|
+
// OPRF needs a `contains {{v}}` match — the attestor substitutes the
|
|
548
|
+
// nullifier into it (a regex match can't express that). Anchor on the
|
|
549
|
+
// surrounding literal context so it's never a bare, spoofable `{{v}}`.
|
|
550
|
+
return {
|
|
551
|
+
match: {
|
|
552
|
+
type: 'contains',
|
|
553
|
+
value: buildContextualTemplate(body, target, paramName),
|
|
554
|
+
},
|
|
555
|
+
redaction,
|
|
556
|
+
paramValues: { [paramName]: target },
|
|
503
557
|
}
|
|
504
|
-
|
|
505
|
-
return escapeRegex(window)
|
|
506
558
|
}
|
|
507
559
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
560
|
+
// The value-isolating regex IS the match: grown to uniquely capture THIS
|
|
561
|
+
// occurrence, so it's position-independent, self-extracting (the attestor
|
|
562
|
+
// reads its named group — verification's `extractBareParams` can't grab a
|
|
563
|
+
// same-shaped earlier occurrence, as a `contains "key":"{{v}}"` would), and
|
|
564
|
+
// never a bare, spoofable `{{v}}`.
|
|
565
|
+
return {
|
|
566
|
+
match: { type: 'regex', value: regex },
|
|
567
|
+
redaction,
|
|
568
|
+
paramValues: { [paramName]: target },
|
|
569
|
+
}
|
|
513
570
|
}
|
|
514
571
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
572
|
+
/**
|
|
573
|
+
* A value-isolating regex around the first occurrence of `target`, matched by
|
|
574
|
+
* CONTENT so it reproduces regardless of DOM position: leading literal context
|
|
575
|
+
* (grown until the first whole-body match captures THIS occurrence) + a
|
|
576
|
+
* `(?<paramName>valueClass)` group + any trailing delimiter. The revealed span
|
|
577
|
+
* must CONTAIN the match template (buildContextualTemplate), so the trailing
|
|
578
|
+
* delimiter is kept unless it's whitespace (which the template also drops).
|
|
579
|
+
*/
|
|
580
|
+
function buildContentRegex(
|
|
520
581
|
body: string,
|
|
521
582
|
target: string,
|
|
522
583
|
paramName: string,
|
|
523
584
|
valueClass: string,
|
|
524
|
-
): string {
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
585
|
+
): string | undefined {
|
|
586
|
+
for(const cand of normalizeCandidates(target)) {
|
|
587
|
+
const idx = body.indexOf(cand)
|
|
588
|
+
if(idx < 0) {
|
|
589
|
+
continue
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const endIdx = idx + cand.length
|
|
593
|
+
const rawRight = findRightContext(body, endIdx)
|
|
594
|
+
const right = /^\s/.test(rawRight) ? '' : escapeRegexFlexWs(rawRight)
|
|
595
|
+
const baseStart = idx - findLeftContext(body, idx).length
|
|
596
|
+
for(let start = baseStart; ; start -= CONTEXT_STEP) {
|
|
597
|
+
const from = Math.max(0, start)
|
|
598
|
+
const before = escapeRegexFlexWs(body.slice(from, idx))
|
|
599
|
+
const re = `${before}(?<${paramName}>${valueClass})${right}`
|
|
600
|
+
try {
|
|
601
|
+
if(new RegExp(re).exec(body)?.groups?.[paramName] === cand) {
|
|
602
|
+
return re
|
|
603
|
+
}
|
|
604
|
+
} catch{
|
|
605
|
+
break
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if(from === 0) {
|
|
609
|
+
break
|
|
610
|
+
}
|
|
611
|
+
}
|
|
528
612
|
}
|
|
529
613
|
|
|
530
|
-
|
|
531
|
-
const leftCtx = findLeftContext(body, idx)
|
|
532
|
-
const rightCtx = findRightContext(body, endIdx)
|
|
533
|
-
// eslint-disable-next-line @stylistic/max-len
|
|
534
|
-
return `${escapeRegex(leftCtx)}(?<${paramName}>${valueClass})${escapeRegex(rightCtx)}`
|
|
614
|
+
return undefined
|
|
535
615
|
}
|
|
536
616
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
* `{{paramName}}`. The attestor substitutes paramValues at proof time
|
|
544
|
-
* (or the OPRF nullifier, when the matching redaction has `hash`). */
|
|
617
|
+
/**
|
|
618
|
+
* The literal `contains` template: surrounding structural context with the
|
|
619
|
+
* value replaced by `{{paramName}}`. Whitespace-adjacent context is dropped (a
|
|
620
|
+
* literal contains can't flex whitespace); stable context (a JSON key, an HTML
|
|
621
|
+
* opening tag) is kept so the match anchors on it.
|
|
622
|
+
*/
|
|
545
623
|
function buildContextualTemplate(
|
|
546
624
|
body: string,
|
|
547
625
|
target: string,
|
|
@@ -553,24 +631,41 @@ function buildContextualTemplate(
|
|
|
553
631
|
}
|
|
554
632
|
|
|
555
633
|
const endIdx = idx + target.length
|
|
556
|
-
const leftCtx = findLeftContext(body, idx)
|
|
557
|
-
|
|
634
|
+
const leftCtx = /\s$/.test(findLeftContext(body, idx))
|
|
635
|
+
? '' : findLeftContext(body, idx)
|
|
636
|
+
const rightCtx = /^\s/.test(findRightContext(body, endIdx))
|
|
637
|
+
? '' : findRightContext(body, endIdx)
|
|
558
638
|
return `${leftCtx}{{${paramName}}}${rightCtx}`
|
|
559
639
|
}
|
|
560
640
|
|
|
561
|
-
/**
|
|
562
|
-
*
|
|
563
|
-
*
|
|
641
|
+
/**
|
|
642
|
+
* Walk left up to 32 bytes to the nearest meaningful anchor, stopping at the
|
|
643
|
+
* first structural delimiter. On an HTML tag boundary (`>`) fold in the whole
|
|
644
|
+
* opening tag — its class/id is the anchor; `>{{value}}<` alone would let a
|
|
645
|
+
* prover surface the value from any element. A leading run of volatile
|
|
646
|
+
* whitespace is skipped first so the anchor lands on the tag, not on `\s*`.
|
|
647
|
+
*/
|
|
564
648
|
function findLeftContext(body: string, idx: number): string {
|
|
565
649
|
const MAX_CONTEXT_BYTES = 32
|
|
566
650
|
const stop = Math.max(0, idx - MAX_CONTEXT_BYTES)
|
|
567
|
-
|
|
651
|
+
let scan = idx - 1
|
|
652
|
+
while(scan >= stop && /\s/.test(body[scan] ?? '')) {
|
|
653
|
+
scan--
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
for(let i = scan; i >= stop; i--) {
|
|
568
657
|
const c = body[i]
|
|
569
658
|
if(c === '>') {
|
|
659
|
+
const tagStart = body.lastIndexOf('<', i)
|
|
660
|
+
if(tagStart >= 0 && i - tagStart <= MAX_TAG_BYTES
|
|
661
|
+
&& body[tagStart + 1] !== '/') {
|
|
662
|
+
return body.slice(tagStart, idx)
|
|
663
|
+
}
|
|
664
|
+
|
|
570
665
|
return body.slice(i, idx)
|
|
571
666
|
}
|
|
572
667
|
|
|
573
|
-
if(c === '{' || c === '}' || c === ',' || c === ';'
|
|
668
|
+
if(c === '{' || c === '}' || c === ',' || c === ';') {
|
|
574
669
|
return body.slice(i + 1, idx)
|
|
575
670
|
}
|
|
576
671
|
}
|
|
@@ -584,6 +679,11 @@ function findRightContext(body: string, endIdx: number): string {
|
|
|
584
679
|
for(let i = endIdx; i < stop; i++) {
|
|
585
680
|
const c = body[i]
|
|
586
681
|
if(c === '<') {
|
|
682
|
+
const tagEnd = body.indexOf('>', i)
|
|
683
|
+
if(tagEnd >= 0 && tagEnd - i <= MAX_TAG_BYTES) {
|
|
684
|
+
return body.slice(endIdx, tagEnd + 1)
|
|
685
|
+
}
|
|
686
|
+
|
|
587
687
|
return body.slice(endIdx, i + 1)
|
|
588
688
|
}
|
|
589
689
|
|
|
@@ -595,15 +695,35 @@ function findRightContext(body: string, endIdx: number): string {
|
|
|
595
695
|
return body.slice(endIdx, stop)
|
|
596
696
|
}
|
|
597
697
|
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// Shared helpers
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
function escapeRegex(s: string): string {
|
|
703
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Escape `s` as a regex literal but match any run of whitespace flexibly
|
|
707
|
+
* (`\s*`): HTML varies inter-token whitespace, so literal whitespace baked
|
|
708
|
+
* into a redaction's capture regex would make it miss at proof time. */
|
|
709
|
+
function escapeRegexFlexWs(s: string): string {
|
|
710
|
+
return s.split(/\s+/).map(escapeRegex).join('\\s*')
|
|
711
|
+
}
|
|
712
|
+
|
|
598
713
|
/** Derive a regex character class from the target's shape. Returns
|
|
599
714
|
* `undefined` for free-form values — those can't be OPRF'd because we
|
|
600
715
|
* can't write a regex capture that reliably matches arbitrary unicode /
|
|
601
|
-
* punctuation. Caller
|
|
716
|
+
* punctuation. Caller refuses the hash request. */
|
|
602
717
|
function inferValueClass(target: string): string | undefined {
|
|
603
718
|
if(/^[0-9]+$/.test(target)) {
|
|
604
719
|
return '\\d+'
|
|
605
720
|
}
|
|
606
721
|
|
|
722
|
+
// Numbers with thousands separators / decimals, e.g. "1,908" or "12,345.6".
|
|
723
|
+
if(/^[0-9][0-9,.]*[0-9]$/.test(target)) {
|
|
724
|
+
return '[0-9,.]+'
|
|
725
|
+
}
|
|
726
|
+
|
|
607
727
|
if(/^[A-Za-z0-9_-]+$/.test(target)) {
|
|
608
728
|
return '[A-Za-z0-9_-]+'
|
|
609
729
|
}
|