@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 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 { GlinerProvider } from './providers/gliner.js';
15
- export { BardsaiProvider } from './providers/bardsai.js';
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";
@@ -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
@@ -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
- onProgress?.(cachedBlob.size, cachedBlob.size);
157
- return cachedBlob;
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
- await tryCachePut(cache, url, blob);
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
- const MODEL_URL = 'https://huggingface.co/bardsai/eu-pii-anonimization-multilang/resolve/main/onnx/model_quantized.onnx';
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;
@@ -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
- const DEFAULT_MODEL_URL = 'https://huggingface.co/knowledgator/gliner-pii-edge-v1.0/resolve/main/onnx/model_quint8.onnx';
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 — no splitting needed
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);