@@ -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
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
|
||||
- Added byte-signature inventory, claimed-vs-detected media types, safe names,
|
||||
|
||||
@@ -22,9 +22,24 @@ lookups, telemetry, or runtime network calls. A source file is never modified.
|
||||
removed, preserved, generated, unsupported, or incompletely checked, and
|
||||
includes source/output SHA-256 hashes and a bounded decoded-pixel sample
|
||||
comparison.
|
||||
5. Download an individual re-encoded image, a JSON report, or a ZIP containing
|
||||
re-encoded images and the report. The report itself may be sensitive because
|
||||
it contains source filenames and metadata values.
|
||||
5. Download generic sequentially named outputs, a pseudonymized safe-share
|
||||
report, or a ZIP containing both. The separate detailed report preserves
|
||||
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
|
||||
|
||||
@@ -107,7 +122,7 @@ npm run release:artifact
|
||||
|
||||
This checks the manifest, types, lint, formatting, unit fixtures, production
|
||||
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
|
||||
dependency licence texts.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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:
|
||||
|
||||
|
||||
@@ -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> |
|
||||
| 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-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell |
|
||||
| `@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.3.0 | Apache-2.0 | Toolbox manifest contract |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | Shared application shell |
|
||||
| React / React DOM | 19.2.8 | MIT | User interface |
|
||||
|
||||
ExifReader is used unmodified. Its MPL-2.0 covered source remains available at
|
||||
|
||||
@@ -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
|
||||
based on explicit coverage and output findings.
|
||||
6. `archive` serializes bounded deterministic JSON and creates stored ZIP
|
||||
entries from application-generated `images/` paths and sanitized unique
|
||||
names.
|
||||
entries from application-generated `images/` paths. Safe-share archives use
|
||||
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
|
||||
because portable cross-browser image encoder support is there; metadata parsing
|
||||
|
||||
@@ -55,5 +55,17 @@ filesystem metadata, clipboard/history records, application caches, backed-up
|
||||
originals, or cloud/recipient copies. Review the visible output, destination,
|
||||
report, and surrounding files yourself.
|
||||
|
||||
The JSON report is sensitive by design: it can include original filenames,
|
||||
hashes, timestamps, and metadata values. Share or retain it only intentionally.
|
||||
The detailed JSON report is sensitive by design: it can include original
|
||||
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.
|
||||
|
||||
Generated
+19
-21
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "privacy-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "privacy-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"exifreader": "4.44.0",
|
||||
"fflate": "0.8.2",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@@ -44,23 +44,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.3",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-helpers": {
|
||||
"version": "0.1.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==",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"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",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -68,17 +67,16 @@
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "privacy-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect and remove shareable-file metadata locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -39,16 +39,16 @@
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"exifreader": "4.44.0",
|
||||
"fflate": "0.8.2",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
||||
timeout: 180_000,
|
||||
},
|
||||
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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
|
||||
- Added byte-signature inventory, claimed-vs-detected media types, safe names,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-contract@0.2.3
|
||||
@add-ideas/toolbox-contract@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- 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
|
||||
==============================================================================
|
||||
--- 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
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
|
||||
+19
-4
@@ -22,9 +22,24 @@ lookups, telemetry, or runtime network calls. A source file is never modified.
|
||||
removed, preserved, generated, unsupported, or incompletely checked, and
|
||||
includes source/output SHA-256 hashes and a bounded decoded-pixel sample
|
||||
comparison.
|
||||
5. Download an individual re-encoded image, a JSON report, or a ZIP containing
|
||||
re-encoded images and the report. The report itself may be sensitive because
|
||||
it contains source filenames and metadata values.
|
||||
5. Download generic sequentially named outputs, a pseudonymized safe-share
|
||||
report, or a ZIP containing both. The separate detailed report preserves
|
||||
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
|
||||
|
||||
@@ -107,7 +122,7 @@ npm run release:artifact
|
||||
|
||||
This checks the manifest, types, lint, formatting, unit fixtures, production
|
||||
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
|
||||
dependency licence texts.
|
||||
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# 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:
|
||||
|
||||
|
||||
@@ -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> |
|
||||
| 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-contract` | 0.2.3 | Apache-2.0 | Toolbox manifest contract |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | Shared application shell |
|
||||
| `@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.3.0 | Apache-2.0 | Toolbox manifest contract |
|
||||
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 | Shared application shell |
|
||||
| React / React DOM | 19.2.8 | MIT | User interface |
|
||||
|
||||
ExifReader is used unmodified. Its MPL-2.0 covered source remains available at
|
||||
|
||||
@@ -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
|
||||
based on explicit coverage and output findings.
|
||||
6. `archive` serializes bounded deterministic JSON and creates stored ZIP
|
||||
entries from application-generated `images/` paths and sanitized unique
|
||||
names.
|
||||
entries from application-generated `images/` paths. Safe-share archives use
|
||||
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
|
||||
because portable cross-browser image encoder support is there; metadata parsing
|
||||
|
||||
@@ -55,5 +55,17 @@ filesystem metadata, clipboard/history records, application caches, backed-up
|
||||
originals, or cloud/recipient copies. Review the visible output, destination,
|
||||
report, and surrounding files yourself.
|
||||
|
||||
The JSON report is sensitive by design: it can include original filenames,
|
||||
hashes, timestamps, and metadata values. Share or retain it only intentionally.
|
||||
The detailed JSON report is sensitive by design: it can include original
|
||||
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
@@ -1,5 +1,5 @@
|
||||
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"];
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
|
||||
+51
-3
@@ -3,12 +3,20 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.privacy-tools",
|
||||
"name": "Privacy Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect and remove shareable-file metadata locally in the browser.",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect metadata and create policy-evidenced sharing copies locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["privacy", "files", "security"],
|
||||
"tags": ["metadata", "exif", "privacy", "sanitize", "share"],
|
||||
"tags": [
|
||||
"metadata",
|
||||
"exif",
|
||||
"privacy",
|
||||
"sanitize",
|
||||
"policy",
|
||||
"evidence",
|
||||
"share"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +29,46 @@
|
||||
"crossOriginIsolated": 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": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+151
-11
@@ -1,14 +1,20 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
import {
|
||||
createBatchArchive,
|
||||
createBatchReport,
|
||||
createPolicyEvidence,
|
||||
createSafeShareReport,
|
||||
genericOutputName,
|
||||
policyById,
|
||||
SELECTIVE_REMOVAL_POLICIES,
|
||||
sanitizeStaticImage,
|
||||
scanFilesInWorker,
|
||||
serializeReport,
|
||||
type FindingCategory,
|
||||
type ImageScanResult,
|
||||
type SelectiveRemovalPolicy,
|
||||
type SanitizedAsset,
|
||||
} from "../privacy";
|
||||
|
||||
@@ -50,6 +56,9 @@ export function Workbench() {
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
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 inputRef = useRef<HTMLInputElement>(null);
|
||||
const cleanable = useMemo(
|
||||
@@ -60,6 +69,16 @@ export function Workbench() {
|
||||
() => records.flatMap((record) => (record.asset ? [record.asset] : [])),
|
||||
[records],
|
||||
);
|
||||
const policy = useMemo(() => policyById(policyId), [policyId]);
|
||||
const policyEvidence = useMemo(
|
||||
() =>
|
||||
createPolicyEvidence(
|
||||
records.map((record) => record.scan),
|
||||
assets,
|
||||
policy,
|
||||
),
|
||||
[assets, policy, records],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -119,6 +138,12 @@ export function Workbench() {
|
||||
try {
|
||||
const asset = await sanitizeStaticImage(record.file, record.scan, {
|
||||
signal: controller.signal,
|
||||
outputName: genericNames
|
||||
? genericOutputName(
|
||||
records.findIndex((item) => item.scan.id === id),
|
||||
record.scan.identity.detectedKind,
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
setRecords((current) =>
|
||||
current.map((item) =>
|
||||
@@ -173,6 +198,12 @@ export function Workbench() {
|
||||
try {
|
||||
const asset = await sanitizeStaticImage(record.file, record.scan, {
|
||||
signal: controller.signal,
|
||||
outputName: genericNames
|
||||
? genericOutputName(
|
||||
records.findIndex((item) => item.scan.id === record.scan.id),
|
||||
record.scan.identity.detectedKind,
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
setRecords((current) =>
|
||||
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 () => {
|
||||
if (assets.length === 0) return;
|
||||
setBusy("archive");
|
||||
@@ -230,6 +291,8 @@ export function Workbench() {
|
||||
const blob = await createBatchArchive(
|
||||
records.map((record) => record.scan),
|
||||
assets,
|
||||
undefined,
|
||||
"safe-share",
|
||||
);
|
||||
triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip");
|
||||
} catch (caught) {
|
||||
@@ -343,6 +406,15 @@ export function Workbench() {
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
type="button"
|
||||
className="primary-button"
|
||||
@@ -446,17 +518,25 @@ export function Workbench() {
|
||||
<p className="eyebrow">Step 3</p>
|
||||
<h2 id="export-heading">Export deliberately</h2>
|
||||
<p className="muted">
|
||||
The JSON report includes original filenames and detected
|
||||
values and may itself be sensitive.
|
||||
Safe-share reports omit names, hashes, exact sizes, values and
|
||||
offsets. Detailed reports retain that evidence and may
|
||||
themselves be sensitive.
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadSafeReport}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
Download safe-share report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadReport}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
Download JSON report
|
||||
Download detailed report
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -464,12 +544,78 @@ export function Workbench() {
|
||||
onClick={() => void downloadArchive()}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
|
||||
@@ -672,12 +818,6 @@ function groupFindings(
|
||||
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 {
|
||||
return error instanceof Error ? error.message : "The operation failed.";
|
||||
}
|
||||
|
||||
+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()
|
||||
|
||||
@@ -317,6 +317,19 @@ button:disabled {
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
overflow: auto;
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.privacy-tools",
|
||||
"name": "Privacy Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect and remove shareable-file metadata locally in the browser.",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect metadata and create policy-evidenced sharing copies locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["privacy", "files", "security"],
|
||||
"tags": ["metadata", "exif", "privacy", "sanitize", "share"],
|
||||
"tags": [
|
||||
"metadata",
|
||||
"exif",
|
||||
"privacy",
|
||||
"sanitize",
|
||||
"policy",
|
||||
"evidence",
|
||||
"share"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +29,46 @@
|
||||
"crossOriginIsolated": 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": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -42,7 +42,7 @@ test("serves the release identity and hardened headers", async ({
|
||||
const manifest = await request.get("/deep/nested/privacy/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.privacy-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
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("PNG Alice").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 expect(page.getByText("Mandatory output re-scan")).toBeVisible();
|
||||
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
|
||||
.getByRole("button", { name: "Download re-encoded output" })
|
||||
.click();
|
||||
expect((await imageDownload).suggestedFilename()).toBe(
|
||||
"metadata-fixture.clean.png",
|
||||
expect((await imageDownload).suggestedFilename()).toBe("image-001.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");
|
||||
await page.getByRole("button", { name: "Download JSON report" }).click();
|
||||
expect((await reportDownload).suggestedFilename()).toBe(
|
||||
const detailedReportDownload = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download detailed report" }).click();
|
||||
expect((await detailedReportDownload).suggestedFilename()).toBe(
|
||||
"privacy-tools-report.json",
|
||||
);
|
||||
const archiveDownload = page.waitForEvent("download");
|
||||
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();
|
||||
expect((await archiveDownload).suggestedFilename()).toBe(
|
||||
"privacy-tools-re-encoded-images.zip",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
buildSanitizationReport,
|
||||
createBatchArchive,
|
||||
createBatchReport,
|
||||
createPolicyEvidence,
|
||||
createSafeShareReport,
|
||||
genericOutputName,
|
||||
policyById,
|
||||
serializeReport,
|
||||
type ImageScanResult,
|
||||
type MetadataFinding,
|
||||
@@ -160,6 +164,98 @@ describe("sanitization and batch reports", () => {
|
||||
),
|
||||
).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) {
|
||||
|
||||
Reference in New Issue
Block a user