Release Privacy Tools 0.1.0

This commit is contained in:
2026-09-01 02:39:44 +02:00
commit bfd6422149
79 changed files with 13485 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
import {
encodeText,
sanitizeDownloadFilename,
stableStringify,
} from "@add-ideas/toolbox-helpers";
import { zipSync } from "fflate";
import { APP_VERSION } from "../version";
import {
assertLimit,
DEFAULT_PRIVACY_LIMITS,
PrivacyLimitError,
} from "./limits";
import type {
BatchReport,
ImageScanResult,
SanitizedAsset,
SanitizationReport,
} from "./model";
export function createBatchReport(
files: readonly ImageScanResult[],
assets: readonly SanitizedAsset[],
generatedAt = new Date().toISOString(),
): BatchReport {
return {
schemaVersion: 1,
generatedAt,
application: { name: "Privacy Tools", version: APP_VERSION },
files: [...files],
sanitizations: assets.map((asset) => asset.report),
warnings: [
"This report may itself contain sensitive source metadata and filenames; store and share it deliberately.",
"A successful re-scan is not an anonymity guarantee; see each sanitization disclaimer.",
],
};
}
export function serializeReport(
report: BatchReport | SanitizationReport,
): string {
return stableStringify(report, 2, {
maxDepth: 64,
maxNodes: 500_000,
maxTextChars: 2_000_000,
});
}
export async function createBatchArchive(
files: readonly ImageScanResult[],
assets: readonly SanitizedAsset[],
generatedAt = new Date().toISOString(),
): Promise<Blob> {
assertLimit(
files.length,
DEFAULT_PRIVACY_LIMITS.maxFiles,
"Report file count",
);
assertLimit(
assets.length,
DEFAULT_PRIVACY_LIMITS.maxFiles,
"Archive image count",
);
const entries: Record<string, Uint8Array> = Object.create(null) as Record<
string,
Uint8Array
>;
const names = new Set<string>();
let total = 0;
for (const asset of assets) {
const name = uniqueName(asset.report.outputName, names);
const bytes = new Uint8Array(await asset.blob.arrayBuffer());
total += bytes.byteLength;
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
throw new PrivacyLimitError(
"Batch archive input size",
total,
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
);
entries[`images/${name}`] = bytes;
}
const report = encodeText(
serializeReport(createBatchReport(files, assets, generatedAt)),
);
total += report.byteLength;
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
throw new PrivacyLimitError(
"Batch archive input size",
total,
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
);
entries["privacy-tools-report.json"] = report;
const archive = zipSync(entries, {
level: 0,
mtime: new Date("1980-01-01T00:00:00.000Z"),
});
if (archive.byteLength > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
throw new PrivacyLimitError(
"Batch archive output size",
archive.byteLength,
DEFAULT_PRIVACY_LIMITS.maxZipBytes,
);
const ownedArchive = archive.slice().buffer as ArrayBuffer;
return new Blob([ownedArchive], { type: "application/zip" });
}
function uniqueName(input: string, used: Set<string>): string {
const safe = sanitizeDownloadFilename(input, "image.clean");
if (!used.has(safe)) {
used.add(safe);
return safe;
}
const dot = safe.lastIndexOf(".");
const stem = dot > 0 ? safe.slice(0, dot) : safe;
const extension = dot > 0 ? safe.slice(dot) : "";
let counter = 2;
while (used.has(`${stem}-${counter}${extension}`)) counter += 1;
const candidate = `${stem}-${counter}${extension}`;
used.add(candidate);
return candidate;
}
+149
View File
@@ -0,0 +1,149 @@
import type { DetectedKind, InventoryIdentity } from "./model";
const MIME_BY_KIND: Readonly<Record<DetectedKind, string>> = Object.freeze({
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
tiff: "image/tiff",
heic: "image/heic",
avif: "image/avif",
jxl: "image/jxl",
pdf: "application/pdf",
zip: "application/zip",
ole: "application/x-ole-storage",
unknown: "application/octet-stream",
});
const EXTENSIONS: Readonly<Record<string, DetectedKind>> = Object.freeze({
jpg: "jpeg",
jpeg: "jpeg",
jpe: "jpeg",
png: "png",
webp: "webp",
gif: "gif",
tif: "tiff",
tiff: "tiff",
heic: "heic",
heif: "heic",
avif: "avif",
jxl: "jxl",
pdf: "pdf",
zip: "zip",
docx: "zip",
xlsx: "zip",
pptx: "zip",
odt: "zip",
ods: "zip",
odp: "zip",
doc: "ole",
xls: "ole",
ppt: "ole",
});
export function fileExtension(name: string): string {
const leaf = name.replace(/\\/gu, "/").split("/").at(-1) ?? "";
const index = leaf.lastIndexOf(".");
return index > 0 && index < leaf.length - 1
? leaf.slice(index + 1).toLowerCase()
: "";
}
export function detectKind(bytes: Uint8Array): DetectedKind {
if (starts(bytes, [0xff, 0xd8, 0xff])) return "jpeg";
if (starts(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
return "png";
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP")
return "webp";
if (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a")
return "gif";
if (
starts(bytes, [0x49, 0x49, 0x2a, 0x00]) ||
starts(bytes, [0x4d, 0x4d, 0x00, 0x2a])
)
return "tiff";
if (starts(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "pdf";
if (
starts(bytes, [0x50, 0x4b, 0x03, 0x04]) ||
starts(bytes, [0x50, 0x4b, 0x05, 0x06]) ||
starts(bytes, [0x50, 0x4b, 0x07, 0x08])
)
return "zip";
if (starts(bytes, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]))
return "ole";
if (starts(bytes, [0xff, 0x0a])) return "jxl";
if (
starts(
bytes,
[0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a],
)
)
return "jxl";
const brand = isoBmffBrand(bytes);
if (["avif", "avis"].includes(brand)) return "avif";
if (["heic", "heix", "hevc", "hevx", "mif1", "msf1"].includes(brand))
return "heic";
return "unknown";
}
export function inventoryIdentity(
name: string,
claimedType: string,
bytes: Uint8Array,
): InventoryIdentity {
const extension = fileExtension(name);
const detectedKind = detectKind(bytes);
const detectedType = MIME_BY_KIND[detectedKind];
const normalizedClaim = claimedType.trim().toLowerCase();
const extensionKind = EXTENSIONS[extension];
const claimedMatches =
!normalizedClaim || normalizedClaim === "application/octet-stream"
? undefined
: normalizeMime(normalizedClaim) === detectedType;
const extensionMatches = !extensionKind || extensionKind === detectedKind;
const typeMatch =
detectedKind === "unknown"
? "unknown"
: claimedMatches === false || !extensionMatches
? "mismatch"
: claimedMatches === undefined && !extensionKind
? "unclaimed"
: "match";
return {
claimedType: normalizedClaim,
extension,
detectedKind,
detectedType,
typeMatch,
};
}
function normalizeMime(value: string): string {
if (value === "image/jpg" || value === "image/pjpeg") return "image/jpeg";
if (value === "image/x-png") return "image/png";
if (value === "image/heif") return "image/heic";
return value;
}
function starts(bytes: Uint8Array, signature: readonly number[]): boolean {
return signature.every((value, index) => bytes[index] === value);
}
function ascii(bytes: Uint8Array, offset: number, length: number): string {
if (offset < 0 || length < 0 || offset + length > bytes.byteLength) return "";
let result = "";
for (let index = 0; index < length; index += 1)
result += String.fromCharCode(bytes[offset + index] ?? 0);
return result;
}
function isoBmffBrand(bytes: Uint8Array): string {
if (bytes.byteLength < 12 || ascii(bytes, 4, 4) !== "ftyp") return "";
const length =
((bytes[0] ?? 0) << 24) |
((bytes[1] ?? 0) << 16) |
((bytes[2] ?? 0) << 8) |
(bytes[3] ?? 0);
if (length < 12 || length > bytes.byteLength) return "";
return ascii(bytes, 8, 4).toLowerCase();
}
+141
View File
@@ -0,0 +1,141 @@
import ExifReader, { type ExpandedTags } from "exifreader";
import { categoryForName, FindingCollector } from "./findings";
import type { PrivacyLimits } from "./model";
import { scanXmp } from "./xmp";
export interface SecondaryScan {
status: "complete" | "partial" | "unsupported" | "failed";
width?: number;
height?: number;
notes: string[];
}
const GROUP_LABELS: Readonly<Record<string, string>> = Object.freeze({
exif: "ExifReader EXIF",
iptc: "ExifReader IPTC",
icc: "ExifReader ICC",
jfif: "ExifReader JFIF",
png: "ExifReader PNG",
pngText: "ExifReader PNG text",
riff: "ExifReader WebP",
gps: "ExifReader GPS",
photoshop: "ExifReader Photoshop",
makerNotes: "ExifReader maker notes",
composite: "ExifReader computed",
});
export function scanWithExifReader(
bytes: ArrayBuffer,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): SecondaryScan {
try {
const tags = ExifReader.load(bytes, {
expanded: true,
async: false,
computed: true,
includeUnknown: false,
excludeTags: { xmp: true, mpf: true },
decompress: { maxDecompressedSize: limits.maxInflatedMetadataBytes },
});
const fileType = tags.file?.FileType?.value;
if (!fileType)
return {
status: "unsupported",
notes: ["ExifReader did not recognize this format."],
};
const width = numericTag(tags.file?.["Image Width"]);
const height = numericTag(tags.file?.["Image Height"]);
let nodes = 0;
for (const [groupName, source] of Object.entries(GROUP_LABELS)) {
const group = tags[groupName as keyof ExpandedTags] as unknown;
if (!group || typeof group !== "object") continue;
for (const [name, tag] of Object.entries(group)) {
nodes += 1;
if (nodes > limits.maxFindings) {
collector.warn(
"ExifReader finding count reached the application limit.",
);
return {
status: "partial",
width,
height,
notes: ["ExifReader output was truncated at the finding limit."],
};
}
if (name.startsWith("_") || name === "base64" || name === "image")
continue;
const value = tagValue(tag);
if (!value) continue;
const classification =
groupName === "gps"
? ({ category: "location", risk: "sensitive" } as const)
: categoryForName(`${groupName} ${name}`);
collector.add({
...classification,
source,
label: name,
value,
});
}
}
if (tags.Thumbnail) {
const image = tags.Thumbnail.image;
const length =
image instanceof ArrayBuffer || image instanceof SharedArrayBuffer
? image.byteLength
: image?.byteLength;
collector.add({
category: "thumbnail",
risk: "sensitive",
source: "ExifReader thumbnail",
label: "Embedded thumbnail",
value: `${length ?? "unknown"} bytes`,
});
}
const rawXmp = tags.xmp?._raw;
if (typeof rawXmp === "string")
scanXmp(rawXmp, "ExifReader XMP", undefined, collector);
if (width === undefined || height === undefined)
return {
status: "partial",
width,
height,
notes: [
"ExifReader recognized the container but could not establish both pixel dimensions.",
],
};
return { status: "complete", width, height, notes: [] };
} catch (error) {
return {
status: "failed",
notes: [
`ExifReader could not complete: ${error instanceof Error ? error.message : "unknown error"}`,
],
};
}
}
function numericTag(tag: unknown): number | undefined {
if (!tag || typeof tag !== "object") return undefined;
const value = (tag as { value?: unknown }).value;
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
function tagValue(tag: unknown): string {
if (tag == null) return "";
if (typeof tag === "string" || typeof tag === "number") return String(tag);
if (Array.isArray(tag)) return tag.slice(0, 128).map(tagValue).join(", ");
if (ArrayBuffer.isView(tag)) return `${tag.byteLength} bytes`;
if (tag instanceof ArrayBuffer || tag instanceof SharedArrayBuffer)
return `${tag.byteLength} bytes`;
if (typeof tag === "object") {
const item = tag as { description?: unknown; value?: unknown };
if (typeof item.description === "string") return item.description;
if (item.value !== undefined) return tagValue(item.value);
}
return "";
}
+159
View File
@@ -0,0 +1,159 @@
import type {
FindingCategory,
FindingRisk,
MetadataFinding,
PrivacyLimits,
} from "./model";
export class FindingCollector {
readonly findings: MetadataFinding[] = [];
readonly warnings: string[] = [];
limited = false;
readonly #keys = new Set<string>();
readonly #limits: Readonly<PrivacyLimits>;
#textChars = 0;
constructor(limits: Readonly<PrivacyLimits>) {
this.#limits = limits;
}
add(
finding: Omit<MetadataFinding, "id" | "value"> & {
id?: string;
value: unknown;
},
): void {
if (this.limited) return;
const source = boundedLabel(
finding.source,
this.#limits.maxFindingLabelChars,
);
const label = boundedLabel(
finding.label,
this.#limits.maxFindingLabelChars,
);
let value = displayValue(finding.value);
if (value.length > this.#limits.maxFindingValueChars) {
value = `${value.slice(0, this.#limits.maxFindingValueChars)}`;
this.warn(
`${source} ${label} was truncated to ${this.#limits.maxFindingValueChars} characters.`,
);
}
const key = `${source}\u0000${label}\u0000${value}\u0000${finding.offset ?? ""}`;
if (this.#keys.has(key)) return;
const addedText = source.length + label.length + value.length;
if (
this.findings.length >= this.#limits.maxFindings ||
this.#textChars + addedText > this.#limits.maxFindingTextChars
) {
this.limited = true;
this.warn(
`Metadata findings were truncated at ${this.#limits.maxFindings} entries or ${this.#limits.maxFindingTextChars} text characters.`,
);
return;
}
this.#keys.add(key);
this.#textChars += addedText;
this.findings.push({
...finding,
source,
label,
id:
finding.id ??
`${source.toLowerCase().replace(/[^a-z0-9]+/gu, "-")}-${this.findings.length + 1}`,
value,
});
}
warn(message: string): void {
if (!this.warnings.includes(message)) this.warnings.push(message);
}
}
function boundedLabel(value: string, maximum: number): string {
const sanitized = sanitizeText(value);
return sanitized.length <= maximum
? sanitized
: `${sanitized.slice(0, Math.max(0, maximum - 1))}`;
}
export function categoryForName(name: string): {
category: FindingCategory;
risk: FindingRisk;
} {
const normalized = name.toLowerCase();
if (
/(?:gps|latitude|longitude|location|city|country|province|state|sublocation|altitude)/u.test(
normalized,
)
)
return { category: "location", risk: "sensitive" };
if (
/(?:artist|author|creator|byline|credit|owner|person|copyright|rights|email)/u.test(
normalized,
)
)
return { category: "identity", risk: "sensitive" };
if (/(?:date|time|created|modified|timestamp)/u.test(normalized))
return { category: "timestamp", risk: "sensitive" };
if (
/(?:serial|camera|device|make|model|lens|makernote|ownername)/u.test(
normalized,
)
)
return { category: "device", risk: "sensitive" };
if (
/(?:software|history|creatortool|processing|hostcomputer)/u.test(normalized)
)
return { category: "software", risk: "sensitive" };
if (
/(?:documentid|instanceid|uniqueid|originaldocumentid|assetid)/u.test(
normalized,
)
)
return { category: "document-id", risk: "sensitive" };
if (
/(?:comment|description|caption|headline|keyword|subject|title)/u.test(
normalized,
)
)
return { category: "comment", risk: "context" };
if (/(?:thumbnail|preview)/u.test(normalized))
return { category: "thumbnail", risk: "sensitive" };
if (/(?:icc|profile|colorspace|colourspace)/u.test(normalized))
return { category: "colour-profile", risk: "technical" };
if (/(?:c2pa|jumbf|provenance|manifest|signature)/u.test(normalized))
return { category: "provenance", risk: "context" };
if (
/(?:orientation|resolution|jfif|density|dimensions|pixel)/u.test(normalized)
)
return { category: "technical", risk: "technical" };
return { category: "unknown", risk: "context" };
}
function displayValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return sanitizeText(value);
if (typeof value === "number" || typeof value === "bigint")
return String(value);
if (typeof value === "boolean") return value ? "Yes" : "No";
if (value instanceof Uint8Array) return `${value.byteLength} bytes`;
if (Array.isArray(value)) return value.map(displayValue).join(", ");
try {
return sanitizeText(JSON.stringify(value));
} catch {
return String(value);
}
}
function sanitizeText(value: string): string {
return Array.from(value, (character) => {
const point = character.codePointAt(0) ?? 0;
if (point === 0) return "";
if ((point >= 1 && point <= 8) || point === 11 || point === 12) return " ";
if ((point >= 14 && point <= 31) || point === 127) return " ";
return character;
})
.join("")
.trim();
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./archive";
export * from "./detect";
export * from "./limits";
export * from "./model";
export * from "./sanitize";
export * from "./scan-client";
export * from "./scanner";
+29
View File
@@ -0,0 +1,29 @@
import { Unzlib } from "fflate";
import { PrivacyLimitError } from "./limits";
export function inflateZlibBounded(
input: Uint8Array,
maximumBytes: number,
): Uint8Array {
const chunks: Uint8Array[] = [];
let total = 0;
const inflater = new Unzlib((chunk) => {
total += chunk.byteLength;
if (total > maximumBytes)
throw new PrivacyLimitError(
"Inflated metadata size",
total,
maximumBytes,
);
chunks.push(chunk.slice());
});
inflater.push(input, true);
const output = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
+128
View File
@@ -0,0 +1,128 @@
import { categoryForName, FindingCollector } from "./findings";
const DATASET_NAMES: Readonly<Record<number, string>> = Object.freeze({
5: "Object Name",
25: "Keywords",
55: "Date Created",
60: "Time Created",
80: "By-line",
85: "By-line Title",
90: "City",
92: "Sublocation",
95: "Province/State",
100: "Country Code",
101: "Country",
105: "Headline",
110: "Credit",
115: "Source",
116: "Copyright Notice",
120: "Caption/Abstract",
122: "Writer/Editor",
});
export function scanIptc(
bytes: Uint8Array,
source: string,
baseOffset: number,
collector: FindingCollector,
): void {
let offset = 0;
let datasets = 0;
while (offset + 5 <= bytes.byteLength) {
if (bytes[offset] !== 0x1c) {
offset += 1;
continue;
}
const record = bytes[offset + 1] ?? 0;
const dataset = bytes[offset + 2] ?? 0;
let length = ((bytes[offset + 3] ?? 0) << 8) | (bytes[offset + 4] ?? 0);
let header = 5;
if ((length & 0x8000) !== 0) {
const lengthBytes = length & 0x7fff;
if (
lengthBytes < 1 ||
lengthBytes > 4 ||
offset + 5 + lengthBytes > bytes.byteLength
) {
collector.warn(`${source} contains an invalid extended IPTC length.`);
return;
}
length = 0;
for (let index = 0; index < lengthBytes; index += 1)
length = length * 256 + (bytes[offset + 5 + index] ?? 0);
header += lengthBytes;
}
if (length > 1024 * 1024 || offset + header + length > bytes.byteLength) {
collector.warn(
`${source} contains a truncated or oversized IPTC dataset.`,
);
return;
}
const valueBytes = bytes.subarray(
offset + header,
offset + header + length,
);
const label =
record === 2
? (DATASET_NAMES[dataset] ?? `IPTC 2:${dataset}`)
: `IPTC ${record}:${dataset}`;
const classification = categoryForName(label);
collector.add({
...classification,
source,
label,
value: new TextDecoder("utf-8", { fatal: false }).decode(valueBytes),
offset: baseOffset + offset,
length: header + length,
});
datasets += 1;
if (datasets > 2048) {
collector.warn(`${source} IPTC dataset count exceeded 2048.`);
return;
}
offset += header + length;
}
}
export function findPhotoshopIptc(
bytes: Uint8Array,
): Array<{ bytes: Uint8Array; relativeOffset: number }> {
const result: Array<{ bytes: Uint8Array; relativeOffset: number }> = [];
let offset = bytes.byteLength >= 14 ? 14 : 0;
while (offset + 12 <= bytes.byteLength) {
if (
bytes[offset] !== 0x38 ||
bytes[offset + 1] !== 0x42 ||
bytes[offset + 2] !== 0x49 ||
bytes[offset + 3] !== 0x4d
) {
offset += 1;
continue;
}
const resource = ((bytes[offset + 4] ?? 0) << 8) | (bytes[offset + 5] ?? 0);
const nameLength = bytes[offset + 6] ?? 0;
const paddedName = 1 + nameLength + ((1 + nameLength) % 2);
const sizeOffset = offset + 6 + paddedName;
if (sizeOffset + 4 > bytes.byteLength) break;
const length = readU32be(bytes, sizeOffset);
const dataOffset = sizeOffset + 4;
if (length > 8 * 1024 * 1024 || dataOffset + length > bytes.byteLength)
break;
if (resource === 0x0404)
result.push({
bytes: bytes.subarray(dataOffset, dataOffset + length),
relativeOffset: dataOffset,
});
offset = dataOffset + length + (length % 2);
}
return result;
}
function readU32be(bytes: Uint8Array, offset: number): number {
return (
(bytes[offset] ?? 0) * 0x1000000 +
((bytes[offset + 1] ?? 0) << 16) +
((bytes[offset + 2] ?? 0) << 8) +
(bytes[offset + 3] ?? 0)
);
}
+323
View File
@@ -0,0 +1,323 @@
import { FindingCollector } from "./findings";
import { findPhotoshopIptc, scanIptc } from "./iptc";
import type { MetadataBlock, PrivacyLimits } from "./model";
import { scanTiff } from "./tiff";
import { decodeMetadataText, scanXmp } from "./xmp";
export interface FormatScan {
width?: number;
height?: number;
orientation?: number;
animated: boolean;
multiImage: boolean;
complete: boolean;
blocks: MetadataBlock[];
}
const XMP_HEADER = "http://ns.adobe.com/xap/1.0/\u0000";
const XMP_EXTENDED_HEADER = "http://ns.adobe.com/xmp/extension/\u0000";
export function scanJpeg(
bytes: Uint8Array,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): FormatScan {
const blocks: MetadataBlock[] = [];
if (bytes.byteLength < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
collector.warn("JPEG start-of-image marker is missing.");
return { animated: false, multiImage: false, complete: false, blocks };
}
let offset = 2;
let complete = true;
let width: number | undefined;
let height: number | undefined;
let orientation: number | undefined;
let multiImage = false;
let segments = 0;
let enteredScan = false;
while (offset < bytes.byteLength) {
if (bytes[offset] !== 0xff) {
collector.warn(`JPEG marker sync was lost at byte ${offset}.`);
complete = false;
break;
}
while (offset < bytes.byteLength && bytes[offset] === 0xff) offset += 1;
if (offset >= bytes.byteLength) {
complete = false;
break;
}
const marker = bytes[offset] ?? 0;
offset += 1;
if (marker === 0xd9) break;
if (marker === 0xda) {
enteredScan = true;
break;
}
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
if (offset + 2 > bytes.byteLength) {
collector.warn("JPEG segment length is truncated.");
complete = false;
break;
}
const segmentLength =
((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
if (segmentLength < 2 || offset + segmentLength > bytes.byteLength) {
collector.warn(
`JPEG marker 0x${marker.toString(16)} has an invalid length.`,
);
complete = false;
break;
}
segments += 1;
if (segments > limits.maxMetadataBlocks) {
collector.warn(
`JPEG segment count exceeded ${limits.maxMetadataBlocks}.`,
);
complete = false;
break;
}
const dataOffset = offset + 2;
const dataLength = segmentLength - 2;
const data = bytes.subarray(dataOffset, dataOffset + dataLength);
if (isStartOfFrame(marker) && data.byteLength >= 5) {
height = ((data[1] ?? 0) << 8) | (data[2] ?? 0);
width = ((data[3] ?? 0) << 8) | (data[4] ?? 0);
}
if (marker >= 0xe0 && marker <= 0xef) {
if (dataLength > limits.maxMetadataBlockBytes) {
collector.warn(
`JPEG APP${marker - 0xe0} metadata exceeds the block limit.`,
);
complete = false;
} else {
blocks.push({
kind: `APP${marker - 0xe0}`,
offset: dataOffset,
length: dataLength,
});
const result = scanJpegApplication(
marker,
data,
dataOffset,
collector,
limits,
);
if (result.orientation !== undefined) orientation = result.orientation;
multiImage ||= result.multiImage;
complete &&= result.complete;
}
} else if (marker === 0xfe) {
blocks.push({ kind: "COM", offset: dataOffset, length: dataLength });
collector.add({
category: "comment",
risk: "context",
source: "JPEG COM",
label: "Comment",
value: decodeMetadataText(data),
offset: dataOffset,
length: dataLength,
});
}
offset += segmentLength;
}
const eoi = enteredScan
? findJpegEnd(bytes, offset)
: bytes.lastIndexOf(0xd9);
if (eoi < 1 || bytes[eoi - 1] !== 0xff) {
collector.warn("JPEG end-of-image marker was not found.");
complete = false;
} else if (eoi + 1 < bytes.byteLength) {
const trailing = bytes.byteLength - eoi - 1;
blocks.push({ kind: "trailing-data", offset: eoi + 1, length: trailing });
collector.add({
category: "unknown",
risk: "sensitive",
source: "JPEG",
label: "Trailing data",
value: `${trailing} bytes after end-of-image`,
offset: eoi + 1,
length: trailing,
});
}
return {
width,
height,
orientation,
animated: false,
multiImage,
complete,
blocks,
};
}
function scanJpegApplication(
marker: number,
data: Uint8Array,
offset: number,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): { orientation?: number; multiImage: boolean; complete: boolean } {
if (marker === 0xe0 && ascii(data, 0, 5) === "JFIF\u0000") {
const thumbnailWidth = data[12] ?? 0;
const thumbnailHeight = data[13] ?? 0;
collector.add({
category: thumbnailWidth && thumbnailHeight ? "thumbnail" : "technical",
risk: thumbnailWidth && thumbnailHeight ? "sensitive" : "technical",
source: "JPEG APP0",
label: "JFIF header",
value: `version ${data[5] ?? 0}.${String(data[6] ?? 0).padStart(2, "0")}; density ${readU16be(data, 8)}×${readU16be(data, 10)}; thumbnail ${thumbnailWidth}×${thumbnailHeight}`,
offset,
length: data.byteLength,
});
return { multiImage: false, complete: true };
}
if (marker === 0xe1 && ascii(data, 0, 6) === "Exif\u0000\u0000") {
const result = scanTiff(
data.subarray(6),
"JPEG EXIF",
offset + 6,
collector,
limits,
);
return {
orientation: result.orientation,
multiImage: false,
complete: result.complete,
};
}
if (marker === 0xe1 && ascii(data, 0, XMP_HEADER.length) === XMP_HEADER) {
scanXmp(data.subarray(XMP_HEADER.length), "JPEG XMP", offset, collector);
return { multiImage: false, complete: true };
}
if (
marker === 0xe1 &&
ascii(data, 0, XMP_EXTENDED_HEADER.length) === XMP_EXTENDED_HEADER
) {
scanXmp(
data.subarray(XMP_EXTENDED_HEADER.length),
"JPEG extended XMP",
offset,
collector,
);
return { multiImage: false, complete: true };
}
if (marker === 0xed && ascii(data, 0, 13) === "Photoshop 3.0") {
const resources = findPhotoshopIptc(data);
for (const resource of resources)
scanIptc(
resource.bytes,
"JPEG IPTC",
offset + resource.relativeOffset,
collector,
);
if (resources.length === 0)
collector.add({
category: "unknown",
risk: "context",
source: "JPEG APP13",
label: "Photoshop image resources",
value: `${data.byteLength} bytes`,
offset,
length: data.byteLength,
});
return { multiImage: false, complete: true };
}
if (marker === 0xe2 && ascii(data, 0, 12) === "ICC_PROFILE\u0000") {
collector.add({
category: "colour-profile",
risk: "technical",
source: "JPEG APP2",
label: "ICC profile chunk",
value: `chunk ${data[12] ?? 0} of ${data[13] ?? 0}; ${Math.max(0, data.byteLength - 14)} bytes`,
offset,
length: data.byteLength,
});
return { multiImage: false, complete: true };
}
if (marker === 0xe2 && ascii(data, 0, 4) === "MPF\u0000") {
collector.add({
category: "thumbnail",
risk: "sensitive",
source: "JPEG APP2",
label: "Multi-Picture Format",
value: `${data.byteLength} bytes; additional images may be embedded`,
offset,
length: data.byteLength,
});
return { multiImage: true, complete: true };
}
if (marker === 0xeb && containsAscii(data, ["jumb", "c2pa"])) {
collector.add({
category: "provenance",
risk: "context",
source: "JPEG APP11",
label: "JUMBF / C2PA provenance data",
value: `${data.byteLength} bytes; re-encoding will remove or invalidate this provenance`,
offset,
length: data.byteLength,
});
return { multiImage: false, complete: true };
}
if (marker === 0xee && ascii(data, 0, 5) === "Adobe") {
collector.add({
category: "technical",
risk: "technical",
source: "JPEG APP14",
label: "Adobe colour transform header",
value: `${data.byteLength} bytes`,
offset,
length: data.byteLength,
});
return { multiImage: false, complete: true };
}
collector.add({
category: containsAscii(data, ["c2pa", "jumb"]) ? "provenance" : "unknown",
risk: "context",
source: `JPEG APP${marker - 0xe0}`,
label: "Unrecognized application metadata",
value: `${data.byteLength} bytes`,
offset,
length: data.byteLength,
});
return { multiImage: false, complete: true };
}
function isStartOfFrame(marker: number): boolean {
return (
marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)
);
}
function findJpegEnd(bytes: Uint8Array, offset: number): number {
for (let index = offset; index + 1 < bytes.byteLength; index += 1) {
if (bytes[index] !== 0xff) continue;
const marker = bytes[index + 1] ?? 0;
if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) {
index += 1;
continue;
}
if (marker === 0xd9) return index + 1;
}
return -1;
}
function ascii(bytes: Uint8Array, offset: number, length: number): string {
if (offset < 0 || offset + length > bytes.byteLength) return "";
let result = "";
for (let index = 0; index < length; index += 1)
result += String.fromCharCode(bytes[offset + index] ?? 0);
return result;
}
function containsAscii(bytes: Uint8Array, needles: readonly string[]): boolean {
const sample = ascii(
bytes.subarray(0, Math.min(bytes.byteLength, 4096)),
0,
Math.min(bytes.byteLength, 4096),
).toLowerCase();
return needles.some((needle) => sample.includes(needle));
}
function readU16be(bytes: Uint8Array, offset: number): number {
return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
}
+73
View File
@@ -0,0 +1,73 @@
import type { PrivacyLimits } from "./model";
export const DEFAULT_PRIVACY_LIMITS: Readonly<PrivacyLimits> = Object.freeze({
maxFiles: 100,
maxFileBytes: 128 * 1024 * 1024,
maxBatchBytes: 512 * 1024 * 1024,
maxMetadataBlocks: 4096,
maxMetadataBlockBytes: 8 * 1024 * 1024,
maxInflatedMetadataBytes: 4 * 1024 * 1024,
maxFindingLabelChars: 512,
maxFindingValueChars: 16_384,
maxFindingTextChars: 256 * 1024,
maxFindings: 4096,
maxTiffEntries: 4096,
maxTiffDepth: 16,
maxPixels: 40_000_000,
maxEdge: 32_768,
maxZipBytes: 256 * 1024 * 1024,
});
export class PrivacyLimitError extends RangeError {
readonly actual: number;
readonly limit: number;
constructor(label: string, actual: number, limit: number) {
super(`${label} is ${actual}; the limit is ${limit}`);
this.name = "PrivacyLimitError";
this.actual = actual;
this.limit = limit;
}
}
export function assertLimit(
actual: number,
limit: number,
label: string,
): void {
if (!Number.isSafeInteger(actual) || actual < 0)
throw new TypeError(`${label} is not a valid non-negative integer`);
if (!Number.isSafeInteger(limit) || limit <= 0)
throw new TypeError(`${label} limit is not a positive safe integer`);
if (actual > limit) throw new PrivacyLimitError(label, actual, limit);
}
export function resolveLimits(
overrides: Partial<PrivacyLimits> = {},
): Readonly<PrivacyLimits> {
const merged = { ...DEFAULT_PRIVACY_LIMITS, ...overrides };
for (const [name, value] of Object.entries(merged)) {
if (!Number.isSafeInteger(value) || value <= 0)
throw new TypeError(`${name} must be a positive safe integer`);
}
return Object.freeze(merged);
}
export function assertBatchFiles(
files: readonly { size: number }[],
limits: Readonly<PrivacyLimits> = DEFAULT_PRIVACY_LIMITS,
): void {
assertLimit(files.length, limits.maxFiles, "File count");
let total = 0;
for (const file of files) {
assertLimit(file.size, limits.maxFileBytes, "File size");
total += file.size;
if (!Number.isSafeInteger(total))
throw new PrivacyLimitError(
"Batch byte size",
Number.MAX_SAFE_INTEGER,
limits.maxBatchBytes,
);
assertLimit(total, limits.maxBatchBytes, "Batch byte size");
}
}
+156
View File
@@ -0,0 +1,156 @@
export type DetectedKind =
| "jpeg"
| "png"
| "webp"
| "gif"
| "tiff"
| "heic"
| "avif"
| "jxl"
| "pdf"
| "zip"
| "ole"
| "unknown";
export type FindingCategory =
| "location"
| "identity"
| "timestamp"
| "device"
| "software"
| "document-id"
| "comment"
| "thumbnail"
| "colour-profile"
| "provenance"
| "technical"
| "unknown";
export type FindingRisk = "sensitive" | "context" | "technical";
export interface MetadataFinding {
id: string;
category: FindingCategory;
risk: FindingRisk;
source: string;
label: string;
value: string;
offset?: number;
length?: number;
}
export interface MetadataBlock {
kind: string;
offset: number;
length: number;
}
export interface ParserCoverage {
projectScanner: "complete" | "partial" | "unsupported";
secondaryScanner: "complete" | "partial" | "unsupported" | "failed";
notes: string[];
}
export interface InventoryIdentity {
claimedType: string;
extension: string;
detectedKind: DetectedKind;
detectedType: string;
typeMatch: "match" | "mismatch" | "unclaimed" | "unknown";
}
export interface ImageScanResult {
id: string;
name: string;
safeName: string;
size: number;
lastModified: number;
sha256: string;
identity: InventoryIdentity;
width?: number;
height?: number;
orientation?: number;
animated: boolean;
multiImage: boolean;
deepSupported: boolean;
cleanable: boolean;
findings: MetadataFinding[];
blocks: MetadataBlock[];
warnings: string[];
coverage: ParserCoverage;
}
export interface ScanInput {
id: string;
name: string;
claimedType: string;
size: number;
lastModified: number;
bytes: ArrayBuffer;
}
export type SanitizationStatus = "verified" | "warning" | "failed";
export interface PixelComparison {
method: "oriented-256px-sample";
sourceDigest: string;
outputDigest: string;
identical: boolean;
note: string;
}
export interface SanitizationReport {
sourceId: string;
sourceName: string;
outputName: string;
outputType: string;
sourceSha256: string;
outputSha256: string;
sourceBytes: number;
outputBytes: number;
sourceDimensions?: { width: number; height: number };
outputDimensions?: { width: number; height: number };
orientationNormalized: boolean;
removed: MetadataFinding[];
preserved: MetadataFinding[];
generated: MetadataFinding[];
unsupported: string[];
incomplete: string[];
outputScan: ImageScanResult;
pixelComparison: PixelComparison;
status: SanitizationStatus;
summary: string;
disclaimer: string;
}
export interface SanitizedAsset {
blob: Blob;
report: SanitizationReport;
}
export interface BatchReport {
schemaVersion: 1;
generatedAt: string;
application: { name: "Privacy Tools"; version: "0.1.0" };
files: ImageScanResult[];
sanitizations: SanitizationReport[];
warnings: string[];
}
export interface PrivacyLimits {
maxFiles: number;
maxFileBytes: number;
maxBatchBytes: number;
maxMetadataBlocks: number;
maxMetadataBlockBytes: number;
maxInflatedMetadataBytes: number;
maxFindingLabelChars: number;
maxFindingValueChars: number;
maxFindingTextChars: number;
maxFindings: number;
maxTiffEntries: number;
maxTiffDepth: number;
maxPixels: number;
maxEdge: number;
maxZipBytes: number;
}
+359
View File
@@ -0,0 +1,359 @@
import { crc32 } from "@add-ideas/toolbox-helpers";
import { FindingCollector } from "./findings";
import { inflateZlibBounded } from "./inflate";
import type { MetadataBlock, PrivacyLimits } from "./model";
import type { FormatScan } from "./jpeg";
import { scanTiff } from "./tiff";
import { decodeMetadataText, scanXmp } from "./xmp";
export function scanPng(
bytes: Uint8Array,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): FormatScan {
const blocks: MetadataBlock[] = [];
let offset = 8;
let width: number | undefined;
let height: number | undefined;
let orientation: number | undefined;
let animated = false;
let complete = true;
let chunks = 0;
let sawEnd = false;
let sawHeader = false;
let sawImageData = false;
while (offset + 12 <= bytes.byteLength) {
const length = readU32be(bytes, offset);
const type = ascii(bytes, offset + 4, 4);
const dataOffset = offset + 8;
const end = dataOffset + length;
if (!/^[A-Za-z]{4}$/u.test(type) || end + 4 > bytes.byteLength) {
collector.warn(`PNG chunk at byte ${offset} is malformed or truncated.`);
complete = false;
break;
}
chunks += 1;
if (chunks > limits.maxMetadataBlocks) {
collector.warn(`PNG chunk count exceeded ${limits.maxMetadataBlocks}.`);
complete = false;
break;
}
const data = bytes.subarray(dataOffset, end);
const expectedCrc = readU32be(bytes, end);
const crcInput = bytes.subarray(offset + 4, end);
if (crc32(crcInput, limits.maxFileBytes) !== expectedCrc) {
collector.warn(`PNG ${type} chunk at byte ${offset} has an invalid CRC.`);
complete = false;
}
if (chunks === 1 && type !== "IHDR") {
collector.warn("PNG IHDR is not the first chunk.");
complete = false;
}
if (type === "IHDR") {
if (sawHeader || chunks !== 1 || length !== 13) {
collector.warn("PNG IHDR is duplicated, misplaced, or malformed.");
complete = false;
} else {
width = readU32be(data, 0);
height = readU32be(data, 4);
}
sawHeader = true;
} else if (type === "IDAT") {
sawImageData = true;
} else if (type === "IEND") {
if (length !== 0) {
collector.warn("PNG IEND chunk is not empty.");
complete = false;
}
sawEnd = true;
offset = end + 4;
break;
} else if (type === "acTL" || type === "fcTL" || type === "fdAT") {
animated = true;
blocks.push({ kind: type, offset: dataOffset, length });
collector.add({
category: "technical",
risk: "context",
source: "PNG",
label: "APNG animation",
value: `${type} chunk (${length} bytes)`,
offset: dataOffset,
length,
});
} else if (isMetadataChunk(type)) {
if (length > limits.maxMetadataBlockBytes) {
collector.warn(`PNG ${type} metadata exceeds the block limit.`);
complete = false;
} else {
blocks.push({ kind: type, offset: dataOffset, length });
const result = scanPngMetadata(
type,
data,
dataOffset,
collector,
limits,
);
if (result.orientation !== undefined) orientation = result.orientation;
complete &&= result.complete;
}
} else if (type[1] === type[1]?.toLowerCase()) {
blocks.push({ kind: type, offset: dataOffset, length });
collector.add({
category: type === "caBX" ? "provenance" : "unknown",
risk: "context",
source: "PNG",
label:
type === "caBX" ? "C2PA provenance chunk" : `Private chunk ${type}`,
value: `${length} bytes`,
offset: dataOffset,
length,
});
}
offset = end + 4;
}
if (!sawEnd) {
collector.warn("PNG IEND chunk was not found.");
complete = false;
} else if (offset < bytes.byteLength) {
const trailing = bytes.byteLength - offset;
blocks.push({ kind: "trailing-data", offset, length: trailing });
collector.add({
category: "unknown",
risk: "sensitive",
source: "PNG",
label: "Trailing data",
value: `${trailing} bytes after IEND`,
offset,
length: trailing,
});
}
if (!sawHeader) {
collector.warn("PNG IHDR chunk was not found.");
complete = false;
}
if (!sawImageData) {
collector.warn("PNG IDAT image data was not found.");
complete = false;
}
return {
width,
height,
orientation,
animated,
multiImage: false,
complete,
blocks,
};
}
function scanPngMetadata(
type: string,
data: Uint8Array,
offset: number,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): { orientation?: number; complete: boolean } {
try {
if (type === "eXIf") {
const result = scanTiff(data, "PNG eXIf", offset, collector, limits);
return { orientation: result.orientation, complete: result.complete };
}
if (type === "tEXt") {
const separator = data.indexOf(0);
const keyword = decodeLatin(
data.subarray(0, separator < 0 ? data.length : separator),
);
const value =
separator < 0 ? "" : decodeLatin(data.subarray(separator + 1));
addPngText(keyword, value, type, offset, data.byteLength, collector);
} else if (type === "zTXt") {
const separator = data.indexOf(0);
if (separator < 0 || data[separator + 1] !== 0)
throw new SyntaxError("invalid zTXt header");
const keyword = decodeLatin(data.subarray(0, separator));
const inflated = inflateZlibBounded(
data.subarray(separator + 2),
limits.maxInflatedMetadataBytes,
);
addPngText(
keyword,
decodeLatin(inflated),
type,
offset,
data.byteLength,
collector,
);
} else if (type === "iTXt") {
const parsed = parseInternationalText(
data,
limits.maxInflatedMetadataBytes,
);
addPngText(
parsed.keyword,
parsed.text,
type,
offset,
data.byteLength,
collector,
);
} else if (type === "iCCP") {
const separator = data.indexOf(0);
if (separator < 0 || data[separator + 1] !== 0)
throw new SyntaxError("invalid iCCP header");
const profile = inflateZlibBounded(
data.subarray(separator + 2),
limits.maxInflatedMetadataBytes,
);
collector.add({
category: "colour-profile",
risk: "technical",
source: "PNG iCCP",
label: "ICC profile",
value: `${decodeLatin(data.subarray(0, separator)) || "unnamed"}; ${profile.byteLength} bytes inflated`,
offset,
length: data.byteLength,
});
} else if (type === "pHYs") {
collector.add({
category: "technical",
risk: "technical",
source: "PNG pHYs",
label: "Pixel density",
value: `${readU32be(data, 0)}×${readU32be(data, 4)} per ${data[8] === 1 ? "metre" : "unknown unit"}`,
offset,
length: data.byteLength,
});
} else if (type === "tIME" && data.byteLength === 7) {
collector.add({
category: "timestamp",
risk: "sensitive",
source: "PNG tIME",
label: "Last modification time",
value: `${readU16be(data, 0)}-${pad(data[2])}-${pad(data[3])} ${pad(data[4])}:${pad(data[5])}:${pad(data[6])} UTC-like fields`,
offset,
length: data.byteLength,
});
} else if (type === "caBX") {
collector.add({
category: "provenance",
risk: "context",
source: "PNG caBX",
label: "C2PA provenance data",
value: `${data.byteLength} bytes; re-encoding will remove or invalidate this provenance`,
offset,
length: data.byteLength,
});
}
return { complete: true };
} catch (error) {
collector.warn(
`PNG ${type} metadata could not be fully read: ${error instanceof Error ? error.message : "unknown error"}.`,
);
return { complete: false };
}
}
function addPngText(
keyword: string,
value: string,
type: string,
offset: number,
length: number,
collector: FindingCollector,
): void {
if (/xmp/iu.test(keyword) || /<\?xpacket|<rdf:/iu.test(value)) {
scanXmp(value, `PNG ${type} (${keyword || "XMP"})`, offset, collector);
return;
}
const normalized = keyword.toLowerCase();
const category = /author|copyright|creator/iu.test(keyword)
? "identity"
: /date|time/iu.test(keyword)
? "timestamp"
: /software/iu.test(keyword)
? "software"
: /description|comment|title|keyword/iu.test(keyword)
? "comment"
: normalized === "raw profile type exif"
? "device"
: "unknown";
collector.add({
category,
risk: category === "unknown" ? "context" : "sensitive",
source: `PNG ${type}`,
label: keyword || "Text entry",
value,
offset,
length,
});
}
function parseInternationalText(
data: Uint8Array,
maximumInflatedBytes: number,
): { keyword: string; text: string } {
const first = data.indexOf(0);
if (first < 0 || first + 2 >= data.byteLength)
throw new SyntaxError("invalid iTXt header");
const compressed = data[first + 1] === 1;
if ((data[first + 1] !== 0 && !compressed) || data[first + 2] !== 0)
throw new SyntaxError("unsupported iTXt compression");
const languageEnd = data.indexOf(0, first + 3);
if (languageEnd < 0) throw new SyntaxError("truncated iTXt language tag");
const translatedEnd = data.indexOf(0, languageEnd + 1);
if (translatedEnd < 0)
throw new SyntaxError("truncated iTXt translated keyword");
const payload = data.subarray(translatedEnd + 1);
return {
keyword: decodeLatin(data.subarray(0, first)),
text: decodeMetadataText(
compressed ? inflateZlibBounded(payload, maximumInflatedBytes) : payload,
),
};
}
function isMetadataChunk(type: string): boolean {
return [
"tEXt",
"zTXt",
"iTXt",
"eXIf",
"iCCP",
"pHYs",
"tIME",
"caBX",
].includes(type);
}
function readU32be(bytes: Uint8Array, offset: number): number {
return (
(bytes[offset] ?? 0) * 0x1000000 +
((bytes[offset + 1] ?? 0) << 16) +
((bytes[offset + 2] ?? 0) << 8) +
(bytes[offset + 3] ?? 0)
);
}
function readU16be(bytes: Uint8Array, offset: number): number {
return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
}
function ascii(bytes: Uint8Array, offset: number, length: number): string {
let value = "";
for (
let index = 0;
index < length && offset + index < bytes.length;
index += 1
)
value += String.fromCharCode(bytes[offset + index] ?? 0);
return value;
}
function decodeLatin(bytes: Uint8Array): string {
return new TextDecoder("latin1", { fatal: false }).decode(bytes);
}
function pad(value: number | undefined): string {
return String(value ?? 0).padStart(2, "0");
}
+435
View File
@@ -0,0 +1,435 @@
import {
digestHex,
sanitizeDownloadFilename,
} from "@add-ideas/toolbox-helpers";
import type {
ImageScanResult,
MetadataFinding,
PixelComparison,
SanitizedAsset,
SanitizationReport,
} from "./model";
import { scanImageBytes } from "./scanner";
export interface SanitizeOptions {
jpegQuality?: number;
webpQuality?: number;
signal?: AbortSignal;
}
export async function sanitizeStaticImage(
file: File,
source: ImageScanResult,
options: SanitizeOptions = {},
): Promise<SanitizedAsset> {
if (!source.cleanable)
throw new TypeError(
`${source.name} is not a supported static clean-copy input.`,
);
if (source.coverage.projectScanner !== "complete")
throw new TypeError("The source project scan is not complete.");
if (file.size !== source.size || file.name !== source.name)
throw new TypeError(
"The selected source file no longer matches its scan result.",
);
throwIfAborted(options.signal);
await verifySourceHash(file, source);
throwIfAborted(options.signal);
const mime = outputMime(source.identity.detectedKind);
const decoded = await decodeDrawable(file, source, options.signal);
const canvas = document.createElement("canvas");
canvas.width = decoded.outputWidth;
canvas.height = decoded.outputHeight;
const context = canvas.getContext("2d", {
alpha: true,
colorSpace: "srgb",
willReadFrequently: false,
});
if (!context) throw new Error("The browser did not provide a 2D canvas.");
context.save();
applyOrientationTransform(
context,
decoded.orientationToApply,
decoded.rawWidth,
decoded.rawHeight,
);
context.drawImage(decoded.drawable, 0, 0);
context.restore();
decoded.close();
throwIfAborted(options.signal);
const sourceSample = await sampleDigest(canvas);
const quality =
mime === "image/jpeg"
? boundedQuality(options.jpegQuality ?? 0.92)
: mime === "image/webp"
? boundedQuality(options.webpQuality ?? 0.92)
: undefined;
const blob = await canvasToBlob(canvas, mime, quality);
if (blob.type !== mime)
throw new Error(
`This browser encoded ${blob.type || "an unknown format"} instead of ${mime}.`,
);
throwIfAborted(options.signal);
const outputBytes = await blob.arrayBuffer();
const outputName = cleanOutputName(
source.safeName,
source.identity.detectedKind,
);
const outputScan = await scanImageBytes({
id: `${source.id}-clean`,
name: outputName,
claimedType: mime,
size: outputBytes.byteLength,
lastModified: 0,
bytes: outputBytes,
});
const outputSample = await sampleBlobDigest(blob);
const pixelComparison: PixelComparison = {
method: "oriented-256px-sample",
sourceDigest: sourceSample,
outputDigest: outputSample,
identical: sourceSample === outputSample,
note:
mime === "image/png"
? "The decoded, orientation-normalized sample should normally be identical."
: "JPEG and lossy WebP encoding may change decoded sample pixels even when the visible image is preserved.",
};
const report = buildSanitizationReport(
source,
outputScan,
outputName,
mime,
blob.size,
pixelComparison,
);
return { blob, report };
}
export function buildSanitizationReport(
source: ImageScanResult,
output: ImageScanResult,
outputName: string,
outputType: string,
outputBytes: number,
pixelComparison: PixelComparison,
): SanitizationReport {
const outputKeys = new Set(output.findings.map(findingSignature));
const sourceKeys = new Set(source.findings.map(findingSignature));
const removed = source.findings.filter(
(finding) => !outputKeys.has(findingSignature(finding)),
);
const preserved = source.findings.filter((finding) =>
outputKeys.has(findingSignature(finding)),
);
const generated = output.findings.filter(
(finding) => !sourceKeys.has(findingSignature(finding)),
);
const unsupported: string[] = [];
const incomplete: string[] = [];
if (source.coverage.projectScanner !== "complete")
incomplete.push(
"The source project scanner did not reach complete coverage.",
);
if (source.coverage.secondaryScanner !== "complete")
incomplete.push(
`The source secondary scanner status was ${source.coverage.secondaryScanner}.`,
);
if (output.coverage.projectScanner !== "complete")
incomplete.push(
"The output project scanner did not reach complete coverage.",
);
if (output.coverage.secondaryScanner !== "complete")
incomplete.push(
`The output secondary scanner status was ${output.coverage.secondaryScanner}.`,
);
if (source.findings.some((finding) => finding.category === "provenance"))
unsupported.push(
"Source provenance/signature data was removed or invalidated; authenticity cannot be carried through pixel re-encoding.",
);
const dimensionsMatch =
source.width === undefined ||
source.height === undefined ||
(output.width === expectedWidth(source) &&
output.height === expectedHeight(source));
if (!dimensionsMatch)
incomplete.push(
"Output dimensions do not match the expected oriented dimensions.",
);
const orientationNormalized =
(output.orientation === undefined || output.orientation === 1) &&
dimensionsMatch;
if (!orientationNormalized)
incomplete.push("Output orientation was not normalized to pixel order.");
if (output.identity.typeMatch !== "match")
incomplete.push("Output type, filename, or detected bytes do not agree.");
if (outputType === "image/png" && !pixelComparison.identical)
incomplete.push(
"Lossless PNG output did not preserve the decoded pixel sample.",
);
const unsafeOutput = output.findings.filter(
(finding) =>
finding.risk === "sensitive" ||
finding.category === "comment" ||
finding.category === "provenance",
);
const unexpectedBlocks = findUnexpectedOutputBlocks(output);
if (unexpectedBlocks.length > 0)
incomplete.push(
`Output contains unexpected metadata blocks: ${unexpectedBlocks.join(", ")}.`,
);
const failed =
output.coverage.projectScanner !== "complete" ||
unsafeOutput.length > 0 ||
output.animated ||
output.multiImage ||
!orientationNormalized ||
output.identity.typeMatch !== "match" ||
(outputType === "image/png" && !pixelComparison.identical);
const warning =
incomplete.length > 0 ||
output.coverage.secondaryScanner !== "complete" ||
unsupported.length > 0;
const status = failed ? "failed" : warning ? "warning" : "verified";
return {
sourceId: source.id,
sourceName: source.name,
outputName,
outputType,
sourceSha256: source.sha256,
outputSha256: output.sha256,
sourceBytes: source.size,
outputBytes,
sourceDimensions:
source.width !== undefined && source.height !== undefined
? { width: source.width, height: source.height }
: undefined,
outputDimensions:
output.width !== undefined && output.height !== undefined
? { width: output.width, height: output.height }
: undefined,
orientationNormalized,
removed,
preserved,
generated,
unsupported,
incomplete,
outputScan: output,
pixelComparison,
status,
summary:
status === "verified"
? "The pixel re-encode completed and both bounded output scanners found no sensitive metadata."
: status === "warning"
? "The pixel re-encode completed, but one or more verification limits require review."
: "The output did not pass the mandatory metadata verification gate.",
disclaimer:
"This report is not an anonymity guarantee. Visible faces or text, steganography, invisible watermarks, reverse-image matching, sidecars, filesystem records, and cloud copies are outside this tool's checks.",
};
}
async function verifySourceHash(
file: File,
source: ImageScanResult,
): Promise<void> {
const bytes = new Uint8Array(await file.arrayBuffer());
const currentHash = await digestHex(bytes, "SHA-256", source.size);
if (currentHash !== source.sha256)
throw new TypeError(
"The selected source bytes no longer match their scan hash.",
);
}
function findUnexpectedOutputBlocks(output: ImageScanResult): string[] {
const allowed =
output.identity.detectedKind === "jpeg"
? new Set(["APP0", "APP2"])
: output.identity.detectedKind === "png"
? new Set(["iCCP", "pHYs"])
: output.identity.detectedKind === "webp"
? new Set(["ICCP"])
: new Set<string>();
return [...new Set(output.blocks.map((block) => block.kind))].filter(
(kind) => !allowed.has(kind),
);
}
interface DecodedDrawable {
drawable: CanvasImageSource;
rawWidth: number;
rawHeight: number;
outputWidth: number;
outputHeight: number;
orientationToApply: number;
close(): void;
}
async function decodeDrawable(
file: File,
source: ImageScanResult,
signal?: AbortSignal,
): Promise<DecodedDrawable> {
if ("createImageBitmap" in globalThis) {
const bitmap = await createImageBitmap(file, { imageOrientation: "none" });
throwIfAborted(signal);
const orientation = normalizeOrientation(source.orientation);
const scannerWidth = source.width ?? bitmap.width;
const scannerHeight = source.height ?? bitmap.height;
const browserAlreadyOriented =
swapsAxes(orientation) &&
bitmap.width === scannerHeight &&
bitmap.height === scannerWidth;
const orientationToApply = browserAlreadyOriented ? 1 : orientation;
return {
drawable: bitmap,
rawWidth: bitmap.width,
rawHeight: bitmap.height,
outputWidth: swapsAxes(orientationToApply) ? bitmap.height : bitmap.width,
outputHeight: swapsAxes(orientationToApply)
? bitmap.width
: bitmap.height,
orientationToApply,
close: () => bitmap.close(),
};
}
const url = URL.createObjectURL(file);
try {
const image = new Image();
image.decoding = "async";
image.src = url;
await image.decode();
throwIfAborted(signal);
return {
drawable: image,
rawWidth: image.naturalWidth,
rawHeight: image.naturalHeight,
outputWidth: image.naturalWidth,
outputHeight: image.naturalHeight,
orientationToApply: 1,
close: () => undefined,
};
} finally {
URL.revokeObjectURL(url);
}
}
function applyOrientationTransform(
context: CanvasRenderingContext2D,
orientation: number,
width: number,
height: number,
): void {
if (orientation === 2) context.transform(-1, 0, 0, 1, width, 0);
else if (orientation === 3) context.transform(-1, 0, 0, -1, width, height);
else if (orientation === 4) context.transform(1, 0, 0, -1, 0, height);
else if (orientation === 5) context.transform(0, 1, 1, 0, 0, 0);
else if (orientation === 6) context.transform(0, 1, -1, 0, height, 0);
else if (orientation === 7) context.transform(0, -1, -1, 0, height, width);
else if (orientation === 8) context.transform(0, -1, 1, 0, 0, width);
}
async function sampleDigest(canvas: HTMLCanvasElement): Promise<string> {
const maximum = 256;
const scale = Math.min(1, maximum / canvas.width, maximum / canvas.height);
const width = Math.max(1, Math.round(canvas.width * scale));
const height = Math.max(1, Math.round(canvas.height * scale));
const sample = document.createElement("canvas");
sample.width = width;
sample.height = height;
const context = sample.getContext("2d", { willReadFrequently: true });
if (!context)
throw new Error("The browser could not create a verification canvas.");
context.drawImage(canvas, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
return digestHex(pixels, "SHA-256", maximum * maximum * 4);
}
async function sampleBlobDigest(blob: Blob): Promise<string> {
const bitmap = await createImageBitmap(blob, { imageOrientation: "none" });
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context)
throw new Error("The browser could not decode output pixels.");
context.drawImage(bitmap, 0, 0);
return sampleDigest(canvas);
} finally {
bitmap.close();
}
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality?: number,
): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error(`The browser could not encode ${type}.`));
},
type,
quality,
);
});
}
function outputMime(kind: ImageScanResult["identity"]["detectedKind"]): string {
if (kind === "jpeg") return "image/jpeg";
if (kind === "png") return "image/png";
if (kind === "webp") return "image/webp";
throw new TypeError(`No clean-copy encoder is available for ${kind}.`);
}
function cleanOutputName(
input: string,
kind: ImageScanResult["identity"]["detectedKind"],
): string {
const extension = kind === "jpeg" ? "jpg" : kind;
const withoutExtension = input.replace(/\.[^.]*$/u, "") || "image";
return sanitizeDownloadFilename(`${withoutExtension}.clean.${extension}`);
}
function findingSignature(finding: MetadataFinding): string {
const label = finding.label
.toLowerCase()
.replace(/[^a-z0-9]+/gu, " ")
.trim();
return `${finding.category}\u0000${label}\u0000${finding.value}`;
}
function normalizeOrientation(value: number | undefined): number {
return value !== undefined && value >= 1 && value <= 8 ? value : 1;
}
function swapsAxes(orientation: number): boolean {
return orientation >= 5 && orientation <= 8;
}
function expectedWidth(scan: ImageScanResult): number | undefined {
if (scan.width === undefined || scan.height === undefined) return undefined;
return swapsAxes(normalizeOrientation(scan.orientation))
? scan.height
: scan.width;
}
function expectedHeight(scan: ImageScanResult): number | undefined {
if (scan.width === undefined || scan.height === undefined) return undefined;
return swapsAxes(normalizeOrientation(scan.orientation))
? scan.width
: scan.height;
}
function boundedQuality(value: number): number {
if (!Number.isFinite(value) || value < 0.5 || value > 1)
throw new RangeError("Image quality must be between 0.5 and 1.");
return value;
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted)
throw new DOMException("Operation cancelled", "AbortError");
}
+112
View File
@@ -0,0 +1,112 @@
import { assertBatchFiles } from "./limits";
import type { ImageScanResult, ScanInput } from "./model";
import type { ScanWorkerRequest, ScanWorkerResponse } from "../worker/protocol";
export interface ScanProgress {
completed: number;
total: number;
currentName: string;
}
export async function scanFilesInWorker(
files: readonly File[],
onProgress: (progress: ScanProgress) => void = () => undefined,
signal?: AbortSignal,
): Promise<ImageScanResult[]> {
assertBatchFiles(files);
throwIfAborted(signal);
const worker = new Worker(
new URL("../worker/scan.worker.ts", import.meta.url),
{
type: "module",
name: "privacy-metadata-scanner",
},
);
const cancel = () => worker.terminate();
signal?.addEventListener("abort", cancel, { once: true });
const results: ImageScanResult[] = [];
try {
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
if (!file) continue;
throwIfAborted(signal);
onProgress({
completed: index,
total: files.length,
currentName: file.name,
});
const bytes = await file.arrayBuffer();
throwIfAborted(signal);
const requestId = `scan-${index}-${fileId(file, index)}`;
const input: ScanInput = {
id: fileId(file, index),
name: file.name,
claimedType: file.type,
size: file.size,
lastModified: file.lastModified,
bytes,
};
results.push(
await scanOne(
worker,
{ type: "scan-file", requestId, file: input },
signal,
),
);
onProgress({
completed: index + 1,
total: files.length,
currentName: file.name,
});
}
return results;
} finally {
worker.terminate();
signal?.removeEventListener("abort", cancel);
}
}
function scanOne(
worker: Worker,
request: ScanWorkerRequest,
signal?: AbortSignal,
): Promise<ImageScanResult> {
return new Promise((resolve, reject) => {
const dispose = () => {
worker.removeEventListener("message", handleMessage);
worker.removeEventListener("error", handleError);
signal?.removeEventListener("abort", handleAbort);
};
const handleMessage = (event: MessageEvent<ScanWorkerResponse>) => {
const response = event.data;
if (response.requestId !== request.requestId) return;
dispose();
if (response.type === "scanned") resolve(response.result);
else reject(new Error(response.message));
};
const handleError = (event: ErrorEvent) => {
dispose();
reject(new Error(event.message || "Metadata worker failed."));
};
const handleAbort = () => {
dispose();
reject(new DOMException("Scan cancelled", "AbortError"));
};
worker.addEventListener("message", handleMessage);
worker.addEventListener("error", handleError);
signal?.addEventListener("abort", handleAbort, { once: true });
worker.postMessage(request, [request.file.bytes]);
});
}
function fileId(file: File, index: number): string {
const safe = file.name
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-|-$/gu, "");
return `${index + 1}-${safe || "file"}-${file.size}-${file.lastModified}`;
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException("Scan cancelled", "AbortError");
}
+151
View File
@@ -0,0 +1,151 @@
import {
digestHex,
sanitizeDownloadFilename,
} from "@add-ideas/toolbox-helpers";
import { inventoryIdentity } from "./detect";
import { scanWithExifReader } from "./exif-reader-adapter";
import { FindingCollector } from "./findings";
import { scanJpeg, type FormatScan } from "./jpeg";
import { assertLimit, resolveLimits } from "./limits";
import type { ImageScanResult, PrivacyLimits, ScanInput } from "./model";
import { scanPng } from "./png";
import { scanWebp } from "./webp";
const EMPTY_FORMAT: FormatScan = Object.freeze({
animated: false,
multiImage: false,
complete: false,
blocks: [],
});
export async function scanImageBytes(
input: ScanInput,
overrides: Partial<PrivacyLimits> = {},
): Promise<ImageScanResult> {
const limits = resolveLimits(overrides);
assertLimit(input.size, limits.maxFileBytes, "File size");
if (input.bytes.byteLength !== input.size)
throw new RangeError(
`Declared file size ${input.size} does not match ${input.bytes.byteLength} bytes read`,
);
const bytes = new Uint8Array(input.bytes);
const identity = inventoryIdentity(input.name, input.claimedType, bytes);
const collector = new FindingCollector(limits);
let format: FormatScan = EMPTY_FORMAT;
if (identity.detectedKind === "jpeg")
format = scanJpeg(bytes, collector, limits);
else if (identity.detectedKind === "png")
format = scanPng(bytes, collector, limits);
else if (identity.detectedKind === "webp")
format = scanWebp(bytes, collector, limits);
const deepSupported = ["jpeg", "png", "webp"].includes(identity.detectedKind);
const secondarySupported = [
"jpeg",
"png",
"webp",
"gif",
"tiff",
"heic",
"avif",
"jxl",
].includes(identity.detectedKind);
const secondary = secondarySupported
? scanWithExifReader(input.bytes, collector, limits)
: {
status: "unsupported" as const,
notes: ["No deep metadata adapter is available for this format."],
};
const width = format.width ?? secondary.width;
const height = format.height ?? secondary.height;
let dimensionsAllowed = true;
if (width !== undefined && height !== undefined) {
if (width <= 0 || height <= 0) {
collector.warn("Image dimensions are invalid.");
dimensionsAllowed = false;
} else if (
width > limits.maxEdge ||
height > limits.maxEdge ||
width * height > limits.maxPixels
) {
collector.warn(
`Image dimensions ${width}×${height} exceed the ${limits.maxEdge}-pixel edge or ${limits.maxPixels.toLocaleString("en-US")}-pixel processing limit.`,
);
dimensionsAllowed = false;
}
} else if (deepSupported) {
collector.warn(
"Pixel dimensions could not be established from the container.",
);
dimensionsAllowed = false;
}
if (identity.typeMatch === "mismatch")
collector.warn(
"The filename or browser-claimed media type does not match the detected bytes.",
);
if (!deepSupported)
collector.warn(
"This format receives inventory and best-effort secondary inspection only; clean-copy output is unavailable.",
);
if (format.animated)
collector.warn("Animated images are inspect-only in version 0.1.");
if (format.multiImage)
collector.warn("Multi-image containers are inspect-only in version 0.1.");
const cleanable =
deepSupported &&
format.complete &&
!collector.limited &&
!format.animated &&
!format.multiImage &&
dimensionsAllowed;
const sha256 = await digestHex(bytes, "SHA-256", limits.maxFileBytes);
return {
id: input.id,
name: input.name,
safeName: sanitizeDownloadFilename(input.name, "image"),
size: input.size,
lastModified: input.lastModified,
sha256,
identity,
width,
height,
orientation: format.orientation,
animated: format.animated,
multiImage: format.multiImage,
deepSupported,
cleanable,
findings: collector.findings.sort(compareFindings),
blocks: format.blocks,
warnings: collector.warnings,
coverage: {
projectScanner: deepSupported
? format.complete && !collector.limited
? "complete"
: "partial"
: "unsupported",
secondaryScanner:
collector.limited && secondary.status === "complete"
? "partial"
: secondary.status,
notes: collector.limited
? [
...secondary.notes,
"Normalized finding output reached its text limit.",
]
: secondary.notes,
},
};
}
function compareFindings(
left: ImageScanResult["findings"][number],
right: ImageScanResult["findings"][number],
): number {
return (
left.category.localeCompare(right.category) ||
left.source.localeCompare(right.source) ||
left.label.localeCompare(right.label)
);
}
+346
View File
@@ -0,0 +1,346 @@
import { categoryForName, FindingCollector } from "./findings";
import { scanIptc } from "./iptc";
import type { PrivacyLimits } from "./model";
import { scanXmp } from "./xmp";
const TYPE_BYTES: Readonly<Record<number, number>> = Object.freeze({
1: 1,
2: 1,
3: 2,
4: 4,
5: 8,
7: 1,
9: 4,
10: 8,
11: 4,
12: 8,
13: 4,
});
const TAG_NAMES: Readonly<Record<number, string>> = Object.freeze({
0x010e: "Image Description",
0x010f: "Camera Make",
0x0110: "Camera Model",
0x0112: "Orientation",
0x0131: "Software",
0x0132: "Date/Time",
0x013b: "Artist",
0x0201: "JPEG Thumbnail Offset",
0x0202: "JPEG Thumbnail Length",
0x02bc: "XMP",
0x8298: "Copyright",
0x83bb: "IPTC/NAA",
0x8769: "Exif IFD",
0x8825: "GPS IFD",
0x9003: "Date/Time Original",
0x9004: "Date/Time Digitized",
0x927c: "Maker Note",
0x9286: "User Comment",
0xa005: "Interoperability IFD",
0xa420: "Image Unique ID",
0xa430: "Camera Owner Name",
0xa431: "Camera Body Serial Number",
0xa432: "Lens Specification",
0xa433: "Lens Make",
0xa434: "Lens Model",
0xa435: "Lens Serial Number",
0x9c9b: "Windows Title",
0x9c9c: "Windows Comment",
0x9c9d: "Windows Author",
0x9c9e: "Windows Keywords",
0x9c9f: "Windows Subject",
0x8773: "ICC Profile",
});
const GPS_NAMES: Readonly<Record<number, string>> = Object.freeze({
0: "GPS Version",
1: "GPS Latitude Reference",
2: "GPS Latitude",
3: "GPS Longitude Reference",
4: "GPS Longitude",
5: "GPS Altitude Reference",
6: "GPS Altitude",
7: "GPS Time Stamp",
11: "GPS Dilution of Precision",
12: "GPS Speed Reference",
13: "GPS Speed",
16: "GPS Direction Reference",
17: "GPS Direction",
18: "GPS Map Datum",
27: "GPS Processing Method",
28: "GPS Area Information",
29: "GPS Date Stamp",
31: "GPS Horizontal Positioning Error",
});
export interface TiffScan {
orientation?: number;
complete: boolean;
}
interface IfdTask {
offset: number;
role: "main" | "exif" | "gps" | "interop" | "thumbnail";
depth: number;
}
export function scanTiff(
bytes: Uint8Array,
source: string,
baseOffset: number,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): TiffScan {
if (bytes.byteLength < 8) {
collector.warn(`${source} TIFF data is truncated.`);
return { complete: false };
}
const littleEndian = bytes[0] === 0x49 && bytes[1] === 0x49;
const bigEndian = bytes[0] === 0x4d && bytes[1] === 0x4d;
if (!littleEndian && !bigEndian) {
collector.warn(`${source} TIFF byte order is invalid.`);
return { complete: false };
}
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const u16 = (offset: number) => view.getUint16(offset, littleEndian);
const u32 = (offset: number) => view.getUint32(offset, littleEndian);
if (u16(2) !== 42) {
collector.warn(`${source} TIFF magic is unsupported.`);
return { complete: false };
}
const tasks: IfdTask[] = [{ offset: u32(4), role: "main", depth: 0 }];
const visited = new Set<number>();
let totalEntries = 0;
let orientation: number | undefined;
let complete = true;
while (tasks.length > 0) {
const task = tasks.pop();
if (!task) break;
if (task.depth > limits.maxTiffDepth) {
collector.warn(
`${source} TIFF IFD depth exceeded ${limits.maxTiffDepth}.`,
);
complete = false;
continue;
}
if (task.offset === 0) continue;
if (visited.has(task.offset)) {
collector.warn(
`${source} TIFF IFD cycle was stopped at offset ${task.offset}.`,
);
complete = false;
continue;
}
visited.add(task.offset);
if (task.offset < 8 || task.offset + 2 > bytes.byteLength) {
collector.warn(
`${source} TIFF IFD offset is outside the metadata block.`,
);
complete = false;
continue;
}
const count = u16(task.offset);
totalEntries += count;
if (totalEntries > limits.maxTiffEntries) {
collector.warn(
`${source} TIFF entry count exceeded ${limits.maxTiffEntries}.`,
);
complete = false;
break;
}
const tableEnd = task.offset + 2 + count * 12;
if (tableEnd + 4 > bytes.byteLength) {
collector.warn(`${source} TIFF IFD entry table is truncated.`);
complete = false;
continue;
}
for (let index = 0; index < count; index += 1) {
const entryOffset = task.offset + 2 + index * 12;
const tag = u16(entryOffset);
const type = u16(entryOffset + 2);
const itemCount = u32(entryOffset + 4);
const unit = TYPE_BYTES[type];
if (!unit) continue;
const byteLength = itemCount * unit;
if (
!Number.isSafeInteger(byteLength) ||
byteLength > limits.maxMetadataBlockBytes
) {
collector.warn(
`${source} TIFF tag 0x${tag.toString(16)} is oversized.`,
);
complete = false;
continue;
}
const dataOffset =
byteLength <= 4 ? entryOffset + 8 : u32(entryOffset + 8);
if (dataOffset + byteLength > bytes.byteLength) {
collector.warn(
`${source} TIFF tag 0x${tag.toString(16)} points outside its block.`,
);
complete = false;
continue;
}
const data = bytes.subarray(dataOffset, dataOffset + byteLength);
const values = readValues(
view,
dataOffset,
itemCount,
type,
littleEndian,
);
const first = values[0];
if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) {
if (typeof first === "number") {
tasks.push({
offset: first,
role: tag === 0x8825 ? "gps" : tag === 0xa005 ? "interop" : "exif",
depth: task.depth + 1,
});
}
continue;
}
if (tag === 0x014a) {
for (const value of values)
if (typeof value === "number")
tasks.push({ offset: value, role: "main", depth: task.depth + 1 });
continue;
}
if (tag === 0x0112 && typeof first === "number") orientation = first;
if (tag === 0x02bc) {
scanXmp(data, `${source} XMP tag`, baseOffset + dataOffset, collector);
continue;
}
if (tag === 0x83bb) {
scanIptc(
data,
`${source} IPTC tag`,
baseOffset + dataOffset,
collector,
);
continue;
}
const label =
task.role === "gps"
? (GPS_NAMES[tag] ?? `GPS tag 0x${tag.toString(16)}`)
: TAG_NAMES[tag];
if (!label) continue;
const classification =
task.role === "gps"
? ({ category: "location", risk: "sensitive" } as const)
: categoryForName(label);
collector.add({
...classification,
source,
label,
value:
tag === 0x0112 && typeof first === "number"
? orientationName(first)
: displayTiffValue(data, values, type, tag),
offset: baseOffset + dataOffset,
length: byteLength,
});
}
const next = u32(tableEnd);
if (next !== 0)
tasks.push({
offset: next,
role: task.role === "main" ? "thumbnail" : task.role,
depth: task.depth + 1,
});
}
return { orientation, complete };
}
function readValues(
view: DataView,
offset: number,
count: number,
type: number,
littleEndian: boolean,
): Array<number | string> {
if (type === 2) {
const bytes = new Uint8Array(view.buffer, view.byteOffset + offset, count);
return [
stripTerminalNulls(
new TextDecoder("utf-8", { fatal: false }).decode(bytes),
),
];
}
const result: Array<number | string> = [];
const maximum = Math.min(count, 128);
for (let index = 0; index < maximum; index += 1) {
const itemOffset =
offset +
index *
(type === 3
? 2
: type === 4 || type === 9 || type === 11
? 4
: type === 5 || type === 10 || type === 12
? 8
: 1);
if (type === 1 || type === 7) result.push(view.getUint8(itemOffset));
else if (type === 3) result.push(view.getUint16(itemOffset, littleEndian));
else if (type === 4 || type === 13)
result.push(view.getUint32(itemOffset, littleEndian));
else if (type === 9) result.push(view.getInt32(itemOffset, littleEndian));
else if (type === 11)
result.push(view.getFloat32(itemOffset, littleEndian));
else if (type === 12)
result.push(view.getFloat64(itemOffset, littleEndian));
else if (type === 5 || type === 10) {
const numerator =
type === 5
? view.getUint32(itemOffset, littleEndian)
: view.getInt32(itemOffset, littleEndian);
const denominator =
type === 5
? view.getUint32(itemOffset + 4, littleEndian)
: view.getInt32(itemOffset + 4, littleEndian);
result.push(
denominator === 0 ? `${numerator}/0` : numerator / denominator,
);
}
}
if (count > maximum) result.push(`${count - maximum} more values`);
return result;
}
function displayTiffValue(
data: Uint8Array,
values: Array<number | string>,
type: number,
tag: number,
): string {
if (type === 2) return String(values[0] ?? "");
if (tag >= 0x9c9b && tag <= 0x9c9f && data.byteLength % 2 === 0)
return stripTerminalNulls(
new TextDecoder("utf-16le", { fatal: false }).decode(data),
);
if (tag === 0x927c || tag === 0x8773) return `${data.byteLength} bytes`;
return values.join(", ");
}
function stripTerminalNulls(value: string): string {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === 0) end -= 1;
return value.slice(0, end);
}
function orientationName(value: number): string {
return (
[
"Unknown",
"1 — normal",
"2 — mirrored horizontally",
"3 — rotated 180°",
"4 — mirrored vertically",
"5 — mirrored then rotated 90° clockwise",
"6 — rotated 90° clockwise",
"7 — mirrored then rotated 90° counter-clockwise",
"8 — rotated 90° counter-clockwise",
][value] ?? `${value} — invalid orientation`
);
}
+189
View File
@@ -0,0 +1,189 @@
import { FindingCollector } from "./findings";
import type { MetadataBlock, PrivacyLimits } from "./model";
import type { FormatScan } from "./jpeg";
import { scanTiff } from "./tiff";
import { scanXmp } from "./xmp";
export function scanWebp(
bytes: Uint8Array,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): FormatScan {
const blocks: MetadataBlock[] = [];
const declared = readU32le(bytes, 4) + 8;
let complete = declared === bytes.byteLength;
if (declared > bytes.byteLength || declared < 12) {
collector.warn("WebP RIFF size is invalid or truncated.");
complete = false;
} else if (declared < bytes.byteLength) {
const trailing = bytes.byteLength - declared;
blocks.push({ kind: "trailing-data", offset: declared, length: trailing });
collector.add({
category: "unknown",
risk: "sensitive",
source: "WebP",
label: "Trailing data",
value: `${trailing} bytes after the declared RIFF container`,
offset: declared,
length: trailing,
});
}
const containerEnd = Math.min(declared, bytes.byteLength);
let offset = 12;
let chunks = 0;
let width: number | undefined;
let height: number | undefined;
let orientation: number | undefined;
let animated = false;
let sawImageData = false;
while (offset + 8 <= containerEnd) {
const type = ascii(bytes, offset, 4);
const length = readU32le(bytes, offset + 4);
const dataOffset = offset + 8;
const end = dataOffset + length;
if (!/^[\x20-\x7e]{4}$/u.test(type) || end > containerEnd) {
collector.warn(`WebP chunk at byte ${offset} is malformed or truncated.`);
complete = false;
break;
}
chunks += 1;
if (chunks > limits.maxMetadataBlocks) {
collector.warn(`WebP chunk count exceeded ${limits.maxMetadataBlocks}.`);
complete = false;
break;
}
const data = bytes.subarray(dataOffset, end);
if (type === "VP8X" && data.byteLength >= 10) {
animated ||= ((data[0] ?? 0) & 0x02) !== 0;
width = 1 + readU24le(data, 4);
height = 1 + readU24le(data, 7);
} else if (type === "VP8 " && data.byteLength >= 10) {
sawImageData = true;
if (data[3] === 0x9d && data[4] === 0x01 && data[5] === 0x2a) {
width = readU16le(data, 6) & 0x3fff;
height = readU16le(data, 8) & 0x3fff;
}
} else if (type === "VP8L" && data.byteLength >= 5 && data[0] === 0x2f) {
sawImageData = true;
const bits = readU32le(data, 1);
width = 1 + (bits & 0x3fff);
height = 1 + ((bits >>> 14) & 0x3fff);
} else if (type === "ANIM" || type === "ANMF") {
animated = true;
blocks.push({ kind: type, offset: dataOffset, length });
} else if (["EXIF", "XMP ", "ICCP", "META"].includes(type)) {
if (length > limits.maxMetadataBlockBytes) {
collector.warn(`WebP ${type.trim()} metadata exceeds the block limit.`);
complete = false;
} else {
blocks.push({ kind: type.trim(), offset: dataOffset, length });
if (type === "EXIF") {
const prefix = ascii(data, 0, 6) === "Exif\u0000\u0000" ? 6 : 0;
const result = scanTiff(
data.subarray(prefix),
"WebP EXIF",
dataOffset + prefix,
collector,
limits,
);
orientation = result.orientation;
complete &&= result.complete;
} else if (type === "XMP ") {
scanXmp(data, "WebP XMP", dataOffset, collector);
} else if (type === "ICCP") {
collector.add({
category: "colour-profile",
risk: "technical",
source: "WebP ICCP",
label: "ICC profile",
value: `${length} bytes`,
offset: dataOffset,
length,
});
} else {
collector.add({
category: "unknown",
risk: "context",
source: "WebP META",
label: "Generic metadata chunk",
value: `${length} bytes`,
offset: dataOffset,
length,
});
}
}
} else if (type === "ALPH") {
// Pixel alpha data, not metadata.
} else if (!["VP8X", "VP8 ", "VP8L", "ANIM", "ANMF"].includes(type)) {
const lower = ascii(
data,
0,
Math.min(data.byteLength, 4096),
).toLowerCase();
const provenance = lower.includes("c2pa") || lower.includes("jumb");
blocks.push({
kind: type.trim() || "unknown",
offset: dataOffset,
length,
});
collector.add({
category: provenance ? "provenance" : "unknown",
risk: "context",
source: "WebP",
label: provenance
? "Unrecognized provenance chunk"
: `Unrecognized ${type} chunk`,
value: `${length} bytes`,
offset: dataOffset,
length,
});
}
offset = end + (length % 2);
}
if (offset !== containerEnd) {
collector.warn("WebP chunk padding or RIFF boundary is inconsistent.");
complete = false;
}
if (!sawImageData && !animated) {
collector.warn("WebP pixel data was not found.");
complete = false;
}
return {
width,
height,
orientation,
animated,
multiImage: false,
complete,
blocks,
};
}
function ascii(bytes: Uint8Array, offset: number, length: number): string {
if (offset < 0 || offset + length > bytes.byteLength) return "";
let value = "";
for (let index = 0; index < length; index += 1)
value += String.fromCharCode(bytes[offset + index] ?? 0);
return value;
}
function readU16le(bytes: Uint8Array, offset: number): number {
return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8);
}
function readU24le(bytes: Uint8Array, offset: number): number {
return (
(bytes[offset] ?? 0) |
((bytes[offset + 1] ?? 0) << 8) |
((bytes[offset + 2] ?? 0) << 16)
);
}
function readU32le(bytes: Uint8Array, offset: number): number {
return (
(bytes[offset] ?? 0) +
(bytes[offset + 1] ?? 0) * 0x100 +
(bytes[offset + 2] ?? 0) * 0x10000 +
(bytes[offset + 3] ?? 0) * 0x1000000
);
}
+94
View File
@@ -0,0 +1,94 @@
import { categoryForName, FindingCollector } from "./findings";
const NAMED_VALUES =
/(?:<|\s)([A-Za-z_][\w.-]*:[A-Za-z_][\w.-]*)(?:\s*=\s*["']([^"']*)["']|[^>]*>([^<]{1,65536})<)/gu;
export function scanXmp(
input: Uint8Array | string,
source: string,
offset: number | undefined,
collector: FindingCollector,
): void {
const raw = typeof input === "string" ? input : decodeMetadataText(input);
if (!raw.trim()) return;
let matched = 0;
for (const match of raw.matchAll(NAMED_VALUES)) {
const name = match[1] ?? "XMP value";
const value = decodeXmlEntities(match[2] ?? match[3] ?? "");
if (!value.trim()) continue;
const classification = categoryForName(name);
if (
classification.category !== "unknown" ||
/(?:xmp|rdf|dc|exif|photoshop|tiff):/iu.test(name)
) {
collector.add({
...classification,
source,
label: name,
value,
offset,
length: typeof input === "string" ? input.length : input.byteLength,
});
matched += 1;
}
}
for (const token of ["c2pa", "jumbf", "provenance", "manifest"]) {
if (raw.toLowerCase().includes(token)) {
collector.add({
category: "provenance",
risk: "context",
source,
label: "Provenance marker",
value: token.toUpperCase(),
offset,
length: typeof input === "string" ? input.length : input.byteLength,
});
}
}
collector.add({
category: matched > 0 ? "technical" : "unknown",
risk: matched > 0 ? "technical" : "context",
source,
label: "Raw XMP packet",
value: raw,
offset,
length: typeof input === "string" ? input.length : input.byteLength,
});
}
export function decodeMetadataText(bytes: Uint8Array): string {
if (bytes.byteLength >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
return new TextDecoder("utf-16le", { fatal: false }).decode(
bytes.subarray(2),
);
if (bytes.byteLength >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff)
return new TextDecoder("utf-16be", { fatal: false }).decode(
bytes.subarray(2),
);
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
function decodeXmlEntities(value: string): string {
return value.replace(
/&(?:amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/giu,
(entity) => {
if (entity === "&amp;") return "&";
if (entity === "&lt;") return "<";
if (entity === "&gt;") return ">";
if (entity === "&quot;") return '"';
if (entity === "&apos;") return "'";
const hex = /^&#x([\da-f]+);$/iu.exec(entity);
const decimal = /^&#(\d+);$/u.exec(entity);
const codePoint = hex
? Number.parseInt(hex[1] ?? "", 16)
: decimal
? Number.parseInt(decimal[1] ?? "", 10)
: Number.NaN;
return Number.isInteger(codePoint) &&
codePoint >= 0 &&
codePoint <= 0x10ffff
? String.fromCodePoint(codePoint)
: entity;
},
);
}