@link-foundation/sensitive-data-sanitizer 0.17.1 → 0.19.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.
@@ -1,8 +1,14 @@
1
1
  import { collectMatches } from './detection.js';
2
+ import {
3
+ detectDatedEmployers,
4
+ isDocumentTechnology,
5
+ } from './document-experience.js';
6
+ import { isDocumentShareLink } from './proper-names.js';
2
7
 
3
8
  const boundary = '[\\p{L}\\p{N}_]';
4
9
  const proper = '[\\p{Lu}][\\p{L}\\p{M}\\p{N}.-]{0,46}[\\p{L}\\p{M}\\p{N}]';
5
10
  const name = `${proper}(?:[ \\t]+${proper}){0,5}`;
11
+ const employerName = `(?:«[^«»\\r\\n]{1,160}»|“[^“”\\r\\n]{1,160}”|"[^"\\r\\n]{1,160}"|${name})`;
6
12
  const orgList = `(?:«[^«»\\r\\n]{1,160}»|${name})(?:[ \\t]*(?:,|и|and)[ \\t]+${name}){0,5}`;
7
13
  const cues = new RegExp(
8
14
  `(?<!${boundary})(?:компании|опыт[ \\t]+в|проекты[ \\t]+для|[Рр]оль[ \\t]+в)[ \\t]+(${orgList})(?!${boundary})`,
@@ -12,13 +18,42 @@ const duration = new RegExp(
12
18
  `(?<!${boundary})(${name})(?=[ \\t]*\\(\\d{1,2}[ \\t]+(?:год|года|лет|years?)\\))`,
13
19
  'gu'
14
20
  );
15
- // JavaScript's \b is ASCII-only; Cyrillic titles use a Unicode lookahead.
16
- const role =
17
- /^(?:менеджер|директор|руководитель|инженер|аналитик|разработчик)(?![\p{L}])|^(?:manager|director|engineer|developer|senior|qa)\b/iu;
18
-
19
- export function detectDocumentExtras(text, emit, range) {
21
+ export function detectDocumentExtras(text, emit, range, debug, options) {
20
22
  for (const [type, rule, pattern, group] of [
21
23
  ['PHONE', 'document-mobile-bare', /^[78]9\d{9}$/gm, 0],
24
+ [
25
+ 'PHONE',
26
+ 'document-mobile-comma',
27
+ /,[ \t]*([78]9\d{9})(?![\p{L}\p{N}_])/gu,
28
+ 1,
29
+ ],
30
+ [
31
+ 'ORGANIZATION',
32
+ 'organization-group-cue',
33
+ new RegExp(
34
+ `(?<!${boundary})(?:ГК|[Гг]руппа компаний)[ \\t]+${employerName}`,
35
+ 'gu'
36
+ ),
37
+ 0,
38
+ ],
39
+ [
40
+ 'EDUCATION',
41
+ 'education-bracket',
42
+ /(?<=\()(МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ)(?=[,)])/gu,
43
+ 1,
44
+ ],
45
+ [
46
+ 'EDUCATION',
47
+ 'education-named-acronym',
48
+ /(?<![\p{L}\p{N}_])(?:МГУ|СПбГУ|МГТУ|РГГУ|ВШЭ)[ \t]+им\.[ \t]+[А-ЯЁ][а-яё-]{2,40}/gu,
49
+ 0,
50
+ ],
51
+ [
52
+ 'EDUCATION',
53
+ 'education-training',
54
+ /(?<![\p{L}\p{N}_])(?:Обучение|Training)[ \t]*:[ \t]*([\p{Lu}][\p{L}\p{M}.-]{1,47})/gu,
55
+ 1,
56
+ ],
22
57
  [
23
58
  'PHONE',
24
59
  'document-mobile-context',
@@ -56,7 +91,8 @@ export function detectDocumentExtras(text, emit, range) {
56
91
  pattern,
57
92
  emit,
58
93
  { type, rule, category: 'personal', confidence: 0.85 },
59
- group
94
+ group,
95
+ (value) => type !== 'ORGANIZATION' || !isDocumentTechnology(value)
60
96
  );
61
97
  }
62
98
  for (const match of text.matchAll(cues)) {
@@ -64,6 +100,9 @@ export function detectDocumentExtras(text, emit, range) {
64
100
  for (const company of match[1].matchAll(
65
101
  new RegExp(`«[^«»\\r\\n]{1,160}»|${name}`, 'gu')
66
102
  )) {
103
+ if (isDocumentTechnology(company[0])) {
104
+ continue;
105
+ }
67
106
  emit({
68
107
  start: offset + company.index,
69
108
  end: offset + company.index + company[0].length,
@@ -75,7 +114,57 @@ export function detectDocumentExtras(text, emit, range) {
75
114
  }
76
115
  }
77
116
  detectQueryContacts(text, emit);
78
- detectExperience(text, emit, range);
117
+ collectMatches(
118
+ text,
119
+ /https?:\/\/[^\s"<>\\]+/gu,
120
+ emit,
121
+ {
122
+ type: 'URL',
123
+ category: 'personal',
124
+ rule: 'document-share-path',
125
+ confidence: 0.85,
126
+ },
127
+ 0,
128
+ isDocumentShareLink
129
+ );
130
+ detectEmployerContinuation(text, emit);
131
+ detectDatedEmployers(text, emit, range, debug, options);
132
+ }
133
+
134
+ function detectEmployerContinuation(text, emit) {
135
+ const continuation = new RegExp(
136
+ `,[ \\t]*(?:потом|затем)[ \\t]+в[ \\t]+(${employerName})`,
137
+ 'uy'
138
+ );
139
+ for (const match of text.matchAll(
140
+ new RegExp(
141
+ `(?<!${boundary})[Рр]аботал[аи]?[ \\t]+в[ \\t]+(${employerName})`,
142
+ 'gu'
143
+ )
144
+ )) {
145
+ continuation.lastIndex = match.index + match[0].length;
146
+ let next = continuation.exec(text);
147
+ if (next) {
148
+ emitEmployer(match);
149
+ }
150
+ while (next) {
151
+ emitEmployer(next);
152
+ next = continuation.exec(text);
153
+ }
154
+ }
155
+ function emitEmployer(match) {
156
+ const end = match.index + match[0].length;
157
+ if (!isDocumentTechnology(match[1])) {
158
+ emit({
159
+ start: end - match[1].length,
160
+ end,
161
+ type: 'ORGANIZATION',
162
+ category: 'personal',
163
+ rule: 'organization-continuation',
164
+ confidence: 0.85,
165
+ });
166
+ }
167
+ }
79
168
  }
80
169
 
81
170
  function detectQueryContacts(text, emit) {
@@ -111,60 +200,3 @@ function detectQueryContacts(text, emit) {
111
200
  });
112
201
  }
113
202
  }
114
-
115
- function detectExperience(text, emit, range) {
116
- // Date anchors bound each entry; do not guess at undated employer/title fragments.
117
- collectMatches(
118
- text,
119
- new RegExp(
120
- `(?:^|[\\r\\n])[ \\t]*[^|\\r\\n]{1,120}\\|[ \\t]*(${name})[ \\t]*\\|[ \\t]*${range}`,
121
- 'gu'
122
- ),
123
- emit,
124
- {
125
- type: 'ORGANIZATION',
126
- rule: 'organization-experience',
127
- category: 'personal',
128
- confidence: 0.85,
129
- },
130
- 1
131
- );
132
- const dated = new RegExp(`([^\\r\\n;|]{1,320}?)\\((${range})\\)`, 'gu');
133
- for (const match of text.matchAll(dated)) {
134
- const prefix = match[1];
135
- const content = prefix
136
- .replace(/^.*\(\d[^)]*\)[, \t]*/, '')
137
- .replace(/^[, \t]+/, '');
138
- const chunks = [...content.matchAll(/[^,]+/g)];
139
- const parts = chunks.map((part) => part[0].trim());
140
- let value;
141
- if (parts.length > 1 && role.test(parts[0])) {
142
- let index = 1;
143
- while (index < parts.length - 1 && /^[\p{Ll}]/u.test(parts[index])) {
144
- index++;
145
- }
146
- value = content.slice(chunks[index].index).trim();
147
- } else if (parts.length === 2 && role.test(parts[1])) {
148
- value = parts[0].trim();
149
- }
150
- if (
151
- !value ||
152
- !/^[\p{Lu}«“"]/u.test(value) ||
153
- value.includes('[REDACTED]')
154
- ) {
155
- continue;
156
- }
157
- const start = match.index + prefix.lastIndexOf(value);
158
- if (start < match.index) {
159
- continue;
160
- }
161
- emit({
162
- start,
163
- end: start + value.length,
164
- type: 'ORGANIZATION',
165
- category: 'personal',
166
- rule: 'organization-experience',
167
- confidence: 0.85,
168
- });
169
- }
170
- }
@@ -15,5 +15,7 @@ const types = new Map(
15
15
  )
16
16
  );
17
17
  export function documentFieldType(field) {
18
- return types.get(field.replace(/[_-]/g, '').toLowerCase());
18
+ return types.get(normalizeFieldName(field));
19
19
  }
20
+ export const normalizeFieldName = (field) =>
21
+ field.replace(/[_-]/g, '').toLowerCase();
@@ -1,71 +1,83 @@
1
1
  // Authored, deliberately finite vocabulary; no network or model downloads.
2
2
  // Each row follows localeVocabulary: locale, legal forms, school types, places.
3
- export const documentVocabulary = [
3
+ export const documentVocabulary = Object.freeze(
4
4
  [
5
- 'en',
6
- 'Inc|LLC|Ltd|PLC|Corporation',
7
- 'University|College|Institute|School',
8
- 'Springfield|Riverton|London|New York|California',
9
- ],
10
- [
11
- 'es',
12
- 'S.A.|S.L.|S.L.U.',
13
- 'Universidad|Colegio|Instituto|Escuela',
14
- 'Madrid|Barcelona|Andalucía',
15
- ],
16
- [
17
- 'fr',
18
- 'SARL|SAS|SA|S.A.',
19
- 'Université|Collège|Institut|École',
20
- 'Paris|Lyon|Bretagne',
21
- ],
22
- [
23
- 'de',
24
- 'GmbH|AG|KG|UG',
25
- 'Universität|Hochschule|Institut|Schule',
26
- 'Berlin|München|Bayern',
27
- ],
28
- [
29
- 'pt',
30
- 'Ltda|S.A.|Unipessoal',
31
- 'Universidade|Faculdade|Instituto|Escola',
32
- 'Lisboa|São Paulo|Algarve',
33
- ],
34
- [
35
- 'ru',
36
- 'ООО|АО|ПАО|ИП|ЗАО',
37
- 'Университет|университет|Институт|институт|Школа|школа',
38
- 'Москва|Санкт-Петербург|Казань|Татарстан|Ярославль|Кострома|Новосибирск|Екатеринбург|Нижний Новгород|Самара|Омск|Челябинск|Ростов-на-Дону|Уфа|Красноярск|Пермь|Воронеж|Волгоград|Саратов|Тюмень|Тольятти|Ижевск|Барнаул|Иркутск|Хабаровск|Владивосток|Махачкала|Томск|Оренбург|Кемерово|Рязань|Астрахань|Пенза|Липецк|Киров|Чебоксары|Тула|Калининград|Курск|Улан-Удэ|Ставрополь|Сочи|Тверь|Брянск|Белгород|Владимир|Архангельск|Калуга|Смоленск|Вологда|Саранск|Орёл',
39
- ],
40
- ['ja', '株式会社|有限会社|合同会社', '大学|学院|学校', '東京|大阪|北海道'],
41
- ['zh', '有限公司|股份有限公司', '大学|学院|学校', '北京|上海|广东'],
42
- ['zh-Hant', '有限公司|股份有限公司', '大學|學院|學校', '臺北|台北|高雄|臺灣'],
43
- ['ko', '주식회사|유한회사', '대학교|대학|학교', '서울|부산|경기도'],
44
- ['ar', 'ذ.م.م|شركة', 'جامعة|معهد|مدرسة', 'القاهرة|دبي|الرياض'],
45
- [
46
- 'he',
47
- 'בע״מ|בע"מ|חברה',
48
- 'אוניברסיטת|אוניברסיטה|מכללת|מכון',
49
- 'ירושלים|תל אביב|חיפה',
50
- ],
51
- [
52
- 'hi',
53
- 'प्राइवेट लिमिटेड|लिमिटेड',
54
- 'विश्वविद्यालय|महाविद्यालय|संस्थान',
55
- 'दिल्ली|मुंबई|महाराष्ट्र',
56
- ],
57
- [
58
- 'tr',
59
- 'A.Ş.|Ltd. Şti.',
60
- 'Üniversitesi|Üniversite|Enstitüsü|Okulu',
61
- 'İstanbul|Ankara|İzmir',
62
- ],
63
- ['id', 'PT|CV|Tbk', 'Universitas|Institut|Sekolah', 'Jakarta|Bandung|Bali'],
64
- ['vi', 'TNHH|CTCP', 'Đại học|Trường|Học viện', 'Hà Nội|Hồ Chí Minh|Đà Nẵng'],
65
- [
66
- 'th',
67
- 'บริษัท|จำกัด',
68
- 'มหาวิทยาลัย|วิทยาลัย|โรงเรียน',
69
- 'กรุงเทพมหานคร|เชียงใหม่|ภูเก็ต',
70
- ],
71
- ];
5
+ [
6
+ 'en',
7
+ 'Inc|LLC|Ltd|PLC|Corporation',
8
+ 'University|College|Institute|School',
9
+ 'Springfield|Riverton|London|New York|California',
10
+ ],
11
+ [
12
+ 'es',
13
+ 'S.A.|S.L.|S.L.U.',
14
+ 'Universidad|Colegio|Instituto|Escuela',
15
+ 'Madrid|Barcelona|Andalucía',
16
+ ],
17
+ [
18
+ 'fr',
19
+ 'SARL|SAS|SA|S.A.',
20
+ 'Université|Collège|Institut|École',
21
+ 'Paris|Lyon|Bretagne',
22
+ ],
23
+ [
24
+ 'de',
25
+ 'GmbH|AG|KG|UG',
26
+ 'Universität|Hochschule|Institut|Schule',
27
+ 'Berlin|München|Bayern',
28
+ ],
29
+ [
30
+ 'pt',
31
+ 'Ltda|S.A.|Unipessoal',
32
+ 'Universidade|Faculdade|Instituto|Escola',
33
+ 'Lisboa|São Paulo|Algarve',
34
+ ],
35
+ [
36
+ 'ru',
37
+ 'ООО|АО|ПАО|ИП|ЗАО',
38
+ 'Университет|университет|Институт|институт|Школа|школа',
39
+ 'Москва|Санкт-Петербург|Казань|Татарстан|Ярославль|Кострома|Новосибирск|Екатеринбург|Нижний Новгород|Самара|Омск|Челябинск|Ростов-на-Дону|Уфа|Красноярск|Пермь|Воронеж|Волгоград|Саратов|Тюмень|Тольятти|Ижевск|Барнаул|Иркутск|Хабаровск|Владивосток|Махачкала|Томск|Оренбург|Кемерово|Рязань|Астрахань|Пенза|Липецк|Киров|Чебоксары|Тула|Калининград|Курск|Улан-Удэ|Ставрополь|Сочи|Тверь|Брянск|Белгород|Владимир|Архангельск|Калуга|Смоленск|Вологда|Саранск|Орёл',
40
+ ],
41
+ ['ja', '株式会社|有限会社|合同会社', '大学|学院|学校', '東京|大阪|北海道'],
42
+ ['zh', '有限公司|股份有限公司', '大学|学院|学校', '北京|上海|广东'],
43
+ [
44
+ 'zh-Hant',
45
+ '有限公司|股份有限公司',
46
+ '大學|學院|學校',
47
+ '臺北|台北|高雄|臺灣',
48
+ ],
49
+ ['ko', '주식회사|유한회사', '대학교|대학|학교', '서울|부산|경기도'],
50
+ ['ar', 'ذ.م.م|شركة', 'جامعة|معهد|مدرسة', 'القاهرة|دبي|الرياض'],
51
+ [
52
+ 'he',
53
+ 'בע״מ|בע"מ|חברה',
54
+ 'אוניברסיטת|אוניברסיטה|מכללת|מכון',
55
+ 'ירושלים|תל אביב|חיפה',
56
+ ],
57
+ [
58
+ 'hi',
59
+ 'प्राइवेट लिमिटेड|लिमिटेड',
60
+ 'विश्वविद्यालय|महाविद्यालय|संस्थान',
61
+ 'दिल्ली|मुंबई|महाराष्ट्र',
62
+ ],
63
+ [
64
+ 'tr',
65
+ 'A.Ş.|Ltd. Şti.',
66
+ 'Üniversitesi|Üniversite|Enstitüsü|Okulu',
67
+ 'İstanbul|Ankara|İzmir',
68
+ ],
69
+ ['id', 'PT|CV|Tbk', 'Universitas|Institut|Sekolah', 'Jakarta|Bandung|Bali'],
70
+ [
71
+ 'vi',
72
+ 'TNHH|CTCP',
73
+ 'Đại học|Trường|Học viện',
74
+ 'Hà Nội|Hồ Chí Minh|Đà Nẵng',
75
+ ],
76
+ [
77
+ 'th',
78
+ 'บริษัท|จำกัด',
79
+ 'มหาวิทยาลัย|วิทยาลัย|โรงเรียน',
80
+ 'กรุงเทพมหานคร|เชียงใหม่|ภูเก็ต',
81
+ ],
82
+ ].map((row) => Object.freeze(row))
83
+ );
package/src/engines.js CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  import { failure, internalFailure } from './detection.js';
17
17
  import { projectText } from './projection.js';
18
18
  import { decodedRuns } from './encoded.js';
19
+ import { hasCustomTransforms } from './transforms.js';
20
+ import { builtInEnabled } from './policy.js';
19
21
  import {
20
22
  initializePlaceholders,
21
23
  placeholderKnown,
@@ -70,7 +72,8 @@ async function secretlintDetect(text) {
70
72
  }));
71
73
  }
72
74
 
73
- async function detect(engine, text, limit) {
75
+ const secretlintEngine = { detect: secretlintDetect };
76
+ async function detect(engine, text, limit, options) {
74
77
  try {
75
78
  const found = await engine.detect(text);
76
79
  if (!Array.isArray(found)) {
@@ -79,7 +82,12 @@ async function detect(engine, text, limit) {
79
82
  if (found.length > limit) {
80
83
  throw failure('ERR_LIMIT');
81
84
  }
82
- return found.map((finding) => sanitizeFinding(finding, text.length));
85
+ return found
86
+ .map((finding) => sanitizeFinding(finding, text.length))
87
+ .filter(
88
+ (finding) =>
89
+ engine !== secretlintEngine || builtInEnabled(finding, options)
90
+ );
83
91
  } catch {
84
92
  throw failure('ERR_ENGINE');
85
93
  }
@@ -88,7 +96,7 @@ async function detect(engine, text, limit) {
88
96
  async function engineFindings(engine, projection, options, emit) {
89
97
  const limit = options.maxFindings ?? 100000;
90
98
  if (projection.changed) {
91
- for (const f of await detect(engine, projection.text, limit)) {
99
+ for (const f of await detect(engine, projection.text, limit, options)) {
92
100
  emit({
93
101
  ...f,
94
102
  start: projection.starts[f.start],
@@ -106,7 +114,7 @@ async function engineFindings(engine, projection, options, emit) {
106
114
  const view = projectText(run.text).text;
107
115
  const decoded = markFake(
108
116
  view,
109
- await detect(engine, view, limit),
117
+ await detect(engine, view, limit, options),
110
118
  options
111
119
  ).filter((f) => actionable(f, options));
112
120
  if (!decoded.length) {
@@ -124,6 +132,28 @@ async function engineFindings(engine, projection, options, emit) {
124
132
  }
125
133
  }
126
134
 
135
+ async function collectEngineFindings(text, engines, options, emit) {
136
+ const projection = engines.length ? projectText(text) : undefined;
137
+ for (const engine of engines) {
138
+ if (engine !== secretlintEngine && !options.detectors?.includes(engine)) {
139
+ continue;
140
+ }
141
+ for (const finding of await detect(
142
+ engine,
143
+ text,
144
+ options.maxFindings ?? 100000,
145
+ options
146
+ )) {
147
+ emit(finding);
148
+ }
149
+ await engineFindings(engine, projection, options, (finding) => {
150
+ if (builtInEnabled(finding, options)) {
151
+ emit(finding);
152
+ }
153
+ });
154
+ }
155
+ }
156
+
127
157
  async function analyzeStructured(
128
158
  text,
129
159
  initial,
@@ -140,7 +170,14 @@ async function analyzeStructured(
140
170
  batch.text,
141
171
  [],
142
172
  true,
143
- options,
173
+ {
174
+ ...options,
175
+ detectors: [],
176
+ properNames: undefined,
177
+ documentEntities: options.properNames
178
+ ? false
179
+ : options.documentEntities,
180
+ },
144
181
  nativeFindings === undefined ? undefined : []
145
182
  ),
146
183
  options
@@ -152,7 +189,11 @@ async function analyzeStructured(
152
189
  view.text,
153
190
  [],
154
191
  true,
155
- { ...options, structured: undefined },
192
+ {
193
+ ...options,
194
+ structured: undefined,
195
+ properNames: view.prose ? options.properNames : undefined,
196
+ },
156
197
  nativeFindings === undefined ? undefined : []
157
198
  );
158
199
  for (const finding of detected) {
@@ -189,9 +230,7 @@ export function createSanitizer(options = {}) {
189
230
  }
190
231
  }
191
232
  const engines =
192
- options.secretlint === false || options.knownOnly
193
- ? []
194
- : [{ detect: secretlintDetect }];
233
+ options.secretlint === false || options.knownOnly ? [] : [secretlintEngine];
195
234
  if (!options.knownOnly) {
196
235
  engines.push(...(options.detectors ?? []));
197
236
  }
@@ -220,20 +259,15 @@ export function createSanitizer(options = {}) {
220
259
  }
221
260
  // Native inspection performs its own normalization. Additional views are
222
261
  // needed here only when a required external engine is enabled.
223
- const projection = engines.length ? projectText(text) : undefined;
224
- for (const engine of engines) {
225
- for (const finding of await detect(
226
- engine,
227
- text,
228
- options.maxFindings ?? 100000
229
- )) {
230
- emit(finding);
231
- }
232
- await engineFindings(engine, projection, options, emit);
233
- }
262
+ await collectEngineFindings(text, engines, options, emit);
234
263
  const detected =
235
264
  nativeFindings === undefined
236
- ? inspect(text, { ...options, structured: undefined, findings })
265
+ ? inspect(text, {
266
+ ...options,
267
+ structured: undefined,
268
+ detectors: [],
269
+ findings,
270
+ })
237
271
  : reviewFindings(text, [...nativeFindings, ...findings], options);
238
272
  return options.verifyPublic
239
273
  ? verifyFindings(text, detected, options.verifyPublic)
@@ -287,16 +321,17 @@ export function createSanitizer(options = {}) {
287
321
  findings,
288
322
  fullRedaction ? fullOptions : options,
289
323
  (decoded, recursiveOptions) =>
290
- depth < 2
291
- ? sanitizeText(
292
- decoded,
293
- depth + 1,
294
- fullRedaction,
295
- true,
296
- recursiveOptions
297
- )
298
- : Promise.resolve({ text: '[REDACTED]' })
324
+ sanitizeNested(
325
+ sanitizeText,
326
+ decoded,
327
+ depth,
328
+ fullRedaction,
329
+ recursiveOptions
330
+ )
299
331
  );
332
+ if (await customResidual(result.text, options, plain, analyze)) {
333
+ throw failure('ERR_RESIDUAL');
334
+ }
300
335
  const sanitized = fullRedaction
301
336
  ? result.text
302
337
  : (
@@ -305,9 +340,13 @@ export function createSanitizer(options = {}) {
305
340
  findings,
306
341
  fullOptions,
307
342
  (decoded, recursiveOptions) =>
308
- depth < 2
309
- ? sanitizeText(decoded, depth + 1, true, true, recursiveOptions)
310
- : Promise.resolve({ text: '[REDACTED]' })
343
+ sanitizeNested(
344
+ sanitizeText,
345
+ decoded,
346
+ depth,
347
+ true,
348
+ recursiveOptions
349
+ )
311
350
  )
312
351
  ).text;
313
352
  // Verification at the publication boundary runs every enabled engine.
@@ -320,15 +359,21 @@ export function createSanitizer(options = {}) {
320
359
  sanitizeText(result.text, depth, fullRedaction, plain, next, nextRound)
321
360
  );
322
361
  }
323
- if (options.structured && !plain) {
324
- verifyStructured(result.text, options);
325
- }
362
+ verifyStructuredResult(result.text, options, plain);
326
363
  if (await placeholderResidual(result.text, options, plain, analyze)) {
327
364
  throw failure('ERR_RESIDUAL');
328
365
  }
329
366
  return { ...result, findings: auditFaked(findings, options, result) };
330
367
  }
331
368
  }
369
+ async function customResidual(text, options, plain, analyze) {
370
+ return (
371
+ hasCustomTransforms(options) &&
372
+ (await analyze(text, [], plain, options)).some((f) =>
373
+ actionable(f, options)
374
+ )
375
+ );
376
+ }
332
377
 
333
378
  function guarded(callback, cleanup = () => {}) {
334
379
  return async (...args) => {
@@ -399,3 +444,15 @@ async function verifyFindings(text, findings, verifier) {
399
444
  }
400
445
  return retained;
401
446
  }
447
+
448
+ function sanitizeNested(sanitizeText, decoded, depth, fullRedaction, options) {
449
+ return depth < 2
450
+ ? sanitizeText(decoded, depth + 1, fullRedaction, true, options)
451
+ : Promise.resolve({ text: '[REDACTED]' });
452
+ }
453
+
454
+ function verifyStructuredResult(text, options, plain) {
455
+ if (options.structured && !plain) {
456
+ verifyStructured(text, options);
457
+ }
458
+ }