@hraness/kb 0.17.1
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/DISCLOSURE +43 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/agent-context.js +35 -0
- package/dist/agent-guide-audit.js +18 -0
- package/dist/attachments.js +19 -0
- package/dist/authoring.js +33 -0
- package/dist/benchmark.js +19 -0
- package/dist/capture.js +49 -0
- package/dist/cli.js +3646 -0
- package/dist/clip/acquire.js +57 -0
- package/dist/clip/args.js +21 -0
- package/dist/clip/bounded-byte-buffer.js +9 -0
- package/dist/clip/browser-profiles.js +17 -0
- package/dist/clip/bundle-reader.js +11 -0
- package/dist/clip/cli.js +27 -0
- package/dist/clip/cookies.js +21 -0
- package/dist/clip/defuddle-worker.js +84 -0
- package/dist/clip/doctor.js +32 -0
- package/dist/clip/jobs.js +784 -0
- package/dist/clip/network-proxy.js +9 -0
- package/dist/clip/network.js +28 -0
- package/dist/clip/persist.js +30 -0
- package/dist/clip/refresh.js +11 -0
- package/dist/clip/terminal.js +11 -0
- package/dist/evaluation-builder.js +12238 -0
- package/dist/evaluation-kb.js +26 -0
- package/dist/evaluation.js +35 -0
- package/dist/git.js +39 -0
- package/dist/graph.js +39 -0
- package/dist/index-01jj6rbv.js +1103 -0
- package/dist/index-06c9ctr6.js +738 -0
- package/dist/index-0kavxzqj.js +2480 -0
- package/dist/index-1gwbassd.js +743 -0
- package/dist/index-1n418kb9.js +827 -0
- package/dist/index-1vrd1rmn.js +56 -0
- package/dist/index-1xxnjn0d.js +171 -0
- package/dist/index-3rm7cz6h.js +373 -0
- package/dist/index-3v2z4f0q.js +328 -0
- package/dist/index-48pz4jpc.js +392 -0
- package/dist/index-4j3tt0c3.js +234 -0
- package/dist/index-5n05se68.js +307 -0
- package/dist/index-5vwpzb5a.js +764 -0
- package/dist/index-6g2pv9d2.js +370 -0
- package/dist/index-7fzc32gf.js +712 -0
- package/dist/index-84x0vjjp.js +432 -0
- package/dist/index-adx6khj5.js +488 -0
- package/dist/index-b88v3vtm.js +805 -0
- package/dist/index-cv6fh7z5.js +462 -0
- package/dist/index-cxfrakt7.js +1067 -0
- package/dist/index-d13v9ckt.js +150 -0
- package/dist/index-dycjbhw2.js +1943 -0
- package/dist/index-dyqwejk5.js +531 -0
- package/dist/index-e5fbsywq.js +658 -0
- package/dist/index-f984hw45.js +927 -0
- package/dist/index-gh719d91.js +63 -0
- package/dist/index-hgve9rh2.js +2046 -0
- package/dist/index-hya40gb2.js +474 -0
- package/dist/index-j4zgmzjr.js +152 -0
- package/dist/index-jsmvyyvf.js +1166 -0
- package/dist/index-kh0pwvdd.js +1974 -0
- package/dist/index-mqx4nd6v.js +119 -0
- package/dist/index-mxxxytys.js +1254 -0
- package/dist/index-n5dd7r0v.js +711 -0
- package/dist/index-npg9z1a4.js +431 -0
- package/dist/index-pj501bh1.js +140 -0
- package/dist/index-qry4vhxk.js +1041 -0
- package/dist/index-s2gw5aw9.js +199 -0
- package/dist/index-vvdj6kca.js +70 -0
- package/dist/index-w2zc0vwa.js +571 -0
- package/dist/index-x3fthpsc.js +585 -0
- package/dist/index-zr53sf63.js +44 -0
- package/dist/index-zxdy5pby.js +2728 -0
- package/dist/index-zzhgcwyt.js +966 -0
- package/dist/index.js +453 -0
- package/dist/navigation.js +13 -0
- package/dist/pdf.js +85 -0
- package/dist/percolate.js +24 -0
- package/dist/portfolio.js +77 -0
- package/dist/query.js +29 -0
- package/dist/repository-memory.js +60 -0
- package/dist/sdk.js +36 -0
- package/dist/search-rules.js +34 -0
- package/dist/search.js +22 -0
- package/dist/semantic.js +39 -0
- package/dist/source-inbox.js +17 -0
- package/dist/untrusted-content.js +32 -0
- package/dist/url-intelligence.js +74 -0
- package/dist/workflow.js +23 -0
- package/dist/workflows/decision-context.js +20 -0
- package/dist/workflows/explain-change.js +8 -0
- package/dist/workflows/index.js +28 -0
- package/dist/workflows/plan-radar.js +10 -0
- package/package.json +409 -0
- package/skills/kb/AGENTS.md +19 -0
- package/skills/kb/SKILL.md +82 -0
- package/skills/kb/agents/openai.yaml +4 -0
- package/skills/kb/references/pdf-review.md +51 -0
- package/skills/kb/references/percolate.md +144 -0
- package/skills/kb/references/plan-structure.md +105 -0
- package/skills/kb/references/plan.md +115 -0
- package/skills/kb/references/query.md +231 -0
- package/skills/kb/references/refresh.md +133 -0
- package/skills/kb/references/save-pdf.md +125 -0
- package/skills/kb/references/save-url.md +178 -0
- package/skills/kb/references/url-authentication.md +75 -0
- package/skills/kb/references/url-platforms.md +25 -0
- package/src/agent-context.ts +1241 -0
- package/src/agent-guide-audit.ts +750 -0
- package/src/attachments.ts +723 -0
- package/src/authoring.ts +1725 -0
- package/src/benchmark.ts +362 -0
- package/src/capture.ts +68 -0
- package/src/cli.ts +3660 -0
- package/src/clip/acquire.ts +1273 -0
- package/src/clip/archive-today.ts +402 -0
- package/src/clip/args.ts +423 -0
- package/src/clip/assets.ts +262 -0
- package/src/clip/bounded-byte-buffer.ts +75 -0
- package/src/clip/browser-profiles.ts +372 -0
- package/src/clip/bundle-reader.ts +608 -0
- package/src/clip/capture.ts +1054 -0
- package/src/clip/cli.ts +181 -0
- package/src/clip/cookies.ts +512 -0
- package/src/clip/doctor.ts +1110 -0
- package/src/clip/extract.ts +868 -0
- package/src/clip/jobs.ts +959 -0
- package/src/clip/lib.ts +861 -0
- package/src/clip/media.ts +1474 -0
- package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
- package/src/clip/metadata-search-tool/Cargo.toml +18 -0
- package/src/clip/metadata-search-tool/runner.ts +685 -0
- package/src/clip/metadata-search-tool/src/main.rs +389 -0
- package/src/clip/metadata-search.ts +687 -0
- package/src/clip/network-proxy.ts +627 -0
- package/src/clip/network.ts +950 -0
- package/src/clip/package-root.ts +54 -0
- package/src/clip/persist.ts +1472 -0
- package/src/clip/platforms.ts +1824 -0
- package/src/clip/refresh.ts +197 -0
- package/src/clip/structured.ts +432 -0
- package/src/clip/terminal.ts +197 -0
- package/src/clip/url-intelligence.ts +581 -0
- package/src/clip/url-metadata-backfill.ts +616 -0
- package/src/clip/url-metadata-cli.ts +251 -0
- package/src/clip/url-metadata.ts +1397 -0
- package/src/evaluation-analysis-v2.ts +1440 -0
- package/src/evaluation-builder.ts +2539 -0
- package/src/evaluation-corpus-authoring.ts +2052 -0
- package/src/evaluation-evidence.ts +1147 -0
- package/src/evaluation-execution-v2.ts +426 -0
- package/src/evaluation-implementation.ts +131 -0
- package/src/evaluation-kb-closure.ts +1430 -0
- package/src/evaluation-kb-runner-v2.ts +735 -0
- package/src/evaluation-kb-v2.ts +2567 -0
- package/src/evaluation-kb.ts +1018 -0
- package/src/evaluation-measurement-v2.ts +1462 -0
- package/src/evaluation-packing-v2.ts +452 -0
- package/src/evaluation-redaction.ts +28 -0
- package/src/evaluation-v2.ts +3857 -0
- package/src/evaluation.ts +1220 -0
- package/src/git.ts +1276 -0
- package/src/graph.ts +1487 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/navigation.ts +277 -0
- package/src/note-lock.ts +529 -0
- package/src/pdf/args.ts +202 -0
- package/src/pdf/capture.ts +298 -0
- package/src/pdf/cli.ts +159 -0
- package/src/pdf/extract.ts +303 -0
- package/src/pdf/layout.ts +618 -0
- package/src/pdf/markdown.ts +154 -0
- package/src/pdf/model.ts +234 -0
- package/src/pdf/ocr.ts +190 -0
- package/src/pdf/persist.ts +314 -0
- package/src/pdf/source.ts +135 -0
- package/src/pdf/tools.ts +146 -0
- package/src/pdf.ts +92 -0
- package/src/percolate.ts +877 -0
- package/src/portfolio-audit.ts +574 -0
- package/src/portfolio-identity.ts +162 -0
- package/src/portfolio-registry.ts +473 -0
- package/src/portfolio.ts +765 -0
- package/src/query.ts +646 -0
- package/src/repository-memory.ts +1150 -0
- package/src/sdk.ts +1452 -0
- package/src/search-rules.ts +712 -0
- package/src/search.ts +710 -0
- package/src/semantic-runtime.ts +1139 -0
- package/src/semantic.ts +2480 -0
- package/src/source-inbox.ts +232 -0
- package/src/untrusted-content.ts +401 -0
- package/src/url-intelligence.ts +104 -0
- package/src/vault.ts +636 -0
- package/src/workflow.ts +653 -0
- package/src/workflows/decision-context.ts +81 -0
- package/src/workflows/explain-change.ts +73 -0
- package/src/workflows/index.ts +3 -0
- package/src/workflows/plan-radar.ts +101 -0
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
sanitizeTerminalText
|
|
4
|
+
} from "./index-1xxnjn0d.js";
|
|
5
|
+
|
|
6
|
+
// src/clip/persist.ts
|
|
7
|
+
import {
|
|
8
|
+
chmodSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
lstatSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync
|
|
19
|
+
} from "fs";
|
|
20
|
+
import { createHash } from "crypto";
|
|
21
|
+
import { homedir } from "os";
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
23
|
+
var CAPTURE_MANIFEST_SCHEMA_VERSION = 4;
|
|
24
|
+
var CAPTURE_MANIFEST_FILENAME = "capture.json";
|
|
25
|
+
var CAPTURE_SOURCE_EVIDENCE_PATH = "evidence/source.html";
|
|
26
|
+
var transactionState = Symbol("captureBundleTransactionState");
|
|
27
|
+
var secretKeyPattern = /(?:auth(?:orization)?|cookie|credential|csrf|xsrf|jwt|pass(?:word|wd)?|secret|session|token|api[-_]?key|private[-_]?key|signature|signed[-_]?url|key[-_]?pair[-_]?id|hdne[at])/i;
|
|
28
|
+
var oneTimeCredentialKeyPattern = /^(?:code|ticket|otp|nonce|key|magic[-_]?link|magic[-_]?token|one[-_]?time(?:[-_]?code|[-_]?token)?)$/i;
|
|
29
|
+
var providerSignatureKeyPattern = /^(?:x-amz-.+|x-goog-.+|signature|sig|policy|key-pair-id|googleaccessid|awsaccesskeyid|hdne[at])$/i;
|
|
30
|
+
var azureSasKeyPattern = /^(?:sv|ss|srt|sp|se|st|spr|sip|sr|sig)$/i;
|
|
31
|
+
var safeManifestTokenPattern = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
|
|
32
|
+
var safeArtifactMimePattern = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
|
|
33
|
+
var MAX_ARTIFACT_URL_CODE_UNITS = 64 * 1024;
|
|
34
|
+
var MAX_ARTIFACT_URL_PARAMETERS = 4096;
|
|
35
|
+
function requireSafeSlug(slug) {
|
|
36
|
+
if (slug.length === 0 || slug.length > 240)
|
|
37
|
+
throw new Error("unsafe capture slug");
|
|
38
|
+
const normalized = slug.normalize("NFKC");
|
|
39
|
+
const codePoints = [...slug];
|
|
40
|
+
const validCharacters = /^[\p{Letter}\p{Number}](?:[\p{Letter}\p{Number}._-]*[\p{Letter}\p{Number}])?$/u;
|
|
41
|
+
if (slug !== normalized || codePoints.length === 0 || codePoints.length > 80 || new TextEncoder().encode(slug).byteLength > 240 || !validCharacters.test(slug) || slug === "." || slug === "..") {
|
|
42
|
+
throw new Error("unsafe capture slug");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function captureMarkdownFilename(slug) {
|
|
46
|
+
requireSafeSlug(slug);
|
|
47
|
+
return `${slug}.md`;
|
|
48
|
+
}
|
|
49
|
+
function isConfinedChild(root, path) {
|
|
50
|
+
const child = relative(root, path);
|
|
51
|
+
return child !== "" && !isAbsolute(child) && child !== ".." && !child.startsWith(`..${sep}`);
|
|
52
|
+
}
|
|
53
|
+
function assertConfinedChild(root, path, label) {
|
|
54
|
+
if (!isConfinedChild(root, path))
|
|
55
|
+
throw new Error(`${label} escapes the capture output root`);
|
|
56
|
+
}
|
|
57
|
+
function ensureOutputRoot(outputRoot) {
|
|
58
|
+
const absolute = resolve(outputRoot);
|
|
59
|
+
mkdirSync(absolute, { recursive: true, mode: 493 });
|
|
60
|
+
const stats = lstatSync(absolute);
|
|
61
|
+
if (!stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
62
|
+
throw new Error(`capture output root is not a directory: ${absolute}`);
|
|
63
|
+
}
|
|
64
|
+
const canonical = realpathSync(absolute);
|
|
65
|
+
if (!lstatSync(canonical).isDirectory())
|
|
66
|
+
throw new Error(`capture output root is not a directory: ${canonical}`);
|
|
67
|
+
if (dirname(canonical) === canonical || canonical === realpathSync(homedir())) {
|
|
68
|
+
throw new Error(`refusing dangerous capture output root: ${canonical}`);
|
|
69
|
+
}
|
|
70
|
+
return canonical;
|
|
71
|
+
}
|
|
72
|
+
function ownedTargetIdentity(targetDirectory, slug) {
|
|
73
|
+
const directory = lstatSync(targetDirectory);
|
|
74
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
75
|
+
throw new Error(`--force only replaces a regular clip-owned directory: ${targetDirectory}`);
|
|
76
|
+
}
|
|
77
|
+
const manifestPath = join(targetDirectory, CAPTURE_MANIFEST_FILENAME);
|
|
78
|
+
const markdownPath = join(targetDirectory, captureMarkdownFilename(slug));
|
|
79
|
+
for (const [label, path] of [["manifest", manifestPath], ["Markdown", markdownPath]]) {
|
|
80
|
+
let stats;
|
|
81
|
+
try {
|
|
82
|
+
stats = lstatSync(path);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(`--force refused an unowned target without its expected ${label}: ${targetDirectory}`);
|
|
85
|
+
}
|
|
86
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
87
|
+
throw new Error(`--force refused an unowned target with an unsafe ${label}: ${targetDirectory}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const manifestStats = lstatSync(manifestPath);
|
|
91
|
+
if (manifestStats.size > 1024 * 1024) {
|
|
92
|
+
throw new Error(`--force refused an unowned target with an oversized manifest: ${targetDirectory}`);
|
|
93
|
+
}
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`--force refused an unowned target with an invalid manifest: ${targetDirectory}`);
|
|
99
|
+
}
|
|
100
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || !("schemaVersion" in parsed) || parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2 && parsed.schemaVersion !== 3 && parsed.schemaVersion !== CAPTURE_MANIFEST_SCHEMA_VERSION || !("sourceUrl" in parsed) || typeof parsed.sourceUrl !== "string") {
|
|
101
|
+
throw new Error(`--force refused an unowned target with an incompatible manifest: ${targetDirectory}`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const source = new URL(parsed.sourceUrl);
|
|
105
|
+
if (source.protocol !== "http:" && source.protocol !== "https:")
|
|
106
|
+
throw new Error("unsupported source protocol");
|
|
107
|
+
} catch {
|
|
108
|
+
throw new Error(`--force refused an unowned target with an invalid manifest source: ${targetDirectory}`);
|
|
109
|
+
}
|
|
110
|
+
return { device: directory.dev, inode: directory.ino };
|
|
111
|
+
}
|
|
112
|
+
function beginCaptureBundle(options) {
|
|
113
|
+
requireSafeSlug(options.slug);
|
|
114
|
+
const outputRoot = ensureOutputRoot(options.outputRoot);
|
|
115
|
+
const targetDirectory = join(outputRoot, options.slug);
|
|
116
|
+
assertConfinedChild(outputRoot, targetDirectory, "capture target");
|
|
117
|
+
const targetExists = existsSync(targetDirectory);
|
|
118
|
+
if (targetExists && !options.force) {
|
|
119
|
+
throw new Error(`capture already exists: ${targetDirectory}; pass --force to replace it`);
|
|
120
|
+
}
|
|
121
|
+
const expectedTargetIdentity = targetExists ? ownedTargetIdentity(targetDirectory, options.slug) : null;
|
|
122
|
+
const stagingDirectory = mkdtempSync(join(outputRoot, ".capture-staging-"));
|
|
123
|
+
chmodSync(stagingDirectory, 448);
|
|
124
|
+
assertConfinedChild(outputRoot, stagingDirectory, "capture staging directory");
|
|
125
|
+
return {
|
|
126
|
+
outputRoot,
|
|
127
|
+
targetDirectory,
|
|
128
|
+
stagingDirectory,
|
|
129
|
+
assetsDirectory: join(stagingDirectory, "assets"),
|
|
130
|
+
slug: options.slug,
|
|
131
|
+
force: options.force,
|
|
132
|
+
expectedTargetIdentity,
|
|
133
|
+
[transactionState]: "open"
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function requireState(transaction, expected) {
|
|
137
|
+
if (transaction[transactionState] !== expected) {
|
|
138
|
+
throw new Error(`capture transaction is ${transaction[transactionState]}, expected ${expected}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function ensureTrailingNewline(value) {
|
|
142
|
+
return value.endsWith(`
|
|
143
|
+
`) ? value : `${value}
|
|
144
|
+
`;
|
|
145
|
+
}
|
|
146
|
+
var MAX_PROJECTED_CREDENTIAL_ENTITIES = 4096;
|
|
147
|
+
var MAX_CREDENTIAL_REPLACEMENTS = 4096;
|
|
148
|
+
var MAX_PROJECTED_URL_CANDIDATES = 4096;
|
|
149
|
+
var MAX_PROJECTED_URL_LENGTH = 16 * 1024;
|
|
150
|
+
var ENTITY_DENSE_REDACTION = "[REDACTED ENTITY-DENSE CONTENT]";
|
|
151
|
+
var CREDENTIAL_DENSE_REDACTION = "[REDACTED CREDENTIAL-DENSE CONTENT]";
|
|
152
|
+
var URL_DENSE_REDACTION = "[REDACTED URL-DENSE CONTENT]";
|
|
153
|
+
var exactCredentialAssignmentKeys = new Set([
|
|
154
|
+
"authorization",
|
|
155
|
+
"cookie",
|
|
156
|
+
"credential",
|
|
157
|
+
"credentials",
|
|
158
|
+
"csrf",
|
|
159
|
+
"hdnea",
|
|
160
|
+
"hdnat",
|
|
161
|
+
"jwt",
|
|
162
|
+
"password",
|
|
163
|
+
"passwd",
|
|
164
|
+
"proxy_authorization",
|
|
165
|
+
"_auth",
|
|
166
|
+
"secret",
|
|
167
|
+
"session",
|
|
168
|
+
"set_cookie",
|
|
169
|
+
"token",
|
|
170
|
+
"xsrf"
|
|
171
|
+
]);
|
|
172
|
+
var credentialAssignmentKeySuffixes = [
|
|
173
|
+
"api_key",
|
|
174
|
+
"authorization",
|
|
175
|
+
"cookie",
|
|
176
|
+
"credential",
|
|
177
|
+
"credentials",
|
|
178
|
+
"csrf",
|
|
179
|
+
"csrf_token",
|
|
180
|
+
"jwt",
|
|
181
|
+
"key_pair_id",
|
|
182
|
+
"passwd",
|
|
183
|
+
"password",
|
|
184
|
+
"private_key",
|
|
185
|
+
"secret",
|
|
186
|
+
"secret_key",
|
|
187
|
+
"session",
|
|
188
|
+
"session_id",
|
|
189
|
+
"session_key",
|
|
190
|
+
"session_token",
|
|
191
|
+
"signature",
|
|
192
|
+
"signed_url",
|
|
193
|
+
"token",
|
|
194
|
+
"xsrf",
|
|
195
|
+
"xsrf_token"
|
|
196
|
+
];
|
|
197
|
+
function normalizeCredentialKey(key) {
|
|
198
|
+
return key.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/-+/g, "_").toLowerCase();
|
|
199
|
+
}
|
|
200
|
+
function isCredentialAssignmentKey(key) {
|
|
201
|
+
const normalized = normalizeCredentialKey(key);
|
|
202
|
+
return exactCredentialAssignmentKeys.has(normalized) || normalized === "aws_access_key_id" || normalized === "aws_secret_access_key" || credentialAssignmentKeySuffixes.some((suffix) => normalized === suffix || normalized.endsWith(`_${suffix}`));
|
|
203
|
+
}
|
|
204
|
+
var authenticationQueryKeys = new Set([
|
|
205
|
+
"action_code",
|
|
206
|
+
"activation_code",
|
|
207
|
+
"assertion",
|
|
208
|
+
"confirmation_code",
|
|
209
|
+
"invite_code",
|
|
210
|
+
"magic_code",
|
|
211
|
+
"oob_code",
|
|
212
|
+
"recovery_code",
|
|
213
|
+
"relay_state",
|
|
214
|
+
"reset_code",
|
|
215
|
+
"saml_request",
|
|
216
|
+
"saml_response",
|
|
217
|
+
"verification_code",
|
|
218
|
+
"verify_code"
|
|
219
|
+
]);
|
|
220
|
+
function isAuthenticationQueryKey(key) {
|
|
221
|
+
return authenticationQueryKeys.has(normalizeCredentialKey(key));
|
|
222
|
+
}
|
|
223
|
+
var credentialEntityCharacters = {
|
|
224
|
+
amp: "&",
|
|
225
|
+
apos: "'",
|
|
226
|
+
bsol: "\\",
|
|
227
|
+
colon: ":",
|
|
228
|
+
comma: ",",
|
|
229
|
+
commat: "@",
|
|
230
|
+
dash: "-",
|
|
231
|
+
equals: "=",
|
|
232
|
+
gt: ">",
|
|
233
|
+
hyphen: "-",
|
|
234
|
+
lowbar: "_",
|
|
235
|
+
lt: "<",
|
|
236
|
+
newline: `
|
|
237
|
+
`,
|
|
238
|
+
nbsp: " ",
|
|
239
|
+
num: "#",
|
|
240
|
+
percnt: "%",
|
|
241
|
+
period: ".",
|
|
242
|
+
plus: "+",
|
|
243
|
+
quot: '"',
|
|
244
|
+
semi: ";",
|
|
245
|
+
sol: "/",
|
|
246
|
+
tab: "\t",
|
|
247
|
+
underbar: "_"
|
|
248
|
+
};
|
|
249
|
+
function decodeCredentialEntity(entity) {
|
|
250
|
+
const numeric = /^&#(?:x([0-9a-f]+)|(\d+));?$/i.exec(entity);
|
|
251
|
+
if (numeric !== null) {
|
|
252
|
+
const codePoint = Number.parseInt(numeric[1] ?? numeric[2] ?? "", numeric[1] === undefined ? 10 : 16);
|
|
253
|
+
if (!Number.isSafeInteger(codePoint) || codePoint <= 0 || codePoint > 1114111)
|
|
254
|
+
return null;
|
|
255
|
+
if (codePoint >= 55296 && codePoint <= 57343)
|
|
256
|
+
return null;
|
|
257
|
+
return String.fromCodePoint(codePoint);
|
|
258
|
+
}
|
|
259
|
+
const named = /^&([a-z][a-z0-9]+);$/i.exec(entity)?.[1]?.toLowerCase();
|
|
260
|
+
return named === undefined ? null : credentialEntityCharacters[named] ?? null;
|
|
261
|
+
}
|
|
262
|
+
function projectCredentialEntities(value) {
|
|
263
|
+
if (!value.includes("&"))
|
|
264
|
+
return { text: value, entities: [], limitExceeded: false };
|
|
265
|
+
const entities = [];
|
|
266
|
+
const parts = [];
|
|
267
|
+
const entityPattern = /&(?:#[xX][0-9a-fA-F]{1,6};|#\d{1,7};|#[xX][0-9a-fA-F]{1,6}(?![0-9a-fA-F])|#\d{1,7}(?!\d)|[a-zA-Z][a-zA-Z0-9]+;)/g;
|
|
268
|
+
let sourceCursor = 0;
|
|
269
|
+
let projectedLength = 0;
|
|
270
|
+
for (const match of value.matchAll(entityPattern)) {
|
|
271
|
+
const sourceStart = match.index ?? 0;
|
|
272
|
+
const sourceEnd = sourceStart + match[0].length;
|
|
273
|
+
const decoded = decodeCredentialEntity(match[0]);
|
|
274
|
+
if (decoded === null)
|
|
275
|
+
continue;
|
|
276
|
+
if (entities.length >= MAX_PROJECTED_CREDENTIAL_ENTITIES) {
|
|
277
|
+
return { text: "", entities: [], limitExceeded: true };
|
|
278
|
+
}
|
|
279
|
+
const unchanged = value.slice(sourceCursor, sourceStart);
|
|
280
|
+
parts.push(unchanged, decoded);
|
|
281
|
+
projectedLength += unchanged.length;
|
|
282
|
+
entities.push({
|
|
283
|
+
projectedStart: projectedLength,
|
|
284
|
+
projectedEnd: projectedLength + decoded.length,
|
|
285
|
+
sourceStart,
|
|
286
|
+
sourceEnd,
|
|
287
|
+
deltaAfter: sourceEnd - (projectedLength + decoded.length)
|
|
288
|
+
});
|
|
289
|
+
projectedLength += decoded.length;
|
|
290
|
+
sourceCursor = sourceEnd;
|
|
291
|
+
}
|
|
292
|
+
if (entities.length === 0)
|
|
293
|
+
return { text: value, entities, limitExceeded: false };
|
|
294
|
+
parts.push(value.slice(sourceCursor));
|
|
295
|
+
return { text: parts.join(""), entities, limitExceeded: false };
|
|
296
|
+
}
|
|
297
|
+
function applyTextReplacements(value, replacements) {
|
|
298
|
+
if (replacements.length === 0)
|
|
299
|
+
return value;
|
|
300
|
+
const ordered = [...replacements].sort((left, right) => left.start - right.start);
|
|
301
|
+
const parts = [];
|
|
302
|
+
let cursor = 0;
|
|
303
|
+
for (const replacement of ordered) {
|
|
304
|
+
if (replacement.start < cursor || replacement.end <= replacement.start)
|
|
305
|
+
continue;
|
|
306
|
+
parts.push(value.slice(cursor, replacement.start), replacement.text);
|
|
307
|
+
cursor = replacement.end;
|
|
308
|
+
}
|
|
309
|
+
parts.push(value.slice(cursor));
|
|
310
|
+
return parts.join("");
|
|
311
|
+
}
|
|
312
|
+
function quotedValueEnd(value, start) {
|
|
313
|
+
const quote = value[start];
|
|
314
|
+
if (quote !== '"' && quote !== "'")
|
|
315
|
+
return null;
|
|
316
|
+
for (let cursor = start + 1;cursor < value.length; cursor += 1) {
|
|
317
|
+
if (value[cursor] === "\\") {
|
|
318
|
+
cursor += 1;
|
|
319
|
+
} else if (value[cursor] === quote) {
|
|
320
|
+
return cursor;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
function firstUnencodedDelimiter(projected, start, delimiter) {
|
|
326
|
+
let low = 0;
|
|
327
|
+
let high = projected.entities.length;
|
|
328
|
+
while (low < high) {
|
|
329
|
+
const middle = Math.floor((low + high) / 2);
|
|
330
|
+
if ((projected.entities[middle]?.projectedEnd ?? 0) <= start)
|
|
331
|
+
low = middle + 1;
|
|
332
|
+
else
|
|
333
|
+
high = middle;
|
|
334
|
+
}
|
|
335
|
+
let entityIndex = low;
|
|
336
|
+
for (let cursor = start;cursor < projected.text.length; cursor += 1) {
|
|
337
|
+
while ((projected.entities[entityIndex]?.projectedEnd ?? Number.POSITIVE_INFINITY) <= cursor)
|
|
338
|
+
entityIndex += 1;
|
|
339
|
+
const entity = projected.entities[entityIndex];
|
|
340
|
+
const insideEntity = entity !== undefined && cursor >= entity.projectedStart && cursor < entity.projectedEnd;
|
|
341
|
+
if (!insideEntity && delimiter.test(projected.text[cursor] ?? ""))
|
|
342
|
+
return cursor;
|
|
343
|
+
}
|
|
344
|
+
return projected.text.length;
|
|
345
|
+
}
|
|
346
|
+
function projectedBoundaryToSource(projected, offset, side) {
|
|
347
|
+
let low = 0;
|
|
348
|
+
let high = projected.entities.length;
|
|
349
|
+
while (low < high) {
|
|
350
|
+
const middle = Math.floor((low + high) / 2);
|
|
351
|
+
if ((projected.entities[middle]?.projectedStart ?? Number.POSITIVE_INFINITY) <= offset)
|
|
352
|
+
low = middle + 1;
|
|
353
|
+
else
|
|
354
|
+
high = middle;
|
|
355
|
+
}
|
|
356
|
+
const entity = projected.entities[low - 1];
|
|
357
|
+
if (entity === undefined)
|
|
358
|
+
return offset;
|
|
359
|
+
if (side === "start" && offset < entity.projectedEnd)
|
|
360
|
+
return entity.sourceStart;
|
|
361
|
+
if (side === "end" && offset > entity.projectedStart && offset <= entity.projectedEnd)
|
|
362
|
+
return entity.sourceEnd;
|
|
363
|
+
if (offset >= entity.projectedEnd)
|
|
364
|
+
return offset + entity.deltaAfter;
|
|
365
|
+
return offset + (projected.entities[low - 2]?.deltaAfter ?? 0);
|
|
366
|
+
}
|
|
367
|
+
function credentialValueEnd(projected, start, key) {
|
|
368
|
+
const quoteEnd = quotedValueEnd(projected.text, start);
|
|
369
|
+
if (quoteEnd !== null)
|
|
370
|
+
return { start: start + 1, end: quoteEnd };
|
|
371
|
+
const normalizedKey = key.toLowerCase().replace(/_/g, "-");
|
|
372
|
+
if (normalizedKey === "cookie" || normalizedKey === "set-cookie") {
|
|
373
|
+
return { start, end: firstUnencodedDelimiter(projected, start, /[\r\n<]/) };
|
|
374
|
+
}
|
|
375
|
+
if (normalizedKey === "authorization" || normalizedKey === "proxy-authorization") {
|
|
376
|
+
const schemeAtStart = /(?:basic|bearer)\s+/iy;
|
|
377
|
+
schemeAtStart.lastIndex = start;
|
|
378
|
+
const scheme = schemeAtStart.exec(projected.text);
|
|
379
|
+
const secretStart = start + (scheme?.[0].length ?? 0);
|
|
380
|
+
return { start, end: firstUnencodedDelimiter(projected, secretStart, /[\s<>&,"'\r\n]/) };
|
|
381
|
+
}
|
|
382
|
+
if (/(?:credential|pass(?:word|wd)?|private-key|secret)/i.test(normalizedKey)) {
|
|
383
|
+
return { start, end: firstUnencodedDelimiter(projected, start, /[\r\n<]/) };
|
|
384
|
+
}
|
|
385
|
+
return { start, end: firstUnencodedDelimiter(projected, start, /[\s<>&;,)\]\r\n]/) };
|
|
386
|
+
}
|
|
387
|
+
function redactProjectedCredentialAssignments(value) {
|
|
388
|
+
const projected = projectCredentialEntities(value);
|
|
389
|
+
if (projected.limitExceeded)
|
|
390
|
+
return { text: ENTITY_DENSE_REDACTION, count: 1 };
|
|
391
|
+
const assignments = /(?<![a-z0-9_-])(?:(['"])([a-z_][a-z0-9_-]{0,127})\1|([a-z_][a-z0-9_-]{0,127}))\s*[:=]\s*/gi;
|
|
392
|
+
const replacements = [];
|
|
393
|
+
let match;
|
|
394
|
+
while ((match = assignments.exec(projected.text)) !== null) {
|
|
395
|
+
const key = match[2] ?? match[3] ?? "";
|
|
396
|
+
if (!isCredentialAssignmentKey(key))
|
|
397
|
+
continue;
|
|
398
|
+
const valueStart = (match.index ?? 0) + match[0].length;
|
|
399
|
+
const range = credentialValueEnd(projected, valueStart, key);
|
|
400
|
+
if (range.end <= range.start)
|
|
401
|
+
continue;
|
|
402
|
+
const sourceStart = projectedBoundaryToSource(projected, range.start, "start");
|
|
403
|
+
const sourceEnd = projectedBoundaryToSource(projected, range.end, "end");
|
|
404
|
+
if (sourceEnd <= sourceStart)
|
|
405
|
+
continue;
|
|
406
|
+
if (replacements.length >= MAX_CREDENTIAL_REPLACEMENTS) {
|
|
407
|
+
return { text: CREDENTIAL_DENSE_REDACTION, count: 1 };
|
|
408
|
+
}
|
|
409
|
+
replacements.push({ start: sourceStart, end: sourceEnd });
|
|
410
|
+
assignments.lastIndex = Math.max(assignments.lastIndex, range.end);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
text: applyTextReplacements(value, replacements.map((replacement) => ({ ...replacement, text: "[REDACTED]" }))),
|
|
414
|
+
count: replacements.length
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function redactPemCredentialBlocks(value) {
|
|
418
|
+
let count = 0;
|
|
419
|
+
const label = "((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY|PGP PRIVATE KEY BLOCK|(?:X509 |TRUSTED )?CERTIFICATE)";
|
|
420
|
+
const pattern = new RegExp(`-----BEGIN ${label}-----[\\s\\S]*?(?:-----END \\1-----|$)`, "gi");
|
|
421
|
+
const text = value.replace(pattern, () => {
|
|
422
|
+
count += 1;
|
|
423
|
+
return "[REDACTED_PEM_CREDENTIAL]";
|
|
424
|
+
});
|
|
425
|
+
return { text, count };
|
|
426
|
+
}
|
|
427
|
+
function redactCredentialText(value) {
|
|
428
|
+
const pemBlocks = redactPemCredentialBlocks(value);
|
|
429
|
+
const assignments = redactProjectedCredentialAssignments(pemBlocks.text);
|
|
430
|
+
let count = pemBlocks.count + assignments.count;
|
|
431
|
+
const replace = (replacement) => () => {
|
|
432
|
+
count += 1;
|
|
433
|
+
return replacement;
|
|
434
|
+
};
|
|
435
|
+
const text = assignments.text.replace(/\beyJ[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9_-]{8,}\b/g, replace("[REDACTED JWT]")).replace(/\bAKIA[A-Z0-9]{16}\b/g, replace("[REDACTED ACCESS KEY]"));
|
|
436
|
+
return { text, count };
|
|
437
|
+
}
|
|
438
|
+
function repeatedlyDecodeURIComponent(value) {
|
|
439
|
+
let decoded = value;
|
|
440
|
+
for (let pass = 0;pass < 5; pass += 1) {
|
|
441
|
+
let next;
|
|
442
|
+
try {
|
|
443
|
+
next = decodeURIComponent(decoded);
|
|
444
|
+
} catch {
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
if (next === decoded)
|
|
448
|
+
break;
|
|
449
|
+
decoded = next;
|
|
450
|
+
}
|
|
451
|
+
return decoded;
|
|
452
|
+
}
|
|
453
|
+
var directCredentialPathMarkerPattern = /^(?:magic[-_]?link|magic[-_]?token|one[-_]?time(?:[-_]?code|[-_]?token)?|password[-_]?reset|reset[-_]?password)$/i;
|
|
454
|
+
var contextualCredentialPathMarkerPattern = /^(?:code|key|nonce|otp|ticket|token)$/i;
|
|
455
|
+
var credentialPathContextPattern = /^(?:account|activate|activation|auth|callback|confirm|confirmation|invite|invitation|login|magic|oauth2?|password|recover|recovery|redeem|reset|signin|verify|verification)$/i;
|
|
456
|
+
function looksLikeCredentialPathValue(value) {
|
|
457
|
+
if (value.length >= 20)
|
|
458
|
+
return true;
|
|
459
|
+
if (/^\d{4,}$/.test(value))
|
|
460
|
+
return true;
|
|
461
|
+
return value.length >= 6 && /[a-z]/i.test(value) && /\d/.test(value);
|
|
462
|
+
}
|
|
463
|
+
function sanitizeCredentialPath(url) {
|
|
464
|
+
const segments = url.pathname.split("/");
|
|
465
|
+
const decodedSegments = segments.map((segment) => repeatedlyDecodeURIComponent(segment));
|
|
466
|
+
for (let index = 0;index < decodedSegments.length - 1; index += 1) {
|
|
467
|
+
const marker = decodedSegments[index] ?? "";
|
|
468
|
+
const credential = decodedSegments[index + 1] ?? "";
|
|
469
|
+
if (credential === "")
|
|
470
|
+
continue;
|
|
471
|
+
const previous = decodedSegments[index - 1] ?? "";
|
|
472
|
+
const isCredentialBoundary = directCredentialPathMarkerPattern.test(marker) || credentialPathContextPattern.test(marker) && looksLikeCredentialPathValue(credential) || contextualCredentialPathMarkerPattern.test(marker) && (credentialPathContextPattern.test(previous) || looksLikeCredentialPathValue(credential));
|
|
473
|
+
if (!isCredentialBoundary)
|
|
474
|
+
continue;
|
|
475
|
+
url.pathname = `${segments.slice(0, index + 1).join("/")}/`;
|
|
476
|
+
return 1;
|
|
477
|
+
}
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
function sanitizeArtifactUrlWithCount(value) {
|
|
481
|
+
if (value.length > MAX_ARTIFACT_URL_CODE_UNITS) {
|
|
482
|
+
throw new Error("manifest URL exceeds the 65536 code-unit safety limit");
|
|
483
|
+
}
|
|
484
|
+
let url;
|
|
485
|
+
try {
|
|
486
|
+
url = new URL(value);
|
|
487
|
+
} catch {
|
|
488
|
+
throw new Error("manifest URL is invalid");
|
|
489
|
+
}
|
|
490
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
491
|
+
throw new Error("manifest URL must use http or https");
|
|
492
|
+
}
|
|
493
|
+
let count = url.username === "" && url.password === "" ? 0 : 1;
|
|
494
|
+
url.username = "";
|
|
495
|
+
url.password = "";
|
|
496
|
+
count += sanitizeCredentialPath(url);
|
|
497
|
+
const entries = [];
|
|
498
|
+
const valuesByKey = new Map;
|
|
499
|
+
let queryLimitExceeded = false;
|
|
500
|
+
for (const [key, queryValue] of url.searchParams) {
|
|
501
|
+
if (entries.length >= MAX_ARTIFACT_URL_PARAMETERS) {
|
|
502
|
+
queryLimitExceeded = true;
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
entries.push([key, queryValue]);
|
|
506
|
+
const values = valuesByKey.get(key);
|
|
507
|
+
if (values === undefined)
|
|
508
|
+
valuesByKey.set(key, [queryValue]);
|
|
509
|
+
else
|
|
510
|
+
values.push(queryValue);
|
|
511
|
+
}
|
|
512
|
+
if (queryLimitExceeded) {
|
|
513
|
+
url.search = "";
|
|
514
|
+
count += 1;
|
|
515
|
+
}
|
|
516
|
+
const keys = queryLimitExceeded ? [] : [...valuesByKey.keys()];
|
|
517
|
+
const decodedKeys = keys.map((key) => repeatedlyDecodeURIComponent(key));
|
|
518
|
+
const hasAzureSignature = decodedKeys.some((key) => key.toLowerCase() === "sig") && decodedKeys.some((key) => /^(?:sv|sp|se|sr)$/i.test(key));
|
|
519
|
+
const hasAuthenticationCredential = decodedKeys.some((key) => isCredentialAssignmentKey(key) || oneTimeCredentialKeyPattern.test(key) || isAuthenticationQueryKey(key));
|
|
520
|
+
const removedKeys = new Set;
|
|
521
|
+
for (const key of keys) {
|
|
522
|
+
const values = valuesByKey.get(key) ?? [];
|
|
523
|
+
const decodedKey = repeatedlyDecodeURIComponent(key);
|
|
524
|
+
if (isCredentialAssignmentKey(decodedKey) || oneTimeCredentialKeyPattern.test(decodedKey) || isAuthenticationQueryKey(decodedKey) || normalizeCredentialKey(decodedKey) === "state" && hasAuthenticationCredential || providerSignatureKeyPattern.test(decodedKey) || hasAzureSignature && azureSasKeyPattern.test(decodedKey) || values.some((item) => {
|
|
525
|
+
const decodedValue = repeatedlyDecodeURIComponent(item);
|
|
526
|
+
return redactCredentialText(decodedValue).text !== decodedValue;
|
|
527
|
+
})) {
|
|
528
|
+
removedKeys.add(key);
|
|
529
|
+
count += Math.max(1, values.length);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (removedKeys.size > 0) {
|
|
533
|
+
const retained = new URLSearchParams;
|
|
534
|
+
for (const [key, queryValue] of entries) {
|
|
535
|
+
if (!removedKeys.has(key))
|
|
536
|
+
retained.append(key, queryValue);
|
|
537
|
+
}
|
|
538
|
+
url.search = retained.toString();
|
|
539
|
+
}
|
|
540
|
+
const decodedHash = repeatedlyDecodeURIComponent(url.hash);
|
|
541
|
+
const fragmentHasCredential = decodedHash.split(/[?&#;]/).some((segment) => {
|
|
542
|
+
const equals = segment.indexOf("=");
|
|
543
|
+
if (equals < 0)
|
|
544
|
+
return false;
|
|
545
|
+
const pathTail = segment.slice(0, equals).trim().split("/").at(-1) ?? "";
|
|
546
|
+
const decodedKey = repeatedlyDecodeURIComponent(pathTail);
|
|
547
|
+
return isCredentialAssignmentKey(decodedKey) || oneTimeCredentialKeyPattern.test(decodedKey) || isAuthenticationQueryKey(decodedKey) || providerSignatureKeyPattern.test(decodedKey);
|
|
548
|
+
});
|
|
549
|
+
if (fragmentHasCredential || redactCredentialText(decodedHash).text !== decodedHash) {
|
|
550
|
+
url.hash = "";
|
|
551
|
+
count += 1;
|
|
552
|
+
}
|
|
553
|
+
return { text: url.href, count };
|
|
554
|
+
}
|
|
555
|
+
function redactProjectedUrls(value) {
|
|
556
|
+
const projected = projectCredentialEntities(value);
|
|
557
|
+
if (projected.limitExceeded)
|
|
558
|
+
return { text: ENTITY_DENSE_REDACTION, count: 1 };
|
|
559
|
+
const replacements = [];
|
|
560
|
+
let count = 0;
|
|
561
|
+
let candidates = 0;
|
|
562
|
+
const webUrl = /https?:\/\/[^\s<>"']+/gi;
|
|
563
|
+
let match;
|
|
564
|
+
while ((match = webUrl.exec(projected.text)) !== null) {
|
|
565
|
+
candidates += 1;
|
|
566
|
+
if (candidates > MAX_PROJECTED_URL_CANDIDATES || match[0].length > MAX_PROJECTED_URL_LENGTH) {
|
|
567
|
+
return { text: URL_DENSE_REDACTION, count: 1 };
|
|
568
|
+
}
|
|
569
|
+
let urlText = match[0];
|
|
570
|
+
while (/[),.!?;:]$/.test(urlText))
|
|
571
|
+
urlText = urlText.slice(0, -1);
|
|
572
|
+
try {
|
|
573
|
+
const normalized = new URL(urlText).href;
|
|
574
|
+
const sanitized = sanitizeArtifactUrlWithCount(urlText);
|
|
575
|
+
if (sanitized.text === normalized)
|
|
576
|
+
continue;
|
|
577
|
+
const projectedStart = match.index ?? 0;
|
|
578
|
+
const projectedEnd = projectedStart + urlText.length;
|
|
579
|
+
const sourceStart = projectedBoundaryToSource(projected, projectedStart, "start");
|
|
580
|
+
const sourceEnd = projectedBoundaryToSource(projected, projectedEnd, "end");
|
|
581
|
+
if (sourceEnd <= sourceStart)
|
|
582
|
+
continue;
|
|
583
|
+
replacements.push({ start: sourceStart, end: sourceEnd, text: sanitized.text });
|
|
584
|
+
count += Math.max(1, sanitized.count);
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
return { text: applyTextReplacements(value, replacements), count };
|
|
588
|
+
}
|
|
589
|
+
function redactSensitiveTextWithCount(value) {
|
|
590
|
+
const urls = redactProjectedUrls(value);
|
|
591
|
+
const credentials = redactCredentialText(urls.text);
|
|
592
|
+
return { text: credentials.text, count: urls.count + credentials.count };
|
|
593
|
+
}
|
|
594
|
+
function redactSensitiveText(value) {
|
|
595
|
+
return redactSensitiveTextWithCount(value).text;
|
|
596
|
+
}
|
|
597
|
+
function sanitizeArtifactUrl(value) {
|
|
598
|
+
return sanitizeArtifactUrlWithCount(value).text;
|
|
599
|
+
}
|
|
600
|
+
function sanitizeAssetUrl(value) {
|
|
601
|
+
const url = new URL(sanitizeArtifactUrl(value));
|
|
602
|
+
url.search = "";
|
|
603
|
+
url.hash = "";
|
|
604
|
+
return url.href;
|
|
605
|
+
}
|
|
606
|
+
function stripEvidenceReferenceControls(value) {
|
|
607
|
+
let segmentStart = 0;
|
|
608
|
+
let output = null;
|
|
609
|
+
for (let index = 0;index < value.length; index += 1) {
|
|
610
|
+
const code = value.charCodeAt(index);
|
|
611
|
+
const unsafe = code <= 31 || code >= 127 && code <= 159 || code === 1564 || code === 8206 || code === 8207 || code >= 8234 && code <= 8238 || code >= 8294 && code <= 8297;
|
|
612
|
+
if (!unsafe)
|
|
613
|
+
continue;
|
|
614
|
+
output ??= [];
|
|
615
|
+
output.push(value.slice(segmentStart, index));
|
|
616
|
+
segmentStart = index + 1;
|
|
617
|
+
}
|
|
618
|
+
if (output === null)
|
|
619
|
+
return value;
|
|
620
|
+
output.push(value.slice(segmentStart));
|
|
621
|
+
return output.join("");
|
|
622
|
+
}
|
|
623
|
+
function sanitizeEvidenceReference(value) {
|
|
624
|
+
if (value.length > 16 * 1024)
|
|
625
|
+
return null;
|
|
626
|
+
const trimmed = stripEvidenceReferenceControls(value.trim());
|
|
627
|
+
if (trimmed === "" || /^(?:data|javascript|vbscript|file|blob):/i.test(trimmed))
|
|
628
|
+
return null;
|
|
629
|
+
try {
|
|
630
|
+
const base = new URL("https://capture.invalid/");
|
|
631
|
+
const parsed = new URL(trimmed, base);
|
|
632
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
633
|
+
return null;
|
|
634
|
+
const sanitized = new URL(sanitizeArtifactUrl(parsed.href));
|
|
635
|
+
sanitized.search = "";
|
|
636
|
+
sanitized.hash = "";
|
|
637
|
+
if (sanitized.origin !== base.origin)
|
|
638
|
+
return sanitized.href;
|
|
639
|
+
return sanitized.pathname;
|
|
640
|
+
} catch {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function escapeAttribute(value) {
|
|
645
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
646
|
+
}
|
|
647
|
+
var removedContentElements = new Set([
|
|
648
|
+
"applet",
|
|
649
|
+
"audio",
|
|
650
|
+
"button",
|
|
651
|
+
"datalist",
|
|
652
|
+
"embed",
|
|
653
|
+
"form",
|
|
654
|
+
"frame",
|
|
655
|
+
"frameset",
|
|
656
|
+
"iframe",
|
|
657
|
+
"math",
|
|
658
|
+
"noscript",
|
|
659
|
+
"object",
|
|
660
|
+
"plaintext",
|
|
661
|
+
"script",
|
|
662
|
+
"select",
|
|
663
|
+
"style",
|
|
664
|
+
"svg",
|
|
665
|
+
"template",
|
|
666
|
+
"textarea",
|
|
667
|
+
"video",
|
|
668
|
+
"xmp"
|
|
669
|
+
]);
|
|
670
|
+
var removedVoidElements = new Set(["base", "input", "link", "meta", "param", "source", "track"]);
|
|
671
|
+
var documentElements = new Set(["body", "head", "html"]);
|
|
672
|
+
var safeElements = new Set([
|
|
673
|
+
"a",
|
|
674
|
+
"abbr",
|
|
675
|
+
"address",
|
|
676
|
+
"article",
|
|
677
|
+
"aside",
|
|
678
|
+
"b",
|
|
679
|
+
"bdi",
|
|
680
|
+
"bdo",
|
|
681
|
+
"blockquote",
|
|
682
|
+
"br",
|
|
683
|
+
"caption",
|
|
684
|
+
"cite",
|
|
685
|
+
"code",
|
|
686
|
+
"col",
|
|
687
|
+
"colgroup",
|
|
688
|
+
"dd",
|
|
689
|
+
"del",
|
|
690
|
+
"details",
|
|
691
|
+
"dfn",
|
|
692
|
+
"div",
|
|
693
|
+
"dl",
|
|
694
|
+
"dt",
|
|
695
|
+
"em",
|
|
696
|
+
"figcaption",
|
|
697
|
+
"figure",
|
|
698
|
+
"footer",
|
|
699
|
+
"h1",
|
|
700
|
+
"h2",
|
|
701
|
+
"h3",
|
|
702
|
+
"h4",
|
|
703
|
+
"h5",
|
|
704
|
+
"h6",
|
|
705
|
+
"header",
|
|
706
|
+
"hgroup",
|
|
707
|
+
"hr",
|
|
708
|
+
"i",
|
|
709
|
+
"img",
|
|
710
|
+
"ins",
|
|
711
|
+
"kbd",
|
|
712
|
+
"li",
|
|
713
|
+
"main",
|
|
714
|
+
"mark",
|
|
715
|
+
"nav",
|
|
716
|
+
"ol",
|
|
717
|
+
"p",
|
|
718
|
+
"picture",
|
|
719
|
+
"pre",
|
|
720
|
+
"q",
|
|
721
|
+
"rp",
|
|
722
|
+
"rt",
|
|
723
|
+
"ruby",
|
|
724
|
+
"s",
|
|
725
|
+
"samp",
|
|
726
|
+
"section",
|
|
727
|
+
"small",
|
|
728
|
+
"span",
|
|
729
|
+
"strong",
|
|
730
|
+
"sub",
|
|
731
|
+
"summary",
|
|
732
|
+
"sup",
|
|
733
|
+
"table",
|
|
734
|
+
"tbody",
|
|
735
|
+
"td",
|
|
736
|
+
"tfoot",
|
|
737
|
+
"th",
|
|
738
|
+
"thead",
|
|
739
|
+
"time",
|
|
740
|
+
"title",
|
|
741
|
+
"tr",
|
|
742
|
+
"u",
|
|
743
|
+
"ul",
|
|
744
|
+
"var",
|
|
745
|
+
"wbr"
|
|
746
|
+
]);
|
|
747
|
+
var voidElements = new Set(["br", "col", "hr", "img", "wbr"]);
|
|
748
|
+
var safeAttributes = new Set([
|
|
749
|
+
"alt",
|
|
750
|
+
"class",
|
|
751
|
+
"colspan",
|
|
752
|
+
"datetime",
|
|
753
|
+
"dir",
|
|
754
|
+
"height",
|
|
755
|
+
"headers",
|
|
756
|
+
"id",
|
|
757
|
+
"lang",
|
|
758
|
+
"open",
|
|
759
|
+
"role",
|
|
760
|
+
"rowspan",
|
|
761
|
+
"scope",
|
|
762
|
+
"span",
|
|
763
|
+
"start",
|
|
764
|
+
"title",
|
|
765
|
+
"width"
|
|
766
|
+
]);
|
|
767
|
+
var inertUrlAttributes = new Set(["cite", "href", "poster", "src"]);
|
|
768
|
+
function findTagEnd(html, start) {
|
|
769
|
+
let quote = null;
|
|
770
|
+
for (let cursor = start + 1;cursor < html.length; cursor += 1) {
|
|
771
|
+
const character = html[cursor];
|
|
772
|
+
if (quote !== null) {
|
|
773
|
+
if (character === quote)
|
|
774
|
+
quote = null;
|
|
775
|
+
} else if (character === '"' || character === "'") {
|
|
776
|
+
quote = character;
|
|
777
|
+
} else if (character === ">") {
|
|
778
|
+
return cursor;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return -1;
|
|
782
|
+
}
|
|
783
|
+
function findRemovedElementEnd(html, start, tagName) {
|
|
784
|
+
const closing = new RegExp(`<\\/\\s*${tagName}\\b`, "gi");
|
|
785
|
+
closing.lastIndex = start;
|
|
786
|
+
const match = closing.exec(html);
|
|
787
|
+
if (match === null)
|
|
788
|
+
return html.length;
|
|
789
|
+
const end = findTagEnd(html, match.index);
|
|
790
|
+
return end === -1 ? html.length : end + 1;
|
|
791
|
+
}
|
|
792
|
+
var MAX_EVIDENCE_TAG_LENGTH = 64 * 1024;
|
|
793
|
+
var MAX_EVIDENCE_ATTRIBUTES_PER_TAG = 512;
|
|
794
|
+
var MAX_EVIDENCE_STRUCTURAL_TOKENS = 50000;
|
|
795
|
+
var EVIDENCE_LIMIT_MARKER = "<p>[Source evidence omitted after a structural safety limit.]</p>";
|
|
796
|
+
function parseAttributes(tag, start) {
|
|
797
|
+
const attributes = [];
|
|
798
|
+
let cursor = start;
|
|
799
|
+
while (cursor < tag.length && attributes.length < MAX_EVIDENCE_ATTRIBUTES_PER_TAG) {
|
|
800
|
+
while (/\s/.test(tag[cursor] ?? ""))
|
|
801
|
+
cursor += 1;
|
|
802
|
+
if (cursor >= tag.length || tag[cursor] === "/" || tag[cursor] === ">")
|
|
803
|
+
break;
|
|
804
|
+
const nameStart = cursor;
|
|
805
|
+
while (cursor < tag.length && !/[\s"'<>/=]/.test(tag[cursor] ?? ""))
|
|
806
|
+
cursor += 1;
|
|
807
|
+
if (cursor === nameStart) {
|
|
808
|
+
cursor += 1;
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
const name = tag.slice(nameStart, cursor).toLowerCase();
|
|
812
|
+
while (/\s/.test(tag[cursor] ?? ""))
|
|
813
|
+
cursor += 1;
|
|
814
|
+
if (tag[cursor] !== "=") {
|
|
815
|
+
attributes.push({ name, value: null });
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
cursor += 1;
|
|
819
|
+
while (/\s/.test(tag[cursor] ?? ""))
|
|
820
|
+
cursor += 1;
|
|
821
|
+
const quote = tag[cursor];
|
|
822
|
+
if (quote === '"' || quote === "'") {
|
|
823
|
+
cursor += 1;
|
|
824
|
+
const valueStart = cursor;
|
|
825
|
+
while (cursor < tag.length && tag[cursor] !== quote)
|
|
826
|
+
cursor += 1;
|
|
827
|
+
attributes.push({ name, value: tag.slice(valueStart, cursor) });
|
|
828
|
+
if (tag[cursor] === quote)
|
|
829
|
+
cursor += 1;
|
|
830
|
+
} else {
|
|
831
|
+
const valueStart = cursor;
|
|
832
|
+
while (cursor < tag.length && !/[\s>]/.test(tag[cursor] ?? ""))
|
|
833
|
+
cursor += 1;
|
|
834
|
+
attributes.push({ name, value: tag.slice(valueStart, cursor) });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return attributes;
|
|
838
|
+
}
|
|
839
|
+
function sanitizedAttributes(tag, nameEnd) {
|
|
840
|
+
const output = [];
|
|
841
|
+
for (const attribute of parseAttributes(tag, nameEnd)) {
|
|
842
|
+
if (attribute.name.startsWith("on") || attribute.name === "style" || secretKeyPattern.test(attribute.name))
|
|
843
|
+
continue;
|
|
844
|
+
if (inertUrlAttributes.has(attribute.name)) {
|
|
845
|
+
if (attribute.value === null)
|
|
846
|
+
continue;
|
|
847
|
+
const reference = sanitizeEvidenceReference(attribute.value);
|
|
848
|
+
if (reference !== null) {
|
|
849
|
+
output.push(`data-captured-${attribute.name}="${escapeAttribute(reference)}"`);
|
|
850
|
+
}
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (!safeAttributes.has(attribute.name) && !attribute.name.startsWith("aria-"))
|
|
854
|
+
continue;
|
|
855
|
+
if (attribute.value === null) {
|
|
856
|
+
output.push(attribute.name);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
output.push(`${attribute.name}="${escapeAttribute(redactSensitiveText(attribute.value))}"`);
|
|
860
|
+
}
|
|
861
|
+
return output.length === 0 ? "" : ` ${output.join(" ")}`;
|
|
862
|
+
}
|
|
863
|
+
function sanitizeSourceHtml(html) {
|
|
864
|
+
const output = [];
|
|
865
|
+
let cursor = 0;
|
|
866
|
+
let structuralTokens = 0;
|
|
867
|
+
while (cursor < html.length) {
|
|
868
|
+
const opening = html.indexOf("<", cursor);
|
|
869
|
+
if (opening === -1) {
|
|
870
|
+
output.push(redactSensitiveText(html.slice(cursor)));
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
output.push(redactSensitiveText(html.slice(cursor, opening)));
|
|
874
|
+
structuralTokens += 1;
|
|
875
|
+
if (structuralTokens > MAX_EVIDENCE_STRUCTURAL_TOKENS) {
|
|
876
|
+
output.push(EVIDENCE_LIMIT_MARKER);
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
if (html.startsWith("<!--", opening)) {
|
|
880
|
+
const commentEnd = html.indexOf("-->", opening + 4);
|
|
881
|
+
cursor = commentEnd === -1 ? html.length : commentEnd + 3;
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
const tagEnd = findTagEnd(html, opening);
|
|
885
|
+
if (tagEnd === -1) {
|
|
886
|
+
if (html.length - opening > MAX_EVIDENCE_TAG_LENGTH)
|
|
887
|
+
output.push(EVIDENCE_LIMIT_MARKER);
|
|
888
|
+
else
|
|
889
|
+
output.push("<", redactSensitiveText(html.slice(opening + 1)));
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
if (tagEnd - opening + 1 > MAX_EVIDENCE_TAG_LENGTH) {
|
|
893
|
+
output.push(EVIDENCE_LIMIT_MARKER);
|
|
894
|
+
cursor = tagEnd + 1;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
const tag = html.slice(opening, tagEnd + 1);
|
|
898
|
+
const nameMatch = /^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9:-]*)/.exec(tag);
|
|
899
|
+
if (nameMatch === null) {
|
|
900
|
+
cursor = tagEnd + 1;
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const closing = nameMatch[1] === "/";
|
|
904
|
+
const name = (nameMatch[2] ?? "").toLowerCase();
|
|
905
|
+
const nameEnd = nameMatch[0].length;
|
|
906
|
+
if (removedContentElements.has(name)) {
|
|
907
|
+
cursor = closing || /\/\s*>$/.test(tag) ? tagEnd + 1 : findRemovedElementEnd(html, tagEnd + 1, name);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (removedVoidElements.has(name) || documentElements.has(name) || !safeElements.has(name)) {
|
|
911
|
+
cursor = tagEnd + 1;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (closing) {
|
|
915
|
+
if (!voidElements.has(name))
|
|
916
|
+
output.push(`</${name}>`);
|
|
917
|
+
} else {
|
|
918
|
+
output.push(`<${name}${sanitizedAttributes(tag, nameEnd)}>`);
|
|
919
|
+
}
|
|
920
|
+
cursor = tagEnd + 1;
|
|
921
|
+
}
|
|
922
|
+
const csp = "default-src 'none'; img-src 'none'; media-src 'none'; style-src 'none'; script-src 'none'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
923
|
+
return ensureTrailingNewline(sanitizeTerminalText(`<!doctype html>
|
|
924
|
+
<html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><meta name="referrer" content="no-referrer"></head><body><main data-captured-source="true">${output.join("")}</main></body></html>`));
|
|
925
|
+
}
|
|
926
|
+
function requireManifestToken(value, label) {
|
|
927
|
+
if (!safeManifestTokenPattern.test(value))
|
|
928
|
+
throw new Error(`${label} is not a safe manifest token`);
|
|
929
|
+
return value;
|
|
930
|
+
}
|
|
931
|
+
function requireFiniteNumber(value, label) {
|
|
932
|
+
if (!Number.isFinite(value))
|
|
933
|
+
throw new Error(`${label} must be a finite number`);
|
|
934
|
+
return value;
|
|
935
|
+
}
|
|
936
|
+
function requireNonNegativeInteger(value, label) {
|
|
937
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
938
|
+
throw new Error(`${label} must be a non-negative safe integer`);
|
|
939
|
+
return value;
|
|
940
|
+
}
|
|
941
|
+
function optionalManifestText(value, maximum, label) {
|
|
942
|
+
if (value === undefined)
|
|
943
|
+
return;
|
|
944
|
+
if (maximum < 1)
|
|
945
|
+
throw new Error(`${label} has an invalid bound`);
|
|
946
|
+
const sanitized = sanitizeTerminalText(redactSensitiveText(value)).trim();
|
|
947
|
+
if (sanitized === "")
|
|
948
|
+
return;
|
|
949
|
+
if (sanitized.length > maximum)
|
|
950
|
+
return `${sanitized.slice(0, Math.max(0, maximum - 1))}\u2026`;
|
|
951
|
+
return sanitized;
|
|
952
|
+
}
|
|
953
|
+
function normalizeVideoContextMetadata(value) {
|
|
954
|
+
if (value === null)
|
|
955
|
+
return null;
|
|
956
|
+
const id = optionalManifestText(value.id, 512, "video id");
|
|
957
|
+
const title = optionalManifestText(value.title, 2048, "video title");
|
|
958
|
+
const description = optionalManifestText(value.description, 8192, "video description");
|
|
959
|
+
const uploader = optionalManifestText(value.uploader, 1024, "video uploader");
|
|
960
|
+
const uploaderId = optionalManifestText(value.uploaderId, 512, "video uploader id");
|
|
961
|
+
const channel = optionalManifestText(value.channel, 1024, "video channel");
|
|
962
|
+
const channelId = optionalManifestText(value.channelId, 512, "video channel id");
|
|
963
|
+
const extractor = optionalManifestText(value.extractor, 512, "video extractor");
|
|
964
|
+
const durationSeconds = value.durationSeconds === undefined ? undefined : requireFiniteNumber(value.durationSeconds, "artifacts.videoContext.metadata.durationSeconds");
|
|
965
|
+
if (durationSeconds !== undefined && durationSeconds < 0) {
|
|
966
|
+
throw new Error("artifacts.videoContext.metadata.durationSeconds must not be negative");
|
|
967
|
+
}
|
|
968
|
+
const timestamp = value.timestamp === undefined ? undefined : requireFiniteNumber(value.timestamp, "artifacts.videoContext.metadata.timestamp");
|
|
969
|
+
return {
|
|
970
|
+
...id === undefined ? {} : { id },
|
|
971
|
+
...title === undefined ? {} : { title },
|
|
972
|
+
...description === undefined ? {} : { description },
|
|
973
|
+
...uploader === undefined ? {} : { uploader },
|
|
974
|
+
...uploaderId === undefined ? {} : { uploaderId },
|
|
975
|
+
...channel === undefined ? {} : { channel },
|
|
976
|
+
...channelId === undefined ? {} : { channelId },
|
|
977
|
+
...value.webpageUrl === undefined ? {} : { webpageUrl: sanitizeArtifactUrl(value.webpageUrl) },
|
|
978
|
+
...extractor === undefined ? {} : { extractor },
|
|
979
|
+
...durationSeconds === undefined ? {} : { durationSeconds },
|
|
980
|
+
...timestamp === undefined ? {} : { timestamp }
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function sanitizeArtifactPath(value, label) {
|
|
984
|
+
if (value === "" || value.includes("\\") || value.includes("\x00") || isAbsolute(value) || value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
985
|
+
throw new Error(`${label} must be a confined relative artifact path`);
|
|
986
|
+
}
|
|
987
|
+
return value;
|
|
988
|
+
}
|
|
989
|
+
function sanitizeContentType(value) {
|
|
990
|
+
if (value === null)
|
|
991
|
+
return null;
|
|
992
|
+
const [rawMimeType, ...parameters] = value.split(";");
|
|
993
|
+
const mimeType = rawMimeType?.trim().toLowerCase();
|
|
994
|
+
if (mimeType === undefined || !safeArtifactMimePattern.test(mimeType))
|
|
995
|
+
return null;
|
|
996
|
+
const charset = parameters.map((parameter) => /^\s*charset\s*=\s*["']?([a-z0-9._-]+)["']?\s*$/i.exec(parameter)?.[1]).find((candidate) => candidate !== undefined);
|
|
997
|
+
return charset === undefined ? mimeType : `${mimeType}; charset=${charset.toLowerCase()}`;
|
|
998
|
+
}
|
|
999
|
+
function requireListedValue(value, allowed, label) {
|
|
1000
|
+
const match = allowed.find((candidate) => candidate === value);
|
|
1001
|
+
if (match === undefined)
|
|
1002
|
+
throw new Error(`${label} is invalid`);
|
|
1003
|
+
return match;
|
|
1004
|
+
}
|
|
1005
|
+
function normalizeManifest(input, hasSourceHtml, document) {
|
|
1006
|
+
const capturedAtTime = Date.parse(input.capturedAt);
|
|
1007
|
+
if (!Number.isFinite(capturedAtTime))
|
|
1008
|
+
throw new Error("capturedAt must be an ISO-compatible timestamp");
|
|
1009
|
+
const assets = input.assets.map((asset, index) => {
|
|
1010
|
+
if (!safeArtifactMimePattern.test(asset.mimeType))
|
|
1011
|
+
throw new Error(`assets[${index}].mimeType is invalid`);
|
|
1012
|
+
if (!/^[a-f0-9]{64}$/i.test(asset.sha256))
|
|
1013
|
+
throw new Error(`assets[${index}].sha256 must be a SHA-256 digest`);
|
|
1014
|
+
return {
|
|
1015
|
+
source: (() => {
|
|
1016
|
+
try {
|
|
1017
|
+
return sanitizeAssetUrl(asset.source);
|
|
1018
|
+
} catch {
|
|
1019
|
+
return sanitizeEvidenceReference(asset.source) ?? redactSensitiveText(asset.source);
|
|
1020
|
+
}
|
|
1021
|
+
})(),
|
|
1022
|
+
url: sanitizeAssetUrl(asset.url),
|
|
1023
|
+
path: sanitizeArtifactPath(asset.path, `assets[${index}].path`),
|
|
1024
|
+
mimeType: asset.mimeType.toLowerCase(),
|
|
1025
|
+
bytes: requireNonNegativeInteger(asset.bytes, `assets[${index}].bytes`),
|
|
1026
|
+
sha256: asset.sha256.toLowerCase()
|
|
1027
|
+
};
|
|
1028
|
+
}).sort((left, right) => left.path.localeCompare(right.path));
|
|
1029
|
+
const screenshotPath = input.evidence.screenshotPath === null ? null : sanitizeArtifactPath(input.evidence.screenshotPath, "evidence.screenshotPath");
|
|
1030
|
+
if (input.artifacts.images.requested === (input.artifacts.images.status === "not-requested")) {
|
|
1031
|
+
throw new Error("artifacts.images requested/status fields disagree");
|
|
1032
|
+
}
|
|
1033
|
+
if (input.artifacts.media.requested === (input.artifacts.media.status === "not-requested")) {
|
|
1034
|
+
throw new Error("artifacts.media requested/status fields disagree");
|
|
1035
|
+
}
|
|
1036
|
+
if (input.artifacts.videoContext.requested === (input.artifacts.videoContext.status === "not-requested")) {
|
|
1037
|
+
throw new Error("artifacts.videoContext requested/status fields disagree");
|
|
1038
|
+
}
|
|
1039
|
+
if (!input.artifacts.images.requested && input.artifacts.images.files !== 0) {
|
|
1040
|
+
throw new Error("artifacts.images cannot record files when not requested");
|
|
1041
|
+
}
|
|
1042
|
+
if (!input.artifacts.media.requested && input.artifacts.media.files !== 0) {
|
|
1043
|
+
throw new Error("artifacts.media cannot record files when not requested");
|
|
1044
|
+
}
|
|
1045
|
+
if (!input.artifacts.videoContext.requested && (input.artifacts.videoContext.thumbnailPath !== null || input.artifacts.videoContext.transcriptLanguage !== null || input.artifacts.videoContext.transcriptCueCount !== 0 || input.artifacts.videoContext.transcriptTruncated || input.artifacts.videoContext.metadata !== null)) {
|
|
1046
|
+
throw new Error("artifacts.videoContext cannot record context when not requested");
|
|
1047
|
+
}
|
|
1048
|
+
const videoThumbnailPath = input.artifacts.videoContext.thumbnailPath === null ? null : sanitizeArtifactPath(input.artifacts.videoContext.thumbnailPath, "artifacts.videoContext.thumbnailPath");
|
|
1049
|
+
if (videoThumbnailPath !== null && !assets.some((asset) => asset.path === videoThumbnailPath && asset.mimeType.startsWith("image/"))) {
|
|
1050
|
+
throw new Error("artifacts.videoContext.thumbnailPath must reference a captured image asset");
|
|
1051
|
+
}
|
|
1052
|
+
const transcriptLanguage = input.artifacts.videoContext.transcriptLanguage === null ? null : requireManifestToken(input.artifacts.videoContext.transcriptLanguage, "artifacts.videoContext.transcriptLanguage");
|
|
1053
|
+
if (transcriptLanguage === null && (input.artifacts.videoContext.transcriptCueCount !== 0 || input.artifacts.videoContext.transcriptTruncated)) {
|
|
1054
|
+
throw new Error("artifacts.videoContext transcript fields disagree");
|
|
1055
|
+
}
|
|
1056
|
+
const screenshotRequested = input.evidence.requested === "screenshot" || input.evidence.requested === "all";
|
|
1057
|
+
const sourceRequested = input.evidence.requested === "source" || input.evidence.requested === "all";
|
|
1058
|
+
if (screenshotRequested === (input.evidence.screenshotStatus === "not-requested")) {
|
|
1059
|
+
throw new Error("evidence screenshot requested/status fields disagree");
|
|
1060
|
+
}
|
|
1061
|
+
if (input.evidence.screenshotStatus === "captured" !== (screenshotPath !== null)) {
|
|
1062
|
+
throw new Error("evidence screenshot status/path fields disagree");
|
|
1063
|
+
}
|
|
1064
|
+
if (sourceRequested === (input.evidence.sourceHtmlStatus === "not-requested")) {
|
|
1065
|
+
throw new Error("evidence source requested/status fields disagree");
|
|
1066
|
+
}
|
|
1067
|
+
if (input.evidence.sourceHtmlStatus === "captured" !== hasSourceHtml) {
|
|
1068
|
+
throw new Error("evidence source status/file fields disagree");
|
|
1069
|
+
}
|
|
1070
|
+
return {
|
|
1071
|
+
schemaVersion: CAPTURE_MANIFEST_SCHEMA_VERSION,
|
|
1072
|
+
document,
|
|
1073
|
+
sourceUrl: sanitizeArtifactUrl(input.sourceUrl),
|
|
1074
|
+
canonicalUrl: sanitizeArtifactUrl(input.canonicalUrl),
|
|
1075
|
+
capturedAt: new Date(capturedAtTime).toISOString(),
|
|
1076
|
+
platform: requireManifestToken(input.platform, "platform"),
|
|
1077
|
+
status: requireManifestToken(input.status, "status"),
|
|
1078
|
+
scope: requireManifestToken(input.scope, "scope"),
|
|
1079
|
+
acquisition: {
|
|
1080
|
+
method: requireManifestToken(input.acquisition.method, "acquisition.method"),
|
|
1081
|
+
finalUrl: sanitizeArtifactUrl(input.acquisition.finalUrl),
|
|
1082
|
+
contentType: sanitizeContentType(input.acquisition.contentType)
|
|
1083
|
+
},
|
|
1084
|
+
extraction: {
|
|
1085
|
+
extractor: redactSensitiveText(input.extraction.extractor),
|
|
1086
|
+
score: requireFiniteNumber(input.extraction.score, "extraction.score"),
|
|
1087
|
+
wordCount: requireNonNegativeInteger(input.extraction.wordCount, "extraction.wordCount"),
|
|
1088
|
+
capturedItems: requireNonNegativeInteger(input.extraction.capturedItems, "extraction.capturedItems"),
|
|
1089
|
+
expectedItems: input.extraction.expectedItems === null ? null : requireNonNegativeInteger(input.extraction.expectedItems, "extraction.expectedItems")
|
|
1090
|
+
},
|
|
1091
|
+
attempts: input.attempts.map((attempt) => ({
|
|
1092
|
+
method: requireManifestToken(attempt.method, "attempts.method"),
|
|
1093
|
+
outcome: requireManifestToken(attempt.outcome, "attempts.outcome"),
|
|
1094
|
+
message: redactSensitiveText(attempt.message).replace(/[\r\n]+/g, " ").slice(0, 1000)
|
|
1095
|
+
})),
|
|
1096
|
+
assets,
|
|
1097
|
+
artifacts: {
|
|
1098
|
+
images: {
|
|
1099
|
+
requested: input.artifacts.images.requested,
|
|
1100
|
+
status: requireListedValue(input.artifacts.images.status, ["not-requested", "captured", "partial"], "artifacts.images.status"),
|
|
1101
|
+
files: requireNonNegativeInteger(input.artifacts.images.files, "artifacts.images.files")
|
|
1102
|
+
},
|
|
1103
|
+
media: {
|
|
1104
|
+
requested: input.artifacts.media.requested,
|
|
1105
|
+
status: requireListedValue(input.artifacts.media.status, ["not-requested", "captured", "partial", "unavailable", "unsupported", "failed"], "artifacts.media.status"),
|
|
1106
|
+
files: requireNonNegativeInteger(input.artifacts.media.files, "artifacts.media.files")
|
|
1107
|
+
},
|
|
1108
|
+
videoContext: {
|
|
1109
|
+
requested: input.artifacts.videoContext.requested,
|
|
1110
|
+
status: requireListedValue(input.artifacts.videoContext.status, ["not-requested", "captured", "partial", "unavailable", "unsupported", "failed"], "artifacts.videoContext.status"),
|
|
1111
|
+
thumbnailPath: videoThumbnailPath,
|
|
1112
|
+
transcriptLanguage,
|
|
1113
|
+
transcriptCueCount: requireNonNegativeInteger(input.artifacts.videoContext.transcriptCueCount, "artifacts.videoContext.transcriptCueCount"),
|
|
1114
|
+
transcriptTruncated: input.artifacts.videoContext.transcriptTruncated,
|
|
1115
|
+
metadata: normalizeVideoContextMetadata(input.artifacts.videoContext.metadata)
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
evidence: {
|
|
1119
|
+
requested: requireListedValue(input.evidence.requested, ["none", "source", "screenshot", "all"], "evidence.requested"),
|
|
1120
|
+
screenshotPath,
|
|
1121
|
+
screenshotStatus: requireListedValue(input.evidence.screenshotStatus, ["not-requested", "captured", "unavailable"], "evidence.screenshotStatus"),
|
|
1122
|
+
sourceHtmlStatus: requireListedValue(input.evidence.sourceHtmlStatus, ["not-requested", "captured", "unavailable"], "evidence.sourceHtmlStatus"),
|
|
1123
|
+
sourceHtmlPath: hasSourceHtml ? CAPTURE_SOURCE_EVIDENCE_PATH : null
|
|
1124
|
+
},
|
|
1125
|
+
warnings: input.warnings.map(redactSensitiveText)
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function removeOwnedStaging(transaction) {
|
|
1129
|
+
assertConfinedChild(transaction.outputRoot, transaction.stagingDirectory, "capture staging directory");
|
|
1130
|
+
if (existsSync(transaction.stagingDirectory)) {
|
|
1131
|
+
rmSync(transaction.stagingDirectory, { recursive: true, force: true });
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function writeCaptureBundle(transaction, input) {
|
|
1135
|
+
requireState(transaction, "open");
|
|
1136
|
+
try {
|
|
1137
|
+
const markdown = ensureTrailingNewline(redactSensitiveText(input.markdown));
|
|
1138
|
+
const markdownBytes = Buffer.from(markdown, "utf8");
|
|
1139
|
+
const document = {
|
|
1140
|
+
path: captureMarkdownFilename(transaction.slug),
|
|
1141
|
+
bytes: markdownBytes.byteLength,
|
|
1142
|
+
sha256: createHash("sha256").update(markdownBytes).digest("hex")
|
|
1143
|
+
};
|
|
1144
|
+
const manifest = normalizeManifest(input.manifest, input.sourceHtml !== undefined, document);
|
|
1145
|
+
writeFileSync(join(transaction.stagingDirectory, document.path), markdownBytes, { encoding: "utf8", flag: "wx", mode: 420 });
|
|
1146
|
+
writeFileSync(join(transaction.stagingDirectory, CAPTURE_MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}
|
|
1147
|
+
`, { encoding: "utf8", flag: "wx", mode: 420 });
|
|
1148
|
+
if (input.sourceHtml !== undefined) {
|
|
1149
|
+
const evidenceDirectory = join(transaction.stagingDirectory, dirname(CAPTURE_SOURCE_EVIDENCE_PATH));
|
|
1150
|
+
mkdirSync(evidenceDirectory, { recursive: true, mode: 448 });
|
|
1151
|
+
chmodSync(evidenceDirectory, 448);
|
|
1152
|
+
const evidencePath = join(transaction.stagingDirectory, CAPTURE_SOURCE_EVIDENCE_PATH);
|
|
1153
|
+
writeFileSync(evidencePath, sanitizeSourceHtml(input.sourceHtml), {
|
|
1154
|
+
encoding: "utf8",
|
|
1155
|
+
flag: "wx",
|
|
1156
|
+
mode: 384
|
|
1157
|
+
});
|
|
1158
|
+
chmodSync(evidencePath, 384);
|
|
1159
|
+
}
|
|
1160
|
+
transaction[transactionState] = "written";
|
|
1161
|
+
return manifest;
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
removeOwnedStaging(transaction);
|
|
1164
|
+
transaction[transactionState] = "aborted";
|
|
1165
|
+
throw error;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
function assertStagingTreeSafe(root, directory = root) {
|
|
1169
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1170
|
+
const path = join(directory, entry.name);
|
|
1171
|
+
assertConfinedChild(root, path, "staged artifact");
|
|
1172
|
+
const stats = lstatSync(path);
|
|
1173
|
+
if (stats.isSymbolicLink())
|
|
1174
|
+
throw new Error(`staged artifact must not be a symbolic link: ${path}`);
|
|
1175
|
+
if (stats.isDirectory())
|
|
1176
|
+
assertStagingTreeSafe(root, path);
|
|
1177
|
+
else if (!stats.isFile())
|
|
1178
|
+
throw new Error(`staged artifact must be a regular file: ${path}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
function unusedBackupPath(outputRoot) {
|
|
1182
|
+
for (;; ) {
|
|
1183
|
+
const candidate = join(outputRoot, `.capture-backup-${crypto.randomUUID()}`);
|
|
1184
|
+
assertConfinedChild(outputRoot, candidate, "capture backup");
|
|
1185
|
+
if (!existsSync(candidate))
|
|
1186
|
+
return candidate;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
function errorMessage(error) {
|
|
1190
|
+
return error instanceof Error ? error.message : String(error);
|
|
1191
|
+
}
|
|
1192
|
+
function commitCaptureBundle(transaction, hooks = {}) {
|
|
1193
|
+
requireState(transaction, "written");
|
|
1194
|
+
assertStagingTreeSafe(transaction.stagingDirectory);
|
|
1195
|
+
const targetExists = existsSync(transaction.targetDirectory);
|
|
1196
|
+
if (targetExists && !transaction.force) {
|
|
1197
|
+
throw new Error(`capture already exists: ${transaction.targetDirectory}; pass --force to replace it`);
|
|
1198
|
+
}
|
|
1199
|
+
if (targetExists !== (transaction.expectedTargetIdentity !== null)) {
|
|
1200
|
+
throw new Error(`capture target changed during the transaction: ${transaction.targetDirectory}`);
|
|
1201
|
+
}
|
|
1202
|
+
if (targetExists) {
|
|
1203
|
+
const currentIdentity = ownedTargetIdentity(transaction.targetDirectory, transaction.slug);
|
|
1204
|
+
if (currentIdentity.device !== transaction.expectedTargetIdentity?.device || currentIdentity.inode !== transaction.expectedTargetIdentity.inode) {
|
|
1205
|
+
throw new Error(`capture target changed during the transaction: ${transaction.targetDirectory}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
const backupDirectory = targetExists ? unusedBackupPath(transaction.outputRoot) : null;
|
|
1209
|
+
let installed = false;
|
|
1210
|
+
let backedUp = false;
|
|
1211
|
+
try {
|
|
1212
|
+
if (backupDirectory !== null) {
|
|
1213
|
+
renameSync(transaction.targetDirectory, backupDirectory);
|
|
1214
|
+
backedUp = true;
|
|
1215
|
+
hooks.afterBackup?.();
|
|
1216
|
+
}
|
|
1217
|
+
renameSync(transaction.stagingDirectory, transaction.targetDirectory);
|
|
1218
|
+
installed = true;
|
|
1219
|
+
hooks.afterInstall?.();
|
|
1220
|
+
transaction[transactionState] = "committed";
|
|
1221
|
+
if (backupDirectory !== null)
|
|
1222
|
+
rmSync(backupDirectory, { recursive: true, force: true });
|
|
1223
|
+
return transaction.targetDirectory;
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
let rollbackError;
|
|
1226
|
+
try {
|
|
1227
|
+
if (installed && existsSync(transaction.targetDirectory)) {
|
|
1228
|
+
rmSync(transaction.targetDirectory, { recursive: true, force: true });
|
|
1229
|
+
}
|
|
1230
|
+
if (backedUp && backupDirectory !== null && existsSync(backupDirectory)) {
|
|
1231
|
+
if (existsSync(transaction.targetDirectory)) {
|
|
1232
|
+
throw new Error(`cannot restore backup because target was recreated: ${transaction.targetDirectory}`);
|
|
1233
|
+
}
|
|
1234
|
+
renameSync(backupDirectory, transaction.targetDirectory);
|
|
1235
|
+
}
|
|
1236
|
+
} catch (caught) {
|
|
1237
|
+
rollbackError = caught;
|
|
1238
|
+
}
|
|
1239
|
+
transaction[transactionState] = existsSync(transaction.stagingDirectory) ? "written" : "aborted";
|
|
1240
|
+
if (rollbackError !== undefined) {
|
|
1241
|
+
const recovery = backupDirectory === null ? "none" : backupDirectory;
|
|
1242
|
+
throw new Error(`capture commit failed (${errorMessage(error)}) and rollback failed (${errorMessage(rollbackError)}); recovery backup: ${recovery}`, { cause: error });
|
|
1243
|
+
}
|
|
1244
|
+
throw error;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
function abortCaptureBundle(transaction) {
|
|
1248
|
+
if (transaction[transactionState] === "committed" || transaction[transactionState] === "aborted")
|
|
1249
|
+
return;
|
|
1250
|
+
removeOwnedStaging(transaction);
|
|
1251
|
+
transaction[transactionState] = "aborted";
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
export { CAPTURE_MANIFEST_SCHEMA_VERSION, CAPTURE_MANIFEST_FILENAME, CAPTURE_SOURCE_EVIDENCE_PATH, captureMarkdownFilename, beginCaptureBundle, redactSensitiveTextWithCount, redactSensitiveText, sanitizeArtifactUrl, sanitizeSourceHtml, writeCaptureBundle, commitCaptureBundle, abortCaptureBundle };
|