Release Privacy Tools 0.1.0
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user