Release Privacy Tools 0.1.0

This commit is contained in:
2026-09-01 02:39:44 +02:00
commit bfd6422149
79 changed files with 13485 additions and 0 deletions
+359
View File
@@ -0,0 +1,359 @@
import { crc32 } from "@add-ideas/toolbox-helpers";
import { FindingCollector } from "./findings";
import { inflateZlibBounded } from "./inflate";
import type { MetadataBlock, PrivacyLimits } from "./model";
import type { FormatScan } from "./jpeg";
import { scanTiff } from "./tiff";
import { decodeMetadataText, scanXmp } from "./xmp";
export function scanPng(
bytes: Uint8Array,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): FormatScan {
const blocks: MetadataBlock[] = [];
let offset = 8;
let width: number | undefined;
let height: number | undefined;
let orientation: number | undefined;
let animated = false;
let complete = true;
let chunks = 0;
let sawEnd = false;
let sawHeader = false;
let sawImageData = false;
while (offset + 12 <= bytes.byteLength) {
const length = readU32be(bytes, offset);
const type = ascii(bytes, offset + 4, 4);
const dataOffset = offset + 8;
const end = dataOffset + length;
if (!/^[A-Za-z]{4}$/u.test(type) || end + 4 > bytes.byteLength) {
collector.warn(`PNG chunk at byte ${offset} is malformed or truncated.`);
complete = false;
break;
}
chunks += 1;
if (chunks > limits.maxMetadataBlocks) {
collector.warn(`PNG chunk count exceeded ${limits.maxMetadataBlocks}.`);
complete = false;
break;
}
const data = bytes.subarray(dataOffset, end);
const expectedCrc = readU32be(bytes, end);
const crcInput = bytes.subarray(offset + 4, end);
if (crc32(crcInput, limits.maxFileBytes) !== expectedCrc) {
collector.warn(`PNG ${type} chunk at byte ${offset} has an invalid CRC.`);
complete = false;
}
if (chunks === 1 && type !== "IHDR") {
collector.warn("PNG IHDR is not the first chunk.");
complete = false;
}
if (type === "IHDR") {
if (sawHeader || chunks !== 1 || length !== 13) {
collector.warn("PNG IHDR is duplicated, misplaced, or malformed.");
complete = false;
} else {
width = readU32be(data, 0);
height = readU32be(data, 4);
}
sawHeader = true;
} else if (type === "IDAT") {
sawImageData = true;
} else if (type === "IEND") {
if (length !== 0) {
collector.warn("PNG IEND chunk is not empty.");
complete = false;
}
sawEnd = true;
offset = end + 4;
break;
} else if (type === "acTL" || type === "fcTL" || type === "fdAT") {
animated = true;
blocks.push({ kind: type, offset: dataOffset, length });
collector.add({
category: "technical",
risk: "context",
source: "PNG",
label: "APNG animation",
value: `${type} chunk (${length} bytes)`,
offset: dataOffset,
length,
});
} else if (isMetadataChunk(type)) {
if (length > limits.maxMetadataBlockBytes) {
collector.warn(`PNG ${type} metadata exceeds the block limit.`);
complete = false;
} else {
blocks.push({ kind: type, offset: dataOffset, length });
const result = scanPngMetadata(
type,
data,
dataOffset,
collector,
limits,
);
if (result.orientation !== undefined) orientation = result.orientation;
complete &&= result.complete;
}
} else if (type[1] === type[1]?.toLowerCase()) {
blocks.push({ kind: type, offset: dataOffset, length });
collector.add({
category: type === "caBX" ? "provenance" : "unknown",
risk: "context",
source: "PNG",
label:
type === "caBX" ? "C2PA provenance chunk" : `Private chunk ${type}`,
value: `${length} bytes`,
offset: dataOffset,
length,
});
}
offset = end + 4;
}
if (!sawEnd) {
collector.warn("PNG IEND chunk was not found.");
complete = false;
} else if (offset < bytes.byteLength) {
const trailing = bytes.byteLength - offset;
blocks.push({ kind: "trailing-data", offset, length: trailing });
collector.add({
category: "unknown",
risk: "sensitive",
source: "PNG",
label: "Trailing data",
value: `${trailing} bytes after IEND`,
offset,
length: trailing,
});
}
if (!sawHeader) {
collector.warn("PNG IHDR chunk was not found.");
complete = false;
}
if (!sawImageData) {
collector.warn("PNG IDAT image data was not found.");
complete = false;
}
return {
width,
height,
orientation,
animated,
multiImage: false,
complete,
blocks,
};
}
function scanPngMetadata(
type: string,
data: Uint8Array,
offset: number,
collector: FindingCollector,
limits: Readonly<PrivacyLimits>,
): { orientation?: number; complete: boolean } {
try {
if (type === "eXIf") {
const result = scanTiff(data, "PNG eXIf", offset, collector, limits);
return { orientation: result.orientation, complete: result.complete };
}
if (type === "tEXt") {
const separator = data.indexOf(0);
const keyword = decodeLatin(
data.subarray(0, separator < 0 ? data.length : separator),
);
const value =
separator < 0 ? "" : decodeLatin(data.subarray(separator + 1));
addPngText(keyword, value, type, offset, data.byteLength, collector);
} else if (type === "zTXt") {
const separator = data.indexOf(0);
if (separator < 0 || data[separator + 1] !== 0)
throw new SyntaxError("invalid zTXt header");
const keyword = decodeLatin(data.subarray(0, separator));
const inflated = inflateZlibBounded(
data.subarray(separator + 2),
limits.maxInflatedMetadataBytes,
);
addPngText(
keyword,
decodeLatin(inflated),
type,
offset,
data.byteLength,
collector,
);
} else if (type === "iTXt") {
const parsed = parseInternationalText(
data,
limits.maxInflatedMetadataBytes,
);
addPngText(
parsed.keyword,
parsed.text,
type,
offset,
data.byteLength,
collector,
);
} else if (type === "iCCP") {
const separator = data.indexOf(0);
if (separator < 0 || data[separator + 1] !== 0)
throw new SyntaxError("invalid iCCP header");
const profile = inflateZlibBounded(
data.subarray(separator + 2),
limits.maxInflatedMetadataBytes,
);
collector.add({
category: "colour-profile",
risk: "technical",
source: "PNG iCCP",
label: "ICC profile",
value: `${decodeLatin(data.subarray(0, separator)) || "unnamed"}; ${profile.byteLength} bytes inflated`,
offset,
length: data.byteLength,
});
} else if (type === "pHYs") {
collector.add({
category: "technical",
risk: "technical",
source: "PNG pHYs",
label: "Pixel density",
value: `${readU32be(data, 0)}×${readU32be(data, 4)} per ${data[8] === 1 ? "metre" : "unknown unit"}`,
offset,
length: data.byteLength,
});
} else if (type === "tIME" && data.byteLength === 7) {
collector.add({
category: "timestamp",
risk: "sensitive",
source: "PNG tIME",
label: "Last modification time",
value: `${readU16be(data, 0)}-${pad(data[2])}-${pad(data[3])} ${pad(data[4])}:${pad(data[5])}:${pad(data[6])} UTC-like fields`,
offset,
length: data.byteLength,
});
} else if (type === "caBX") {
collector.add({
category: "provenance",
risk: "context",
source: "PNG caBX",
label: "C2PA provenance data",
value: `${data.byteLength} bytes; re-encoding will remove or invalidate this provenance`,
offset,
length: data.byteLength,
});
}
return { complete: true };
} catch (error) {
collector.warn(
`PNG ${type} metadata could not be fully read: ${error instanceof Error ? error.message : "unknown error"}.`,
);
return { complete: false };
}
}
function addPngText(
keyword: string,
value: string,
type: string,
offset: number,
length: number,
collector: FindingCollector,
): void {
if (/xmp/iu.test(keyword) || /<\?xpacket|<rdf:/iu.test(value)) {
scanXmp(value, `PNG ${type} (${keyword || "XMP"})`, offset, collector);
return;
}
const normalized = keyword.toLowerCase();
const category = /author|copyright|creator/iu.test(keyword)
? "identity"
: /date|time/iu.test(keyword)
? "timestamp"
: /software/iu.test(keyword)
? "software"
: /description|comment|title|keyword/iu.test(keyword)
? "comment"
: normalized === "raw profile type exif"
? "device"
: "unknown";
collector.add({
category,
risk: category === "unknown" ? "context" : "sensitive",
source: `PNG ${type}`,
label: keyword || "Text entry",
value,
offset,
length,
});
}
function parseInternationalText(
data: Uint8Array,
maximumInflatedBytes: number,
): { keyword: string; text: string } {
const first = data.indexOf(0);
if (first < 0 || first + 2 >= data.byteLength)
throw new SyntaxError("invalid iTXt header");
const compressed = data[first + 1] === 1;
if ((data[first + 1] !== 0 && !compressed) || data[first + 2] !== 0)
throw new SyntaxError("unsupported iTXt compression");
const languageEnd = data.indexOf(0, first + 3);
if (languageEnd < 0) throw new SyntaxError("truncated iTXt language tag");
const translatedEnd = data.indexOf(0, languageEnd + 1);
if (translatedEnd < 0)
throw new SyntaxError("truncated iTXt translated keyword");
const payload = data.subarray(translatedEnd + 1);
return {
keyword: decodeLatin(data.subarray(0, first)),
text: decodeMetadataText(
compressed ? inflateZlibBounded(payload, maximumInflatedBytes) : payload,
),
};
}
function isMetadataChunk(type: string): boolean {
return [
"tEXt",
"zTXt",
"iTXt",
"eXIf",
"iCCP",
"pHYs",
"tIME",
"caBX",
].includes(type);
}
function readU32be(bytes: Uint8Array, offset: number): number {
return (
(bytes[offset] ?? 0) * 0x1000000 +
((bytes[offset + 1] ?? 0) << 16) +
((bytes[offset + 2] ?? 0) << 8) +
(bytes[offset + 3] ?? 0)
);
}
function readU16be(bytes: Uint8Array, offset: number): number {
return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0);
}
function ascii(bytes: Uint8Array, offset: number, length: number): string {
let value = "";
for (
let index = 0;
index < length && offset + index < bytes.length;
index += 1
)
value += String.fromCharCode(bytes[offset + index] ?? 0);
return value;
}
function decodeLatin(bytes: Uint8Array): string {
return new TextDecoder("latin1", { fatal: false }).decode(bytes);
}
function pad(value: number | undefined): string {
return String(value ?? 0).padStart(2, "0");
}