tryharness 0.7.23 → 0.7.26
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/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5268 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +384 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- package/package.json +3 -3
package/lib/redact.mjs
CHANGED
|
@@ -2,7 +2,17 @@ import fs from "node:fs";
|
|
|
2
2
|
import pngjs from "pngjs";
|
|
3
3
|
import { decodeImage, imageKind } from "./ocr.mjs";
|
|
4
4
|
import { secretReason } from "./secrets.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
BASE58ISH,
|
|
7
|
+
BECH32,
|
|
8
|
+
EMAIL,
|
|
9
|
+
EVM,
|
|
10
|
+
EVM_SHORT,
|
|
11
|
+
MONEY,
|
|
12
|
+
PHONE,
|
|
13
|
+
base58,
|
|
14
|
+
phoneLike,
|
|
15
|
+
} from "./personal.mjs";
|
|
6
16
|
|
|
7
17
|
// What an image loses before it leaves the computer for an agent on Harness's side (daemon.mjs, board.file.get): the
|
|
8
18
|
// text in it is read here (ocr.mjs) and every key or password (the handoff firewall's own detection, firewall.mjs),
|
|
@@ -14,19 +24,48 @@ import { BASE58ISH, BECH32, EMAIL, EVM, EVM_SHORT, MONEY, PHONE, base58, phoneLi
|
|
|
14
24
|
// Bump when what is found or how it is drawn changes: covered copies are kept under this version.
|
|
15
25
|
export const REDACTION_VERSION = 1;
|
|
16
26
|
|
|
17
|
-
const LABELS = {
|
|
27
|
+
const LABELS = {
|
|
28
|
+
credential: "key hidden",
|
|
29
|
+
"private-key": "key hidden",
|
|
30
|
+
seed: "seed phrase hidden",
|
|
31
|
+
wallet: "address hidden",
|
|
32
|
+
money: "amount hidden",
|
|
33
|
+
email: "email hidden",
|
|
34
|
+
phone: "phone hidden",
|
|
35
|
+
};
|
|
18
36
|
const NAMES = {
|
|
19
|
-
credential: ["a key or password", "keys or passwords"],
|
|
20
|
-
|
|
37
|
+
credential: ["a key or password", "keys or passwords"],
|
|
38
|
+
"private-key": ["a private key", "private keys"],
|
|
39
|
+
seed: ["a seed phrase", "seed phrases"],
|
|
40
|
+
wallet: ["a wallet address", "wallet addresses"],
|
|
41
|
+
money: ["an amount of money", "amounts of money"],
|
|
42
|
+
email: ["an email address", "email addresses"],
|
|
43
|
+
phone: ["a phone number", "phone numbers"],
|
|
21
44
|
};
|
|
22
45
|
// When finds of about the same length overlap, the stronger kind names the box (sensitiveSpans).
|
|
23
|
-
const RANK = [
|
|
46
|
+
const RANK = [
|
|
47
|
+
"seed",
|
|
48
|
+
"private-key",
|
|
49
|
+
"wallet",
|
|
50
|
+
"credential",
|
|
51
|
+
"email",
|
|
52
|
+
"phone",
|
|
53
|
+
"money",
|
|
54
|
+
];
|
|
24
55
|
|
|
25
56
|
// The firewall takes any 64-hex value for a private key. On a screen most are file digests, and a row says which: a
|
|
26
57
|
// sha256: prefix, the "<hex> <file>" line shasum prints, or a digest or checksum label, with no key named on the row.
|
|
27
58
|
// On such a row nothing is taken for a key on the strength of that value (the firewall may name another token on it).
|
|
28
|
-
const digestRow = (row) =>
|
|
29
|
-
|
|
59
|
+
const digestRow = (row) =>
|
|
60
|
+
!/private|secret|\bkey\b|wallet|seed|mnemonic/i.test(row) &&
|
|
61
|
+
[
|
|
62
|
+
...row.matchAll(/(?<![0-9a-fA-F])(?:0x)?[0-9a-fA-F]{64}(?![0-9a-fA-F])/g),
|
|
63
|
+
].some(
|
|
64
|
+
(m) =>
|
|
65
|
+
/sha-?(?:256|384|512)[:=]\s*$/i.test(row.slice(0, m.index)) ||
|
|
66
|
+
/^\s+\S+\.\w{1,6}\b/.test(row.slice(m.index + m[0].length)) ||
|
|
67
|
+
/\bsha-?(?:256|384|512)\b|\bdigests?\b|\bchecksums?\b/i.test(row)
|
|
68
|
+
);
|
|
30
69
|
|
|
31
70
|
// Seed phrases (BIP-39, bip39-english.txt: the official English list, MIT, sha256 2f5eed53…dbda): a run of at least
|
|
32
71
|
// SEED_RUN whitespace-separated list words, nearly all different, in lower or upper case (a capital allowed on the
|
|
@@ -35,18 +74,40 @@ const digestRow = (row) => !/private|secret|\bkey\b|wallet|seed|mnemonic/i.test(
|
|
|
35
74
|
// a real test phrase (October 14), so nine is far from prose and below every phrase length.
|
|
36
75
|
const SEED_RUN = 9;
|
|
37
76
|
let wordlist = null;
|
|
38
|
-
const bip39 = () =>
|
|
77
|
+
const bip39 = () =>
|
|
78
|
+
(wordlist ||= new Set(
|
|
79
|
+
fs
|
|
80
|
+
.readFileSync(new URL("./bip39-english.txt", import.meta.url), "utf8")
|
|
81
|
+
.split("\n")
|
|
82
|
+
.map((word) => word.trim())
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
));
|
|
39
85
|
const nearWord = (word) => {
|
|
40
86
|
const list = bip39();
|
|
41
87
|
if (list.has(word)) return true;
|
|
42
88
|
if (word.length < 4) return false;
|
|
43
89
|
for (const candidate of list) {
|
|
44
|
-
if (
|
|
45
|
-
|
|
90
|
+
if (
|
|
91
|
+
candidate[0] !== word[0] ||
|
|
92
|
+
Math.abs(candidate.length - word.length) > 1
|
|
93
|
+
)
|
|
94
|
+
continue;
|
|
95
|
+
let i = 0,
|
|
96
|
+
j = 0,
|
|
97
|
+
edits = 0;
|
|
46
98
|
while (i < word.length && j < candidate.length && edits <= 1) {
|
|
47
|
-
if (word[i] === candidate[j]) {
|
|
99
|
+
if (word[i] === candidate[j]) {
|
|
100
|
+
i += 1;
|
|
101
|
+
j += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
48
104
|
edits += 1;
|
|
49
|
-
if (word.length > candidate.length) i += 1;
|
|
105
|
+
if (word.length > candidate.length) i += 1;
|
|
106
|
+
else if (word.length < candidate.length) j += 1;
|
|
107
|
+
else {
|
|
108
|
+
i += 1;
|
|
109
|
+
j += 1;
|
|
110
|
+
}
|
|
50
111
|
}
|
|
51
112
|
if (edits + (word.length - i) + (candidate.length - j) <= 1) return true;
|
|
52
113
|
}
|
|
@@ -56,16 +117,34 @@ const nearWord = (word) => {
|
|
|
56
117
|
/** Lines on one visual row joined left to right (two spaces apart, so no pattern runs across boxes), with each line's offset. */
|
|
57
118
|
function rowsOf(lines) {
|
|
58
119
|
const rows = [];
|
|
59
|
-
for (const line of [...lines].sort(
|
|
120
|
+
for (const line of [...lines].sort(
|
|
121
|
+
(a, b) => a.box[1] + a.box[3] - (b.box[1] + b.box[3])
|
|
122
|
+
)) {
|
|
60
123
|
const [, y0, , y1] = line.box;
|
|
61
|
-
const row = rows.find(
|
|
62
|
-
|
|
124
|
+
const row = rows.find(
|
|
125
|
+
(each) =>
|
|
126
|
+
Math.min(each.y1, y1) - Math.max(each.y0, y0) >=
|
|
127
|
+
0.5 * Math.min(each.y1 - each.y0, y1 - y0)
|
|
128
|
+
);
|
|
129
|
+
if (row) {
|
|
130
|
+
row.lines.push(line);
|
|
131
|
+
row.y0 = Math.min(row.y0, y0);
|
|
132
|
+
row.y1 = Math.max(row.y1, y1);
|
|
133
|
+
} else rows.push({ y0, y1, lines: [line] });
|
|
63
134
|
}
|
|
64
|
-
return rows
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
135
|
+
return rows
|
|
136
|
+
.sort((a, b) => a.y0 - b.y0)
|
|
137
|
+
.map((row) => {
|
|
138
|
+
let text = "";
|
|
139
|
+
const parts = row.lines
|
|
140
|
+
.sort((a, b) => a.box[0] - b.box[0])
|
|
141
|
+
.map((line) => {
|
|
142
|
+
const at = text ? text.length + 2 : 0;
|
|
143
|
+
text = text ? `${text} ${line.text}` : line.text;
|
|
144
|
+
return { line, at };
|
|
145
|
+
});
|
|
146
|
+
return { text, parts };
|
|
147
|
+
});
|
|
69
148
|
}
|
|
70
149
|
|
|
71
150
|
// A value wrapped inside its box (a key in a narrow card): a line ending in a long unbroken token, last on its row and
|
|
@@ -74,17 +153,42 @@ function rowsOf(lines) {
|
|
|
74
153
|
// line (a dotted name is a file or host, not a value cut short); only finds that cross the break count on it.
|
|
75
154
|
function wrapsOf(lines, rows) {
|
|
76
155
|
const out = [];
|
|
77
|
-
const lastOnRow = new Set(rows.map((row) => row.parts.at(-1).line)),
|
|
156
|
+
const lastOnRow = new Set(rows.map((row) => row.parts.at(-1).line)),
|
|
157
|
+
firstOnRow = new Set(rows.map((row) => row.parts[0].line));
|
|
78
158
|
for (const a of lines) {
|
|
79
159
|
const tail = a.text.match(/\S{16,}$/)?.[0];
|
|
80
160
|
if (!tail || /\./.test(tail.slice(-16)) || !lastOnRow.has(a)) continue;
|
|
81
|
-
const h = a.box[3] - a.box[1],
|
|
82
|
-
|
|
161
|
+
const h = a.box[3] - a.box[1],
|
|
162
|
+
char = (a.box[2] - a.box[0]) / a.text.length;
|
|
163
|
+
const column = lines.filter(
|
|
164
|
+
(each) =>
|
|
165
|
+
Math.abs(each.box[0] - a.box[0]) <= 1.5 * char &&
|
|
166
|
+
Math.abs(each.box[1] - a.box[1]) <= 10 * h
|
|
167
|
+
);
|
|
83
168
|
if (column.some((each) => each.box[2] > a.box[2] + 1.5 * char)) continue;
|
|
84
|
-
const goesOn = /^(?:0x)?[0-9a-fA-F]+$/.test(tail)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
169
|
+
const goesOn = /^(?:0x)?[0-9a-fA-F]+$/.test(tail)
|
|
170
|
+
? /^[0-9a-fA-F]+$/
|
|
171
|
+
: /^[a-z0-9]+$/.test(tail.slice(-16))
|
|
172
|
+
? /^[a-z0-9]+$/
|
|
173
|
+
: /^(?=\D*\d)[A-Za-z0-9+/=_-]+$/;
|
|
174
|
+
const b = column.find(
|
|
175
|
+
(each) =>
|
|
176
|
+
each !== a &&
|
|
177
|
+
firstOnRow.has(each) &&
|
|
178
|
+
each.box[1] - a.box[3] >= -0.3 * h &&
|
|
179
|
+
each.box[1] - a.box[3] <= 0.8 * h &&
|
|
180
|
+
Math.abs(each.box[3] - each.box[1] - h) <= 0.35 * h &&
|
|
181
|
+
goesOn.test(each.text.split(/\s/)[0])
|
|
182
|
+
);
|
|
183
|
+
if (b)
|
|
184
|
+
out.push({
|
|
185
|
+
text: a.text + b.text,
|
|
186
|
+
parts: [
|
|
187
|
+
{ line: a, at: 0 },
|
|
188
|
+
{ line: b, at: a.text.length },
|
|
189
|
+
],
|
|
190
|
+
cut: a.text.length,
|
|
191
|
+
});
|
|
88
192
|
}
|
|
89
193
|
return out;
|
|
90
194
|
}
|
|
@@ -92,65 +196,147 @@ function wrapsOf(lines, rows) {
|
|
|
92
196
|
// A word that names a password or key (not "monkey", "keyboard", "passed" or "author"); a lowercase name of a word,
|
|
93
197
|
// digits and dashes (a pod, image or package: keeper-api-7d9f8c6b5d-x2k9p, ami-0c55b159cbfafe1f0), which the credential
|
|
94
198
|
// model alone sometimes takes for a secret; a value that mixes at least three kinds of character, as passwords do.
|
|
95
|
-
const NAMES_A_SECRET =
|
|
96
|
-
|
|
97
|
-
const
|
|
199
|
+
const NAMES_A_SECRET =
|
|
200
|
+
/passw|passphrase|\bpass\b|secret|(?:\b|_|api)keys?\b|key_|token|credential|\bauth(?:orization|[_-]?token)?\b|pwd|\bpin\b/i;
|
|
201
|
+
const nameLike = (text) =>
|
|
202
|
+
/^[a-z0-9]+(?:[-._][a-z0-9]+)+$/.test(text) &&
|
|
203
|
+
text.split(/[-._]/).some((part) => /^[a-z]{3,}$/.test(part));
|
|
204
|
+
const secretish = (text) =>
|
|
205
|
+
Boolean(secretReason(text)) ||
|
|
206
|
+
(text.length >= 8 &&
|
|
207
|
+
[/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(text))
|
|
208
|
+
.length >= 3);
|
|
98
209
|
// Tokens with a known key shape on their own (secrets.mjs), wherever the firewall's pick on the line fell; a NAME=
|
|
99
210
|
// before one is not part of it (OCR may read its first letter as a digit).
|
|
100
|
-
const shaped = (text) =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
});
|
|
211
|
+
const shaped = (text) =>
|
|
212
|
+
[...text.matchAll(/\S+/g)].flatMap((m) => {
|
|
213
|
+
const token = m[0]
|
|
214
|
+
.replace(/^\w[\w.-]*=/, "")
|
|
215
|
+
.replace(/^["'`(<[{]+|["'`)>\]},;.]+$/g, "");
|
|
216
|
+
const start = m.index + m[0].lastIndexOf(token);
|
|
217
|
+
return token.length >= 12 && secretReason(token)
|
|
218
|
+
? [{ start, end: start + token.length, reason: secretReason(token) }]
|
|
219
|
+
: [];
|
|
220
|
+
});
|
|
105
221
|
|
|
106
222
|
// A value read with the label that names it: alone on the line under a short label ("Secret access key", then the
|
|
107
223
|
// key), or in a later cell of a table row whose earlier cell names it ("DEPLOY_TOKEN", then the token).
|
|
108
224
|
function labelsOf(rows) {
|
|
109
225
|
const out = [];
|
|
110
|
-
const pair = (name, value) =>
|
|
226
|
+
const pair = (name, value) =>
|
|
227
|
+
out.push({
|
|
228
|
+
text: `${name}: ${value.text}`,
|
|
229
|
+
parts: [{ line: value, at: name.length + 2 }],
|
|
230
|
+
cut: name.length + 2,
|
|
231
|
+
});
|
|
111
232
|
rows.forEach((row, i) => {
|
|
112
233
|
const next = rows[i + 1];
|
|
113
234
|
if (next && row.parts.length === 1 && next.parts.length === 1) {
|
|
114
|
-
const label = row.parts[0].line,
|
|
115
|
-
|
|
235
|
+
const label = row.parts[0].line,
|
|
236
|
+
value = next.parts[0].line,
|
|
237
|
+
h = label.box[3] - label.box[1];
|
|
238
|
+
if (
|
|
239
|
+
label.text.length <= 40 &&
|
|
240
|
+
NAMES_A_SECRET.test(label.text) &&
|
|
241
|
+
Math.abs(value.box[0] - label.box[0]) <= h &&
|
|
242
|
+
value.box[1] - label.box[3] <= 1.2 * h &&
|
|
243
|
+
/^\S{8,}$/.test(value.text)
|
|
244
|
+
)
|
|
245
|
+
pair(label.text, value);
|
|
116
246
|
}
|
|
117
247
|
row.parts.forEach(({ line }, j) => {
|
|
118
|
-
const name = line.text
|
|
119
|
-
|
|
248
|
+
const name = line.text
|
|
249
|
+
.split(/\s+/)
|
|
250
|
+
.find((word) => word.length <= 40 && NAMES_A_SECRET.test(word));
|
|
251
|
+
if (name)
|
|
252
|
+
for (const { line: value } of row.parts.slice(j + 1))
|
|
253
|
+
if (/^\S{8,}$/.test(value.text)) pair(name, value);
|
|
120
254
|
});
|
|
121
255
|
});
|
|
122
256
|
return out;
|
|
123
257
|
}
|
|
124
258
|
|
|
125
|
-
const matches = (re, text, keep = () => true) =>
|
|
259
|
+
const matches = (re, text, keep = () => true) =>
|
|
260
|
+
[...text.matchAll(re)]
|
|
261
|
+
.filter((m) => keep(m[0], text.slice(0, m.index)))
|
|
262
|
+
.map((m) => ({ start: m.index, end: m.index + m[0].length }));
|
|
126
263
|
|
|
127
264
|
// OCR reads some small lower-case letters as their capitals ("pasSword"), and the credential model leans on such words
|
|
128
265
|
// (0.53 for that line, 0.99 spelled right), so it reads rows with those capitals lowered inside otherwise lower-case
|
|
129
266
|
// words. The length is the same, so what it finds maps back as it is.
|
|
130
|
-
const uncased = (text) =>
|
|
267
|
+
const uncased = (text) =>
|
|
268
|
+
text.replace(/\b[a-z][a-zA-Z]{2,}\b/g, (word) =>
|
|
269
|
+
/^[a-zCOSUVWXZ]+$/.test(word) ? word.toLowerCase() : word
|
|
270
|
+
);
|
|
131
271
|
|
|
132
272
|
// Keys and passwords: the firewall's patterns and credential model per row. A row can hold more than one, and the
|
|
133
273
|
// firewall names one per line, so a row is asked again with what was found blanked, up to three times; a later find
|
|
134
274
|
// counts only if it has a known key shape or mixes upper and lower case letters and digits (not the host or path left
|
|
135
275
|
// beside a password).
|
|
136
|
-
const keyLike = (text) =>
|
|
276
|
+
const keyLike = (text) =>
|
|
277
|
+
Boolean(secretReason(text)) ||
|
|
278
|
+
(text.length >= 16 &&
|
|
279
|
+
/[a-z]/.test(text) &&
|
|
280
|
+
/[A-Z]/.test(text) &&
|
|
281
|
+
/\d/.test(text));
|
|
137
282
|
async function credentials(rows, firewall, lane) {
|
|
138
283
|
const found = [];
|
|
139
284
|
let asking = rows.map((row, index) => ({ index, text: row.text }));
|
|
140
285
|
for (let round = 0; round < 3 && asking.length; round += 1) {
|
|
141
|
-
const spans = await firewall.secrets(
|
|
286
|
+
const spans = await firewall.secrets(
|
|
287
|
+
asking.map((each) => uncased(each.text)).join("\n"),
|
|
288
|
+
{ lane }
|
|
289
|
+
);
|
|
142
290
|
const next = new Map();
|
|
143
291
|
let offset = 0;
|
|
144
|
-
const starts = asking.map((each) => {
|
|
292
|
+
const starts = asking.map((each) => {
|
|
293
|
+
const at = offset;
|
|
294
|
+
offset += each.text.length + 1;
|
|
295
|
+
return at;
|
|
296
|
+
});
|
|
145
297
|
for (const span of spans) {
|
|
146
298
|
const which = starts.findLastIndex((at) => at <= span.start);
|
|
147
|
-
const row = asking[which],
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
299
|
+
const row = asking[which],
|
|
300
|
+
start = span.start - starts[which],
|
|
301
|
+
end = Math.min(span.end - starts[which], row.text.length);
|
|
302
|
+
if (
|
|
303
|
+
end <= start ||
|
|
304
|
+
found.some(
|
|
305
|
+
(each) =>
|
|
306
|
+
each.row === row.index && each.start < end && start < each.end
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
continue;
|
|
310
|
+
const text = rows[row.index].text,
|
|
311
|
+
value = row.text.slice(start, end);
|
|
312
|
+
if (
|
|
313
|
+
(/32-byte hex key/.test(span.reason) && digestRow(text)) ||
|
|
314
|
+
(round > 0 && !keyLike(value))
|
|
315
|
+
)
|
|
316
|
+
continue;
|
|
317
|
+
if (
|
|
318
|
+
span.reason === "a credential" &&
|
|
319
|
+
nameLike(value) &&
|
|
320
|
+
!NAMES_A_SECRET.test(text) &&
|
|
321
|
+
!/:\/\/[^\s:@/]+:$/.test(text.slice(0, start))
|
|
322
|
+
)
|
|
323
|
+
continue;
|
|
324
|
+
next.set(
|
|
325
|
+
row.index,
|
|
326
|
+
(next.get(row.index) ?? row.text).slice(0, start) +
|
|
327
|
+
" ".repeat(end - start) +
|
|
328
|
+
(next.get(row.index) ?? row.text).slice(end)
|
|
329
|
+
);
|
|
330
|
+
found.push({
|
|
331
|
+
row: row.index,
|
|
332
|
+
start,
|
|
333
|
+
end,
|
|
334
|
+
kind: /private key|32-byte hex key/.test(span.reason)
|
|
335
|
+
? "private-key"
|
|
336
|
+
: /seed phrase/.test(span.reason)
|
|
337
|
+
? "seed"
|
|
338
|
+
: "credential",
|
|
339
|
+
});
|
|
154
340
|
}
|
|
155
341
|
asking = [...next].map(([index, text]) => ({ index, text }));
|
|
156
342
|
}
|
|
@@ -161,85 +347,185 @@ async function credentials(rows, firewall, lane) {
|
|
|
161
347
|
async function rowSpans(rows, firewall, lane) {
|
|
162
348
|
const found = [];
|
|
163
349
|
rows.forEach((row, i) => {
|
|
164
|
-
const add = (spans, kind) => {
|
|
165
|
-
|
|
350
|
+
const add = (spans, kind) => {
|
|
351
|
+
for (const span of spans) found.push({ row: i, ...span, kind });
|
|
352
|
+
};
|
|
353
|
+
add(
|
|
354
|
+
[
|
|
355
|
+
...matches(EVM, row.text),
|
|
356
|
+
...matches(EVM_SHORT, row.text),
|
|
357
|
+
...matches(BECH32, row.text),
|
|
358
|
+
],
|
|
359
|
+
"wallet"
|
|
360
|
+
);
|
|
166
361
|
// Base58 of 50 characters or more is a private key (WIF, Solana's 64-byte keys), shorter an address.
|
|
167
|
-
for (const span of matches(
|
|
362
|
+
for (const span of matches(
|
|
363
|
+
BASE58ISH,
|
|
364
|
+
row.text,
|
|
365
|
+
(token, before) =>
|
|
366
|
+
base58(token) && !/sha\d+[-:][A-Za-z0-9+/]*$/i.test(before)
|
|
367
|
+
))
|
|
368
|
+
add([span], span.end - span.start >= 50 ? "private-key" : "wallet");
|
|
168
369
|
// Not git@host, and not the user:password@host part of a URL.
|
|
169
|
-
add(
|
|
370
|
+
add(
|
|
371
|
+
matches(
|
|
372
|
+
EMAIL,
|
|
373
|
+
row.text,
|
|
374
|
+
(text, before) => !/^git@/i.test(text) && !/:\/\/\S*$/.test(before)
|
|
375
|
+
),
|
|
376
|
+
"email"
|
|
377
|
+
);
|
|
170
378
|
add(matches(PHONE, row.text, phoneLike), "phone");
|
|
171
|
-
add(
|
|
379
|
+
add(
|
|
380
|
+
matches(
|
|
381
|
+
MONEY,
|
|
382
|
+
row.text,
|
|
383
|
+
(text) => !/^[-+−]?\s?(?:US)?[$€£¥₹₩₿Ξ]\s?\d$/.test(text)
|
|
384
|
+
),
|
|
385
|
+
"money"
|
|
386
|
+
);
|
|
172
387
|
for (const span of shaped(row.text)) {
|
|
173
388
|
const key = /private key|32-byte hex key/.test(span.reason);
|
|
174
|
-
if (!(key && digestRow(row.text)))
|
|
389
|
+
if (!(key && digestRow(row.text)))
|
|
390
|
+
add([span], key ? "private-key" : "credential");
|
|
175
391
|
}
|
|
176
392
|
});
|
|
177
|
-
return [...found, ...await credentials(rows, firewall, lane)];
|
|
393
|
+
return [...found, ...(await credentials(rows, firewall, lane))];
|
|
178
394
|
}
|
|
179
395
|
|
|
180
396
|
// Seed phrase runs over every line in reading order; numbering between words is skipped, anything else ends a run.
|
|
181
397
|
function seedRuns(rows) {
|
|
182
398
|
const tokens = [];
|
|
183
|
-
for (const row of rows)
|
|
184
|
-
for (const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
399
|
+
for (const row of rows)
|
|
400
|
+
for (const { line } of row.parts) {
|
|
401
|
+
for (const m of line.text.matchAll(/\S+/g)) {
|
|
402
|
+
if (/^\d{0,2}[.):]?$/.test(m[0])) continue;
|
|
403
|
+
const word = m[0].match(/^(\d{0,2}[.):]?)([A-Za-z]{3,8})$/);
|
|
404
|
+
tokens.push(
|
|
405
|
+
word && nearWord(word[2].toLowerCase())
|
|
406
|
+
? {
|
|
407
|
+
line,
|
|
408
|
+
start: m.index + word[1].length,
|
|
409
|
+
end: m.index + m[0].length,
|
|
410
|
+
word: word[2],
|
|
411
|
+
}
|
|
412
|
+
: null
|
|
413
|
+
);
|
|
414
|
+
}
|
|
188
415
|
}
|
|
189
|
-
}
|
|
190
416
|
const runs = [];
|
|
191
417
|
let run = [];
|
|
192
418
|
const close = () => {
|
|
193
419
|
while (run.length > 1 && /^[A-Z][a-z]/.test(run.at(-1).word)) run.pop();
|
|
194
420
|
const distinct = new Set(run.map((each) => each.word.toLowerCase())).size;
|
|
195
|
-
if (
|
|
421
|
+
if (
|
|
422
|
+
Math.min(run.length, distinct + 1) >= SEED_RUN &&
|
|
423
|
+
!run.slice(1).some((each) => /^[A-Z][a-z]/.test(each.word))
|
|
424
|
+
)
|
|
425
|
+
runs.push(run);
|
|
196
426
|
run = [];
|
|
197
427
|
};
|
|
198
|
-
for (const token of tokens) {
|
|
428
|
+
for (const token of tokens) {
|
|
429
|
+
if (token) run.push(token);
|
|
430
|
+
else close();
|
|
431
|
+
}
|
|
199
432
|
close();
|
|
200
433
|
return runs;
|
|
201
434
|
}
|
|
202
435
|
|
|
203
436
|
/** The sensitive spans in what OCR read: [{ line, start, end, kind, item }], one item per thing found. */
|
|
204
437
|
export async function sensitiveSpans(lines, { firewall, lane = "handoff" }) {
|
|
205
|
-
const rows = rowsOf(lines),
|
|
438
|
+
const rows = rowsOf(lines),
|
|
439
|
+
wraps = wrapsOf(lines, rows);
|
|
206
440
|
const found = [];
|
|
207
441
|
let item = 0;
|
|
208
442
|
// A span of a row becomes a span of each line it crosses.
|
|
209
443
|
const place = (row, start, end, kind) => {
|
|
210
444
|
const id = item++;
|
|
211
445
|
for (const { line, at } of row.parts) {
|
|
212
|
-
const from = Math.max(start, at) - at,
|
|
446
|
+
const from = Math.max(start, at) - at,
|
|
447
|
+
to = Math.min(end, at + line.text.length) - at;
|
|
213
448
|
if (to > from) found.push({ line, start: from, end: to, kind, item: id });
|
|
214
449
|
}
|
|
215
450
|
};
|
|
216
|
-
for (const span of await rowSpans(rows, firewall, lane))
|
|
217
|
-
|
|
451
|
+
for (const span of await rowSpans(rows, firewall, lane))
|
|
452
|
+
place(rows[span.row], span.start, span.end, span.kind);
|
|
453
|
+
if (wraps.length)
|
|
454
|
+
for (const span of await rowSpans(wraps, firewall, lane))
|
|
455
|
+
if (span.start < wraps[span.row].cut && wraps[span.row].cut < span.end)
|
|
456
|
+
place(wraps[span.row], span.start, span.end, span.kind);
|
|
218
457
|
const labels = labelsOf(rows);
|
|
219
458
|
// Only a value like a password or a known key counts there ("production" beside DEPLOY_TOKEN does not).
|
|
220
|
-
if (labels.length)
|
|
221
|
-
|
|
459
|
+
if (labels.length)
|
|
460
|
+
for (const span of await credentials(labels, firewall, lane))
|
|
461
|
+
if (
|
|
462
|
+
span.start >= labels[span.row].cut &&
|
|
463
|
+
secretish(labels[span.row].text.slice(span.start, span.end))
|
|
464
|
+
)
|
|
465
|
+
place(labels[span.row], span.start, span.end, span.kind);
|
|
466
|
+
for (const run of seedRuns(rows)) {
|
|
467
|
+
const id = item++;
|
|
468
|
+
for (const token of run)
|
|
469
|
+
found.push({
|
|
470
|
+
line: token.line,
|
|
471
|
+
start: token.start,
|
|
472
|
+
end: token.end,
|
|
473
|
+
kind: "seed",
|
|
474
|
+
item: id,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
222
477
|
// Two-factor recovery and backup codes each work as a password, and look like nothing else: after a heading that names
|
|
223
478
|
// them, every code-shaped token (letters and digits, in dashed groups or 8 to 16 long) is covered, row by row, until a
|
|
224
479
|
// row holds none (eval image-redaction: the credential model scored them 0.03 to 0.05).
|
|
225
480
|
for (const [index, row] of rows.entries()) {
|
|
226
481
|
if (!/\b(?:recovery|backup)\s+codes?\b/i.test(row.text)) continue;
|
|
227
|
-
for (
|
|
228
|
-
|
|
229
|
-
|
|
482
|
+
for (
|
|
483
|
+
let next = index + 1, seen = false;
|
|
484
|
+
next < Math.min(rows.length, index + 8);
|
|
485
|
+
next += 1
|
|
486
|
+
) {
|
|
487
|
+
const codes = [
|
|
488
|
+
...rows[next].text.matchAll(
|
|
489
|
+
/\b(?=[a-z0-9-]*\d)(?=[a-z0-9-]*[a-z])(?:[a-z0-9]{3,8}(?:-[a-z0-9]{3,8})+|[a-z0-9]{8,16})\b/gi
|
|
490
|
+
),
|
|
491
|
+
];
|
|
492
|
+
if (!codes.length) {
|
|
493
|
+
if (seen) break;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
230
496
|
seen = true;
|
|
231
|
-
for (const code of codes)
|
|
497
|
+
for (const code of codes)
|
|
498
|
+
place(
|
|
499
|
+
rows[next],
|
|
500
|
+
code.index,
|
|
501
|
+
code.index + code[0].length,
|
|
502
|
+
"credential"
|
|
503
|
+
);
|
|
232
504
|
}
|
|
233
505
|
}
|
|
234
506
|
// Overlapping spans on a line merge, named by the longer find (a key holding an address-like stretch is a key), the
|
|
235
507
|
// stronger kind when about as long.
|
|
236
508
|
const merged = [];
|
|
237
509
|
for (const span of found.sort((a, b) => a.start - b.start)) {
|
|
238
|
-
const same = merged.find(
|
|
239
|
-
|
|
510
|
+
const same = merged.find(
|
|
511
|
+
(each) =>
|
|
512
|
+
each.line === span.line &&
|
|
513
|
+
each.start < span.end &&
|
|
514
|
+
span.start < each.end
|
|
515
|
+
);
|
|
516
|
+
if (!same) {
|
|
517
|
+
merged.push({ ...span, length: span.end - span.start });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
240
520
|
const length = span.end - span.start;
|
|
241
|
-
if (
|
|
242
|
-
|
|
521
|
+
if (
|
|
522
|
+
length > same.length + 2 ||
|
|
523
|
+
(Math.abs(length - same.length) <= 2 &&
|
|
524
|
+
RANK.indexOf(span.kind) < RANK.indexOf(same.kind))
|
|
525
|
+
)
|
|
526
|
+
Object.assign(same, { kind: span.kind, item: span.item, length });
|
|
527
|
+
same.start = Math.min(same.start, span.start);
|
|
528
|
+
same.end = Math.max(same.end, span.end);
|
|
243
529
|
}
|
|
244
530
|
return merged.map(({ length, ...span }) => span);
|
|
245
531
|
}
|
|
@@ -249,7 +535,11 @@ export async function sensitiveSpans(lines, { firewall, lane = "handoff" }) {
|
|
|
249
535
|
* it, where OCR places the space unevenly), with half a character's margin, so no part of a covered character shows.
|
|
250
536
|
*/
|
|
251
537
|
function boxFor({ line, start, end }, width, height) {
|
|
252
|
-
const {
|
|
538
|
+
const {
|
|
539
|
+
box: [x0, y0, x1, y1],
|
|
540
|
+
chars,
|
|
541
|
+
text,
|
|
542
|
+
} = line;
|
|
253
543
|
const n = text.length;
|
|
254
544
|
const known = chars?.length === n;
|
|
255
545
|
const edge = (k, side) => {
|
|
@@ -260,41 +550,91 @@ function boxFor({ line, start, end }, width, height) {
|
|
|
260
550
|
if (side === "end" && /\s/.test(text[k])) return chars[k][1];
|
|
261
551
|
return (chars[k - 1][1] + chars[k][0]) / 2;
|
|
262
552
|
};
|
|
263
|
-
const pad = Math.max(2, ((x1 - x0) / Math.max(1, n)) * 0.5),
|
|
264
|
-
|
|
553
|
+
const pad = Math.max(2, ((x1 - x0) / Math.max(1, n)) * 0.5),
|
|
554
|
+
lift = (y1 - y0) * 0.08;
|
|
555
|
+
return [
|
|
556
|
+
Math.max(0, Math.floor(edge(start, "start") - pad)),
|
|
557
|
+
Math.max(0, Math.floor(y0 - lift)),
|
|
558
|
+
Math.min(width, Math.ceil(edge(end, "end") + pad)),
|
|
559
|
+
Math.min(height, Math.ceil(y1 + lift)),
|
|
560
|
+
];
|
|
265
561
|
}
|
|
266
562
|
|
|
267
563
|
// A 5x7 pixel font with only the letters the labels use.
|
|
268
564
|
const GLYPHS = {
|
|
269
|
-
a: ".....|.....|.###.|....#|.####|#...#|.####",
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
565
|
+
a: ".....|.....|.###.|....#|.####|#...#|.####",
|
|
566
|
+
d: "....#|....#|.####|#...#|#...#|#...#|.####",
|
|
567
|
+
e: ".....|.....|.###.|#...#|#####|#....|.###.",
|
|
568
|
+
h: "#....|#....|#.##.|##..#|#...#|#...#|#...#",
|
|
569
|
+
i: "..#..|.....|.##..|..#..|..#..|..#..|.###.",
|
|
570
|
+
k: "#....|#....|#..#.|#.#..|##...|#.#..|#..#.",
|
|
571
|
+
l: ".##..|..#..|..#..|..#..|..#..|..#..|.###.",
|
|
572
|
+
m: ".....|.....|##.#.|#.#.#|#.#.#|#.#.#|#.#.#",
|
|
573
|
+
n: ".....|.....|#.##.|##..#|#...#|#...#|#...#",
|
|
574
|
+
o: ".....|.....|.###.|#...#|#...#|#...#|.###.",
|
|
575
|
+
p: ".....|.....|####.|#...#|####.|#....|#....",
|
|
576
|
+
r: ".....|.....|#.##.|##..#|#....|#....|#....",
|
|
577
|
+
s: ".....|.....|.####|#....|.###.|....#|####.",
|
|
578
|
+
t: ".#...|.#...|####.|.#...|.#...|.#..#|..##.",
|
|
579
|
+
u: ".....|.....|#...#|#...#|#...#|#..##|.##.#",
|
|
580
|
+
y: ".....|.....|#...#|#...#|.####|....#|.###.",
|
|
581
|
+
" ": ".....|.....|.....|.....|.....|.....|.....",
|
|
275
582
|
};
|
|
276
583
|
// Dark boxes on light screens and light ones on dark screens, so the box itself is always seen.
|
|
277
|
-
const DARK = [28, 28, 30],
|
|
584
|
+
const DARK = [28, 28, 30],
|
|
585
|
+
LIGHT = [214, 214, 218];
|
|
278
586
|
|
|
279
587
|
function paint(image, [x0, y0, x1, y1], label) {
|
|
280
588
|
const { data, width } = image;
|
|
281
589
|
let sum = 0;
|
|
282
|
-
for (let y = y0; y < y1; y += 1)
|
|
283
|
-
|
|
284
|
-
|
|
590
|
+
for (let y = y0; y < y1; y += 1)
|
|
591
|
+
for (let x = x0; x < x1; x += 1) {
|
|
592
|
+
const p = (y * width + x) * 4;
|
|
593
|
+
sum += 0.3 * data[p] + 0.59 * data[p + 1] + 0.11 * data[p + 2];
|
|
594
|
+
}
|
|
595
|
+
const [FILL, INK] =
|
|
596
|
+
sum / Math.max(1, (x1 - x0) * (y1 - y0)) < 110
|
|
597
|
+
? [LIGHT, DARK]
|
|
598
|
+
: [DARK, LIGHT];
|
|
599
|
+
for (let y = y0; y < y1; y += 1)
|
|
600
|
+
for (let x = x0; x < x1; x += 1) {
|
|
601
|
+
const p = (y * width + x) * 4;
|
|
602
|
+
data[p] = FILL[0];
|
|
603
|
+
data[p + 1] = FILL[1];
|
|
604
|
+
data[p + 2] = FILL[2];
|
|
605
|
+
data[p + 3] = 255;
|
|
606
|
+
}
|
|
285
607
|
// The longest label that fits, at the largest whole scale up to about half the box's height; none if nothing fits.
|
|
286
608
|
for (const text of [label, "hidden"]) {
|
|
287
|
-
for (
|
|
288
|
-
|
|
609
|
+
for (
|
|
610
|
+
let scale = Math.max(1, Math.floor(((y1 - y0) * 0.55) / 7));
|
|
611
|
+
scale >= 1;
|
|
612
|
+
scale -= 1
|
|
613
|
+
) {
|
|
614
|
+
const w = (text.length * 6 - 1) * scale,
|
|
615
|
+
h = 7 * scale;
|
|
289
616
|
if (w > x1 - x0 - 2 * scale || h > y1 - y0) continue;
|
|
290
|
-
const left = x0 + Math.floor((x1 - x0 - w) / 2),
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
617
|
+
const left = x0 + Math.floor((x1 - x0 - w) / 2),
|
|
618
|
+
top = y0 + Math.floor((y1 - y0 - h) / 2);
|
|
619
|
+
[...text].forEach((ch, i) =>
|
|
620
|
+
GLYPHS[ch].split("|").forEach((row, gy) =>
|
|
621
|
+
[...row].forEach((on, gx) => {
|
|
622
|
+
if (on !== "#") return;
|
|
623
|
+
for (let dy = 0; dy < scale; dy += 1)
|
|
624
|
+
for (let dx = 0; dx < scale; dx += 1) {
|
|
625
|
+
const p =
|
|
626
|
+
((top + gy * scale + dy) * width +
|
|
627
|
+
left +
|
|
628
|
+
(i * 6 + gx) * scale +
|
|
629
|
+
dx) *
|
|
630
|
+
4;
|
|
631
|
+
data[p] = INK[0];
|
|
632
|
+
data[p + 1] = INK[1];
|
|
633
|
+
data[p + 2] = INK[2];
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
)
|
|
637
|
+
);
|
|
298
638
|
return;
|
|
299
639
|
}
|
|
300
640
|
}
|
|
@@ -305,9 +645,20 @@ export function describeMasked(masked) {
|
|
|
305
645
|
const items = new Map();
|
|
306
646
|
for (const entry of masked) items.set(entry.item, entry.kind);
|
|
307
647
|
const counts = new Map();
|
|
308
|
-
for (const kind of items.values())
|
|
309
|
-
|
|
310
|
-
|
|
648
|
+
for (const kind of items.values())
|
|
649
|
+
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
650
|
+
const parts = RANK.filter((kind) => counts.has(kind)).map((kind) =>
|
|
651
|
+
counts.get(kind) === 1
|
|
652
|
+
? NAMES[kind][0]
|
|
653
|
+
: `${counts.get(kind)} ${NAMES[kind][1]}`
|
|
654
|
+
);
|
|
655
|
+
return {
|
|
656
|
+
count: items.size,
|
|
657
|
+
text:
|
|
658
|
+
parts.length > 1
|
|
659
|
+
? `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`
|
|
660
|
+
: (parts[0] ?? ""),
|
|
661
|
+
};
|
|
311
662
|
}
|
|
312
663
|
|
|
313
664
|
/**
|
|
@@ -315,20 +666,65 @@ export function describeMasked(masked) {
|
|
|
315
666
|
* the original bytes when nothing was found, or { refused } when it cannot be checked (a format other than PNG or JPEG,
|
|
316
667
|
* an animated PNG, a broken or oversized image).
|
|
317
668
|
*/
|
|
318
|
-
export async function redactImage(
|
|
669
|
+
export async function redactImage(
|
|
670
|
+
{ bytes, mime },
|
|
671
|
+
{ ocr, firewall, lane = "handoff" }
|
|
672
|
+
) {
|
|
319
673
|
const started = performance.now();
|
|
320
674
|
const buffer = Buffer.from(bytes);
|
|
321
675
|
const kind = imageKind(buffer);
|
|
322
|
-
if (!kind)
|
|
323
|
-
|
|
676
|
+
if (!kind)
|
|
677
|
+
return {
|
|
678
|
+
refused: `only PNG and JPEG images can be checked for private details, and this one is ${String(
|
|
679
|
+
mime || "of another type"
|
|
680
|
+
)
|
|
681
|
+
.replace(/^image\//, "")
|
|
682
|
+
.toUpperCase()}`,
|
|
683
|
+
};
|
|
684
|
+
if (kind === "png" && buffer.includes("acTL"))
|
|
685
|
+
return { refused: "animated images cannot be checked for private details" };
|
|
324
686
|
let image;
|
|
325
|
-
try {
|
|
687
|
+
try {
|
|
688
|
+
image = decodeImage(buffer);
|
|
689
|
+
} catch (error) {
|
|
690
|
+
return { refused: `the image could not be read (${error.message})` };
|
|
691
|
+
}
|
|
326
692
|
const read = await ocr.read(image, { lane });
|
|
327
|
-
const masked = (await sensitiveSpans(read.lines, { firewall, lane })).map(
|
|
328
|
-
|
|
693
|
+
const masked = (await sensitiveSpans(read.lines, { firewall, lane })).map(
|
|
694
|
+
(span) => ({
|
|
695
|
+
kind: span.kind,
|
|
696
|
+
box: boxFor(span, image.width, image.height),
|
|
697
|
+
label: LABELS[span.kind],
|
|
698
|
+
item: span.item,
|
|
699
|
+
})
|
|
700
|
+
);
|
|
701
|
+
if (!masked.length)
|
|
702
|
+
return {
|
|
703
|
+
bytes: buffer,
|
|
704
|
+
mime,
|
|
705
|
+
masked,
|
|
706
|
+
ms: Math.round(performance.now() - started),
|
|
707
|
+
};
|
|
329
708
|
for (const entry of masked) paint(image, entry.box, entry.label);
|
|
330
709
|
let opaque = true;
|
|
331
|
-
for (let p = 3; p < image.data.length && opaque; p += 4)
|
|
332
|
-
|
|
333
|
-
|
|
710
|
+
for (let p = 3; p < image.data.length && opaque; p += 4)
|
|
711
|
+
opaque = image.data[p] === 255;
|
|
712
|
+
const png = pngjs.PNG.sync.write(
|
|
713
|
+
{
|
|
714
|
+
width: image.width,
|
|
715
|
+
height: image.height,
|
|
716
|
+
data: Buffer.from(
|
|
717
|
+
image.data.buffer,
|
|
718
|
+
image.data.byteOffset,
|
|
719
|
+
image.data.byteLength
|
|
720
|
+
),
|
|
721
|
+
},
|
|
722
|
+
{ colorType: opaque ? 2 : 6, inputHasAlpha: true }
|
|
723
|
+
);
|
|
724
|
+
return {
|
|
725
|
+
bytes: png,
|
|
726
|
+
mime: "image/png",
|
|
727
|
+
masked,
|
|
728
|
+
ms: Math.round(performance.now() - started),
|
|
729
|
+
};
|
|
334
730
|
}
|