Release Privacy Tools 0.1.0
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 === "&") return "&";
|
||||
if (entity === "<") return "<";
|
||||
if (entity === ">") return ">";
|
||||
if (entity === """) return '"';
|
||||
if (entity === "'") 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user