From ae9b952ea5d265ac83cb818bd1a24eec042ad65e Mon Sep 17 00:00:00 2001 From: juliet Date: Mon, 29 Jun 2026 11:31:06 -0400 Subject: [PATCH] ncmec: tighten email field role from STRING to EMAIL_ADDRESS scalar (#842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ncmec: tighten `email` field role from STRING to EMAIL_ADDRESS scalar Follow-up to #840 and #841. Now that `@roostorg/coop-types@2.4.0` ships a dedicated `EMAIL_ADDRESS` scalar, switch the email field role plumbing to require it instead of any STRING-typed field. Mirrors the two-PR pattern PR #559 → PR #583 used for `IP_ADDRESS`. - Bump `@roostorg/coop-types` to ^2.4.0 in server and client - Add `EMAIL_ADDRESS` to `ScalarType`, `SignalInputType`, `FieldType` SDL enums; regenerate codegen - Add `[ScalarTypes.EMAIL_ADDRESS]` handler in `fieldTypeHandlers.ts` (string coerce + trim; null on empty; format validation deferred per Caleb's note on #840) - `FieldRoleToScalarType.email`: STRING → EMAIL_ADDRESS - Client `schemaFieldRolesFieldTypes.EMAIL`: String → EmailAddress - Migration: drop the `valid_email_field_field_type` CHECK constraint (which required STRING) and replace with one requiring EMAIL_ADDRESS - Exhaustive-switch updates for `ManualReviewJobFieldsComponent` and `RuleTestModal` - `EmailAddressArbitrary` for fast-check test fixtures, with culturally diverse example names on RFC 2606 reserved domains Migration safety: any existing email_field mapping to a STRING field will fail the new constraint. #840 merged hours before this; no adopter should have configured the role yet. Operators between #840 and this migration who did configure it will need to either change their field type to EMAIL_ADDRESS or null out the mapping before applying. Co-Authored-By: Claude Opus 4.7 (1M context) * client: add EMAIL_ADDRESS cases to three exhaustive switches CI on #842 failed `npm run lint` (= `tsc --noEmit`) because widening `ScalarType` to include `EMAIL_ADDRESS` left three client switches incomplete. The `JsonValue` errors in `itemTypeCodeSampleUtils.ts` were downstream of `generateFakeScalarFieldValue` not handling the new scalar. - `signal.ts`: comparators for `EmailAddress` mirror `String` / `Url` / `IpAddress` - `RuleInsightsSamplesTable.tsx`: stringify (mirrors `IpAddress`) - `itemTypeUtils.ts:generateFakeScalarFieldValue`: returns a fake email on RFC 2606 `example.com` Co-Authored-By: Claude Opus 4.7 (1M context) * ncmec: data-migrate stale email_field mappings before tightening CHECK Addresses Tao's review on #842: the original "no adopter has had time to configure the role" assumption was wrong. Deployments between #840 and this migration may have mapped `email_field` to a STRING field; the new CHECK constraint would reject those rows and block the migration entirely. Add a DO-block data migration that runs in the same transaction before the CHECK swap. For each item_type whose `email_field` doesn't point at an EMAIL_ADDRESS-typed field in `fields`, NULL out the mapping and emit a NOTICE naming the row (id, name, org_id) so operators can reconfigure via the admin UI if needed. The underlying STRING field's data is left untouched; only the role pointer is cleared. Header comment updated to reflect the new data-migration step. Co-Authored-By: Claude Opus 4.7 (1M context) * db: forward postgres NOTICE events to console.warn during migrations Addresses Tao's review on #842: the migration's `RAISE NOTICE` lines were being swallowed because Sequelize's pg dialect doesn't surface server-side messages by default. Operators running `npm run db:update` saw the migration succeed but had no signal about which adopter configurations had been cleared. Wire a Sequelize `afterConnect` hook in `pg-base.ts` that attaches a `notice` listener to every pg client the pool opens, forwarding each message to `console.warn` prefixed with the severity. Future migrations that use `RAISE NOTICE` (or `RAISE WARNING`) will also benefit, not just this one. Update the migration's header comment to direct operators to scan the `db:update` output for `[postgres NOTICE]` lines after applying, so the cleared mappings don't go unnoticed. Co-Authored-By: Claude Opus 4.7 (1M context) * ncmec: wire HMA hashes into outgoing report's fileDetails.originalFileHash (#844) NCMEC's CyberTipline accepts an `originalFileHash[]` element with a `hashType` attribute per entry. HMA already computes per-image hashes at item-submission time (`HMAHashBankService.hashContentFromUrl`, stored on the image object alongside `url`), but the NCMEC report builder discarded them. - Add `OriginalFileHash` type and `originalFileHash?: OriginalFileHash[]` to `FileDetails` (positioned after `industryClassification`, before `ipCaptureEvent`, in line with NCMEC XSD ordering for forensic fields). - Add `hashes?: Record` to the internal `Media` type so the report assembly can pass them through to `#upload`. - `buildSubmitReportParamsFromDecision` now extracts hashes from the matching image in the item data (walks scalar, array, and map containers). New `extractHashesForUrl` helper. - `#upload` converts the hashes via `toOriginalFileHashes` and includes the resulting array on `fileDetails` when non-empty. - New `toOriginalFileHashes` exported helper handles trimming, empty-value filtering, and the algorithm-name uppercasing for the `hashType` attribute. Tests: - 4 new in `buildSubmitReportParamsFromDecision.test.ts` covering the scalar IMAGE case, ARRAY-of-IMAGE container traversal, URL-mismatch omission, and missing-hashes omission. - 4 new in `ncmecReporting.test.ts` for `toOriginalFileHashes`'s precedence rules (uppercasing, trimming, empty-value drops, undefined-on-empty). This is one of the P1 items from the audit in #843. Co-authored-by: Claude Opus 4.7 (1M context) * db: also lower client_min_messages so RAISE NOTICE actually surfaces Initial fix only attached a notice listener, but verified locally that NOTICE messages from RAISE NOTICE inside PL/pgSQL DO blocks were still being filtered out. Root cause: Sequelize raises `client_min_messages` to WARNING by default, which makes postgres filter NOTICE-severity messages server-side before they reach the client. The listener was correctly attached but never fired for NOTICEs. In the same afterConnect hook, also issue `SET client_min_messages = 'notice'` so postgres sends NOTICE messages to the client. Verified locally: per-row NOTICEs from the email-field-clearing migration now appear in `db:update` output as expected. Co-Authored-By: Claude Opus 4.7 (1M context) * ncmec: wire webhook fileDetails.hash into outgoing report's originalFileHash (#850) Closes the follow-up gap noted in PR #844. The additional-info webhook can return a single `{ hash, hashType }` per media item; #844 wired HMA-sourced hashes through but webhook-sourced hashes were still dropped because: 1. `MediaAdditionalInfo` (the internal type that `#upload` consumes) didn't declare `fileDetails`; the validation-schema-typed `NcmecAdditionalInfoResponse` has it, but the data was erased at the boundary between webhook response and internal use. 2. `toOriginalFileHashes` took only the HMA map as input. Changes: - Add `fileDetails?: { hash; hashType }` to `MediaAdditionalInfo`. - Refactor `toOriginalFileHashes` to take both sources via an opts object (`hmaHashes`, `webhookFileDetails`). Hashes from both are combined and deduped on (`hashType` uppercase, trimmed hash value) so a webhook returning the same algorithm as HMA produces one entry in the outgoing report, not two. - Update `#upload` call site to pass both sources. - Remove dead `fileDetails: { ipCaptureEvent: [] }` from the no-webhook fallback; it never matched any consumed shape and nothing read it (the `ipCaptureEvent` lives at the top level of `MediaAdditionalInfo`, not nested). Tests: 6 new cases covering webhook-only, both sources, exact-match dedup, same-algorithm-different-hash kept-both, whitespace-only webhook hash dropped, and case-insensitive dedup on algorithm name. The toOriginalFileHashes test block is moved to its own file (`toOriginalFileHashes.test.ts`) to keep `ncmecReporting.test.ts` under the 500-line max-lines limit. Co-authored-by: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- client/package-lock.json | 8 +- client/package.json | 2 +- client/src/graphql/generated.ts | 3 + client/src/models/signal.ts | 1 + .../dashboard/item_types/itemTypeUtils.ts | 4 +- .../v2/ManualReviewJobFieldsComponent.tsx | 8 +- .../dashboard/rules/info/RuleTestModal.tsx | 2 + .../insights/RuleInsightsSamplesTable.tsx | 1 + db/src/configs/pg-base.ts | 42 ++++++- ...hten_email_field_role_to_email_address.sql | 61 ++++++++++ server/graphql/generated.ts | 3 + server/graphql/schema.ts | 3 + server/package-lock.json | 56 +--------- server/package.json | 2 +- .../fieldTypeHandlers.ts | 16 +++ .../types/itemTypes.ts | 2 +- ...uildSubmitReportParamsFromDecision.test.ts | 84 ++++++++++++++ .../buildSubmitReportParamsFromDecision.ts | 47 ++++++++ .../ncmecService/ncmecReporting.test.ts | 3 + .../services/ncmecService/ncmecReporting.ts | 69 +++++++++++- .../ncmecService/toOriginalFileHashes.test.ts | 104 ++++++++++++++++++ server/test/arbitraries/ContentType.ts | 11 ++ 22 files changed, 467 insertions(+), 65 deletions(-) create mode 100644 db/src/scripts/api-server-pg/2026.06.26T03.32.16.tighten_email_field_role_to_email_address.sql create mode 100644 server/services/ncmecService/toOriginalFileHashes.test.ts diff --git a/client/package-lock.json b/client/package-lock.json index 53e53b5..dc54525 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -29,7 +29,7 @@ "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@roostorg/coop-types": "^2.3.0", + "@roostorg/coop-types": "^2.4.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", @@ -3990,9 +3990,9 @@ ] }, "node_modules/@roostorg/coop-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@roostorg/coop-types/-/coop-types-2.3.0.tgz", - "integrity": "sha512-aOHomJIoqNUUoHxewxjOe6x2Y7ALcVgweUX7SxD18DwfylYK1KJZi4Q5nZB0SMm6q7V5H6btJfo4jRQMLxUT+g==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@roostorg/coop-types/-/coop-types-2.4.0.tgz", + "integrity": "sha512-N6apd8CebHsC1cvzm7YuVaGfUEi4RG5Lth785cDoh9KdOqC0MO6AKjPFR76GY6lAE/CCUPzFEqoFQYMWQt05qg==", "license": "ISC", "dependencies": { "date-fns": "^2.29.3", diff --git a/client/package.json b/client/package.json index 2134228..504c3df 100644 --- a/client/package.json +++ b/client/package.json @@ -37,7 +37,7 @@ "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", - "@roostorg/coop-types": "^2.3.0", + "@roostorg/coop-types": "^2.4.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.13", diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index f25d961..afc2273 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -1278,6 +1278,7 @@ export const GQLFieldType = { Audio: 'AUDIO', Boolean: 'BOOLEAN', Datetime: 'DATETIME', + EmailAddress: 'EMAIL_ADDRESS', Geohash: 'GEOHASH', Id: 'ID', Image: 'IMAGE', @@ -4262,6 +4263,7 @@ export const GQLScalarType = { Audio: 'AUDIO', Boolean: 'BOOLEAN', Datetime: 'DATETIME', + EmailAddress: 'EMAIL_ADDRESS', Geohash: 'GEOHASH', Id: 'ID', Image: 'IMAGE', @@ -4403,6 +4405,7 @@ export const GQLSignalInputType = { Audio: 'AUDIO', Boolean: 'BOOLEAN', Datetime: 'DATETIME', + EmailAddress: 'EMAIL_ADDRESS', FullItem: 'FULL_ITEM', Geohash: 'GEOHASH', Id: 'ID', diff --git a/client/src/models/signal.ts b/client/src/models/signal.ts index ec9f57f..24f06f6 100644 --- a/client/src/models/signal.ts +++ b/client/src/models/signal.ts @@ -96,6 +96,7 @@ export function outputTypeToComparators(outputType: GQLSignalOutputType) { case GQLScalarType.Url: case GQLScalarType.String: case GQLScalarType.IpAddress: + case GQLScalarType.EmailAddress: return outputType.__typename === 'EnumSignalOutputType' && outputType.ordered ? orderedComparators diff --git a/client/src/webpages/dashboard/item_types/itemTypeUtils.ts b/client/src/webpages/dashboard/item_types/itemTypeUtils.ts index d6a59dd..73e0d1a 100644 --- a/client/src/webpages/dashboard/item_types/itemTypeUtils.ts +++ b/client/src/webpages/dashboard/item_types/itemTypeUtils.ts @@ -50,7 +50,7 @@ export const schemaFieldRolesFieldTypes = { [SchemaFieldRoles.BACKGROUND_IMAGE]: GQLScalarType.Image, [SchemaFieldRoles.IS_DELETED]: GQLScalarType.Boolean, [SchemaFieldRoles.IP_ADDRESS]: GQLScalarType.IpAddress, - [SchemaFieldRoles.EMAIL]: GQLScalarType.String, + [SchemaFieldRoles.EMAIL]: GQLScalarType.EmailAddress, } satisfies Omit< { [key in SchemaFieldRoles]: GQLScalarType }, SchemaFieldRoles.NONE @@ -229,6 +229,8 @@ export function generateFakeScalarFieldValue(fieldType: ScalarType) { return `https://url.com/some-path/${Math.floor(100 * Math.random())}`; case 'IP_ADDRESS': return `192.0.2.${Math.floor(255 * Math.random())}`; + case 'EMAIL_ADDRESS': + return `user${Math.floor(1000 * Math.random())}@example.com`; case 'MEDIA': { // Like AUDIO/IMAGE/VIDEO, the fake value is the *input* (pre-coercion) form // — a raw URL string. The server's MEDIA coercion resolves the kind from diff --git a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx index 314a6fc..3cf58a5 100644 --- a/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx +++ b/client/src/webpages/dashboard/mrt/manual_review_job/v2/ManualReviewJobFieldsComponent.tsx @@ -195,7 +195,10 @@ function TableRowComponent(props: { case 'ID': case 'NUMBER': case 'POLICY_ID': - case 'STRING': { + case 'STRING': + case 'EMAIL_ADDRESS': { + // EMAIL_ADDRESS renders as plain text for now; a follow-up could make + // it a mailto/pivot link the way IP_ADDRESS pivots on the IP. return (
{label ? ( @@ -520,6 +523,7 @@ function FieldComponent(props: { case 'URL': case 'POLICY_ID': case 'IP_ADDRESS': + case 'EMAIL_ADDRESS': case 'DATETIME': return (
@@ -576,6 +580,7 @@ function ContainerComponent(props: { case 'DATETIME': case 'POLICY_ID': case 'IP_ADDRESS': + case 'EMAIL_ADDRESS': return true; case 'AUDIO': case 'IMAGE': @@ -618,6 +623,7 @@ function ContainerComponent(props: { case 'URL': case 'POLICY_ID': case 'IP_ADDRESS': + case 'EMAIL_ADDRESS': case 'MEDIA': case 'VIDEO': { throw Error('Cannot call container component with scalar field'); diff --git a/client/src/webpages/dashboard/rules/info/RuleTestModal.tsx b/client/src/webpages/dashboard/rules/info/RuleTestModal.tsx index 8c8c374..7d3f201 100644 --- a/client/src/webpages/dashboard/rules/info/RuleTestModal.tsx +++ b/client/src/webpages/dashboard/rules/info/RuleTestModal.tsx @@ -140,6 +140,8 @@ export default function RuleTestModal(props: { return 'user-id'; case 'IP_ADDRESS': return '192.0.2.1'; + case 'EMAIL_ADDRESS': + return 'user@example.com'; case 'ARRAY': return `${getPlaceholder( containerValueScalarType!, diff --git a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx index 1caf6c9..a7e4ad5 100644 --- a/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx +++ b/client/src/webpages/dashboard/rules/info/insights/RuleInsightsSamplesTable.tsx @@ -764,6 +764,7 @@ export function getStringFromContent( case GQLFieldType.Map: case GQLFieldType.PolicyId: case GQLFieldType.IpAddress: + case GQLFieldType.EmailAddress: return content.toString(); case GQLFieldType.Image: case GQLFieldType.Video: diff --git a/db/src/configs/pg-base.ts b/db/src/configs/pg-base.ts index 9b642b2..d6cca26 100644 --- a/db/src/configs/pg-base.ts +++ b/db/src/configs/pg-base.ts @@ -28,7 +28,47 @@ export function makePostgresDatabaseConfig(opts: { driverOpts: Options & { schema: string }; maintenanceDatabase?: string; }): DatabaseConfig { - const { driverOpts, scriptsDirectory, defaultScriptFormat } = opts; + const { scriptsDirectory, defaultScriptFormat } = opts; + // Forward server-side `RAISE NOTICE` / `RAISE WARNING` from migrations + // (e.g. PL/pgSQL DO blocks that report rows they modified) to stderr. + // Sequelize sets `client_min_messages` to WARNING by default, which makes + // postgres filter NOTICE-severity messages server-side before they reach + // the client; we lower it to NOTICE on each connection so RAISE NOTICE + // actually surfaces, then attach a listener that prints each one. + type PgClientLike = { + on: (event: string, cb: (msg: unknown) => void) => void; + query: (sql: string, cb: (err: unknown) => void) => void; + }; + const isPgClientLike = (c: unknown): c is PgClientLike => + c !== null && + typeof c === 'object' && + 'on' in c && + typeof (c as { on: unknown }).on === 'function' && + 'query' in c && + typeof (c as { query: unknown }).query === 'function'; + const driverOpts: Options & { schema: string } = { + ...opts.driverOpts, + hooks: { + ...opts.driverOpts.hooks, + afterConnect(connection: unknown) { + if (!isPgClientLike(connection)) return; + connection.on('notice', (msg) => { + const m = msg as { message?: string; severity?: string }; + const severity = m.severity ?? 'NOTICE'; + // eslint-disable-next-line no-console + console.warn(`[postgres ${severity}] ${m.message ?? ''}`); + }); + connection.query(`SET client_min_messages = 'notice'`, (err) => { + if (err) { + // eslint-disable-next-line no-console + console.warn( + `[postgres] failed to lower client_min_messages: ${String(err)}`, + ); + } + }); + }, + }, + }; // DB used for CREATE/DROP DATABASE (can't be the target DB itself). // Defaults to `postgres`; some managed providers use a different name // (e.g. `defaultdb`). diff --git a/db/src/scripts/api-server-pg/2026.06.26T03.32.16.tighten_email_field_role_to_email_address.sql b/db/src/scripts/api-server-pg/2026.06.26T03.32.16.tighten_email_field_role_to_email_address.sql new file mode 100644 index 0000000..be8b775 --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.06.26T03.32.16.tighten_email_field_role_to_email_address.sql @@ -0,0 +1,61 @@ +-- Follow-up to #840: now that `@roostorg/coop-types@2.4.0` ships a dedicated +-- `EMAIL_ADDRESS` scalar (#841), tighten the `email_field` field-role CHECK +-- constraint from STRING to EMAIL_ADDRESS so adopters can't map arbitrary +-- string fields (e.g. bio, displayName) into NCMEC reports. +-- +-- Deployments between #840 and this migration may have configured the +-- `email` role to point at a STRING field. The new CHECK would reject +-- those rows and block the migration, so the data step below clears any +-- such mapping (setting `email_field` back to NULL) and emits a NOTICE +-- per affected row so operators can see which item type's mapping was +-- dropped and reconfigure via the admin UI if needed. The actual data in +-- the underlying STRING field is untouched; only the role pointer is +-- cleared. +-- +-- Operators: after applying this migration, scan the `db:update` output +-- for lines beginning with `[postgres NOTICE]`. Each one names the item +-- type (id, name, org_id) whose `email_field` mapping was cleared. +-- Reconfigure those item types in the admin UI if NCMEC reporting was +-- relying on them. The forwarding of pg NOTICE events to the migration +-- runner output is provided by the `afterConnect` hook in +-- `db/src/configs/pg-base.ts` (added in this same PR). + +BEGIN; + +DO $$ +DECLARE + affected RECORD; +BEGIN + FOR affected IN + SELECT id, org_id, name, email_field + FROM public.item_types + WHERE email_field IS NOT NULL + AND NOT jsonb_path_exists( + (array_to_json(fields))::jsonb, + '$[*]?(@."name" == $"name" && @."type" == "EMAIL_ADDRESS")'::jsonpath, + jsonb_build_object('name', email_field) + ) + LOOP + RAISE NOTICE 'Clearing email_field=% from item_type id=% (name=%, org=%): no EMAIL_ADDRESS field of that name exists. Reconfigure via admin UI if needed.', + affected.email_field, affected.id, affected.name, affected.org_id; + + UPDATE public.item_types + SET email_field = NULL + WHERE id = affected.id; + END LOOP; +END $$; + +ALTER TABLE public.item_types + DROP CONSTRAINT valid_email_field_field_type; + +ALTER TABLE public.item_types + ADD CONSTRAINT valid_email_field_field_type CHECK ( + (email_field IS NULL) + OR jsonb_path_exists( + (array_to_json(fields))::jsonb, + '$[*]?(@."name" == $"name" && @."type" == "EMAIL_ADDRESS")'::jsonpath, + jsonb_build_object('name', email_field) + ) + ); + +COMMIT; diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 2210da2..4920888 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -1346,6 +1346,7 @@ export const GQLFieldType = { Audio: 'AUDIO', Boolean: 'BOOLEAN', Datetime: 'DATETIME', + EmailAddress: 'EMAIL_ADDRESS', Geohash: 'GEOHASH', Id: 'ID', Image: 'IMAGE', @@ -4330,6 +4331,7 @@ export const GQLScalarType = { Audio: 'AUDIO', Boolean: 'BOOLEAN', Datetime: 'DATETIME', + EmailAddress: 'EMAIL_ADDRESS', Geohash: 'GEOHASH', Id: 'ID', Image: 'IMAGE', @@ -4471,6 +4473,7 @@ export const GQLSignalInputType = { Audio: 'AUDIO', Boolean: 'BOOLEAN', Datetime: 'DATETIME', + EmailAddress: 'EMAIL_ADDRESS', FullItem: 'FULL_ITEM', Geohash: 'GEOHASH', Id: 'ID', diff --git a/server/graphql/schema.ts b/server/graphql/schema.ts index 194c123..00affb1 100644 --- a/server/graphql/schema.ts +++ b/server/graphql/schema.ts @@ -67,6 +67,7 @@ const typeDefs = /* GraphQL */ ` URL POLICY_ID IP_ADDRESS + EMAIL_ADDRESS } # This is equivalent to ScalarType, but with 'FULL_ITEM' added @@ -87,6 +88,7 @@ const typeDefs = /* GraphQL */ ` FULL_ITEM POLICY_ID IP_ADDRESS + EMAIL_ADDRESS } # !! IMPORTANT: when you add a value here, also add it to FieldType !! @@ -113,6 +115,7 @@ const typeDefs = /* GraphQL */ ` URL POLICY_ID IP_ADDRESS + EMAIL_ADDRESS } enum Language { diff --git a/server/package-lock.json b/server/package-lock.json index c46fbb8..77d2fac 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -25,7 +25,7 @@ "@opentelemetry/api": "^1.8.0", "@opentelemetry/semantic-conventions": "^1.22.0", "@roostorg/coop-integration-example": "^2.0.0", - "@roostorg/coop-types": "^2.3.0", + "@roostorg/coop-types": "^2.4.0", "@sendgrid/mail": "^8.1.6", "@stdlib/stats-binomial-test": "^0.0.7", "@total-typescript/ts-reset": "^0.3.7", @@ -3624,9 +3624,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3644,9 +3641,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3664,9 +3658,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3684,9 +3675,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3704,9 +3692,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3724,9 +3709,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3744,9 +3726,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3764,9 +3743,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3979,9 +3955,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3996,9 +3969,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4013,9 +3983,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4030,9 +3997,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4047,9 +4011,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4064,9 +4025,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4081,9 +4039,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4098,9 +4053,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4275,9 +4227,9 @@ } }, "node_modules/@roostorg/coop-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@roostorg/coop-types/-/coop-types-2.3.0.tgz", - "integrity": "sha512-aOHomJIoqNUUoHxewxjOe6x2Y7ALcVgweUX7SxD18DwfylYK1KJZi4Q5nZB0SMm6q7V5H6btJfo4jRQMLxUT+g==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@roostorg/coop-types/-/coop-types-2.4.0.tgz", + "integrity": "sha512-N6apd8CebHsC1cvzm7YuVaGfUEi4RG5Lth785cDoh9KdOqC0MO6AKjPFR76GY6lAE/CCUPzFEqoFQYMWQt05qg==", "license": "ISC", "dependencies": { "date-fns": "^2.29.3", diff --git a/server/package.json b/server/package.json index aff7cce..cd0d330 100644 --- a/server/package.json +++ b/server/package.json @@ -44,7 +44,7 @@ "@opentelemetry/api": "^1.8.0", "@opentelemetry/semantic-conventions": "^1.22.0", "@roostorg/coop-integration-example": "^2.0.0", - "@roostorg/coop-types": "^2.3.0", + "@roostorg/coop-types": "^2.4.0", "@sendgrid/mail": "^8.1.6", "@stdlib/stats-binomial-test": "^0.0.7", "@total-typescript/ts-reset": "^0.3.7", diff --git a/server/services/itemProcessingService/fieldTypeHandlers.ts b/server/services/itemProcessingService/fieldTypeHandlers.ts index 47a2c89..f919d6d 100644 --- a/server/services/itemProcessingService/fieldTypeHandlers.ts +++ b/server/services/itemProcessingService/fieldTypeHandlers.ts @@ -246,6 +246,22 @@ export const fieldTypeHandlers: Handlers = { }, getValues: scalarGetValues, }, + [ScalarTypes.EMAIL_ADDRESS]: { + // Leading/trailing whitespace stripped; all-whitespace/empty becomes + // "field omitted". Format validation (RFC 5321/5322 shape) is deferred + // to a follow-up issue per PR #840 reviewer note; for now this just + // enforces "string" so the type system can rely on it. + coerce: (v) => { + if (typeof v !== 'string') { + return new Error( + 'This field, if given, must be a string email address.', + ); + } + const trimmed = v.trim(); + return trimmed === '' ? null : trimmed; + }, + getValues: scalarGetValues, + }, [ContainerTypes.ARRAY]: { coerce(value, itemTypeIds, container) { if (!Array.isArray(value)) { diff --git a/server/services/moderationConfigService/types/itemTypes.ts b/server/services/moderationConfigService/types/itemTypes.ts index 076386b..c38c4e9 100644 --- a/server/services/moderationConfigService/types/itemTypes.ts +++ b/server/services/moderationConfigService/types/itemTypes.ts @@ -128,7 +128,7 @@ export type FieldRoleToScalarType = { backgroundImage: ScalarTypes['IMAGE']; isDeleted: ScalarTypes['BOOLEAN']; ipAddress: ScalarTypes['IP_ADDRESS']; - email: ScalarTypes['STRING']; + email: ScalarTypes['EMAIL_ADDRESS']; }; export function getPartialSchemaFromOriginal(schema: ItemSchema) { diff --git a/server/services/ncmecService/buildSubmitReportParamsFromDecision.test.ts b/server/services/ncmecService/buildSubmitReportParamsFromDecision.test.ts index f8b9315..6f161e6 100644 --- a/server/services/ncmecService/buildSubmitReportParamsFromDecision.test.ts +++ b/server/services/ncmecService/buildSubmitReportParamsFromDecision.test.ts @@ -376,4 +376,88 @@ describe('buildSubmitReportParamsFromDecision', () => { expect(result.reportedUser).not.toHaveProperty('email'); }); }); + + describe('HMA hash extraction on media', () => { + it('attaches hashes from the matching image in item data', async () => { + const result = await buildSubmitReportParamsFromDecision( + makeInput({ + reportedUserItemType: makeUserItemType({}), + reportedUserData: asNormalizedData({ display_name: 'Alice' }), + contentItemType: makeContentItemType({}), + contentData: asNormalizedData({ + created_at: FIXED_NOW, + image: { + url: 'https://example.com/m1.png', + hashes: { md5: 'abc123', pdq: 'def456' }, + }, + }), + }), + ); + + expect(result.media[0]).toMatchObject({ + url: 'https://example.com/m1.png', + hashes: { md5: 'abc123', pdq: 'def456' }, + }); + }); + + it('finds the matching image inside an ARRAY-of-IMAGE container', async () => { + const result = await buildSubmitReportParamsFromDecision( + makeInput({ + reportedUserItemType: makeUserItemType({}), + reportedUserData: asNormalizedData({ display_name: 'Alice' }), + contentItemType: makeContentItemType({}), + contentData: asNormalizedData({ + created_at: FIXED_NOW, + images: [ + { + url: 'https://example.com/other.png', + hashes: { md5: 'wrong' }, + }, + { + url: 'https://example.com/m1.png', + hashes: { md5: 'right' }, + }, + ], + }), + }), + ); + + expect(result.media[0].hashes).toEqual({ md5: 'right' }); + }); + + it('omits `hashes` when no image in the data matches the reported URL', async () => { + const result = await buildSubmitReportParamsFromDecision( + makeInput({ + reportedUserItemType: makeUserItemType({}), + reportedUserData: asNormalizedData({ display_name: 'Alice' }), + contentItemType: makeContentItemType({}), + contentData: asNormalizedData({ + created_at: FIXED_NOW, + image: { + url: 'https://example.com/different.png', + hashes: { md5: 'abc' }, + }, + }), + }), + ); + + expect(result.media[0]).not.toHaveProperty('hashes'); + }); + + it('omits `hashes` when the matching image has no hashes attached', async () => { + const result = await buildSubmitReportParamsFromDecision( + makeInput({ + reportedUserItemType: makeUserItemType({}), + reportedUserData: asNormalizedData({ display_name: 'Alice' }), + contentItemType: makeContentItemType({}), + contentData: asNormalizedData({ + created_at: FIXED_NOW, + image: { url: 'https://example.com/m1.png' }, + }), + }), + ); + + expect(result.media[0]).not.toHaveProperty('hashes'); + }); + }); }); diff --git a/server/services/ncmecService/buildSubmitReportParamsFromDecision.ts b/server/services/ncmecService/buildSubmitReportParamsFromDecision.ts index f002677..43b2a0e 100644 --- a/server/services/ncmecService/buildSubmitReportParamsFromDecision.ts +++ b/server/services/ncmecService/buildSubmitReportParamsFromDecision.ts @@ -168,6 +168,7 @@ export async function buildSubmitReportParamsFromDecision( 'ipAddress', reportedItem.contentItem.data, ); + const hashes = extractHashesForUrl(reportedItem.contentItem.data, it.url); return { id: it.id, typeId: it.typeId, @@ -176,6 +177,7 @@ export async function buildSubmitReportParamsFromDecision( industryClassification: it.industryClassification, fileAnnotations: it.fileAnnotations, ...(mediaIp ? { ipAddress: mediaIp } : {}), + ...(hashes ? { hashes } : {}), }; }), ); @@ -214,3 +216,48 @@ export async function buildSubmitReportParamsFromDecision( ...(jobId !== undefined ? { jobId } : {}), }; } + +/** Walk an item's data looking for an image-shaped value (`{ url, hashes }`) + * whose `url` matches the target. Returns the `hashes` map (typically + * populated by HMA at item-submission time, e.g. `{ md5: '...', pdq: '...' }`) + * or undefined when no match is found. + * + * Recurses into arrays and plain objects so ARRAY-of-IMAGE and MAP-of-IMAGE + * containers are covered, not just scalar IMAGE fields. Returns on the first + * match — duplicate URLs across fields would only ever yield the same hashes + * since HMA is deterministic per URL. */ +export function extractHashesForUrl( + data: NormalizedItemData, + url: string, +): Record | undefined { + const visit = (value: unknown): Record | undefined => { + if (Array.isArray(value)) { + for (const item of value) { + const found = visit(item); + if (found) return found; + } + return undefined; + } + if (typeof value !== 'object' || value === null) return undefined; + const obj = value as Record; + if ( + typeof obj.url === 'string' && + obj.url === url && + typeof obj.hashes === 'object' && + obj.hashes !== null + ) { + const hashes = obj.hashes as Record; + const stringHashes: Record = {}; + for (const [k, v] of Object.entries(hashes)) { + if (typeof v === 'string') stringHashes[k] = v; + } + return Object.keys(stringHashes).length > 0 ? stringHashes : undefined; + } + for (const inner of Object.values(obj)) { + const found = visit(inner); + if (found) return found; + } + return undefined; + }; + return visit(data); +} diff --git a/server/services/ncmecService/ncmecReporting.test.ts b/server/services/ncmecService/ncmecReporting.test.ts index 71b5659..9ab5040 100644 --- a/server/services/ncmecService/ncmecReporting.test.ts +++ b/server/services/ncmecService/ncmecReporting.test.ts @@ -350,6 +350,9 @@ describe('NCMEC reporting', () => { }); }); + // toOriginalFileHashes tests moved to ./toOriginalFileHashes.test.ts + // (this file was over the 500-line max-lines limit after expansion). + describe('summarizeCyberTipFailure', () => { const previousDebug = process.env.NCMEC_DEBUG; const previousNodeEnv = process.env.NODE_ENV; diff --git a/server/services/ncmecService/ncmecReporting.ts b/server/services/ncmecService/ncmecReporting.ts index 4f95fc7..c68e67e 100644 --- a/server/services/ncmecService/ncmecReporting.ts +++ b/server/services/ncmecService/ncmecReporting.ts @@ -133,6 +133,7 @@ type FileDetails = { fileRelevance?: 'Reported' | 'Supplemental Reported'; fileAnnotations?: FileAnnotations; industryClassification?: NCMECIndustryClassificationType; + originalFileHash?: OriginalFileHash[]; ipCaptureEvent?: IPNCMECEvent[]; deviceId?: DeviceId[]; details?: Detail[]; @@ -140,6 +141,13 @@ type FileDetails = { }; }; +type OriginalFileHash = { + _text: string; + _attributes: { + hashType: string; + }; +}; + type FileAnnotations = { animeDrawingVirtualHentai?: undefined; potentialMeme?: undefined; @@ -174,6 +182,12 @@ type Media = { * `Upload` event. */ ipAddress?: string; deviceId?: DeviceNCMECEvent[]; + /** Hashes computed for this URL (typically by HMA at item submission + * time). Keyed by hash algorithm name (e.g. `md5`, `pdq`); the value is + * the hex-encoded hash. Forwarded to NCMEC as `originalFileHash` + * entries with the algorithm name uppercased into the `hashType` + * attribute. */ + hashes?: Record; }; type NCMECUserParams = { @@ -547,6 +561,42 @@ export function mergeFieldRoleIpIntoEvents( return events.length > 0 ? events : undefined; } +/** Build the NCMEC `originalFileHash[]` shape from both hash sources Coop + * has: HMA-computed hashes stored on the item data (keyed by algorithm), + * and any single `{ hash, hashType }` returned by the additional-info + * webhook for this media. Trims blanks, uppercases the algorithm name into + * the `hashType` attribute, drops empty entries, and dedupes on + * (`hashType`, hash value) so a webhook that returns the same algorithm as + * HMA doesn't produce duplicate entries. Returns undefined when no usable + * hashes survive filtering; callers should branch on that to omit the key + * entirely rather than serialise an empty array. */ +export function toOriginalFileHashes(opts: { + hmaHashes?: Record; + webhookFileDetails?: { hash: string; hashType: string }; +}): OriginalFileHash[] | undefined { + const result: OriginalFileHash[] = []; + const seen = new Set(); + const push = (algorithm: string, hash: string) => { + const trimmedHash = typeof hash === 'string' ? hash.trim() : ''; + const trimmedAlgorithm = algorithm.trim(); + if (trimmedHash === '' || trimmedAlgorithm === '') return; + const hashType = trimmedAlgorithm.toUpperCase(); + const key = `${hashType}${trimmedHash}`; + if (seen.has(key)) return; + seen.add(key); + result.push({ _text: trimmedHash, _attributes: { hashType } }); + }; + if (opts.hmaHashes) { + for (const [algorithm, hash] of Object.entries(opts.hmaHashes)) { + push(algorithm, hash); + } + } + if (opts.webhookFileDetails) { + push(opts.webhookFileDetails.hashType, opts.webhookFileDetails.hash); + } + return result.length > 0 ? result : undefined; +} + /** Resolve the email(s) for `personOrUserReportedPerson`. Prefers the * webhook's enriched response (carries NCMEC `type` / `verified` attributes); * falls back to a bare field-role email otherwise. Returns undefined when @@ -713,6 +763,15 @@ type MediaAdditionalInfo = { fileName?: string; /** When set, sent to NCMEC in file details (whether the content was publicly viewable). */ publiclyAvailable?: boolean; + /** Optional single hash from the additional-info webhook response. + * Merged with HMA-sourced hashes (see `toOriginalFileHashes`); deduped + * on (`hashType` uppercase, trimmed hash value) so a webhook that + * returns the same algorithm as HMA doesn't produce duplicate entries + * in the outgoing `originalFileHash[]` list. */ + fileDetails?: { + hash: string; + hashType: string; + }; }; type FileAdditionalInfo = { @@ -1102,9 +1161,6 @@ export default class NcmecReporting { media: reportedMedia.map((media) => ({ id: media.id, typeId: media.typeId, - fileDetails: { - ipCaptureEvent: [], - }, })), }; } @@ -1958,6 +2014,10 @@ export default class NcmecReporting { const fileAnnotations = this.#fileAnnotationArrayToNCMECFileAnnotation( media.fileAnnotations, ); + const originalFileHash = toOriginalFileHashes({ + hmaHashes: media.hashes, + webhookFileDetails: additionalInfo.fileDetails, + }); const xml = await this.#uploadFileDetails( { fileDetails: { @@ -1970,6 +2030,9 @@ export default class NcmecReporting { : {}), ...(fileAnnotations ? { fileAnnotations } : {}), industryClassification: media.industryClassification, + ...(originalFileHash && originalFileHash.length > 0 + ? { originalFileHash } + : {}), ...(additionalInfo.ipCaptureEvent && additionalInfo.ipCaptureEvent.length > 0 ? { diff --git a/server/services/ncmecService/toOriginalFileHashes.test.ts b/server/services/ncmecService/toOriginalFileHashes.test.ts new file mode 100644 index 0000000..e0fd780 --- /dev/null +++ b/server/services/ncmecService/toOriginalFileHashes.test.ts @@ -0,0 +1,104 @@ +import { toOriginalFileHashes } from './ncmecReporting.js'; + +describe('toOriginalFileHashes', () => { + it('uppercases the algorithm into the `hashType` attribute', () => { + expect( + toOriginalFileHashes({ hmaHashes: { md5: 'abc', pdq: 'def' } }), + ).toEqual([ + { _text: 'abc', _attributes: { hashType: 'MD5' } }, + { _text: 'def', _attributes: { hashType: 'PDQ' } }, + ]); + }); + + it('trims surrounding whitespace from hash values', () => { + expect(toOriginalFileHashes({ hmaHashes: { md5: ' abc ' } })).toEqual([ + { _text: 'abc', _attributes: { hashType: 'MD5' } }, + ]); + }); + + it('drops entries with empty or whitespace-only hash values', () => { + // NCMEC requires non-empty `hash` text and `hashType` attribute; an + // entry with whitespace would fail XSD validation on receipt. + expect( + toOriginalFileHashes({ + hmaHashes: { md5: '', sha1: ' ', sha256: 'kept' }, + }), + ).toEqual([{ _text: 'kept', _attributes: { hashType: 'SHA256' } }]); + }); + + it('returns undefined when no entries survive filtering', () => { + // Caller branches on undefined to omit the `originalFileHash` key + // entirely rather than serialise an empty array. + expect(toOriginalFileHashes({})).toBeUndefined(); + expect(toOriginalFileHashes({ hmaHashes: {} })).toBeUndefined(); + expect( + toOriginalFileHashes({ hmaHashes: { md5: '', sha1: ' ' } }), + ).toBeUndefined(); + }); + + it('includes the webhook hash when only the webhook source has data', () => { + expect( + toOriginalFileHashes({ + webhookFileDetails: { hash: 'webhook-abc', hashType: 'md5' }, + }), + ).toEqual([{ _text: 'webhook-abc', _attributes: { hashType: 'MD5' } }]); + }); + + it('combines HMA and webhook hashes when both sources provide data', () => { + expect( + toOriginalFileHashes({ + hmaHashes: { md5: 'hma-md5', pdq: 'hma-pdq' }, + webhookFileDetails: { hash: 'webhook-sha1', hashType: 'sha1' }, + }), + ).toEqual([ + { _text: 'hma-md5', _attributes: { hashType: 'MD5' } }, + { _text: 'hma-pdq', _attributes: { hashType: 'PDQ' } }, + { _text: 'webhook-sha1', _attributes: { hashType: 'SHA1' } }, + ]); + }); + + it('dedupes when HMA and webhook return the same (algorithm, hash) pair', () => { + // Common case: both sources independently compute MD5 of the same + // content. Single entry in the outgoing report instead of two. + expect( + toOriginalFileHashes({ + hmaHashes: { md5: 'same-value' }, + webhookFileDetails: { hash: 'same-value', hashType: 'md5' }, + }), + ).toEqual([{ _text: 'same-value', _attributes: { hashType: 'MD5' } }]); + }); + + it('keeps both entries when HMA and webhook return the same algorithm but different hashes', () => { + // Real conflict (different MD5 values for the same content) is signal + // NCMEC investigators may care about; surface both rather than picking + // a winner. + expect( + toOriginalFileHashes({ + hmaHashes: { md5: 'hma-md5' }, + webhookFileDetails: { hash: 'webhook-md5', hashType: 'md5' }, + }), + ).toEqual([ + { _text: 'hma-md5', _attributes: { hashType: 'MD5' } }, + { _text: 'webhook-md5', _attributes: { hashType: 'MD5' } }, + ]); + }); + + it('drops a whitespace-only webhook hash', () => { + expect( + toOriginalFileHashes({ + webhookFileDetails: { hash: ' ', hashType: 'md5' }, + }), + ).toBeUndefined(); + }); + + it('dedupe is case-insensitive on the algorithm name', () => { + // Webhook returns `MD5` uppercase; HMA returns `md5` lowercase. Same + // algorithm, should dedupe on identical hash value. + expect( + toOriginalFileHashes({ + hmaHashes: { md5: 'shared' }, + webhookFileDetails: { hash: 'shared', hashType: 'MD5' }, + }), + ).toEqual([{ _text: 'shared', _attributes: { hashType: 'MD5' } }]); + }); +}); diff --git a/server/test/arbitraries/ContentType.ts b/server/test/arbitraries/ContentType.ts index 1b4cf8c..70fb29b 100644 --- a/server/test/arbitraries/ContentType.ts +++ b/server/test/arbitraries/ContentType.ts @@ -87,6 +87,16 @@ export const IpAddressArbitrary = fc.oneof( ), ); +// RFC 2606 reserved domains for example purposes. +export const EmailAddressArbitrary = fc.constantFrom( + 'yuki@example.com', + 'amara@example.org', + 'priya@example.net', + 'mateo@example.com', + 'aaliyah@example.org', + 'jian@example.net', +); + export const ScalarValidValuesArbitraries = { [ScalarTypes.AUDIO]: MediaUrlArbitrary, [ScalarTypes.BOOLEAN]: fc.boolean(), @@ -106,6 +116,7 @@ export const ScalarValidValuesArbitraries = { [ScalarTypes.RELATED_ITEM]: RelatedItemArbitrary, [ScalarTypes.POLICY_ID]: IdLikeArbitrary, [ScalarTypes.IP_ADDRESS]: IpAddressArbitrary, + [ScalarTypes.EMAIL_ADDRESS]: EmailAddressArbitrary, }; export const ScalarFieldArbitrary = fc -- 2.51.2