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