@doccloak/core 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -3
- package/dist/model-loader.d.ts +45 -2
- package/dist/model-loader.js +99 -5
- package/dist/providers/bardsai.d.ts +12 -0
- package/dist/providers/bardsai.js +35 -3
- package/dist/providers/gliner.d.ts +12 -0
- package/dist/providers/gliner.js +36 -4
- package/dist/regex/rules.data.js +186 -0
- package/dist/regex/validators.d.ts +7 -0
- package/dist/regex/validators.js +95 -4
- package/dist/restore-tokens.d.ts +67 -0
- package/dist/restore-tokens.js +120 -0
- package/dist/session.d.ts +89 -14
- package/dist/session.js +270 -37
- package/dist/surrogates.d.ts +82 -0
- package/dist/surrogates.js +1510 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +4 -0
- package/package.json +7 -1
- package/rules/gb.json +17 -0
- package/rules/schema.json +2 -0
- package/rules/universal.json +169 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,114 @@ All notable changes to `@doccloak/core` are documented here. The format is
|
|
|
4
4
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
|
|
5
5
|
project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.9.0] - 2026-08-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Secrets & credentials detection (T099/T116):** new `SECRET` and
|
|
12
|
+
`API_KEY` entity types plus eleven universal regex rules covering AWS
|
|
13
|
+
access keys, GitHub/Slack tokens, OpenAI/Anthropic/Google API keys,
|
|
14
|
+
PEM private-key blocks, JWTs, connection strings with embedded
|
|
15
|
+
credentials, `secret=`-style assignments and high-entropy tokens, and
|
|
16
|
+
a GB driving-licence rule. Credentials never receive realistic
|
|
17
|
+
stand-ins — even in surrogate mode they always fall back to typed
|
|
18
|
+
placeholders, since a same-shape fake key still reads as a live
|
|
19
|
+
credential.
|
|
20
|
+
|
|
21
|
+
- **Tolerant restore-token matching (T098):** new side-effect-free
|
|
22
|
+
`@doccloak/core/restore-tokens` export that restores placeholders LLM
|
|
23
|
+
replies have mangled — case changes (`[person_1]`), separator swaps
|
|
24
|
+
(`[PERSON 1]`), markdown wrapping (`**[PERSON_1]**`), dropped brackets
|
|
25
|
+
and stray edge punctuation. Restoration only happens on an unambiguous
|
|
26
|
+
canonical hit; anything ambiguous is left exactly as the model wrote
|
|
27
|
+
it, because a wrong restore silently corrupts the document while a
|
|
28
|
+
missed one is visible and recoverable.
|
|
29
|
+
|
|
30
|
+
- **Model integrity verification:** `fetchModelBlob` verifies downloads
|
|
31
|
+
against a pinned SHA-256 when the caller provides one and rejects
|
|
32
|
+
mismatches with a dedicated `ModelIntegrityError` (the corrupt copy is
|
|
33
|
+
discarded; verified cache entries carry a marker so they are not
|
|
34
|
+
re-hashed). The GLiNER and bardsai providers export their pinned
|
|
35
|
+
`*_MODEL_URL` / `*_MODEL_REVISION` / `*_MODEL_SHA256` constants.
|
|
36
|
+
|
|
37
|
+
- **Multilingual surrogates (T073):** realistic stand-ins now follow the
|
|
38
|
+
original's language beyond EN/PL. New locale packs matching every
|
|
39
|
+
region the web app's language picker offers: German, French, Spanish,
|
|
40
|
+
Italian, Czech, Ukrainian, Dutch, Portuguese, Swedish, Norwegian,
|
|
41
|
+
Danish, Finnish, Japanese and Chinese: given-name/surname pools with
|
|
42
|
+
gender detection and surname feminization (-ova, -ska/-cka), month
|
|
43
|
+
names for date shifting (genitive forms where dates inflect), street
|
|
44
|
+
formats (Hauptstrasse 5 / 12 rue des Lilas / Calle del Sol 3 / Via dei
|
|
45
|
+
Tigli 10 / vul. Zelena 98) and company pools with locale-detecting
|
|
46
|
+
legal suffixes (GmbH, SARL, S.r.l., s.r.o., TOV) kept verbatim.
|
|
47
|
+
Locale detection scores name-pool and surname matches per token
|
|
48
|
+
(shared names like "Marie" resolve to the best-matching language);
|
|
49
|
+
Cyrillic script resolves decisively. Non-Latin surrogate names fall
|
|
50
|
+
back to an unrelated ASCII identity in derived email local parts.
|
|
51
|
+
CJK names compose surname-first without spaces via a per-pack
|
|
52
|
+
formatPerson hook (Chinese given-name length mirrored; kana decides
|
|
53
|
+
Japanese, surname-prefix scoring separates kanji-only Japanese from
|
|
54
|
+
Chinese, a Han script fallback catches unpooled surnames); CJK dates
|
|
55
|
+
(2024\u5e743\u670815\u65e5) shift in place; Finnish dates keep the partitive.
|
|
56
|
+
Bare legal suffixes (AB/AS) now require a preceding space, so 'SAAB'
|
|
57
|
+
never sheds an 'AB'. Adding further locales is data-only (one
|
|
58
|
+
LocalePack entry).
|
|
59
|
+
|
|
60
|
+
- **Person-variant unification (T057):** repeated mentions of the same
|
|
61
|
+
person share one replacement. When a new PERSON value is a name-variant
|
|
62
|
+
of an already-mapped one (case-insensitive token subset with at least
|
|
63
|
+
one substantive shared token: "John" and "Smith" both match
|
|
64
|
+
"John Smith"; "Mr. Smith" and "Person 11" do not), the session reuses
|
|
65
|
+
its placeholder/surrogate instead of issuing a new one. The reverse map
|
|
66
|
+
keeps the LONGEST variant, so restore always yields the fullest known
|
|
67
|
+
form; `renameLabel` renames the whole variant group; `deserialize`
|
|
68
|
+
keeps the longest variant canonical regardless of entry order.
|
|
69
|
+
|
|
70
|
+
- **Surrogate replacement mode (T043):** `AnonymizationSession` accepts a
|
|
71
|
+
new `'surrogate'` mode in which replacements are realistic,
|
|
72
|
+
shape-preserving stand-ins instead of typed placeholders: locale-aware
|
|
73
|
+
fake names (EN + PL, gendered Polish surname endings), emails whose
|
|
74
|
+
local part mirrors the in-session person surrogate on reserved example
|
|
75
|
+
domains, dates shifted by a per-session day offset while keeping their
|
|
76
|
+
format, phones/IBANs/IDs/cards that keep their shape (IBAN mod-97,
|
|
77
|
+
PESEL and Luhn checksums valid; generated PESELs encode a 19th-century
|
|
78
|
+
birth date and SSN shapes use the never-allocated 900-999 area, so a
|
|
79
|
+
generated identifier can never be a real living person's). Generation
|
|
80
|
+
is deterministic per session (seeded by a per-session salt, new
|
|
81
|
+
`src/surrogates.ts` module) and collision-safe against every original
|
|
82
|
+
value and every already-issued replacement in the session. The default
|
|
83
|
+
mode remains `'labeled'` (typed placeholders); restore is unchanged
|
|
84
|
+
exact-literal lookup. Surrogate-mode `serialize()` wraps the entry
|
|
85
|
+
array in `{ mode, salt, entries }` so `deserialize()` reproduces the
|
|
86
|
+
mode and salt; placeholder/blanked sessions keep the plain-array
|
|
87
|
+
Python-parity schema byte-identically.
|
|
88
|
+
|
|
89
|
+
### Changed
|
|
90
|
+
|
|
91
|
+
- **Breaking (user-visible output):** `AnonymizationSession` now generates
|
|
92
|
+
typed placeholders with per-type counters, e.g. `[PERSON_1]`, `[EMAIL_1]`,
|
|
93
|
+
`[DATE_2]`, instead of the global `<<REDACTED_N>>`. Typed placeholders
|
|
94
|
+
keep LLM answers coherent (pronouns, date reasoning, formatting), make
|
|
95
|
+
protected text human-readable, and avoid the `<<...>>` token that
|
|
96
|
+
Markdown-rendering chats can mangle. Placeholder type names are the
|
|
97
|
+
stable uppercase `EntityType` ids, so every generated token matches
|
|
98
|
+
`\[[A-Z_]+_\d+\]`.
|
|
99
|
+
- Blanked-mode replacements no longer consume counter numbers, so labeled
|
|
100
|
+
placeholders number contiguously per type.
|
|
101
|
+
- `AnonymizationSession.deserialize` rebuilds the per-type counters from
|
|
102
|
+
`[TYPE_N]` placeholders in the map, and `anonymize` skips any number
|
|
103
|
+
already present in the reverse map (renamed labels included), so freshly
|
|
104
|
+
issued placeholders never collide with restored or renamed ones.
|
|
105
|
+
|
|
106
|
+
### Backward compatibility
|
|
107
|
+
|
|
108
|
+
- Restoring is unchanged: `deanonymize` performs exact-literal replacement
|
|
109
|
+
of whatever placeholder strings the map contains. Maps serialized before
|
|
110
|
+
0.9.0 (or by the Python CLI's `save_map`) with `<<REDACTED_N>>` tokens
|
|
111
|
+
still deserialize and restore old-format text byte-identically.
|
|
112
|
+
- The serialization schema (`original` / `replacement` / `entity_type`,
|
|
113
|
+
Python `save_map` parity) is unchanged.
|
|
114
|
+
|
|
7
115
|
## [0.8.0] - 2026-07-27
|
|
8
116
|
|
|
9
117
|
First public release. The engine was extracted from the DocCloak web app
|
package/dist/index.d.ts
CHANGED
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
export type { KVStore, BlobCache, HardwareHints, CoreEnv, MemoryBlobCacheOptions, } from './env.js';
|
|
5
5
|
export { memoryKV, memoryBlobCache } from './env.js';
|
|
6
6
|
export declare const CORE_PACKAGE_NAME = "@doccloak/core";
|
|
7
|
-
export { fetchModelBlob, evictModelFromCache, retryAsync } from './model-loader.js';
|
|
8
|
-
export type { ModelLoaderEnv, DownloadProgress, FetchModelOptions } from './model-loader.js';
|
|
7
|
+
export { fetchModelBlob, evictModelFromCache, retryAsync, ModelIntegrityError, verificationMarkerKey } from './model-loader.js';
|
|
8
|
+
export type { ModelLoaderEnv, DownloadProgress, FetchModelOptions, ModelVerification } from './model-loader.js';
|
|
9
9
|
export { ALL_REGEX_RULES, REGEX_REGIONS } from './regex/index.js';
|
|
10
10
|
export type { RegexRule, PiiDomain, RegionCode, RegexRegionId } from './regex/index.js';
|
|
11
11
|
export * from './types.js';
|
|
12
12
|
export * from './session.js';
|
|
13
13
|
export * from './doc.js';
|
|
14
|
-
export {
|
|
15
|
-
export {
|
|
14
|
+
export { generateSurrogate, generateUniqueSurrogate, generateSessionSalt, sessionDayOffset, FAKE_EMAIL_DOMAINS, } from './surrogates.js';
|
|
15
|
+
export type { SurrogateContext } from './surrogates.js';
|
|
16
|
+
export { GlinerProvider, GLINER_MODEL_URL, GLINER_MODEL_REVISION, GLINER_MODEL_SHA256 } from './providers/gliner.js';
|
|
17
|
+
export { BardsaiProvider, BARDSAI_MODEL_URL, BARDSAI_MODEL_REVISION, BARDSAI_MODEL_SHA256 } from './providers/bardsai.js';
|
|
16
18
|
export { detectWithRegex, resolveOverlaps, filterFalsePositives, propagateEntities, detectEntities, } from './pipeline.js';
|
|
17
19
|
export { createEngine, PROVIDERS, pickDefaultProvider, defaultThresholdFor, clampThreshold, ENGINE_SETTINGS_KEYS, } from './engine.js';
|
|
18
20
|
export type { ProviderId, ProviderEntry, ProviderFactory, EngineOptions, EngineSettings, DocCloakEngine, } from './engine.js';
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export { memoryKV, memoryBlobCache } from "./env.js";
|
|
5
5
|
export const CORE_PACKAGE_NAME = '@doccloak/core';
|
|
6
6
|
// T006: model loader
|
|
7
|
-
export { fetchModelBlob, evictModelFromCache, retryAsync } from "./model-loader.js";
|
|
7
|
+
export { fetchModelBlob, evictModelFromCache, retryAsync, ModelIntegrityError, verificationMarkerKey } from "./model-loader.js";
|
|
8
8
|
// end T006
|
|
9
9
|
// T004: regex rules (REGEX_REGIONS moved here from the web engine in T009)
|
|
10
10
|
export { ALL_REGEX_RULES, REGEX_REGIONS } from "./regex/index.js";
|
|
@@ -14,11 +14,14 @@ export * from "./types.js";
|
|
|
14
14
|
export * from "./session.js";
|
|
15
15
|
export * from "./doc.js";
|
|
16
16
|
// end T003
|
|
17
|
+
// T043: surrogate generator (realistic, shape-preserving replacements)
|
|
18
|
+
export { generateSurrogate, generateUniqueSurrogate, generateSessionSalt, sessionDayOffset, FAKE_EMAIL_DOMAINS, } from "./surrogates.js";
|
|
19
|
+
// end T043
|
|
17
20
|
// T007: gliner provider
|
|
18
|
-
export { GlinerProvider } from "./providers/gliner.js";
|
|
21
|
+
export { GlinerProvider, GLINER_MODEL_URL, GLINER_MODEL_REVISION, GLINER_MODEL_SHA256 } from "./providers/gliner.js";
|
|
19
22
|
// end T007
|
|
20
23
|
// T008: bardsai provider
|
|
21
|
-
export { BardsaiProvider } from "./providers/bardsai.js";
|
|
24
|
+
export { BardsaiProvider, BARDSAI_MODEL_URL, BARDSAI_MODEL_REVISION, BARDSAI_MODEL_SHA256 } from "./providers/bardsai.js";
|
|
22
25
|
// end T008
|
|
23
26
|
// T005: pipeline
|
|
24
27
|
export { detectWithRegex, resolveOverlaps, filterFalsePositives, propagateEntities, detectEntities, } from "./pipeline.js";
|
package/dist/model-loader.d.ts
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* - Serves the model from the injected BlobCache when available and prefers
|
|
15
15
|
* the cached (typically disk-backed) copy over the in-memory one to reduce
|
|
16
16
|
* peak RAM, which matters on iOS Safari where large tabs get killed.
|
|
17
|
+
* - Verifies downloads against a pinned SHA-256 when the caller provides one
|
|
18
|
+
* (supply-chain hardening, T116): mismatches evict the cache entry and
|
|
19
|
+
* reject with ModelIntegrityError; verified cache entries carry a marker
|
|
20
|
+
* so they are not re-hashed on every startup.
|
|
17
21
|
*
|
|
18
22
|
* Environment-agnostic: all host capabilities (blob cache, fetch, persistent
|
|
19
23
|
* storage request) are injected via ModelLoaderEnv. This module never touches
|
|
@@ -33,9 +37,47 @@ export interface ModelLoaderEnv {
|
|
|
33
37
|
/** Best-effort persistent-storage request (navigator.storage.persist() on the web). */
|
|
34
38
|
persistStorage?: () => Promise<boolean>;
|
|
35
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* A downloaded (or cached) model blob did not match the pinned SHA-256.
|
|
42
|
+
* Distinct error type so hosts can show a dedicated "integrity check
|
|
43
|
+
* failed" state instead of a generic download error. The offending cache
|
|
44
|
+
* entry is evicted before this is thrown; the regex/rules tier keeps
|
|
45
|
+
* working (existing degraded-mode behavior).
|
|
46
|
+
*/
|
|
47
|
+
export declare class ModelIntegrityError extends Error {
|
|
48
|
+
url: string;
|
|
49
|
+
expectedSha256: string;
|
|
50
|
+
actualSha256: string;
|
|
51
|
+
constructor(url: string, expectedSha256: string, actualSha256: string);
|
|
52
|
+
}
|
|
53
|
+
/** Successful SHA-256 verification of a model blob, surfaced to the model-status UI. */
|
|
54
|
+
export interface ModelVerification {
|
|
55
|
+
url: string;
|
|
56
|
+
/** The verified SHA-256, lowercase hex - equals the pinned hash. */
|
|
57
|
+
sha256: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Cache key of the tiny "this URL was verified against this hash" marker.
|
|
61
|
+
* Uses a query parameter (not a URL fragment) because Cache Storage strips
|
|
62
|
+
* fragments, which would collide with the model entry itself. Keyed by
|
|
63
|
+
* url AND hash so bumping a pinned hash automatically invalidates markers
|
|
64
|
+
* written for the previous pin.
|
|
65
|
+
*/
|
|
66
|
+
export declare function verificationMarkerKey(url: string, sha256: string): string;
|
|
36
67
|
export interface FetchModelOptions {
|
|
37
68
|
maxAttempts?: number;
|
|
38
69
|
retryBaseDelayMs?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Pinned SHA-256 (lowercase hex) of the model blob. When set, every
|
|
72
|
+
* network download is hashed before use; a mismatch evicts the cache
|
|
73
|
+
* entry and rejects with ModelIntegrityError. Cached blobs that carry a
|
|
74
|
+
* verification marker for this hash skip re-hashing so startup cost is
|
|
75
|
+
* unchanged; cached blobs without a marker are hashed once and either
|
|
76
|
+
* marked verified or evicted and re-downloaded.
|
|
77
|
+
*/
|
|
78
|
+
sha256?: string;
|
|
79
|
+
/** Fired once the served blob is known to match the pinned sha256. */
|
|
80
|
+
onVerified?: (verification: ModelVerification) => void;
|
|
39
81
|
}
|
|
40
82
|
/**
|
|
41
83
|
* Download a model with progress reporting, resume and retry.
|
|
@@ -43,9 +85,10 @@ export interface FetchModelOptions {
|
|
|
43
85
|
*/
|
|
44
86
|
export declare function fetchModelBlob(env: ModelLoaderEnv, url: string, onProgress?: DownloadProgress, options?: FetchModelOptions): Promise<Blob>;
|
|
45
87
|
/**
|
|
46
|
-
* Remove a single model from the cache (e.g. when its URL changes).
|
|
88
|
+
* Remove a single model from the cache (e.g. when its URL changes). When a
|
|
89
|
+
* pinned sha256 is given, the matching verification marker is removed too.
|
|
47
90
|
*/
|
|
48
|
-
export declare function evictModelFromCache(env: ModelLoaderEnv, url: string): Promise<void>;
|
|
91
|
+
export declare function evictModelFromCache(env: ModelLoaderEnv, url: string, sha256?: string): Promise<void>;
|
|
49
92
|
/**
|
|
50
93
|
* Retry an async operation with exponential backoff. Used for the smaller
|
|
51
94
|
* companion downloads (tokenizer files) that go through libraries without
|
package/dist/model-loader.js
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* - Serves the model from the injected BlobCache when available and prefers
|
|
15
15
|
* the cached (typically disk-backed) copy over the in-memory one to reduce
|
|
16
16
|
* peak RAM, which matters on iOS Safari where large tabs get killed.
|
|
17
|
+
* - Verifies downloads against a pinned SHA-256 when the caller provides one
|
|
18
|
+
* (supply-chain hardening, T116): mismatches evict the cache entry and
|
|
19
|
+
* reject with ModelIntegrityError; verified cache entries carry a marker
|
|
20
|
+
* so they are not re-hashed on every startup.
|
|
17
21
|
*
|
|
18
22
|
* Environment-agnostic: all host capabilities (blob cache, fetch, persistent
|
|
19
23
|
* storage request) are injected via ModelLoaderEnv. This module never touches
|
|
@@ -30,6 +34,52 @@ class HttpError extends Error {
|
|
|
30
34
|
this.status = status;
|
|
31
35
|
}
|
|
32
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* A downloaded (or cached) model blob did not match the pinned SHA-256.
|
|
39
|
+
* Distinct error type so hosts can show a dedicated "integrity check
|
|
40
|
+
* failed" state instead of a generic download error. The offending cache
|
|
41
|
+
* entry is evicted before this is thrown; the regex/rules tier keeps
|
|
42
|
+
* working (existing degraded-mode behavior).
|
|
43
|
+
*/
|
|
44
|
+
export class ModelIntegrityError extends Error {
|
|
45
|
+
constructor(url, expectedSha256, actualSha256) {
|
|
46
|
+
super(`Model failed SHA-256 integrity check: expected ${expectedSha256.slice(0, 12)}..., ` +
|
|
47
|
+
`got ${actualSha256.slice(0, 12)}... (${url}). The downloaded copy was discarded.`);
|
|
48
|
+
this.name = 'ModelIntegrityError';
|
|
49
|
+
this.url = url;
|
|
50
|
+
this.expectedSha256 = expectedSha256;
|
|
51
|
+
this.actualSha256 = actualSha256;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Cache key of the tiny "this URL was verified against this hash" marker.
|
|
56
|
+
* Uses a query parameter (not a URL fragment) because Cache Storage strips
|
|
57
|
+
* fragments, which would collide with the model entry itself. Keyed by
|
|
58
|
+
* url AND hash so bumping a pinned hash automatically invalidates markers
|
|
59
|
+
* written for the previous pin.
|
|
60
|
+
*/
|
|
61
|
+
export function verificationMarkerKey(url, sha256) {
|
|
62
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
63
|
+
return `${url}${sep}doccloak-sha256-verified=${sha256}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* SHA-256 of a blob as lowercase hex. WebCrypto has no streaming digest,
|
|
67
|
+
* so the blob is hashed as one contiguous buffer: peak cost is one extra
|
|
68
|
+
* transient copy of the model, released before ONNX Runtime allocates its
|
|
69
|
+
* own copy.
|
|
70
|
+
*/
|
|
71
|
+
async function sha256Hex(blob) {
|
|
72
|
+
const buffer = await blob.arrayBuffer();
|
|
73
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
|
74
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
75
|
+
}
|
|
76
|
+
/** Best-effort write of the verification marker (a 1-byte blob). */
|
|
77
|
+
async function markVerified(cache, url, sha256) {
|
|
78
|
+
try {
|
|
79
|
+
await cache.put(verificationMarkerKey(url, sha256), new Blob(['1']));
|
|
80
|
+
}
|
|
81
|
+
catch { /* marker is an optimisation - next startup just re-hashes */ }
|
|
82
|
+
}
|
|
33
83
|
function isRetryable(err) {
|
|
34
84
|
if (err instanceof HttpError) {
|
|
35
85
|
return err.status === 408 || err.status === 429 || err.status >= 500;
|
|
@@ -80,9 +130,11 @@ async function tryCachePut(cache, url, blob) {
|
|
|
80
130
|
if (!stored) {
|
|
81
131
|
console.warn('[DocCloak] Model cache put refused (model still loaded in memory)');
|
|
82
132
|
}
|
|
133
|
+
return stored;
|
|
83
134
|
}
|
|
84
135
|
catch (err) {
|
|
85
136
|
console.warn('[DocCloak] Model cache put failed (model still loaded in memory):', err);
|
|
137
|
+
return false;
|
|
86
138
|
}
|
|
87
139
|
}
|
|
88
140
|
/**
|
|
@@ -150,11 +202,33 @@ async function downloadAttempt(fetchFn, url, offset, onChunk, onHeaders) {
|
|
|
150
202
|
export async function fetchModelBlob(env, url, onProgress, options) {
|
|
151
203
|
const maxAttempts = options?.maxAttempts ?? MAX_ATTEMPTS;
|
|
152
204
|
const retryBaseDelayMs = options?.retryBaseDelayMs ?? RETRY_BASE_DELAY_MS;
|
|
205
|
+
const expectedSha256 = options?.sha256?.toLowerCase();
|
|
153
206
|
const cache = env.cache;
|
|
154
207
|
const cachedBlob = await readFromCache(cache, url);
|
|
155
208
|
if (cachedBlob) {
|
|
156
|
-
|
|
157
|
-
|
|
209
|
+
if (!expectedSha256) {
|
|
210
|
+
onProgress?.(cachedBlob.size, cachedBlob.size);
|
|
211
|
+
return cachedBlob;
|
|
212
|
+
}
|
|
213
|
+
// Previously verified against this exact pin: skip the re-hash so
|
|
214
|
+
// startup cost is unchanged.
|
|
215
|
+
const marker = await readFromCache(cache, verificationMarkerKey(url, expectedSha256));
|
|
216
|
+
if (marker) {
|
|
217
|
+
onProgress?.(cachedBlob.size, cachedBlob.size);
|
|
218
|
+
options?.onVerified?.({ url, sha256: expectedSha256 });
|
|
219
|
+
return cachedBlob;
|
|
220
|
+
}
|
|
221
|
+
// No marker (cache predates pinning, or the pin changed): hash once.
|
|
222
|
+
const actualSha256 = await sha256Hex(cachedBlob);
|
|
223
|
+
if (actualSha256 === expectedSha256) {
|
|
224
|
+
await markVerified(cache, url, expectedSha256);
|
|
225
|
+
onProgress?.(cachedBlob.size, cachedBlob.size);
|
|
226
|
+
options?.onVerified?.({ url, sha256: expectedSha256 });
|
|
227
|
+
return cachedBlob;
|
|
228
|
+
}
|
|
229
|
+
// Stale or corrupt cached copy - evict and fall through to the network.
|
|
230
|
+
console.warn(`[DocCloak] Cached model failed SHA-256 check (expected ${expectedSha256.slice(0, 12)}..., got ${actualSha256.slice(0, 12)}...), re-downloading`);
|
|
231
|
+
await evictModelFromCache(env, url, expectedSha256);
|
|
158
232
|
}
|
|
159
233
|
await requestPersistentStorage(env);
|
|
160
234
|
let lastProgressAt = 0;
|
|
@@ -213,22 +287,42 @@ export async function fetchModelBlob(env, url, onProgress, options) {
|
|
|
213
287
|
reportProgress(downloaded, total || downloaded, true);
|
|
214
288
|
let blob = new Blob(chunks);
|
|
215
289
|
chunks = [];
|
|
216
|
-
|
|
290
|
+
if (expectedSha256) {
|
|
291
|
+
// Verify the final assembled blob (covers both fresh and resumed
|
|
292
|
+
// downloads) BEFORE it is cached or handed to the runtime.
|
|
293
|
+
const actualSha256 = await sha256Hex(blob);
|
|
294
|
+
if (actualSha256 !== expectedSha256) {
|
|
295
|
+
await evictModelFromCache(env, url, expectedSha256);
|
|
296
|
+
throw new ModelIntegrityError(url, expectedSha256, actualSha256);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const stored = await tryCachePut(cache, url, blob);
|
|
300
|
+
if (expectedSha256 && stored)
|
|
301
|
+
await markVerified(cache, url, expectedSha256);
|
|
217
302
|
// Prefer the cached (disk-backed) copy so the in-memory chunks can be
|
|
218
303
|
// collected before ONNX Runtime allocates its own copy of the model.
|
|
219
304
|
const diskBlob = await readFromCache(cache, url);
|
|
220
305
|
if (diskBlob && diskBlob.size === blob.size)
|
|
221
306
|
blob = diskBlob;
|
|
307
|
+
if (expectedSha256)
|
|
308
|
+
options?.onVerified?.({ url, sha256: expectedSha256 });
|
|
222
309
|
return blob;
|
|
223
310
|
}
|
|
224
311
|
/**
|
|
225
|
-
* Remove a single model from the cache (e.g. when its URL changes).
|
|
312
|
+
* Remove a single model from the cache (e.g. when its URL changes). When a
|
|
313
|
+
* pinned sha256 is given, the matching verification marker is removed too.
|
|
226
314
|
*/
|
|
227
|
-
export async function evictModelFromCache(env, url) {
|
|
315
|
+
export async function evictModelFromCache(env, url, sha256) {
|
|
228
316
|
try {
|
|
229
317
|
await env.cache.delete(url);
|
|
230
318
|
}
|
|
231
319
|
catch { /* ignore */ }
|
|
320
|
+
if (sha256) {
|
|
321
|
+
try {
|
|
322
|
+
await env.cache.delete(verificationMarkerKey(url, sha256.toLowerCase()));
|
|
323
|
+
}
|
|
324
|
+
catch { /* ignore */ }
|
|
325
|
+
}
|
|
232
326
|
}
|
|
233
327
|
/**
|
|
234
328
|
* Retry an async operation with exponential backoff. Used for the smaller
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { DetectedEntity, DetectionProvider, ProgressCallback } from '../types.js';
|
|
14
14
|
import type { CoreEnv } from '../env.js';
|
|
15
|
+
import type { ModelVerification } from '../model-loader.js';
|
|
16
|
+
export declare const BARDSAI_MODEL_REVISION = "0e72e19f030ed4e661b1673e549af8e0dd176386";
|
|
17
|
+
/** SHA-256 of onnx/model_quantized.onnx (278,736,360 bytes) at BARDSAI_MODEL_REVISION. */
|
|
18
|
+
export declare const BARDSAI_MODEL_SHA256 = "8c9f555c743ed14eb7e505ff9d9c7785775a1671fd14031af33f424de8bd0e7b";
|
|
19
|
+
export declare const BARDSAI_MODEL_URL = "https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/0e72e19f030ed4e661b1673e549af8e0dd176386/onnx/model_quantized.onnx";
|
|
15
20
|
export declare class BardsaiProvider implements DetectionProvider {
|
|
16
21
|
private _name;
|
|
17
22
|
get name(): string;
|
|
@@ -23,7 +28,14 @@ export declare class BardsaiProvider implements DetectionProvider {
|
|
|
23
28
|
private loadWaiters;
|
|
24
29
|
private progressCallback;
|
|
25
30
|
private threshold;
|
|
31
|
+
private verification;
|
|
26
32
|
constructor(env: CoreEnv);
|
|
33
|
+
/**
|
|
34
|
+
* SHA-256 verification result of the served model blob (null until the
|
|
35
|
+
* model has been verified against the pinned hash). Surfaced so the
|
|
36
|
+
* model-status UI can show the positive "Model verified" state.
|
|
37
|
+
*/
|
|
38
|
+
getVerification(): ModelVerification | null;
|
|
27
39
|
isLoaded(): boolean;
|
|
28
40
|
isLoading(): boolean;
|
|
29
41
|
onProgress(callback: ProgressCallback): void;
|
|
@@ -11,9 +11,23 @@
|
|
|
11
11
|
* import.meta, caches, navigator or @huggingface/transformers directly.
|
|
12
12
|
*/
|
|
13
13
|
import * as ort from 'onnxruntime-web';
|
|
14
|
-
import { fetchModelBlob, retryAsync } from "../model-loader.js";
|
|
14
|
+
import { evictModelFromCache, fetchModelBlob, retryAsync } from "../model-loader.js";
|
|
15
15
|
// ── Model config ──────────────────────────────────────────
|
|
16
|
-
|
|
16
|
+
// Supply-chain pinning (T116): the model is fetched from an immutable
|
|
17
|
+
// commit revision, never from mutable resolve/main, and the downloaded
|
|
18
|
+
// blob is verified against a pinned SHA-256. A model update is a
|
|
19
|
+
// deliberate act: bump BARDSAI_MODEL_REVISION and BARDSAI_MODEL_SHA256
|
|
20
|
+
// together in a reviewed commit and update
|
|
21
|
+
// documentation/model-provenance.md.
|
|
22
|
+
// Revision: main of bardsai/eu-pii-anonimization-multilang as of its last
|
|
23
|
+
// modification 2026-05-13; pinned and hashed 2026-08-10.
|
|
24
|
+
export const BARDSAI_MODEL_REVISION = '0e72e19f030ed4e661b1673e549af8e0dd176386';
|
|
25
|
+
/** SHA-256 of onnx/model_quantized.onnx (278,736,360 bytes) at BARDSAI_MODEL_REVISION. */
|
|
26
|
+
export const BARDSAI_MODEL_SHA256 = '8c9f555c743ed14eb7e505ff9d9c7785775a1671fd14031af33f424de8bd0e7b';
|
|
27
|
+
export const BARDSAI_MODEL_URL = `https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/${BARDSAI_MODEL_REVISION}/onnx/model_quantized.onnx`;
|
|
28
|
+
const MODEL_URL = BARDSAI_MODEL_URL;
|
|
29
|
+
/** Pre-pinning download URL; its cache entry is evicted best-effort on load. */
|
|
30
|
+
const LEGACY_MODEL_URL = 'https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/main/onnx/model_quantized.onnx';
|
|
17
31
|
const TOKENIZER_HF = 'bardsai/eu-pii-anonimization-multilang';
|
|
18
32
|
const MODEL_NAME = 'BardS.ai EU PII';
|
|
19
33
|
const DEFAULT_THRESHOLD = 0.5;
|
|
@@ -102,8 +116,17 @@ export class BardsaiProvider {
|
|
|
102
116
|
this.loadWaiters = [];
|
|
103
117
|
this.progressCallback = null;
|
|
104
118
|
this.threshold = DEFAULT_THRESHOLD;
|
|
119
|
+
this.verification = null;
|
|
105
120
|
this.env = env;
|
|
106
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* SHA-256 verification result of the served model blob (null until the
|
|
124
|
+
* model has been verified against the pinned hash). Surfaced so the
|
|
125
|
+
* model-status UI can show the positive "Model verified" state.
|
|
126
|
+
*/
|
|
127
|
+
getVerification() {
|
|
128
|
+
return this.verification;
|
|
129
|
+
}
|
|
107
130
|
isLoaded() {
|
|
108
131
|
return this.session !== null && this.tokenizer !== null;
|
|
109
132
|
}
|
|
@@ -141,9 +164,18 @@ export class BardsaiProvider {
|
|
|
141
164
|
fetch: this.env.fetch,
|
|
142
165
|
persistStorage: this.env.persistStorage,
|
|
143
166
|
};
|
|
167
|
+
// Old cache entry from the pre-pinning resolve/main URL - drop it so
|
|
168
|
+
// a 279 MB stale copy does not linger against the origin quota.
|
|
169
|
+
void evictModelFromCache(loaderEnv, LEGACY_MODEL_URL);
|
|
144
170
|
// Load tokenizer and model in parallel (skip tokenizer if already loaded from a prior session)
|
|
145
171
|
const tasks = [
|
|
146
|
-
fetchModelBlob(loaderEnv, MODEL_URL, (downloaded, total) => this.progressCallback?.(downloaded, total)
|
|
172
|
+
fetchModelBlob(loaderEnv, MODEL_URL, (downloaded, total) => this.progressCallback?.(downloaded, total), {
|
|
173
|
+
sha256: BARDSAI_MODEL_SHA256,
|
|
174
|
+
onVerified: (verification) => {
|
|
175
|
+
this.verification = verification;
|
|
176
|
+
console.info(`[DocCloak] Model verified: ${this._name} SHA-256 ${verification.sha256.slice(0, 12)}... matches the published hash (see documentation/model-provenance.md)`);
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
147
179
|
];
|
|
148
180
|
if (!this.tokenizer) {
|
|
149
181
|
tasks.push(retryAsync(() => this.env.loadTokenizer(TOKENIZER_HF), 'Tokenizer download').then((t) => {
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { DetectedEntity, DetectionProvider, ProgressCallback } from '../types.js';
|
|
10
10
|
import type { CoreEnv } from '../env.js';
|
|
11
|
+
import type { ModelVerification } from '../model-loader.js';
|
|
12
|
+
export declare const GLINER_MODEL_REVISION = "9b7f39b0a2da971a5beea78d35f1539d4009c891";
|
|
13
|
+
/** SHA-256 of onnx/model_quint8.onnx (45,820,894 bytes) at GLINER_MODEL_REVISION. */
|
|
14
|
+
export declare const GLINER_MODEL_SHA256 = "988acb03456b26e2d9f2521016d820310c2ed64deb4a846297d3289f0c2eb7e4";
|
|
15
|
+
export declare const GLINER_MODEL_URL = "https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/9b7f39b0a2da971a5beea78d35f1539d4009c891/onnx/model_quint8.onnx";
|
|
11
16
|
export declare class GlinerProvider implements DetectionProvider {
|
|
12
17
|
private _name;
|
|
13
18
|
get name(): string;
|
|
@@ -20,7 +25,14 @@ export declare class GlinerProvider implements DetectionProvider {
|
|
|
20
25
|
private progressCallback;
|
|
21
26
|
private threshold;
|
|
22
27
|
private customLabels;
|
|
28
|
+
private verification;
|
|
23
29
|
constructor(env: CoreEnv);
|
|
30
|
+
/**
|
|
31
|
+
* SHA-256 verification result of the served model blob (null until the
|
|
32
|
+
* model has been verified against the pinned hash). Surfaced so the
|
|
33
|
+
* model-status UI can show the positive "Model verified" state.
|
|
34
|
+
*/
|
|
35
|
+
getVerification(): ModelVerification | null;
|
|
24
36
|
isLoaded(): boolean;
|
|
25
37
|
isLoading(): boolean;
|
|
26
38
|
onProgress(callback: ProgressCallback): void;
|
package/dist/providers/gliner.js
CHANGED
|
@@ -7,9 +7,23 @@
|
|
|
7
7
|
* caches, navigator or @huggingface/transformers directly.
|
|
8
8
|
*/
|
|
9
9
|
import * as ort from 'onnxruntime-web';
|
|
10
|
-
import { fetchModelBlob, retryAsync } from "../model-loader.js";
|
|
10
|
+
import { evictModelFromCache, fetchModelBlob, retryAsync } from "../model-loader.js";
|
|
11
11
|
// ── Model config ──────────────────────────────────────────
|
|
12
|
-
|
|
12
|
+
// Supply-chain pinning (T116): the model is fetched from an immutable
|
|
13
|
+
// commit revision, never from mutable resolve/main, and the downloaded
|
|
14
|
+
// blob is verified against a pinned SHA-256. A model update is a
|
|
15
|
+
// deliberate act: bump GLINER_MODEL_REVISION and GLINER_MODEL_SHA256
|
|
16
|
+
// together in a reviewed commit and update
|
|
17
|
+
// documentation/model-provenance.md.
|
|
18
|
+
// Revision: main of knowledgator/gliner-pii-edge-v1.0 as of its last
|
|
19
|
+
// modification 2026-03-26; pinned and hashed 2026-08-10.
|
|
20
|
+
export const GLINER_MODEL_REVISION = '9b7f39b0a2da971a5beea78d35f1539d4009c891';
|
|
21
|
+
/** SHA-256 of onnx/model_quint8.onnx (45,820,894 bytes) at GLINER_MODEL_REVISION. */
|
|
22
|
+
export const GLINER_MODEL_SHA256 = '988acb03456b26e2d9f2521016d820310c2ed64deb4a846297d3289f0c2eb7e4';
|
|
23
|
+
export const GLINER_MODEL_URL = `https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/${GLINER_MODEL_REVISION}/onnx/model_quint8.onnx`;
|
|
24
|
+
const DEFAULT_MODEL_URL = GLINER_MODEL_URL;
|
|
25
|
+
/** Pre-pinning download URL; its cache entry is evicted best-effort on load. */
|
|
26
|
+
const LEGACY_MODEL_URL = 'https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/main/onnx/model_quint8.onnx';
|
|
13
27
|
const DEFAULT_TOKENIZER_HF = 'knowledgator/gliner-pii-edge-v1.0';
|
|
14
28
|
const DEFAULT_MODEL_NAME = 'GLiNER PII Edge';
|
|
15
29
|
const CUSTOM_LABELS_STORAGE_KEY = 'doccloak-custom-labels';
|
|
@@ -101,8 +115,17 @@ export class GlinerProvider {
|
|
|
101
115
|
this.progressCallback = null;
|
|
102
116
|
this.threshold = DEFAULT_THRESHOLD;
|
|
103
117
|
this.customLabels = [];
|
|
118
|
+
this.verification = null;
|
|
104
119
|
this.env = env;
|
|
105
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* SHA-256 verification result of the served model blob (null until the
|
|
123
|
+
* model has been verified against the pinned hash). Surfaced so the
|
|
124
|
+
* model-status UI can show the positive "Model verified" state.
|
|
125
|
+
*/
|
|
126
|
+
getVerification() {
|
|
127
|
+
return this.verification;
|
|
128
|
+
}
|
|
106
129
|
isLoaded() {
|
|
107
130
|
return this.session !== null && this.tokenizer !== null;
|
|
108
131
|
}
|
|
@@ -176,9 +199,18 @@ export class GlinerProvider {
|
|
|
176
199
|
fetch: this.env.fetch,
|
|
177
200
|
persistStorage: this.env.persistStorage,
|
|
178
201
|
};
|
|
202
|
+
// Old cache entry from the pre-pinning resolve/main URL - drop it so
|
|
203
|
+
// a 45 MB stale copy does not linger against the origin quota.
|
|
204
|
+
void evictModelFromCache(loaderEnv, LEGACY_MODEL_URL);
|
|
179
205
|
// Load tokenizer and model in parallel (skip tokenizer if already loaded from a prior session)
|
|
180
206
|
const tasks = [
|
|
181
|
-
fetchModelBlob(loaderEnv, DEFAULT_MODEL_URL, (downloaded, total) => this.progressCallback?.(downloaded, total)
|
|
207
|
+
fetchModelBlob(loaderEnv, DEFAULT_MODEL_URL, (downloaded, total) => this.progressCallback?.(downloaded, total), {
|
|
208
|
+
sha256: GLINER_MODEL_SHA256,
|
|
209
|
+
onVerified: (verification) => {
|
|
210
|
+
this.verification = verification;
|
|
211
|
+
console.info(`[DocCloak] Model verified: ${this._name} SHA-256 ${verification.sha256.slice(0, 12)}... matches the published hash (see documentation/model-provenance.md)`);
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
182
214
|
];
|
|
183
215
|
if (!this.tokenizer) {
|
|
184
216
|
tasks.push(retryAsync(() => this.env.loadTokenizer(DEFAULT_TOKENIZER_HF), 'Tokenizer download').then((t) => {
|
|
@@ -235,7 +267,7 @@ export class GlinerProvider {
|
|
|
235
267
|
const allSpans = [];
|
|
236
268
|
onProgress?.(0);
|
|
237
269
|
if (words.length <= chunkSize) {
|
|
238
|
-
// Single chunk
|
|
270
|
+
// Single chunk - no splitting needed
|
|
239
271
|
const spans = await this.inferChunk(words, starts, ends, text, promptTokens, activeLabels);
|
|
240
272
|
allSpans.push(...spans);
|
|
241
273
|
onProgress?.(1);
|