Mirrored from GitHub
github.com/roostorg/coop
81 kB
2562 lines
1/* eslint-disable max-lines */
2import type { Exception } from '@opentelemetry/api';
3import { makeEnumLike, type ItemIdentifier } from '@roostorg/coop-types';
4import _Ajv from 'ajv';
5import { sql, type Kysely } from 'kysely';
6import _ from 'lodash';
7import { FormData } from 'undici';
8import { js2xml } from 'xml-js';
9
10import { type Dependencies } from '../../iocContainer/index.js';
11import { jsonStringify } from '../../utils/encoding.js';
12import { type JSONSchemaV4 } from '../../utils/json-schema-types.js';
13import { type FixKyselyRowCorrelation } from '../../utils/kysely.js';
14import { logErrorJson } from '../../utils/logging.js';
15import { assertUnreachable, withRetries } from '../../utils/misc.js';
16import {
17 type CollapseCases,
18 type NonEmptyArray,
19} from '../../utils/typescript-types.js';
20import { rawItemSubmissionToItemSubmission } from '../itemProcessingService/makeItemSubmission.js';
21import { type RawItemData } from '../itemProcessingService/toNormalizedItemDataOrErrors.js';
22import {
23 rawItemSubmissionSchema,
24 type RawItemSubmission,
25} from '../itemProcessingService/types.js';
26import type {
27 NCMECReportedContentInThread,
28 NCMECThreadReport,
29} from '../manualReviewToolService/modules/JobDecisioning.js';
30import {
31 makeFormDataLikeWithStreams,
32 type FormDataLikeWithStreams,
33} from '../networkingService/index.js';
34import { type NcmecReportingServicePg } from './dbTypes.js';
35import {
36 ncmecDebugDump,
37 ncmecDebugEnabled,
38 ncmecDebugLog,
39} from './ncmecDebug.js';
40import { summarizeNcmecErrorForReviewer } from './ncmecReviewerErrors.js';
41
42export const NCMECEvent = makeEnumLike([
43 'Login',
44 'Registration',
45 'Purchase',
46 'Upload',
47 'Other',
48 'Unknown',
49]);
50export type NCMECEventType = keyof typeof NCMECEvent;
51
52export const NCMECIndustryClassification = makeEnumLike([
53 'A1',
54 'A2',
55 'B1',
56 'B2',
57]);
58export type NCMECIndustryClassificationType =
59 keyof typeof NCMECIndustryClassification;
60
61export const NCMECFileAnnotation = makeEnumLike([
62 'ANIME_DRAWING_VIRTUAL_HENTAI',
63 'POTENTIAL_MEME',
64 'VIRAL',
65 'POSSIBLE_SELF_PRODUCTION',
66 'PHYSICAL_HARM',
67 'VIOLENCE_GORE',
68 'BESTIALITY',
69 'LIVE_STREAMING',
70 'INFANT',
71 'GENERATIVE_AI',
72]);
73export type NCMECFileAnnotationType = keyof typeof NCMECFileAnnotation;
74
75export const NCMECIncidentType = makeEnumLike([
76 'Child Pornography (possession, manufacture, and distribution)',
77 'Child Sex Trafficking',
78 'Child Sex Tourism',
79 'Child Sexual Molestation',
80 'Misleading Domain Name',
81 'Misleading Words or Digital Images on the Internet',
82 'Online Enticement of Children for Sexual Acts',
83 'Unsolicited Obscene Material Sent to a Child',
84]);
85export type NCMECIncidentType = keyof typeof NCMECIncidentType;
86
87export const NCMECEmailType = makeEnumLike(['Home', 'Work', 'Business']);
88export type NCMECEmailType = keyof typeof NCMECEmailType;
89
90export type NCMECMediaReport = {
91 id: string;
92 typeId: string;
93 url: string;
94 fileAnnotations: readonly NCMECFileAnnotationType[];
95 industryClassification: NCMECIndustryClassificationType;
96};
97
98type NCMECEventInfo = {
99 eventName: NCMECEventType;
100 dateTime: string;
101};
102
103type IPNCMECEvent = NCMECEventInfo & {
104 ipAddress: string;
105 port?: number;
106 possibleProxy?: boolean;
107};
108
109type DeviceNCMECEvent = NCMECEventInfo & {
110 idType: string;
111 idValue: string;
112};
113
114type NCMECPerson = {
115 phone?: Phone;
116 email?: Email[];
117 firstName?: string;
118 lastName?: string;
119 deviceId?: DeviceNCMECEvent[];
120};
121
122// Key insertion order must match the NCMEC XSD `<fileDetails>` sequence
123// (Appendix C); see the `Report` type comment for why this matters.
124type FileDetails = {
125 fileDetails: {
126 reportId: number;
127 fileId: string;
128 originalFileName?: string;
129 locationOfFile?: string;
130 fileViewedByEsp?: boolean;
131 exifViewedByEsp?: boolean;
132 publiclyAvailable?: boolean;
133 fileRelevance?: 'Reported' | 'Supplemental Reported';
134 fileAnnotations?: FileAnnotations;
135 industryClassification?: NCMECIndustryClassificationType;
136 originalFileHash?: OriginalFileHash[];
137 ipCaptureEvent?: IPNCMECEvent[];
138 deviceId?: DeviceId[];
139 details?: Detail[];
140 additionalInfo?: string[];
141 };
142};
143
144type OriginalFileHash = {
145 _text: string;
146 _attributes: {
147 hashType: string;
148 };
149};
150
151type FileAnnotations = {
152 animeDrawingVirtualHentai?: undefined;
153 potentialMeme?: undefined;
154 viral?: undefined;
155 possibleSelfProduction?: undefined;
156 physicalHarm?: undefined;
157 violenceGore?: undefined;
158 bestiality?: undefined;
159 liveStreaming?: undefined;
160 infant?: undefined;
161 generativeAi?: undefined;
162};
163
164type Detail = {
165 nameValuePair: {
166 name: string;
167 value: string;
168 };
169 type?: 'EXIF' | 'HASH';
170};
171
172type Media = {
173 id: string;
174 typeId: string;
175 url: string;
176 createdAt: string;
177 industryClassification: NCMECIndustryClassificationType;
178 fileAnnotations?: readonly NCMECFileAnnotationType[];
179 /** Pre-built IP event(s); accepts a single event or an array. */
180 ipCaptureEvent?: IPNCMECEvent | readonly IPNCMECEvent[];
181 /** Bare IP from the `ipAddress` field role; appended as a synthesised
182 * `Upload` event. */
183 ipAddress?: string;
184 deviceId?: DeviceNCMECEvent[];
185 /** Hashes computed for this URL (typically by HMA at item submission
186 * time). Keyed by hash algorithm name (e.g. `md5`, `pdq`); the value is
187 * the hex-encoded hash. Forwarded to NCMEC as `originalFileHash`
188 * entries with the algorithm name uppercased into the `hashType`
189 * attribute. */
190 hashes?: Record<string, string>;
191};
192
193type NCMECUserParams = {
194 id: string;
195 typeId: string;
196 profilePicture?: string;
197 displayName?: string;
198 /** Pre-built IP event(s); accepts a single event or an array. */
199 ipCaptureEvent?: IPNCMECEvent | readonly IPNCMECEvent[];
200 /** Bare IP from the `ipAddress` field role; appended as a synthesised
201 * `Unknown` event. */
202 ipAddress?: string;
203 /** Bare email from the `email` field role. Used as the
204 * `personOrUserReportedPerson.email` when no external additional-info
205 * endpoint provided one. */
206 email?: string;
207};
208
209export type NCMECReportParams = {
210 reportedUser: NCMECUserParams;
211 orgId: string;
212 media: Media[];
213 threads: readonly NCMECThreadReport[];
214 reviewerId: string;
215 incidentType: string;
216 /** Optional reason for higher urgency; if present must be non-blank and max 3000 chars. */
217 escalateToHighPriority?: string;
218 /** Optional free-text notes; if present must be non-blank and max 3000 chars. */
219 additionalInfo?: string;
220 /** MRT decision id; when set, failures are recorded to `ncmec_reports_errors`. */
221 jobId?: string;
222};
223
224// Key insertion order in these object literals must match the NCMEC XSD
225// sequence (Appendix B): js2xml emits children in insertion order, and NCMEC
226// rejects out-of-order submissions with responseCode=4100.
227type Report = {
228 report: {
229 incidentSummary: {
230 incidentType: NCMECIncidentType;
231 escalateToHighPriority?: string;
232 incidentDateTime: string;
233 incidentDateTimeDescription?: string;
234 };
235 internetDetails?: (
236 | {
237 webPageIncident: {
238 url?: string;
239 additionalInfo?: string;
240 thirdPartyHostedContent?: boolean;
241 };
242 }
243 | {
244 emailIncident: {
245 emailAddress?: string[];
246 content?: string;
247 additionalInfo?: string;
248 };
249 }
250 | {
251 newsgroupIncident: {
252 name?: string;
253 emailAddress?: string[];
254 content?: string;
255 additionalInfo?: string;
256 };
257 }
258 | {
259 chatImIncident: {
260 chatClient?: string;
261 chatRoomName?: string;
262 content?: string;
263 additionalInfo?: string;
264 };
265 }
266 | {
267 onlineGamingIncident: {
268 gameName?: string;
269 console?: string;
270 content?: string;
271 additionalInfo?: string;
272 };
273 }
274 | {
275 cellPhoneIncident: {
276 phoneNumber?: Phone;
277 latitude?: number;
278 longitude?: number;
279 additionalInfo?: string;
280 };
281 }
282 | {
283 nonInternetIncident: {
284 locationName?: string;
285 incidentAddress?: Address[];
286 additionalInfo?: string;
287 };
288 }
289 | {
290 peer2peerIncident: {
291 client?: string;
292 ipCaptureEvent?: IpCaptureEvent[];
293 additionalInfo?: string;
294 };
295 }
296 )[];
297 lawEnforcement?: {
298 agencyName: string;
299 caseNumber?: string;
300 officerContact: NCMECPerson;
301 reportedToLe?: boolean;
302 servedLegalProcessDomestic?: boolean;
303 servedLegalPorcessInternational?: boolean;
304 };
305 reporter: {
306 reportingPerson: NCMECPerson;
307 contactPerson?: NCMECPerson;
308 companyTemplate?: string;
309 termsOfService?: string;
310 legalURL?: string;
311 };
312 personOrUserReported?: {
313 personOrUserReportedPerson?: NCMECPerson;
314 vehicleDescription?: string;
315 espIdentifier?: string;
316 espService?: string;
317 screenName?: string;
318 displayName?: string[];
319 profileUrl?: string[];
320 ipCaptureEvent?: IpCaptureEvent[];
321 deviceId?: DeviceId[];
322 thirdPartyUserReported?: boolean;
323 priorCTReports?: number[];
324 groupIdentifier?: string;
325 estimatedLocation?: EstimatedLocation;
326 additionalInfo?: string;
327 };
328 intendedRecipient?: {
329 intendedRecipientPerson: NCMECPerson;
330 espIdentifier?: string;
331 espService?: string;
332 screenName?: string;
333 displayName?: string[];
334 profileUrl?: string[];
335 ipCaptureEvent?: IpCaptureEvent[];
336 deviceId?: DeviceId[];
337 priorCTReports?: number[];
338 accountTemporarilyDisabled?: boolean;
339 accountPermanentlyDisabled?: boolean;
340 estimatedLocation?: EstimatedLocation;
341 additionalInfo?: string;
342 }[];
343 victim?: {
344 victimPerson: NCMECPerson;
345 espIdentifier?: string;
346 espService?: string;
347 screenName?: string;
348 displayName?: string[];
349 profileUrl?: string[];
350 ipCaptureEvent?: IpCaptureEvent[];
351 deviceId?: DeviceId[];
352 schoolName?: string;
353 priorCTReports?: number[];
354 estimatedLocation?: EstimatedLocation;
355 additionalInfo?: string;
356 }[];
357 additionalInfo?: string;
358 };
359};
360
361type EstimatedLocation = {
362 city?: string;
363 region?: string;
364 countryCode: string;
365 verified?: boolean;
366 timestamp?: string;
367};
368
369type DeviceId = (
370 | { idType: string; idValue: string }
371 | { idType: undefined; idValue: undefined }
372) & {
373 eventName?: NCMECEventType;
374 dateTime?: string;
375};
376
377type Phone = {
378 // _text should be the phone number
379 _text: string;
380 type?: 'Mobile' | 'Home' | 'Business' | 'Work' | 'Fax' | 'Internet';
381 verified?: boolean;
382 verificationDate?: string;
383 countryCallingCode?: string;
384 extension?: string;
385};
386
387type Email = {
388 // _text should be the email
389 _text: string;
390 _attributes?: {
391 type?: NCMECEmailType;
392 verified?: boolean;
393 verificationDate?: string;
394 };
395};
396
397type Address = {
398 address?: string;
399 city?: string;
400 zipCode?: string;
401 state?: string;
402 nonUsaState?: string;
403 country?: string;
404 type?: string;
405};
406
407type IpCaptureEvent = {
408 ipAddress: string;
409 eventName?: NCMECEventType;
410 dateTime?: string;
411 possibleProxy?: boolean;
412 port?: number;
413};
414
415const NCMEC_INTERNET_DETAIL_TYPES = [
416 'WEB_PAGE',
417 'EMAIL',
418 'NEWSGROUP',
419 'CHAT_IM',
420 'ONLINE_GAMING',
421 'CELL_PHONE',
422 'NON_INTERNET',
423 'PEER_TO_PEER',
424] as const;
425type NcmecInternetDetailTypeSetting =
426 (typeof NCMEC_INTERNET_DETAIL_TYPES)[number];
427
428/** Extract NCMEC's `responseCode`/`responseDescription` from any wrapper
429 * element (`reportResponse`, `uploadResponse`, ...). Either field may be
430 * `undefined` when not present. */
431function extractNcmecResponseStatus(body: unknown): {
432 responseCode?: string;
433 responseDescription?: string;
434} {
435 if (!body || typeof body !== 'object') {
436 return {};
437 }
438 const readText = (value: unknown): string | undefined => {
439 if (typeof value === 'string') {
440 return value;
441 }
442 if (
443 value &&
444 typeof value === 'object' &&
445 '_text' in (value as Record<string, unknown>)
446 ) {
447 const inner = (value as { _text: unknown })._text;
448 return typeof inner === 'string' ? inner : undefined;
449 }
450 return undefined;
451 };
452 const truncate = (value: string | undefined): string | undefined =>
453 value && value.length > 200 ? `${value.slice(0, 200)}…` : value;
454
455 for (const wrapper of Object.values(body as Record<string, unknown>)) {
456 if (!wrapper || typeof wrapper !== 'object') {
457 continue;
458 }
459 const obj = wrapper as Record<string, unknown>;
460 const responseCode = readText(obj.responseCode);
461 const responseDescription = readText(obj.responseDescription);
462 if (responseCode != null || responseDescription != null) {
463 return {
464 responseCode,
465 responseDescription: truncate(responseDescription),
466 };
467 }
468 }
469 return {};
470}
471
472/** Error message for a failed CyberTip response. In production only NCMEC's
473 * documented codes are surfaced (the body may echo reportable content); the
474 * full truncated body is appended only with `NCMEC_DEBUG=1`. */
475export function summarizeCyberTipFailure(
476 route: string,
477 status: number,
478 body: unknown,
479): string {
480 const { responseCode, responseDescription } =
481 extractNcmecResponseStatus(body);
482 const parts: string[] = [
483 `CyberTip request to ${route} failed: status=${status}`,
484 ];
485 if (responseCode != null) {
486 parts.push(`responseCode=${responseCode}`);
487 }
488 if (responseDescription != null) {
489 parts.push(`responseDescription=${responseDescription}`);
490 }
491 if (ncmecDebugEnabled()) {
492 let snippet: string;
493 try {
494 const serialized = jsonStringify(body ?? null);
495 snippet =
496 serialized.length > 500 ? `${serialized.slice(0, 500)}…` : serialized;
497 } catch {
498 snippet = '<unserializable>';
499 }
500 parts.push(`body=${snippet}`);
501 }
502 return parts.join(', ');
503}
504
505/** Clamp a `createdAt` ISO to "now - 1s" if it's ahead of our clock — NCMEC
506 * rejects `<incidentDateTime>` in the future. `value` is always canonicalized
507 * to UTC ISO; `wasClamped` reflects the numeric (not string) comparison so
508 * callers don't misreport plain UTC normalization as a clamp. */
509export function clampIncidentDateTimeToPast(
510 maxCreatedAt: string,
511 nowMs: number = Date.now(),
512): { value: string; wasClamped: boolean } {
513 const maxCreatedAtMs = new Date(maxCreatedAt).getTime();
514 if (Number.isNaN(maxCreatedAtMs)) {
515 throw new Error(
516 `Invalid media createdAt timestamp for incidentDateTime: ${maxCreatedAt}`,
517 );
518 }
519 const ceilingMs = nowMs - 1000;
520 const wasClamped = maxCreatedAtMs > ceilingMs;
521 const finalMs = wasClamped ? ceilingMs : maxCreatedAtMs;
522 return {
523 value: new Date(finalMs).toISOString(),
524 wasClamped,
525 };
526}
527
528/** Build the `ipCaptureEvent` array for an NCMEC person or media block:
529 * webhook events + caller-supplied events + role-IP-synthesised event, in
530 * that order. Returns `undefined` when all sources are empty. */
531export function mergeFieldRoleIpIntoEvents(
532 webhookEvents: readonly IPNCMECEvent[] | undefined,
533 paramEvents: IPNCMECEvent | readonly IPNCMECEvent[] | undefined,
534 roleIpAddress: string | undefined | null,
535 synthesisedEvent: { eventName: NCMECEventType; dateTime: string },
536): IPNCMECEvent[] | undefined {
537 const isEventArray = (
538 v: IPNCMECEvent | readonly IPNCMECEvent[],
539 ): v is readonly IPNCMECEvent[] => Array.isArray(v);
540 const paramEventsArray: readonly IPNCMECEvent[] =
541 paramEvents == null
542 ? []
543 : isEventArray(paramEvents)
544 ? paramEvents
545 : [paramEvents];
546 const trimmedRoleIp =
547 typeof roleIpAddress === 'string' ? roleIpAddress.trim() : '';
548 const events: IPNCMECEvent[] = [
549 ...(webhookEvents ?? []),
550 ...paramEventsArray,
551 ...(trimmedRoleIp !== ''
552 ? [
553 {
554 ipAddress: trimmedRoleIp,
555 eventName: synthesisedEvent.eventName,
556 dateTime: synthesisedEvent.dateTime,
557 },
558 ]
559 : []),
560 ];
561 return events.length > 0 ? events : undefined;
562}
563
564/** Build the NCMEC `originalFileHash[]` shape from both hash sources Coop
565 * has: HMA-computed hashes stored on the item data (keyed by algorithm),
566 * and any single `{ hash, hashType }` returned by the additional-info
567 * webhook for this media. Trims blanks, uppercases the algorithm name into
568 * the `hashType` attribute, drops empty entries, and dedupes on
569 * (`hashType`, hash value) so a webhook that returns the same algorithm as
570 * HMA doesn't produce duplicate entries. Returns undefined when no usable
571 * hashes survive filtering; callers should branch on that to omit the key
572 * entirely rather than serialise an empty array. */
573export function toOriginalFileHashes(opts: {
574 hmaHashes?: Record<string, string>;
575 webhookFileDetails?: { hash: string; hashType: string };
576}): OriginalFileHash[] | undefined {
577 const result: OriginalFileHash[] = [];
578 const seen = new Set<string>();
579 const push = (algorithm: string, hash: string) => {
580 const trimmedHash = typeof hash === 'string' ? hash.trim() : '';
581 const trimmedAlgorithm = algorithm.trim();
582 if (trimmedHash === '' || trimmedAlgorithm === '') return;
583 const hashType = trimmedAlgorithm.toUpperCase();
584 const key = `${hashType}