diff --git a/Dockerfile b/Dockerfile index e3c32d50..064d7676 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ # We can't use alpine anymore because crypto has rust deps. FROM node:20-slim + +COPY --from=mwader/static-ffmpeg:7.1.1 /ffmpeg /usr/local/bin/ + COPY . /tmp/src RUN cd /tmp/src \ && yarn install \ diff --git a/config/default.yaml b/config/default.yaml index 35c5d585..47fdeb5b 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -277,6 +277,14 @@ displayReports: true # and redacted as NSFW nsfwSensitivity: .6 +# Which model should be in use when processing NSFW media. +# Available options are specified in https://github.com/infinitered/nsfwjs/tree/v4.2.0?tab=readme-ov-file#load-the-model +nsfwModel: MobileNetV2 + +# FFMPEG is used to generate thumbnails of media for processing with the NSFW model. This can be +# set to `false` to forbid processing media with ffmpeg. +ffmpegPath: /usr/bin/ffmpeg + # Set this to true if the synapse this mjolnir is protecting is using Matrix Authentication Service for auth # If so, provide the base url and clientId + clientSecret needed to obtain a token from MAS - see # https://element-hq.github.io/matrix-authentication-service/index.html for more information about diff --git a/package.json b/package.json index 04793554..94523805 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@types/jsdom": "^16.2.11", "@types/mocha": "^9.0.0", "@types/nedb": "^1.8.12", - "@types/node": "^18.0.0", + "@types/node": "^20", "@types/pg": "^8.6.5", "@types/request": "^2.48.8", "@types/shell-quote": "1.7.1", @@ -47,7 +47,7 @@ "dependencies": { "@sentry/node": "^7.17.2", "@sentry/tracing": "^7.17.2", - "@tensorflow/tfjs-node": "^4.21.0", + "@tensorflow/tfjs-node": "^4.22.0", "@vector-im/matrix-bot-sdk": "^0.7.1-element.6", "await-lock": "^2.2.2", "axios": "^1.8.2", @@ -60,7 +60,7 @@ "js-yaml": "^4.1.0", "jsdom": "^16.6.0", "matrix-appservice-bridge": "10.3.1", - "nsfwjs": "^4.1.0", + "nsfwjs": "4.2.0", "parse-duration": "^2.1.3", "pg": "^8.8.0", "prom-client": "^14.1.0", @@ -69,6 +69,9 @@ "ulidx": "^0.3.0", "yaml": "^2.2.2" }, + "_dependencies_notes": { + "nsfwjs": "4.2.1 has broken imports - https://github.com/infinitered/nsfwjs/issues/921" + }, "engines": { "node": ">=20.0.0" } diff --git a/src/config.ts b/src/config.ts index 0b30adf0..4f280a1f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -187,6 +187,8 @@ export interface IConfig { }; }; nsfwSensitivity: number; + nsfwModelName: string; + ffmpegPath: string | false; /** * Config options only set at runtime. Try to avoid using the objects @@ -274,6 +276,8 @@ const defaultConfig: IConfig = { }, }, nsfwSensitivity: 0.6, + nsfwModelName: "MobileNetV2", + ffmpegPath: "/usr/bin/ffmpeg", // Needed to make the interface happy. RUNTIME: {}, }; diff --git a/src/protections/NsfwProtection.ts b/src/protections/NsfwProtection.ts index 6013e543..35ac77ff 100644 --- a/src/protections/NsfwProtection.ts +++ b/src/protections/NsfwProtection.ts @@ -17,20 +17,113 @@ limitations under the License. import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; import * as nsfw from "nsfwjs"; -import { LogLevel, LogService } from "@vector-im/matrix-bot-sdk"; -import { node } from "@tensorflow/tfjs-node"; +import { LogLevel, LogService, MXCUrl } from "@vector-im/matrix-bot-sdk"; +import { node, Tensor3D } from "@tensorflow/tfjs-node"; +import { spawn } from "node:child_process"; +import { ReadableStream } from "node:stream/web"; + +const TENSOR_SUPPORTED_TYPES = [ + "image/png", + "image/apng", + "image/bmp", + "image/x-bmp", + "image/gif", + "image/jpeg", + "image/jp2", + "image/jpx", + "image/jpm", +]; + +const FFMPEG_SUPPORTED_TYPES = ["video/", "image/"]; + +const FFMPEG_EXTRA_ARGS: Record = { + // Extra params needed to extract svg thumbnails. + "image/svg+xml": ["-f", "svg_pipe", "-frame_size", "10000", "-video_size", "512x512"], +}; export class NsfwProtection extends Protection { settings = {}; // @ts-ignore - private model: any; + private model: nsfw.NSFWJS; + + private classificationCache = new Map(); + + /** + * Extract the first frame from a video or image source. + * @param ffmpegPath The path to the `ffmpeg` binary. + * @param stream The stream containing the source. + * @param mimetype The mimetype provided by the source. + * + * @returns A byte array containing the thumbnail in JPEG format. + */ + static async extractFrame( + ffmpegPath: string, + stream: ReadableStream, + mimetype: string, + ): Promise { + const errData: Buffer[] = []; + const imageData: Buffer[] = []; + const extraArgs = FFMPEG_EXTRA_ARGS[mimetype] ?? []; + const cmd = spawn(ffmpegPath, [ + ...extraArgs, + "-i", + "-", + "-update", + "true", + "-frames:v", + "1", + "-f", + "image2", + "-", + ]); + let stdinErrorFinished!: Promise; + const p = new Promise((resolve, reject) => { + cmd.once("exit", (code) => { + if (code !== 0) { + reject(new Error(`FFMPEG failed to run: ${code}, ${Buffer.concat(errData).toString()}`)); + } else { + resolve(Buffer.concat(imageData)); + } + }); + cmd.stderr.on("data", (b) => { + errData.push(b); + }); + cmd.stdout.on("data", (b) => { + imageData.push(b); + }); + // EPIPE is "normal" for ffmpeg to emit after it's finished processing an input. + stdinErrorFinished = new Promise((res, rej) => { + cmd.stdin.once("error", (e: { code: string }) => { + if (e.code !== "EPIPE") { + LogService.debug("NsfwProtection", "Unexpected error from ffmpeg", e); + rej(e); + } + res(); + }); + }); + }); + for await (const element of stream) { + if (cmd.stdin.write(element) === false) { + // Wait for either a drain, or the whole stream to complete + await Promise.race([stdinErrorFinished, new Promise((r) => cmd.stdin.once("drain", r))]); + } + } + if (!cmd.stdin.writableEnded) { + cmd.stdin.end(); + } + try { + return await p; + } finally { + LogService.debug("NsfwProtection", `Generated thumbnail`); + } + } constructor() { super(); } - async initialize() { - this.model = await nsfw.load(); + async initialize(modelName: string) { + this.model = await nsfw.load(modelName); } public get name(): string { @@ -44,6 +137,57 @@ export class NsfwProtection extends Protection { ); } + /** + * Given a MXC URL, attempt to extract a image supported by our NSFW model. + * @param mjolnir The mjolnir instance. + * @param mxc The MXC url. + * @returns Bytes of an image processable by the model. + */ + private async determineImageFromMedia(mjolnir: Mjolnir, mxc: MXCUrl): Promise { + const res = await fetch( + `${mjolnir.client.homeserverUrl}/_matrix/client/v1/media/download/${encodeURIComponent(mxc.domain)}/${encodeURIComponent(mxc.mediaId)}`, + { + headers: { + Authorization: `Bearer ${mjolnir.client.accessToken}`, + }, + }, + ); + if (!res.body || res.status !== 200) { + LogService.error("NsfwProtection", `Could not fetch mxc ${mxc}: ${res.status}`); + return null; + } + const contentType = res.headers.get("content-type")?.split(";")[0]; + if (!contentType) { + LogService.warn("NsfwProtection", `No content type header specified`); + return null; + } + console.log(contentType); + if (TENSOR_SUPPORTED_TYPES.includes(contentType)) { + return new Uint8Array(await res.arrayBuffer()); + } + // Why do we do this? + // - We don't want to trust client thumbnails, which might not match the content. Or they might + // not exist at all (which forces clients to generate their own) + // - We also don't want to make our homeserver generate thumbnails of potentially + // harmful images, so this locally generates a thumbnail of a range of types in memory. + if (mjolnir.config.ffmpegPath && FFMPEG_SUPPORTED_TYPES.some((mt) => contentType.startsWith(mt))) { + const stream = res.body as ReadableStream; + try { + LogService.debug( + "NsfwProtection", + `Image type ${contentType} is unsupported by model, attempting to generate thumbnail`, + ); + return await NsfwProtection.extractFrame(mjolnir.config.ffmpegPath, stream, contentType); + } catch (ex) { + LogService.warn("NsfwProtection", "Could not extract thumbnail from image", ex); + return null; + } + } + + LogService.debug("NsfwProtection", `Unsupported file type ${contentType}`); + return null; + } + public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (event.type !== "m.room.message" && event.type !== "m.sticker") { return; @@ -66,27 +210,46 @@ export class NsfwProtection extends Protection { } for (const mxc of mxcs) { - const image = await mjolnir.client.downloadContent(mxc); + // If we've already scanned this media, return early. + if (this.classificationCache.has(mxc)) { + if (this.classificationCache.get(mxc)) { + await this.redactEvent(mjolnir, roomId, event, room); + break; + } + continue; + } - let decodedImage; + const data = await this.determineImageFromMedia(mjolnir, MXCUrl.parse(mxc)); + if (!data) { + // Couldn't extract an image, skip. + continue; + } else { + LogService.debug("NsfwProtection", `Thumbnail generated for ${mxc}`); + } + let decodedImage: Tensor3D | undefined; try { - decodedImage = await node.decodeImage(image.data, 3); + decodedImage = (await node.decodeImage(data, 3)) as Tensor3D; + const predictions = await this.model.classify(decodedImage); + LogService.debug("NsfwProtection", `Classified ${mxc} as`, predictions); + const isNsfw = predictions.some( + (prediction) => + ["Hentai", "Porn"].includes(prediction.className) && + prediction.probability > mjolnir.config.nsfwSensitivity, + ); + this.classificationCache.set(mxc, isNsfw); + if (isNsfw) { + await this.redactEvent(mjolnir, roomId, event, room); + // Stop scanning media once we've redacted. + break; + } } catch (e) { LogService.error("NsfwProtection", `There was an error processing an image: ${e}`); continue; - } - - const predictions = await this.model.classify(decodedImage); - - for (const prediction of predictions) { - if (["Hentai", "Porn"].includes(prediction["className"])) { - if (prediction["probability"] > mjolnir.config.nsfwSensitivity) { - await this.redactEvent(mjolnir, roomId, event, room); - break; - } + } finally { + if (decodedImage) { + decodedImage.dispose(); } } - decodedImage.dispose(); } } diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 76fc1548..712f81c6 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -108,8 +108,8 @@ export class ProtectionManager { protection.settings[key].setValue(value); } if (protection.enabled) { - if (protection.name === "NsfwProtection") { - (protection as NsfwProtection).initialize(); + if (protection instanceof NsfwProtection) { + await protection.initialize(this.mjolnir.config.nsfwModelName); } for (let roomId of this.mjolnir.protectedRoomsTracker.getProtectedRooms()) { await protection.startProtectingRoom(this.mjolnir, roomId); diff --git a/yarn.lock b/yarn.lock index 6cef2418..c50d9695 100644 --- a/yarn.lock +++ b/yarn.lock @@ -238,33 +238,33 @@ resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== -"@tensorflow/tfjs-backend-cpu@4.21.0": - version "4.21.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.21.0.tgz#27a489f838b88aa98220da0aaf29f58d16fc9d05" - integrity sha512-yS9Oisg4L48N7ML6677ilv1eP5Jt59S74skSU1cCsM4yBAtH4DAn9b89/JtqBISh6JadanfX26b4HCWQvMvqFg== +"@tensorflow/tfjs-backend-cpu@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-4.22.0.tgz#72aeaab14f6f16bbd995c9e6751a8d094d5639a9" + integrity sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw== dependencies: "@types/seedrandom" "^2.4.28" seedrandom "^3.0.5" -"@tensorflow/tfjs-backend-webgl@4.21.0": - version "4.21.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.21.0.tgz#30fba5a0c583d1fdb8b2e94a73b3080f5f7e524d" - integrity sha512-7k6mb7dd0uF9jI51iunF3rhEXjvR/a613kjWZ0Rj3o1COFrneyku2C7cRMZERWPhbgXZ+dF+j9MdpGIpgtShIQ== +"@tensorflow/tfjs-backend-webgl@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-4.22.0.tgz#c6ffb8c5e737b1b1ef7fab8f721328b5b2e658c0" + integrity sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg== dependencies: - "@tensorflow/tfjs-backend-cpu" "4.21.0" + "@tensorflow/tfjs-backend-cpu" "4.22.0" "@types/offscreencanvas" "~2019.3.0" "@types/seedrandom" "^2.4.28" seedrandom "^3.0.5" -"@tensorflow/tfjs-converter@4.21.0": - version "4.21.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-converter/-/tfjs-converter-4.21.0.tgz#a4d9f36025c9128a57476818eb8bf0c9e2011628" - integrity sha512-cUhU+F1lGx2qnKk/gRy8odBh0PZlFz0Dl71TG8LVnj0/g352DqiNrKXlKO/po9aWzP8x0KUGC3gNMSMJW+T0DA== +"@tensorflow/tfjs-converter@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-converter/-/tfjs-converter-4.22.0.tgz#a5d727c1d97cf1fafda18b79be278e83b38a1ad3" + integrity sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ== -"@tensorflow/tfjs-core@4.21.0": - version "4.21.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-core/-/tfjs-core-4.21.0.tgz#5f12bd67badf28d65b628bb7c4968dae716a8fba" - integrity sha512-ZbECwXps5wb9XXcGq4ZXvZDVjr5okc3I0+i/vU6bpQ+nVApyIrMiyEudP8f6vracVTvNmnlN62vUXoEsQb2F8g== +"@tensorflow/tfjs-core@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-core/-/tfjs-core-4.22.0.tgz#fc4b45d2377410fa3a42c88ca77d8f23d83cffc3" + integrity sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A== dependencies: "@types/long" "^4.0.1" "@types/offscreencanvas" "~2019.7.0" @@ -274,27 +274,27 @@ node-fetch "~2.6.1" seedrandom "^3.0.5" -"@tensorflow/tfjs-data@4.21.0": - version "4.21.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-data/-/tfjs-data-4.21.0.tgz#cfc1abf63e753c31482cf7a01d17cbb882c18605" - integrity sha512-LpJ/vyQMwYHkcVCqIRg7IVVw13VBY7rNAiuhmKP9S5NP/2ye4KA8BJ4XwDIDgjCVQM7glK9L8bMav++xCDf7xA== +"@tensorflow/tfjs-data@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-data/-/tfjs-data-4.22.0.tgz#ec2dd94adc865d32ff75c9d64d4f404fd6b89022" + integrity sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w== dependencies: "@types/node-fetch" "^2.1.2" node-fetch "~2.6.1" string_decoder "^1.3.0" -"@tensorflow/tfjs-layers@4.21.0": - version "4.21.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-layers/-/tfjs-layers-4.21.0.tgz#b28a64438ce7025066bbaca337b5422888962192" - integrity sha512-a8KaMYlY3+llvE9079nvASKpaaf8xpCMdOjbgn+eGhdOGOcY7QuFUkd/2odvnXDG8fK/jffE1LoNOlfYoBHC4w== +"@tensorflow/tfjs-layers@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-layers/-/tfjs-layers-4.22.0.tgz#b3a3608da222203c01698d1285e6f221c6d12207" + integrity sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA== -"@tensorflow/tfjs-node@^4.21.0": - version "4.21.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-node/-/tfjs-node-4.21.0.tgz#50b23c29b6e8f5cac388477ed8c13e1a2dee3a42" - integrity sha512-Vnc+x/xr9LgGEADlVwFr0xTtfOIug5E9LTRWXNE3WANLisJz9g0qycrMPiJhk4hrvr2wg8BkJKLOMfroxKwZ1w== +"@tensorflow/tfjs-node@^4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-node/-/tfjs-node-4.22.0.tgz#9883605202424e993b6c7cc4dedfd9282634567c" + integrity sha512-uHrXeUlfgkMxTZqHkESSV7zSdKdV0LlsBeblqkuKU9nnfxB1pC6DtoyYVaLxznzZy7WQSegjcohxxCjAf6Dc7w== dependencies: "@mapbox/node-pre-gyp" "1.0.9" - "@tensorflow/tfjs" "4.21.0" + "@tensorflow/tfjs" "4.22.0" adm-zip "^0.5.2" google-protobuf "^3.9.2" https-proxy-agent "^2.2.1" @@ -302,17 +302,17 @@ rimraf "^2.6.2" tar "^6.2.1" -"@tensorflow/tfjs@4.21.0": - version "4.21.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs/-/tfjs-4.21.0.tgz#dab78f7d924d4061ac8edca7f1bae19452008c76" - integrity sha512-7D/+H150ptvt+POMbsME3WlIvLiuBR2rCC2Z0hOKKb/5Ygkj7xsp/K2HzOvUj0g0yjk+utkU45QEYhnhjnbHRA== - dependencies: - "@tensorflow/tfjs-backend-cpu" "4.21.0" - "@tensorflow/tfjs-backend-webgl" "4.21.0" - "@tensorflow/tfjs-converter" "4.21.0" - "@tensorflow/tfjs-core" "4.21.0" - "@tensorflow/tfjs-data" "4.21.0" - "@tensorflow/tfjs-layers" "4.21.0" +"@tensorflow/tfjs@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs/-/tfjs-4.22.0.tgz#6fa2d850d39579ef190d2cb8d2451524059ddab5" + integrity sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg== + dependencies: + "@tensorflow/tfjs-backend-cpu" "4.22.0" + "@tensorflow/tfjs-backend-webgl" "4.22.0" + "@tensorflow/tfjs-converter" "4.22.0" + "@tensorflow/tfjs-core" "4.22.0" + "@tensorflow/tfjs-data" "4.22.0" + "@tensorflow/tfjs-layers" "4.22.0" argparse "^1.0.10" chalk "^4.1.0" core-js "3.29.1" @@ -483,12 +483,12 @@ resolved "https://registry.npmjs.org/@types/node/-/node-16.10.2.tgz" integrity sha512-zCclL4/rx+W5SQTzFs9wyvvyCwoK9QtBpratqz2IYJ3O8Umrn0m3nsTv0wQBk9sRGpvUe9CwPDrQFB10f1FIjQ== -"@types/node@^18.0.0": - version "18.18.9" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.18.9.tgz#5527ea1832db3bba8eb8023ce8497b7d3f299592" - integrity sha512-0f5klcuImLnG4Qreu9hPj/rEfFq6YRc5n2mAjSsH+ec/mJL+3voBH0+8T7o8RpFjH7ovc+TRsL/c7OYIQsPTfQ== +"@types/node@^20": + version "20.17.29" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.29.tgz#c0bb2b8d22c1d5135165b645d14b376e361becd8" + integrity sha512-6rbekrnsa5WWCo5UnPYEKfNuoF2yqAmigUKXM8wBzfEbZc+E/CITqjCrHqiq+6QBifsw0ZDaA5VdTFONOtG7+A== dependencies: - undici-types "~5.26.4" + undici-types "~6.19.2" "@types/offscreencanvas@~2019.3.0": version "2019.3.0" @@ -2960,10 +2960,10 @@ npmlog@^5.0.1: gauge "^3.0.0" set-blocking "^2.0.0" -nsfwjs@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/nsfwjs/-/nsfwjs-4.1.0.tgz#021b7b38a043630e5f1f66bc7ef5d65afaf1eb59" - integrity sha512-2V27SoNXUJbAAi+vW8RHJbONs2AHa1odmjlRsVX2mirxR4IGt1OIzZ49IXgPVg6nktwA8Su61YEJql7VreEKyg== +nsfwjs@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/nsfwjs/-/nsfwjs-4.2.0.tgz#8514794447723cdc18edaff673717915a719e428" + integrity sha512-8eMmV+Hfr3hqSX7PzDJx94kFSP4QiN4/G9+b92I3catgQuqOb9Qmns1nDyVI0jMcWc1sVWdqBIA6eYLvKkryxA== nwsapi@^2.2.0: version "2.2.0" @@ -4118,10 +4118,10 @@ ulidx@^0.3.0: dependencies: layerr "^0.1.2" -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== universalify@^0.1.2: version "0.1.2"