tapline-host 1.6.1 → 1.7.0
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/dist/accounts.d.ts +129 -12
- package/dist/accounts.js +844 -75
- package/dist/accounts.js.map +1 -1
- package/dist/actionlog.d.ts +11 -4
- package/dist/actionlog.js +22 -1
- package/dist/actionlog.js.map +1 -1
- package/dist/animations.d.ts +4 -2
- package/dist/animations.js +7 -7
- package/dist/animations.js.map +1 -1
- package/dist/api.d.ts +7 -1
- package/dist/api.js +12 -1
- package/dist/api.js.map +1 -1
- package/dist/audit.d.ts +1 -0
- package/dist/auditlog.d.ts +40 -0
- package/dist/auditlog.js +150 -0
- package/dist/auditlog.js.map +1 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +143 -7
- package/dist/cli.js.map +1 -1
- package/dist/control.d.ts +51 -0
- package/dist/control.js +110 -0
- package/dist/control.js.map +1 -1
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.js +61 -4
- package/dist/doctor.js.map +1 -1
- package/dist/drive.d.ts +5 -2
- package/dist/drive.js +55 -16
- package/dist/drive.js.map +1 -1
- package/dist/elicit.d.ts +47 -0
- package/dist/elicit.js +87 -0
- package/dist/elicit.js.map +1 -1
- package/dist/format.d.ts +6 -0
- package/dist/format.js +25 -4
- package/dist/format.js.map +1 -1
- package/dist/grants.d.ts +297 -0
- package/dist/grants.js +938 -0
- package/dist/grants.js.map +1 -0
- package/dist/grantscmd.d.ts +34 -0
- package/dist/grantscmd.js +100 -0
- package/dist/grantscmd.js.map +1 -0
- package/dist/hostfacts.d.ts +23 -0
- package/dist/hostfacts.js +65 -0
- package/dist/hostfacts.js.map +1 -0
- package/dist/inspect.d.ts +3 -2
- package/dist/inspect.js +20 -8
- package/dist/inspect.js.map +1 -1
- package/dist/mapcmd.d.ts +2 -2
- package/dist/mapcmd.js +5 -4
- package/dist/mapcmd.js.map +1 -1
- package/dist/mcp.js +1 -1
- package/dist/nochange.d.ts +1 -0
- package/dist/otpclient.d.ts +79 -0
- package/dist/otpclient.js +362 -0
- package/dist/otpclient.js.map +1 -0
- package/dist/pidlock.d.ts +99 -0
- package/dist/pidlock.js +382 -0
- package/dist/pidlock.js.map +1 -0
- package/dist/places.d.ts +17 -0
- package/dist/places.js +44 -2
- package/dist/places.js.map +1 -1
- package/dist/policy.d.ts +2 -1
- package/dist/policy.js +5 -2
- package/dist/policy.js.map +1 -1
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.js +2 -0
- package/dist/protocol.js.map +1 -1
- package/dist/report.d.ts +8 -0
- package/dist/report.js +34 -1
- package/dist/report.js.map +1 -1
- package/dist/routes.d.ts +1 -1
- package/dist/routes.js +27 -11
- package/dist/routes.js.map +1 -1
- package/dist/runschedule.d.ts +119 -0
- package/dist/runschedule.js +281 -0
- package/dist/runschedule.js.map +1 -0
- package/dist/runtime.d.ts +48 -1
- package/dist/runtime.js +214 -18
- package/dist/runtime.js.map +1 -1
- package/dist/schedule.d.ts +145 -0
- package/dist/schedule.js +401 -0
- package/dist/schedule.js.map +1 -0
- package/dist/schedulehost.d.ts +73 -0
- package/dist/schedulehost.js +391 -0
- package/dist/schedulehost.js.map +1 -0
- package/dist/script.d.ts +2 -0
- package/dist/script.js +2 -0
- package/dist/script.js.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/thincall.d.ts +13 -0
- package/dist/thincall.js +139 -59
- package/dist/thincall.js.map +1 -1
- package/dist/tool-text-data.d.ts +1 -1
- package/dist/tool-text-data.js +1 -1
- package/dist/tool-text-data.js.map +1 -1
- package/dist/tool-text.d.ts +1 -1
- package/dist/tools.d.ts +116 -6
- package/dist/tools.js +758 -134
- package/dist/tools.js.map +1 -1
- package/dist/unlockcmd.d.ts +44 -0
- package/dist/unlockcmd.js +80 -0
- package/dist/unlockcmd.js.map +1 -0
- package/dist/uxcheck.d.ts +1 -0
- package/dist/vault.d.ts +75 -0
- package/dist/vault.js +395 -0
- package/dist/vault.js.map +1 -0
- package/package.json +1 -1
- package/skills/tapline/SKILL.md +34 -6
- package/skills/tapline/references/setup.md +11 -1
package/dist/accounts.js
CHANGED
|
@@ -1,26 +1,71 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
2
3
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
4
|
import { dirname, join, parse, resolve } from "node:path";
|
|
4
5
|
import { z } from "zod";
|
|
6
|
+
import { stable } from "./mapfile.js";
|
|
7
|
+
import { openProtected, peekFile, plainWarning, realVaultDeps, VaultOpenError } from "./vault.js";
|
|
5
8
|
/**
|
|
6
|
-
*
|
|
7
|
-
* from the agent:
|
|
8
|
-
* whole text of a step, and `redact` scrubs every value from what the app sends
|
|
9
|
+
* The vault (AGENT-TOOLS 1.14 §20, which grew out of the test accounts of 1.13 §18.6). The file holds real test credentials
|
|
10
|
+
* and personal data, so this module is built to keep them from the agent: a plain file is refused inside a git work tree that
|
|
11
|
+
* doesn't ignore it, a value is typed only as the whole text of a step, and `redact` scrubs every value from what the app sends
|
|
12
|
+
* back. A version 1 accounts file still loads; each of its entries is an `account`.
|
|
9
13
|
*/
|
|
14
|
+
/** The name a version 1 account may have (kept as it was); version 2 names are ASCII letters, digits and `_`. */
|
|
10
15
|
const ACCOUNT_NAME = /^[\w.-]{1,60}$/;
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
const ENTRY_NAME = /^[A-Za-z0-9_]{1,60}$/;
|
|
17
|
+
// Fail closed: a secret field (password, username, card number, expiry, cvc, a note's text, an identity's phone, email and
|
|
18
|
+
// birthday) shorter than 3 characters is refused at load, because a value that short is not scrubbed. The message quotes no value.
|
|
19
|
+
const SHORT = "must be at least 3 characters, because a shorter secret can't be scrubbed from answers without garbling other text";
|
|
20
|
+
/**
|
|
21
|
+
* A value shorter than this is held and typed, but not scrubbed: a country code such as `QA` would garble every answer. Only
|
|
22
|
+
* non-secret fields (an address's street, city, postcode and country, an identity's name) may be that short; every secret
|
|
23
|
+
* field is refused below this length at load.
|
|
24
|
+
*/
|
|
25
|
+
const MIN_SCRUB = 3;
|
|
26
|
+
// -- kinds and fields (§20.1)
|
|
27
|
+
export const VAULT_KINDS = ["account", "card", "address", "identity", "note", "phone"];
|
|
28
|
+
/** The fields each kind has. `phone` is Tapwright's: Tapline holds it and never types it. */
|
|
29
|
+
export const KIND_FIELDS = {
|
|
30
|
+
account: ["username", "password"],
|
|
31
|
+
card: ["number", "expiry", "cvc"],
|
|
32
|
+
address: ["street", "city", "postcode", "country"],
|
|
33
|
+
identity: ["name", "phone", "email", "birthday"],
|
|
34
|
+
note: ["text"],
|
|
35
|
+
phone: ["passcode"],
|
|
36
|
+
};
|
|
37
|
+
const secret = z.string().min(3, SHORT);
|
|
38
|
+
const plain = z.string().min(1);
|
|
39
|
+
const apps = z.array(z.string().min(1)).optional();
|
|
40
|
+
const entrySchema = z.discriminatedUnion("kind", [
|
|
41
|
+
z.object({ kind: z.literal("account"), username: secret.optional(), password: secret.optional(), apps }),
|
|
42
|
+
z.object({ kind: z.literal("card"), number: secret.optional(), expiry: secret.optional(), cvc: secret.optional(), apps }),
|
|
43
|
+
z.object({ kind: z.literal("address"), street: plain.optional(), city: plain.optional(), postcode: plain.optional(), country: plain.optional(), apps }),
|
|
44
|
+
z.object({ kind: z.literal("identity"), name: plain.optional(), phone: secret.optional(), email: secret.optional(), birthday: secret.optional(), apps }),
|
|
45
|
+
z.object({ kind: z.literal("note"), text: secret.optional(), apps }),
|
|
46
|
+
z.object({ kind: z.literal("phone"), passcode: plain.optional(), apps }),
|
|
47
|
+
]);
|
|
48
|
+
/** Version 2 (§20.1). A top-level `otp` key is Tapwright's and is ignored here. */
|
|
49
|
+
export const vaultFileSchema = z.object({
|
|
50
|
+
version: z.literal(2),
|
|
51
|
+
vault: z.record(z.string().regex(ENTRY_NAME, "entry names use ASCII letters, digits and '_'"), entrySchema),
|
|
52
|
+
});
|
|
13
53
|
const accountSchema = z.object({
|
|
14
|
-
username:
|
|
15
|
-
password:
|
|
54
|
+
username: secret.optional(),
|
|
55
|
+
password: secret.optional(),
|
|
16
56
|
/** Tapwright's: the phone's passcode. Tapline loads it without complaint and never types it. */
|
|
17
57
|
passcode: z.string().min(1).optional(),
|
|
18
|
-
apps
|
|
58
|
+
apps,
|
|
19
59
|
});
|
|
60
|
+
/** Version 1 (§18.6). */
|
|
20
61
|
export const accountsFileSchema = z.object({
|
|
21
62
|
version: z.literal(1),
|
|
22
63
|
accounts: z.record(z.string().regex(ACCOUNT_NAME, "account names use letters, digits, '.', '_' and '-'"), accountSchema),
|
|
23
64
|
});
|
|
65
|
+
/** Where `value=` and the other answers name a value: `<home's street>`. */
|
|
66
|
+
export function placeholderOf(name, field) {
|
|
67
|
+
return `<${name}'s ${field}>`;
|
|
68
|
+
}
|
|
24
69
|
function git(args, cwd) {
|
|
25
70
|
return new Promise((done) => {
|
|
26
71
|
execFile("git", args, { cwd, timeout: 10_000, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
|
|
@@ -54,81 +99,732 @@ export const realGitProbe = {
|
|
|
54
99
|
},
|
|
55
100
|
};
|
|
56
101
|
/** What the user is told wherever the accounts option is described (the parameter text, `--help`, `doctor`). */
|
|
57
|
-
export const ACCOUNTS_WARNING = "A password stays out of pictures only while its field is secure; a \"show password\" toggle shows it. Keep the
|
|
102
|
+
export const ACCOUNTS_WARNING = "A password stays out of pictures only while its field is secure; a \"show password\" toggle shows it. Keep the vault file outside the folders the agent may read.";
|
|
58
103
|
/** The refusal's words, for the first answer, `doctor` and every use. */
|
|
59
104
|
export function blockedMessage(file) {
|
|
60
105
|
return `accounts file is inside the git repo at ${file} and not ignored; move it or add it to .gitignore.`;
|
|
61
106
|
}
|
|
62
|
-
|
|
63
|
-
|
|
107
|
+
// -- redaction (§20.3)
|
|
108
|
+
/** Invisible direction marks (§3.3) and zero-width characters: the app strips the first, and text can carry the second. */
|
|
109
|
+
const INVISIBLE_CLASS = "\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B-\u200D\u2060\uFEFF";
|
|
110
|
+
const INVISIBLE = new RegExp(`[${INVISIBLE_CLASS}]`, "g");
|
|
111
|
+
const INVISIBLE_ANY = new RegExp(`[${INVISIBLE_CLASS}]`);
|
|
112
|
+
function escapeRegExp(text) {
|
|
113
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
}
|
|
115
|
+
/** JSON escaping as a serializer that writes only ASCII produces it (`é`). */
|
|
116
|
+
function asciiJson(text) {
|
|
117
|
+
return JSON.stringify(text)
|
|
118
|
+
.slice(1, -1)
|
|
119
|
+
.replace(/[^\x00-\x7f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
120
|
+
}
|
|
121
|
+
const CUT = 200;
|
|
64
122
|
/**
|
|
65
|
-
* The shapes a value can take on its way back: as it is,
|
|
66
|
-
* as the app's `normalizeText` leaves it
|
|
123
|
+
* The shapes a value can take on its way back: as it is, with direction marks and zero-width characters stripped, with
|
|
124
|
+
* whitespace collapsed (as the app's `normalizeText` leaves it), each of those inside a JSON string (also twice, and with
|
|
125
|
+
* non-ASCII escaped), and in a URL (percent or form encoded, either case of hex). A value the app cuts at 200 characters
|
|
126
|
+
* leaves its first 200.
|
|
67
127
|
*/
|
|
68
|
-
function variantsOf(value) {
|
|
69
|
-
const
|
|
128
|
+
export function variantsOf(value) {
|
|
129
|
+
const collapse = (text) => text.replace(/\s+/g, " ").trim();
|
|
130
|
+
const stripped = value.replace(INVISIBLE, "");
|
|
131
|
+
const bases = new Set([value, stripped, collapse(value), collapse(stripped)]);
|
|
132
|
+
for (const base of [...bases])
|
|
133
|
+
if (base.length > CUT)
|
|
134
|
+
bases.add(base.slice(0, CUT));
|
|
70
135
|
const forms = new Set();
|
|
71
|
-
for (const base of
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
136
|
+
for (const base of bases) {
|
|
137
|
+
const jsons = [base, JSON.stringify(base).slice(1, -1), asciiJson(base), JSON.stringify(JSON.stringify(base)).slice(1, -1)];
|
|
138
|
+
for (const form of jsons) {
|
|
139
|
+
forms.add(form);
|
|
140
|
+
const percent = encodeURIComponent(form);
|
|
141
|
+
forms.add(percent);
|
|
142
|
+
forms.add(percent.replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase()));
|
|
143
|
+
forms.add(percent.replace(/%20/g, "+"));
|
|
144
|
+
// URLSearchParams encodes more than encodeURIComponent does (`~` becomes %7E).
|
|
145
|
+
forms.add(new URLSearchParams({ k: form }).toString().slice(2));
|
|
146
|
+
}
|
|
79
147
|
}
|
|
80
148
|
return [...forms].filter((form) => form !== "");
|
|
81
149
|
}
|
|
82
|
-
/**
|
|
83
|
-
function
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
150
|
+
/** The value with any whitespace run matching any whitespace and invisible characters allowed between its characters. */
|
|
151
|
+
function fuzzySource(value) {
|
|
152
|
+
const gap = `[${INVISIBLE_CLASS}]*`;
|
|
153
|
+
const parts = [];
|
|
154
|
+
for (const piece of value.split(/(\s+)/)) {
|
|
155
|
+
if (piece === "")
|
|
156
|
+
continue;
|
|
157
|
+
if (/^\s+$/.test(piece))
|
|
158
|
+
parts.push(`[\\s${INVISIBLE_CLASS}]+`);
|
|
159
|
+
else
|
|
160
|
+
parts.push([...piece].map((c) => escapeRegExp(c)).join(gap));
|
|
161
|
+
}
|
|
162
|
+
return parts.join(gap);
|
|
163
|
+
}
|
|
164
|
+
/** A hex number as a pattern that takes either case of its letters (`e9` matches `E9`). */
|
|
165
|
+
function hexSource(digits) {
|
|
166
|
+
return [...digits].map((d) => (/[a-f]/i.test(d) ? `[${d.toLowerCase()}${d.toUpperCase()}]` : d)).join("");
|
|
167
|
+
}
|
|
168
|
+
/** What a run of whitespace can come back as: itself, a percent or plus sign, a numeric entity, or a JSON escape. */
|
|
169
|
+
const SPACE_SOURCE = `(?:[\\s${INVISIBLE_CLASS}]|%(?:09|0[aA]|0[dD]|20)|\\+|�*(?:9|10|13|32);|&#[xX]0*(?:9|a|A|d|D|20);|\\\\{1,2}(?:[nrt]|u00(?:0[9aAdD]|20)))+`;
|
|
170
|
+
/** Every way one character can be written: raw, JSON `\uXXXX` (either case, once or twice escaped), percent-encoded, an HTML numeric entity. */
|
|
171
|
+
function charSource(ch) {
|
|
172
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
173
|
+
const alts = [escapeRegExp(ch)];
|
|
174
|
+
const units = [...ch].flatMap((c) => {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (let i = 0; i < c.length; i++)
|
|
177
|
+
out.push(c.charCodeAt(i).toString(16).padStart(4, "0"));
|
|
178
|
+
return out;
|
|
179
|
+
});
|
|
180
|
+
alts.push(units.map((u) => `\\\\{1,2}u${hexSource(u)}`).join(""));
|
|
181
|
+
alts.push([...Buffer.from(ch, "utf8")].map((b) => `%${hexSource(b.toString(16).padStart(2, "0"))}`).join(""));
|
|
182
|
+
alts.push(`�*${cp};`, `&#[xX]0*${hexSource(cp.toString(16))};`);
|
|
183
|
+
return `(?:${alts.join("|")})`;
|
|
184
|
+
}
|
|
185
|
+
/** The value in any of its escape forms, character by character, with invisible characters allowed between (§20.3). */
|
|
186
|
+
function escapedSource(value) {
|
|
187
|
+
const gap = `[${INVISIBLE_CLASS}]*`;
|
|
188
|
+
const parts = [];
|
|
189
|
+
for (const piece of value.split(/(\s+)/)) {
|
|
190
|
+
if (piece === "")
|
|
191
|
+
continue;
|
|
192
|
+
parts.push(/^\s+$/.test(piece) ? SPACE_SOURCE : [...piece].map(charSource).join(gap));
|
|
193
|
+
}
|
|
194
|
+
return parts.join(gap);
|
|
195
|
+
}
|
|
196
|
+
/** The shortest prefix of a value that is scrubbed alone: what an app's cut leaves of it (§20.3, a fragment of 12 or more). */
|
|
197
|
+
const PREFIX_MIN = 12;
|
|
198
|
+
function plainView(text) {
|
|
199
|
+
const start = [];
|
|
200
|
+
const end = [];
|
|
201
|
+
for (let i = 0; i < text.length; i++) {
|
|
202
|
+
start.push(i);
|
|
203
|
+
end.push(i + 1);
|
|
204
|
+
}
|
|
205
|
+
return { text, start, end };
|
|
206
|
+
}
|
|
207
|
+
const HEX2 = /^[0-9a-fA-F]{2}$/;
|
|
208
|
+
function isHexCode(code) {
|
|
209
|
+
return (code >= 48 && code <= 57) || (code >= 65 && code <= 70) || (code >= 97 && code <= 102);
|
|
210
|
+
}
|
|
211
|
+
/** The direction marks and zero-width characters of INVISIBLE_CLASS, by code. */
|
|
212
|
+
function isInvisibleCode(code) {
|
|
213
|
+
return code === 0x200e || code === 0x200f || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069) || (code >= 0x200b && code <= 0x200d) || code === 0x2060 || code === 0xfeff;
|
|
214
|
+
}
|
|
215
|
+
/** How many bytes the UTF-8 sequence opened by `lead` has (1 for a byte that opens none). */
|
|
216
|
+
function sequenceLength(lead) {
|
|
217
|
+
if (lead < 0x80)
|
|
218
|
+
return 1;
|
|
219
|
+
if (lead >= 0xf0 && lead < 0xf8)
|
|
220
|
+
return 4;
|
|
221
|
+
if (lead >= 0xe0 && lead < 0xf0)
|
|
222
|
+
return 3;
|
|
223
|
+
if (lead >= 0xc2 && lead < 0xe0)
|
|
224
|
+
return 2;
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
const SIMPLE_ESCAPES = { n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", "\\": "\\", '"': '"', "/": "/", "'": "'" };
|
|
228
|
+
const NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", sol: "/", bsol: "\\", num: "#", percnt: "%", plus: "+", equals: "=" };
|
|
229
|
+
const ENTITY = /^&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z]+));/;
|
|
230
|
+
/**
|
|
231
|
+
* One pass over a view that decodes every escape in it at once, each in a single left-to-right sweep (so the work is linear):
|
|
232
|
+
* a percent run (`%XX` bytes as UTF-8, a byte that is no sequence of its own stays escaped), JSON escapes (`\uXXXX` either hex
|
|
233
|
+
* case, `\n`, `\\`, `\"` ...), HTML entities (numeric of any padding, and the common named ones) and invisible characters (dropped).
|
|
234
|
+
* Every decoded character keeps the span of the bytes it was written with, and only those. `+` is not touched: it can be a form
|
|
235
|
+
* space or a plus, and the matcher accepts either. Null when nothing changed.
|
|
236
|
+
*/
|
|
237
|
+
function decodePass(view) {
|
|
238
|
+
const src = view.text;
|
|
239
|
+
const n = src.length;
|
|
240
|
+
const parts = [];
|
|
241
|
+
const start = [];
|
|
242
|
+
const end = [];
|
|
243
|
+
let changed = false;
|
|
244
|
+
const put = (piece, from, to) => {
|
|
245
|
+
parts.push(piece);
|
|
246
|
+
for (let k = 0; k < piece.length; k++) {
|
|
247
|
+
start.push(from);
|
|
248
|
+
end.push(to);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
let i = 0;
|
|
252
|
+
while (i < n) {
|
|
253
|
+
const code = src.charCodeAt(i);
|
|
254
|
+
if (isInvisibleCode(code)) {
|
|
255
|
+
changed = true;
|
|
256
|
+
i += 1;
|
|
257
|
+
}
|
|
258
|
+
else if (code === 37) {
|
|
259
|
+
// a run of %XX is read once, whole: a byte that decodes to nothing is kept and the sweep goes on past it
|
|
260
|
+
let j = i;
|
|
261
|
+
const bytes = [];
|
|
262
|
+
while (src.charCodeAt(j) === 37 && isHexCode(src.charCodeAt(j + 1)) && isHexCode(src.charCodeAt(j + 2))) {
|
|
263
|
+
bytes.push(parseInt(src.slice(j + 1, j + 3), 16));
|
|
264
|
+
j += 3;
|
|
265
|
+
}
|
|
266
|
+
if (bytes.length === 0) {
|
|
267
|
+
put("%", view.start[i], view.end[i]);
|
|
268
|
+
i += 1;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
let b = 0;
|
|
272
|
+
while (b < bytes.length) {
|
|
273
|
+
const at = i + b * 3;
|
|
274
|
+
const lead = bytes[b];
|
|
275
|
+
const need = sequenceLength(lead);
|
|
276
|
+
let piece = null;
|
|
277
|
+
if (need === 1 && lead < 0x80)
|
|
278
|
+
piece = String.fromCharCode(lead);
|
|
279
|
+
else if (need > 1 && b + need <= bytes.length) {
|
|
280
|
+
const decoded = Buffer.from(bytes.slice(b, b + need)).toString("utf8");
|
|
281
|
+
if (!decoded.includes("�"))
|
|
282
|
+
piece = decoded;
|
|
283
|
+
}
|
|
284
|
+
if (piece === null) {
|
|
285
|
+
for (let k = 0; k < 3; k++)
|
|
286
|
+
put(src[at + k], view.start[at + k], view.end[at + k]);
|
|
287
|
+
b += 1;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
put(piece, view.start[at], view.end[at + need * 3 - 1]);
|
|
291
|
+
changed = true;
|
|
292
|
+
b += need;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
i = j;
|
|
296
|
+
}
|
|
297
|
+
else if (code === 92) {
|
|
298
|
+
const next = src[i + 1];
|
|
299
|
+
let piece = null;
|
|
300
|
+
let used = 0;
|
|
301
|
+
if (next === "u" && i + 6 <= n && isHexCode(src.charCodeAt(i + 2)) && isHexCode(src.charCodeAt(i + 3)) && isHexCode(src.charCodeAt(i + 4)) && isHexCode(src.charCodeAt(i + 5))) {
|
|
302
|
+
piece = String.fromCharCode(parseInt(src.slice(i + 2, i + 6), 16));
|
|
303
|
+
used = 6;
|
|
304
|
+
}
|
|
305
|
+
else if (next !== undefined && Object.prototype.hasOwnProperty.call(SIMPLE_ESCAPES, next)) {
|
|
306
|
+
piece = SIMPLE_ESCAPES[next];
|
|
307
|
+
used = 2;
|
|
308
|
+
}
|
|
309
|
+
if (piece === null) {
|
|
310
|
+
put("\\", view.start[i], view.end[i]);
|
|
311
|
+
i += 1;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
put(piece, view.start[i], view.end[i + used - 1]);
|
|
315
|
+
changed = true;
|
|
316
|
+
i += used;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else if (code === 38) {
|
|
320
|
+
const m = ENTITY.exec(src.slice(i, i + 16));
|
|
321
|
+
let piece;
|
|
322
|
+
if (m !== null) {
|
|
323
|
+
if (m[1] !== undefined || m[2] !== undefined) {
|
|
324
|
+
const cp = m[1] !== undefined ? parseInt(m[1], 10) : parseInt(m[2], 16);
|
|
325
|
+
piece = cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : undefined;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
const name = m[3].toLowerCase();
|
|
329
|
+
piece = Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, name) ? NAMED_ENTITIES[name] : undefined;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (m === null || piece === undefined) {
|
|
333
|
+
put("&", view.start[i], view.end[i]);
|
|
334
|
+
i += 1;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
put(piece, view.start[i], view.end[i + m[0].length - 1]);
|
|
338
|
+
changed = true;
|
|
339
|
+
i += m[0].length;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
parts.push(src[i]);
|
|
344
|
+
start.push(view.start[i]);
|
|
345
|
+
end.push(view.end[i]);
|
|
346
|
+
i += 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return changed ? { text: parts.join(""), start, end } : null;
|
|
350
|
+
}
|
|
351
|
+
/** How many decoding passes at most: each layer of encoding takes one, and a text with more is not one a person wrote. */
|
|
352
|
+
const MAX_LAYERS = 8;
|
|
353
|
+
const ESCAPE_HINT = new RegExp(`[%\\\\&${INVISIBLE_CLASS}]`);
|
|
354
|
+
/**
|
|
355
|
+
* The text and what it decodes to, pass after pass, until nothing more decodes (at most MAX_LAYERS passes, each linear in the
|
|
356
|
+
* text). Every layer in any nesting order (HTML, JSON, percent) is undone by the passes together; the stages are all kept, so
|
|
357
|
+
* a text that was only partly encoded is read as it stands at each stage too.
|
|
358
|
+
*/
|
|
359
|
+
function viewsOf(text) {
|
|
360
|
+
const views = [plainView(text)];
|
|
361
|
+
if (!ESCAPE_HINT.test(text))
|
|
362
|
+
return views;
|
|
363
|
+
for (let layer = 0; layer < MAX_LAYERS; layer++) {
|
|
364
|
+
const next = decodePass(views[views.length - 1]);
|
|
365
|
+
if (next === null || next.text === views[views.length - 1].text)
|
|
366
|
+
break;
|
|
367
|
+
views.push(next);
|
|
368
|
+
}
|
|
369
|
+
return views;
|
|
370
|
+
}
|
|
371
|
+
/** The view without invisible characters, each whitespace run as one space. */
|
|
372
|
+
function normalizedView(view) {
|
|
373
|
+
const parts = [];
|
|
374
|
+
const start = [];
|
|
375
|
+
const end = [];
|
|
376
|
+
let inSpace = false;
|
|
377
|
+
for (let i = 0; i < view.text.length; i++) {
|
|
378
|
+
const c = view.text[i];
|
|
379
|
+
if (isInvisibleCode(view.text.charCodeAt(i)))
|
|
380
|
+
continue;
|
|
381
|
+
const space = /\s/.test(c);
|
|
382
|
+
if (space && inSpace) {
|
|
383
|
+
end[end.length - 1] = view.end[i];
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
inSpace = space;
|
|
387
|
+
parts.push(space ? " " : c);
|
|
388
|
+
start.push(view.start[i]);
|
|
389
|
+
end.push(view.end[i]);
|
|
390
|
+
}
|
|
391
|
+
return { text: parts.join(""), start, end };
|
|
392
|
+
}
|
|
393
|
+
/** How much of a half-written escape (a cut in the middle of `%C3%A`, `\u00`, `&#x`) follows `at`: it belongs to the fragment. */
|
|
394
|
+
function dangling(text, at) {
|
|
395
|
+
const rest = text.slice(at, at + 64);
|
|
396
|
+
const percent = /^%(?:[0-9a-fA-F](?![0-9a-fA-F]))?/.exec(rest);
|
|
397
|
+
if (percent !== null && !HEX2.test(rest.slice(1, 3)))
|
|
398
|
+
return percent[0].length;
|
|
399
|
+
const json = /^\\{1,2}u[0-9a-fA-F]{0,3}(?![0-9a-fA-F])/.exec(rest);
|
|
400
|
+
if (json !== null)
|
|
401
|
+
return json[0].length;
|
|
402
|
+
const entity = /^&#?[xX]?[0-9a-fA-F]*(?![0-9a-fA-F;])/.exec(rest);
|
|
403
|
+
if (entity !== null && entity[0].length > 1 && /^&#/.test(rest))
|
|
404
|
+
return entity[0].length;
|
|
405
|
+
const run = /^(?:%[0-9a-fA-F]{2})+/.exec(rest);
|
|
406
|
+
if (run !== null) {
|
|
407
|
+
const bytes = [];
|
|
408
|
+
for (let k = 0; k < run[0].length; k += 3)
|
|
409
|
+
bytes.push(parseInt(run[0].slice(k + 1, k + 3), 16));
|
|
410
|
+
let b = 0;
|
|
411
|
+
while (b < bytes.length) {
|
|
412
|
+
const need = sequenceLength(bytes[b]);
|
|
413
|
+
if (b + need > bytes.length)
|
|
414
|
+
return (bytes.length - b) * 3;
|
|
415
|
+
b += need;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
function probeOf(base, token) {
|
|
421
|
+
const head = base.slice(0, PREFIX_MIN);
|
|
422
|
+
return { base, token, head: new RegExp([...head].map((c) => (c === " " ? "[ +]" : escapeRegExp(c))).join(""), "g") };
|
|
423
|
+
}
|
|
424
|
+
const sameChar = (seen, wanted) => seen === wanted || (wanted === " " && seen === "+");
|
|
425
|
+
/**
|
|
426
|
+
* Every place a decoded view holds a value (all of it, when it is shorter than 12 characters) or a prefix of 12 or more of its
|
|
427
|
+
* characters: the first 12 and as many more as the text goes on to match. The span is the original text of those characters.
|
|
428
|
+
*/
|
|
429
|
+
function probeSpans(views, probe, out) {
|
|
430
|
+
const { base, head, token } = probe;
|
|
431
|
+
const need = Math.min(PREFIX_MIN, base.length);
|
|
432
|
+
for (const view of views) {
|
|
433
|
+
if (view.text.length < need)
|
|
434
|
+
continue;
|
|
435
|
+
head.lastIndex = 0;
|
|
436
|
+
for (let m = head.exec(view.text); m !== null; m = head.exec(view.text)) {
|
|
437
|
+
const from = m.index;
|
|
438
|
+
head.lastIndex = from + 1;
|
|
439
|
+
let len = need;
|
|
440
|
+
while (len < base.length && sameChar(view.text[from + len], base[len]))
|
|
441
|
+
len++;
|
|
442
|
+
const last = from + len - 1;
|
|
443
|
+
const extra = dangling(view.text, last + 1);
|
|
444
|
+
out.push({ start: view.start[from], end: view.end[last + extra], token });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// -- encoded forms matched directly (the literal reading is never lost to decoding)
|
|
449
|
+
/** One layer of encoding a value can be wrapped in on its way back. */
|
|
450
|
+
const ENCODERS = [
|
|
451
|
+
(s) => JSON.stringify(s).slice(1, -1),
|
|
452
|
+
(s) => asciiJson(s),
|
|
453
|
+
(s) => asciiJson(s).replace(/\\u([0-9a-f]{4})/g, (_m, h) => `\\u${h.toUpperCase()}`),
|
|
454
|
+
(s) => encodeURIComponent(s),
|
|
455
|
+
(s) => encodeURIComponent(s).replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase()),
|
|
456
|
+
(s) => encodeURIComponent(s).replace(/%20/g, "+"),
|
|
457
|
+
(s) => encodeURIComponent(s).replace(/%[0-9A-F]{2}/g, (m) => m.toLowerCase()).replace(/%20/g, "+"),
|
|
458
|
+
(s) => new URLSearchParams({ k: s }).toString().slice(2),
|
|
459
|
+
(s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"),
|
|
460
|
+
(s) => [...s].map((c) => (/[a-zA-Z0-9]/.test(c) ? c : `&#${c.codePointAt(0)};`)).join(""),
|
|
461
|
+
// a serializer that names the four markup characters and writes every other symbol as a number
|
|
462
|
+
(s) => [...s].map((c) => (c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : /[a-zA-Z0-9]/.test(c) ? c : `&#${c.codePointAt(0)};`)).join(""),
|
|
463
|
+
(s) => [...s].map((c) => (c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : c === '"' ? """ : /[a-zA-Z0-9]/.test(c) ? c : `&#x${(c.codePointAt(0) ?? 0).toString(16)};`)).join(""),
|
|
464
|
+
(s) => [...s].map((c) => (/[a-zA-Z0-9]/.test(c) ? c : `&#x${(c.codePointAt(0) ?? 0).toString(16)};`)).join(""),
|
|
465
|
+
(s) => [...s].map((c) => (/[a-zA-Z0-9]/.test(c) ? c : `&#x${(c.codePointAt(0) ?? 0).toString(16).toUpperCase()};`)).join(""),
|
|
466
|
+
];
|
|
467
|
+
/** How many layers of encoding are combined, by how long the value is (the forms are values times encoders to that power). */
|
|
468
|
+
function layerLimit(length) {
|
|
469
|
+
return length <= 64 ? 3 : length <= 300 ? 2 : 1;
|
|
470
|
+
}
|
|
471
|
+
const FORM_CAP = 20000;
|
|
472
|
+
/**
|
|
473
|
+
* Every way the value reads once it is encoded: each encoding alone and the combinations in any order up to the layer limit
|
|
474
|
+
* (JSON inside a URL, a URL inside HTML ...). A literal `%41`, a quote or an ampersand in the value is written, not decoded, in
|
|
475
|
+
* each, so the form a text really holds can be found without decoding it first.
|
|
476
|
+
*/
|
|
477
|
+
function encodedForms(base) {
|
|
478
|
+
const seen = new Set([base]);
|
|
479
|
+
let layer = [base];
|
|
480
|
+
for (let depth = 0; depth < layerLimit(base.length); depth++) {
|
|
481
|
+
const next = [];
|
|
482
|
+
for (const text of layer) {
|
|
483
|
+
for (const encode of ENCODERS) {
|
|
484
|
+
const form = encode(text);
|
|
485
|
+
if (form.length > FORM_CAP || seen.has(form))
|
|
486
|
+
continue;
|
|
487
|
+
seen.add(form);
|
|
488
|
+
next.push(form);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
layer = next;
|
|
492
|
+
}
|
|
493
|
+
seen.delete(base);
|
|
494
|
+
return [...seen].filter((form) => form !== "");
|
|
495
|
+
}
|
|
496
|
+
function formIndexOf(bases) {
|
|
497
|
+
const heads = new Map();
|
|
498
|
+
const shorts = [];
|
|
499
|
+
const seen = new Set();
|
|
500
|
+
for (const [base, token] of bases) {
|
|
501
|
+
for (const form of [base, ...encodedForms(base)]) {
|
|
502
|
+
const key = `${token}\u0000${form}`;
|
|
503
|
+
if (seen.has(key))
|
|
504
|
+
continue;
|
|
505
|
+
seen.add(key);
|
|
506
|
+
if (form.length < PREFIX_MIN) {
|
|
507
|
+
if (form.length >= MIN_SCRUB)
|
|
508
|
+
shorts.push({ form, token });
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const head = form.slice(0, PREFIX_MIN);
|
|
512
|
+
const list = heads.get(head);
|
|
513
|
+
if (list === undefined)
|
|
514
|
+
heads.set(head, [{ form, token }]);
|
|
515
|
+
else
|
|
516
|
+
list.push({ form, token });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { heads, shorts };
|
|
520
|
+
}
|
|
521
|
+
/** How many characters a fragment of an encoded form reads as once decoded (`https%253A%252F%252Fp` is `https://p`: 9). */
|
|
522
|
+
function decodedLength(fragment) {
|
|
523
|
+
const views = viewsOf(fragment);
|
|
524
|
+
return [...views[views.length - 1].text].length;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Whether a prefix of an encoded form, which carries fewer than 12 decoded characters, is a text really cut there: it ends the text,
|
|
528
|
+
* a cut marker (`…`, `...`, `…(+N)`) follows, or what follows is not a character that could carry the value on (a delimiter such as
|
|
529
|
+
* `]`, a space or a quote that the form itself never holds). A letter, a digit or one of the form's own characters that differs from the
|
|
530
|
+
* form's next one says the text goes on as something else: not a truncation.
|
|
531
|
+
*/
|
|
532
|
+
function reallyCut(text, end, form) {
|
|
533
|
+
if (end >= text.length)
|
|
534
|
+
return true;
|
|
535
|
+
const next = text[end];
|
|
536
|
+
if (next === "\u2026" || text.startsWith("...", end))
|
|
537
|
+
return true;
|
|
538
|
+
return !/[A-Za-z0-9]/.test(next) && !form.includes(next);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Every place the text holds an encoded form of a value, or a prefix of 12 or more characters of one. The text is searched as it
|
|
542
|
+
* stands and in each decoded view, so a form that decoding would damage is found at the stage where it still reads literally.
|
|
543
|
+
*/
|
|
544
|
+
function formSpans(views, index, out) {
|
|
545
|
+
for (const view of views) {
|
|
546
|
+
const text = view.text;
|
|
547
|
+
for (const { form, token } of index.shorts) {
|
|
548
|
+
let at = text.indexOf(form);
|
|
549
|
+
while (at !== -1) {
|
|
550
|
+
out.push({ start: view.start[at], end: view.end[at + form.length - 1], token });
|
|
551
|
+
at = text.indexOf(form, at + 1);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
for (let i = 0; i + PREFIX_MIN <= text.length; i++) {
|
|
555
|
+
const list = index.heads.get(text.slice(i, i + PREFIX_MIN));
|
|
556
|
+
if (list === undefined)
|
|
557
|
+
continue;
|
|
558
|
+
for (const { form, token } of list) {
|
|
559
|
+
let len = PREFIX_MIN;
|
|
560
|
+
while (len < form.length && i + len < text.length && text.charCodeAt(i + len) === form.charCodeAt(len))
|
|
561
|
+
len++;
|
|
562
|
+
// 12 encoded characters can hold far fewer decoded ones: such a prefix is a match only where the text is really cut.
|
|
563
|
+
if (len < form.length && decodedLength(text.slice(i, i + len)) < PREFIX_MIN && !reallyCut(text, i + len, form))
|
|
564
|
+
continue;
|
|
565
|
+
out.push({ start: view.start[i], end: view.end[i + len - 1], token });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Every value's every form, found in one pass and cut into spans. Where spans overlap the leftmost wins (the longest of those
|
|
572
|
+
* that start together), and one that runs past its end is scrubbed beyond it too, so no fragment of a longer value survives a
|
|
573
|
+
* shorter one. A placeholder is never scrubbed again.
|
|
574
|
+
*/
|
|
575
|
+
function scrubber(entries, tokenOf = placeholderOf) {
|
|
576
|
+
const literals = new Map();
|
|
577
|
+
const patterns = new Map();
|
|
578
|
+
const bases = new Map();
|
|
579
|
+
const formBases = new Map();
|
|
580
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
581
|
+
for (const [field, value] of Object.entries(entry.values)) {
|
|
582
|
+
if (value.length < MIN_SCRUB)
|
|
583
|
+
continue;
|
|
584
|
+
const token = tokenOf(name, field);
|
|
585
|
+
for (const form of variantsOf(value))
|
|
586
|
+
if (!literals.has(form))
|
|
587
|
+
literals.set(form, token);
|
|
588
|
+
const collapsed = value.replace(INVISIBLE, "").replace(/\s+/g, " ").trim();
|
|
589
|
+
const addPattern = (source) => {
|
|
590
|
+
if (!patterns.has(source))
|
|
591
|
+
patterns.set(source, token);
|
|
592
|
+
};
|
|
593
|
+
if (value.length <= 500)
|
|
594
|
+
for (const base of new Set([value, collapsed]))
|
|
595
|
+
if (base.length >= MIN_SCRUB)
|
|
596
|
+
addPattern(fuzzySource(base));
|
|
597
|
+
if (value.length <= 2000)
|
|
598
|
+
for (const base of new Set([value, collapsed]))
|
|
599
|
+
if (base.length >= MIN_SCRUB)
|
|
600
|
+
addPattern(escapedSource(base));
|
|
601
|
+
// The value whole, and what an app's cut leaves of it (a prefix of 12 or more characters), in every layer of encoding.
|
|
602
|
+
if (collapsed.length >= MIN_SCRUB && !bases.has(collapsed))
|
|
603
|
+
bases.set(collapsed, token);
|
|
604
|
+
// The value's encoded forms are searched in the raw text itself, so decoding can never lose the literal reading.
|
|
605
|
+
for (const base of new Set([value, collapsed]))
|
|
606
|
+
if (base.length >= MIN_SCRUB && !formBases.has(base))
|
|
607
|
+
formBases.set(base, token);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const regexes = [...patterns].map(([source, token]) => ({ re: new RegExp(`(?=(${source}))`, "g"), token }));
|
|
611
|
+
const forms = [...literals];
|
|
612
|
+
const probes = [...bases].map(([base, token]) => probeOf(base, token));
|
|
613
|
+
const formIndex = formIndexOf(formBases);
|
|
614
|
+
if (forms.length === 0 && regexes.length === 0 && probes.length === 0)
|
|
615
|
+
return (text) => text;
|
|
94
616
|
return (text) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
617
|
+
if (text.length < MIN_SCRUB)
|
|
618
|
+
return text;
|
|
619
|
+
const spans = [];
|
|
620
|
+
for (const [form, token] of forms) {
|
|
621
|
+
let at = text.indexOf(form);
|
|
622
|
+
while (at !== -1) {
|
|
623
|
+
spans.push({ start: at, end: at + form.length, token });
|
|
624
|
+
at = text.indexOf(form, at + 1);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
for (const { re, token } of regexes) {
|
|
628
|
+
re.lastIndex = 0;
|
|
629
|
+
for (const m of text.matchAll(re)) {
|
|
630
|
+
const got = m[1].length;
|
|
631
|
+
if (got > 0)
|
|
632
|
+
spans.push({ start: m.index, end: m.index + got, token });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (probes.length > 0) {
|
|
636
|
+
const raw = viewsOf(text);
|
|
637
|
+
const views = raw.map(normalizedView);
|
|
638
|
+
for (const probe of probes)
|
|
639
|
+
probeSpans(views, probe, spans);
|
|
640
|
+
formSpans(raw, formIndex, spans);
|
|
641
|
+
}
|
|
642
|
+
if (spans.length === 0)
|
|
643
|
+
return text;
|
|
644
|
+
spans.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
645
|
+
let out = "";
|
|
646
|
+
let cursor = 0;
|
|
647
|
+
let last = "";
|
|
648
|
+
for (const span of spans) {
|
|
649
|
+
if (span.end <= cursor)
|
|
650
|
+
continue;
|
|
651
|
+
if (span.start >= cursor)
|
|
652
|
+
out += text.slice(cursor, span.start) + span.token;
|
|
653
|
+
else if (span.token !== last)
|
|
654
|
+
out += span.token;
|
|
655
|
+
last = span.token;
|
|
656
|
+
cursor = span.end;
|
|
657
|
+
}
|
|
658
|
+
return out + text.slice(cursor);
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* A scrubber for loose values that are not vault entries (a one-time code, AGENT-TOOLS 1.14 §20.4): each shows as `token`.
|
|
663
|
+
* The same forms and the same minimum length as a vault value.
|
|
664
|
+
*/
|
|
665
|
+
export function looseScrubber(values, token) {
|
|
666
|
+
const entries = {};
|
|
667
|
+
values.forEach((value, i) => {
|
|
668
|
+
entries[`v${i}`] = { kind: "note", values: { text: value } };
|
|
669
|
+
});
|
|
670
|
+
return scrubber(entries, () => token);
|
|
671
|
+
}
|
|
672
|
+
function normalizedEntries(file) {
|
|
673
|
+
const out = {};
|
|
674
|
+
if (file.version === 1) {
|
|
675
|
+
for (const [name, account] of Object.entries(file.accounts)) {
|
|
676
|
+
const values = {};
|
|
677
|
+
for (const field of ["username", "password", "passcode"]) {
|
|
678
|
+
const value = account[field];
|
|
679
|
+
if (value !== undefined)
|
|
680
|
+
values[field] = value;
|
|
681
|
+
}
|
|
682
|
+
const onlyPasscode = values["passcode"] !== undefined && values["username"] === undefined && values["password"] === undefined;
|
|
683
|
+
out[name] = { kind: onlyPasscode ? "phone" : "account", values, ...(account.apps !== undefined ? { apps: account.apps } : {}) };
|
|
684
|
+
}
|
|
99
685
|
return out;
|
|
686
|
+
}
|
|
687
|
+
for (const [name, entry] of Object.entries(file.vault)) {
|
|
688
|
+
const { kind, apps: appList, ...fields } = entry;
|
|
689
|
+
const values = {};
|
|
690
|
+
for (const [field, value] of Object.entries(fields))
|
|
691
|
+
if (typeof value === "string")
|
|
692
|
+
values[field] = value;
|
|
693
|
+
out[name] = { kind, values, ...(appList !== undefined ? { apps: appList } : {}) };
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
697
|
+
function has(entries, name) {
|
|
698
|
+
return Object.prototype.hasOwnProperty.call(entries, name) ? entries[name] : undefined;
|
|
699
|
+
}
|
|
700
|
+
const PASSCODE_NOTE = "an account's passcode is typed only by Tapwright on a locked phone's keypad; Tapline never types it.";
|
|
701
|
+
/** The entries each store was made from, so a scrubber over several stores can be built from their union (never chained). */
|
|
702
|
+
const ENTRIES_OF = new WeakMap();
|
|
703
|
+
/**
|
|
704
|
+
* One scrubber over the values of several stores (the current one and the ones a reload retired): every value is matched against the
|
|
705
|
+
* ORIGINAL text in one pass and the spans are merged leftmost-longest, so a value two stores share a prefix, a suffix or a middle
|
|
706
|
+
* of is never half-scrubbed by the other one. Each value keeps its own entry's placeholder.
|
|
707
|
+
*/
|
|
708
|
+
export function combinedRedact(stores, codes) {
|
|
709
|
+
const union = {};
|
|
710
|
+
stores.forEach((store, i) => {
|
|
711
|
+
const entries = ENTRIES_OF.get(store);
|
|
712
|
+
if (entries === undefined)
|
|
713
|
+
return;
|
|
714
|
+
for (const [name, entry] of Object.entries(entries))
|
|
715
|
+
union[`${i}\u0000${name}`] = entry;
|
|
716
|
+
});
|
|
717
|
+
// The registered one-time codes are sources of the same matcher (section 20.4): a code that overlaps a vault value is matched
|
|
718
|
+
// against the same original text, so the longer complete match wins and no fragment of either is left.
|
|
719
|
+
codes?.values.forEach((value, i) => {
|
|
720
|
+
union[`${CODE_KEY}${i}`] = { kind: "note", values: { text: value } };
|
|
721
|
+
});
|
|
722
|
+
return scrubber(union, (key, field) => (codes !== undefined && key.startsWith(CODE_KEY) ? codes.token : placeholderOf(key.slice(key.indexOf("\u0000") + 1), field)));
|
|
723
|
+
}
|
|
724
|
+
const CODE_KEY = "otp\u0001";
|
|
725
|
+
/** The stores that make a store's values: itself, or (a store that keeps retired values) the current one and the retired ones. */
|
|
726
|
+
const BASES_OF = new WeakMap();
|
|
727
|
+
/** A store that also scrubs every retired store's values (redaction is cumulative for a session, section 20.3), in one matcher. */
|
|
728
|
+
export function historyStore(current, retired) {
|
|
729
|
+
let built = -1;
|
|
730
|
+
let combined = current.redact;
|
|
731
|
+
const store = {
|
|
732
|
+
...current,
|
|
733
|
+
redact: (text) => {
|
|
734
|
+
if (built !== retired.length) {
|
|
735
|
+
combined = retired.length === 0 ? current.redact : combinedRedact([current, ...retired]);
|
|
736
|
+
built = retired.length;
|
|
737
|
+
}
|
|
738
|
+
return combined(text);
|
|
739
|
+
},
|
|
100
740
|
};
|
|
741
|
+
BASES_OF.set(store, () => [current, ...retired]);
|
|
742
|
+
return store;
|
|
101
743
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
744
|
+
const SHARED = new WeakMap();
|
|
745
|
+
/**
|
|
746
|
+
* Scrubs a text of every vault value (current and retired) AND every registered one-time code with ONE matcher over the original
|
|
747
|
+
* text, never one scrubber after another: where a code and a vault value overlap, the longer complete match wins. The matcher is
|
|
748
|
+
* cached until a code is registered or the vault's stores change.
|
|
749
|
+
*/
|
|
750
|
+
export function redactAll(store, codes, text) {
|
|
751
|
+
if (store === null || store === undefined)
|
|
752
|
+
return codes.redact(text);
|
|
753
|
+
const values = codes.values();
|
|
754
|
+
if (values.length === 0)
|
|
755
|
+
return store.redact(text);
|
|
756
|
+
const bases = BASES_OF.get(store)?.() ?? [store];
|
|
757
|
+
if (bases.some((base) => ENTRIES_OF.get(base) === undefined))
|
|
758
|
+
return codes.redact(store.redact(text));
|
|
759
|
+
const hit = SHARED.get(store);
|
|
760
|
+
if (hit !== undefined && hit.codes === values && hit.bases.length === bases.length && hit.bases.every((base, i) => base === bases[i]))
|
|
761
|
+
return hit.redact(text);
|
|
762
|
+
const redact = combinedRedact(bases, { values, token: codes.token });
|
|
763
|
+
SHARED.set(store, { bases: [...bases], codes: values, redact });
|
|
764
|
+
return redact(text);
|
|
765
|
+
}
|
|
766
|
+
function storeOf(entries) {
|
|
767
|
+
const scrub = scrubber(entries);
|
|
768
|
+
const names = Object.keys(entries);
|
|
769
|
+
const list = (word) => (names.length === 0 ? "the vault file has none" : `the ${word} are ${names.join(", ")}`);
|
|
770
|
+
const forApp = (entry, appId) => entry.apps !== undefined && !entry.apps.includes(appId) ? `isn't for this app (${appId}); it is for ${entry.apps.join(", ")}.` : null;
|
|
771
|
+
const store = {
|
|
108
772
|
names: () => [...names],
|
|
773
|
+
kindOf: (name) => has(entries, name)?.kind,
|
|
109
774
|
redact: scrub,
|
|
110
775
|
resolve(name, field, appId) {
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
return { ok: false, code: "not_found", message: `no account "${name}"; ${list()}.` };
|
|
114
|
-
if (
|
|
115
|
-
return { ok: false, code: "bad_params", message: `account "${name}"
|
|
776
|
+
const entry = has(entries, name);
|
|
777
|
+
if (entry === undefined)
|
|
778
|
+
return { ok: false, code: "not_found", message: `no account "${name}"; ${list("accounts")}.` };
|
|
779
|
+
if (entry.kind !== "account" && entry.kind !== "phone") {
|
|
780
|
+
return { ok: false, code: "bad_params", message: `"account": "${name}" names ${entry.kind === "address" ? "an" : "a"} ${entry.kind} entry; "account" names account entries only (use \${vault.${name}.<field>}).` };
|
|
116
781
|
}
|
|
117
|
-
const
|
|
782
|
+
const wrong = forApp(entry, appId);
|
|
783
|
+
if (wrong !== null)
|
|
784
|
+
return { ok: false, code: "bad_params", message: `account "${name}" ${wrong}` };
|
|
785
|
+
const value = entry.values[field];
|
|
118
786
|
if (value === undefined)
|
|
119
787
|
return { ok: false, code: "bad_params", message: `account "${name}" has no ${field}.` };
|
|
120
788
|
return { ok: true, value };
|
|
121
789
|
},
|
|
790
|
+
resolveEntry(name, field, appId) {
|
|
791
|
+
const entry = has(entries, name);
|
|
792
|
+
if (entry === undefined)
|
|
793
|
+
return { ok: false, code: "not_found", message: `no vault entry "${name}"; ${list("entries")}.` };
|
|
794
|
+
if (entry.kind === "phone")
|
|
795
|
+
return { ok: false, code: "bad_params", message: PASSCODE_NOTE };
|
|
796
|
+
if (!KIND_FIELDS[entry.kind].includes(field)) {
|
|
797
|
+
return { ok: false, code: "bad_params", message: `vault entry "${name}" is ${entry.kind === "address" ? "an" : "a"} ${entry.kind}; its fields are ${KIND_FIELDS[entry.kind].join(", ")}.` };
|
|
798
|
+
}
|
|
799
|
+
const wrong = forApp(entry, appId);
|
|
800
|
+
if (wrong !== null)
|
|
801
|
+
return { ok: false, code: "bad_params", message: `vault entry "${name}" ${wrong}` };
|
|
802
|
+
const value = entry.values[field];
|
|
803
|
+
if (value === undefined)
|
|
804
|
+
return { ok: false, code: "bad_params", message: `vault entry "${name}" has no ${field}.` };
|
|
805
|
+
return { ok: true, value };
|
|
806
|
+
},
|
|
122
807
|
};
|
|
808
|
+
ENTRIES_OF.set(store, entries);
|
|
809
|
+
return store;
|
|
810
|
+
}
|
|
811
|
+
/** The store of a version 1 file (the accounts of §18.6). */
|
|
812
|
+
export function accountStore(file) {
|
|
813
|
+
return storeOf(normalizedEntries(file));
|
|
814
|
+
}
|
|
815
|
+
/** The store of a version 2 file (§20.1). */
|
|
816
|
+
export function vaultStore(file) {
|
|
817
|
+
return storeOf(normalizedEntries(file));
|
|
123
818
|
}
|
|
124
819
|
/**
|
|
125
|
-
* Reads the
|
|
126
|
-
*
|
|
820
|
+
* Reads the vault file. A plain file inside a git work tree that doesn't ignore it is not loaded (a protected file holds no
|
|
821
|
+
* value, so that rule doesn't apply to it). A protected file that can't be opened loads nothing and says why. A file that
|
|
822
|
+
* can't be read or isn't the format throws a plain message that never quotes the file (it holds secrets).
|
|
127
823
|
*/
|
|
128
|
-
export async function loadAccounts(file, probe = realGitProbe) {
|
|
824
|
+
export async function loadAccounts(file, probe = realGitProbe, deps = realVaultDeps(), signal) {
|
|
129
825
|
const path = resolve(file);
|
|
130
826
|
if (!existsSync(path))
|
|
131
|
-
throw new Error(`the
|
|
827
|
+
throw new Error(`the vault file ${path} doesn't exist`);
|
|
132
828
|
// Through a symlink or a junction the file may really sit in a repo: ask about where it is.
|
|
133
829
|
let real = path;
|
|
134
830
|
try {
|
|
@@ -137,25 +833,49 @@ export async function loadAccounts(file, probe = realGitProbe) {
|
|
|
137
833
|
catch {
|
|
138
834
|
// the path stands
|
|
139
835
|
}
|
|
140
|
-
|
|
141
|
-
const inGitDir = real.split(/[\\/]/).includes(".git");
|
|
142
|
-
if (inGitDir || ((await probe.insideWorkTree(dirname(real))) && !(await probe.ignored(real)))) {
|
|
143
|
-
return { store: null, blocked: blockedMessage(path) };
|
|
144
|
-
}
|
|
145
|
-
let json;
|
|
836
|
+
let text;
|
|
146
837
|
try {
|
|
147
|
-
|
|
838
|
+
text = readFileSync(real, "utf8");
|
|
148
839
|
}
|
|
149
840
|
catch {
|
|
150
|
-
|
|
151
|
-
|
|
841
|
+
throw new Error(`the vault file ${path} can't be read`);
|
|
842
|
+
}
|
|
843
|
+
const peek = peekFile(text);
|
|
844
|
+
let json;
|
|
845
|
+
if (peek?.protected !== undefined) {
|
|
846
|
+
try {
|
|
847
|
+
json = JSON.parse(await openProtected(real, peek, deps, signal));
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
if (error instanceof VaultOpenError)
|
|
851
|
+
return { store: null, blocked: `the vault file can't be opened: ${error.message.replace(/[.\s]+$/, "")}.` };
|
|
852
|
+
throw new Error(`the vault file ${path} was opened, but what it holds isn't valid JSON`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
// A `.git` folder in the path is a repo's own files, which no .gitignore covers.
|
|
857
|
+
const inGitDir = real.split(/[\\/]/).includes(".git");
|
|
858
|
+
if (inGitDir || ((await probe.insideWorkTree(dirname(real))) && !(await probe.ignored(real)))) {
|
|
859
|
+
return { store: null, blocked: blockedMessage(path) };
|
|
860
|
+
}
|
|
861
|
+
try {
|
|
862
|
+
json = JSON.parse(text);
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// Node's own message quotes part of the text, and this file holds passwords.
|
|
866
|
+
throw new Error(`the vault file ${path} isn't valid JSON`);
|
|
867
|
+
}
|
|
152
868
|
}
|
|
153
|
-
const
|
|
869
|
+
const version = typeof json === "object" && json !== null ? json.version : undefined;
|
|
870
|
+
const parsed = version === 2 ? vaultFileSchema.safeParse(json) : accountsFileSchema.safeParse(json);
|
|
154
871
|
if (!parsed.success) {
|
|
155
872
|
const why = parsed.error.issues.map((issue) => `${issue.path.join(".") || "file"}: ${issue.message}`).join("; ");
|
|
156
|
-
throw new Error(`the
|
|
873
|
+
throw new Error(`the vault file ${path} isn't the right format: ${why}`);
|
|
157
874
|
}
|
|
158
|
-
|
|
875
|
+
const entries = normalizedEntries(parsed.data);
|
|
876
|
+
const store = storeOf(entries);
|
|
877
|
+
const fingerprint = (key) => createHmac("sha256", key).update(stable(entries)).digest("hex");
|
|
878
|
+
return peek?.protected === undefined ? { store, note: plainWarning(path), fingerprint } : { store, fingerprint };
|
|
159
879
|
}
|
|
160
880
|
/**
|
|
161
881
|
* Scrubs every string in a JSON value (an app's answer), keeping its shape. Property names are scrubbed too; two names
|
|
@@ -180,27 +900,76 @@ export function redactValue(value, redact) {
|
|
|
180
900
|
}
|
|
181
901
|
return value;
|
|
182
902
|
}
|
|
183
|
-
const WHOLE = /^\$\{account\.(\w+)\}$/;
|
|
184
903
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
904
|
+
* The backstop at a writer of files (§20.3): scrubs the content values of a JSON value, never its keys, so a script keeps
|
|
905
|
+
* its tool names and its `${vault.home.street}` placeholders.
|
|
906
|
+
*/
|
|
907
|
+
export function redactContent(value, redact) {
|
|
908
|
+
if (typeof value === "string") {
|
|
909
|
+
// `${…}` is syntax (a placeholder, a variable): only the text between is content.
|
|
910
|
+
return value
|
|
911
|
+
.split(/(\$\{[^}]*\})/)
|
|
912
|
+
.map((piece) => (piece.startsWith("${") && piece.endsWith("}") ? piece : redact(piece)))
|
|
913
|
+
.join("");
|
|
914
|
+
}
|
|
915
|
+
if (Array.isArray(value))
|
|
916
|
+
return value.map((item) => redactContent(item, redact));
|
|
917
|
+
if (typeof value === "object" && value !== null) {
|
|
918
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, CONTENT_FREE.has(key) ? item : redactContent(item, redact)]));
|
|
919
|
+
}
|
|
920
|
+
return value;
|
|
921
|
+
}
|
|
922
|
+
/** Keys whose values are the tool's own words (a tool or method name, a time, a format), never a typed or shown value. */
|
|
923
|
+
const CONTENT_FREE = new Set(["t", "tool", "source", "method", "format", "version", "kind", "app", "name", "start", "end", "route", "variables"]);
|
|
924
|
+
const WHOLE_ACCOUNT = /^\$\{account\.(\w+)\}$/;
|
|
925
|
+
const WHOLE_VAULT = /^\$\{vault\.(\w+)\.(\w+)\}$/;
|
|
926
|
+
const VAULT_ANY = /^\$\{vault\.[^}]*\}$/;
|
|
927
|
+
/** Whether a text refers to the vault (`${account.…}`, `${vault.…}`) or to a one-time code (`${otp.<app>}`, §20.4). */
|
|
928
|
+
export function mentionsVault(text) {
|
|
929
|
+
return text.includes("${account.") || text.includes("${vault.") || text.includes("${otp.");
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* `${account.username}`, `${account.password}` or `${vault.<name>.<field>}` becomes the value, but only when it is the whole
|
|
933
|
+
* text: joined with other text it could get a password past the secure-field rule. Text without a reference passes unchanged
|
|
934
|
+
* (field null).
|
|
187
935
|
*/
|
|
188
936
|
export function substituteAccount(text, store, name, appId) {
|
|
189
|
-
|
|
937
|
+
// A one-time code is fetched and typed by the tool itself (otpclient.ts); text that reaches here isn't one it can type.
|
|
938
|
+
if (text.includes("${otp.")) {
|
|
939
|
+
const alone = /^\$\{otp\.[^}]+\}$/.test(text);
|
|
940
|
+
return { ok: false, code: "bad_params", message: alone ? "a one-time code takes no account; write ${otp.<app>} alone." : "a one-time code is the whole text of a step, not part of one." };
|
|
941
|
+
}
|
|
942
|
+
const vault = WHOLE_VAULT.exec(text);
|
|
943
|
+
if (vault !== null) {
|
|
944
|
+
const entry = vault[1] ?? "";
|
|
945
|
+
const field = vault[2] ?? "";
|
|
946
|
+
const resolved = store.resolveEntry(entry, field, appId);
|
|
947
|
+
if (!resolved.ok)
|
|
948
|
+
return resolved;
|
|
949
|
+
const kind = store.kindOf(entry);
|
|
950
|
+
if (kind === undefined)
|
|
951
|
+
return { ok: false, code: "not_found", message: `no vault entry "${entry}".` };
|
|
952
|
+
return { ok: true, text: resolved.value, field, use: { name: entry, kind, field } };
|
|
953
|
+
}
|
|
954
|
+
if (VAULT_ANY.test(text)) {
|
|
955
|
+
return { ok: false, code: "bad_params", message: "a vault value is written \${vault.<name>.<field>}, for example \${vault.home.street}." };
|
|
956
|
+
}
|
|
957
|
+
const whole = WHOLE_ACCOUNT.exec(text);
|
|
190
958
|
if (whole === null) {
|
|
959
|
+
if (text.includes("${vault."))
|
|
960
|
+
return { ok: false, code: "bad_params", message: "a vault value is the whole text of a step, not part of one." };
|
|
191
961
|
if (text.includes("${account."))
|
|
192
962
|
return { ok: false, code: "bad_params", message: "an account value is the whole text of a step, not part of one." };
|
|
193
963
|
return { ok: true, text, field: null };
|
|
194
964
|
}
|
|
195
965
|
const field = whole[1];
|
|
196
|
-
if (field === "passcode")
|
|
197
|
-
return { ok: false, code: "bad_params", message:
|
|
198
|
-
}
|
|
966
|
+
if (field === "passcode")
|
|
967
|
+
return { ok: false, code: "bad_params", message: PASSCODE_NOTE };
|
|
199
968
|
if (field !== "username" && field !== "password") {
|
|
200
969
|
return { ok: false, code: "bad_params", message: `\${account.${field ?? ""}} isn't a value an account has; use \${account.username} or \${account.password}.` };
|
|
201
970
|
}
|
|
202
971
|
if (name === undefined) {
|
|
203
|
-
return { ok: false, code: "bad_params", message: `name the account: add "account": "<name>" (${store.names().length === 0 ? "the
|
|
972
|
+
return { ok: false, code: "bad_params", message: `name the account: add "account": "<name>" (${store.names().length === 0 ? "the vault file has none" : `the entries are ${store.names().join(", ")}`}).` };
|
|
204
973
|
}
|
|
205
974
|
const resolved = store.resolve(name, field, appId);
|
|
206
975
|
return resolved.ok ? { ok: true, text: resolved.value, field } : resolved;
|