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