From 7043b286b2446bc838abce54909be577946a747e Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 15 Apr 2026 17:12:35 +0000 Subject: [PATCH 01/15] feat(security): implement bulk advisory audit fallback for deprecated pnpm audit - Added fallback to npm bulk advisory endpoint when pnpm audit endpoint is retired. - Improved resilience by parsing legacy and new audit outputs uniformly. - Updated audit utilities to fetch installed package versions and query bulk advisories. - Enhanced validate-audit.js to await asynchronous runAuditJson for advisory validation. - Added comprehensive tests for the shared audit helper including bulk advisory fallback. - Updated Makefile audit target to run audit:validate for consistent validation. This enables seamless audit compliance checks despite registry audit endpoint changes. Co-authored-by: devboxerhub[bot] --- Makefile | 2 +- bun.lock | 212 +++++++++++++-------- docs/repository-structure.md | 7 +- frontend-pwa/scripts/audit-utils.test.mjs | 204 ++++++++++++++++++++ frontend-pwa/scripts/run-audit.mjs | 20 +- security/audit-utils.js | 220 ++++++++++++++++++++-- security/validate-audit.js | 10 +- 7 files changed, 568 insertions(+), 107 deletions(-) create mode 100644 frontend-pwa/scripts/audit-utils.test.mjs diff --git a/Makefile b/Makefile index 79a744fae..aca387031 100644 --- a/Makefile +++ b/Makefile @@ -208,7 +208,7 @@ typecheck: deps ; for dir in $(TS_WORKSPACES); do $(call exec_or_bunx,tsc,--noEm audit: deps pnpm -r install pnpm -r --if-present run audit - pnpm run audit + pnpm run audit:validate lockfile: pnpm install --lockfile-only diff --git a/bun.lock b/bun.lock index 645db315b..1796494cc 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "markdownlint-cli": "^0.45.0", "puppeteer": "^23.11.1", "validator": "^13.15.23", - "vite": "^7.1.12", + "vite": "^7.3.2", }, }, "frontend-pwa": { @@ -34,7 +34,7 @@ "orval": "^8.2.0", "tailwindcss": "^3", "typescript": "^5", - "vite": "^7.1.12", + "vite": "^7.3.2", "vitest": "^3.2.4", }, }, @@ -46,7 +46,7 @@ "color": "^5.0.0", "markdownlint-cli": "^0.45.0", "style-dictionary": "^5.0.4", - "vite": "^7.1.12", + "vite": "^7.3.2", }, }, "packages/types": { @@ -147,57 +147,57 @@ "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], @@ -773,7 +773,7 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1425,7 +1425,7 @@ "validator": ["validator@13.15.23", "", {}, "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw=="], - "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], + "vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -1493,8 +1493,6 @@ "@orval/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "@orval/core/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], - "@orval/query/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@scalar/openapi-parser/@scalar/json-magic": ["@scalar/json-magic@0.9.0", "", { "dependencies": { "@scalar/helpers": "0.2.7", "yaml": "^2.8.0" } }, "sha512-aSWd8rd3O73Ak9Ylson2TywvOuTjjOYiXydl9Cn8Ip/r7fi+h0QqAGom5gqo/WewrhySF9v+H/sW/Qmd05T/Kg=="], @@ -1577,6 +1575,10 @@ "unbzip2-stream/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "vite-node/vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], + + "vitest/vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], + "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], @@ -1587,93 +1589,149 @@ "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], - "@orval/core/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + "@scalar/openapi-parser/@scalar/json-magic/@scalar/helpers": ["@scalar/helpers@0.2.7", "", {}, "sha512-uFTcdi3XYDDuaJLWiMuM3ijQit1OBw7AkuOuujReY8L9UmUQHY56erYg0+Db3llTsinuIYFh+eS/WX/sYuevYQ=="], - "@orval/core/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + "@zenuml/core/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "@orval/core/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "@orval/core/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], - "@orval/core/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], - "@orval/core/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - "@orval/core/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + "path/util/inherits": ["inherits@2.0.3", "", {}, "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw=="], - "@orval/core/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + "sucrase/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "@orval/core/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + "sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "@orval/core/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + "sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "@orval/core/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "@orval/core/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "@orval/core/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + "vite-node/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "@orval/core/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + "vitest/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "@orval/core/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "@orval/core/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "@orval/core/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], - "@orval/core/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + "cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "@orval/core/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + "sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@orval/core/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "@orval/core/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + "vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "@orval/core/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + "vite-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@orval/core/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + "vite-node/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@orval/core/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + "vite-node/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@orval/core/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + "vite-node/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@orval/core/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "vite-node/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@scalar/openapi-parser/@scalar/json-magic/@scalar/helpers": ["@scalar/helpers@0.2.7", "", {}, "sha512-uFTcdi3XYDDuaJLWiMuM3ijQit1OBw7AkuOuujReY8L9UmUQHY56erYg0+Db3llTsinuIYFh+eS/WX/sYuevYQ=="], + "vite-node/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@zenuml/core/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "vite-node/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "vite-node/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "vite-node/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "vite-node/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "vite-node/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "path/util/inherits": ["inherits@2.0.3", "", {}, "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw=="], + "vite-node/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "sucrase/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "vite-node/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "vite-node/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "sucrase/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "vite-node/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "vite-node/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "vite-node/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "vite-node/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "vite-node/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.0", "", {}, "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg=="], + "vite-node/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "vite-node/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "vite-node/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "vite-node/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vite-node/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vite-node/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/docs/repository-structure.md b/docs/repository-structure.md index 833380f99..42a796e04 100644 --- a/docs/repository-structure.md +++ b/docs/repository-structure.md @@ -519,9 +519,10 @@ docker-down: ``` Use `make audit` to validate the audit exception allowlist against its schema -and expiry dates. The target installs its validator with `pnpm dlx`; enable -Corepack (`corepack enable` and `corepack prepare pnpm@10.15.1 --activate`) so -`pnpm` is available in local and CI environments. +and expiry dates. The shared helper tries `pnpm audit --json` first and falls +back to npm's bulk advisory endpoint when the registry retires pnpm's legacy +audit endpoints. Enable Corepack (`corepack enable` and `corepack prepare +pnpm@10.15.1 --activate`) so `pnpm` is available in local and CI environments. ### pnpm setup sequence diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs new file mode 100644 index 000000000..e730db78c --- /dev/null +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -0,0 +1,204 @@ +/** @file Tests the shared audit helper, including the bulk advisory fallback. */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const execFileSyncMock = vi.fn(); +const spawnSyncMock = vi.fn(); +const githubAdvisoryIdKey = 'github_advisory_id'; +const packageNameKey = 'package_name'; + +vi.mock('node:child_process', () => ({ + execFileSync: execFileSyncMock, + spawnSync: spawnSyncMock, +})); + +const originalFetch = globalThis.fetch; + +function createPnpmResult({ status = 0, stdout = '', error = undefined } = {}) { + return { error, status, stdout }; +} + +async function loadAuditUtils() { + const module = await import('../../security/audit-utils.js'); + return module; +} + +describe('runAuditJson', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + globalThis.fetch = vi.fn(); + vi.unstubAllEnvs(); + vi.stubEnv('npm_config_registry', ''); + vi.stubEnv('NPM_CONFIG_REGISTRY', ''); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns pnpm audit output when the native command succeeds', async () => { + spawnSyncMock.mockReturnValueOnce( + createPnpmResult({ + status: 1, + stdout: JSON.stringify({ + advisories: { + validator: { + [githubAdvisoryIdKey]: 'GHSA-vghf-hv5q-vc2g', + title: 'Validator SSRF', + }, + }, + }), + }), + ); + const { runAuditJson } = await loadAuditUtils(); + + const result = await runAuditJson(); + + expect(result).toEqual({ + json: { + advisories: { + validator: { + [githubAdvisoryIdKey]: 'GHSA-vghf-hv5q-vc2g', + title: 'Validator SSRF', + }, + }, + }, + status: 1, + }); + expect(fetch).not.toHaveBeenCalled(); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + it('falls back to the bulk advisory endpoint when pnpm audit hits the retired endpoint', async () => { + spawnSyncMock + .mockReturnValueOnce( + createPnpmResult({ + status: 1, + stdout: JSON.stringify({ + error: { + code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', + message: + 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', + }, + }), + }), + ) + .mockReturnValueOnce( + createPnpmResult({ + stdout: JSON.stringify([ + { + name: 'frontend-pwa', + path: '/tmp/frontend-pwa', + dependencies: { + '@app/types': { + version: 'link:../packages/types', + }, + validator: { + version: '13.15.23', + dependencies: { + nanoid: { + version: '3.3.11', + }, + }, + }, + }, + }, + ]), + }), + ); + execFileSyncMock.mockReturnValueOnce('https://registry.npmjs.org/\n'); + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => + JSON.stringify({ + validator: [ + { + id: 100000, + url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g', + title: 'Validator SSRF', + severity: 'high', + }, + ], + nanoid: [], + }), + }); + const { runAuditJson } = await loadAuditUtils(); + + const result = await runAuditJson(); + + expect(spawnSyncMock).toHaveBeenNthCalledWith( + 1, + 'pnpm', + ['audit', '--json'], + expect.objectContaining({ encoding: 'utf8' }), + ); + expect(spawnSyncMock).toHaveBeenNthCalledWith( + 2, + 'pnpm', + ['ls', '--json', '--depth', 'Infinity'], + expect.objectContaining({ encoding: 'utf8' }), + ); + expect(execFileSyncMock).toHaveBeenCalledWith( + 'pnpm', + ['config', 'get', 'registry'], + expect.objectContaining({ encoding: 'utf8' }), + ); + expect(String(fetch.mock.calls[0][0])).toBe( + 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', + ); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ + nanoid: ['3.3.11'], + validator: ['13.15.23'], + }); + expect(result).toEqual({ + json: { + advisories: { + 'GHSA-VGHF-HV5Q-VC2G': { + [githubAdvisoryIdKey]: 'GHSA-VGHF-HV5Q-VC2G', + id: 100000, + [packageNameKey]: 'validator', + severity: 'high', + title: 'Validator SSRF', + url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g', + }, + }, + }, + status: 1, + }); + }); + + it('throws a clear error when the bulk advisory endpoint fails', async () => { + spawnSyncMock + .mockReturnValueOnce( + createPnpmResult({ + status: 1, + stdout: JSON.stringify({ + error: { + code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', + message: + 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', + }, + }), + }), + ) + .mockReturnValueOnce( + createPnpmResult({ + stdout: JSON.stringify([{ name: 'frontend-pwa', dependencies: {} }]), + }), + ); + fetch.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => '{"error":"upstream unavailable"}', + }); + const { runAuditJson } = await loadAuditUtils(); + + await expect(runAuditJson()).rejects.toThrow( + 'Bulk advisory audit failed (503 Service Unavailable)', + ); + }); +}); diff --git a/frontend-pwa/scripts/run-audit.mjs b/frontend-pwa/scripts/run-audit.mjs index c0386ba69..5d2ce2ac1 100644 --- a/frontend-pwa/scripts/run-audit.mjs +++ b/frontend-pwa/scripts/run-audit.mjs @@ -1,6 +1,6 @@ -/** @file Ensures `pnpm audit` only fails for advisories covered by the - * frontend workspace ledger and a validator dependency that includes the - * upstream fix for the current advisory. +/** @file Ensures the frontend audit only fails for advisories covered by the + * workspace ledger and a validator dependency that includes the upstream fix + * for the current advisory. * * The validator advisory is considered mitigated when the workspace ships a * version at or above the minimum safe release, falling back to the legacy @@ -171,7 +171,7 @@ function isExecutedDirectly(meta) { } /** - * Evaluate pnpm audit output and determine the appropriate exit code. + * Evaluate audit output and determine the appropriate exit code. * * @param {{ advisories?: Array>, status?: number }} payload Audit * result containing advisories and the pnpm exit status. @@ -211,22 +211,22 @@ export function evaluateAudit(payload, options = {}) { } /** - * Execute `pnpm audit` and exit according to {@link evaluateAudit}. + * Execute the audit helper and exit according to {@link evaluateAudit}. * - * @returns {number} Exit code produced by {@link evaluateAudit}. + * @returns {Promise} Exit code produced by {@link evaluateAudit}. * @example - * const exitCode = main(); + * const exitCode = await main(); * console.log(exitCode); */ -export function main() { - const { json, status } = runAuditJson(); +export async function main() { + const { json, status } = await runAuditJson(); const advisories = collectAdvisories(json); return evaluateAudit({ advisories, status }); } if (isExecutedDirectly(import.meta)) { try { - const exitCode = main(); + const exitCode = await main(); process.exit(exitCode); } catch (error) { // biome-ignore lint/suspicious/noConsole: CLI script reports failures via stderr. diff --git a/security/audit-utils.js b/security/audit-utils.js index 9ff28be65..55cced1d3 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -1,35 +1,231 @@ -/** @file Shared helpers for running `pnpm audit` and reasoning about advisories. +/** @file Shared helpers for running dependency audits and reasoning about + * advisories. * * These helpers centralise the JSON parsing and filtering logic used by the - * security validation scripts. They ensure both the security gate and - * workspace-specific audit wrappers interpret the CLI output consistently. + * security validation scripts. They keep the workspace wrappers aligned even + * when the package manager needs a compatibility fallback. * * Cross-link: `frontend-pwa/scripts/run-audit.mjs` consumes these helpers to * enforce the validator patch requirement during workspace audits. */ -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; + +const AUDIT_ARGS = ['audit', '--json']; +const LIST_ARGS = ['ls', '--json', '--depth', 'Infinity']; +const BULK_ADVISORY_PATH = '-/npm/v1/security/advisories/bulk'; +const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; +const COMMAND_MAX_BUFFER = 64 * 1024 * 1024; +const RETIRED_AUDIT_ENDPOINT_MESSAGE = + 'This endpoint is being retired. Use the bulk advisory endpoint instead.'; + +function parseJsonOutput(stdout, commandLabel) { + if (!stdout) { + return {}; + } + + try { + return JSON.parse(stdout); + } catch (error) { + error.message = `Failed to parse ${commandLabel} JSON: ${error.message}`; + throw error; + } +} + +function isRetiredAuditEndpoint(payload) { + return ( + payload?.error?.code === 'ERR_PNPM_AUDIT_BAD_RESPONSE' && + typeof payload?.error?.message === 'string' && + payload.error.message.includes(RETIRED_AUDIT_ENDPOINT_MESSAGE) + ); +} + +function isLocalWorkspaceVersion(version) { + return ( + version.startsWith('file:') || + version.startsWith('link:') || + version.startsWith('workspace:') + ); +} + +function addPackageVersion(versionsByPackage, packageName, version) { + if (!packageName || !version || isLocalWorkspaceVersion(version)) { + return; + } + + const knownVersions = versionsByPackage.get(packageName) ?? new Set(); + knownVersions.add(version); + versionsByPackage.set(packageName, knownVersions); +} + +function walkDependencyTree(node, versionsByPackage) { + if (!node || typeof node !== 'object') { + return; + } + + for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies']) { + const section = node[sectionName]; + if (!section || typeof section !== 'object') { + continue; + } + + for (const [packageName, dependency] of Object.entries(section)) { + if (!dependency || typeof dependency !== 'object') { + continue; + } + + if (typeof dependency.version === 'string') { + addPackageVersion(versionsByPackage, packageName, dependency.version); + } + + walkDependencyTree(dependency, versionsByPackage); + } + } +} + +function collectInstalledPackageVersions() { + const result = spawnSync('pnpm', LIST_ARGS, { + encoding: 'utf8', + maxBuffer: COMMAND_MAX_BUFFER, + stdio: ['ignore', 'pipe', 'inherit'], + }); + + if (result.error) { + throw result.error; + } + + const status = result.status ?? 0; + if (status !== 0) { + throw new Error(`pnpm ls failed without producing a dependency tree (exit status ${status}).`); + } + + const packageTrees = parseJsonOutput(result.stdout?.trim() ?? '', 'pnpm ls'); + const versionsByPackage = new Map(); + + for (const tree of Array.isArray(packageTrees) ? packageTrees : [packageTrees]) { + walkDependencyTree(tree, versionsByPackage); + } + + return Object.fromEntries( + [...versionsByPackage.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([packageName, versions]) => [packageName, [...versions].sort()]), + ); +} + +function normaliseRegistryUrl(rawRegistry) { + const trimmed = String(rawRegistry ?? '').trim(); + const registry = + trimmed && trimmed !== 'undefined' && trimmed !== 'null' ? trimmed : DEFAULT_REGISTRY; + return registry.endsWith('/') ? registry : `${registry}/`; +} + +function readRegistryUrl() { + const envRegistry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY; + if (envRegistry) { + return normaliseRegistryUrl(envRegistry); + } + + try { + return normaliseRegistryUrl( + execFileSync('pnpm', ['config', 'get', 'registry'], { + encoding: 'utf8', + }), + ); + } catch { + return DEFAULT_REGISTRY; + } +} + +function extractGithubAdvisoryId(advisoryUrl) { + if (typeof advisoryUrl !== 'string') { + return undefined; + } + + const match = advisoryUrl.match(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i); + return match?.[0].toUpperCase(); +} + +function normaliseBulkAdvisories(bulkPayload) { + const advisories = {}; + + for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { + if (!Array.isArray(packageAdvisories)) { + continue; + } + + for (const advisory of packageAdvisories) { + const githubAdvisoryId = extractGithubAdvisoryId(advisory?.url); + const advisoryKey = githubAdvisoryId ?? `${packageName}:${String(advisory?.id ?? 'unknown')}`; + + if (advisoryKey in advisories) { + continue; + } + + advisories[advisoryKey] = { + ...advisory, + github_advisory_id: githubAdvisoryId, + package_name: packageName, + }; + } + } + + return advisories; +} + +async function runBulkAdvisoryAudit() { + const registryUrl = readRegistryUrl(); + const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(collectInstalledPackageVersions()), + }); + const responseText = await response.text(); + + if (!response.ok) { + throw new Error( + `Bulk advisory audit failed (${response.status} ${response.statusText}) at ${endpoint}: ${responseText || ''}`, + ); + } + + const bulkPayload = parseJsonOutput(responseText, 'bulk advisory audit'); + const advisories = normaliseBulkAdvisories(bulkPayload); + + return { + json: { advisories }, + status: Object.keys(advisories).length === 0 ? 0 : 1, + }; +} /** * Run `pnpm audit --json` and return the parsed payload alongside the exit * status. Whitespace-only output is treated as an empty advisory list so that * callers can rely on deterministic results even when pnpm prints nothing. * + * Newer npm registries now retire the legacy audit endpoints used by pnpm. + * When that happens, the helper falls back to npm's supported bulk advisory + * endpoint using the installed PNPM dependency tree. + * * @returns {{ * json: { advisories?: Record }, * status: number, * }} Parsed audit * output and the pnpm exit status (defaults to zero when undefined). * @example - * const { json, status } = runAuditJson(); + * const { json, status } = await runAuditJson(); * if (status !== 0) { * throw new Error('pnpm audit failed'); * } * console.log(Object.keys(json.advisories ?? {})); */ -export function runAuditJson() { - const result = spawnSync('pnpm', ['audit', '--json'], { +export async function runAuditJson() { + const result = spawnSync('pnpm', AUDIT_ARGS, { encoding: 'utf8', + maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'], }); @@ -44,12 +240,12 @@ export function runAuditJson() { return { json: { advisories: {} }, status }; } - try { - return { json: JSON.parse(stdout), status }; - } catch (error) { - error.message = `Failed to parse pnpm audit JSON: ${error.message}`; - throw error; + const json = parseJsonOutput(stdout, 'pnpm audit'); + if (isRetiredAuditEndpoint(json)) { + return runBulkAdvisoryAudit(); } + + return { json, status }; } /** diff --git a/security/validate-audit.js b/security/validate-audit.js index 7b89baf81..064e64fe6 100644 --- a/security/validate-audit.js +++ b/security/validate-audit.js @@ -1,4 +1,6 @@ -/** @file Validate audit exception entries against schema and expiry. */ +/** @file Validate audit exception entries against schema, expiry, and live + * advisories. + */ import Ajv from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { VALIDATOR_ADVISORY_ID, VALIDATOR_MIN_SAFE_VERSION } from './constants.js'; @@ -93,7 +95,7 @@ function assertNoExpired(entries) { } /** - * Validate that `pnpm audit` advisories align with the configured exceptions. + * Validate that current advisories align with the configured exceptions. * * Advisories are partitioned against the exception list; unexpected entries are * surfaced via {@link reportUnexpectedAdvisories}. The validator advisory @@ -106,7 +108,7 @@ function assertNoExpired(entries) { * * @param {typeof data} entries Exception ledger entries keyed by advisory ID. * @param {ReturnType} advisories Advisories reported - * by `pnpm audit`. + * by the shared audit helper. * @returns {void} Returns undefined when all advisories are mitigated. * @throws {Error} When unexpected advisories are reported or the validator * mitigation is absent. @@ -157,7 +159,7 @@ assertValidSchema(data); assertNoExpired(data); try { - const { json: auditJson, status } = runAuditJson(); + const { json: auditJson, status } = await runAuditJson(); const advisories = collectAdvisories(auditJson); if (status !== 0 && advisories.length === 0) { From c4da648d533e6e30525b2e7d0a8e4002fd411bd4 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 15 Apr 2026 22:27:40 +0000 Subject: [PATCH 02/15] refactor(security): improve dependency tree walking and bulk advisory normalization - Introduce DEPENDENCY_SECTION_NAMES constant for consistent dependency sections. - Extract processDependencySection for clearer iteration over dependencies. - Refactor walkDependencyTree and walkDependencySection for clarity and correctness. - Add shouldSkipPackageVersion helper to simplify package version filtering logic. - Extract deriveAdvisoryKey and addPackageAdvisories helper functions to clean up normaliseBulkAdvisories. - Improve comments and examples to clarify APIs and internal logic. These changes improve code readability and maintainability without changing functionality. Co-authored-by: devboxerhub[bot] --- security/audit-utils.js | 118 ++++++++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index 55cced1d3..67fa8c3ac 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -16,6 +16,11 @@ const LIST_ARGS = ['ls', '--json', '--depth', 'Infinity']; const BULK_ADVISORY_PATH = '-/npm/v1/security/advisories/bulk'; const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; const COMMAND_MAX_BUFFER = 64 * 1024 * 1024; +const DEPENDENCY_SECTION_NAMES = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', +]; const RETIRED_AUDIT_ENDPOINT_MESSAGE = 'This endpoint is being retired. Use the bulk advisory endpoint instead.'; @@ -48,8 +53,13 @@ function isLocalWorkspaceVersion(version) { ); } +function shouldSkipPackageVersion(packageName, version) { + const isMissing = !packageName || !version; + return isMissing || isLocalWorkspaceVersion(version); +} + function addPackageVersion(versionsByPackage, packageName, version) { - if (!packageName || !version || isLocalWorkspaceVersion(version)) { + if (shouldSkipPackageVersion(packageName, version)) { return; } @@ -58,28 +68,47 @@ function addPackageVersion(versionsByPackage, packageName, version) { versionsByPackage.set(packageName, knownVersions); } -function walkDependencyTree(node, versionsByPackage) { - if (!node || typeof node !== 'object') { - return; - } - - for (const sectionName of ['dependencies', 'devDependencies', 'optionalDependencies']) { - const section = node[sectionName]; - if (!section || typeof section !== 'object') { +function processDependencySection(section, versionsByPackage) { + for (const [packageName, dependency] of Object.entries(section)) { + if (!dependency || typeof dependency !== 'object') { continue; } - for (const [packageName, dependency] of Object.entries(section)) { - if (!dependency || typeof dependency !== 'object') { - continue; - } + if (typeof dependency.version === 'string') { + addPackageVersion(versionsByPackage, packageName, dependency.version); + } - if (typeof dependency.version === 'string') { - addPackageVersion(versionsByPackage, packageName, dependency.version); - } + walkDependencyTree(dependency, versionsByPackage); + } +} - walkDependencyTree(dependency, versionsByPackage); - } +/** + * Walk a dependency section from `pnpm ls` and record the versions discovered. + * + * @param {Record | undefined} section Dependency section from + * a package tree node. + * @param {Map>} versionsByPackage Collected versions keyed + * by package name. + * @example + * const versions = new Map(); + * walkDependencySection({ validator: { version: '1.0.0' } }, versions); + * console.log([...versions.get('validator') ?? []]); // ['1.0.0'] + */ +function walkDependencySection(section, versionsByPackage) { + if (!section || typeof section !== 'object') { + return; + } + + processDependencySection(section, versionsByPackage); +} + +function walkDependencyTree(node, versionsByPackage) { + if (!node || typeof node !== 'object') { + return; + } + + for (const sectionName of DEPENDENCY_SECTION_NAMES) { + walkDependencySection(node[sectionName], versionsByPackage); } } @@ -146,28 +175,49 @@ function extractGithubAdvisoryId(advisoryUrl) { return match?.[0].toUpperCase(); } -function normaliseBulkAdvisories(bulkPayload) { - const advisories = {}; +function deriveAdvisoryKey(packageName, advisory) { + const githubAdvisoryId = extractGithubAdvisoryId(advisory?.url); + const key = githubAdvisoryId ?? `${packageName}:${String(advisory?.id ?? 'unknown')}`; + return { key, githubAdvisoryId }; +} - for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { - if (!Array.isArray(packageAdvisories)) { +/** + * Add bulk advisories for a package into the normalised advisory map. + * + * @param {string} packageName Package name associated with the advisory list. + * @param {unknown} packageAdvisories Advisory payload for the package. + * @param {Record>} advisories Advisory map + * keyed by GitHub advisory ID or package-local fallback identifier. + * @example + * const advisories = {}; + * addPackageAdvisories('validator', [{ id: 1, url: 'https://github.com/advisories/GHSA-abcd-1234-efgh' }], advisories); + * console.log(Object.keys(advisories).length); // 1 + */ +function addPackageAdvisories(packageName, packageAdvisories, advisories) { + if (!Array.isArray(packageAdvisories)) { + return; + } + + for (const advisory of packageAdvisories) { + const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); + + if (key in advisories) { continue; } - for (const advisory of packageAdvisories) { - const githubAdvisoryId = extractGithubAdvisoryId(advisory?.url); - const advisoryKey = githubAdvisoryId ?? `${packageName}:${String(advisory?.id ?? 'unknown')}`; + advisories[key] = { + ...advisory, + github_advisory_id: githubAdvisoryId, + package_name: packageName, + }; + } +} - if (advisoryKey in advisories) { - continue; - } +function normaliseBulkAdvisories(bulkPayload) { + const advisories = {}; - advisories[advisoryKey] = { - ...advisory, - github_advisory_id: githubAdvisoryId, - package_name: packageName, - }; - } + for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { + addPackageAdvisories(packageName, packageAdvisories, advisories); } return advisories; From 172c334e28c1b6b064a4300aa92da813176ef578 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 15 Apr 2026 23:33:48 +0000 Subject: [PATCH 03/15] fix(security): preserve advisory ID casing and reject blank bulk audit responses - Updated advisory ID extraction to preserve original casing instead of forcing uppercase. - Enhanced parseJsonOutput to optionally require non-empty JSON strings, adding validation for empty audit responses. - Refactored bulk advisory normalization logic to prevent advisory ID case normalization. - Added tests to ensure advisory casing is preserved from bulk payload URL. - Added tests to verify rejection of blank bulk advisory responses. - Improved dependency tree walking function for clarity and maintainability. Co-authored-by: devboxerhub[bot] --- frontend-pwa/scripts/audit-utils.test.mjs | 82 ++++++++++++++- security/audit-utils.js | 123 +++++++++------------- 2 files changed, 132 insertions(+), 73 deletions(-) diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index e730db78c..1e1eadce6 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -156,8 +156,8 @@ describe('runAuditJson', () => { expect(result).toEqual({ json: { advisories: { - 'GHSA-VGHF-HV5Q-VC2G': { - [githubAdvisoryIdKey]: 'GHSA-VGHF-HV5Q-VC2G', + 'GHSA-vghf-hv5q-vc2g': { + [githubAdvisoryIdKey]: 'GHSA-vghf-hv5q-vc2g', id: 100000, [packageNameKey]: 'validator', severity: 'high', @@ -201,4 +201,82 @@ describe('runAuditJson', () => { 'Bulk advisory audit failed (503 Service Unavailable)', ); }); + + it('preserves advisory ID casing from the bulk payload URL', async () => { + spawnSyncMock + .mockReturnValueOnce( + createPnpmResult({ + status: 1, + stdout: JSON.stringify({ + error: { + code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', + message: + 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', + }, + }), + }), + ) + .mockReturnValueOnce( + createPnpmResult({ + stdout: JSON.stringify([{ name: 'frontend-pwa', dependencies: {} }]), + }), + ); + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => + JSON.stringify({ + validator: [ + { + id: 100000, + url: 'https://github.com/advisories/GHSA-Vghf-HV5Q-vC2G', + title: 'Validator SSRF', + }, + ], + }), + }); + const { runAuditJson } = await loadAuditUtils(); + + const result = await runAuditJson(); + + expect(result.json.advisories).toEqual({ + 'GHSA-Vghf-HV5Q-vC2G': expect.objectContaining({ + [githubAdvisoryIdKey]: 'GHSA-Vghf-HV5Q-vC2G', + [packageNameKey]: 'validator', + }), + }); + }); + + it('rejects blank bulk advisory responses instead of treating them as empty JSON', async () => { + spawnSyncMock + .mockReturnValueOnce( + createPnpmResult({ + status: 1, + stdout: JSON.stringify({ + error: { + code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', + message: + 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', + }, + }), + }), + ) + .mockReturnValueOnce( + createPnpmResult({ + stdout: JSON.stringify([{ name: 'frontend-pwa', dependencies: {} }]), + }), + ); + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => ' ', + }); + const { runAuditJson } = await loadAuditUtils(); + + await expect(runAuditJson()).rejects.toThrow( + 'Failed to parse bulk advisory audit JSON: response body was empty.', + ); + }); }); diff --git a/security/audit-utils.js b/security/audit-utils.js index 67fa8c3ac..c2763eb21 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -24,13 +24,19 @@ const DEPENDENCY_SECTION_NAMES = [ const RETIRED_AUDIT_ENDPOINT_MESSAGE = 'This endpoint is being retired. Use the bulk advisory endpoint instead.'; -function parseJsonOutput(stdout, commandLabel) { - if (!stdout) { +function parseJsonOutput(payloadText, commandLabel, options = {}) { + const { requireNonEmpty = false } = options; + const text = payloadText?.trim?.() ?? ''; + if (!text) { + if (requireNonEmpty) { + throw new Error(`Failed to parse ${commandLabel} JSON: response body was empty.`); + } + return {}; } try { - return JSON.parse(stdout); + return JSON.parse(text); } catch (error) { error.message = `Failed to parse ${commandLabel} JSON: ${error.message}`; throw error; @@ -53,13 +59,9 @@ function isLocalWorkspaceVersion(version) { ); } -function shouldSkipPackageVersion(packageName, version) { - const isMissing = !packageName || !version; - return isMissing || isLocalWorkspaceVersion(version); -} - function addPackageVersion(versionsByPackage, packageName, version) { - if (shouldSkipPackageVersion(packageName, version)) { + const isMissing = !packageName || !version; + if (isMissing || isLocalWorkspaceVersion(version)) { return; } @@ -68,47 +70,40 @@ function addPackageVersion(versionsByPackage, packageName, version) { versionsByPackage.set(packageName, knownVersions); } -function processDependencySection(section, versionsByPackage) { - for (const [packageName, dependency] of Object.entries(section)) { - if (!dependency || typeof dependency !== 'object') { - continue; - } - - if (typeof dependency.version === 'string') { - addPackageVersion(versionsByPackage, packageName, dependency.version); - } - - walkDependencyTree(dependency, versionsByPackage); - } -} - /** - * Walk a dependency section from `pnpm ls` and record the versions discovered. + * Walk a dependency tree from `pnpm ls` and record the versions discovered. * - * @param {Record | undefined} section Dependency section from - * a package tree node. + * @param {Record | undefined} node Dependency tree node from + * `pnpm ls`. * @param {Map>} versionsByPackage Collected versions keyed * by package name. * @example * const versions = new Map(); - * walkDependencySection({ validator: { version: '1.0.0' } }, versions); + * walkDependencies({ dependencies: { validator: { version: '1.0.0' } } }, versions); * console.log([...versions.get('validator') ?? []]); // ['1.0.0'] */ -function walkDependencySection(section, versionsByPackage) { - if (!section || typeof section !== 'object') { - return; - } - - processDependencySection(section, versionsByPackage); -} - -function walkDependencyTree(node, versionsByPackage) { +function walkDependencies(node, versionsByPackage) { if (!node || typeof node !== 'object') { return; } for (const sectionName of DEPENDENCY_SECTION_NAMES) { - walkDependencySection(node[sectionName], versionsByPackage); + const section = node[sectionName]; + if (!section || typeof section !== 'object') { + continue; + } + + for (const [packageName, dependency] of Object.entries(section)) { + if (!dependency || typeof dependency !== 'object') { + continue; + } + + if (typeof dependency.version === 'string') { + addPackageVersion(versionsByPackage, packageName, dependency.version); + } + + walkDependencies(dependency, versionsByPackage); + } } } @@ -132,7 +127,7 @@ function collectInstalledPackageVersions() { const versionsByPackage = new Map(); for (const tree of Array.isArray(packageTrees) ? packageTrees : [packageTrees]) { - walkDependencyTree(tree, versionsByPackage); + walkDependencies(tree, versionsByPackage); } return Object.fromEntries( @@ -172,7 +167,7 @@ function extractGithubAdvisoryId(advisoryUrl) { } const match = advisoryUrl.match(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i); - return match?.[0].toUpperCase(); + return match?.[0]; } function deriveAdvisoryKey(packageName, advisory) { @@ -181,43 +176,27 @@ function deriveAdvisoryKey(packageName, advisory) { return { key, githubAdvisoryId }; } -/** - * Add bulk advisories for a package into the normalised advisory map. - * - * @param {string} packageName Package name associated with the advisory list. - * @param {unknown} packageAdvisories Advisory payload for the package. - * @param {Record>} advisories Advisory map - * keyed by GitHub advisory ID or package-local fallback identifier. - * @example - * const advisories = {}; - * addPackageAdvisories('validator', [{ id: 1, url: 'https://github.com/advisories/GHSA-abcd-1234-efgh' }], advisories); - * console.log(Object.keys(advisories).length); // 1 - */ -function addPackageAdvisories(packageName, packageAdvisories, advisories) { - if (!Array.isArray(packageAdvisories)) { - return; - } - - for (const advisory of packageAdvisories) { - const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); +function normaliseBulkAdvisories(bulkPayload) { + const advisories = {}; - if (key in advisories) { + for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { + if (!Array.isArray(packageAdvisories)) { continue; } - advisories[key] = { - ...advisory, - github_advisory_id: githubAdvisoryId, - package_name: packageName, - }; - } -} + for (const advisory of packageAdvisories) { + const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); -function normaliseBulkAdvisories(bulkPayload) { - const advisories = {}; + if (Object.hasOwn(advisories, key)) { + continue; + } - for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { - addPackageAdvisories(packageName, packageAdvisories, advisories); + advisories[key] = { + ...advisory, + github_advisory_id: githubAdvisoryId, + package_name: packageName, + }; + } } return advisories; @@ -242,7 +221,9 @@ async function runBulkAdvisoryAudit() { ); } - const bulkPayload = parseJsonOutput(responseText, 'bulk advisory audit'); + const bulkPayload = parseJsonOutput(responseText, 'bulk advisory audit', { + requireNonEmpty: true, + }); const advisories = normaliseBulkAdvisories(bulkPayload); return { From 6550eafee86d74b17987f513f378ce826214384d Mon Sep 17 00:00:00 2001 From: Lody Archive Date: Thu, 16 Apr 2026 21:43:14 +0200 Subject: [PATCH 04/15] chore: archive backup for session e3ff12f2 --- security/audit-utils.js | 42 ++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index c2763eb21..4e5519d64 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -176,27 +176,39 @@ function deriveAdvisoryKey(packageName, advisory) { return { key, githubAdvisoryId }; } -function normaliseBulkAdvisories(bulkPayload) { - const advisories = {}; +/** + * Merge advisories for a single package into the shared accumulator, + * skipping entries whose key is already present. + * + * @param {string} packageName + * @param {unknown[]} packageAdvisories Validated array of raw advisory objects. + * @param {Record} advisories Accumulator mutated in place. + */ +function addPackageAdvisories(packageName, packageAdvisories, advisories) { + for (const advisory of packageAdvisories) { + const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); - for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { - if (!Array.isArray(packageAdvisories)) { + if (Object.hasOwn(advisories, key)) { continue; } - for (const advisory of packageAdvisories) { - const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); + advisories[key] = { + ...advisory, + github_advisory_id: githubAdvisoryId, + package_name: packageName, + }; + } +} - if (Object.hasOwn(advisories, key)) { - continue; - } +function normalizeBulkAdvisories(bulkPayload) { + const advisories = {}; - advisories[key] = { - ...advisory, - github_advisory_id: githubAdvisoryId, - package_name: packageName, - }; + for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { + if (!Array.isArray(packageAdvisories)) { + continue; } + + addPackageAdvisories(packageName, packageAdvisories, advisories); } return advisories; @@ -224,7 +236,7 @@ async function runBulkAdvisoryAudit() { const bulkPayload = parseJsonOutput(responseText, 'bulk advisory audit', { requireNonEmpty: true, }); - const advisories = normaliseBulkAdvisories(bulkPayload); + const advisories = normalizeBulkAdvisories(bulkPayload); return { json: { advisories }, From d96bd63930c69f8f73e106922af543917ea61ba6 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 16 Apr 2026 22:11:20 +0200 Subject: [PATCH 05/15] Refine audit utils helpers Extract the repeated retired-endpoint pnpm test fixture into a\nshared helper so the audit fallback tests stay focused on their\nassertions.\n\nAdd compact JSDoc coverage for the internal audit utility helpers\nwith outcome-driven examples, while keeping the module under the\nrepository's 400-line file limit.\n\nValidated with:\n- make check-fmt\n- make lint\n- make test --- frontend-pwa/scripts/audit-utils.test.mjs | 129 +++++++--------------- security/audit-utils.js | 97 ++++++++-------- 2 files changed, 92 insertions(+), 134 deletions(-) diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index 1e1eadce6..6e318f6e9 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -18,6 +18,27 @@ function createPnpmResult({ status = 0, stdout = '', error = undefined } = {}) { return { error, status, stdout }; } +function setupRetiredPnpmAudit(lsPayload = [{ name: 'frontend-pwa', dependencies: {} }]) { + spawnSyncMock + .mockReturnValueOnce( + createPnpmResult({ + status: 1, + stdout: JSON.stringify({ + error: { + code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', + message: + 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', + }, + }), + }), + ) + .mockReturnValueOnce( + createPnpmResult({ + stdout: JSON.stringify(lsPayload), + }), + ); +} + async function loadAuditUtils() { const module = await import('../../security/audit-utils.js'); return module; @@ -71,42 +92,25 @@ describe('runAuditJson', () => { }); it('falls back to the bulk advisory endpoint when pnpm audit hits the retired endpoint', async () => { - spawnSyncMock - .mockReturnValueOnce( - createPnpmResult({ - status: 1, - stdout: JSON.stringify({ - error: { - code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', - message: - 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', - }, - }), - }), - ) - .mockReturnValueOnce( - createPnpmResult({ - stdout: JSON.stringify([ - { - name: 'frontend-pwa', - path: '/tmp/frontend-pwa', - dependencies: { - '@app/types': { - version: 'link:../packages/types', - }, - validator: { - version: '13.15.23', - dependencies: { - nanoid: { - version: '3.3.11', - }, - }, - }, + setupRetiredPnpmAudit([ + { + name: 'frontend-pwa', + path: '/tmp/frontend-pwa', + dependencies: { + '@app/types': { + version: 'link:../packages/types', + }, + validator: { + version: '13.15.23', + dependencies: { + nanoid: { + version: '3.3.11', }, }, - ]), - }), - ); + }, + }, + }, + ]); execFileSyncMock.mockReturnValueOnce('https://registry.npmjs.org/\n'); fetch.mockResolvedValueOnce({ ok: true, @@ -171,24 +175,7 @@ describe('runAuditJson', () => { }); it('throws a clear error when the bulk advisory endpoint fails', async () => { - spawnSyncMock - .mockReturnValueOnce( - createPnpmResult({ - status: 1, - stdout: JSON.stringify({ - error: { - code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', - message: - 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', - }, - }), - }), - ) - .mockReturnValueOnce( - createPnpmResult({ - stdout: JSON.stringify([{ name: 'frontend-pwa', dependencies: {} }]), - }), - ); + setupRetiredPnpmAudit(); fetch.mockResolvedValueOnce({ ok: false, status: 503, @@ -203,24 +190,7 @@ describe('runAuditJson', () => { }); it('preserves advisory ID casing from the bulk payload URL', async () => { - spawnSyncMock - .mockReturnValueOnce( - createPnpmResult({ - status: 1, - stdout: JSON.stringify({ - error: { - code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', - message: - 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', - }, - }), - }), - ) - .mockReturnValueOnce( - createPnpmResult({ - stdout: JSON.stringify([{ name: 'frontend-pwa', dependencies: {} }]), - }), - ); + setupRetiredPnpmAudit(); fetch.mockResolvedValueOnce({ ok: true, status: 200, @@ -249,24 +219,7 @@ describe('runAuditJson', () => { }); it('rejects blank bulk advisory responses instead of treating them as empty JSON', async () => { - spawnSyncMock - .mockReturnValueOnce( - createPnpmResult({ - status: 1, - stdout: JSON.stringify({ - error: { - code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', - message: - 'The audit endpoint responded with 410: {"error":"This endpoint is being retired. Use the bulk advisory endpoint instead."}', - }, - }), - }), - ) - .mockReturnValueOnce( - createPnpmResult({ - stdout: JSON.stringify([{ name: 'frontend-pwa', dependencies: {} }]), - }), - ); + setupRetiredPnpmAudit(); fetch.mockResolvedValueOnce({ ok: true, status: 200, diff --git a/security/audit-utils.js b/security/audit-utils.js index 4e5519d64..13a77ff1a 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -1,12 +1,7 @@ -/** @file Shared helpers for running dependency audits and reasoning about - * advisories. +/** @file Shared helpers for dependency audits and advisory filtering. * - * These helpers centralise the JSON parsing and filtering logic used by the - * security validation scripts. They keep the workspace wrappers aligned even - * when the package manager needs a compatibility fallback. - * - * Cross-link: `frontend-pwa/scripts/run-audit.mjs` consumes these helpers to - * enforce the validator patch requirement during workspace audits. + * These helpers keep the security scripts aligned when package manager output + * or audit endpoints need compatibility fallbacks. */ import { execFileSync, spawnSync } from 'node:child_process'; @@ -24,6 +19,10 @@ const DEPENDENCY_SECTION_NAMES = [ const RETIRED_AUDIT_ENDPOINT_MESSAGE = 'This endpoint is being retired. Use the bulk advisory endpoint instead.'; +/** Parse command JSON and optionally reject blank responses. + * @param {string | undefined | null} payloadText Raw command output. @param {string} commandLabel Label used in parse errors. @param {{ requireNonEmpty?: boolean }} [options={}] Parsing options. + * @returns {Record | unknown[]} Parsed JSON value, or `{}` for optional blank output. @example parseJsonOutput('{"advisories":{}}', 'pnpm audit'); // { advisories: {} } + */ function parseJsonOutput(payloadText, commandLabel, options = {}) { const { requireNonEmpty = false } = options; const text = payloadText?.trim?.() ?? ''; @@ -43,6 +42,10 @@ function parseJsonOutput(payloadText, commandLabel, options = {}) { } } +/** Detect whether pnpm reported the retired audit endpoint. + * @param {unknown} payload Parsed `pnpm audit --json` payload. + * @returns {boolean} `true` when pnpm should fall back to the bulk advisory endpoint. @example isRetiredAuditEndpoint({ error: { code: 'ERR_PNPM_AUDIT_BAD_RESPONSE', message: 'Use the bulk advisory endpoint instead.' } }); // true + */ function isRetiredAuditEndpoint(payload) { return ( payload?.error?.code === 'ERR_PNPM_AUDIT_BAD_RESPONSE' && @@ -51,6 +54,10 @@ function isRetiredAuditEndpoint(payload) { ); } +/** Check whether a version points at a local workspace dependency. + * @param {string} version Package version or workspace reference. + * @returns {boolean} `true` when the version should be ignored for registry audits. @example isLocalWorkspaceVersion('workspace:*'); // true + */ function isLocalWorkspaceVersion(version) { return ( version.startsWith('file:') || @@ -59,6 +66,10 @@ function isLocalWorkspaceVersion(version) { ); } +/** Record an installed package version unless it is missing or workspace-local. + * @param {Map>} versionsByPackage Installed versions keyed by package name. @param {string} packageName Package name from `pnpm ls`. @param {string} version Installed package version. + * @returns {void} @example const versions = new Map(); addPackageVersion(versions, 'validator', '13.15.23'); console.log([...versions.get('validator')]); // ['13.15.23'] + */ function addPackageVersion(versionsByPackage, packageName, version) { const isMissing = !packageName || !version; if (isMissing || isLocalWorkspaceVersion(version)) { @@ -70,17 +81,9 @@ function addPackageVersion(versionsByPackage, packageName, version) { versionsByPackage.set(packageName, knownVersions); } -/** - * Walk a dependency tree from `pnpm ls` and record the versions discovered. - * - * @param {Record | undefined} node Dependency tree node from - * `pnpm ls`. - * @param {Map>} versionsByPackage Collected versions keyed - * by package name. - * @example - * const versions = new Map(); - * walkDependencies({ dependencies: { validator: { version: '1.0.0' } } }, versions); - * console.log([...versions.get('validator') ?? []]); // ['1.0.0'] +/** Walk a `pnpm ls` tree and record every installed package version. + * @param {Record | undefined} node Dependency tree node from `pnpm ls`. @param {Map>} versionsByPackage Collected versions keyed by package name. + * @returns {void} @example const versions = new Map(); walkDependencies({ dependencies: { validator: { version: '13.15.23' } } }, versions); console.log([...versions.get('validator')]); // ['13.15.23'] */ function walkDependencies(node, versionsByPackage) { if (!node || typeof node !== 'object') { @@ -107,6 +110,9 @@ function walkDependencies(node, versionsByPackage) { } } +/** Collect installed package versions from `pnpm ls` for bulk advisory lookups. + * @returns {Record} Sorted installed versions keyed by package name. @example // With `pnpm ls` returning one installed validator version: collectInstalledPackageVersions(); // { validator: ['13.15.23'] } + */ function collectInstalledPackageVersions() { const result = spawnSync('pnpm', LIST_ARGS, { encoding: 'utf8', @@ -137,6 +143,10 @@ function collectInstalledPackageVersions() { ); } +/** Normalise a registry URL so bulk advisory requests always target a valid base URL. + * @param {string | undefined | null} rawRegistry Raw registry setting from env or pnpm config. + * @returns {string} Registry URL with a trailing slash. @example normaliseRegistryUrl('https://registry.npmjs.org'); // 'https://registry.npmjs.org/' + */ function normaliseRegistryUrl(rawRegistry) { const trimmed = String(rawRegistry ?? '').trim(); const registry = @@ -144,6 +154,9 @@ function normaliseRegistryUrl(rawRegistry) { return registry.endsWith('/') ? registry : `${registry}/`; } +/** Read the npm registry URL from the environment or pnpm config. + * @returns {string} Normalised registry URL, or the npm default when lookup fails. @example // With `npm_config_registry=https://registry.npmjs.org`: readRegistryUrl(); // 'https://registry.npmjs.org/' + */ function readRegistryUrl() { const envRegistry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY; if (envRegistry) { @@ -161,6 +174,10 @@ function readRegistryUrl() { } } +/** Extract a GitHub advisory identifier from an advisory URL. + * @param {unknown} advisoryUrl Advisory URL from pnpm or npm audit output. + * @returns {string | undefined} Matching GHSA identifier when one is present. @example extractGithubAdvisoryId('https://github.com/advisories/GHSA-vghf-hv5q-vc2g'); // 'GHSA-vghf-hv5q-vc2g' + */ function extractGithubAdvisoryId(advisoryUrl) { if (typeof advisoryUrl !== 'string') { return undefined; @@ -170,19 +187,18 @@ function extractGithubAdvisoryId(advisoryUrl) { return match?.[0]; } +/** Derive the advisory key used to deduplicate bulk advisory responses. + * @param {string} packageName Advisory package name. @param {{ id?: unknown, url?: unknown }} advisory Raw advisory object. + * @returns {{ key: string, githubAdvisoryId: string | undefined }} Stable advisory key and extracted GHSA identifier. @example deriveAdvisoryKey('validator', { id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }); // { key: 'GHSA-vghf-hv5q-vc2g', githubAdvisoryId: 'GHSA-vghf-hv5q-vc2g' } + */ function deriveAdvisoryKey(packageName, advisory) { const githubAdvisoryId = extractGithubAdvisoryId(advisory?.url); const key = githubAdvisoryId ?? `${packageName}:${String(advisory?.id ?? 'unknown')}`; return { key, githubAdvisoryId }; } -/** - * Merge advisories for a single package into the shared accumulator, - * skipping entries whose key is already present. - * - * @param {string} packageName - * @param {unknown[]} packageAdvisories Validated array of raw advisory objects. - * @param {Record} advisories Accumulator mutated in place. +/** Merge advisories for one package into the shared accumulator. + * @param {string} packageName Package name from the bulk advisory payload. @param {unknown[]} packageAdvisories Validated array of raw advisory objects. @param {Record} advisories Accumulator mutated in place. */ function addPackageAdvisories(packageName, packageAdvisories, advisories) { for (const advisory of packageAdvisories) { @@ -200,6 +216,10 @@ function addPackageAdvisories(packageName, packageAdvisories, advisories) { } } +/** Normalise bulk advisory responses into the shared advisory object shape. + * @param {Record | undefined} bulkPayload Bulk advisory payload keyed by package name. + * @returns {Record} Deduplicated advisories keyed by GHSA identifier or package fallback. @example normalizeBulkAdvisories({ validator: [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }] }); // { 'GHSA-vghf-hv5q-vc2g': { github_advisory_id: 'GHSA-vghf-hv5q-vc2g', package_name: 'validator', id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' } } + */ function normalizeBulkAdvisories(bulkPayload) { const advisories = {}; @@ -214,6 +234,9 @@ function normalizeBulkAdvisories(bulkPayload) { return advisories; } +/** Query the npm bulk advisory endpoint using the installed PNPM dependency tree. + * @returns {Promise<{ json: { advisories: Record }, status: number }>} Bulk advisory payload and derived exit status. @example // With a successful bulk advisory response containing one advisory: await runBulkAdvisoryAudit(); // { json: { advisories: { 'GHSA-vghf-hv5q-vc2g': { ... } } }, status: 1 } + */ async function runBulkAdvisoryAudit() { const registryUrl = readRegistryUrl(); const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); @@ -244,26 +267,8 @@ async function runBulkAdvisoryAudit() { }; } -/** - * Run `pnpm audit --json` and return the parsed payload alongside the exit - * status. Whitespace-only output is treated as an empty advisory list so that - * callers can rely on deterministic results even when pnpm prints nothing. - * - * Newer npm registries now retire the legacy audit endpoints used by pnpm. - * When that happens, the helper falls back to npm's supported bulk advisory - * endpoint using the installed PNPM dependency tree. - * - * @returns {{ - * json: { advisories?: Record }, - * status: number, - * }} Parsed audit - * output and the pnpm exit status (defaults to zero when undefined). - * @example - * const { json, status } = await runAuditJson(); - * if (status !== 0) { - * throw new Error('pnpm audit failed'); - * } - * console.log(Object.keys(json.advisories ?? {})); +/** Run `pnpm audit --json`, falling back to the bulk advisory endpoint when needed. + * @returns {{ json: { advisories?: Record }, status: number }} Parsed audit output and pnpm exit status. @example const { json, status } = await runAuditJson(); console.log(status, Object.keys(json.advisories ?? {})); */ export async function runAuditJson() { const result = spawnSync('pnpm', AUDIT_ARGS, { From fa72f9f2af215eb39d6fb8e31be5f2d089a718e6 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 16 Apr 2026 22:19:39 +0200 Subject: [PATCH 06/15] Extract dependency section walker Refactor walkDependencies by extracting the per-section traversal\ninto a private helper.\n\nThis keeps the package-version collection behaviour unchanged while\nreducing the branching inside walkDependencies to address the\nreported Bumpy Road issue.\n\nValidated with:\n- make check-fmt\n- make lint\n- make test --- security/audit-utils.js | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index 13a77ff1a..b00171e5d 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -81,6 +81,28 @@ function addPackageVersion(versionsByPackage, packageName, version) { versionsByPackage.set(packageName, knownVersions); } +/** Walk one dependency section from `pnpm ls` and record installed versions. + * @param {Record | undefined} section Dependency section keyed by package name. + * @param {Map>} versionsByPackage Collected versions keyed by package name. + */ +function walkDependencySection(section, versionsByPackage) { + if (!section || typeof section !== 'object') { + return; + } + + for (const [packageName, dependency] of Object.entries(section)) { + if (!dependency || typeof dependency !== 'object') { + continue; + } + + if (typeof dependency.version === 'string') { + addPackageVersion(versionsByPackage, packageName, dependency.version); + } + + walkDependencies(dependency, versionsByPackage); + } +} + /** Walk a `pnpm ls` tree and record every installed package version. * @param {Record | undefined} node Dependency tree node from `pnpm ls`. @param {Map>} versionsByPackage Collected versions keyed by package name. * @returns {void} @example const versions = new Map(); walkDependencies({ dependencies: { validator: { version: '13.15.23' } } }, versions); console.log([...versions.get('validator')]); // ['13.15.23'] @@ -91,22 +113,7 @@ function walkDependencies(node, versionsByPackage) { } for (const sectionName of DEPENDENCY_SECTION_NAMES) { - const section = node[sectionName]; - if (!section || typeof section !== 'object') { - continue; - } - - for (const [packageName, dependency] of Object.entries(section)) { - if (!dependency || typeof dependency !== 'object') { - continue; - } - - if (typeof dependency.version === 'string') { - addPackageVersion(versionsByPackage, packageName, dependency.version); - } - - walkDependencies(dependency, versionsByPackage); - } + walkDependencySection(node[sectionName], versionsByPackage); } } From 03771bbd8b29a6d6b2c4c44f0a32f354806bbbdf Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 16 Apr 2026 22:27:16 +0200 Subject: [PATCH 07/15] Reduce audit utils complexity Extract version-map construction and advisory line formatting\nhelpers in security/audit-utils.js while preserving behaviour.\n\nThis complements the earlier dependency-section extraction and brings\nthe file's measured mean cyclomatic complexity below 4 without\npushing any individual function above 9.\n\nValidated with:\n- make check-fmt\n- make lint\n- make test\n- AST-based complexity check (mean 3.95, max 7) --- security/audit-utils.js | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index b00171e5d..e46a3fac2 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -117,6 +117,20 @@ function walkDependencies(node, versionsByPackage) { } } +/** Build the installed package-version map from parsed `pnpm ls` output. + * @param {Record | Record[] | undefined} packageTrees Parsed `pnpm ls` output as one tree or many. + * @returns {Map>} Installed versions keyed by package name. + */ +function buildVersionMap(packageTrees) { + const versionsByPackage = new Map(); + + for (const tree of Array.isArray(packageTrees) ? packageTrees : [packageTrees]) { + walkDependencies(tree, versionsByPackage); + } + + return versionsByPackage; +} + /** Collect installed package versions from `pnpm ls` for bulk advisory lookups. * @returns {Record} Sorted installed versions keyed by package name. @example // With `pnpm ls` returning one installed validator version: collectInstalledPackageVersions(); // { validator: ['13.15.23'] } */ @@ -137,11 +151,7 @@ function collectInstalledPackageVersions() { } const packageTrees = parseJsonOutput(result.stdout?.trim() ?? '', 'pnpm ls'); - const versionsByPackage = new Map(); - - for (const tree of Array.isArray(packageTrees) ? packageTrees : [packageTrees]) { - walkDependencies(tree, versionsByPackage); - } + const versionsByPackage = buildVersionMap(packageTrees); return Object.fromEntries( [...versionsByPackage.entries()] @@ -355,6 +365,16 @@ export function partitionAdvisoriesById(advisories, allowedIds) { return { expected, unexpected }; } +/** Format one advisory as a report line. + * @param {{ github_advisory_id?: string, title?: string }} advisory Advisory to print. + * @returns {string} Human-readable bullet line for the advisory. + */ +function formatAdvisoryLine(advisory) { + const id = advisory.github_advisory_id ?? 'UNKNOWN'; + const suffix = advisory.title ? `: ${advisory.title}` : ''; + return `- ${id}${suffix}`; +} + /** * Report unexpected advisories to stderr. * @@ -376,9 +396,7 @@ export function reportUnexpectedAdvisories(unexpected, heading) { console.error(heading); for (const advisory of unexpected) { - const id = advisory.github_advisory_id ?? 'UNKNOWN'; - const suffix = advisory.title ? `: ${advisory.title}` : ''; - console.error(`- ${id}${suffix}`); + console.error(formatAdvisoryLine(advisory)); } return true; } From 1a6f8ac0c9e106b6f8b72e86ace3f6fd520733e2 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 16 Apr 2026 22:39:05 +0200 Subject: [PATCH 08/15] Harden bulk audit advisory parsing Document the audit-utils test helpers with terse JSDoc so their\nmocking contracts stay explicit.\n\nValidate bulk advisory entries before spreading them into the\naccumulator, and add a fetch timeout to the bulk advisory fallback\nso the audit flow fails clearly instead of hanging indefinitely.\n\nAlso complete the addPackageAdvisories JSDoc with an explicit\n tag and outcome-focused example while keeping the file\nunder the repository line-limit rule.\n\nValidated with:\n- make check-fmt\n- make lint\n- make test --- frontend-pwa/scripts/audit-utils.test.mjs | 17 ++++ security/audit-utils.js | 112 ++++++++++------------ 2 files changed, 66 insertions(+), 63 deletions(-) diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index 6e318f6e9..c97fabf89 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -14,10 +14,23 @@ vi.mock('node:child_process', () => ({ const originalFetch = globalThis.fetch; +/** + * Create a pnpm-like child-process result for audit command tests. + * @param {{ status?: number, stdout?: string, error?: Error | undefined }} [options={}] Result overrides. + * @param {number} [options.status=0] Process exit status. + * @param {string} [options.stdout=''] Command stdout payload. + * @param {Error | undefined} [options.error=undefined] Spawn error to surface. + * @returns {{ error: Error | undefined, status: number, stdout: string }} Mocked pnpm result object. + */ function createPnpmResult({ status = 0, stdout = '', error = undefined } = {}) { return { error, status, stdout }; } +/** + * Configure the retired-endpoint pnpm audit flow for fallback tests. + * @param {unknown[]} [lsPayload=[{ name: 'frontend-pwa', dependencies: {} }]] Parsed `pnpm ls` payload for the second mock result. + * @returns {void} + */ function setupRetiredPnpmAudit(lsPayload = [{ name: 'frontend-pwa', dependencies: {} }]) { spawnSyncMock .mockReturnValueOnce( @@ -39,6 +52,10 @@ function setupRetiredPnpmAudit(lsPayload = [{ name: 'frontend-pwa', dependencies ); } +/** + * Dynamically import the shared audit utility module under test. + * @returns {Promise} Imported audit utility module. + */ async function loadAuditUtils() { const module = await import('../../security/audit-utils.js'); return module; diff --git a/security/audit-utils.js b/security/audit-utils.js index e46a3fac2..195918bae 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -1,14 +1,11 @@ -/** @file Shared helpers for dependency audits and advisory filtering. - * - * These helpers keep the security scripts aligned when package manager output - * or audit endpoints need compatibility fallbacks. - */ +/** @file Shared helpers for dependency audits and advisory filtering. */ import { execFileSync, spawnSync } from 'node:child_process'; const AUDIT_ARGS = ['audit', '--json']; const LIST_ARGS = ['ls', '--json', '--depth', 'Infinity']; const BULK_ADVISORY_PATH = '-/npm/v1/security/advisories/bulk'; +const BULK_AUDIT_TIMEOUT_MS = 30_000; const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; const COMMAND_MAX_BUFFER = 64 * 1024 * 1024; const DEPENDENCY_SECTION_NAMES = [ @@ -82,8 +79,7 @@ function addPackageVersion(versionsByPackage, packageName, version) { } /** Walk one dependency section from `pnpm ls` and record installed versions. - * @param {Record | undefined} section Dependency section keyed by package name. - * @param {Map>} versionsByPackage Collected versions keyed by package name. + * @param {Record | undefined} section Dependency section keyed by package name. @param {Map>} versionsByPackage Collected versions keyed by package name. */ function walkDependencySection(section, versionsByPackage) { if (!section || typeof section !== 'object') { @@ -118,8 +114,7 @@ function walkDependencies(node, versionsByPackage) { } /** Build the installed package-version map from parsed `pnpm ls` output. - * @param {Record | Record[] | undefined} packageTrees Parsed `pnpm ls` output as one tree or many. - * @returns {Map>} Installed versions keyed by package name. + * @param {Record | Record[] | undefined} packageTrees Parsed `pnpm ls` output as one tree or many. @returns {Map>} Installed versions keyed by package name. */ function buildVersionMap(packageTrees) { const versionsByPackage = new Map(); @@ -216,9 +211,16 @@ function deriveAdvisoryKey(packageName, advisory) { /** Merge advisories for one package into the shared accumulator. * @param {string} packageName Package name from the bulk advisory payload. @param {unknown[]} packageAdvisories Validated array of raw advisory objects. @param {Record} advisories Accumulator mutated in place. + * @returns {void} @example const advisories = {}; addPackageAdvisories('validator', [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g', title: 'Validator SSRF' }], advisories); console.log(advisories['GHSA-vghf-hv5q-vc2g'].package_name); // 'validator' */ function addPackageAdvisories(packageName, packageAdvisories, advisories) { - for (const advisory of packageAdvisories) { + for (const [index, advisory] of packageAdvisories.entries()) { + const isPlainObject = + typeof advisory === 'object' && advisory !== null && !Array.isArray(advisory); + if (!isPlainObject) { + throw new Error(`Invalid advisory for package ${packageName} at index ${index}: expected object`); + } + const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); if (Object.hasOwn(advisories, key)) { @@ -257,15 +259,31 @@ function normalizeBulkAdvisories(bulkPayload) { async function runBulkAdvisoryAudit() { const registryUrl = readRegistryUrl(); const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); - const response = await fetch(endpoint, { - method: 'POST', - headers: { - accept: 'application/json', - 'content-type': 'application/json', - }, - body: JSON.stringify(collectInstalledPackageVersions()), - }); - const responseText = await response.text(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), BULK_AUDIT_TIMEOUT_MS); + let response; + let responseText; + + try { + response = await fetch(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(collectInstalledPackageVersions()), + signal: controller.signal, + }); + responseText = await response.text(); + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`Bulk advisory audit timed out after ${BULK_AUDIT_TIMEOUT_MS}ms at ${endpoint}`); + } + + throw error; + } finally { + clearTimeout(timeoutId); + } if (!response.ok) { throw new Error( @@ -313,40 +331,19 @@ export async function runAuditJson() { return { json, status }; } -/** - * Convert the advisories object returned by `pnpm audit` into a flat array that - * is easier to filter. - * - * @param {{ advisories?: Record }} auditJson Raw JSON payload - * from `pnpm audit`. - * @returns {Array>} List of advisory objects. - * @example - * const advisories = collectAdvisories({ advisories: { "GHSA-123": { id: 1 } } }); - * console.log(advisories.length); // 1 +/** Convert the advisories object returned by `pnpm audit` into a flat array. + * @param {{ advisories?: Record }} auditJson Raw JSON payload from `pnpm audit`. + * @returns {Array>} List of advisory objects. @example const advisories = collectAdvisories({ advisories: { "GHSA-123": { id: 1 } } }); console.log(advisories.length); // 1 */ export function collectAdvisories(auditJson) { return Object.values(auditJson.advisories ?? {}); } -/** - * Split advisories into those whose GitHub advisory IDs are present in the - * allowed list and those that are unexpected. - * - * @param {Array<{ github_advisory_id?: string }>} advisories Advisories to - * partition. - * @param {Iterable} allowedIds Advisory IDs the caller expects. - * @returns {{ expected: typeof advisories, unexpected: typeof advisories }} - * Partitioned advisories. - * @example - * const { expected, unexpected } = partitionAdvisoriesById( - * [ - * { github_advisory_id: 'GHSA-1' }, - * { github_advisory_id: 'GHSA-2' }, - * ], - * ['GHSA-2'], - * ); - * console.log(expected.length); // 1 - * console.log(unexpected.length); // 1 +/** Split advisories into allowed and unexpected groups. + * @param {Array<{ github_advisory_id?: string }>} advisories Advisories to partition. @param {Iterable} allowedIds Advisory IDs the caller expects. + * @returns {{ expected: typeof advisories, unexpected: typeof advisories }} Partitioned advisories. + * @example const { expected, unexpected } = partitionAdvisoriesById([{ github_advisory_id: 'GHSA-1' }, { github_advisory_id: 'GHSA-2' }], ['GHSA-2']); console.log(expected.length); // 1 + * @example console.log(unexpected.length); // 1 */ export function partitionAdvisoriesById(advisories, allowedIds) { const allowed = new Set(allowedIds); @@ -366,8 +363,7 @@ export function partitionAdvisoriesById(advisories, allowedIds) { } /** Format one advisory as a report line. - * @param {{ github_advisory_id?: string, title?: string }} advisory Advisory to print. - * @returns {string} Human-readable bullet line for the advisory. + * @param {{ github_advisory_id?: string, title?: string }} advisory Advisory to print. @returns {string} Human-readable bullet line for the advisory. */ function formatAdvisoryLine(advisory) { const id = advisory.github_advisory_id ?? 'UNKNOWN'; @@ -375,19 +371,9 @@ function formatAdvisoryLine(advisory) { return `- ${id}${suffix}`; } -/** - * Report unexpected advisories to stderr. - * - * @param {Array<{ github_advisory_id?: string, title?: string }>} unexpected - * Advisories that were not permitted. - * @param {string} heading Descriptive heading for the error output. - * @returns {boolean} Whether any advisories were reported. - * @example - * const hadUnexpected = reportUnexpectedAdvisories( - * [{ github_advisory_id: 'GHSA-1', title: 'Example' }], - * 'Unexpected advisories:', - * ); - * console.log(hadUnexpected); // true +/** Report unexpected advisories to stderr. + * @param {Array<{ github_advisory_id?: string, title?: string }>} unexpected Advisories that were not permitted. @param {string} heading Descriptive heading for the error output. + * @returns {boolean} Whether any advisories were reported. @example const hadUnexpected = reportUnexpectedAdvisories([{ github_advisory_id: 'GHSA-1', title: 'Example' }], 'Unexpected advisories:'); console.log(hadUnexpected); // true */ export function reportUnexpectedAdvisories(unexpected, heading) { if (unexpected.length === 0) { From 81aabee0e3a6bacb2224ceede01b5bfba11c14e5 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 16 Apr 2026 23:32:20 +0200 Subject: [PATCH 09/15] Align audit utils normalize spelling Rename `normaliseRegistryUrl` to `normalizeRegistryUrl` and update its local call sites so the helper spelling matches the rest of the module. Also align the `normalizeBulkAdvisories` JSDoc summary with the function name. Validated with: - make check-fmt - make lint - make test --- security/audit-utils.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index 195918bae..2214c21d6 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -155,11 +155,11 @@ function collectInstalledPackageVersions() { ); } -/** Normalise a registry URL so bulk advisory requests always target a valid base URL. +/** Normalize a registry URL so bulk advisory requests always target a valid base URL. * @param {string | undefined | null} rawRegistry Raw registry setting from env or pnpm config. - * @returns {string} Registry URL with a trailing slash. @example normaliseRegistryUrl('https://registry.npmjs.org'); // 'https://registry.npmjs.org/' + * @returns {string} Registry URL with a trailing slash. @example normalizeRegistryUrl('https://registry.npmjs.org'); // 'https://registry.npmjs.org/' */ -function normaliseRegistryUrl(rawRegistry) { +function normalizeRegistryUrl(rawRegistry) { const trimmed = String(rawRegistry ?? '').trim(); const registry = trimmed && trimmed !== 'undefined' && trimmed !== 'null' ? trimmed : DEFAULT_REGISTRY; @@ -172,11 +172,11 @@ function normaliseRegistryUrl(rawRegistry) { function readRegistryUrl() { const envRegistry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY; if (envRegistry) { - return normaliseRegistryUrl(envRegistry); + return normalizeRegistryUrl(envRegistry); } try { - return normaliseRegistryUrl( + return normalizeRegistryUrl( execFileSync('pnpm', ['config', 'get', 'registry'], { encoding: 'utf8', }), @@ -235,7 +235,7 @@ function addPackageAdvisories(packageName, packageAdvisories, advisories) { } } -/** Normalise bulk advisory responses into the shared advisory object shape. +/** Normalize bulk advisory responses into the shared advisory object shape. * @param {Record | undefined} bulkPayload Bulk advisory payload keyed by package name. * @returns {Record} Deduplicated advisories keyed by GHSA identifier or package fallback. @example normalizeBulkAdvisories({ validator: [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }] }); // { 'GHSA-vghf-hv5q-vc2g': { github_advisory_id: 'GHSA-vghf-hv5q-vc2g', package_name: 'validator', id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' } } */ From aab818f0f3615f2bb58727dcfd037e15cec9d8fa Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 16 Apr 2026 23:42:33 +0200 Subject: [PATCH 10/15] Extract bulk audit request helpers Extract the bulk advisory fetch and result shaping logic out of `runBulkAdvisoryAudit` so the fallback audit path stays easier to read and remains below the repository complexity thresholds. Validated with: - make check-fmt - make lint - make test - AST-based complexity check (mean 3.56, max 7) --- security/audit-utils.js | 46 +++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index 2214c21d6..6e15d6c44 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -253,28 +253,27 @@ function normalizeBulkAdvisories(bulkPayload) { return advisories; } -/** Query the npm bulk advisory endpoint using the installed PNPM dependency tree. - * @returns {Promise<{ json: { advisories: Record }, status: number }>} Bulk advisory payload and derived exit status. @example // With a successful bulk advisory response containing one advisory: await runBulkAdvisoryAudit(); // { json: { advisories: { 'GHSA-vghf-hv5q-vc2g': { ... } } }, status: 1 } +/** Post package versions to the npm bulk advisory endpoint and return the raw response. + * @param {URL} endpoint Bulk advisory endpoint URL. @param {Record} packageVersions Installed package versions keyed by package name. + * @returns {Promise<{ response: Response, responseText: string }>} HTTP response and response body text. */ -async function runBulkAdvisoryAudit() { - const registryUrl = readRegistryUrl(); - const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); +async function fetchBulkAdvisories(endpoint, packageVersions) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), BULK_AUDIT_TIMEOUT_MS); - let response; - let responseText; try { - response = await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', }, - body: JSON.stringify(collectInstalledPackageVersions()), + body: JSON.stringify(packageVersions), signal: controller.signal, }); - responseText = await response.text(); + const responseText = await response.text(); + + return { response, responseText }; } catch (error) { if (error?.name === 'AbortError') { throw new Error(`Bulk advisory audit timed out after ${BULK_AUDIT_TIMEOUT_MS}ms at ${endpoint}`); @@ -284,6 +283,23 @@ async function runBulkAdvisoryAudit() { } finally { clearTimeout(timeoutId); } +} + +/** Convert normalized advisories into the shared audit result structure. + * @param {Record} advisories Normalized advisories keyed by advisory identifier. + * @returns {{ json: { advisories: Record }, status: number }} Audit result payload and exit status. + */ +function toAdvisoryResult(advisories) { + return { json: { advisories }, status: Object.keys(advisories).length === 0 ? 0 : 1 }; +} + +/** Query the npm bulk advisory endpoint using the installed PNPM dependency tree. + * @returns {Promise<{ json: { advisories: Record }, status: number }>} Bulk advisory payload and derived exit status. @example // With a successful bulk advisory response containing one advisory: await runBulkAdvisoryAudit(); // { json: { advisories: { 'GHSA-vghf-hv5q-vc2g': { ... } } }, status: 1 } + */ +async function runBulkAdvisoryAudit() { + const registryUrl = readRegistryUrl(); + const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); + const { response, responseText } = await fetchBulkAdvisories(endpoint, collectInstalledPackageVersions()); if (!response.ok) { throw new Error( @@ -291,15 +307,9 @@ async function runBulkAdvisoryAudit() { ); } - const bulkPayload = parseJsonOutput(responseText, 'bulk advisory audit', { - requireNonEmpty: true, - }); - const advisories = normalizeBulkAdvisories(bulkPayload); + const bulkPayload = parseJsonOutput(responseText, 'bulk advisory audit', { requireNonEmpty: true }); - return { - json: { advisories }, - status: Object.keys(advisories).length === 0 ? 0 : 1, - }; + return toAdvisoryResult(normalizeBulkAdvisories(bulkPayload)); } /** Run `pnpm audit --json`, falling back to the bulk advisory endpoint when needed. From d5e0ecb4152f051d5300933af37521d9ec0cdd4c Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 17 Apr 2026 00:17:21 +0200 Subject: [PATCH 11/15] Harden bulk advisory input validation Fail closed when normalized bulk advisory payload entries are not arrays, reject empty `pnpm ls` output before parsing, and add the requested outcome-focused examples to the remaining helper JSDoc blocks. Validated with: - make check-fmt - make lint - make test --- security/audit-utils.js | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index 6e15d6c44..475a79506 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -80,6 +80,7 @@ function addPackageVersion(versionsByPackage, packageName, version) { /** Walk one dependency section from `pnpm ls` and record installed versions. * @param {Record | undefined} section Dependency section keyed by package name. @param {Map>} versionsByPackage Collected versions keyed by package name. + * @returns {void} @example const versions = new Map(); walkDependencySection({ validator: { version: '13.15.23' } }, versions); console.log(versions.get('validator').has('13.15.23')); // true */ function walkDependencySection(section, versionsByPackage) { if (!section || typeof section !== 'object') { @@ -114,15 +115,11 @@ function walkDependencies(node, versionsByPackage) { } /** Build the installed package-version map from parsed `pnpm ls` output. - * @param {Record | Record[] | undefined} packageTrees Parsed `pnpm ls` output as one tree or many. @returns {Map>} Installed versions keyed by package name. + * @param {Record | Record[] | undefined} packageTrees Parsed `pnpm ls` output as one tree or many. @returns {Map>} Installed versions keyed by package name. @example const versions = buildVersionMap([{ dependencies: { validator: { version: '13.15.23' } } }]); console.log(versions.get('validator').has('13.15.23')); // true */ function buildVersionMap(packageTrees) { const versionsByPackage = new Map(); - - for (const tree of Array.isArray(packageTrees) ? packageTrees : [packageTrees]) { - walkDependencies(tree, versionsByPackage); - } - + for (const tree of Array.isArray(packageTrees) ? packageTrees : [packageTrees]) walkDependencies(tree, versionsByPackage); return versionsByPackage; } @@ -130,11 +127,7 @@ function buildVersionMap(packageTrees) { * @returns {Record} Sorted installed versions keyed by package name. @example // With `pnpm ls` returning one installed validator version: collectInstalledPackageVersions(); // { validator: ['13.15.23'] } */ function collectInstalledPackageVersions() { - const result = spawnSync('pnpm', LIST_ARGS, { - encoding: 'utf8', - maxBuffer: COMMAND_MAX_BUFFER, - stdio: ['ignore', 'pipe', 'inherit'], - }); + const result = spawnSync('pnpm', LIST_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'] }); if (result.error) { throw result.error; @@ -145,7 +138,12 @@ function collectInstalledPackageVersions() { throw new Error(`pnpm ls failed without producing a dependency tree (exit status ${status}).`); } - const packageTrees = parseJsonOutput(result.stdout?.trim() ?? '', 'pnpm ls'); + const stdout = result.stdout?.trim(); + if (!stdout) { + throw new Error('pnpm ls failed without producing a dependency tree.'); + } + + const packageTrees = parseJsonOutput(stdout, 'pnpm ls'); const versionsByPackage = buildVersionMap(packageTrees); return Object.fromEntries( @@ -244,7 +242,7 @@ function normalizeBulkAdvisories(bulkPayload) { for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { if (!Array.isArray(packageAdvisories)) { - continue; + throw new TypeError(`Invalid bulk advisory entry for package ${packageName}: expected array, received ${JSON.stringify(packageAdvisories)}`); } addPackageAdvisories(packageName, packageAdvisories, advisories); @@ -359,7 +357,6 @@ export function partitionAdvisoriesById(advisories, allowedIds) { const allowed = new Set(allowedIds); const expected = []; const unexpected = []; - for (const advisory of advisories) { const id = advisory.github_advisory_id; if (id && allowed.has(id)) { @@ -373,7 +370,7 @@ export function partitionAdvisoriesById(advisories, allowedIds) { } /** Format one advisory as a report line. - * @param {{ github_advisory_id?: string, title?: string }} advisory Advisory to print. @returns {string} Human-readable bullet line for the advisory. + * @param {{ github_advisory_id?: string, title?: string }} advisory Advisory to print. @returns {string} Human-readable bullet line for the advisory. @example formatAdvisoryLine({ github_advisory_id: 'GHSA-1', title: 'Example' }); // "- GHSA-1: Example" */ function formatAdvisoryLine(advisory) { const id = advisory.github_advisory_id ?? 'UNKNOWN'; From ed5ee0aeb72faa11bb2d7e0b44316c31b5bb5949 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 17 Apr 2026 02:07:30 +0200 Subject: [PATCH 12/15] Harden audit payload validation Validate bulk advisory and pnpm dependency-tree payloads before\nprocessing them so malformed upstream JSON fails closed instead of\nquietly degrading to empty results.\n\nAdd the missing outcome-focused JSDoc examples for the extracted bulk\naudit helpers while keeping the file within the repository line limit. --- security/audit-utils.js | 59 ++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index 475a79506..cd23af212 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -8,13 +8,8 @@ const BULK_ADVISORY_PATH = '-/npm/v1/security/advisories/bulk'; const BULK_AUDIT_TIMEOUT_MS = 30_000; const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'; const COMMAND_MAX_BUFFER = 64 * 1024 * 1024; -const DEPENDENCY_SECTION_NAMES = [ - 'dependencies', - 'devDependencies', - 'optionalDependencies', -]; -const RETIRED_AUDIT_ENDPOINT_MESSAGE = - 'This endpoint is being retired. Use the bulk advisory endpoint instead.'; +const DEPENDENCY_SECTION_NAMES = ['dependencies', 'devDependencies', 'optionalDependencies']; +const RETIRED_AUDIT_ENDPOINT_MESSAGE = 'This endpoint is being retired. Use the bulk advisory endpoint instead.'; /** Parse command JSON and optionally reject blank responses. * @param {string | undefined | null} payloadText Raw command output. @param {string} commandLabel Label used in parse errors. @param {{ requireNonEmpty?: boolean }} [options={}] Parsing options. @@ -27,10 +22,8 @@ function parseJsonOutput(payloadText, commandLabel, options = {}) { if (requireNonEmpty) { throw new Error(`Failed to parse ${commandLabel} JSON: response body was empty.`); } - return {}; } - try { return JSON.parse(text); } catch (error) { @@ -47,8 +40,7 @@ function isRetiredAuditEndpoint(payload) { return ( payload?.error?.code === 'ERR_PNPM_AUDIT_BAD_RESPONSE' && typeof payload?.error?.message === 'string' && - payload.error.message.includes(RETIRED_AUDIT_ENDPOINT_MESSAGE) - ); + payload.error.message.includes(RETIRED_AUDIT_ENDPOINT_MESSAGE)); } /** Check whether a version points at a local workspace dependency. @@ -59,8 +51,7 @@ function isLocalWorkspaceVersion(version) { return ( version.startsWith('file:') || version.startsWith('link:') || - version.startsWith('workspace:') - ); + version.startsWith('workspace:')); } /** Record an installed package version unless it is missing or workspace-local. @@ -72,7 +63,6 @@ function addPackageVersion(versionsByPackage, packageName, version) { if (isMissing || isLocalWorkspaceVersion(version)) { return; } - const knownVersions = versionsByPackage.get(packageName) ?? new Set(); knownVersions.add(version); versionsByPackage.set(packageName, knownVersions); @@ -86,16 +76,13 @@ function walkDependencySection(section, versionsByPackage) { if (!section || typeof section !== 'object') { return; } - for (const [packageName, dependency] of Object.entries(section)) { if (!dependency || typeof dependency !== 'object') { continue; } - if (typeof dependency.version === 'string') { addPackageVersion(versionsByPackage, packageName, dependency.version); } - walkDependencies(dependency, versionsByPackage); } } @@ -108,18 +95,31 @@ function walkDependencies(node, versionsByPackage) { if (!node || typeof node !== 'object') { return; } - for (const sectionName of DEPENDENCY_SECTION_NAMES) { walkDependencySection(node[sectionName], versionsByPackage); } } +/** Check whether `pnpm ls` returned a dependency tree object. + * @param {unknown} value Parsed `pnpm ls` payload value. + * @returns {boolean} `true` when the value can be walked as one dependency tree. @example isDependencyTreeNode({ dependencies: {} }); // true + */ +function isDependencyTreeNode(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + /** Build the installed package-version map from parsed `pnpm ls` output. * @param {Record | Record[] | undefined} packageTrees Parsed `pnpm ls` output as one tree or many. @returns {Map>} Installed versions keyed by package name. @example const versions = buildVersionMap([{ dependencies: { validator: { version: '13.15.23' } } }]); console.log(versions.get('validator').has('13.15.23')); // true */ function buildVersionMap(packageTrees) { const versionsByPackage = new Map(); - for (const tree of Array.isArray(packageTrees) ? packageTrees : [packageTrees]) walkDependencies(tree, versionsByPackage); + const trees = Array.isArray(packageTrees) ? packageTrees : [packageTrees]; + for (const tree of trees) { + if (!isDependencyTreeNode(tree)) { + throw new TypeError('pnpm ls returned an invalid dependency tree payload.'); + } + walkDependencies(tree, versionsByPackage); + } return versionsByPackage; } @@ -137,7 +137,6 @@ function collectInstalledPackageVersions() { if (status !== 0) { throw new Error(`pnpm ls failed without producing a dependency tree (exit status ${status}).`); } - const stdout = result.stdout?.trim(); if (!stdout) { throw new Error('pnpm ls failed without producing a dependency tree.'); @@ -159,8 +158,7 @@ function collectInstalledPackageVersions() { */ function normalizeRegistryUrl(rawRegistry) { const trimmed = String(rawRegistry ?? '').trim(); - const registry = - trimmed && trimmed !== 'undefined' && trimmed !== 'null' ? trimmed : DEFAULT_REGISTRY; + const registry = trimmed && trimmed !== 'undefined' && trimmed !== 'null' ? trimmed : DEFAULT_REGISTRY; return registry.endsWith('/') ? registry : `${registry}/`; } @@ -213,8 +211,7 @@ function deriveAdvisoryKey(packageName, advisory) { */ function addPackageAdvisories(packageName, packageAdvisories, advisories) { for (const [index, advisory] of packageAdvisories.entries()) { - const isPlainObject = - typeof advisory === 'object' && advisory !== null && !Array.isArray(advisory); + const isPlainObject = typeof advisory === 'object' && advisory !== null && !Array.isArray(advisory); if (!isPlainObject) { throw new Error(`Invalid advisory for package ${packageName} at index ${index}: expected object`); } @@ -238,9 +235,13 @@ function addPackageAdvisories(packageName, packageAdvisories, advisories) { * @returns {Record} Deduplicated advisories keyed by GHSA identifier or package fallback. @example normalizeBulkAdvisories({ validator: [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }] }); // { 'GHSA-vghf-hv5q-vc2g': { github_advisory_id: 'GHSA-vghf-hv5q-vc2g', package_name: 'validator', id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' } } */ function normalizeBulkAdvisories(bulkPayload) { + if (typeof bulkPayload !== 'object' || bulkPayload === null || Array.isArray(bulkPayload)) { + throw new TypeError('Invalid bulk advisory payload: expected an object keyed by package name.'); + } + const advisories = {}; - for (const [packageName, packageAdvisories] of Object.entries(bulkPayload ?? {})) { + for (const [packageName, packageAdvisories] of Object.entries(bulkPayload)) { if (!Array.isArray(packageAdvisories)) { throw new TypeError(`Invalid bulk advisory entry for package ${packageName}: expected array, received ${JSON.stringify(packageAdvisories)}`); } @@ -254,6 +255,7 @@ function normalizeBulkAdvisories(bulkPayload) { /** Post package versions to the npm bulk advisory endpoint and return the raw response. * @param {URL} endpoint Bulk advisory endpoint URL. @param {Record} packageVersions Installed package versions keyed by package name. * @returns {Promise<{ response: Response, responseText: string }>} HTTP response and response body text. + * @example const { responseText } = await fetchBulkAdvisories(new URL('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'), { validator: ['13.15.23'] }); console.log(responseText); // '{}' */ async function fetchBulkAdvisories(endpoint, packageVersions) { const controller = new AbortController(); @@ -285,7 +287,7 @@ async function fetchBulkAdvisories(endpoint, packageVersions) { /** Convert normalized advisories into the shared audit result structure. * @param {Record} advisories Normalized advisories keyed by advisory identifier. - * @returns {{ json: { advisories: Record }, status: number }} Audit result payload and exit status. + * @returns {{ json: { advisories: Record }, status: number }} Audit result payload and exit status. @example toAdvisoryResult({}); // { json: { advisories: {} }, status: 0 } */ function toAdvisoryResult(advisories) { return { json: { advisories }, status: Object.keys(advisories).length === 0 ? 0 : 1 }; @@ -297,7 +299,10 @@ function toAdvisoryResult(advisories) { async function runBulkAdvisoryAudit() { const registryUrl = readRegistryUrl(); const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); - const { response, responseText } = await fetchBulkAdvisories(endpoint, collectInstalledPackageVersions()); + const { response, responseText } = await fetchBulkAdvisories( + endpoint, + collectInstalledPackageVersions(), + ); if (!response.ok) { throw new Error( From 2aa17697e36e355cd2840cdba57df0e2f9da1999 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 17 Apr 2026 02:28:33 +0200 Subject: [PATCH 13/15] Extract audit utility predicates Add three private predicate helpers in `security/audit-utils.js` to reduce per-function branching and lower the module's mean cyclomatic complexity. This keeps behaviour unchanged while making the registry, advisory-shape, and allow-list checks reusable and easier to scan. A code-only complexity check puts the file at a mean of 3.58 with a maximum individual function complexity of 7. --- security/audit-utils.js | 42 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index cd23af212..fbf093e3d 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -128,11 +128,9 @@ function buildVersionMap(packageTrees) { */ function collectInstalledPackageVersions() { const result = spawnSync('pnpm', LIST_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'] }); - if (result.error) { throw result.error; } - const status = result.status ?? 0; if (status !== 0) { throw new Error(`pnpm ls failed without producing a dependency tree (exit status ${status}).`); @@ -152,13 +150,18 @@ function collectInstalledPackageVersions() { ); } +/** Return `true` when a raw registry string is a real URL and not a placeholder. + * @param {string} value Trimmed registry string. @returns {boolean} + */ +function isValidRegistryValue(value) { return Boolean(value) && value !== 'undefined' && value !== 'null'; } + /** Normalize a registry URL so bulk advisory requests always target a valid base URL. * @param {string | undefined | null} rawRegistry Raw registry setting from env or pnpm config. * @returns {string} Registry URL with a trailing slash. @example normalizeRegistryUrl('https://registry.npmjs.org'); // 'https://registry.npmjs.org/' */ function normalizeRegistryUrl(rawRegistry) { const trimmed = String(rawRegistry ?? '').trim(); - const registry = trimmed && trimmed !== 'undefined' && trimmed !== 'null' ? trimmed : DEFAULT_REGISTRY; + const registry = isValidRegistryValue(trimmed) ? trimmed : DEFAULT_REGISTRY; return registry.endsWith('/') ? registry : `${registry}/`; } @@ -170,7 +173,6 @@ function readRegistryUrl() { if (envRegistry) { return normalizeRegistryUrl(envRegistry); } - try { return normalizeRegistryUrl( execFileSync('pnpm', ['config', 'get', 'registry'], { @@ -190,7 +192,6 @@ function extractGithubAdvisoryId(advisoryUrl) { if (typeof advisoryUrl !== 'string') { return undefined; } - const match = advisoryUrl.match(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i); return match?.[0]; } @@ -205,23 +206,24 @@ function deriveAdvisoryKey(packageName, advisory) { return { key, githubAdvisoryId }; } +/** Return `true` when a value is a plain (non-array, non-null) object. + * @param {unknown} value Value to test. @returns {boolean} + */ +function isPlainAdvisoryObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } + /** Merge advisories for one package into the shared accumulator. * @param {string} packageName Package name from the bulk advisory payload. @param {unknown[]} packageAdvisories Validated array of raw advisory objects. @param {Record} advisories Accumulator mutated in place. * @returns {void} @example const advisories = {}; addPackageAdvisories('validator', [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g', title: 'Validator SSRF' }], advisories); console.log(advisories['GHSA-vghf-hv5q-vc2g'].package_name); // 'validator' */ function addPackageAdvisories(packageName, packageAdvisories, advisories) { for (const [index, advisory] of packageAdvisories.entries()) { - const isPlainObject = typeof advisory === 'object' && advisory !== null && !Array.isArray(advisory); - if (!isPlainObject) { + if (!isPlainAdvisoryObject(advisory)) { throw new Error(`Invalid advisory for package ${packageName} at index ${index}: expected object`); } - const { key, githubAdvisoryId } = deriveAdvisoryKey(packageName, advisory); - if (Object.hasOwn(advisories, key)) { continue; } - advisories[key] = { ...advisory, github_advisory_id: githubAdvisoryId, @@ -238,14 +240,11 @@ function normalizeBulkAdvisories(bulkPayload) { if (typeof bulkPayload !== 'object' || bulkPayload === null || Array.isArray(bulkPayload)) { throw new TypeError('Invalid bulk advisory payload: expected an object keyed by package name.'); } - const advisories = {}; - for (const [packageName, packageAdvisories] of Object.entries(bulkPayload)) { if (!Array.isArray(packageAdvisories)) { throw new TypeError(`Invalid bulk advisory entry for package ${packageName}: expected array, received ${JSON.stringify(packageAdvisories)}`); } - addPackageAdvisories(packageName, packageAdvisories, advisories); } @@ -260,7 +259,6 @@ function normalizeBulkAdvisories(bulkPayload) { async function fetchBulkAdvisories(endpoint, packageVersions) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), BULK_AUDIT_TIMEOUT_MS); - try { const response = await fetch(endpoint, { method: 'POST', @@ -272,13 +270,11 @@ async function fetchBulkAdvisories(endpoint, packageVersions) { signal: controller.signal, }); const responseText = await response.text(); - return { response, responseText }; } catch (error) { if (error?.name === 'AbortError') { throw new Error(`Bulk advisory audit timed out after ${BULK_AUDIT_TIMEOUT_MS}ms at ${endpoint}`); } - throw error; } finally { clearTimeout(timeoutId); @@ -324,14 +320,11 @@ export async function runAuditJson() { maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'], }); - if (result.error) { throw result.error; } - const status = result.status ?? 0; const stdout = result.stdout ? result.stdout.trim() : ''; - if (!stdout) { return { json: { advisories: {} }, status }; } @@ -352,6 +345,14 @@ export function collectAdvisories(auditJson) { return Object.values(auditJson.advisories ?? {}); } +/** Return `true` when an advisory's GHSA ID is present in the allow-set. + * @param {{ github_advisory_id?: string }} advisory Advisory to check. @param {Set} allowed Set of permitted advisory IDs. @returns {boolean} + */ +function isExpectedAdvisory(advisory, allowed) { + const id = advisory.github_advisory_id; + return Boolean(id) && allowed.has(id); +} + /** Split advisories into allowed and unexpected groups. * @param {Array<{ github_advisory_id?: string }>} advisories Advisories to partition. @param {Iterable} allowedIds Advisory IDs the caller expects. * @returns {{ expected: typeof advisories, unexpected: typeof advisories }} Partitioned advisories. @@ -363,8 +364,7 @@ export function partitionAdvisoriesById(advisories, allowedIds) { const expected = []; const unexpected = []; for (const advisory of advisories) { - const id = advisory.github_advisory_id; - if (id && allowed.has(id)) { + if (isExpectedAdvisory(advisory, allowed)) { expected.push(advisory); } else { unexpected.push(advisory); From b01c6027cb42c1acdf357b93c7b5450969aadbd1 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 17 Apr 2026 02:39:53 +0200 Subject: [PATCH 14/15] Reuse advisory object guard Replace the inline bulk advisory payload object check with the existing `isPlainAdvisoryObject` helper. This keeps the behaviour unchanged while reducing duplication in `normalizeBulkAdvisories`. Repo formatting, lint, and test gates all passed before commit. --- security/audit-utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/security/audit-utils.js b/security/audit-utils.js index fbf093e3d..b1518eef3 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -237,7 +237,7 @@ function addPackageAdvisories(packageName, packageAdvisories, advisories) { * @returns {Record} Deduplicated advisories keyed by GHSA identifier or package fallback. @example normalizeBulkAdvisories({ validator: [{ id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' }] }); // { 'GHSA-vghf-hv5q-vc2g': { github_advisory_id: 'GHSA-vghf-hv5q-vc2g', package_name: 'validator', id: 100000, url: 'https://github.com/advisories/GHSA-vghf-hv5q-vc2g' } } */ function normalizeBulkAdvisories(bulkPayload) { - if (typeof bulkPayload !== 'object' || bulkPayload === null || Array.isArray(bulkPayload)) { + if (!isPlainAdvisoryObject(bulkPayload)) { throw new TypeError('Invalid bulk advisory payload: expected an object keyed by package name.'); } const advisories = {}; From a755235a1806ff4fad1c59e7509d6a474b1843d4 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 17 Apr 2026 02:46:02 +0200 Subject: [PATCH 15/15] Normalize extracted advisory IDs Canonicalize GHSA IDs extracted from bulk advisory URLs so\nallowlist lookups are case-stable.\n\nAlso fix the async runAuditJson() JSDoc return type and add\noutcome-driven examples to the predicate helper documentation.\n\nGates passed:\n- make check-fmt\n- make lint\n- make test --- frontend-pwa/scripts/audit-utils.test.mjs | 6 +++--- security/audit-utils.js | 13 ++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index c97fabf89..83a2167da 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -206,7 +206,7 @@ describe('runAuditJson', () => { ); }); - it('preserves advisory ID casing from the bulk payload URL', async () => { + it('normalizes advisory IDs from the bulk payload URL to lowercase groups', async () => { setupRetiredPnpmAudit(); fetch.mockResolvedValueOnce({ ok: true, @@ -228,8 +228,8 @@ describe('runAuditJson', () => { const result = await runAuditJson(); expect(result.json.advisories).toEqual({ - 'GHSA-Vghf-HV5Q-vC2G': expect.objectContaining({ - [githubAdvisoryIdKey]: 'GHSA-Vghf-HV5Q-vC2G', + 'GHSA-vghf-hv5q-vc2g': expect.objectContaining({ + [githubAdvisoryIdKey]: 'GHSA-vghf-hv5q-vc2g', [packageNameKey]: 'validator', }), }); diff --git a/security/audit-utils.js b/security/audit-utils.js index b1518eef3..21e891cc5 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -152,6 +152,7 @@ function collectInstalledPackageVersions() { /** Return `true` when a raw registry string is a real URL and not a placeholder. * @param {string} value Trimmed registry string. @returns {boolean} + * @example isValidRegistryValue('https://registry.npmjs.org'); // true */ function isValidRegistryValue(value) { return Boolean(value) && value !== 'undefined' && value !== 'null'; } @@ -192,8 +193,12 @@ function extractGithubAdvisoryId(advisoryUrl) { if (typeof advisoryUrl !== 'string') { return undefined; } - const match = advisoryUrl.match(/GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i); - return match?.[0]; + const match = advisoryUrl.match(/GHSA-([0-9a-z]{4})-([0-9a-z]{4})-([0-9a-z]{4})/i); + if (!match) { + return undefined; + } + const [, first, second, third] = match; + return `GHSA-${first.toLowerCase()}-${second.toLowerCase()}-${third.toLowerCase()}`; } /** Derive the advisory key used to deduplicate bulk advisory responses. @@ -208,6 +213,7 @@ function deriveAdvisoryKey(packageName, advisory) { /** Return `true` when a value is a plain (non-array, non-null) object. * @param {unknown} value Value to test. @returns {boolean} + * @example isPlainAdvisoryObject({ id: 1 }); // true */ function isPlainAdvisoryObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -312,7 +318,7 @@ async function runBulkAdvisoryAudit() { } /** Run `pnpm audit --json`, falling back to the bulk advisory endpoint when needed. - * @returns {{ json: { advisories?: Record }, status: number }} Parsed audit output and pnpm exit status. @example const { json, status } = await runAuditJson(); console.log(status, Object.keys(json.advisories ?? {})); + * @returns {Promise<{ json: { advisories?: Record }, status: number }>} Parsed audit output and pnpm exit status. @example const { json, status } = await runAuditJson(); console.log(status, Object.keys(json.advisories ?? {})); */ export async function runAuditJson() { const result = spawnSync('pnpm', AUDIT_ARGS, { @@ -347,6 +353,7 @@ export function collectAdvisories(auditJson) { /** Return `true` when an advisory's GHSA ID is present in the allow-set. * @param {{ github_advisory_id?: string }} advisory Advisory to check. @param {Set} allowed Set of permitted advisory IDs. @returns {boolean} + * @example isExpectedAdvisory({ github_advisory_id: 'GHSA-vghf-hv5q-vc2g' }, new Set(['GHSA-vghf-hv5q-vc2g'])); // true */ function isExpectedAdvisory(advisory, allowed) { const id = advisory.github_advisory_id;