Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

NCMEC Additional fields fixes #27 (#64)

* NCMEC <-> Queue Improvements and default NCMEC queue

* code review changes

* NCMEC Additional fields fixes #27

* tests

* lint fixes

* add exif viewed by esp and publicly available from additional info response

* broke out NCMEC docs into separate file and added more detail. Updated USER_GUIDE and SUMMARY

* code review comments

---------

Co-authored-by: Juliet Shen <juliet@roost.tools>

authored by

Juan Mrad
Juliet Shen
and committed by
GitHub
3d73fc32 1492b715

+1166 -94
+8 -8
.betterer.results
··· 479 479 "client/src/webpages/dashboard/components/table/TableFilter.tsx:774089589": [ 480 480 [169, 48, 10, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "4083471778"] 481 481 ], 482 - "client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx:3607966009": [ 483 - [1193, 32, 25, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "1346054869"], 484 - [1259, 26, 25, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "1346054869"] 482 + "client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx:3177287099": [ 483 + [1196, 32, 25, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "1346054869"], 484 + [1272, 26, 25, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "1346054869"] 485 485 ], 486 486 "client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx:3836324260": [ 487 487 [204, 33, 16, "When a function \`x\` is written inline and passed as an argument, it\'s usually better not to write explicit type annotations on \`x\`\'s arguments because the argument types should be able to be inferred, and the inferred type will usually be more accurate than what you\'d write manually. Plus, the inferred type will automatically update.\\n\\nIf the type for x\'s arguments is not being correctly inferred, that suggests an issue with the type definition of the function that \`x\` is being passed to.", "2813373547"] ··· 571 571 "client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobDequeueErrorComponent.tsx:2713757749": [ 572 572 [0, 0, 62, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "2914458485"] 573 573 ], 574 - "client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx:3607966009": [ 574 + "client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx:3177287099": [ 575 575 [5, 0, 66, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "1801263448"] 576 576 ], 577 577 "client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobCommentSection.tsx:97903294": [ ··· 583 583 "client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx:3836324260": [ 584 584 [2, 0, 49, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "2561705942"] 585 585 ], 586 - "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECInspectedMedia.tsx:2598522849": [ 586 + "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECInspectedMedia.tsx:2610319804": [ 587 587 [2, 0, 131, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "1687632664"] 588 588 ], 589 589 "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECMediaViewer.tsx:1857584542": [ 590 590 [3, 0, 51, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "3222053322"] 591 591 ], 592 - "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx:560024571": [ 592 + "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx:86063495": [ 593 593 [0, 0, 76, "\'@ant-design/icons\' import is restricted from being used. AntDesign icons are now deprecated in our codebase. Please use line icons instead.", "2300443108"] 594 594 ], 595 595 "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECThreadItemComponent.tsx:2373785518": [ ··· 775 775 [0, 0, 87, "\'@/icons/lni/Direction/chevron-down.svg\' import is restricted from being used by a pattern.", "3761457464"], 776 776 [1, 0, 83, "\'@/icons/lni/Direction/chevron-up.svg\' import is restricted from being used by a pattern.", "1296196504"] 777 777 ], 778 - "client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx:3607966009": [ 778 + "client/src/webpages/dashboard/mrt/manual_review_job/ManualReviewJobReview.tsx:3177287099": [ 779 779 [0, 0, 78, "\'@/icons/lni/Design/sidebar-1.svg\' import is restricted from being used by a pattern.", "2210410415"], 780 780 [1, 0, 98, "\'@/icons/lni/Direction/angle-double-right.svg\' import is restricted from being used by a pattern.", "2094766677"] 781 781 ], 782 - "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECInspectedMedia.tsx:2598522849": [ 782 + "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECInspectedMedia.tsx:2610319804": [ 783 783 [0, 0, 77, "\'@/icons/lni/User/user-alt-4.svg\' import is restricted from being used by a pattern.", "4178014241"] 784 784 ], 785 785 "client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECThreadItemComponent.tsx:2373785518": [
+32
.devops/migrator/src/scripts/api-server-pg/2026.02.16T00.00.00.add_default_internet_detail_type_to_ncmec_org_settings.sql
··· 1 + -- Add default internet detail type for NCMEC reports (channel/medium: web page, chat/IM, etc.). 2 + ALTER TABLE ncmec_reporting.ncmec_org_settings 3 + ADD COLUMN IF NOT EXISTS default_internet_detail_type character varying(50) NULL; 4 + 5 + COMMENT ON COLUMN ncmec_reporting.ncmec_org_settings.default_internet_detail_type IS 6 + 'Default incident context for CyberTip reports: WEB_PAGE, EMAIL, NEWSGROUP, CHAT_IM, ONLINE_GAMING, CELL_PHONE, NON_INTERNET, PEER_TO_PEER. When set, report.internetDetails is populated.'; 7 + 8 + -- Terms of Service line for CyberTip reporter (e.g. child abuse/CSAM not allowed). Max 3000 chars. 9 + ALTER TABLE ncmec_reporting.ncmec_org_settings 10 + ADD COLUMN IF NOT EXISTS terms_of_service text NULL; 11 + 12 + COMMENT ON COLUMN ncmec_reporting.ncmec_org_settings.terms_of_service IS 13 + 'Optional TOS line included in CyberTip reporter (e.g. child abuse/CSAM not allowed). Max 3000 characters.'; 14 + 15 + -- Contact person for law enforcement (other than submitter). 16 + ALTER TABLE ncmec_reporting.ncmec_org_settings 17 + ADD COLUMN IF NOT EXISTS contact_person_email character varying(255) NULL; 18 + ALTER TABLE ncmec_reporting.ncmec_org_settings 19 + ADD COLUMN IF NOT EXISTS contact_person_first_name character varying(255) NULL; 20 + ALTER TABLE ncmec_reporting.ncmec_org_settings 21 + ADD COLUMN IF NOT EXISTS contact_person_last_name character varying(255) NULL; 22 + ALTER TABLE ncmec_reporting.ncmec_org_settings 23 + ADD COLUMN IF NOT EXISTS contact_person_phone character varying(50) NULL; 24 + 25 + COMMENT ON COLUMN ncmec_reporting.ncmec_org_settings.contact_person_email IS 26 + 'Email for the person law enforcement should contact (other than the reporting person).'; 27 + COMMENT ON COLUMN ncmec_reporting.ncmec_org_settings.contact_person_first_name IS 28 + 'First name of the law enforcement contact person.'; 29 + COMMENT ON COLUMN ncmec_reporting.ncmec_org_settings.contact_person_last_name IS 30 + 'Last name of the law enforcement contact person.'; 31 + COMMENT ON COLUMN ncmec_reporting.ncmec_org_settings.contact_person_phone IS 32 + 'Phone number for the law enforcement contact person.';
+38
client/src/graphql/generated.ts
··· 2697 2697 2698 2698 export type GQLNcmecIndustryClassification = 2699 2699 (typeof GQLNcmecIndustryClassification)[keyof typeof GQLNcmecIndustryClassification]; 2700 + export const GQLNcmecInternetDetailType = { 2701 + CellPhone: 'CELL_PHONE', 2702 + ChatIm: 'CHAT_IM', 2703 + Email: 'EMAIL', 2704 + Newsgroup: 'NEWSGROUP', 2705 + NonInternet: 'NON_INTERNET', 2706 + OnlineGaming: 'ONLINE_GAMING', 2707 + PeerToPeer: 'PEER_TO_PEER', 2708 + WebPage: 'WEB_PAGE', 2709 + } as const; 2710 + 2711 + export type GQLNcmecInternetDetailType = 2712 + (typeof GQLNcmecInternetDetailType)[keyof typeof GQLNcmecInternetDetailType]; 2700 2713 export type GQLNcmecManualReviewJobPayload = { 2701 2714 readonly __typename: 'NcmecManualReviewJobPayload'; 2702 2715 readonly allMediaItems: ReadonlyArray<GQLNcmecContentItem>; ··· 2717 2730 readonly __typename: 'NcmecOrgSettings'; 2718 2731 readonly companyTemplate?: Maybe<Scalars['String']>; 2719 2732 readonly contactEmail?: Maybe<Scalars['String']>; 2733 + readonly contactPersonEmail?: Maybe<Scalars['String']>; 2734 + readonly contactPersonFirstName?: Maybe<Scalars['String']>; 2735 + readonly contactPersonLastName?: Maybe<Scalars['String']>; 2736 + readonly contactPersonPhone?: Maybe<Scalars['String']>; 2737 + readonly defaultInternetDetailType?: Maybe<GQLNcmecInternetDetailType>; 2720 2738 readonly defaultNcmecQueueId?: Maybe<Scalars['String']>; 2721 2739 readonly legalUrl?: Maybe<Scalars['String']>; 2722 2740 readonly moreInfoUrl?: Maybe<Scalars['String']>; 2723 2741 readonly ncmecAdditionalInfoEndpoint?: Maybe<Scalars['String']>; 2724 2742 readonly ncmecPreservationEndpoint?: Maybe<Scalars['String']>; 2725 2743 readonly password: Scalars['String']; 2744 + readonly termsOfService?: Maybe<Scalars['String']>; 2726 2745 readonly username: Scalars['String']; 2727 2746 }; 2728 2747 2729 2748 export type GQLNcmecOrgSettingsInput = { 2730 2749 readonly companyTemplate?: InputMaybe<Scalars['String']>; 2731 2750 readonly contactEmail?: InputMaybe<Scalars['String']>; 2751 + readonly contactPersonEmail?: InputMaybe<Scalars['String']>; 2752 + readonly contactPersonFirstName?: InputMaybe<Scalars['String']>; 2753 + readonly contactPersonLastName?: InputMaybe<Scalars['String']>; 2754 + readonly contactPersonPhone?: InputMaybe<Scalars['String']>; 2755 + readonly defaultInternetDetailType?: InputMaybe<GQLNcmecInternetDetailType>; 2732 2756 readonly defaultNcmecQueueId?: InputMaybe<Scalars['String']>; 2733 2757 readonly legalUrl?: InputMaybe<Scalars['String']>; 2734 2758 readonly moreInfoUrl?: InputMaybe<Scalars['String']>; 2735 2759 readonly ncmecAdditionalInfoEndpoint?: InputMaybe<Scalars['String']>; 2736 2760 readonly ncmecPreservationEndpoint?: InputMaybe<Scalars['String']>; 2737 2761 readonly password: Scalars['String']; 2762 + readonly termsOfService?: InputMaybe<Scalars['String']>; 2738 2763 readonly username: Scalars['String']; 2739 2764 }; 2740 2765 ··· 4046 4071 }; 4047 4072 4048 4073 export type GQLSubmitNcmecReportInput = { 4074 + readonly escalateToHighPriority?: InputMaybe<Scalars['String']>; 4049 4075 readonly incidentType: GQLNcmecIncidentType; 4050 4076 readonly reportedMedia: ReadonlyArray<GQLNcmecMediaInput>; 4051 4077 readonly reportedMessages: ReadonlyArray<GQLNcmecThreadInput>; ··· 23625 23651 readonly ncmecPreservationEndpoint?: string | null; 23626 23652 readonly ncmecAdditionalInfoEndpoint?: string | null; 23627 23653 readonly defaultNcmecQueueId?: string | null; 23654 + readonly defaultInternetDetailType?: GQLNcmecInternetDetailType | null; 23655 + readonly termsOfService?: string | null; 23656 + readonly contactPersonEmail?: string | null; 23657 + readonly contactPersonFirstName?: string | null; 23658 + readonly contactPersonLastName?: string | null; 23659 + readonly contactPersonPhone?: string | null; 23628 23660 } | null; 23629 23661 readonly myOrg?: { 23630 23662 readonly __typename: 'Org'; ··· 36559 36591 ncmecPreservationEndpoint 36560 36592 ncmecAdditionalInfoEndpoint 36561 36593 defaultNcmecQueueId 36594 + defaultInternetDetailType 36595 + termsOfService 36596 + contactPersonEmail 36597 + contactPersonFirstName 36598 + contactPersonLastName 36599 + contactPersonPhone 36562 36600 } 36563 36601 myOrg { 36564 36602 hasNCMECReportingEnabled
+83 -63
client/src/webpages/dashboard/mrt/manual_review_job/v2/ncmec/NCMECReviewUser.tsx
··· 134 134 } 135 135 return Array.isArray(valueOrValues) 136 136 ? valueOrValues.map((it) => ({ 137 - url: it.value.url, 138 - mediaType: it.type, 139 - })) 137 + url: it.value.url, 138 + mediaType: it.type, 139 + })) 140 140 : { url: valueOrValues.value.url, mediaType: valueOrValues.type }; 141 141 }) 142 142 .flat(), ··· 203 203 payload: NCMECJobPayloadQueryResult; 204 204 showMessages?: boolean; 205 205 } & ( 206 - | { 206 + | { 207 207 isActionable: false; 208 208 ncmecDecisions?: readonly { 209 209 readonly id: string; ··· 213 213 readonly industryClassification: GQLNcmecIndustryClassification; 214 214 }[]; 215 215 } 216 - | { 216 + | { 217 217 isActionable: true; 218 218 submitDecision: (input: GQLDecisionSubmission) => Promise<void>; 219 219 skipToNextJob: () => Promise<void>; 220 220 ncmecDecisions: undefined; 221 221 } 222 - ), 222 + ), 223 223 ) { 224 224 const { orgId, payload, isActionable, ncmecDecisions, showMessages } = props; 225 225 const { item, allMediaItems } = payload; ··· 267 267 : undefined; 268 268 return threadId 269 269 ? { 270 - id: threadId.id, 271 - typeId: threadId.typeId, 272 - } 270 + id: threadId.id, 271 + typeId: threadId.typeId, 272 + } 273 273 : undefined; 274 274 }), 275 275 ), ··· 285 285 >( 286 286 allMediaItemsWithUrls.length > 0 287 287 ? { 288 - itemId: allMediaItemsWithUrls[0].contentItem.id, 289 - urlInfo: allMediaItemsWithUrls[0].urlInfo, 290 - itemTypeId: allMediaItemsWithUrls[0].contentItem.type.id, 291 - } 288 + itemId: allMediaItemsWithUrls[0].contentItem.id, 289 + urlInfo: allMediaItemsWithUrls[0].urlInfo, 290 + itemTypeId: allMediaItemsWithUrls[0].contentItem.type.id, 291 + } 292 292 : undefined, 293 293 ); 294 294 // Selected Media = media that has been selected to be included in the ··· 296 296 const [selectedMedia, setSelectedMedia] = useState<NCMECMediaState[]>( 297 297 ncmecDecisions 298 298 ? allMediaItemsWithUrls.map((media) => { 299 - const decision = ncmecDecisions.find( 300 - (decision) => 301 - media.contentItem.id === decision.id && 302 - media.contentItem.type.id === decision.typeId, 303 - ); 304 - if (decision) { 305 - return { 306 - itemId: decision.id, 307 - itemTypeId: decision.typeId, 308 - urlInfo: media.urlInfo, 309 - category: decision.industryClassification, 310 - labels: [...decision.fileAnnotations], 311 - }; 312 - } 299 + const decision = ncmecDecisions.find( 300 + (decision) => 301 + media.contentItem.id === decision.id && 302 + media.contentItem.type.id === decision.typeId, 303 + ); 304 + if (decision) { 313 305 return { 314 - itemId: media.contentItem.id, 315 - itemTypeId: media.contentItem.type.id, 306 + itemId: decision.id, 307 + itemTypeId: decision.typeId, 316 308 urlInfo: media.urlInfo, 317 - category: 'None', 318 - labels: [], 309 + category: decision.industryClassification, 310 + labels: [...decision.fileAnnotations], 319 311 }; 320 - }) 312 + } 313 + return { 314 + itemId: media.contentItem.id, 315 + itemTypeId: media.contentItem.type.id, 316 + urlInfo: media.urlInfo, 317 + category: 'None', 318 + labels: [], 319 + }; 320 + }) 321 321 : [], 322 322 ); 323 323 const [selectedThreadsWithMessages, setSelectedThreadsWithMessages] = ··· 325 325 const [incidentType, setIncidentType] = useState<GQLNcmecIncidentType>( 326 326 GQLNcmecIncidentType.ChildPornography, 327 327 ); 328 + const [escalateToHighPriority, setEscalateToHighPriority] = useState(''); 328 329 const [sendReportModalVisible, setSendReportModalVisible] = useState(false); 329 330 const [deselectAndIgnoreModalVisible, setDeselectAndIgnoreModalVisible] = 330 331 useState(false); ··· 354 355 </div> 355 356 <CopyTextComponent 356 357 value={erroredMedia.map((it) => it.id).join(',')} 357 - displayValue={`${erroredMedia.length} video${ 358 - erroredMedia.length === 1 ? '' : 's' 359 - } or image${ 360 - erroredMedia.length === 1 ? '' : 's' 361 - } failed to load. Click here to copy a list of the IDs that failed to load.`} 358 + displayValue={`${erroredMedia.length} video${erroredMedia.length === 1 ? '' : 's' 359 + } or image${erroredMedia.length === 1 ? '' : 's' 360 + } failed to load. Click here to copy a list of the IDs that failed to load.`} 362 361 isError={true} 363 362 /> 364 363 {isActionable ? ( ··· 581 580 > 582 581 <div className="overflow-hidden shadow-lg rounded-2xl w-fit"> 583 582 {!loading && 584 - moderatorSafetyBlurLevel != null && 585 - moderatorSafetyGrayscale != null ? ( 583 + moderatorSafetyBlurLevel != null && 584 + moderatorSafetyGrayscale != null ? ( 586 585 media.urlInfo.mediaType === 'IMAGE' ? ( 587 586 <img 588 - className={`object-scale-down w-64 h-48 rounded-2xl ${ 589 - unblurAllMediaInConfirmation 587 + className={`object-scale-down w-64 h-48 rounded-2xl ${unblurAllMediaInConfirmation 590 588 ? 'blur-0' 591 589 : BLUR_LEVELS[moderatorSafetyBlurLevel as BlurStrength] 592 - } ${moderatorSafetyGrayscale ? 'grayscale' : ''}`} 590 + } ${moderatorSafetyGrayscale ? 'grayscale' : ''}`} 593 591 alt="" 594 592 src={media.urlInfo.url} 595 593 /> 596 594 ) : ( 597 595 <ManualReviewJobContentBlurableVideo 598 596 url={media.urlInfo.url} 599 - className={`object-scale-down w-64 h-48 rounded-2xl ${ 600 - moderatorSafetyGrayscale ? 'grayscale' : '' 601 - }`} 597 + className={`object-scale-down w-64 h-48 rounded-2xl ${moderatorSafetyGrayscale ? 'grayscale' : '' 598 + }`} 602 599 options={{ 603 600 shouldBlur: 604 601 !unblurAllMediaInConfirmation && ··· 661 658 }), 662 659 reportedMessages: selectedThreadsWithMessages, 663 660 incidentType, 661 + ...(escalateToHighPriority.trim() !== '' 662 + ? { escalateToHighPriority: escalateToHighPriority.trim() } 663 + : {}), 664 664 }, 665 665 }); 666 666 }, ··· 701 701 <div className="!my-4 divider" /> 702 702 <div className="text-base font-bold">Media</div> 703 703 {selectedMediaConfirmationGrid} 704 + {selectedThreadsWithMessages.length > 0 705 + ? ` 704 706 <div className="!my-4 divider" /> 705 707 <div className="text-base font-bold">Messages</div> 706 - {selectedThreadsWithMessages.length > 0 707 - ? selectedThreadsWithMessages.map((thread) => ( 708 - <div key={thread.threadId}> 709 - {thread.threadId}: {thread.reportedContent.length} reported 710 - </div> 711 - )) 712 - : undefined} 708 + ${selectedThreadsWithMessages.map((thread) => ( 709 + <div key={thread.threadId}> 710 + {thread.threadId}: {thread.reportedContent.length} reported 711 + </div> 712 + ))} 713 + ` : undefined} 714 + 713 715 <div className="!my-4 divider" /> 714 716 <div className="flex flex-col gap-2"> 715 717 <div className="text-base font-bold">Incident Type Category</div> ··· 726 728 </option> 727 729 ))} 728 730 </select> 731 + </div> 732 + <div className="flex flex-col gap-2"> 733 + <label 734 + htmlFor="escalateToHighPriority" 735 + className="text-base font-bold" 736 + > 737 + Escalate as High Priority (optional) 738 + </label> 739 + <textarea 740 + id="escalateToHighPriority" 741 + maxLength={3000} 742 + value={escalateToHighPriority} 743 + onChange={(e) => setEscalateToHighPriority(e.target.value)} 744 + placeholder="e.g. immediate risk to child. Supplying a value will escalate the report." 745 + className="min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 746 + rows={3} 747 + /> 748 + <span className="text-xs text-slate-500"> 749 + {escalateToHighPriority.length}/3000 characters 750 + </span> 729 751 </div> 730 752 </div> 731 753 </CoopModal> ··· 946 968 threadId={mediaInDetailViewThread?.id} 947 969 threadInfo={ 948 970 threadInfo?.partialItems.__typename === 949 - 'PartialItemsSuccessResponse' 971 + 'PartialItemsSuccessResponse' 950 972 ? (threadInfo.partialItems.items.find( 951 - (it) => 952 - it.__typename === 'ThreadItem' && 953 - it.id === mediaInDetailViewThread?.id && 954 - it.type.id === mediaInDetailViewThread?.typeId, 955 - ) as GQLThreadItem) 973 + (it) => 974 + it.__typename === 'ThreadItem' && 975 + it.id === mediaInDetailViewThread?.id && 976 + it.type.id === mediaInDetailViewThread?.typeId, 977 + ) as GQLThreadItem) 956 978 : undefined 957 979 } 958 980 threadLoading={threadLoading} ··· 961 983 <div className="self-start pt-2"> 962 984 <CopyTextComponent 963 985 value={erroredMedia.map((it) => it.id).join(',')} 964 - displayValue={`${erroredMedia.length} video${ 965 - erroredMedia.length === 1 ? '' : 's' 966 - } or image${ 967 - erroredMedia.length === 1 ? '' : 's' 968 - } failed to load. Click here to copy a list of the IDs that failed to load.`} 986 + displayValue={`${erroredMedia.length} video${erroredMedia.length === 1 ? '' : 's' 987 + } or image${erroredMedia.length === 1 ? '' : 's' 988 + } failed to load. Click here to copy a list of the IDs that failed to load.`} 969 989 isError={true} 970 990 /> 971 991 </div>
+158 -5
client/src/webpages/settings/NCMECSettings.tsx
··· 10 10 } from '@/coop-ui/Select'; 11 11 import { toast } from '@/coop-ui/Toast'; 12 12 import { Heading, Text } from '@/coop-ui/Typography'; 13 + import type { GQLNcmecInternetDetailType } from '@/graphql/generated'; 13 14 import { 14 15 useGQLNcmecOrgSettingsQuery, 15 16 useGQLUpdateNcmecOrgSettingsMutation, ··· 32 33 ncmecPreservationEndpoint 33 34 ncmecAdditionalInfoEndpoint 34 35 defaultNcmecQueueId 36 + defaultInternetDetailType 37 + termsOfService 38 + contactPersonEmail 39 + contactPersonFirstName 40 + contactPersonLastName 41 + contactPersonPhone 35 42 } 36 43 myOrg { 37 44 hasNCMECReportingEnabled ··· 59 66 ncmecPreservationEndpoint: string; 60 67 ncmecAdditionalInfoEndpoint: string; 61 68 defaultNcmecQueueId: string; 69 + defaultInternetDetailType: string; 70 + termsOfService: string; 71 + contactPersonEmail: string; 72 + contactPersonFirstName: string; 73 + contactPersonLastName: string; 74 + contactPersonPhone: string; 62 75 }; 63 76 64 77 export default function NCMECSettings() { ··· 72 85 ncmecPreservationEndpoint: '', 73 86 ncmecAdditionalInfoEndpoint: '', 74 87 defaultNcmecQueueId: '', 88 + defaultInternetDetailType: '', 89 + termsOfService: '', 90 + contactPersonEmail: '', 91 + contactPersonFirstName: '', 92 + contactPersonLastName: '', 93 + contactPersonPhone: '', 75 94 }); 76 95 77 96 const { loading, error, data } = useGQLNcmecOrgSettingsQuery(); ··· 103 122 data.ncmecOrgSettings.ncmecAdditionalInfoEndpoint ?? '', 104 123 defaultNcmecQueueId: 105 124 data.ncmecOrgSettings.defaultNcmecQueueId ?? '', 125 + defaultInternetDetailType: 126 + data.ncmecOrgSettings.defaultInternetDetailType ?? '', 127 + termsOfService: data.ncmecOrgSettings.termsOfService ?? '', 128 + contactPersonEmail: data.ncmecOrgSettings.contactPersonEmail ?? '', 129 + contactPersonFirstName: 130 + data.ncmecOrgSettings.contactPersonFirstName ?? '', 131 + contactPersonLastName: data.ncmecOrgSettings.contactPersonLastName ?? '', 132 + contactPersonPhone: data.ncmecOrgSettings.contactPersonPhone ?? '', 106 133 }); 107 134 } 108 135 }, [data?.ncmecOrgSettings]); ··· 123 150 toast.error('Username and Password are required.'); 124 151 return; 125 152 } 126 - 153 + 127 154 if (!settings.companyTemplate || !settings.legalUrl) { 128 155 toast.error('Company Template and Legal URL are required for NCMEC reporting.'); 129 156 return; 130 157 } 131 - 158 + 132 159 updateSettings({ 133 160 variables: { 134 161 input: { ··· 143 170 ncmecAdditionalInfoEndpoint: 144 171 settings.ncmecAdditionalInfoEndpoint || null, 145 172 defaultNcmecQueueId: settings.defaultNcmecQueueId || null, 173 + defaultInternetDetailType: settings.defaultInternetDetailType 174 + ? (settings.defaultInternetDetailType as GQLNcmecInternetDetailType) 175 + : null, 176 + termsOfService: settings.termsOfService || null, 177 + contactPersonEmail: settings.contactPersonEmail || null, 178 + contactPersonFirstName: settings.contactPersonFirstName || null, 179 + contactPersonLastName: settings.contactPersonLastName || null, 180 + contactPersonPhone: settings.contactPersonPhone || null, 146 181 }, 147 182 }, 148 183 }); ··· 163 198 Exploited Children) reporting settings. These credentials will be used 164 199 when submitting reports to NCMEC CyberTipline. 165 200 </Text> 166 - 201 + 167 202 {!isNCMECEnabled && ( 168 203 <div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded"> 169 204 <Text size="SM" className="text-blue-800"> ··· 173 208 </Text> 174 209 </div> 175 210 )} 176 - 211 + 177 212 {isNCMECEnabled && ( 178 213 <div className="mb-6 p-4 bg-green-50 border border-green-200 rounded"> 179 214 <Text size="SM" className="text-green-800"> ··· 270 305 </div> 271 306 272 307 <div className="flex flex-col gap-2"> 308 + <Label htmlFor="termsOfService" className="text-sm font-medium"> 309 + Terms of Service 310 + </Label> 311 + <textarea 312 + id="termsOfService" 313 + maxLength={3000} 314 + value={settings.termsOfService} 315 + onChange={(e) => 316 + setSettings({ ...settings, termsOfService: e.target.value }) 317 + } 318 + placeholder="e.g. Child abuse and CSAM are not allowed on our platform." 319 + className="min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 320 + rows={3} 321 + /> 322 + <Text size="XS" className="text-gray-500"> 323 + Optional TOS line included in the CyberTip reporter. {settings.termsOfService.length}/3000 characters. 324 + </Text> 325 + </div> 326 + 327 + <div className="flex flex-col gap-2"> 328 + <Label className="text-sm font-medium"> 329 + Contact person (for law enforcement) 330 + </Label> 331 + <Text size="XS" className="text-gray-500 mb-1"> 332 + Person law enforcement can contact other than the reporting person. All fields optional. 333 + </Text> 334 + <div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> 335 + <Input 336 + id="contactPersonFirstName" 337 + type="text" 338 + value={settings.contactPersonFirstName} 339 + onChange={(e) => 340 + setSettings({ 341 + ...settings, 342 + contactPersonFirstName: e.target.value, 343 + }) 344 + } 345 + placeholder="First name" 346 + /> 347 + <Input 348 + id="contactPersonLastName" 349 + type="text" 350 + value={settings.contactPersonLastName} 351 + onChange={(e) => 352 + setSettings({ 353 + ...settings, 354 + contactPersonLastName: e.target.value, 355 + }) 356 + } 357 + placeholder="Last name" 358 + /> 359 + </div> 360 + <Input 361 + id="contactPersonEmail" 362 + type="email" 363 + value={settings.contactPersonEmail} 364 + onChange={(e) => 365 + setSettings({ 366 + ...settings, 367 + contactPersonEmail: e.target.value, 368 + }) 369 + } 370 + placeholder="Email" 371 + /> 372 + <Input 373 + id="contactPersonPhone" 374 + type="tel" 375 + value={settings.contactPersonPhone} 376 + onChange={(e) => 377 + setSettings({ 378 + ...settings, 379 + contactPersonPhone: e.target.value, 380 + }) 381 + } 382 + placeholder="Phone" 383 + /> 384 + </div> 385 + 386 + <div className="flex flex-col gap-2"> 273 387 <Label htmlFor="moreInfoUrl" className="text-sm font-medium"> 274 388 More Info URL 275 389 </Label> ··· 324 438 325 439 <div className="flex flex-col gap-2"> 326 440 <Label 441 + htmlFor="defaultInternetDetailType" 442 + className="text-sm font-medium" 443 + > 444 + Default internet detail type 445 + </Label> 446 + <Select 447 + value={settings.defaultInternetDetailType || '__none__'} 448 + onValueChange={(value) => 449 + setSettings({ 450 + ...settings, 451 + defaultInternetDetailType: 452 + value === '__none__' ? '' : value, 453 + }) 454 + } 455 + > 456 + <SelectTrigger id="defaultInternetDetailType"> 457 + <SelectValue placeholder="No default" /> 458 + </SelectTrigger> 459 + <SelectContent> 460 + <SelectItem value="__none__">No default</SelectItem> 461 + <SelectItem value="WEB_PAGE">Web page</SelectItem> 462 + <SelectItem value="EMAIL">Email</SelectItem> 463 + <SelectItem value="NEWSGROUP">Newsgroup</SelectItem> 464 + <SelectItem value="CHAT_IM">Chat / IM</SelectItem> 465 + <SelectItem value="ONLINE_GAMING">Online gaming</SelectItem> 466 + <SelectItem value="CELL_PHONE">Cell phone</SelectItem> 467 + <SelectItem value="NON_INTERNET">Non-internet</SelectItem> 468 + <SelectItem value="PEER_TO_PEER">Peer-to-peer</SelectItem> 469 + </SelectContent> 470 + </Select> 471 + <Text size="XS" className="text-gray-500"> 472 + Incident context (channel/medium) for CyberTip reports. When set, 473 + each report will include this in internetDetails. For &quot;Web 474 + page&quot;, the More Info URL above is used if set. 475 + </Text> 476 + </div> 477 + 478 + <div className="flex flex-col gap-2"> 479 + <Label 327 480 htmlFor="ncmecPreservationEndpoint" 328 481 className="text-sm font-medium" 329 482 > ··· 381 534 {isNCMECEnabled ? 'Update Settings' : 'Enable NCMEC & Save Settings'} 382 535 </Button> 383 536 </div> 384 - 537 + 385 538 {!isNCMECEnabled && ( 386 539 <Text size="XS" className="mt-4 text-gray-600"> 387 540 Note: Saving these settings will enable NCMEC reporting for your
+418
docs/NCMEC.md
··· 1 + # NCMEC Reporting 2 + 3 + Coop is integrated with the [CyberTipline Reporting API](https://report.cybertip.org/ispws/documentation) from the National Center for Missing and Exploited Children (NCMEC). Coop handles the full lifecycle: detecting known CSAM via hash matching or AI-based rules, routing content to a dedicated NCMEC manual review queue, and submitting CyberTips with the relevant metadata. 4 + 5 + > [!IMPORTANT] 6 + > NCMEC reporting requires your organization to be registered with NCMEC as an Electronic Service Provider (ESP). You can register at [esp.ncmec.org/registration](https://esp.ncmec.org/registration). 7 + 8 + ## Prerequisites 9 + 10 + ### CyberTip Reporting 11 + 12 + Before you can review and report content to NCMEC, you must have the following configured: 13 + 14 + 1. **CyberTipline API credentials**: a username and password for the CyberTipline API, obtained from NCMEC. 15 + 16 + #### Two separate sets of NCMEC credentials 17 + 18 + Coop's NCMEC integration uses two different NCMEC APIs, each requiring its own credentials: 19 + 20 + | | [Hash Sharing API](https://report.cybertip.org/ws-hashsharing/v2/documentation/) | [CyberTipline Reporting API](https://report.cybertip.org/ispws/documentation/index.html) | 21 + |---|---|---| 22 + | **Purpose** | Pull known CSAM hashes for matching | Submit CyberTip reports | 23 + | **URL** | `report.cybertip.org/ws-hashsharing` | `report.cybertip.org/ispws` | 24 + | **How to configure credentials** | In HMA's curator UI or via `TX_NCMEC_CREDENTIALS` env var on the HMA service | In Coop under **Settings → NCMEC** | 25 + 26 + Both sets of credentials must be obtained from NCMEC by [registering as an ESP](https://esp.ncmec.org/registration). They may be the same account or different accounts depending on how NCMEC provisions access for your organization. 27 + 28 + 2. **A User Item Type with a `creatorId` field**: NCMEC-type jobs are centered on a user, not individual pieces of content. Coop extracts the user from a content item via a `creatorId` field (a `RELATED_ITEM` field referencing the User Item Type). Coop then aggregates all media associated with that user into a single NCMEC review job. 29 + 30 + 3. **NCMEC org settings configured**: set via **Settings → NCMEC** (Admin only). See [NCMEC Settings](#ncmec-settings) below. 31 + 32 + 4. **A dedicated NCMEC manual review queue named "NCMEC Review**: Coop routes NCMEC jobs to the queue specified in your NCMEC settings. Queue IDs that are registered as production queues with Coop support will submit real CyberTips; all others use the NCMEC test environment. 33 + 34 + 5. **An Additional Info endpoint** (optional, but strongly recommended): a webhook Coop calls before submitting a CyberTip to fetch enriched metadata: email addresses, screen names, IP capture events, and per-media details. Without this, Coop submits the CyberTip with only the user ID and basic information from the Item data. 35 + 36 + 6. **A Preservation endpoint** (optional): a webhook Coop calls after a successful CyberTip submission so your platform can preserve relevant user data per NCMEC requirements. NOTE: Coop does not come with built-in preservation functionalities. 37 + 38 + ### Hash Matching (HMA) 39 + 40 + If you want to automatically detect known CSAM via hash matching, you need a **separate** set of credentials for NCMEC's Hash Sharing API — distinct from the CyberTipline credentials above. See [Hash Banks in the User Guide](/docs/USER_GUIDE.md#hash-banks) for more detailed setup. Both credential sets are obtained from NCMEC when registering as an ESP, but they authenticate against different APIs and are configured in different places: Hash Sharing credentials go into HMA's own configuration (its curator UI or the `TX_NCMEC_CREDENTIALS` env var on the HMA service), while CyberTipline credentials are configured in the Coop settings UI. 41 + 42 + ## NCMEC Settings 43 + 44 + Configure NCMEC reporting under **Settings � NCMEC** (`/dashboard/settings/ncmec`). 45 + 46 + ![Setting up NCMEC reporting on Coop: add the required information for the reports submitted to NCMEC for content violating your company policies](./images/coop-ncmec-settings.png) 47 + 48 + | Setting | Description | 49 + |--------|-------------| 50 + | **Username** | Your NCMEC CyberTipline API username. | 51 + | **Password** | Your NCMEC CyberTipline API password. | 52 + | **Company Report Name** | Your organization name as it appears in NCMEC reports. This value is also sent as the ESP service name for the reported user in each CyberTip. | 53 + | **Legal URL** | URL to your Terms of Service or legal policies (e.g. `https://yourcompany.com/terms`). | 54 + | **Contact Email** | Email for the reporting person on the CyberTip. The XML receipt from NCMEC can serve as the ESP notification. | 55 + | **Terms of Service** | TOS text or URL to an acceptable use policy relevant to the incident being reported. Maximum 3000 characters. | 56 + | **Contact Person (for law enforcement)** | A contact person law enforcement can reach (other than the reporting contact email): first name, last name, email, phone. | 57 + | **More Info URL** | URL for additional information about your reporting process (e.g. `https://yourcompany.com/ncmec-info`). Used as the web page URL when "Default internet detail type" is set to "Web page." | 58 + | **Default NCMEC Queue** | When reviewers click "Enqueue to NCMEC," jobs are sent to this queue. Leave as "Use org default queue" to fall back to the organization's default queue. | 59 + | **Default Internet Detail Type** | The incident context (channel/medium) included in every CyberTip: Web page, Email, Newsgroup, Chat/IM, Online gaming, Cell phone, Non-internet, or Peer-to-peer. | 60 + | **NCMEC Additional Info Endpoint** | Webhook URL Coop calls before submitting a CyberTip to fetch enriched user and media metadata. See [Additional Info Endpoint](#additional-info-endpoint) below. Strongly recommended as without it, CyberTips are submitted with minimal user data. | 61 + | **NCMEC Preservation Endpoint** | Webhook URL Coop calls after a successful CyberTip submission with the report ID. See [Preservation Endpoint](#preservation-endpoint) below. | 62 + 63 + Saving credentials, Company Report Name, and Legal URL enables NCMEC reporting for the organization. The remaining fields are not required to submit a CyberTip, but filling them out makes reports significantly more actionable for NCMEC investigators. 64 + 65 + ## Access and Roles 66 + 67 + NCMEC data is sensitive. Coop restricts access based on user roles: 68 + 69 + | Role | Can Access NCMEC Jobs | Can Submit CyberTips | Can Configure NCMEC Settings | 70 + |---|---|---|---| 71 + | Admin | Yes | Yes | Yes | 72 + | Moderator Manager | Yes | Yes | No | 73 + | Child Safety Moderator | Yes | Yes | No | 74 + | Moderator | No | No | No | 75 + | Analyst / Rules Manager | No | No | No | 76 + | External Moderator | No | No | No | 77 + 78 + ## How Content Gets Routed to the NCMEC Queue 79 + 80 + There are four ways content can enter the NCMEC review queue: 81 + 82 + ### 1. Hash Matching Known CSAM (HMA) 83 + 84 + Coop integrates with Meta's [Hasher-Matcher-Actioner (HMA)](https://github.com/facebook/ThreatExchange/tree/main/hasher-matcher-actioner), which matches uploaded media against NCMEC's database of known CSAM hashes. This is the most reliable detection path: a hash match is a strong signal that content is confirmed CSAM. 85 + 86 + #### How it works 87 + 88 + When media is submitted to your platform, Coop calls HMA to compute a perceptual hash (PDQ for images, MD5 for video) and checks it against the hash banks configured in HMA. If a match is found against an NCMEC-sourced bank, configure a routing rule to assign the content to your NCMEC review queue automatically. 89 + 90 + HMA syncs hashes from NCMEC via NCMEC's [Hash Sharing API](https://report.cybertip.org/ws-hashsharing/v2/documentation/), a separate API from the CyberTipline reporting API. The Hash Sharing API gives you access to NCMEC's database of image and video fingerprints for known CSAM, which HMA pulls on a schedule and indexes locally for fast matching. 91 + 92 + #### Setup 93 + 94 + 1. Configure NCMEC Hash Sharing API credentials in HMA (via HMA's curator UI or by setting the `TX_NCMEC_CREDENTIALS` environment variable on the HMA service). 95 + 2. In HMA, create a bank sourced from the NCMEC exchange. HMA will begin syncing hashes on its background fetch schedule (every 5 minutes by default). 96 + 3. In Coop, go to **Settings → Integrations** and add your HMA service URL. 97 + 4. In Coop's **Matching Banks**, the NCMEC-sourced bank will be available to reference in rules. 98 + 5. How you set things up depends on your use case: 99 + 100 + * If items are submitted by user reports (`POST /api/v1/report`): create a routing rule with the NCMEC hash match logic and assign it to the NCMEC queue. 101 + 102 + * If items are submitted via the items API `(POST /api/v1/items/async/)` and you want Coop to proactively flag matches without a user report: you need an automated enforcement rule with the image hash condition and a "Enqueue to NCMEC" action. 103 + 104 + 105 + ### 2. Novel CSAM Detection (Content Safety API) 106 + 107 + For content that hasn't been seen before and therefore has no known hash, Coop integrates with Google's Content Safety API that classifies images for potential CSAM. You can configure a Routing Rule using a Content Safety signal to route high-confidence detections directly to the NCMEC queue, or to a triage queue for human review before escalation. 108 + 109 + ### 3. Inbound Report Flagged as CSAM 110 + 111 + When your platform sends a user report to Coop's Report API with `reportedForReason.csam: true`, Coop automatically routes it to the NCMEC queue instead of the default review queue. These reasons should be configured by your reporting flow and match whatever reporting reasons you have defined. 112 + 113 + ```json 114 + { 115 + "reporter": { "id": "user123", "typeId": "user-type-id" }, 116 + "reportedAt": "2025-01-01T00:00:00Z", 117 + "reportedItem": { 118 + "id": "content456", 119 + "typeId": "post-type-id", 120 + "data": { ... } 121 + }, 122 + "reportedForReason": { 123 + "csam": true 124 + } 125 + } 126 + ``` 127 + 128 + ### 4. Manual Escalation from the Review Console 129 + 130 + In any review task, moderators with NCMEC access can select **Enqueue to NCMEC** from the action list. This immediately moves the job out of the current queue and into the NCMEC queue. 131 + 132 + ### What Happens When Content Is Enqueued to NCMEC 133 + 134 + When any of the three triggers above fire, Coop: 135 + 136 + 1. Identifies the **user** associated with the content (via the `creatorId` field on the content item, or directly if the item is a User type). 137 + 2. Checks whether that user already has an open NCMEC job. If one exists, the existing job is updated with the new payload (the new job's content always takes precedence). No duplicate jobs are created. 138 + 3. Fetches **all media** ever associated with that user across your platform. 139 + 4. Creates a single consolidated NCMEC review job containing the user and all their media. 140 + 5. Routes the job to the configured NCMEC queue (or the org default queue if none is configured). 141 + 142 + This user-centric aggregation means that even if a user has uploaded many pieces of CSAM, a single NCMEC review job is created for the reviewer, and a single CyberTip is submitted to NCMEC rather than separate reports per piece of content. 143 + 144 + 145 + ## Reviewing an NCMEC Job 146 + 147 + The NCMEC job UI is distinct from standard review tasks. It is designed around the user and all of their associated media. 148 + 149 + ![Coop's NCMEC Reporting task view showing aggregated media for a user, keyboard shortcuts for industry classifications, incident type dropdown, and per-media label selectors](./images/coop-ncmec-job.png) 150 + 151 + ### Incident Type 152 + 153 + Select the applicable incident type from the NCMEC CyberTipline's defined categories: 154 + 155 + - Child Pornography (possession, manufacture, and distribution) 156 + - Child Sex Trafficking 157 + - Child Sex Tourism 158 + - Child Sexual Molestation 159 + - Misleading Domain Name 160 + - Misleading Words or Digital Images on the Internet 161 + - Online Enticement of Children for Sexual Acts 162 + - Unsolicited Obscene Material Sent to a Child 163 + 164 + ### Industry Classification 165 + 166 + Apply an [ESP-designated industry classification](https://technologycoalition.org/wp-content/uploads/Tech_Coalition_Industry_Classification_System.pdf) to each media item being reported: 167 + 168 + | Classification | Description | 169 + |---|---| 170 + | **A1** | Prepubescent minor, explicit sexual activity | 171 + | **A2** | Prepubescent minor, non-explicit nudity or sexual posing | 172 + | **B1** | Pubescent minor, explicit sexual activity | 173 + | **B2** | Pubescent minor, non-explicit nudity or sexual posing | 174 + 175 + Keyboard shortcuts are available in the review UI to speed up classification. 176 + 177 + ### File Annotations (Labels) 178 + 179 + Apply one or more labels to individual media items to provide NCMEC with additional context: 180 + 181 + | Label | Description | 182 + |---|---| 183 + | `animeDrawingVirtualHentai` | The file depicts anime, cartoon, virtual, or hentai content. | 184 + | `potentialMeme` | The file appears to be shared out of mimicry or other seemingly non-malicious intent. | 185 + | `viral` | The file is circulating rapidly from user to user. | 186 + | `possibleSelfProduction` | The file is believed to be self-produced. | 187 + | `physicalHarm` | The file depicts an intentional act of causing physical injury or trauma. | 188 + | `violenceGore` | The file depicts graphic violence or brutality. | 189 + | `bestiality` | The file involves an animal. | 190 + | `liveStreaming` | The content was streamed live at the time it was uploaded. | 191 + | `infant` | The file depicts an infant. | 192 + | `generativeAi` | The file is believed to be generated by AI. | 193 + 194 + ### Submitting the CyberTip 195 + 196 + Once the reviewer has classified all media and selected the incident type, they click **Submit to NCMEC**. Coop then builds and submits the CyberTip automatically. See [CyberTip Submission Flow](#cybertip-submission-flow) below. 197 + 198 + 199 + ## CyberTip Submission Flow 200 + 201 + When a reviewer submits a CyberTip, Coop performs the following steps: 202 + 203 + 1. **Fetch additional info** — Coop calls your [Additional Info endpoint](#additional-info-endpoint) to retrieve enriched metadata: user email, screen name, IP capture events, and per-media details. 204 + 205 + 2. **Build the CyberTip XML** — Coop assembles the full report: 206 + 207 + - **escalateToHighPriority**: this is a boolean that marks the report as high priority (ie. there is abuse happening now) that NCMEC prioritizes when triaging. 208 + - **Incident summary**: the incident type selected by the reviewer, and the timestamp of the most recently created media item as the `incidentDateTime`. 209 + 210 + - **Internet details**: the channel or medium of the incident (e.g. Web page, Chat/IM, Email), set via the "Default Internet Detail Type" in NCMEC org settings. 211 + 212 + - **Reporter**: your organization's name (`companyTemplate`), legal URL, contact email, optional terms of service language, and optional law enforcement contact person. All sourced from NCMEC org settings. 213 + 214 + - **Reported user (`personOrUserReported`)**: the suspected perpetrator. Coop includes: 215 + - `espIdentifier`: the user's internal platform ID 216 + - `espService`: your organization's name (from `companyTemplate`) 217 + - `screenName`: the user's username, from your Additional Info endpoint 218 + - `displayName`: the user's display name, if available 219 + - `email`: known email addresses for the user, from your Additional Info endpoint 220 + - `ipCaptureEvent`: IP addresses associated with the user (e.g. login, upload events), from your Additional Info endpoint. Providing IP data significantly improves NCMEC's ability to identify and locate the suspect. 221 + 222 + - **Victim**: if a child victim is identifiable (e.g. from a messaging context), Coop includes their `espIdentifier`, `screenName`, `displayName`, and `ipCaptureEvent`. This helps NCMEC locate and provide assistance to the victim. 223 + 224 + 3. **Submit the report**: Coop POSTs the report XML to the NCMEC CyberTipline API and receives a `reportId`. 225 + 226 + 4. **Upload media**: For each media item, Coop downloads the file from its URL and uploads it to NCMEC with full file metadata: 227 + - Industry classification (A1/A2/B1/B2) 228 + - File annotations (labels selected by the reviewer) 229 + - IP capture events associated with the upload 230 + - Whether the content was publicly available on your platform (`publiclyAvailable`) 231 + - Whether the ESP viewed the file and its EXIF data (`fileViewedByEsp: true`, `exifViewedByEsp: true`) 232 + - File hash, if provided by your Additional Info endpoint 233 + 234 + 5. **Upload supplemental files**: Any additional files returned by your Additional Info endpoint (e.g. screenshots, supporting evidence) are uploaded to NCMEC as supplemental reported files. 235 + 236 + 6. **Upload message threads**: If the user was reported in a messaging context, Coop generates a CSV for each conversation thread and uploads it to NCMEC. 237 + 238 + 7. **Finalize the report**: Coop calls the NCMEC `/finish` endpoint to complete the submission. 239 + 240 + 8. **Store the report**: The completed report (report ID, XML, all media details) is saved in Coop's database. 241 + 242 + 9. **Send a preservation request**: If your org has a [Preservation endpoint](#preservation-endpoint) configured, Coop calls it with the report ID so you can preserve relevant user data. 243 + 244 + ### Test vs. Production Submissions 245 + 246 + Coop determines whether to submit to the NCMEC test environment or production based on whether the NCMEC queue is registered as a production queue with Coop support. Test submissions go to `exttest.cybertip.org`; production submissions go to `report.cybertip.org`. 247 + 248 + 249 + ## Webhooks 250 + 251 + ### Additional Info Endpoint 252 + 253 + Coop calls this webhook **before** building a CyberTip to retrieve enriched metadata for the reported user and their media. This endpoint is optional but **strongly recommended** since without it, Coop submits the CyberTip with only the user's ID and whatever data was already sent to Coop via your Item API. 254 + 255 + Coop signs every request with your org's signing key. Verify the signature before processing. 256 + 257 + #### Request 258 + 259 + ```json 260 + { 261 + "users": [ 262 + { "id": "string", "typeId": "string" } 263 + ], 264 + "media": [ 265 + { "id": "string", "typeId": "string" } 266 + ] 267 + } 268 + ``` 269 + 270 + #### Response 271 + 272 + ```json 273 + { 274 + "users": [ 275 + { 276 + "id": "string", 277 + "typeId": "string", 278 + "screenName": "string", 279 + "email": [ 280 + { 281 + "email": "user@example.com", 282 + "type": "Home", 283 + "verified": true, 284 + "verificationDate": "2025-01-01T00:00:00Z" 285 + } 286 + ], 287 + "ipCaptureEvent": [ 288 + { 289 + "ipAddress": "192.0.2.1", 290 + "eventName": "Upload", 291 + "dateTime": "2025-01-01T00:00:00Z", 292 + "possibleProxy": false, 293 + "port": 443 294 + } 295 + ], 296 + "data": {} 297 + } 298 + ], 299 + "media": [ 300 + { 301 + "id": "string", 302 + "typeId": "string", 303 + "missing": false, 304 + "publiclyAvailable": true, 305 + "fileName": "image.jpg", 306 + "additionalInfo": ["string"], 307 + "ipCaptureEvent": [ 308 + { 309 + "ipAddress": "192.0.2.1", 310 + "eventName": "Upload", 311 + "dateTime": "2025-01-01T00:00:00Z", 312 + "possibleProxy": false, 313 + "port": 443 314 + } 315 + ], 316 + "fileDetails": { 317 + "hash": "abee9985862d273160d930d2ac6ddb2cc33c74e73c702bcc8183d235f6f9685a", 318 + "hashType": "PDQ" 319 + } 320 + } 321 + ], 322 + "additionalFiles": [ 323 + { 324 + "fileUrl": "https://yourplatform.com/evidence/file.pdf", 325 + "fileName": "evidence.pdf", 326 + "additionalInfo": ["Supporting evidence"] 327 + } 328 + ], 329 + "messages": [ 330 + { "id": "string", "typeId": "string", "ipAddress": "192.0.2.1" } 331 + ], 332 + "additionalInfo": "string" 333 + } 334 + ``` 335 + 336 + #### Response Fields 337 + 338 + | Field | Type | Description | 339 + |---|---|---| 340 + | `users` | Array | Must include an entry for every user in the request. | 341 + | `users.id` | String | Must match the `id` from the request. | 342 + | `users.typeId` | String | Must match the `typeId` from the request. | 343 + | `users.screenName` | String | The user's screen name or username on your platform. | 344 + | `users.email` | Array | Known email addresses for the user. `type` may be `Business`, `Home`, or `Work`. | 345 + | `users.ipCaptureEvent` | Array | IP events associated with the user (e.g. logins, registrations). `eventName` may be `Login`, `Registration`, `Purchase`, `Upload`, `Other`, or `Unknown`. | 346 + | `users.data` | Object | Raw item data for the user. | 347 + | `media` | Array | Must include an entry for every media item in the request if present. | 348 + | `media.id` | String | Must match the `id` from the request. | 349 + | `media.typeId` | String | Must match the `typeId` from the request. | 350 + | `media.missing` | Boolean | Set to `true` if the media is no longer available. If **all** media items are `missing: true`, no CyberTip is filed. | 351 + | `media.publiclyAvailable` | Boolean | Whether the media was publicly accessible on your platform at the time of reporting. | 352 + | `media.fileName` | String | Original filename of the media. | 353 + | `media.additionalInfo` | Array\<String\> | Additional context about the media to include in the NCMEC file details. | 354 + | `media.ipCaptureEvent` | Array | IP events associated with this media item (e.g. the upload event). | 355 + | `media.fileDetails` | Object | Hash information for the file: `{ hash, hashType }`. | 356 + | `additionalFiles` | Array | Extra files to upload to NCMEC as supplemental evidence (e.g. screenshots). | 357 + | `messages` | Array | Message-level IP address data for conversation thread context. | 358 + | `additionalInfo` | String | Top-level freeform additional information to include in the report. | 359 + 360 + > [!IMPORTANT] 361 + > If the response does not include an entry for every user and media item in the request, Coop will throw an error and not submit the CyberTip. Your endpoint must return a response entry for each requested user and media item. 362 + > If all media items have `missing: true`, Coop will not file the CyberTip. The job will be marked as a permanent error and will not be retried. 363 + 364 + 365 + ### Preservation Endpoint 366 + 367 + Platforms that submit CyberTips may have data preservation obligations under laws like [18 U.S.C. § 368 + 2258A](https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title18-section2258A) and the [REPORT Act 369 + (2024)](https://www.missingkids.org/blog/2024/first-line-of-defense-guidelines-to-help-online-platforms-detect-sexually-exploited-kids), which extended content retention 370 + requirements to one year. Talk to your legal team to understand your organization's specific obligations. 371 + 372 + Coop calls a preservation endpoint you build and host immediately after a CyberTip is successfully submitted. Your endpoint should trigger whatever internal workflow 373 + handles data retention — for example, flagging the account for legal hold, snapshotting relevant records, or notifying your legal team. Coop passes the reported user, the 374 + media included in the CyberTip, and the NCMEC-assigned report ID so you have everything you need to identify what to retain. 375 + Coop signs every request with your org's signing key. Verify the signature before processing. 376 + 377 + #### Request 378 + 379 + ```json 380 + { 381 + "user": { "id": "string", "typeId": "string" }, 382 + "reportedMedia": [ 383 + { "id": "string", "typeId": "string" } 384 + ], 385 + "reportId": "string" 386 + } 387 + ``` 388 + 389 + | Field | Description | 390 + |---|---| 391 + | `user` | The user who was reported to NCMEC. | 392 + | `reportedMedia` | All media items that were included in the CyberTip. | 393 + | `reportId` | The NCMEC-assigned CyberTip report ID. | 394 + 395 + Coop only checks for a successful HTTP status code. The response body is ignored. This webhook is only called for production (non-test) CyberTip submissions. 396 + 397 + 398 + ## Retry Behavior 399 + 400 + If a CyberTip submission fails (e.g. due to a transient network error or NCMEC API outage), Coop automatically retries the submission. A background job runs periodically and retries any failed NCMEC decisions that: 401 + 402 + * Have not already been successfully submitted (no matching report in the database) 403 + * Have fewer than 10 prior retry attempts 404 + * Are not marked as a permanent error (e.g. all media missing) 405 + * Were decided within the past 30 days 406 + 407 + 408 + ## Viewing Submitted Reports 409 + 410 + After a CyberTip is submitted, the report is stored in Coop and accessible from the NCMEC Reports dashboard. The report record includes: 411 + 412 + * The NCMEC-assigned report ID 413 + * The reported user 414 + * All media included in the report 415 + * The full CyberTip XML 416 + * Any supplemental files uploaded 417 + * Any conversation thread CSVs uploaded 418 + * Whether the submission was a test or production report
+1
docs/SUMMARY.md
··· 10 10 - [Rules](RULES.md) 11 11 - [Reports](REPORTS.md) 12 12 - [Appeals](APPEALS.md) 13 + - [NCMEC Reporting](NCMEC.md) 13 14
+43 -5
docs/USER_GUIDE.md
··· 67 67 | Moderator | Yes | No | No | No | No | No | 68 68 | External Moderator | Yes | No | No | No | No | No | 69 69 70 + 71 + ### NCMEC Reporting Settings 72 + 73 + If your organization submits reports to the NCMEC CyberTipline, configure NCMEC reporting under **Settings → NCMEC** (or `/dashboard/settings/ncmec`). These settings are used when building and submitting CyberTip reports. 74 + 75 + ![Setting up NCMEC reporting on Coop: add the required information for the reports submitted to NCMEC for content violating your company policies](./images/coop-ncmec-settings.png) 76 + 77 + | Setting | Required | Description | 78 + |--------|----------|-------------| 79 + | **Username** | Yes | Your NCMEC CyberTipline API username. | 80 + | **Password** | Yes | Your NCMEC CyberTipline API password. | 81 + | **Company Report Name** | Yes | Your organization name as it appears in NCMEC reports. This value is also sent as the reporter’s product/service name (ESP service) for the reported user in each report. | 82 + | **Legal URL** | Yes | URL to your Terms of Service or legal policies (e.g. `https://yourcompany.com/terms`). | 83 + | **Contact Email** | No | Email for the reporting person on the CyberTip report. The XML receipt from NCMEC can serve as the ESP notification. | 84 + | **Terms of Service** | No | Optional TOS relevant to the incident being reported or URL to acceptable use policy. | 85 + | **Contact person (for law enforcement)** | No | Optional person law enforcement can contact (other than the reporting person): first name, last name, email, phone. All fields optional. | 86 + | **More Info URL** | No | Optional URL for additional information (e.g. `https://yourcompany.com/ncmec-info`). | 87 + | **Default NCMEC queue** | No | When reviewers choose “Enqueue to NCMEC,” jobs are sent to this manual review queue. Leave as “Use org default queue” to use the organization’s default queue. | 88 + | **Default internet detail type** | No | Incident context (channel/medium) for CyberTip reports: Web page, Email, Newsgroup, Chat/IM, Online gaming, Cell phone, Non-internet, or Peer-to-peer. When set, each report includes this in internetDetails. For "Web page," the More Info URL is used if set. | 89 + | **NCMEC Preservation Endpoint** | No | Optional webhook URL for NCMEC preservation requests after a report is submitted. Your service can use this to preserve user/data as required. | 90 + | **NCMEC Additional Info Endpoint** | No | Optional webhook URL. When building a report, Coop calls this endpoint to request additional information (e.g. user email, screen name, IP capture events) for the reported users and media. If not set, reports use minimal defaults (e.g. user ID as screen name). | 91 + 92 + Saving credentials and required fields (Company Report Name, Legal URL) enables NCMEC reporting for the organization. Reporting only occurs when reviewers submit a report from the NCMEC Review queue. 93 + 70 94 **Admin** 71 95 Admins manage their entire organizations. They have full control over all of the organization's resources and settings within Coop. 72 96 ··· 257 281 258 282 ## NCMEC Review and Reporting 259 283 260 - Coop is integrated with the [CyberTip Reporting API](https://report.cybertip.org/ispws/documentation) from the National Center for Missing and Exploited Children. In order to review accounts and content to report to NCMEC, you must have: 284 + Coop is integrated with the [CyberTip Reporting API](https://report.cybertip.org/ispws/documentation) from the National Center for Missing and Exploited Children. Head to [NCMEC.md](/docs/NCMEC.md) for more information. 261 285 262 - 1. A manual review queue called “NCMEC Review” 263 - 2. A User item type that is a RELATED_ITEM field for associated content. This stores a structured reference to the User item. NCMEC-type jobs extract the user identifier from this structured reference to look up the full user in the Item Investigation Service. 264 - 1. Coop will automatically convert content Item Types and aggregate all media associated with the user to convert the job into a NCMEC-type job. This creates a detailed NCMEC report around one user, rather than multiple NCMEC reports for multiple pieces of content from the same user. 286 + ![Coop's NCMEC settings page where you populate your organization's ESP username, password, name of org, and legal URL.](./images/coop-ncmec-settings.png) 287 + 288 + ### Prerequisites 289 + In order to review accounts and content to report to NCMEC, you must have: 290 + 291 + 1. NCMEC API credentials — a username and password for the CyberTip API, obtained from NCMEC directly by [registering as an Electronic Service Prover](https://esp.ncmec.org/registration). 292 + 2. A User item type with a creatorId field role on content types A User item type that is a RELATED_ITEM field for associated content, sometimes the `creatorId` field. This stores a structured reference to the User item. NCMEC-type jobs extract the user identifier from this structured reference to look up the full user in the Item Investigation Service. 293 + 3. NCMEC org settings configured. This is set via the Settings page (Admin only): API credentials, company template, legal URL, and contact email 294 + 4. A dedicated NCMEC manual review queue called "NCMEC Review". Coop uses a default_ncmec_queue_id setting to route NCMEC jobs. Queue IDs registered as production queues submit real CyberTips; all 295 + others use the NCMEC test environment. 296 + 5. An Additional Info endpoint (optional but recommended) is a signed webhook Coop calls before submitting a CyberTip to retrieve user email addresses, screen names, IP 297 + capture events, and per-media metadata. If not configured, Coop submits with minimal user data that can make reports less actionable. 298 + 6. A Preservation endpoint (optional) is a webhook Coop calls after a successful CyberTip submission with the report ID, so you can preserve relevant data per NCMEC 299 + requirements. 300 + 301 + Coop will automatically convert content Item Types and aggregate all media associated with the user to convert the job into a NCMEC-type job. This creates a detailed NCMEC report around one user, rather than multiple NCMEC reports for multiple pieces of content from the same user. 302 + 265 303 266 304 ![Coop's NCMEC Reporting task view. This differs from the usual task view as it aggregates all media associated wih a user. There are keyboard shortcuts to apply specific industry classifications, a dropdown for selecting the incident type, and add labels per NCMEC's CyberTip fields](./images/coop-ncmec-job.png) 267 305 The NCMEC job UI includes: ··· 275 313 * Misleading Words or Digital Images on the Internet 276 314 * Online Enticement of Children for Sexual Acts 277 315 * Unsolicited Obscene Material Sent to a Child 278 - * [Industry categorization](https://report.cybertip.org/ispws/documentation/index.html#incident-summary) (A categorization from the ESP-designated categorization scale): 316 + * [Industry categorization](https://report.cybertip.org/ispws/documentation/index.html#incident-summary) (A categorization from the [ESP-designated categorization scale](https://technologycoalition.org/wp-content/uploads/Tech_Coalition_Industry_Classification_System.pdf)): 279 317 * A1 280 318 * A2 281 319 * B1
docs/images/coop-ncmec-settings.png

This is a binary file and will not be displayed.

+57
server/graphql/generated.ts
··· 2766 2766 2767 2767 export type GQLNcmecIndustryClassification = 2768 2768 (typeof GQLNcmecIndustryClassification)[keyof typeof GQLNcmecIndustryClassification]; 2769 + export const GQLNcmecInternetDetailType = { 2770 + CellPhone: 'CELL_PHONE', 2771 + ChatIm: 'CHAT_IM', 2772 + Email: 'EMAIL', 2773 + Newsgroup: 'NEWSGROUP', 2774 + NonInternet: 'NON_INTERNET', 2775 + OnlineGaming: 'ONLINE_GAMING', 2776 + PeerToPeer: 'PEER_TO_PEER', 2777 + WebPage: 'WEB_PAGE', 2778 + } as const; 2779 + 2780 + export type GQLNcmecInternetDetailType = 2781 + (typeof GQLNcmecInternetDetailType)[keyof typeof GQLNcmecInternetDetailType]; 2769 2782 export type GQLNcmecManualReviewJobPayload = { 2770 2783 readonly __typename?: 'NcmecManualReviewJobPayload'; 2771 2784 readonly allMediaItems: ReadonlyArray<GQLNcmecContentItem>; ··· 2786 2799 readonly __typename?: 'NcmecOrgSettings'; 2787 2800 readonly companyTemplate?: Maybe<Scalars['String']>; 2788 2801 readonly contactEmail?: Maybe<Scalars['String']>; 2802 + readonly contactPersonEmail?: Maybe<Scalars['String']>; 2803 + readonly contactPersonFirstName?: Maybe<Scalars['String']>; 2804 + readonly contactPersonLastName?: Maybe<Scalars['String']>; 2805 + readonly contactPersonPhone?: Maybe<Scalars['String']>; 2806 + readonly defaultInternetDetailType?: Maybe<GQLNcmecInternetDetailType>; 2789 2807 readonly defaultNcmecQueueId?: Maybe<Scalars['String']>; 2790 2808 readonly legalUrl?: Maybe<Scalars['String']>; 2791 2809 readonly moreInfoUrl?: Maybe<Scalars['String']>; 2792 2810 readonly ncmecAdditionalInfoEndpoint?: Maybe<Scalars['String']>; 2793 2811 readonly ncmecPreservationEndpoint?: Maybe<Scalars['String']>; 2794 2812 readonly password: Scalars['String']; 2813 + readonly termsOfService?: Maybe<Scalars['String']>; 2795 2814 readonly username: Scalars['String']; 2796 2815 }; 2797 2816 2798 2817 export type GQLNcmecOrgSettingsInput = { 2799 2818 readonly companyTemplate?: InputMaybe<Scalars['String']>; 2800 2819 readonly contactEmail?: InputMaybe<Scalars['String']>; 2820 + readonly contactPersonEmail?: InputMaybe<Scalars['String']>; 2821 + readonly contactPersonFirstName?: InputMaybe<Scalars['String']>; 2822 + readonly contactPersonLastName?: InputMaybe<Scalars['String']>; 2823 + readonly contactPersonPhone?: InputMaybe<Scalars['String']>; 2824 + readonly defaultInternetDetailType?: InputMaybe<GQLNcmecInternetDetailType>; 2801 2825 readonly defaultNcmecQueueId?: InputMaybe<Scalars['String']>; 2802 2826 readonly legalUrl?: InputMaybe<Scalars['String']>; 2803 2827 readonly moreInfoUrl?: InputMaybe<Scalars['String']>; 2804 2828 readonly ncmecAdditionalInfoEndpoint?: InputMaybe<Scalars['String']>; 2805 2829 readonly ncmecPreservationEndpoint?: InputMaybe<Scalars['String']>; 2806 2830 readonly password: Scalars['String']; 2831 + readonly termsOfService?: InputMaybe<Scalars['String']>; 2807 2832 readonly username: Scalars['String']; 2808 2833 }; 2809 2834 ··· 4115 4140 }; 4116 4141 4117 4142 export type GQLSubmitNcmecReportInput = { 4143 + readonly escalateToHighPriority?: InputMaybe<Scalars['String']>; 4118 4144 readonly incidentType: GQLNcmecIncidentType; 4119 4145 readonly reportedMedia: ReadonlyArray<GQLNcmecMediaInput>; 4120 4146 readonly reportedMessages: ReadonlyArray<GQLNcmecThreadInput>; ··· 5416 5442 >; 5417 5443 NcmecFileAnnotation: GQLNcmecFileAnnotation; 5418 5444 NcmecIndustryClassification: GQLNcmecIndustryClassification; 5445 + NcmecInternetDetailType: GQLNcmecInternetDetailType; 5419 5446 NcmecManualReviewJobPayload: ResolverTypeWrapper<NcmecManualReviewJobPayload>; 5420 5447 NcmecMediaInput: GQLNcmecMediaInput; 5421 5448 NcmecOrgSettings: ResolverTypeWrapper<GQLNcmecOrgSettings>; ··· 10549 10576 ParentType, 10550 10577 ContextType 10551 10578 >; 10579 + contactPersonEmail?: Resolver< 10580 + Maybe<GQLResolversTypes['String']>, 10581 + ParentType, 10582 + ContextType 10583 + >; 10584 + contactPersonFirstName?: Resolver< 10585 + Maybe<GQLResolversTypes['String']>, 10586 + ParentType, 10587 + ContextType 10588 + >; 10589 + contactPersonLastName?: Resolver< 10590 + Maybe<GQLResolversTypes['String']>, 10591 + ParentType, 10592 + ContextType 10593 + >; 10594 + contactPersonPhone?: Resolver< 10595 + Maybe<GQLResolversTypes['String']>, 10596 + ParentType, 10597 + ContextType 10598 + >; 10599 + defaultInternetDetailType?: Resolver< 10600 + Maybe<GQLResolversTypes['NcmecInternetDetailType']>, 10601 + ParentType, 10602 + ContextType 10603 + >; 10552 10604 defaultNcmecQueueId?: Resolver< 10553 10605 Maybe<GQLResolversTypes['String']>, 10554 10606 ParentType, ··· 10575 10627 ContextType 10576 10628 >; 10577 10629 password?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 10630 + termsOfService?: Resolver< 10631 + Maybe<GQLResolversTypes['String']>, 10632 + ParentType, 10633 + ContextType 10634 + >; 10578 10635 username?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 10579 10636 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 10580 10637 };
+6 -1
server/graphql/modules/manualReviewTool.ts
··· 237 237 reportedMedia: [NcmecMediaInput!]! 238 238 reportedMessages: [NcmecThreadInput!]! 239 239 incidentType: NCMECIncidentType! 240 + escalateToHighPriority: String 240 241 } 241 242 242 243 enum AppealDecision { ··· 2183 2184 }; 2184 2185 case 'ACCEPT_APPEAL': 2185 2186 case 'REJECT_APPEAL': 2186 - case 'SUBMIT_NCMEC_REPORT': 2187 2187 case 'IGNORE': 2188 2188 case 'TRANSFORM_JOB_AND_RECREATE_IN_QUEUE': 2189 2189 return decision; 2190 + case 'SUBMIT_NCMEC_REPORT': 2191 + return { 2192 + ...decision, 2193 + escalateToHighPriority: decision.escalateToHighPriority ?? undefined, 2194 + }; 2190 2195 2191 2196 default: 2192 2197 assertUnreachable(decision);
+84 -5
server/graphql/modules/ncmec.ts
··· 1 1 import { AuthenticationError } from 'apollo-server-core'; 2 + import { UserInputError } from 'apollo-server-express'; 2 3 3 4 import { formatItemSubmissionForGQL } from '../../graphql/types.js'; 4 - import type { GQLMutationResolvers, GQLQueryResolvers } from '../generated.js'; 5 + import type { 6 + GQLMutationResolvers, 7 + GQLNcmecOrgSettings, 8 + GQLQueryResolvers, 9 + } from '../generated.js'; 10 + 11 + /** Input shape for updateNcmecOrgSettings; matches NcmecOrgSettingsInput in schema (used so resolver type-checks even if generated types are stale). */ 12 + type NcmecOrgSettingsInputShape = { 13 + username: string; 14 + password: string; 15 + contactEmail?: string | null; 16 + moreInfoUrl?: string | null; 17 + companyTemplate?: string | null; 18 + legalUrl?: string | null; 19 + ncmecPreservationEndpoint?: string | null; 20 + ncmecAdditionalInfoEndpoint?: string | null; 21 + defaultNcmecQueueId?: string | null; 22 + defaultInternetDetailType?: string | null; 23 + termsOfService?: string | null; 24 + contactPersonEmail?: string | null; 25 + contactPersonFirstName?: string | null; 26 + contactPersonLastName?: string | null; 27 + contactPersonPhone?: string | null; 28 + }; 29 + 30 + const VALID_NCMEC_INTERNET_DETAIL_TYPES: readonly string[] = [ 31 + 'WEB_PAGE', 32 + 'EMAIL', 33 + 'NEWSGROUP', 34 + 'CHAT_IM', 35 + 'ONLINE_GAMING', 36 + 'CELL_PHONE', 37 + 'NON_INTERNET', 38 + 'PEER_TO_PEER', 39 + ]; 5 40 6 41 const typeDefs = /* GraphQL */ ` 7 42 type Query { ··· 19 54 ): UpdateNcmecOrgSettingsResponse! 20 55 } 21 56 57 + enum NcmecInternetDetailType { 58 + WEB_PAGE 59 + EMAIL 60 + NEWSGROUP 61 + CHAT_IM 62 + ONLINE_GAMING 63 + CELL_PHONE 64 + NON_INTERNET 65 + PEER_TO_PEER 66 + } 67 + 22 68 type NcmecOrgSettings { 23 69 username: String! 24 70 password: String! ··· 29 75 ncmecPreservationEndpoint: String 30 76 ncmecAdditionalInfoEndpoint: String 31 77 defaultNcmecQueueId: String 78 + defaultInternetDetailType: NcmecInternetDetailType 79 + termsOfService: String 80 + contactPersonEmail: String 81 + contactPersonFirstName: String 82 + contactPersonLastName: String 83 + contactPersonPhone: String 32 84 } 33 85 34 86 input NcmecOrgSettingsInput { ··· 41 93 ncmecPreservationEndpoint: String 42 94 ncmecAdditionalInfoEndpoint: String 43 95 defaultNcmecQueueId: String 96 + defaultInternetDetailType: NcmecInternetDetailType 97 + termsOfService: String 98 + contactPersonEmail: String 99 + contactPersonFirstName: String 100 + contactPersonLastName: String 101 + contactPersonPhone: String 44 102 } 45 103 46 104 type UpdateNcmecOrgSettingsResponse { ··· 206 264 })), 207 265 })); 208 266 }, 209 - async ncmecOrgSettings(_, __, context) { 267 + async ncmecOrgSettings(_, __, context): Promise<GQLNcmecOrgSettings | null> { 210 268 const user = context.getUser(); 211 269 if (!user) { 212 270 throw new AuthenticationError('User required.'); ··· 214 272 const settings = await context.services.NcmecService.getNcmecOrgSettings( 215 273 user.orgId, 216 274 ); 217 - return settings; 275 + return settings as GQLNcmecOrgSettings | null; 218 276 }, 219 277 }; 220 278 221 279 const Mutation: GQLMutationResolvers = { 222 - async updateNcmecOrgSettings(_, { input }, context) { 280 + async updateNcmecOrgSettings(_, { input: rawInput }, context) { 223 281 const user = context.getUser(); 224 282 if (!user) { 225 283 throw new AuthenticationError('User required.'); 226 284 } 227 - 285 + const input = rawInput as NcmecOrgSettingsInputShape; 286 + const defaultInternetDetailType = 287 + input.defaultInternetDetailType == null 288 + ? null 289 + : (() => { 290 + const trimmed = String(input.defaultInternetDetailType).trim(); 291 + if ( 292 + trimmed !== '' && 293 + !VALID_NCMEC_INTERNET_DETAIL_TYPES.includes(trimmed) 294 + ) { 295 + throw new UserInputError( 296 + `defaultInternetDetailType must be one of: ${VALID_NCMEC_INTERNET_DETAIL_TYPES.join(', ')}`, 297 + ); 298 + } 299 + return trimmed === '' ? null : trimmed; 300 + })(); 228 301 await context.services.NcmecService.updateNcmecOrgSettings({ 229 302 orgId: user.orgId, 230 303 username: input.username, ··· 236 309 ncmecPreservationEndpoint: input.ncmecPreservationEndpoint ?? null, 237 310 ncmecAdditionalInfoEndpoint: input.ncmecAdditionalInfoEndpoint ?? null, 238 311 defaultNcmecQueueId: input.defaultNcmecQueueId ?? null, 312 + defaultInternetDetailType, 313 + termsOfService: input.termsOfService ?? null, 314 + contactPersonEmail: input.contactPersonEmail ?? null, 315 + contactPersonFirstName: input.contactPersonFirstName ?? null, 316 + contactPersonLastName: input.contactPersonLastName ?? null, 317 + contactPersonPhone: input.contactPersonPhone ?? null, 239 318 }); 240 319 241 320 return { success: true };
+4
server/iocContainer/index.ts
··· 1385 1385 media, 1386 1386 reviewerId, 1387 1387 incidentType: decision.incidentType, 1388 + ...(decision.escalateToHighPriority != null && 1389 + decision.escalateToHighPriority.trim() !== '' 1390 + ? { escalateToHighPriority: decision.escalateToHighPriority.trim() } 1391 + : {}), 1388 1392 }, 1389 1393 isTest, 1390 1394 );
+1
server/services/manualReviewToolService/modules/JobDecisioning.ts
··· 79 79 reportedMedia: readonly NCMECMediaReport[]; 80 80 reportedMessages: readonly NCMECThreadReport[]; 81 81 incidentType: string; 82 + escalateToHighPriority?: string; 82 83 } 83 84 | { 84 85 type: 'TRANSFORM_JOB_AND_RECREATE_IN_QUEUE';
+6
server/services/ncmecService/dbTypes.ts
··· 19 19 ncmec_preservation_endpoint?: string; 20 20 ncmec_additional_info_endpoint?: string; 21 21 default_ncmec_queue_id?: string | null; 22 + default_internet_detail_type?: string | null; 23 + terms_of_service?: string | null; 24 + contact_person_email?: string | null; 25 + contact_person_first_name?: string | null; 26 + contact_person_last_name?: string | null; 27 + contact_person_phone?: string | null; 22 28 created_at: GeneratedAlways<Date>; 23 29 updated_at: GeneratedAlways<Date>; 24 30 } & (
+67
server/services/ncmecService/ncmecReporting.test.ts
··· 1 + import { 2 + buildInternetDetailsFromOrgSetting, 3 + } from './ncmecReporting.js'; 4 + 5 + describe('NCMEC reporting', () => { 6 + describe('buildInternetDetailsFromOrgSetting', () => { 7 + it('returns undefined when type is null or undefined', () => { 8 + expect(buildInternetDetailsFromOrgSetting(null, undefined)).toBeUndefined(); 9 + expect(buildInternetDetailsFromOrgSetting(undefined, 'https://example.com')).toBeUndefined(); 10 + }); 11 + 12 + it('returns undefined when type is blank string', () => { 13 + expect(buildInternetDetailsFromOrgSetting('', undefined)).toBeUndefined(); 14 + expect(buildInternetDetailsFromOrgSetting(' ', undefined)).toBeUndefined(); 15 + }); 16 + 17 + it('returns undefined for unknown type', () => { 18 + expect(buildInternetDetailsFromOrgSetting('UNKNOWN', undefined)).toBeUndefined(); 19 + expect(buildInternetDetailsFromOrgSetting('web_page', undefined)).toBeUndefined(); 20 + }); 21 + 22 + it('returns WEB_PAGE incident with moreInfoUrl when provided', () => { 23 + const result = buildInternetDetailsFromOrgSetting('WEB_PAGE', 'https://example.com/info'); 24 + expect(result).toEqual([{ webPageIncident: { url: 'https://example.com/info' } }]); 25 + }); 26 + 27 + it('returns WEB_PAGE incident with "Not specified" when moreInfoUrl is empty', () => { 28 + const result = buildInternetDetailsFromOrgSetting('WEB_PAGE', undefined); 29 + expect(result).toEqual([{ webPageIncident: { url: 'Not specified' } }]); 30 + }); 31 + 32 + it('returns WEB_PAGE incident with "Not specified" when moreInfoUrl is blank', () => { 33 + const result = buildInternetDetailsFromOrgSetting('WEB_PAGE', ' '); 34 + expect(result).toEqual([{ webPageIncident: { url: 'Not specified' } }]); 35 + }); 36 + 37 + it('returns correct structure for each valid type (no extra fields)', () => { 38 + expect(buildInternetDetailsFromOrgSetting('EMAIL', undefined)).toEqual([ 39 + { emailIncident: {} }, 40 + ]); 41 + expect(buildInternetDetailsFromOrgSetting('NEWSGROUP', undefined)).toEqual([ 42 + { newsgroupIncident: {} }, 43 + ]); 44 + expect(buildInternetDetailsFromOrgSetting('CHAT_IM', undefined)).toEqual([ 45 + { chatImIncident: {} }, 46 + ]); 47 + expect(buildInternetDetailsFromOrgSetting('ONLINE_GAMING', undefined)).toEqual([ 48 + { onlineGamingIncident: {} }, 49 + ]); 50 + expect(buildInternetDetailsFromOrgSetting('CELL_PHONE', undefined)).toEqual([ 51 + { cellPhoneIncident: {} }, 52 + ]); 53 + expect(buildInternetDetailsFromOrgSetting('NON_INTERNET', undefined)).toEqual([ 54 + { nonInternetIncident: {} }, 55 + ]); 56 + expect(buildInternetDetailsFromOrgSetting('PEER_TO_PEER', undefined)).toEqual([ 57 + { peer2peerIncident: {} }, 58 + ]); 59 + }); 60 + 61 + it('trims type before matching', () => { 62 + expect(buildInternetDetailsFromOrgSetting(' CHAT_IM ', undefined)).toEqual([ 63 + { chatImIncident: {} }, 64 + ]); 65 + }); 66 + }); 67 + });
+134 -7
server/services/ncmecService/ncmecReporting.ts
··· 12 12 import { type JSONSchemaV4 } from '../../utils/json-schema-types.js'; 13 13 import { type FixKyselyRowCorrelation } from '../../utils/kysely.js'; 14 14 import { logErrorJson } from '../../utils/logging.js'; 15 - import { withRetries } from '../../utils/misc.js'; 15 + import { assertUnreachable, withRetries } from '../../utils/misc.js'; 16 16 import { 17 17 type CollapseCases, 18 18 type NonEmptyArray, ··· 178 178 threads: readonly NCMECThreadReport[]; 179 179 reviewerId: string; 180 180 incidentType: string; 181 + /** Optional reason for higher urgency; if present must be non-blank and max 3000 chars. */ 182 + escalateToHighPriority?: string; 181 183 }; 182 184 183 185 type Report = { ··· 368 370 port?: number; 369 371 }; 370 372 373 + const NCMEC_INTERNET_DETAIL_TYPES = [ 374 + 'WEB_PAGE', 375 + 'EMAIL', 376 + 'NEWSGROUP', 377 + 'CHAT_IM', 378 + 'ONLINE_GAMING', 379 + 'CELL_PHONE', 380 + 'NON_INTERNET', 381 + 'PEER_TO_PEER', 382 + ] as const; 383 + type NcmecInternetDetailTypeSetting = 384 + (typeof NCMEC_INTERNET_DETAIL_TYPES)[number]; 385 + 386 + export function buildInternetDetailsFromOrgSetting( 387 + defaultInternetDetailType: string | null | undefined, 388 + moreInfoUrl: string | null | undefined, 389 + ): Report['report']['internetDetails'] { 390 + if (!defaultInternetDetailType?.trim()) { 391 + return undefined; 392 + } 393 + const type = defaultInternetDetailType.trim() as NcmecInternetDetailTypeSetting; 394 + if (!NCMEC_INTERNET_DETAIL_TYPES.includes(type)) { 395 + return undefined; 396 + } 397 + // Use || so blank/empty URL becomes 'Not specified' (?? would keep '') 398 + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional: empty string should fallback 399 + const webPageUrl = moreInfoUrl?.trim() || 'Not specified'; 400 + switch (type) { 401 + case 'WEB_PAGE': 402 + return [{ webPageIncident: { url: webPageUrl } }]; 403 + case 'EMAIL': 404 + return [{ emailIncident: {} }]; 405 + case 'NEWSGROUP': 406 + return [{ newsgroupIncident: {} }]; 407 + case 'CHAT_IM': 408 + return [{ chatImIncident: {} }]; 409 + case 'ONLINE_GAMING': 410 + return [{ onlineGamingIncident: {} }]; 411 + case 'CELL_PHONE': 412 + return [{ cellPhoneIncident: {} }]; 413 + case 'NON_INTERNET': 414 + return [{ nonInternetIncident: {} }]; 415 + case 'PEER_TO_PEER': 416 + return [{ peer2peerIncident: {} }]; 417 + default: 418 + return assertUnreachable(type); 419 + } 420 + } 421 + 371 422 // Because CyberTip always responds with XML and how xml2js works, all of the 372 423 // objects returned by it are objects with _text keys 373 424 type CyberTipSubmitResponse = { ··· 434 485 additionalInfo?: string[]; 435 486 fileName?: string; 436 487 missing?: boolean; 488 + publiclyAvailable?: boolean; 437 489 fileDetails?: { 438 490 hash: string; 439 491 hashType: string; ··· 472 524 ipCaptureEvent?: IPNCMECEvent[]; 473 525 additionalInfo?: string[]; 474 526 fileName?: string; 527 + /** When set, sent to NCMEC in file details (whether the content was publicly viewable). */ 528 + publiclyAvailable?: boolean; 475 529 }; 476 530 477 531 type FileAdditionalInfo = { ··· 635 689 }, 636 690 fileName: { type: 'string' }, 637 691 missing: { type: 'boolean' }, 692 + publiclyAvailable: { type: 'boolean' }, 638 693 fileDetails: { 639 694 type: 'object', 640 695 properties: { ··· 1147 1202 .select([ 1148 1203 'company_template as companyTemplate', 1149 1204 'legal_url as legalURL', 1205 + 'default_internet_detail_type as defaultInternetDetailType', 1206 + 'terms_of_service as termsOfService', 1207 + 'contact_person_email as contactPersonEmail', 1208 + 'contact_person_first_name as contactPersonFirstName', 1209 + 'contact_person_last_name as contactPersonLastName', 1210 + 'contact_person_phone as contactPersonPhone', 1150 1211 ]) 1151 1212 .where('org_id', '=', reportParams.orgId) 1152 1213 .executeTakeFirst(); ··· 1210 1271 1211 1272 // Use the incident type from the report params 1212 1273 const incidentType = NCMECIncidentType[reportParams.incidentType as NCMECIncidentType]; 1213 - 1274 + 1275 + const escalateToHighPriority = 1276 + reportParams.escalateToHighPriority != null 1277 + ? reportParams.escalateToHighPriority.trim() 1278 + : undefined; 1279 + if ( 1280 + escalateToHighPriority !== undefined && 1281 + (escalateToHighPriority === '' || escalateToHighPriority.length > 3000) 1282 + ) { 1283 + throw new Error( 1284 + 'escalateToHighPriority must be non-blank when supplied and at most 3000 characters', 1285 + ); 1286 + } 1287 + 1288 + const internetDetails = buildInternetDetailsFromOrgSetting( 1289 + queryResponse.defaultInternetDetailType, 1290 + ncmecConfig?.more_info_url, 1291 + ); 1292 + 1214 1293 const report: Report = { 1215 1294 report: { 1216 1295 incidentSummary: { 1217 1296 incidentType, 1218 1297 incidentDateTime: maxCreatedAt, 1298 + ...(escalateToHighPriority 1299 + ? { escalateToHighPriority } 1300 + : {}), 1219 1301 }, 1220 - // TODO: Make this configurable per organization or item type 1221 - // internetDetails: [ 1222 - // ], 1302 + ...(internetDetails ? { internetDetails } : {}), 1223 1303 reporter: { 1224 1304 reportingPerson: { 1225 1305 email: [emailStringToNCMECEmail(ncmecConfig?.contact_email ?? '')], 1226 1306 }, 1227 1307 companyTemplate: queryResponse.companyTemplate, 1228 1308 legalURL: queryResponse.legalURL, 1309 + ...(queryResponse.termsOfService != null && 1310 + queryResponse.termsOfService.trim() !== '' && 1311 + queryResponse.termsOfService.length <= 3000 1312 + ? { termsOfService: queryResponse.termsOfService.trim() } 1313 + : {}), 1314 + // Use || so we only add contactPerson when at least one field is non-empty (?? would use first non-null even if empty) 1315 + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- intentional: treat empty string as absent */ 1316 + ...((queryResponse.contactPersonEmail?.trim() || 1317 + queryResponse.contactPersonFirstName?.trim() || 1318 + queryResponse.contactPersonLastName?.trim() || 1319 + queryResponse.contactPersonPhone?.trim()) 1320 + ? { 1321 + contactPerson: { 1322 + ...(queryResponse.contactPersonEmail?.trim() 1323 + ? { 1324 + email: [ 1325 + emailStringToNCMECEmail( 1326 + queryResponse.contactPersonEmail.trim(), 1327 + ), 1328 + ], 1329 + } 1330 + : {}), 1331 + ...(queryResponse.contactPersonFirstName?.trim() 1332 + ? { 1333 + firstName: queryResponse.contactPersonFirstName.trim(), 1334 + } 1335 + : {}), 1336 + ...(queryResponse.contactPersonLastName?.trim() 1337 + ? { 1338 + lastName: queryResponse.contactPersonLastName.trim(), 1339 + } 1340 + : {}), 1341 + ...(queryResponse.contactPersonPhone?.trim() 1342 + ? { 1343 + phone: { 1344 + _text: queryResponse.contactPersonPhone.trim(), 1345 + }, 1346 + } 1347 + : {}), 1348 + }, 1349 + } 1350 + : {}), 1351 + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ 1229 1352 }, 1230 1353 personOrUserReported: { 1231 1354 personOrUserReportedPerson: { 1232 1355 email: userAdditionalInfo.email, 1233 1356 }, 1234 1357 espIdentifier: reportParams.reportedUser.id, 1235 - // TODO: Make this configurable per organization 1236 - espService: 'Actions', 1358 + espService: queryResponse.companyTemplate, 1237 1359 screenName: userAdditionalInfo.screenName, 1238 1360 ...(reportParams.reportedUser.displayName 1239 1361 ? { ··· 1557 1679 fileDetails: { 1558 1680 reportId: parseInt(reportId), 1559 1681 fileId, 1682 + // All reported content is reviewed by the ESP before submission. 1560 1683 fileViewedByEsp: true, 1684 + exifViewedByEsp: true, 1561 1685 fileAnnotations: this.#fileAnnotationArrayToNCMECFileAnnotation( 1562 1686 media.fileAnnotations, 1563 1687 ), 1564 1688 industryClassification: media.industryClassification, 1689 + ...(additionalInfo.publiclyAvailable !== undefined 1690 + ? { publiclyAvailable: additionalInfo.publiclyAvailable } 1691 + : {}), 1565 1692 // Annoyingly, NCMEC only accepts IP Address XML in order so unwrap it 1566 1693 // in the correct order in case it was passed in incorrectly 1567 1694 ...(additionalInfo.ipCaptureEvent &&
+26
server/services/ncmecService/ncmecService.ts
··· 188 188 'ncmec_preservation_endpoint as ncmecPreservationEndpoint', 189 189 'ncmec_additional_info_endpoint as ncmecAdditionalInfoEndpoint', 190 190 'default_ncmec_queue_id as defaultNcmecQueueId', 191 + 'default_internet_detail_type as defaultInternetDetailType', 192 + 'terms_of_service as termsOfService', 193 + 'contact_person_email as contactPersonEmail', 194 + 'contact_person_first_name as contactPersonFirstName', 195 + 'contact_person_last_name as contactPersonLastName', 196 + 'contact_person_phone as contactPersonPhone', 191 197 ]) 192 198 .where('org_id', '=', orgId) 193 199 .executeTakeFirst(); ··· 206 212 ncmecPreservationEndpoint: string | null; 207 213 ncmecAdditionalInfoEndpoint: string | null; 208 214 defaultNcmecQueueId: string | null; 215 + defaultInternetDetailType: string | null; 216 + termsOfService: string | null; 217 + contactPersonEmail: string | null; 218 + contactPersonFirstName: string | null; 219 + contactPersonLastName: string | null; 220 + contactPersonPhone: string | null; 209 221 }) { 210 222 await this.pgQuery 211 223 .insertInto('ncmec_reporting.ncmec_org_settings') ··· 222 234 ncmec_additional_info_endpoint: 223 235 params.ncmecAdditionalInfoEndpoint ?? undefined, 224 236 default_ncmec_queue_id: params.defaultNcmecQueueId ?? null, 237 + default_internet_detail_type: 238 + params.defaultInternetDetailType ?? null, 239 + terms_of_service: params.termsOfService ?? null, 240 + contact_person_email: params.contactPersonEmail ?? null, 241 + contact_person_first_name: params.contactPersonFirstName ?? null, 242 + contact_person_last_name: params.contactPersonLastName ?? null, 243 + contact_person_phone: params.contactPersonPhone ?? null, 225 244 actions_to_run_upon_report_creation: null, 226 245 policies_applied_to_actions_run_on_report_creation: null, 227 246 }) ··· 238 257 ncmec_additional_info_endpoint: 239 258 params.ncmecAdditionalInfoEndpoint ?? undefined, 240 259 default_ncmec_queue_id: params.defaultNcmecQueueId ?? null, 260 + default_internet_detail_type: 261 + params.defaultInternetDetailType ?? null, 262 + terms_of_service: params.termsOfService ?? null, 263 + contact_person_email: params.contactPersonEmail ?? null, 264 + contact_person_first_name: params.contactPersonFirstName ?? null, 265 + contact_person_last_name: params.contactPersonLastName ?? null, 266 + contact_person_phone: params.contactPersonPhone ?? null, 241 267 }), 242 268 ) 243 269 .execute();