+101
-7
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user