Release Privacy Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:09:14 +02:00
parent bfd6422149
commit 38fa45dbaa
31 changed files with 1039 additions and 95 deletions
+101 -7
View File
@@ -13,9 +13,13 @@ import {
} from "./limits";
import type {
BatchReport,
FindingCategory,
FindingRisk,
ImageScanResult,
MetadataFinding,
SafeShareFindingSummary,
SafeShareReport,
SanitizedAsset,
SanitizationReport,
} from "./model";
export function createBatchReport(
@@ -36,9 +40,63 @@ export function createBatchReport(
};
}
export function serializeReport(
report: BatchReport | SanitizationReport,
): string {
export function createSafeShareReport(
files: readonly ImageScanResult[],
assets: readonly SanitizedAsset[],
generatedAt = new Date().toISOString(),
): SafeShareReport {
const fileIds = new Map(
files.map((file, index) => [
file.id,
`file-${String(index + 1).padStart(3, "0")}`,
]),
);
return {
schemaVersion: 1,
profile: "safe-share",
generatedAt,
application: { name: "Privacy Tools", version: APP_VERSION },
files: files.map((file, index) => ({
fileId:
fileIds.get(file.id) ?? `file-${String(index + 1).padStart(3, "0")}`,
detectedKind: file.identity.detectedKind,
detectedType: file.identity.detectedType,
typeMatch: file.identity.typeMatch,
dimensions:
file.width !== undefined && file.height !== undefined
? { width: file.width, height: file.height }
: undefined,
animated: file.animated,
multiImage: file.multiImage,
coverage: {
projectScanner: file.coverage.projectScanner,
secondaryScanner: file.coverage.secondaryScanner,
noteCount: file.coverage.notes.length,
},
findings: summarizeFindings(file.findings),
warningCount: file.warnings.length,
})),
sanitizations: assets.map((asset, index) => ({
fileId: fileIds.get(asset.report.sourceId) ?? "unknown-file",
outputName: genericNameForAsset(asset, index),
outputType: asset.report.outputType,
status: asset.report.status,
orientationNormalized: asset.report.orientationNormalized,
removed: summarizeFindings(asset.report.removed),
preserved: summarizeFindings(asset.report.preserved),
generated: summarizeFindings(asset.report.generated),
unsupported: [...asset.report.unsupported],
incomplete: [...asset.report.incomplete],
})),
warnings: [
"This safe-share profile omits source filenames, timestamps, hashes, exact byte sizes, metadata values and offsets.",
"Counts, dimensions and format details can still identify unusual files; review before sharing.",
"A successful re-scan is not an anonymity guarantee.",
],
};
}
export function serializeReport(report: object): string {
return stableStringify(report, 2, {
maxDepth: 64,
maxNodes: 500_000,
@@ -50,6 +108,7 @@ export async function createBatchArchive(
files: readonly ImageScanResult[],
assets: readonly SanitizedAsset[],
generatedAt = new Date().toISOString(),
reportProfile: "detailed" | "safe-share" = "detailed",
): Promise<Blob> {
assertLimit(
files.length,
@@ -67,8 +126,13 @@ export async function createBatchArchive(
>;
const names = new Set<string>();
let total = 0;
for (const asset of assets) {
const name = uniqueName(asset.report.outputName, names);
for (const [index, asset] of assets.entries()) {
const name = uniqueName(
reportProfile === "safe-share"
? genericNameForAsset(asset, index)
: asset.report.outputName,
names,
);
const bytes = new Uint8Array(await asset.blob.arrayBuffer());
total += bytes.byteLength;
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
@@ -80,7 +144,11 @@ export async function createBatchArchive(
entries[`images/${name}`] = bytes;
}
const report = encodeText(
serializeReport(createBatchReport(files, assets, generatedAt)),
serializeReport(
reportProfile === "safe-share"
? createSafeShareReport(files, assets, generatedAt)
: createBatchReport(files, assets, generatedAt),
),
);
total += report.byteLength;
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
@@ -104,6 +172,32 @@ export async function createBatchArchive(
return new Blob([ownedArchive], { type: "application/zip" });
}
function summarizeFindings(
findings: readonly MetadataFinding[],
): SafeShareFindingSummary {
const byRisk: Record<FindingRisk, number> = {
sensitive: 0,
context: 0,
technical: 0,
};
const byCategory: Partial<Record<FindingCategory, number>> = {};
for (const finding of findings) {
byRisk[finding.risk] += 1;
byCategory[finding.category] = (byCategory[finding.category] ?? 0) + 1;
}
return { total: findings.length, byRisk, byCategory };
}
function genericNameForAsset(asset: SanitizedAsset, index: number): string {
const extension =
asset.report.outputType === "image/jpeg"
? "jpg"
: asset.report.outputType === "image/webp"
? "webp"
: "png";
return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`;
}
function uniqueName(input: string, used: Set<string>): string {
const safe = sanitizeDownloadFilename(input, "image.clean");
if (!used.has(safe)) {
+1
View File
@@ -2,6 +2,7 @@ export * from "./archive";
export * from "./detect";
export * from "./limits";
export * from "./model";
export * from "./policy";
export * from "./sanitize";
export * from "./scan-client";
export * from "./scanner";
+49 -1
View File
@@ -131,12 +131,60 @@ export interface SanitizedAsset {
export interface BatchReport {
schemaVersion: 1;
generatedAt: string;
application: { name: "Privacy Tools"; version: "0.1.0" };
application: {
name: "Privacy Tools";
version: typeof import("../version").APP_VERSION;
};
files: ImageScanResult[];
sanitizations: SanitizationReport[];
warnings: string[];
}
export interface SafeShareFindingSummary {
total: number;
byRisk: Record<FindingRisk, number>;
byCategory: Partial<Record<FindingCategory, number>>;
}
export interface SafeShareReport {
schemaVersion: 1;
profile: "safe-share";
generatedAt: string;
application: {
name: "Privacy Tools";
version: typeof import("../version").APP_VERSION;
};
files: Array<{
fileId: string;
detectedKind: DetectedKind;
detectedType: string;
typeMatch: InventoryIdentity["typeMatch"];
dimensions?: { width: number; height: number };
animated: boolean;
multiImage: boolean;
coverage: {
projectScanner: ParserCoverage["projectScanner"];
secondaryScanner: ParserCoverage["secondaryScanner"];
noteCount: number;
};
findings: SafeShareFindingSummary;
warningCount: number;
}>;
sanitizations: Array<{
fileId: string;
outputName: string;
outputType: string;
status: SanitizationStatus;
orientationNormalized: boolean;
removed: SafeShareFindingSummary;
preserved: SafeShareFindingSummary;
generated: SafeShareFindingSummary;
unsupported: string[];
incomplete: string[];
}>;
warnings: string[];
}
export interface PrivacyLimits {
maxFiles: number;
maxFileBytes: number;
+266
View File
@@ -0,0 +1,266 @@
import type {
FindingCategory,
ImageScanResult,
MetadataFinding,
SanitizedAsset,
} from "./model";
export type PolicyAction = "remove" | "preserve" | "review";
export interface SelectiveRemovalPolicy {
readonly id: "safe-share" | "location-only" | "archive-review";
readonly name: string;
readonly description: string;
readonly actions: Readonly<Record<FindingCategory, PolicyAction>>;
}
export interface PolicyFileEvidence {
readonly fileId: string;
readonly detectedKind: ImageScanResult["identity"]["detectedKind"];
readonly coverage: ImageScanResult["coverage"];
readonly availableOperation:
| "verified-reencode"
| "reencode-available"
| "inspect-only"
| "policy-not-executable";
readonly decision: "verified" | "review" | "blocked" | "no-target-findings";
readonly findings: Readonly<Record<PolicyAction, number>>;
readonly requiredRemovals: readonly string[];
readonly preservedAgainstPolicy: readonly string[];
readonly removedAgainstPolicy: readonly string[];
readonly unresolved: readonly string[];
}
export interface PolicyEvidence {
readonly schemaVersion: 1;
readonly profile: "selective-removal-evidence";
readonly generatedAt: string;
readonly policy: SelectiveRemovalPolicy;
readonly files: readonly PolicyFileEvidence[];
readonly limitations: readonly string[];
}
const categories: readonly FindingCategory[] = [
"location",
"identity",
"timestamp",
"device",
"software",
"document-id",
"comment",
"thumbnail",
"colour-profile",
"provenance",
"technical",
"unknown",
];
function actions(
remove: readonly FindingCategory[],
preserve: readonly FindingCategory[] = [],
): Readonly<Record<FindingCategory, PolicyAction>> {
const removed = new Set(remove);
const preserved = new Set(preserve);
return Object.freeze(
Object.fromEntries(
categories.map((category) => [
category,
removed.has(category)
? "remove"
: preserved.has(category)
? "preserve"
: "review",
]),
) as unknown as Record<FindingCategory, PolicyAction>,
);
}
export const SELECTIVE_REMOVAL_POLICIES: readonly SelectiveRemovalPolicy[] =
Object.freeze([
Object.freeze({
id: "safe-share",
name: "Strict safe-share",
description:
"Remove sensitive, contextual, preview and provenance metadata; independently review technical and colour-profile output.",
actions: actions([
"location",
"identity",
"timestamp",
"device",
"software",
"document-id",
"comment",
"thumbnail",
"provenance",
"unknown",
]),
}),
Object.freeze({
id: "location-only",
name: "Location-only request",
description:
"Request removal of location metadata while preserving other metadata. The current pixel re-encoder cannot promise this selective preservation.",
actions: actions(
["location"],
[
"identity",
"timestamp",
"device",
"software",
"document-id",
"comment",
"thumbnail",
"colour-profile",
"provenance",
"technical",
],
),
}),
Object.freeze({
id: "archive-review",
name: "Archival review",
description:
"Preserve provenance, colour and technical context while removing direct location and identity clues; all other categories require review.",
actions: actions(
["location", "identity", "document-id"],
["colour-profile", "provenance", "technical"],
),
}),
]);
export function policyById(
id: SelectiveRemovalPolicy["id"],
): SelectiveRemovalPolicy {
const policy = SELECTIVE_REMOVAL_POLICIES.find((item) => item.id === id);
if (!policy) throw new TypeError(`Unknown privacy policy: ${id}`);
return policy;
}
function counts(
findings: readonly MetadataFinding[],
policy: SelectiveRemovalPolicy,
): Record<PolicyAction, number> {
const result: Record<PolicyAction, number> = {
remove: 0,
preserve: 0,
review: 0,
};
findings.forEach((finding) => {
result[policy.actions[finding.category]] += 1;
});
return result;
}
function signatures(findings: readonly MetadataFinding[]): string[] {
return findings.map(
(finding) => `${finding.category}: ${finding.source} / ${finding.label}`,
);
}
export function createPolicyEvidence(
files: readonly ImageScanResult[],
assets: readonly SanitizedAsset[],
policy: SelectiveRemovalPolicy,
generatedAt = new Date().toISOString(),
): PolicyEvidence {
if (files.length > 100 || assets.length > 100)
throw new RangeError(
"Policy evidence is limited to 100 files and outputs.",
);
const outputBySource = new Map(
assets.map((asset) => [asset.report.sourceId, asset.report]),
);
return Object.freeze({
schemaVersion: 1,
profile: "selective-removal-evidence",
generatedAt,
policy,
files: Object.freeze(
files.map((file, index): PolicyFileEvidence => {
const report = outputBySource.get(file.id);
const grouped = counts(file.findings, policy);
const required = file.findings.filter(
(finding) => policy.actions[finding.category] === "remove",
);
const mustPreserve = file.findings.filter(
(finding) => policy.actions[finding.category] === "preserve",
);
const preservedAgainstPolicy = report
? report.preserved.filter(
(finding) => policy.actions[finding.category] === "remove",
)
: [];
const removedAgainstPolicy = report
? report.removed.filter(
(finding) => policy.actions[finding.category] === "preserve",
)
: [];
const generatedAgainstPolicy = report
? report.generated.filter(
(finding) => policy.actions[finding.category] === "remove",
)
: [];
const unresolved: string[] = [];
if (file.coverage.projectScanner !== "complete")
unresolved.push("Project scanner coverage is not complete.");
if (file.coverage.secondaryScanner !== "complete")
unresolved.push("Secondary scanner coverage is not complete.");
if (!file.deepSupported)
unresolved.push(
"This format has inventory evidence only; metadata removal is unsupported.",
);
if (mustPreserve.length && file.cleanable)
unresolved.push(
"The pixel re-encoder cannot guarantee selective preservation of requested metadata.",
);
if (generatedAgainstPolicy.length)
unresolved.push(
`Output generated ${generatedAgainstPolicy.length} finding(s) marked for removal.`,
);
if (report?.incomplete.length) unresolved.push(...report.incomplete);
const violations =
preservedAgainstPolicy.length +
removedAgainstPolicy.length +
generatedAgainstPolicy.length;
const availableOperation: PolicyFileEvidence["availableOperation"] =
report && violations === 0
? "verified-reencode"
: required.length === 0
? "inspect-only"
: !file.cleanable
? "inspect-only"
: mustPreserve.length
? "policy-not-executable"
: "reencode-available";
const decision: PolicyFileEvidence["decision"] =
report && violations === 0 && unresolved.length === 0
? "verified"
: required.length === 0 && unresolved.length === 0
? "no-target-findings"
: violations > 0 || (required.length > 0 && !file.cleanable)
? "blocked"
: "review";
return Object.freeze({
fileId: `file-${String(index + 1).padStart(3, "0")}`,
detectedKind: file.identity.detectedKind,
coverage: file.coverage,
availableOperation,
decision,
findings: grouped,
requiredRemovals: Object.freeze(signatures(required)),
preservedAgainstPolicy: Object.freeze(
signatures(preservedAgainstPolicy),
),
removedAgainstPolicy: Object.freeze(signatures(removedAgainstPolicy)),
unresolved: Object.freeze(unresolved),
});
}),
),
limitations: Object.freeze([
"Policy evidence summarizes bounded metadata scanners; it is not an anonymity guarantee.",
"Only supported static JPEG, PNG and WebP inputs can be pixel re-encoded. Other formats remain inspect-only.",
"Pixel re-encoding is an all-container rewrite, not a surgical metadata editor, so preservation policies can be non-executable.",
"Visible content, sidecars, watermarks, steganography and remote copies are outside this evidence.",
]),
});
}
+29 -4
View File
@@ -16,6 +16,8 @@ export interface SanitizeOptions {
jpegQuality?: number;
webpQuality?: number;
signal?: AbortSignal;
/** A caller-selected, non-identifying output name. The detected extension is enforced. */
outputName?: string;
}
export async function sanitizeStaticImage(
@@ -72,10 +74,9 @@ export async function sanitizeStaticImage(
);
throwIfAborted(options.signal);
const outputBytes = await blob.arrayBuffer();
const outputName = cleanOutputName(
source.safeName,
source.identity.detectedKind,
);
const outputName = options.outputName
? enforceOutputExtension(options.outputName, source.identity.detectedKind)
: cleanOutputName(source.safeName, source.identity.detectedKind);
const outputScan = await scanImageBytes({
id: `${source.id}-clean`,
name: outputName,
@@ -393,6 +394,30 @@ function cleanOutputName(
return sanitizeDownloadFilename(`${withoutExtension}.clean.${extension}`);
}
export function genericOutputName(
index: number,
kind: ImageScanResult["identity"]["detectedKind"],
): string {
if (!Number.isSafeInteger(index) || index < 0)
throw new RangeError(
"Generic output index must be a non-negative integer.",
);
const extension = kind === "jpeg" ? "jpg" : kind;
if (!new Set(["jpg", "png", "webp"]).has(extension))
throw new TypeError(`No generic clean-copy name is available for ${kind}.`);
return `image-${String(index + 1).padStart(3, "0")}.clean.${extension}`;
}
function enforceOutputExtension(
input: string,
kind: ImageScanResult["identity"]["detectedKind"],
): string {
const extension = kind === "jpeg" ? "jpg" : kind;
const safe = sanitizeDownloadFilename(input, `image.clean.${extension}`);
const stem = safe.replace(/\.[^.]*$/u, "") || "image.clean";
return `${stem}.${extension}`;
}
function findingSignature(finding: MetadataFinding): string {
const label = finding.label
.toLowerCase()