@maestroagora/agora 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +6 -5
- package/DISCLAIMER.md +3 -1
- package/PRIVACY.md +12 -2
- package/README.md +46 -5
- package/package.json +8 -3
- package/scripts/install.mjs +3 -0
- package/skills/agora/SKILL.md +58 -18
- package/skills/agora/agents/openai.yaml +2 -2
- package/skills/agora/references/agora-case-studies.md +13 -5
- package/skills/agora/references/agora-conversion.md +202 -0
- package/skills/agora/references/agora-craft.md +7 -2
- package/skills/agora/references/agora-invest.md +8 -6
- package/skills/agora/references/agora-marketing.md +69 -3
- package/skills/agora/references/agora-publication.md +137 -0
- package/skills/agora/references/agora-science.md +9 -3
- package/skills/agora/references/agora-voice.md +2 -2
- package/skills/agora/scripts/publication-audit.mjs +1225 -0
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { constants as fsConstants } from "node:fs";
|
|
6
|
+
import { lstat, mkdtemp, open, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import { inflateRawSync, inflateSync } from "node:zlib";
|
|
11
|
+
|
|
12
|
+
const SCHEMA_VERSION = "agora.publication-audit.v1";
|
|
13
|
+
const SCANNER_VERSION = "1.0.0";
|
|
14
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
15
|
+
const MAX_FILES = 1000;
|
|
16
|
+
const MAX_ZIP_ENTRIES = 10000;
|
|
17
|
+
const MAX_ZIP_EXPANDED_BYTES = 128 * 1024 * 1024;
|
|
18
|
+
const MAX_ZIP_ENTRY_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
const MAX_EXTERNAL_OUTPUT = 4 * 1024 * 1024;
|
|
20
|
+
const MAX_FINDINGS_PER_FILE = 500;
|
|
21
|
+
const MAX_CONTAINER_SEGMENTS = 10000;
|
|
22
|
+
const MAX_MATCHES_PER_CHECK = 1000;
|
|
23
|
+
const MAX_PNG_EXPANDED_TEXT_BYTES = 32 * 1024 * 1024;
|
|
24
|
+
const TEXT_EXTENSIONS = new Set([".txt", ".md", ".markdown", ".html", ".htm", ".svg"]);
|
|
25
|
+
const OFFICE_EXTENSIONS = new Set([".docx", ".pptx"]);
|
|
26
|
+
const MEDIA_EXTENSIONS = new Set([".pdf", ".png", ".jpg", ".jpeg"]);
|
|
27
|
+
const ALLOWED_STATUSES = new Set(["FOUND", "NOT_FOUND_BY_THIS_CHECK", "UNKNOWN", "ERROR"]);
|
|
28
|
+
const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, index) => {
|
|
29
|
+
let value = index;
|
|
30
|
+
for (let bit = 0; bit < 8; bit += 1) value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
|
|
31
|
+
return value >>> 0;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const HELP = `Agora publication privacy and provenance audit
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
agora-publication-audit <path...> [options]
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--json Print JSON instead of the human report
|
|
41
|
+
--show-values Include bounded metadata values and text context
|
|
42
|
+
--include-paths Include absolute source paths in the report
|
|
43
|
+
--verify-c2pa Run local c2patool verification when available
|
|
44
|
+
--output <path> Write the report to a new file instead of stdout
|
|
45
|
+
--help Show this help
|
|
46
|
+
|
|
47
|
+
The audit never changes source files. It does not remove watermarks, determine
|
|
48
|
+
authorship, or prove that a file is clean. Missing or partial coverage is
|
|
49
|
+
reported as UNKNOWN.
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
function sha256(buffer) {
|
|
53
|
+
return createHash("sha256").update(buffer).digest("hex");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function bounded(value, limit = 240) {
|
|
57
|
+
const normalized = String(value)
|
|
58
|
+
.replace(/\p{Cf}/gu, (character) => `<${codePointLabel(character.codePointAt(0))}>`)
|
|
59
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ")
|
|
60
|
+
.replace(/\s+/g, " ")
|
|
61
|
+
.trim();
|
|
62
|
+
return normalized.length <= limit ? normalized : `${normalized.slice(0, limit)}...`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseArgs(argv) {
|
|
66
|
+
const options = {
|
|
67
|
+
json: false,
|
|
68
|
+
showValues: false,
|
|
69
|
+
includePaths: false,
|
|
70
|
+
verifyC2pa: false,
|
|
71
|
+
output: null,
|
|
72
|
+
paths: [],
|
|
73
|
+
};
|
|
74
|
+
let positionalOnly = false;
|
|
75
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
76
|
+
const arg = argv[index];
|
|
77
|
+
if (positionalOnly) {
|
|
78
|
+
options.paths.push(arg);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (arg === "--") {
|
|
82
|
+
positionalOnly = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (arg === "--help" || arg === "-h") return { ...options, help: true };
|
|
86
|
+
if (arg === "--json") options.json = true;
|
|
87
|
+
else if (arg === "--show-values") options.showValues = true;
|
|
88
|
+
else if (arg === "--include-paths") options.includePaths = true;
|
|
89
|
+
else if (arg === "--verify-c2pa") options.verifyC2pa = true;
|
|
90
|
+
else if (arg === "--output") {
|
|
91
|
+
const value = argv[index + 1];
|
|
92
|
+
if (!value || value.startsWith("--")) throw new Error("--output requires a path");
|
|
93
|
+
options.output = resolve(value);
|
|
94
|
+
index += 1;
|
|
95
|
+
} else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
|
|
96
|
+
else options.paths.push(arg);
|
|
97
|
+
}
|
|
98
|
+
if (options.paths.length === 0) throw new Error("Provide at least one file or directory");
|
|
99
|
+
return options;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function displayPath(path, includePaths) {
|
|
103
|
+
return bounded(includePaths ? resolve(path) : basename(path), 1000);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function collectFiles(inputPaths, options) {
|
|
107
|
+
const files = [];
|
|
108
|
+
const inputErrors = [];
|
|
109
|
+
const skippedSymlinks = [];
|
|
110
|
+
const outputPath = options.output ? resolve(options.output) : null;
|
|
111
|
+
|
|
112
|
+
async function visit(path) {
|
|
113
|
+
if (files.length >= MAX_FILES) throw new Error(`Audit is limited to ${MAX_FILES} files`);
|
|
114
|
+
let stats;
|
|
115
|
+
try {
|
|
116
|
+
stats = await lstat(path);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
inputErrors.push({
|
|
119
|
+
path: displayPath(path, options.includePaths),
|
|
120
|
+
status: "ERROR",
|
|
121
|
+
detail: `Cannot inspect input: ${error.code || "read error"}`,
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (stats.isSymbolicLink()) {
|
|
126
|
+
skippedSymlinks.push(displayPath(path, options.includePaths));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (stats.isDirectory()) {
|
|
130
|
+
let entries;
|
|
131
|
+
try {
|
|
132
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
inputErrors.push({
|
|
135
|
+
path: displayPath(path, options.includePaths),
|
|
136
|
+
status: "ERROR",
|
|
137
|
+
detail: `Cannot read directory: ${error.code || "read error"}`,
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
142
|
+
await visit(join(path, entry.name));
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!stats.isFile()) return;
|
|
147
|
+
if (outputPath && resolve(path) === outputPath) return;
|
|
148
|
+
files.push(resolve(path));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const input of inputPaths) await visit(resolve(input));
|
|
152
|
+
return { files, inputErrors, skippedSymlinks };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createResult(path, options) {
|
|
156
|
+
return {
|
|
157
|
+
path: displayPath(path, options.includePaths),
|
|
158
|
+
...(options.includePaths ? { absolutePath: bounded(resolve(path), 1000) } : {}),
|
|
159
|
+
extension: bounded(extname(path).toLowerCase(), 32) || null,
|
|
160
|
+
sizeBytes: null,
|
|
161
|
+
sha256Before: null,
|
|
162
|
+
sha256After: null,
|
|
163
|
+
sourceUnchanged: null,
|
|
164
|
+
status: "UNKNOWN",
|
|
165
|
+
findings: [],
|
|
166
|
+
coverage: [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function addCoverage(result, check, status, detail) {
|
|
171
|
+
if (!ALLOWED_STATUSES.has(status)) throw new Error(`Invalid status: ${status}`);
|
|
172
|
+
result.coverage.push({ check: bounded(check, 100), status, detail: bounded(detail, 500) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function addFinding(result, options, finding) {
|
|
176
|
+
if (result.findings.length >= MAX_FINDINGS_PER_FILE) {
|
|
177
|
+
if (!result.coverage.some((entry) => entry.check === "finding-limit")) {
|
|
178
|
+
addCoverage(result, "finding-limit", "UNKNOWN", `More than ${MAX_FINDINGS_PER_FILE} findings were present; remaining findings were omitted.`);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const entry = {
|
|
183
|
+
id: bounded(finding.id, 100),
|
|
184
|
+
category: bounded(finding.category, 40),
|
|
185
|
+
severity: bounded(finding.severity, 40),
|
|
186
|
+
status: "FOUND",
|
|
187
|
+
title: bounded(finding.title, 200),
|
|
188
|
+
...(finding.field ? { field: finding.sourceControlledField && !options.showValues ? "[redacted]" : bounded(finding.field, 200) } : {}),
|
|
189
|
+
...(finding.count ? { count: finding.count } : {}),
|
|
190
|
+
...(finding.locations ? { locations: finding.locations } : {}),
|
|
191
|
+
...(finding.detail ? { detail: bounded(finding.detail, 500) } : {}),
|
|
192
|
+
};
|
|
193
|
+
if (Object.hasOwn(finding, "value")) {
|
|
194
|
+
entry.value = options.showValues ? bounded(finding.value) : "[redacted]";
|
|
195
|
+
}
|
|
196
|
+
result.findings.push(entry);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function finalizeStatus(result) {
|
|
200
|
+
if (result.coverage.some((entry) => entry.status === "ERROR")) result.status = "ERROR";
|
|
201
|
+
else if (result.findings.length > 0) result.status = "FOUND";
|
|
202
|
+
else if (result.coverage.some((entry) => entry.status === "UNKNOWN")) result.status = "UNKNOWN";
|
|
203
|
+
else if (result.coverage.some((entry) => entry.status === "NOT_FOUND_BY_THIS_CHECK")) {
|
|
204
|
+
result.status = "NOT_FOUND_BY_THIS_CHECK";
|
|
205
|
+
} else result.status = "UNKNOWN";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function codePointLabel(codePoint) {
|
|
209
|
+
return `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function classifyInvisible(codePoint, offset) {
|
|
213
|
+
const map = new Map([
|
|
214
|
+
[0x00a0, ["NO-BREAK SPACE", "info", "Visible spacing can differ across renderers."]],
|
|
215
|
+
[0x00ad, ["SOFT HYPHEN", "review", "May change line wrapping or remain visually hidden."]],
|
|
216
|
+
[0x200b, ["ZERO WIDTH SPACE", "review", "Invisible separator; review its purpose."]],
|
|
217
|
+
[0x200c, ["ZERO WIDTH NON-JOINER", "info", "May be required for language-specific shaping."]],
|
|
218
|
+
[0x200d, ["ZERO WIDTH JOINER", "info", "May be required for emoji or script shaping."]],
|
|
219
|
+
[0x200e, ["LEFT-TO-RIGHT MARK", "review", "Directional control; review its purpose."]],
|
|
220
|
+
[0x200f, ["RIGHT-TO-LEFT MARK", "review", "Directional control; review its purpose."]],
|
|
221
|
+
[0x202a, ["LEFT-TO-RIGHT EMBEDDING", "review", "Directional control; review display order."]],
|
|
222
|
+
[0x202b, ["RIGHT-TO-LEFT EMBEDDING", "review", "Directional control; review display order."]],
|
|
223
|
+
[0x202c, ["POP DIRECTIONAL FORMATTING", "review", "Directional control; review display order."]],
|
|
224
|
+
[0x202d, ["LEFT-TO-RIGHT OVERRIDE", "review", "Directional override; review display order."]],
|
|
225
|
+
[0x202e, ["RIGHT-TO-LEFT OVERRIDE", "review", "Directional override; review display order."]],
|
|
226
|
+
[0x2060, ["WORD JOINER", "review", "Invisible joining control; review its purpose."]],
|
|
227
|
+
[0x2066, ["LEFT-TO-RIGHT ISOLATE", "review", "Directional control; review display order."]],
|
|
228
|
+
[0x2067, ["RIGHT-TO-LEFT ISOLATE", "review", "Directional control; review display order."]],
|
|
229
|
+
[0x2068, ["FIRST STRONG ISOLATE", "review", "Directional control; review display order."]],
|
|
230
|
+
[0x2069, ["POP DIRECTIONAL ISOLATE", "review", "Directional control; review display order."]],
|
|
231
|
+
[0xfeff, [offset === 0 ? "BYTE ORDER MARK" : "ZERO WIDTH NO-BREAK SPACE", offset === 0 ? "info" : "review", offset === 0 ? "Normal at the start of some text files." : "Invisible character inside text; review its purpose."]],
|
|
232
|
+
]);
|
|
233
|
+
if (map.has(codePoint)) return map.get(codePoint);
|
|
234
|
+
if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) || (codePoint >= 0xe0100 && codePoint <= 0xe01ef)) {
|
|
235
|
+
return ["VARIATION SELECTOR", "info", "May be required for emoji or glyph presentation."];
|
|
236
|
+
}
|
|
237
|
+
if ((codePoint >= 0 && codePoint <= 0x08) || codePoint === 0x0b || codePoint === 0x0c
|
|
238
|
+
|| (codePoint >= 0x0e && codePoint <= 0x1f) || codePoint === 0x7f) {
|
|
239
|
+
return ["CONTROL CHARACTER", "review", "Non-printing control; review its purpose."];
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function scanUnicode(text, result, options) {
|
|
245
|
+
const groups = new Map();
|
|
246
|
+
let line = 1;
|
|
247
|
+
let column = 1;
|
|
248
|
+
for (let offset = 0; offset < text.length;) {
|
|
249
|
+
const codePoint = text.codePointAt(offset);
|
|
250
|
+
const width = codePoint > 0xffff ? 2 : 1;
|
|
251
|
+
const classification = classifyInvisible(codePoint, offset);
|
|
252
|
+
if (classification) {
|
|
253
|
+
const key = codePointLabel(codePoint);
|
|
254
|
+
const existing = groups.get(key) ?? {
|
|
255
|
+
codePoint,
|
|
256
|
+
name: classification[0],
|
|
257
|
+
severity: classification[1],
|
|
258
|
+
detail: classification[2],
|
|
259
|
+
count: 0,
|
|
260
|
+
locations: [],
|
|
261
|
+
contexts: [],
|
|
262
|
+
};
|
|
263
|
+
existing.count += 1;
|
|
264
|
+
if (existing.locations.length < 5) existing.locations.push({ line, column, offset });
|
|
265
|
+
if (existing.contexts.length < 3) existing.contexts.push(text.slice(Math.max(0, offset - 16), offset + width + 16));
|
|
266
|
+
groups.set(key, existing);
|
|
267
|
+
}
|
|
268
|
+
if (codePoint === 0x0a) {
|
|
269
|
+
line += 1;
|
|
270
|
+
column = 1;
|
|
271
|
+
} else column += 1;
|
|
272
|
+
offset += width;
|
|
273
|
+
}
|
|
274
|
+
for (const [label, group] of groups) {
|
|
275
|
+
addFinding(result, options, {
|
|
276
|
+
id: `unicode-${label.toLowerCase().replace("+", "-")}`,
|
|
277
|
+
category: "privacy",
|
|
278
|
+
severity: group.severity,
|
|
279
|
+
title: `${label} ${group.name}`,
|
|
280
|
+
count: group.count,
|
|
281
|
+
locations: group.locations,
|
|
282
|
+
detail: group.detail,
|
|
283
|
+
...(options.showValues ? { value: group.contexts.join(" | ") } : {}),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
addCoverage(
|
|
287
|
+
result,
|
|
288
|
+
"unicode-invisible-controls",
|
|
289
|
+
groups.size > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
290
|
+
groups.size > 0 ? `${groups.size} code-point type(s) found.` : "No configured invisible or control characters found.",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function parseHtmlAttributes(tag) {
|
|
295
|
+
const attributes = new Map();
|
|
296
|
+
const pattern = /([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
297
|
+
for (const match of tag.matchAll(pattern)) {
|
|
298
|
+
attributes.set(match[1].toLowerCase(), match[2] ?? match[3] ?? match[4] ?? "");
|
|
299
|
+
}
|
|
300
|
+
return attributes;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function collectMatches(text, pattern, limit = MAX_MATCHES_PER_CHECK) {
|
|
304
|
+
const matches = [];
|
|
305
|
+
for (const match of text.matchAll(pattern)) {
|
|
306
|
+
if (matches.length >= limit) return { matches, truncated: true };
|
|
307
|
+
matches.push(match);
|
|
308
|
+
}
|
|
309
|
+
return { matches, truncated: false };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function scanMarkup(text, extension, result, options) {
|
|
313
|
+
let metadataCount = 0;
|
|
314
|
+
const metaTags = collectMatches(text, /<meta\b[^>]*>/gi);
|
|
315
|
+
for (const match of metaTags.matches) {
|
|
316
|
+
const attributes = parseHtmlAttributes(match[0]);
|
|
317
|
+
const field = attributes.get("name") || attributes.get("property") || attributes.get("itemprop") || "meta";
|
|
318
|
+
if (!/(?:author|creator|generator|producer|application|company|manager|copyright|email|c2pa|credential)/i.test(field)) continue;
|
|
319
|
+
const value = attributes.get("content") ?? "";
|
|
320
|
+
metadataCount += 1;
|
|
321
|
+
addFinding(result, options, {
|
|
322
|
+
id: "markup-meta-field",
|
|
323
|
+
category: /c2pa|credential/i.test(field) ? "provenance" : "privacy",
|
|
324
|
+
severity: /c2pa|credential/i.test(field) ? "provenance" : /author|creator|company|manager|email/i.test(field) ? "sensitive" : "info",
|
|
325
|
+
title: "Markup metadata field",
|
|
326
|
+
field,
|
|
327
|
+
sourceControlledField: true,
|
|
328
|
+
value,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (metaTags.truncated) addCoverage(result, "markup-meta-fields", "UNKNOWN", `Metadata scan stopped after ${MAX_MATCHES_PER_CHECK} tags.`);
|
|
332
|
+
const comments = collectMatches(text, /<!--[\s\S]*?-->/g);
|
|
333
|
+
if (comments.matches.length > 0) {
|
|
334
|
+
metadataCount += comments.matches.length;
|
|
335
|
+
addFinding(result, options, {
|
|
336
|
+
id: "markup-comments",
|
|
337
|
+
category: "privacy",
|
|
338
|
+
severity: "review",
|
|
339
|
+
title: "Published markup comments",
|
|
340
|
+
count: comments.matches.length,
|
|
341
|
+
detail: "Comments remain available to recipients and page-source viewers.",
|
|
342
|
+
...(options.showValues ? { value: comments.matches.slice(0, 3).map((item) => item[0]).join(" | ") } : {}),
|
|
343
|
+
});
|
|
344
|
+
if (comments.truncated) addCoverage(result, "markup-comments", "UNKNOWN", `Comment scan stopped after ${MAX_MATCHES_PER_CHECK} matches.`);
|
|
345
|
+
}
|
|
346
|
+
if (extension === ".svg") {
|
|
347
|
+
const metadataBlocks = collectMatches(text, /<metadata\b[\s\S]*?<\/metadata>/gi);
|
|
348
|
+
if (metadataBlocks.matches.length > 0) {
|
|
349
|
+
metadataCount += metadataBlocks.matches.length;
|
|
350
|
+
addFinding(result, options, {
|
|
351
|
+
id: "svg-metadata",
|
|
352
|
+
category: "privacy",
|
|
353
|
+
severity: "review",
|
|
354
|
+
title: "SVG metadata block",
|
|
355
|
+
count: metadataBlocks.matches.length,
|
|
356
|
+
...(options.showValues ? { value: metadataBlocks.matches.slice(0, 2).map((item) => item[0]).join(" | ") } : {}),
|
|
357
|
+
});
|
|
358
|
+
if (metadataBlocks.truncated) addCoverage(result, "svg-metadata-blocks", "UNKNOWN", `Metadata scan stopped after ${MAX_MATCHES_PER_CHECK} matches.`);
|
|
359
|
+
}
|
|
360
|
+
for (const attribute of ["inkscape:version", "sodipodi:docname"]) {
|
|
361
|
+
const match = text.match(new RegExp(`${attribute}\\s*=\\s*["']([^"']*)["']`, "i"));
|
|
362
|
+
if (!match) continue;
|
|
363
|
+
metadataCount += 1;
|
|
364
|
+
addFinding(result, options, {
|
|
365
|
+
id: "svg-editor-field",
|
|
366
|
+
category: "provenance",
|
|
367
|
+
severity: "provenance",
|
|
368
|
+
title: "SVG editor field",
|
|
369
|
+
field: attribute,
|
|
370
|
+
value: match[1],
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
addCoverage(
|
|
375
|
+
result,
|
|
376
|
+
"markup-metadata",
|
|
377
|
+
metadataCount > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
378
|
+
metadataCount > 0 ? `${metadataCount} markup metadata item(s) found.` : "No configured markup metadata found.",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function zipEntries(buffer) {
|
|
383
|
+
const minimum = Math.max(0, buffer.length - 65557);
|
|
384
|
+
let eocd = -1;
|
|
385
|
+
for (let index = buffer.length - 22; index >= minimum; index -= 1) {
|
|
386
|
+
if (buffer.readUInt32LE(index) === 0x06054b50) {
|
|
387
|
+
eocd = index;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (eocd < 0) throw new Error("ZIP end-of-central-directory record not found");
|
|
392
|
+
const entryCount = buffer.readUInt16LE(eocd + 10);
|
|
393
|
+
const diskNumber = buffer.readUInt16LE(eocd + 4);
|
|
394
|
+
const centralDisk = buffer.readUInt16LE(eocd + 6);
|
|
395
|
+
const diskEntryCount = buffer.readUInt16LE(eocd + 8);
|
|
396
|
+
const archiveCommentLength = buffer.readUInt16LE(eocd + 20);
|
|
397
|
+
if (eocd + 22 + archiveCommentLength !== buffer.length) throw new Error("ZIP end record or archive comment is inconsistent");
|
|
398
|
+
if (diskNumber !== 0 || centralDisk !== 0 || diskEntryCount !== entryCount) {
|
|
399
|
+
throw new Error("Multi-disk ZIP files are not supported");
|
|
400
|
+
}
|
|
401
|
+
const centralSize = buffer.readUInt32LE(eocd + 12);
|
|
402
|
+
const centralOffset = buffer.readUInt32LE(eocd + 16);
|
|
403
|
+
if (entryCount > MAX_ZIP_ENTRIES) throw new Error(`ZIP contains more than ${MAX_ZIP_ENTRIES} entries`);
|
|
404
|
+
if (centralOffset + centralSize > buffer.length) throw new Error("ZIP central directory is outside the file");
|
|
405
|
+
if (centralOffset + centralSize !== eocd) throw new Error("ZIP central directory boundary is inconsistent");
|
|
406
|
+
const entries = [];
|
|
407
|
+
const names = new Set();
|
|
408
|
+
let cursor = centralOffset;
|
|
409
|
+
let totalExpanded = 0;
|
|
410
|
+
for (let count = 0; count < entryCount; count += 1) {
|
|
411
|
+
if (cursor + 46 > buffer.length || buffer.readUInt32LE(cursor) !== 0x02014b50) {
|
|
412
|
+
throw new Error("ZIP central directory is malformed");
|
|
413
|
+
}
|
|
414
|
+
const flags = buffer.readUInt16LE(cursor + 8);
|
|
415
|
+
const method = buffer.readUInt16LE(cursor + 10);
|
|
416
|
+
const crc = buffer.readUInt32LE(cursor + 16);
|
|
417
|
+
const compressedSize = buffer.readUInt32LE(cursor + 20);
|
|
418
|
+
const uncompressedSize = buffer.readUInt32LE(cursor + 24);
|
|
419
|
+
const nameLength = buffer.readUInt16LE(cursor + 28);
|
|
420
|
+
const extraLength = buffer.readUInt16LE(cursor + 30);
|
|
421
|
+
const commentLength = buffer.readUInt16LE(cursor + 32);
|
|
422
|
+
const localOffset = buffer.readUInt32LE(cursor + 42);
|
|
423
|
+
if ([compressedSize, uncompressedSize, localOffset].includes(0xffffffff)) {
|
|
424
|
+
throw new Error("ZIP64 office files are not supported by this check");
|
|
425
|
+
}
|
|
426
|
+
if (flags & 0x1) throw new Error("Encrypted ZIP entries are not supported");
|
|
427
|
+
const nameStart = cursor + 46;
|
|
428
|
+
const nameEnd = nameStart + nameLength;
|
|
429
|
+
if (nameEnd > buffer.length) throw new Error("ZIP entry name is outside the file");
|
|
430
|
+
const encoding = flags & 0x800 ? "utf8" : "latin1";
|
|
431
|
+
const name = buffer.subarray(nameStart, nameEnd).toString(encoding).replaceAll("\\", "/");
|
|
432
|
+
if (name.split("/").some((part) => part === "..")) throw new Error("ZIP entry contains parent traversal");
|
|
433
|
+
if (names.has(name)) throw new Error(`ZIP contains duplicate entry: ${bounded(name)}`);
|
|
434
|
+
names.add(name);
|
|
435
|
+
totalExpanded += uncompressedSize;
|
|
436
|
+
if (totalExpanded > MAX_ZIP_EXPANDED_BYTES) throw new Error("ZIP expanded-size limit exceeded");
|
|
437
|
+
entries.push({ name, flags, method, crc, compressedSize, uncompressedSize, localOffset });
|
|
438
|
+
cursor = nameEnd + extraLength + commentLength;
|
|
439
|
+
if (cursor > centralOffset + centralSize) throw new Error("ZIP central entry exceeds the central directory");
|
|
440
|
+
}
|
|
441
|
+
if (cursor !== centralOffset + centralSize) throw new Error("ZIP central directory size is inconsistent");
|
|
442
|
+
return entries;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function crc32Parts(parts) {
|
|
446
|
+
let crc = 0xffffffff;
|
|
447
|
+
for (const buffer of parts) {
|
|
448
|
+
for (const byte of buffer) crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
449
|
+
}
|
|
450
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function crc32(buffer) {
|
|
454
|
+
return crc32Parts([buffer]);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function readZipEntry(buffer, entry) {
|
|
458
|
+
if (entry.uncompressedSize > MAX_ZIP_ENTRY_BYTES) throw new Error(`ZIP entry too large: ${entry.name}`);
|
|
459
|
+
const offset = entry.localOffset;
|
|
460
|
+
if (offset + 30 > buffer.length || buffer.readUInt32LE(offset) !== 0x04034b50) {
|
|
461
|
+
throw new Error(`ZIP local header is malformed: ${entry.name}`);
|
|
462
|
+
}
|
|
463
|
+
const localFlags = buffer.readUInt16LE(offset + 6);
|
|
464
|
+
const localMethod = buffer.readUInt16LE(offset + 8);
|
|
465
|
+
const localCrc = buffer.readUInt32LE(offset + 14);
|
|
466
|
+
const localCompressedSize = buffer.readUInt32LE(offset + 18);
|
|
467
|
+
const localUncompressedSize = buffer.readUInt32LE(offset + 22);
|
|
468
|
+
const nameLength = buffer.readUInt16LE(offset + 26);
|
|
469
|
+
const extraLength = buffer.readUInt16LE(offset + 28);
|
|
470
|
+
const localNameStart = offset + 30;
|
|
471
|
+
const localNameEnd = localNameStart + nameLength;
|
|
472
|
+
if (localNameEnd > buffer.length) throw new Error(`ZIP local name is outside the file: ${bounded(entry.name)}`);
|
|
473
|
+
const localEncoding = localFlags & 0x800 ? "utf8" : "latin1";
|
|
474
|
+
const localName = buffer.subarray(localNameStart, localNameEnd).toString(localEncoding).replaceAll("\\", "/");
|
|
475
|
+
if (localName !== entry.name || localFlags !== entry.flags || localMethod !== entry.method) {
|
|
476
|
+
throw new Error(`ZIP local and central headers disagree: ${bounded(entry.name)}`);
|
|
477
|
+
}
|
|
478
|
+
if (!(entry.flags & 0x8)
|
|
479
|
+
&& (localCrc !== entry.crc || localCompressedSize !== entry.compressedSize || localUncompressedSize !== entry.uncompressedSize)) {
|
|
480
|
+
throw new Error(`ZIP local sizes or CRC disagree: ${bounded(entry.name)}`);
|
|
481
|
+
}
|
|
482
|
+
const start = offset + 30 + nameLength + extraLength;
|
|
483
|
+
const end = start + entry.compressedSize;
|
|
484
|
+
if (end > buffer.length) throw new Error(`ZIP entry data is outside the file: ${entry.name}`);
|
|
485
|
+
const compressed = buffer.subarray(start, end);
|
|
486
|
+
let data;
|
|
487
|
+
if (entry.method === 0) data = compressed;
|
|
488
|
+
else if (entry.method === 8) data = inflateRawSync(compressed, { maxOutputLength: MAX_ZIP_ENTRY_BYTES });
|
|
489
|
+
else throw new Error(`Unsupported ZIP compression method ${entry.method}: ${entry.name}`);
|
|
490
|
+
if (data.length !== entry.uncompressedSize) throw new Error(`ZIP entry size mismatch: ${entry.name}`);
|
|
491
|
+
if (crc32(data) !== entry.crc) throw new Error(`ZIP entry CRC mismatch: ${bounded(entry.name)}`);
|
|
492
|
+
return data;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function decodeXml(value) {
|
|
496
|
+
return bounded(value
|
|
497
|
+
.replace(/<[^>]*>/g, " ")
|
|
498
|
+
.replace(/</g, "<")
|
|
499
|
+
.replace(/>/g, ">")
|
|
500
|
+
.replace(/"/g, '"')
|
|
501
|
+
.replace(/'/g, "'")
|
|
502
|
+
.replace(/&/g, "&"));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function xmlTagValues(xml, localName) {
|
|
506
|
+
const pattern = new RegExp(`<[^>:/\\s]+:${localName}\\b[^>]*>([\\s\\S]*?)<\\/[^>:/\\s]+:${localName}>`, "gi");
|
|
507
|
+
const values = [];
|
|
508
|
+
for (const match of xml.matchAll(pattern)) {
|
|
509
|
+
if (values.length >= MAX_MATCHES_PER_CHECK) break;
|
|
510
|
+
const value = decodeXml(match[1]);
|
|
511
|
+
if (value) values.push(value);
|
|
512
|
+
}
|
|
513
|
+
return values;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function officeEntryText(buffer, entries, name) {
|
|
517
|
+
const entry = entries.find((candidate) => candidate.name.toLowerCase() === name.toLowerCase());
|
|
518
|
+
return entry ? readZipEntry(buffer, entry).toString("utf8") : "";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function addOfficeProperty(result, options, source, field, value, severity = "review") {
|
|
522
|
+
addFinding(result, options, {
|
|
523
|
+
id: "office-property",
|
|
524
|
+
category: severity === "info" ? "provenance" : "privacy",
|
|
525
|
+
severity,
|
|
526
|
+
title: "Office document property",
|
|
527
|
+
field: `${source}.${field}`,
|
|
528
|
+
value,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function scanOffice(buffer, extension, result, options) {
|
|
533
|
+
let entries;
|
|
534
|
+
try {
|
|
535
|
+
entries = zipEntries(buffer);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
addCoverage(result, "office-container", "ERROR", error.message);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
addCoverage(result, "office-container", "NOT_FOUND_BY_THIS_CHECK", `Parsed ${entries.length} container entries without extraction.`);
|
|
541
|
+
const core = officeEntryText(buffer, entries, "docProps/core.xml");
|
|
542
|
+
const app = officeEntryText(buffer, entries, "docProps/app.xml");
|
|
543
|
+
let propertyCount = 0;
|
|
544
|
+
for (const [field, severity] of [
|
|
545
|
+
["creator", "sensitive"],
|
|
546
|
+
["lastModifiedBy", "sensitive"],
|
|
547
|
+
["title", "review"],
|
|
548
|
+
["subject", "review"],
|
|
549
|
+
["description", "review"],
|
|
550
|
+
["keywords", "review"],
|
|
551
|
+
["created", "info"],
|
|
552
|
+
["modified", "info"],
|
|
553
|
+
]) {
|
|
554
|
+
for (const value of xmlTagValues(core, field)) {
|
|
555
|
+
propertyCount += 1;
|
|
556
|
+
addOfficeProperty(result, options, "core", field, value, severity);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
for (const [field, severity] of [
|
|
560
|
+
["Application", "info"],
|
|
561
|
+
["AppVersion", "info"],
|
|
562
|
+
["Company", "sensitive"],
|
|
563
|
+
["Manager", "sensitive"],
|
|
564
|
+
["Template", "review"],
|
|
565
|
+
]) {
|
|
566
|
+
const pattern = new RegExp(`<${field}\\b[^>]*>([\\s\\S]*?)<\\/${field}>`, "gi");
|
|
567
|
+
for (const match of app.matchAll(pattern)) {
|
|
568
|
+
propertyCount += 1;
|
|
569
|
+
addOfficeProperty(result, options, "app", field, decodeXml(match[1]), severity);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const customProperties = entries.filter((entry) => /^docProps\/custom\.xml$/i.test(entry.name));
|
|
573
|
+
if (customProperties.length > 0) {
|
|
574
|
+
propertyCount += customProperties.length;
|
|
575
|
+
addFinding(result, options, {
|
|
576
|
+
id: "office-custom-properties",
|
|
577
|
+
category: "privacy",
|
|
578
|
+
severity: "review",
|
|
579
|
+
title: "Office custom properties",
|
|
580
|
+
count: customProperties.length,
|
|
581
|
+
detail: "Custom properties can contain organization-specific or personal data.",
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
addCoverage(
|
|
585
|
+
result,
|
|
586
|
+
"office-properties",
|
|
587
|
+
propertyCount > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
588
|
+
propertyCount > 0 ? `${propertyCount} configured property value(s) found.` : "No configured Office properties found.",
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
if (extension === ".docx") {
|
|
592
|
+
const comments = entries.filter((entry) => /^word\/comments[^/]*\.xml$/i.test(entry.name));
|
|
593
|
+
const storyParts = entries.filter((entry) => /^word\/(?:document|header\d+|footer\d+|footnotes|endnotes|comments[^/]*)\.xml$/i.test(entry.name));
|
|
594
|
+
let tracked = 0;
|
|
595
|
+
let trackedTruncated = false;
|
|
596
|
+
for (const entry of storyParts) {
|
|
597
|
+
const xml = readZipEntry(buffer, entry).toString("utf8");
|
|
598
|
+
for (const _match of xml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?(?:ins|del|moveFrom|moveTo)\b/gi)) {
|
|
599
|
+
tracked += 1;
|
|
600
|
+
if (tracked >= MAX_MATCHES_PER_CHECK) {
|
|
601
|
+
trackedTruncated = true;
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (trackedTruncated) break;
|
|
606
|
+
}
|
|
607
|
+
if (comments.length > 0) addFinding(result, options, {
|
|
608
|
+
id: "docx-comments",
|
|
609
|
+
category: "privacy",
|
|
610
|
+
severity: "sensitive",
|
|
611
|
+
title: "Word comments",
|
|
612
|
+
count: comments.length,
|
|
613
|
+
detail: "Comments and their author fields can remain in the document package.",
|
|
614
|
+
});
|
|
615
|
+
if (tracked > 0) addFinding(result, options, {
|
|
616
|
+
id: "docx-tracked-changes",
|
|
617
|
+
category: "privacy",
|
|
618
|
+
severity: "sensitive",
|
|
619
|
+
title: "Word tracked changes",
|
|
620
|
+
count: tracked,
|
|
621
|
+
detail: "Inserted or deleted content remains represented in document XML.",
|
|
622
|
+
});
|
|
623
|
+
if (trackedTruncated) addCoverage(result, "docx-tracked-changes", "UNKNOWN", `Tracked-change scan stopped after ${MAX_MATCHES_PER_CHECK} elements.`);
|
|
624
|
+
addCoverage(result, "docx-review-data", comments.length + tracked > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
625
|
+
comments.length + tracked > 0 ? "Comments or tracked changes found." : "No comments or tracked-change elements found.");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (extension === ".pptx") {
|
|
629
|
+
const notes = entries.filter((entry) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/i.test(entry.name));
|
|
630
|
+
const comments = entries.filter((entry) => /^ppt\/comments\/comment\d+\.xml$/i.test(entry.name));
|
|
631
|
+
const commentAuthors = entries.filter((entry) => /^ppt\/commentAuthors\.xml$/i.test(entry.name));
|
|
632
|
+
let hiddenSlides = 0;
|
|
633
|
+
for (const entry of entries.filter((candidate) => /^ppt\/slides\/slide\d+\.xml$/i.test(candidate.name))) {
|
|
634
|
+
const xml = readZipEntry(buffer, entry).toString("utf8");
|
|
635
|
+
if (/<(?:[A-Za-z_][\w.-]*:)?sld\b[^>]*\bshow=["'](?:0|false|off)["']/i.test(xml)) hiddenSlides += 1;
|
|
636
|
+
}
|
|
637
|
+
if (notes.length > 0) addFinding(result, options, {
|
|
638
|
+
id: "pptx-speaker-notes",
|
|
639
|
+
category: "privacy",
|
|
640
|
+
severity: "sensitive",
|
|
641
|
+
title: "PowerPoint speaker notes",
|
|
642
|
+
count: notes.length,
|
|
643
|
+
});
|
|
644
|
+
if (comments.length + commentAuthors.length > 0) addFinding(result, options, {
|
|
645
|
+
id: "pptx-comments",
|
|
646
|
+
category: "privacy",
|
|
647
|
+
severity: "sensitive",
|
|
648
|
+
title: "PowerPoint comments or comment authors",
|
|
649
|
+
count: comments.length + commentAuthors.length,
|
|
650
|
+
});
|
|
651
|
+
if (hiddenSlides > 0) addFinding(result, options, {
|
|
652
|
+
id: "pptx-hidden-slides",
|
|
653
|
+
category: "privacy",
|
|
654
|
+
severity: "review",
|
|
655
|
+
title: "PowerPoint hidden slides",
|
|
656
|
+
count: hiddenSlides,
|
|
657
|
+
});
|
|
658
|
+
addCoverage(result, "pptx-review-data", notes.length + comments.length + commentAuthors.length + hiddenSlides > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
659
|
+
notes.length + comments.length + commentAuthors.length + hiddenSlides > 0 ? "Notes, comments, authors, or hidden slides found." : "No notes, comments, authors, or hidden slides found.");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const customXml = entries.filter((entry) => /^customXml\//i.test(entry.name));
|
|
663
|
+
if (customXml.length > 0) addFinding(result, options, {
|
|
664
|
+
id: "office-custom-xml",
|
|
665
|
+
category: "privacy",
|
|
666
|
+
severity: "review",
|
|
667
|
+
title: "Office custom XML",
|
|
668
|
+
count: customXml.length,
|
|
669
|
+
});
|
|
670
|
+
addCoverage(result, "office-custom-xml", customXml.length > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
671
|
+
customXml.length > 0 ? `${customXml.length} custom XML entry or entries found.` : "No custom XML entries found.");
|
|
672
|
+
|
|
673
|
+
const media = entries.filter((entry) => /^(?:word|ppt)\/media\//i.test(entry.name));
|
|
674
|
+
addCoverage(result, "office-embedded-media-metadata", media.length > 0 ? "UNKNOWN" : "NOT_FOUND_BY_THIS_CHECK",
|
|
675
|
+
media.length > 0 ? `${media.length} embedded media file(s) were not recursively inspected.` : "No embedded media files found.");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function pdfLiteral(value) {
|
|
679
|
+
return bounded(value.replace(/\\([\\()])/g, "$1").replace(/\\[nrtbf]/g, " "));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function scanPdf(buffer, result, options) {
|
|
683
|
+
const text = buffer.toString("latin1");
|
|
684
|
+
let infoCount = 0;
|
|
685
|
+
for (const field of ["Author", "Creator", "Producer", "Title", "Subject", "Keywords", "CreationDate", "ModDate"]) {
|
|
686
|
+
const pattern = new RegExp(`/${field}\\s*\\(([^)]*)\\)`, "g");
|
|
687
|
+
for (const match of text.matchAll(pattern)) {
|
|
688
|
+
if (infoCount >= MAX_MATCHES_PER_CHECK) {
|
|
689
|
+
addCoverage(result, "pdf-document-info", "UNKNOWN", `Raw PDF metadata scan stopped after ${MAX_MATCHES_PER_CHECK} fields.`);
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
infoCount += 1;
|
|
693
|
+
addFinding(result, options, {
|
|
694
|
+
id: "pdf-document-info",
|
|
695
|
+
category: /Author|Creator/i.test(field) ? "privacy" : "provenance",
|
|
696
|
+
severity: /Author/i.test(field) ? "sensitive" : /Creator|Producer/i.test(field) ? "provenance" : "review",
|
|
697
|
+
title: "PDF document information",
|
|
698
|
+
field,
|
|
699
|
+
value: pdfLiteral(match[1]),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
addCoverage(result, "pdf-document-info", infoCount > 0 ? "FOUND" : "UNKNOWN",
|
|
704
|
+
infoCount > 0 ? `${infoCount} raw PDF information field(s) found; compressed, indirect, hexadecimal, and UTF-16 values require ExifTool.` : "Raw-byte scan found no configured PDF information fields; compressed, indirect, hexadecimal, and UTF-16 values remain uninspected.");
|
|
705
|
+
const xmp = /<x:xmpmeta\b[\s\S]*?<\/x:xmpmeta>/i.exec(text);
|
|
706
|
+
if (xmp) addFinding(result, options, {
|
|
707
|
+
id: "pdf-xmp",
|
|
708
|
+
category: "provenance",
|
|
709
|
+
severity: "provenance",
|
|
710
|
+
title: "PDF XMP metadata packet",
|
|
711
|
+
...(options.showValues ? { value: xmp[0] } : {}),
|
|
712
|
+
});
|
|
713
|
+
addCoverage(result, "pdf-xmp", xmp ? "FOUND" : "UNKNOWN",
|
|
714
|
+
xmp ? "Uncompressed XMP packet found." : "No uncompressed XMP packet found; compressed XMP remains uninspected.");
|
|
715
|
+
const carrier = /(?:c2pa|content credentials|jumbf)/i.test(text);
|
|
716
|
+
recordC2paCarrier(result, options, carrier, "PDF byte markers", "UNKNOWN");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function pngChunks(buffer) {
|
|
720
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
721
|
+
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(signature)) throw new Error("PNG signature is invalid");
|
|
722
|
+
const chunks = [];
|
|
723
|
+
let cursor = 8;
|
|
724
|
+
while (cursor + 12 <= buffer.length) {
|
|
725
|
+
if (chunks.length >= MAX_CONTAINER_SEGMENTS) throw new Error(`PNG contains more than ${MAX_CONTAINER_SEGMENTS} chunks`);
|
|
726
|
+
const length = buffer.readUInt32BE(cursor);
|
|
727
|
+
const type = buffer.subarray(cursor + 4, cursor + 8).toString("ascii");
|
|
728
|
+
const end = cursor + 12 + length;
|
|
729
|
+
if (end > buffer.length) throw new Error("PNG chunk is outside the file");
|
|
730
|
+
const data = buffer.subarray(cursor + 8, cursor + 8 + length);
|
|
731
|
+
const suppliedCrc = buffer.readUInt32BE(cursor + 8 + length);
|
|
732
|
+
const actualCrc = crc32Parts([buffer.subarray(cursor + 4, cursor + 8), data]);
|
|
733
|
+
if (suppliedCrc !== actualCrc) throw new Error(`PNG ${bounded(type)} chunk CRC mismatch`);
|
|
734
|
+
chunks.push({ type, data });
|
|
735
|
+
cursor = end;
|
|
736
|
+
if (type === "IEND") {
|
|
737
|
+
if (length !== 0) throw new Error("PNG IEND chunk must be empty");
|
|
738
|
+
if (cursor !== buffer.length) throw new Error("PNG contains data after IEND");
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (chunks.length === 0 || chunks[0].type !== "IHDR" || chunks[0].data.length !== 13) {
|
|
743
|
+
throw new Error("PNG must begin with one 13-byte IHDR chunk");
|
|
744
|
+
}
|
|
745
|
+
if (chunks.filter((chunk) => chunk.type === "IHDR").length !== 1) throw new Error("PNG must contain exactly one IHDR chunk");
|
|
746
|
+
if (chunks.at(-1)?.type !== "IEND") throw new Error("PNG IEND chunk is missing");
|
|
747
|
+
return chunks;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function parsePngText(chunk, remainingExpandedBytes) {
|
|
751
|
+
const separator = chunk.data.indexOf(0);
|
|
752
|
+
if (separator < 0) return null;
|
|
753
|
+
const key = chunk.data.subarray(0, separator).toString("latin1");
|
|
754
|
+
if (chunk.type === "tEXt") {
|
|
755
|
+
const payload = chunk.data.subarray(separator + 1);
|
|
756
|
+
if (payload.length > remainingExpandedBytes) throw new Error("PNG expanded text metadata limit exceeded");
|
|
757
|
+
return { key, value: payload.toString("latin1"), expandedBytes: payload.length };
|
|
758
|
+
}
|
|
759
|
+
if (chunk.type === "zTXt") {
|
|
760
|
+
if (chunk.data[separator + 1] !== 0) return null;
|
|
761
|
+
const payload = inflateSync(chunk.data.subarray(separator + 2), { maxOutputLength: remainingExpandedBytes });
|
|
762
|
+
return { key, value: payload.toString("latin1"), expandedBytes: payload.length };
|
|
763
|
+
}
|
|
764
|
+
if (chunk.type === "iTXt") {
|
|
765
|
+
let cursor = separator + 1;
|
|
766
|
+
const compressed = chunk.data[cursor] === 1;
|
|
767
|
+
cursor += 2;
|
|
768
|
+
for (let field = 0; field < 2; field += 1) {
|
|
769
|
+
const end = chunk.data.indexOf(0, cursor);
|
|
770
|
+
if (end < 0) return null;
|
|
771
|
+
cursor = end + 1;
|
|
772
|
+
}
|
|
773
|
+
const payload = chunk.data.subarray(cursor);
|
|
774
|
+
const expanded = compressed ? inflateSync(payload, { maxOutputLength: remainingExpandedBytes }) : payload;
|
|
775
|
+
if (expanded.length > remainingExpandedBytes) throw new Error("PNG expanded text metadata limit exceeded");
|
|
776
|
+
return { key, value: expanded.toString("utf8"), expandedBytes: expanded.length };
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function classifyMetadataKey(key) {
|
|
782
|
+
if (/gps|serial|owner|email|history|ancestor|hostcomputer/i.test(key)) return ["privacy", "sensitive"];
|
|
783
|
+
if (/author|artist|creator|company|manager|comment|description|documentname/i.test(key)) return ["privacy", "review"];
|
|
784
|
+
if (/c2pa|credential|jumbf/i.test(key)) return ["provenance", "provenance"];
|
|
785
|
+
return ["provenance", "info"];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function scanPng(buffer, result, options) {
|
|
789
|
+
let chunks;
|
|
790
|
+
try {
|
|
791
|
+
chunks = pngChunks(buffer);
|
|
792
|
+
} catch (error) {
|
|
793
|
+
addCoverage(result, "png-structure", "ERROR", error.message);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
addCoverage(result, "png-structure", "NOT_FOUND_BY_THIS_CHECK", `Parsed ${chunks.length} PNG chunks.`);
|
|
797
|
+
let metadataCount = 0;
|
|
798
|
+
let expandedTextBytes = 0;
|
|
799
|
+
for (const chunk of chunks.filter((item) => ["tEXt", "zTXt", "iTXt"].includes(item.type))) {
|
|
800
|
+
if (metadataCount >= MAX_MATCHES_PER_CHECK) {
|
|
801
|
+
addCoverage(result, "png-text-metadata-limit", "UNKNOWN", `PNG text scan stopped after ${MAX_MATCHES_PER_CHECK} chunks.`);
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
let item;
|
|
805
|
+
try {
|
|
806
|
+
item = parsePngText(chunk, MAX_PNG_EXPANDED_TEXT_BYTES - expandedTextBytes);
|
|
807
|
+
} catch {
|
|
808
|
+
addCoverage(result, "png-text-metadata", "ERROR", `Could not safely decode ${chunk.type} metadata within the aggregate expansion limit.`);
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
if (!item) continue;
|
|
812
|
+
expandedTextBytes += item.expandedBytes;
|
|
813
|
+
metadataCount += 1;
|
|
814
|
+
const [category, severity] = classifyMetadataKey(item.key);
|
|
815
|
+
addFinding(result, options, {
|
|
816
|
+
id: "png-text-metadata",
|
|
817
|
+
category,
|
|
818
|
+
severity,
|
|
819
|
+
title: "PNG text metadata",
|
|
820
|
+
field: item.key,
|
|
821
|
+
sourceControlledField: true,
|
|
822
|
+
value: item.value,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const exif = chunks.filter((item) => item.type === "eXIf").length;
|
|
826
|
+
if (exif > 0) addFinding(result, options, {
|
|
827
|
+
id: "png-exif-container",
|
|
828
|
+
category: "privacy",
|
|
829
|
+
severity: "review",
|
|
830
|
+
title: "PNG EXIF metadata container",
|
|
831
|
+
count: exif,
|
|
832
|
+
});
|
|
833
|
+
addCoverage(result, "png-text-and-exif-metadata", metadataCount + exif > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
834
|
+
metadataCount + exif > 0 ? `${metadataCount} text field(s) and ${exif} EXIF container(s) found.` : "No PNG text or EXIF metadata found.");
|
|
835
|
+
const carrier = chunks.some((item) => item.type === "caBX")
|
|
836
|
+
|| chunks.some((item) => ["iTXt", "tEXt", "zTXt"].includes(item.type) && /(?:c2pa|content credentials|jumbf)/i.test(item.data.toString("latin1")));
|
|
837
|
+
recordC2paCarrier(result, options, carrier, "PNG caBX or text markers");
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function jpegSegments(buffer) {
|
|
841
|
+
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) throw new Error("JPEG signature is invalid");
|
|
842
|
+
const segments = [];
|
|
843
|
+
let cursor = 2;
|
|
844
|
+
while (cursor + 4 <= buffer.length) {
|
|
845
|
+
if (segments.length >= MAX_CONTAINER_SEGMENTS) throw new Error(`JPEG contains more than ${MAX_CONTAINER_SEGMENTS} metadata segments`);
|
|
846
|
+
while (buffer[cursor] === 0xff) cursor += 1;
|
|
847
|
+
const marker = buffer[cursor];
|
|
848
|
+
cursor += 1;
|
|
849
|
+
if (marker === 0xd9 || marker === 0xda) break;
|
|
850
|
+
if (marker >= 0xd0 && marker <= 0xd7) continue;
|
|
851
|
+
if (cursor + 2 > buffer.length) throw new Error("JPEG segment length is missing");
|
|
852
|
+
const length = buffer.readUInt16BE(cursor);
|
|
853
|
+
if (length < 2 || cursor + length > buffer.length) throw new Error("JPEG segment is outside the file");
|
|
854
|
+
segments.push({ marker, data: buffer.subarray(cursor + 2, cursor + length) });
|
|
855
|
+
cursor += length;
|
|
856
|
+
}
|
|
857
|
+
return segments;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function scanJpeg(buffer, result, options) {
|
|
861
|
+
let segments;
|
|
862
|
+
try {
|
|
863
|
+
segments = jpegSegments(buffer);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
addCoverage(result, "jpeg-structure", "ERROR", error.message);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
addCoverage(result, "jpeg-structure", "NOT_FOUND_BY_THIS_CHECK", `Parsed ${segments.length} JPEG metadata segments.`);
|
|
869
|
+
const exif = segments.filter((item) => item.marker === 0xe1 && item.data.subarray(0, 6).toString("ascii") === "Exif\0\0");
|
|
870
|
+
const xmp = segments.filter((item) => item.marker === 0xe1 && /(?:xmpmeta|ns\.adobe\.com\/xap)/i.test(item.data.toString("latin1")));
|
|
871
|
+
const iptc = segments.filter((item) => item.marker === 0xed);
|
|
872
|
+
const comments = segments.filter((item) => item.marker === 0xfe);
|
|
873
|
+
for (const [id, title, count, severity] of [
|
|
874
|
+
["jpeg-exif", "JPEG EXIF metadata container", exif.length, "review"],
|
|
875
|
+
["jpeg-xmp", "JPEG XMP metadata packet", xmp.length, "provenance"],
|
|
876
|
+
["jpeg-iptc", "JPEG IPTC metadata container", iptc.length, "review"],
|
|
877
|
+
["jpeg-comment", "JPEG comment", comments.length, "review"],
|
|
878
|
+
]) {
|
|
879
|
+
if (count === 0) continue;
|
|
880
|
+
addFinding(result, options, {
|
|
881
|
+
id,
|
|
882
|
+
category: severity === "provenance" ? "provenance" : "privacy",
|
|
883
|
+
severity,
|
|
884
|
+
title,
|
|
885
|
+
count,
|
|
886
|
+
...(id === "jpeg-comment" && options.showValues ? { value: comments.slice(0, 3).map((item) => item.data.toString("latin1")).join(" | ") } : {}),
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
const total = exif.length + xmp.length + iptc.length + comments.length;
|
|
890
|
+
addCoverage(result, "jpeg-metadata-containers", total > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
891
|
+
total > 0 ? `${total} metadata container(s) or comment(s) found.` : "No EXIF, XMP, IPTC, or comment segments found.");
|
|
892
|
+
const carrier = segments.some((item) => item.marker === 0xeb && /(?:jumb|c2pa|content credentials)/i.test(item.data.toString("latin1")))
|
|
893
|
+
|| xmp.some((item) => /(?:c2pa|content credentials|jumbf)/i.test(item.data.toString("latin1")));
|
|
894
|
+
recordC2paCarrier(result, options, carrier, "JPEG APP11 or XMP markers");
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function recordC2paCarrier(result, options, found, source, absentStatus = "NOT_FOUND_BY_THIS_CHECK") {
|
|
898
|
+
if (found) addFinding(result, options, {
|
|
899
|
+
id: "c2pa-carrier-hint",
|
|
900
|
+
category: "provenance",
|
|
901
|
+
severity: "provenance",
|
|
902
|
+
title: "Possible C2PA or Content Credentials carrier",
|
|
903
|
+
detail: `${source} indicate provenance data. Presence is not a privacy failure and is not validation.`,
|
|
904
|
+
});
|
|
905
|
+
addCoverage(result, "c2pa-carrier-presence", found ? "FOUND" : absentStatus,
|
|
906
|
+
found ? `Possible carrier found through ${source}.` : absentStatus === "UNKNOWN" ? `No carrier found through partial ${source}; other representations remain uninspected.` : `No carrier found through ${source}.`);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
let exifToolAvailability;
|
|
910
|
+
function externalCommand(name) {
|
|
911
|
+
const override = name === "exiftool" ? process.env.AGORA_EXIFTOOL : process.env.AGORA_C2PATOOL;
|
|
912
|
+
return override || name;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function toolAvailable(name) {
|
|
916
|
+
if (name === "exiftool" && exifToolAvailability !== undefined) return exifToolAvailability;
|
|
917
|
+
const command = externalCommand(name);
|
|
918
|
+
const result = spawnSync(command, [name === "exiftool" ? "-ver" : "--version"], {
|
|
919
|
+
encoding: "utf8",
|
|
920
|
+
windowsHide: true,
|
|
921
|
+
timeout: 5000,
|
|
922
|
+
maxBuffer: 1024 * 1024,
|
|
923
|
+
});
|
|
924
|
+
const available = !result.error && result.status === 0;
|
|
925
|
+
if (name === "exiftool") exifToolAvailability = available;
|
|
926
|
+
return available;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function scanExifTool(path, result, options) {
|
|
930
|
+
if (!toolAvailable("exiftool")) {
|
|
931
|
+
addCoverage(result, "exiftool-metadata", "UNKNOWN", "ExifTool is not installed; built-in format checks still ran.");
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const run = spawnSync(externalCommand("exiftool"), ["-j", "-G1", "-n", path], {
|
|
935
|
+
encoding: "utf8",
|
|
936
|
+
windowsHide: true,
|
|
937
|
+
timeout: 30000,
|
|
938
|
+
maxBuffer: MAX_EXTERNAL_OUTPUT,
|
|
939
|
+
});
|
|
940
|
+
if (run.error || run.status !== 0) {
|
|
941
|
+
addCoverage(result, "exiftool-metadata", "ERROR", "ExifTool could not inspect the file.");
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
let records;
|
|
945
|
+
try {
|
|
946
|
+
records = JSON.parse(run.stdout);
|
|
947
|
+
} catch {
|
|
948
|
+
addCoverage(result, "exiftool-metadata", "ERROR", "ExifTool returned invalid JSON.");
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const record = Array.isArray(records) ? records[0] ?? {} : {};
|
|
952
|
+
let found = 0;
|
|
953
|
+
for (const [field, value] of Object.entries(record)) {
|
|
954
|
+
if (/^(?:SourceFile|File:|System:)/i.test(field)) continue;
|
|
955
|
+
if (!/(?:gps|serial|owner|artist|author|creator|company|manager|email|comment|description|history|ancestor|hostcomputer|software|producer|creatortool|c2pa|credential|jumbf)/i.test(field)) continue;
|
|
956
|
+
const [category, severity] = classifyMetadataKey(field);
|
|
957
|
+
found += 1;
|
|
958
|
+
addFinding(result, options, {
|
|
959
|
+
id: "exiftool-field",
|
|
960
|
+
category,
|
|
961
|
+
severity,
|
|
962
|
+
title: "ExifTool metadata field",
|
|
963
|
+
field,
|
|
964
|
+
sourceControlledField: true,
|
|
965
|
+
value: typeof value === "object" ? JSON.stringify(value) : value,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
addCoverage(result, "exiftool-metadata", found > 0 ? "FOUND" : "NOT_FOUND_BY_THIS_CHECK",
|
|
969
|
+
found > 0 ? `${found} configured metadata field(s) found by ExifTool.` : "ExifTool found no configured privacy or provenance fields.");
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export function parseC2paInfo(output, status) {
|
|
973
|
+
const text = bounded(output, 2000);
|
|
974
|
+
if (status !== 0) return { status: "ERROR", detail: "c2patool could not complete local verification." };
|
|
975
|
+
if (/no (?:claim|manifest)|manifest.*not found|claim.*not found/i.test(text)) {
|
|
976
|
+
return { status: "NOT_FOUND_BY_THIS_CHECK", detail: "c2patool reported no manifest." };
|
|
977
|
+
}
|
|
978
|
+
if (/(?:validation|signature).*(?:error|failed|invalid)|(?:error|failed|invalid).*(?:validation|signature)/i.test(text)) {
|
|
979
|
+
return { status: "FOUND", detail: "c2patool reported a manifest with validation problems.", invalid: true };
|
|
980
|
+
}
|
|
981
|
+
if (/(?:mismatch|malformed|untrusted|not[_ -]?trusted|expired|revoked|unsupported|failure|claimSignature\.[A-Za-z]*mismatch)/i.test(text)) {
|
|
982
|
+
return { status: "FOUND", detail: "c2patool reported a manifest with validation problems.", invalid: true };
|
|
983
|
+
}
|
|
984
|
+
if (/\b(?:claim|claimSignature|assertion|ingredient|manifest|hardBinding|signingCredential|timeStamp|hashedURI|algorithm|general)(?:\.[A-Za-z][\w-]*)*\.(?:missing|multiple|undeclared|inaccessible|notRedacted|redacted|invalid|mismatch|malformed|untrusted|expired|revoked|unsupported|failure|error)\b/i.test(text)) {
|
|
985
|
+
return { status: "FOUND", detail: "c2patool reported a manifest with validation problems.", invalid: true };
|
|
986
|
+
}
|
|
987
|
+
if (/(?:c2pa|manifest|claim|signature|validation)/i.test(text)) {
|
|
988
|
+
return { status: "FOUND", detail: "c2patool reported local C2PA manifest information.", invalid: false };
|
|
989
|
+
}
|
|
990
|
+
return { status: "UNKNOWN", detail: "c2patool returned no recognizable manifest or validation result." };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function verifyC2pa(path, result, options) {
|
|
994
|
+
if (!options.verifyC2pa) {
|
|
995
|
+
addCoverage(result, "c2pa-verification", "UNKNOWN", "Verification not requested. Use --verify-c2pa for local c2patool inspection.");
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
if (!toolAvailable("c2patool")) {
|
|
999
|
+
addCoverage(result, "c2pa-verification", "UNKNOWN", "c2patool is not installed.");
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const temporary = await mkdtemp(join(tmpdir(), "agora-c2pa-settings-"));
|
|
1003
|
+
const settings = join(temporary, "settings.json");
|
|
1004
|
+
try {
|
|
1005
|
+
await writeFile(settings, JSON.stringify({ verify: { remote_manifest_fetch: false } }), { encoding: "utf8", flag: "wx" });
|
|
1006
|
+
const run = spawnSync(externalCommand("c2patool"), [path, "--info", "--settings", settings], {
|
|
1007
|
+
encoding: "utf8",
|
|
1008
|
+
windowsHide: true,
|
|
1009
|
+
timeout: 30000,
|
|
1010
|
+
maxBuffer: MAX_EXTERNAL_OUTPUT,
|
|
1011
|
+
});
|
|
1012
|
+
if (run.error) {
|
|
1013
|
+
addCoverage(result, "c2pa-verification", "ERROR", "c2patool could not start.");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const parsed = parseC2paInfo(`${run.stdout}\n${run.stderr}`, run.status);
|
|
1017
|
+
if (parsed.status === "FOUND") addFinding(result, options, {
|
|
1018
|
+
id: parsed.invalid ? "c2pa-validation-problem" : "c2pa-verified-manifest",
|
|
1019
|
+
category: "provenance",
|
|
1020
|
+
severity: "provenance",
|
|
1021
|
+
title: parsed.invalid ? "C2PA validation problem" : "C2PA manifest information",
|
|
1022
|
+
detail: parsed.detail,
|
|
1023
|
+
});
|
|
1024
|
+
addCoverage(result, "c2pa-verification", parsed.status, parsed.detail);
|
|
1025
|
+
} finally {
|
|
1026
|
+
await rm(temporary, { recursive: true, force: true });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function readOpenFile(handle, size) {
|
|
1031
|
+
const buffer = Buffer.alloc(size);
|
|
1032
|
+
let offset = 0;
|
|
1033
|
+
while (offset < size) {
|
|
1034
|
+
const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
|
|
1035
|
+
if (bytesRead === 0) break;
|
|
1036
|
+
offset += bytesRead;
|
|
1037
|
+
}
|
|
1038
|
+
if (offset !== size) throw new Error("File size changed while it was being read");
|
|
1039
|
+
return buffer;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function sameFileIdentity(left, right) {
|
|
1043
|
+
if (Number.isFinite(left.dev) && Number.isFinite(left.ino)
|
|
1044
|
+
&& Number.isFinite(right.dev) && Number.isFinite(right.ino)
|
|
1045
|
+
&& (left.dev !== 0 || left.ino !== 0 || right.dev !== 0 || right.ino !== 0)) {
|
|
1046
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
1047
|
+
}
|
|
1048
|
+
return left.mode === right.mode
|
|
1049
|
+
&& left.birthtimeMs === right.birthtimeMs
|
|
1050
|
+
&& left.size === right.size
|
|
1051
|
+
&& left.mtimeMs === right.mtimeMs;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function scanFile(path, options) {
|
|
1055
|
+
const result = createResult(path, options);
|
|
1056
|
+
let handle;
|
|
1057
|
+
try {
|
|
1058
|
+
const preOpenStats = await lstat(path);
|
|
1059
|
+
if (preOpenStats.isSymbolicLink()) throw new Error("Symbolic links are not inspected");
|
|
1060
|
+
if (!preOpenStats.isFile()) throw new Error("Input is not a regular file");
|
|
1061
|
+
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
1062
|
+
const stats = await handle.stat();
|
|
1063
|
+
if (!stats.isFile()) throw new Error("Input is not a regular file");
|
|
1064
|
+
if (!sameFileIdentity(preOpenStats, stats)) throw new Error("Input identity changed while it was being opened");
|
|
1065
|
+
result.sizeBytes = stats.size;
|
|
1066
|
+
if (stats.size > MAX_FILE_BYTES) {
|
|
1067
|
+
addCoverage(result, "file-size", "UNKNOWN", `File exceeds the ${MAX_FILE_BYTES} byte audit limit.`);
|
|
1068
|
+
finalizeStatus(result);
|
|
1069
|
+
return result;
|
|
1070
|
+
}
|
|
1071
|
+
const buffer = await readOpenFile(handle, stats.size);
|
|
1072
|
+
result.sha256Before = sha256(buffer);
|
|
1073
|
+
const extension = result.extension;
|
|
1074
|
+
if (TEXT_EXTENSIONS.has(extension)) {
|
|
1075
|
+
let text;
|
|
1076
|
+
try {
|
|
1077
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
1078
|
+
} catch {
|
|
1079
|
+
addCoverage(result, "utf8-text-decoding", "ERROR", "Text file is not valid UTF-8.");
|
|
1080
|
+
}
|
|
1081
|
+
if (text !== undefined) {
|
|
1082
|
+
addCoverage(result, "utf8-text-decoding", "NOT_FOUND_BY_THIS_CHECK", "Decoded as UTF-8.");
|
|
1083
|
+
scanUnicode(text, result, options);
|
|
1084
|
+
if ([".html", ".htm", ".svg"].includes(extension)) scanMarkup(text, extension, result, options);
|
|
1085
|
+
}
|
|
1086
|
+
} else if (OFFICE_EXTENSIONS.has(extension)) scanOffice(buffer, extension, result, options);
|
|
1087
|
+
else if (extension === ".pdf") scanPdf(buffer, result, options);
|
|
1088
|
+
else if (extension === ".png") scanPng(buffer, result, options);
|
|
1089
|
+
else if ([".jpg", ".jpeg"].includes(extension)) scanJpeg(buffer, result, options);
|
|
1090
|
+
else addCoverage(result, "format-support", "UNKNOWN", `Unsupported extension: ${extension || "none"}`);
|
|
1091
|
+
|
|
1092
|
+
if (MEDIA_EXTENSIONS.has(extension)) {
|
|
1093
|
+
const temporary = await mkdtemp(join(tmpdir(), "agora-publication-media-"));
|
|
1094
|
+
const copy = join(temporary, `artifact${extension}`);
|
|
1095
|
+
try {
|
|
1096
|
+
await writeFile(copy, buffer, { flag: "wx" });
|
|
1097
|
+
scanExifTool(copy, result, options);
|
|
1098
|
+
await verifyC2pa(copy, result, options);
|
|
1099
|
+
} finally {
|
|
1100
|
+
await rm(temporary, { recursive: true, force: true });
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
const afterStats = await handle.stat();
|
|
1104
|
+
const finalPathStats = await lstat(path);
|
|
1105
|
+
const after = afterStats.size <= MAX_FILE_BYTES ? await readOpenFile(handle, afterStats.size) : null;
|
|
1106
|
+
result.sha256After = after ? sha256(after) : null;
|
|
1107
|
+
result.sourceUnchanged = after !== null
|
|
1108
|
+
&& stats.size === afterStats.size
|
|
1109
|
+
&& stats.mtimeMs === afterStats.mtimeMs
|
|
1110
|
+
&& !finalPathStats.isSymbolicLink()
|
|
1111
|
+
&& sameFileIdentity(stats, finalPathStats)
|
|
1112
|
+
&& result.sha256Before === result.sha256After;
|
|
1113
|
+
addCoverage(result, "source-integrity", result.sourceUnchanged ? "NOT_FOUND_BY_THIS_CHECK" : "ERROR",
|
|
1114
|
+
result.sourceUnchanged ? "Source bytes are unchanged after inspection." : "Source bytes changed during inspection.");
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
addCoverage(result, "file-inspection", "ERROR", `Inspection failed: ${error.code || error.message}`);
|
|
1117
|
+
} finally {
|
|
1118
|
+
await handle?.close().catch(() => {});
|
|
1119
|
+
}
|
|
1120
|
+
finalizeStatus(result);
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
export async function auditPaths(inputPaths, suppliedOptions = {}) {
|
|
1125
|
+
const options = {
|
|
1126
|
+
json: false,
|
|
1127
|
+
showValues: false,
|
|
1128
|
+
includePaths: false,
|
|
1129
|
+
verifyC2pa: false,
|
|
1130
|
+
output: null,
|
|
1131
|
+
...suppliedOptions,
|
|
1132
|
+
};
|
|
1133
|
+
const collected = await collectFiles(inputPaths, options);
|
|
1134
|
+
const files = [];
|
|
1135
|
+
for (const path of collected.files) files.push(await scanFile(path, options));
|
|
1136
|
+
const statusCounts = Object.fromEntries([...ALLOWED_STATUSES].map((status) => [status, files.filter((file) => file.status === status).length]));
|
|
1137
|
+
return {
|
|
1138
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1139
|
+
scannerVersion: SCANNER_VERSION,
|
|
1140
|
+
generatedAtUtc: new Date().toISOString(),
|
|
1141
|
+
guarantees: {
|
|
1142
|
+
sourceMutation: "Source files are read only and compared by SHA-256 before and after inspection.",
|
|
1143
|
+
network: options.verifyC2pa
|
|
1144
|
+
? "c2patool receives settings that disable remote-manifest fetching. Other local tool behavior is outside Agora's control."
|
|
1145
|
+
: "No network-capable verifier is invoked by default.",
|
|
1146
|
+
interpretation: "Findings are signals, not proof of authorship, safety, cleanliness, or complete provenance.",
|
|
1147
|
+
modelLevelTextWatermark: "Not inspected. Detection requires the matching provider verifier or detector configuration, normalization rules, and enough eligible text; Unicode and metadata checks cannot answer it.",
|
|
1148
|
+
},
|
|
1149
|
+
options: {
|
|
1150
|
+
showValues: options.showValues,
|
|
1151
|
+
includePaths: options.includePaths,
|
|
1152
|
+
verifyC2pa: options.verifyC2pa,
|
|
1153
|
+
},
|
|
1154
|
+
summary: {
|
|
1155
|
+
fileCount: files.length,
|
|
1156
|
+
statusCounts,
|
|
1157
|
+
inputErrorCount: collected.inputErrors.length,
|
|
1158
|
+
skippedSymlinkCount: collected.skippedSymlinks.length,
|
|
1159
|
+
},
|
|
1160
|
+
inputErrors: collected.inputErrors,
|
|
1161
|
+
skippedSymlinks: collected.skippedSymlinks,
|
|
1162
|
+
files,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function humanReport(report) {
|
|
1167
|
+
const lines = [
|
|
1168
|
+
"Agora publication privacy and provenance audit",
|
|
1169
|
+
`Files: ${report.summary.fileCount}`,
|
|
1170
|
+
"Source mutation: none requested; byte hashes checked after inspection.",
|
|
1171
|
+
"Interpretation: findings are signals, not proof that content is AI-generated, human-written, clean, or safe.",
|
|
1172
|
+
"Model-level text watermark: not inspected; matching provider verifier or detector configuration required.",
|
|
1173
|
+
"",
|
|
1174
|
+
];
|
|
1175
|
+
for (const file of report.files) {
|
|
1176
|
+
lines.push(`${file.status} ${file.path}`);
|
|
1177
|
+
lines.push(` SHA-256: ${file.sha256Before ?? "unavailable"}`);
|
|
1178
|
+
for (const finding of file.findings) {
|
|
1179
|
+
const field = finding.field ? ` [${finding.field}]` : "";
|
|
1180
|
+
const count = finding.count ? ` x${finding.count}` : "";
|
|
1181
|
+
lines.push(` - ${finding.severity}: ${finding.title}${field}${count}`);
|
|
1182
|
+
if (finding.detail) lines.push(` ${finding.detail}`);
|
|
1183
|
+
if (finding.value) lines.push(` value: ${finding.value}`);
|
|
1184
|
+
}
|
|
1185
|
+
for (const coverage of file.coverage.filter((entry) => ["UNKNOWN", "ERROR"].includes(entry.status))) {
|
|
1186
|
+
lines.push(` - ${coverage.status}: ${coverage.check}: ${coverage.detail}`);
|
|
1187
|
+
}
|
|
1188
|
+
lines.push("");
|
|
1189
|
+
}
|
|
1190
|
+
for (const error of report.inputErrors) lines.push(`${error.status} ${error.path}: ${error.detail}`);
|
|
1191
|
+
for (const path of report.skippedSymlinks) lines.push(`UNKNOWN ${path}: symbolic link skipped`);
|
|
1192
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async function main() {
|
|
1196
|
+
let options;
|
|
1197
|
+
try {
|
|
1198
|
+
options = parseArgs(process.argv.slice(2));
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
process.stderr.write(`${error.message}\n\n${HELP}`);
|
|
1201
|
+
process.exitCode = 2;
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (options.help) {
|
|
1205
|
+
process.stdout.write(HELP);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
try {
|
|
1209
|
+
if (options.output && options.paths.some((path) => resolve(path) === options.output)) {
|
|
1210
|
+
throw new Error("Report output path cannot also be an input file");
|
|
1211
|
+
}
|
|
1212
|
+
const report = await auditPaths(options.paths, options);
|
|
1213
|
+
const output = options.json ? `${JSON.stringify(report, null, 2)}\n` : humanReport(report);
|
|
1214
|
+
if (options.output) await writeFile(options.output, output, { encoding: "utf8", flag: "wx" });
|
|
1215
|
+
else process.stdout.write(output);
|
|
1216
|
+
if (report.inputErrors.length > 0 || report.files.some((file) => file.status === "ERROR")) process.exitCode = 1;
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
process.stderr.write(`Audit failed: ${error.message}\n`);
|
|
1219
|
+
process.exitCode = 1;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
1224
|
+
await main();
|
|
1225
|
+
}
|