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
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Added reusable selective-removal profiles and pseudonymized per-file policy evidence across supported and inspect-only formats.
- Distinguished verified full re-encoding, available re-encoding, inspect-only input, and non-executable selective-preservation requests; added Toolbox I/O/capability metadata.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Added byte-signature inventory, claimed-vs-detected media types, safe names, - Added byte-signature inventory, claimed-vs-detected media types, safe names,
+19 -4
View File
@@ -22,9 +22,24 @@ lookups, telemetry, or runtime network calls. A source file is never modified.
removed, preserved, generated, unsupported, or incompletely checked, and removed, preserved, generated, unsupported, or incompletely checked, and
includes source/output SHA-256 hashes and a bounded decoded-pixel sample includes source/output SHA-256 hashes and a bounded decoded-pixel sample
comparison. comparison.
5. Download an individual re-encoded image, a JSON report, or a ZIP containing 5. Download generic sequentially named outputs, a pseudonymized safe-share
re-encoded images and the report. The report itself may be sensitive because report, or a ZIP containing both. The separate detailed report preserves
it contains source filenames and metadata values. source filenames and metadata values and may itself be sensitive.
6. Apply a reusable selective-removal policy to the scan evidence. The policy
matrix records remove, preserve, and review categories for every input,
distinguishes verified full re-encoding from inspect-only formats, and can
be exported without source names or raw metadata values.
The safe-share report deliberately omits source names, timestamps, hashes,
exact byte sizes, metadata values and offsets. It retains scanner coverage,
format/dimension facts and categorized counts; unusual combinations can still
be identifying, so the report remains subject to human review.
Policy evidence is a decision aid, not a surgical metadata editor. Current
JPEG/PNG/WebP output is a complete decoded-pixel re-encode; it does not preserve
selected source metadata. For TIFF, GIF, HEIF-family, JPEG XL, PDF, archives,
Office and unknown inputs the policy honestly reports `inspect-only` or
`policy-not-executable` rather than promising a cleaned file.
## Format support ## Format support
@@ -107,7 +122,7 @@ npm run release:artifact
This checks the manifest, types, lint, formatting, unit fixtures, production This checks the manifest, types, lint, formatting, unit fixtures, production
build, Toolbox contract, and Chromium/Firefox workflows, then creates the build, Toolbox contract, and Chromium/Firefox workflows, then creates the
deterministic `release/privacy-tools-0.1.0.zip` plus its SHA-256 sidecar. The deterministic `release/privacy-tools-0.2.0.zip` plus its SHA-256 sidecar. The
archive contains the static application, project documents, and exact runtime archive contains the static application, project documents, and exact runtime
dependency licence texts. dependency licence texts.
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source # Corresponding source
The corresponding source for Privacy Tools 0.1.0 is published at: The corresponding source for Privacy Tools 0.2.0 is published at:
https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.2.0
Build with Node.js 22 and npm 11: Build with Node.js 22 and npm 11:
+3 -3
View File
@@ -11,9 +11,9 @@ Material runtime components include:
| -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- | | -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; <https://github.com/mattiasw/ExifReader/tree/v4.44.0> | | ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; <https://github.com/mattiasw/ExifReader/tree/v4.44.0> |
| fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; <https://github.com/101arrowz/fflate/tree/v0.8.2> | | fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; <https://github.com/101arrowz/fflate/tree/v0.8.2> |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | Toolbox manifest contract |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | Shared application shell |
| React / React DOM | 19.2.8 | MIT | User interface | | React / React DOM | 19.2.8 | MIT | User interface |
ExifReader is used unmodified. Its MPL-2.0 covered source remains available at ExifReader is used unmodified. Its MPL-2.0 covered source remains available at
+8 -2
View File
@@ -27,8 +27,14 @@ arrays, and plain objects. No parser returns HTML or a live third-party object.
decoded sample. `buildSanitizationReport` assigns verified/warning/failed decoded sample. `buildSanitizationReport` assigns verified/warning/failed
based on explicit coverage and output findings. based on explicit coverage and output findings.
6. `archive` serializes bounded deterministic JSON and creates stored ZIP 6. `archive` serializes bounded deterministic JSON and creates stored ZIP
entries from application-generated `images/` paths and sanitized unique entries from application-generated `images/` paths. Safe-share archives use
names. generic sequential image names and a reduced report; detailed reports remain
an explicit, separate download for local auditing.
7. `policy` applies frozen, versioned category rules to normalized findings. It
creates pseudonymous per-file evidence across every inventoried format and
separately reports whether the requested removal can be executed by the
existing verified re-encode path. New policy profiles can be added without
changing a container parser.
Pixel decoding and Canvas encoding currently run on the main browser context Pixel decoding and Canvas encoding currently run on the main browser context
because portable cross-browser image encoder support is there; metadata parsing because portable cross-browser image encoder support is there; metadata parsing
+14 -2
View File
@@ -55,5 +55,17 @@ filesystem metadata, clipboard/history records, application caches, backed-up
originals, or cloud/recipient copies. Review the visible output, destination, originals, or cloud/recipient copies. Review the visible output, destination,
report, and surrounding files yourself. report, and surrounding files yourself.
The JSON report is sensitive by design: it can include original filenames, The detailed JSON report is sensitive by design: it can include original
hashes, timestamps, and metadata values. Share or retain it only intentionally. filenames, hashes, timestamps, metadata values, offsets, and parser notes. Share
or retain it only intentionally. The separate safe-share report replaces file
identity with sequential pseudonyms and omits those source identifiers and raw
values. It retains formats, dimensions, coverage states, note counts, and
finding-category totals so it is useful for review without claiming anonymity.
Safe-share ZIPs likewise use generic sequential output names.
Selective-removal policy evidence follows the same pseudonymization boundary.
It can show which finding categories a policy would remove, preserve, or send
for review, but it never upgrades inspection coverage and never calls a format
safe when no verified output exists. A requested “location-only” policy is
therefore non-executable with the current all-metadata pixel re-encode; users
receive evidence rather than a misleading partially preserved output.
+19 -21
View File
@@ -1,24 +1,24 @@
{ {
"name": "privacy-tools", "name": "privacy-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "privacy-tools", "name": "privacy-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"exifreader": "4.44.0", "exifreader": "4.44.0",
"fflate": "0.8.2", "fflate": "0.8.2",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -44,23 +44,22 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"license": "Apache-2.0", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"engines": { "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"node": ">=20" "license": "Apache-2.0"
}
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
"license": "GPL-3.0-or-later" "license": "GPL-3.0-or-later"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -68,17 +67,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "privacy-tools", "name": "privacy-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect and remove shareable-file metadata locally in the browser.", "description": "Inspect and remove shareable-file metadata locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,16 +39,16 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"exifreader": "4.44.0", "exifreader": "4.44.0",
"fflate": "0.8.2", "fflate": "0.8.2",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, {
{ name: "firefox", use: { ...devices["Desktop Firefox"] } }, name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
], ],
}); });
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Added reusable selective-removal profiles and pseudonymized per-file policy evidence across supported and inspect-only formats.
- Distinguished verified full re-encoding, available re-encoding, inspect-only input, and non-executable selective-preservation requests; added Toolbox I/O/capability metadata.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Added byte-signature inventory, claimed-vs-detected media types, safe names, - Added byte-signature inventory, claimed-vs-detected media types, safe names,
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
============================================================================== ==============================================================================
@add-ideas/toolbox-helpers@0.1.0 @add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
============================================================================== ==============================================================================
@add-ideas/toolbox-shell-react@0.2.3 @add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+19 -4
View File
@@ -22,9 +22,24 @@ lookups, telemetry, or runtime network calls. A source file is never modified.
removed, preserved, generated, unsupported, or incompletely checked, and removed, preserved, generated, unsupported, or incompletely checked, and
includes source/output SHA-256 hashes and a bounded decoded-pixel sample includes source/output SHA-256 hashes and a bounded decoded-pixel sample
comparison. comparison.
5. Download an individual re-encoded image, a JSON report, or a ZIP containing 5. Download generic sequentially named outputs, a pseudonymized safe-share
re-encoded images and the report. The report itself may be sensitive because report, or a ZIP containing both. The separate detailed report preserves
it contains source filenames and metadata values. source filenames and metadata values and may itself be sensitive.
6. Apply a reusable selective-removal policy to the scan evidence. The policy
matrix records remove, preserve, and review categories for every input,
distinguishes verified full re-encoding from inspect-only formats, and can
be exported without source names or raw metadata values.
The safe-share report deliberately omits source names, timestamps, hashes,
exact byte sizes, metadata values and offsets. It retains scanner coverage,
format/dimension facts and categorized counts; unusual combinations can still
be identifying, so the report remains subject to human review.
Policy evidence is a decision aid, not a surgical metadata editor. Current
JPEG/PNG/WebP output is a complete decoded-pixel re-encode; it does not preserve
selected source metadata. For TIFF, GIF, HEIF-family, JPEG XL, PDF, archives,
Office and unknown inputs the policy honestly reports `inspect-only` or
`policy-not-executable` rather than promising a cleaned file.
## Format support ## Format support
@@ -107,7 +122,7 @@ npm run release:artifact
This checks the manifest, types, lint, formatting, unit fixtures, production This checks the manifest, types, lint, formatting, unit fixtures, production
build, Toolbox contract, and Chromium/Firefox workflows, then creates the build, Toolbox contract, and Chromium/Firefox workflows, then creates the
deterministic `release/privacy-tools-0.1.0.zip` plus its SHA-256 sidecar. The deterministic `release/privacy-tools-0.2.0.zip` plus its SHA-256 sidecar. The
archive contains the static application, project documents, and exact runtime archive contains the static application, project documents, and exact runtime
dependency licence texts. dependency licence texts.
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source # Corresponding source
The corresponding source for Privacy Tools 0.1.0 is published at: The corresponding source for Privacy Tools 0.2.0 is published at:
https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/privacy-tools/src/tag/v0.2.0
Build with Node.js 22 and npm 11: Build with Node.js 22 and npm 11:
+3 -3
View File
@@ -11,9 +11,9 @@ Material runtime components include:
| -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- | | -------------------------------- | ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; <https://github.com/mattiasw/ExifReader/tree/v4.44.0> | | ExifReader | 4.44.0 | MPL-2.0 | Secondary metadata parser; <https://github.com/mattiasw/ExifReader/tree/v4.44.0> |
| fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; <https://github.com/101arrowz/fflate/tree/v0.8.2> | | fflate | 0.8.2 | MIT | Bounded PNG metadata inflation and ZIP creation; <https://github.com/101arrowz/fflate/tree/v0.8.2> |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later | Hashing, safe names, download and deterministic JSON primitives |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 | Toolbox manifest contract |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | Shared application shell |
| React / React DOM | 19.2.8 | MIT | User interface | | React / React DOM | 19.2.8 | MIT | User interface |
ExifReader is used unmodified. Its MPL-2.0 covered source remains available at ExifReader is used unmodified. Its MPL-2.0 covered source remains available at
+8 -2
View File
@@ -27,8 +27,14 @@ arrays, and plain objects. No parser returns HTML or a live third-party object.
decoded sample. `buildSanitizationReport` assigns verified/warning/failed decoded sample. `buildSanitizationReport` assigns verified/warning/failed
based on explicit coverage and output findings. based on explicit coverage and output findings.
6. `archive` serializes bounded deterministic JSON and creates stored ZIP 6. `archive` serializes bounded deterministic JSON and creates stored ZIP
entries from application-generated `images/` paths and sanitized unique entries from application-generated `images/` paths. Safe-share archives use
names. generic sequential image names and a reduced report; detailed reports remain
an explicit, separate download for local auditing.
7. `policy` applies frozen, versioned category rules to normalized findings. It
creates pseudonymous per-file evidence across every inventoried format and
separately reports whether the requested removal can be executed by the
existing verified re-encode path. New policy profiles can be added without
changing a container parser.
Pixel decoding and Canvas encoding currently run on the main browser context Pixel decoding and Canvas encoding currently run on the main browser context
because portable cross-browser image encoder support is there; metadata parsing because portable cross-browser image encoder support is there; metadata parsing
+14 -2
View File
@@ -55,5 +55,17 @@ filesystem metadata, clipboard/history records, application caches, backed-up
originals, or cloud/recipient copies. Review the visible output, destination, originals, or cloud/recipient copies. Review the visible output, destination,
report, and surrounding files yourself. report, and surrounding files yourself.
The JSON report is sensitive by design: it can include original filenames, The detailed JSON report is sensitive by design: it can include original
hashes, timestamps, and metadata values. Share or retain it only intentionally. filenames, hashes, timestamps, metadata values, offsets, and parser notes. Share
or retain it only intentionally. The separate safe-share report replaces file
identity with sequential pseudonyms and omits those source identifiers and raw
values. It retains formats, dimensions, coverage states, note counts, and
finding-category totals so it is useful for review without claiming anonymity.
Safe-share ZIPs likewise use generic sequential output names.
Selective-removal policy evidence follows the same pseudonymization boundary.
It can show which finding categories a policy would remove, preserve, or send
for review, but it never upgrades inspection coverage and never calls a format
safe when no verified output exists. A requested “location-only” policy is
therefore non-executable with the current all-metadata pixel re-encode; users
receive evidence rather than a misleading partially preserved output.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "privacy-tools-shell-"; const CACHE_PREFIX = "privacy-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0"; const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+51 -3
View File
@@ -3,12 +3,20 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.privacy-tools", "id": "de.add-ideas.privacy-tools",
"name": "Privacy Tools", "name": "Privacy Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect and remove shareable-file metadata locally in the browser.", "description": "Inspect metadata and create policy-evidenced sharing copies locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["privacy", "files", "security"], "categories": ["privacy", "files", "security"],
"tags": ["metadata", "exif", "privacy", "sanitize", "share"], "tags": [
"metadata",
"exif",
"privacy",
"sanitize",
"policy",
"evidence",
"share"
],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +29,46 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "*/*",
"extensions": [],
"label": "Files for bounded metadata inventory"
}
],
"produces": [
{
"mediaType": "image/jpeg",
"extensions": [".jpg", ".jpeg"],
"label": "Verified re-encoded JPEG"
},
{
"mediaType": "image/png",
"extensions": [".png"],
"label": "Verified re-encoded PNG"
},
{
"mediaType": "image/webp",
"extensions": [".webp"],
"label": "Verified re-encoded WebP"
},
{
"mediaType": "application/json",
"extensions": [".json"],
"label": "Inspection and policy evidence"
},
{
"mediaType": "application/zip",
"extensions": [".zip"],
"label": "Safe-share bundle"
}
]
},
"capabilities": {
"required": ["canvas-2d", "web-crypto", "workers"],
"optional": ["create-image-bitmap"]
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+151 -11
View File
@@ -1,14 +1,20 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers"; import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { import {
createBatchArchive, createBatchArchive,
createBatchReport, createBatchReport,
createPolicyEvidence,
createSafeShareReport,
genericOutputName,
policyById,
SELECTIVE_REMOVAL_POLICIES,
sanitizeStaticImage, sanitizeStaticImage,
scanFilesInWorker, scanFilesInWorker,
serializeReport, serializeReport,
type FindingCategory, type FindingCategory,
type ImageScanResult, type ImageScanResult,
type SelectiveRemovalPolicy,
type SanitizedAsset, type SanitizedAsset,
} from "../privacy"; } from "../privacy";
@@ -50,6 +56,9 @@ export function Workbench() {
const [progress, setProgress] = useState<Progress | null>(null); const [progress, setProgress] = useState<Progress | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [genericNames, setGenericNames] = useState(true);
const [policyId, setPolicyId] =
useState<SelectiveRemovalPolicy["id"]>("safe-share");
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const cleanable = useMemo( const cleanable = useMemo(
@@ -60,6 +69,16 @@ export function Workbench() {
() => records.flatMap((record) => (record.asset ? [record.asset] : [])), () => records.flatMap((record) => (record.asset ? [record.asset] : [])),
[records], [records],
); );
const policy = useMemo(() => policyById(policyId), [policyId]);
const policyEvidence = useMemo(
() =>
createPolicyEvidence(
records.map((record) => record.scan),
assets,
policy,
),
[assets, policy, records],
);
useEffect( useEffect(
() => () => { () => () => {
@@ -119,6 +138,12 @@ export function Workbench() {
try { try {
const asset = await sanitizeStaticImage(record.file, record.scan, { const asset = await sanitizeStaticImage(record.file, record.scan, {
signal: controller.signal, signal: controller.signal,
outputName: genericNames
? genericOutputName(
records.findIndex((item) => item.scan.id === id),
record.scan.identity.detectedKind,
)
: undefined,
}); });
setRecords((current) => setRecords((current) =>
current.map((item) => current.map((item) =>
@@ -173,6 +198,12 @@ export function Workbench() {
try { try {
const asset = await sanitizeStaticImage(record.file, record.scan, { const asset = await sanitizeStaticImage(record.file, record.scan, {
signal: controller.signal, signal: controller.signal,
outputName: genericNames
? genericOutputName(
records.findIndex((item) => item.scan.id === record.scan.id),
record.scan.identity.detectedKind,
)
: undefined,
}); });
setRecords((current) => setRecords((current) =>
current.map((item) => current.map((item) =>
@@ -222,6 +253,36 @@ export function Workbench() {
} }
}; };
const downloadSafeReport = () => {
setError("");
try {
const report = createSafeShareReport(
records.map((record) => record.scan),
assets,
);
triggerBlobDownload(
new Blob([serializeReport(report)], { type: "application/json" }),
"privacy-tools-safe-share-report.json",
);
} catch (caught) {
setError(errorMessage(caught));
}
};
const downloadPolicyEvidence = () => {
setError("");
try {
triggerBlobDownload(
new Blob([serializeReport(policyEvidence)], {
type: "application/json",
}),
`privacy-tools-${policy.id}-policy-evidence.json`,
);
} catch (caught) {
setError(errorMessage(caught));
}
};
const downloadArchive = async () => { const downloadArchive = async () => {
if (assets.length === 0) return; if (assets.length === 0) return;
setBusy("archive"); setBusy("archive");
@@ -230,6 +291,8 @@ export function Workbench() {
const blob = await createBatchArchive( const blob = await createBatchArchive(
records.map((record) => record.scan), records.map((record) => record.scan),
assets, assets,
undefined,
"safe-share",
); );
triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip"); triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip");
} catch (caught) { } catch (caught) {
@@ -343,6 +406,15 @@ export function Workbench() {
</p> </p>
</div> </div>
<div className="button-row"> <div className="button-row">
<label className="privacy-option">
<input
type="checkbox"
checked={genericNames}
disabled={busy !== null || assets.length > 0}
onChange={(event) => setGenericNames(event.target.checked)}
/>
Use generic sequential output names
</label>
<button <button
type="button" type="button"
className="primary-button" className="primary-button"
@@ -446,17 +518,25 @@ export function Workbench() {
<p className="eyebrow">Step 3</p> <p className="eyebrow">Step 3</p>
<h2 id="export-heading">Export deliberately</h2> <h2 id="export-heading">Export deliberately</h2>
<p className="muted"> <p className="muted">
The JSON report includes original filenames and detected Safe-share reports omit names, hashes, exact sizes, values and
values and may itself be sensitive. offsets. Detailed reports retain that evidence and may
themselves be sensitive.
</p> </p>
</div> </div>
<div className="button-row"> <div className="button-row">
<button
type="button"
onClick={downloadSafeReport}
disabled={busy !== null}
>
Download safe-share report
</button>
<button <button
type="button" type="button"
onClick={downloadReport} onClick={downloadReport}
disabled={busy !== null} disabled={busy !== null}
> >
Download JSON report Download detailed report
</button> </button>
<button <button
type="button" type="button"
@@ -464,12 +544,78 @@ export function Workbench() {
onClick={() => void downloadArchive()} onClick={() => void downloadArchive()}
disabled={busy !== null || assets.length === 0} disabled={busy !== null || assets.length === 0}
> >
Download {assets.length} re-encoded{" "} Download safe-share ZIP with {assets.length} re-encoded{" "}
{assets.length === 1 ? "image" : "images"} + report {assets.length === 1 ? "image" : "images"} + report
</button> </button>
</div> </div>
</div> </div>
</section> </section>
<section className="panel" aria-labelledby="policy-heading">
<div className="panel-heading action-heading">
<div>
<p className="eyebrow">Reusable policy evidence</p>
<h2 id="policy-heading">Selective-removal policy</h2>
<p className="muted">{policy.description}</p>
</div>
<div className="button-row">
<label className="policy-select">
<span>Policy profile</span>
<select
value={policyId}
onChange={(event) =>
setPolicyId(
event.target.value as SelectiveRemovalPolicy["id"],
)
}
>
{SELECTIVE_REMOVAL_POLICIES.map((item) => (
<option value={item.id} key={item.id}>
{item.name}
</option>
))}
</select>
</label>
<button type="button" onClick={downloadPolicyEvidence}>
Download policy evidence
</button>
</div>
</div>
<div className="inventory-table-wrap">
<table className="inventory-table">
<thead>
<tr>
<th>Evidence ID</th>
<th>Format</th>
<th>Decision</th>
<th>Operation</th>
<th>Remove / preserve / review</th>
</tr>
</thead>
<tbody>
{policyEvidence.files.map((file) => (
<tr key={file.fileId}>
<td>{file.fileId}</td>
<td>{file.detectedKind}</td>
<td>
<StatusBadge status={file.decision} />
</td>
<td>{file.availableOperation}</td>
<td>
{file.findings.remove} / {file.findings.preserve} /{" "}
{file.findings.review}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="boundary-inline">
A policy can require preservation that the pixel re-encoder cannot
guarantee. In that case the evidence says policy-not-executable
instead of silently discarding metadata. Unsupported formats
remain inspect-only.
</p>
</section>
</> </>
) : null} ) : null}
@@ -672,12 +818,6 @@ function groupFindings(
return [...groups.entries()]; return [...groups.entries()];
} }
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 ** 2).toFixed(1)} MiB`;
}
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "The operation failed."; return error instanceof Error ? error.message : "The operation failed.";
} }
+101 -7
View File
@@ -13,9 +13,13 @@ import {
} from "./limits"; } from "./limits";
import type { import type {
BatchReport, BatchReport,
FindingCategory,
FindingRisk,
ImageScanResult, ImageScanResult,
MetadataFinding,
SafeShareFindingSummary,
SafeShareReport,
SanitizedAsset, SanitizedAsset,
SanitizationReport,
} from "./model"; } from "./model";
export function createBatchReport( export function createBatchReport(
@@ -36,9 +40,63 @@ export function createBatchReport(
}; };
} }
export function serializeReport( export function createSafeShareReport(
report: BatchReport | SanitizationReport, files: readonly ImageScanResult[],
): string { 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, { return stableStringify(report, 2, {
maxDepth: 64, maxDepth: 64,
maxNodes: 500_000, maxNodes: 500_000,
@@ -50,6 +108,7 @@ export async function createBatchArchive(
files: readonly ImageScanResult[], files: readonly ImageScanResult[],
assets: readonly SanitizedAsset[], assets: readonly SanitizedAsset[],
generatedAt = new Date().toISOString(), generatedAt = new Date().toISOString(),
reportProfile: "detailed" | "safe-share" = "detailed",
): Promise<Blob> { ): Promise<Blob> {
assertLimit( assertLimit(
files.length, files.length,
@@ -67,8 +126,13 @@ export async function createBatchArchive(
>; >;
const names = new Set<string>(); const names = new Set<string>();
let total = 0; let total = 0;
for (const asset of assets) { for (const [index, asset] of assets.entries()) {
const name = uniqueName(asset.report.outputName, names); const name = uniqueName(
reportProfile === "safe-share"
? genericNameForAsset(asset, index)
: asset.report.outputName,
names,
);
const bytes = new Uint8Array(await asset.blob.arrayBuffer()); const bytes = new Uint8Array(await asset.blob.arrayBuffer());
total += bytes.byteLength; total += bytes.byteLength;
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes) if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
@@ -80,7 +144,11 @@ export async function createBatchArchive(
entries[`images/${name}`] = bytes; entries[`images/${name}`] = bytes;
} }
const report = encodeText( const report = encodeText(
serializeReport(createBatchReport(files, assets, generatedAt)), serializeReport(
reportProfile === "safe-share"
? createSafeShareReport(files, assets, generatedAt)
: createBatchReport(files, assets, generatedAt),
),
); );
total += report.byteLength; total += report.byteLength;
if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes) if (total > DEFAULT_PRIVACY_LIMITS.maxZipBytes)
@@ -104,6 +172,32 @@ export async function createBatchArchive(
return new Blob([ownedArchive], { type: "application/zip" }); 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 { function uniqueName(input: string, used: Set<string>): string {
const safe = sanitizeDownloadFilename(input, "image.clean"); const safe = sanitizeDownloadFilename(input, "image.clean");
if (!used.has(safe)) { if (!used.has(safe)) {
+1
View File
@@ -2,6 +2,7 @@ export * from "./archive";
export * from "./detect"; export * from "./detect";
export * from "./limits"; export * from "./limits";
export * from "./model"; export * from "./model";
export * from "./policy";
export * from "./sanitize"; export * from "./sanitize";
export * from "./scan-client"; export * from "./scan-client";
export * from "./scanner"; export * from "./scanner";
+49 -1
View File
@@ -131,12 +131,60 @@ export interface SanitizedAsset {
export interface BatchReport { export interface BatchReport {
schemaVersion: 1; schemaVersion: 1;
generatedAt: string; generatedAt: string;
application: { name: "Privacy Tools"; version: "0.1.0" }; application: {
name: "Privacy Tools";
version: typeof import("../version").APP_VERSION;
};
files: ImageScanResult[]; files: ImageScanResult[];
sanitizations: SanitizationReport[]; sanitizations: SanitizationReport[];
warnings: string[]; 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 { export interface PrivacyLimits {
maxFiles: number; maxFiles: number;
maxFileBytes: 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; jpegQuality?: number;
webpQuality?: number; webpQuality?: number;
signal?: AbortSignal; signal?: AbortSignal;
/** A caller-selected, non-identifying output name. The detected extension is enforced. */
outputName?: string;
} }
export async function sanitizeStaticImage( export async function sanitizeStaticImage(
@@ -72,10 +74,9 @@ export async function sanitizeStaticImage(
); );
throwIfAborted(options.signal); throwIfAborted(options.signal);
const outputBytes = await blob.arrayBuffer(); const outputBytes = await blob.arrayBuffer();
const outputName = cleanOutputName( const outputName = options.outputName
source.safeName, ? enforceOutputExtension(options.outputName, source.identity.detectedKind)
source.identity.detectedKind, : cleanOutputName(source.safeName, source.identity.detectedKind);
);
const outputScan = await scanImageBytes({ const outputScan = await scanImageBytes({
id: `${source.id}-clean`, id: `${source.id}-clean`,
name: outputName, name: outputName,
@@ -393,6 +394,30 @@ function cleanOutputName(
return sanitizeDownloadFilename(`${withoutExtension}.clean.${extension}`); 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 { function findingSignature(finding: MetadataFinding): string {
const label = finding.label const label = finding.label
.toLowerCase() .toLowerCase()
+13
View File
@@ -317,6 +317,19 @@ button:disabled {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
.privacy-option {
display: inline-flex;
gap: 0.45rem;
align-items: center;
min-height: 2.55rem;
color: var(--toolbox-muted);
font-size: 0.8rem;
font-weight: 650;
}
.privacy-option input {
width: auto;
min-height: auto;
}
.inventory-table-wrap { .inventory-table-wrap {
overflow: auto; overflow: auto;
+51 -3
View File
@@ -3,12 +3,20 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.privacy-tools", "id": "de.add-ideas.privacy-tools",
"name": "Privacy Tools", "name": "Privacy Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect and remove shareable-file metadata locally in the browser.", "description": "Inspect metadata and create policy-evidenced sharing copies locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["privacy", "files", "security"], "categories": ["privacy", "files", "security"],
"tags": ["metadata", "exif", "privacy", "sanitize", "share"], "tags": [
"metadata",
"exif",
"privacy",
"sanitize",
"policy",
"evidence",
"share"
],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +29,46 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "*/*",
"extensions": [],
"label": "Files for bounded metadata inventory"
}
],
"produces": [
{
"mediaType": "image/jpeg",
"extensions": [".jpg", ".jpeg"],
"label": "Verified re-encoded JPEG"
},
{
"mediaType": "image/png",
"extensions": [".png"],
"label": "Verified re-encoded PNG"
},
{
"mediaType": "image/webp",
"extensions": [".webp"],
"label": "Verified re-encoded WebP"
},
{
"mediaType": "application/json",
"extensions": [".json"],
"label": "Inspection and policy evidence"
},
{
"mediaType": "application/zip",
"extensions": [".zip"],
"label": "Safe-share bundle"
}
]
},
"capabilities": {
"required": ["canvas-2d", "web-crypto", "workers"],
"optional": ["create-image-bitmap"]
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+23 -7
View File
@@ -42,7 +42,7 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/privacy/toolbox-app.json"); const manifest = await request.get("/deep/nested/privacy/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.privacy-tools", id: "de.add-ideas.privacy-tools",
version: "0.1.0", version: "0.2.0",
entry: "./", entry: "./",
}); });
}); });
@@ -76,6 +76,14 @@ test("inspects and independently verifies a re-encoded PNG without network acces
await expect(page.getByText("Alice PNG").first()).toBeVisible(); await expect(page.getByText("Alice PNG").first()).toBeVisible();
await expect(page.getByText("PNG Alice").first()).toBeVisible(); await expect(page.getByText("PNG Alice").first()).toBeVisible();
await expect(page.getByText("inventory-only.pdf").first()).toBeVisible(); await expect(page.getByText("inventory-only.pdf").first()).toBeVisible();
await expect(
page.getByRole("heading", { name: "Selective-removal policy" }),
).toBeVisible();
await expect(page.getByText("inspect-only", { exact: true })).toBeVisible();
await page.getByLabel("Policy profile").selectOption("location-only");
await expect(
page.getByText("policy-not-executable", { exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Re-encode & verify" }).click(); await page.getByRole("button", { name: "Re-encode & verify" }).click();
await expect(page.getByText("Mandatory output re-scan")).toBeVisible(); await expect(page.getByText("Mandatory output re-scan")).toBeVisible();
await expect(page.locator(".verification")).not.toHaveClass(/is-failed/u); await expect(page.locator(".verification")).not.toHaveClass(/is-failed/u);
@@ -87,18 +95,26 @@ test("inspects and independently verifies a re-encoded PNG without network acces
await page await page
.getByRole("button", { name: "Download re-encoded output" }) .getByRole("button", { name: "Download re-encoded output" })
.click(); .click();
expect((await imageDownload).suggestedFilename()).toBe( expect((await imageDownload).suggestedFilename()).toBe("image-001.clean.png");
"metadata-fixture.clean.png",
const safeReportDownload = page.waitForEvent("download");
await page
.getByRole("button", { name: "Download safe-share report" })
.click();
expect((await safeReportDownload).suggestedFilename()).toBe(
"privacy-tools-safe-share-report.json",
); );
const reportDownload = page.waitForEvent("download"); const detailedReportDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "Download JSON report" }).click(); await page.getByRole("button", { name: "Download detailed report" }).click();
expect((await reportDownload).suggestedFilename()).toBe( expect((await detailedReportDownload).suggestedFilename()).toBe(
"privacy-tools-report.json", "privacy-tools-report.json",
); );
const archiveDownload = page.waitForEvent("download"); const archiveDownload = page.waitForEvent("download");
await page await page
.getByRole("button", { name: "Download 1 re-encoded image + report" }) .getByRole("button", {
name: "Download safe-share ZIP with 1 re-encoded image + report",
})
.click(); .click();
expect((await archiveDownload).suggestedFilename()).toBe( expect((await archiveDownload).suggestedFilename()).toBe(
"privacy-tools-re-encoded-images.zip", "privacy-tools-re-encoded-images.zip",
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/privacy/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+96
View File
@@ -7,6 +7,10 @@ import {
buildSanitizationReport, buildSanitizationReport,
createBatchArchive, createBatchArchive,
createBatchReport, createBatchReport,
createPolicyEvidence,
createSafeShareReport,
genericOutputName,
policyById,
serializeReport, serializeReport,
type ImageScanResult, type ImageScanResult,
type MetadataFinding, type MetadataFinding,
@@ -160,6 +164,98 @@ describe("sanitization and batch reports", () => {
), ),
).rejects.toThrow(/Archive image count/iu); ).rejects.toThrow(/Archive image count/iu);
}); });
it("builds a pseudonymized safe-share report and generic names", () => {
const source = result({
id: "private-id",
name: "Alice-at-home.png",
sha256: "secret-hash",
findings: [sensitive],
coverage: {
projectScanner: "complete",
secondaryScanner: "complete",
notes: ["source metadata value"],
},
});
const output = result({ id: "clean", name: "output.png" });
const report = buildSanitizationReport(
source,
output,
"Alice-at-home.clean.png",
"image/png",
20,
comparison(true),
);
const safe = serializeReport(
createSafeShareReport(
[source],
[{ blob: new Blob(), report }],
"2026-09-01T00:00:00.000Z",
),
);
expect(safe).toContain('"profile": "safe-share"');
expect(safe).toContain("image-001.clean.png");
expect(safe).not.toContain("Alice");
expect(safe).not.toContain("secret-hash");
expect(safe).not.toContain("48.1 N");
expect(safe).not.toContain("source metadata value");
expect(genericOutputName(9, "jpeg")).toBe("image-010.clean.jpg");
});
it("evaluates reusable policies across cleanable and inspect-only formats", () => {
const cleanable = result({ findings: [sensitive] });
const unsupported = result({
id: "pdf",
name: "document.pdf",
identity: {
claimedType: "application/pdf",
extension: "pdf",
detectedKind: "pdf",
detectedType: "application/pdf",
typeMatch: "match",
},
deepSupported: false,
cleanable: false,
findings: [sensitive],
coverage: {
projectScanner: "unsupported",
secondaryScanner: "unsupported",
notes: [],
},
});
const strict = createPolicyEvidence(
[cleanable, unsupported],
[],
policyById("safe-share"),
"2026-09-01T00:00:00.000Z",
);
expect(strict.files[0]).toMatchObject({
decision: "review",
availableOperation: "reencode-available",
findings: { remove: 1 },
});
expect(strict.files[1]).toMatchObject({
decision: "blocked",
availableOperation: "inspect-only",
});
expect(JSON.stringify(strict)).not.toContain("document.pdf");
const selective = createPolicyEvidence(
[
result({
findings: [
sensitive,
{ ...sensitive, id: "author", category: "identity" },
],
}),
],
[],
policyById("location-only"),
);
expect(selective.files[0]?.availableOperation).toBe(
"policy-not-executable",
);
});
}); });
function comparison(identical: boolean) { function comparison(identical: boolean) {