From 142d51440e90aca094d3b127fc59a3d0fee113ae Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 18 May 2026 21:18:26 +0200 Subject: [PATCH 01/30] Add Rust dependency audit to Makefile Split the audit target into frontend and Rust phases, and run `cargo audit` against the workspace lockfile. Install `cargo-audit` in CI before the audit gate so the target is available on runners. Update vulnerable frontend and Rust dependencies to patched releases. Keep the SQLx optional MySQL RSA advisory ignored because this workspace only enables PostgreSQL and RustSec has no fixed RSA release. --- .github/workflows/ci.yml | 3 + Cargo.lock | 147 ++++++++++++------------------- Makefile | 13 ++- backend/Cargo.toml | 14 +-- docs/repository-structure.md | 12 +-- package.json | 8 +- pnpm-lock.yaml | 164 ++++++++++++++++++++++++++++------- 7 files changed, 219 insertions(+), 142 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70ec4aa6..546f7c6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,6 +91,9 @@ jobs: mmdc --version bun --version + - name: Install cargo-audit + run: cargo binstall --no-confirm cargo-audit + - name: Audit run: make audit diff --git a/Cargo.lock b/Cargo.lock index d6162f1d..58c2fa00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,18 +795,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bb8" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89aabfae550a5c44b43ab941844ffcd2e993cb6900b342debf59e9ea74acdb8" -dependencies = [ - "async-trait", - "futures-util", - "parking_lot 0.12.4", - "tokio", -] - [[package]] name = "bb8" version = "0.9.1" @@ -825,7 +813,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1063effc7f6cf848bcbcc6e31b5962be75215835587d3109607c643d616f66" dependencies = [ - "bb8 0.9.1", + "bb8", "redis", ] @@ -926,9 +914,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytestring" @@ -1376,38 +1364,14 @@ dependencies = [ "cipher", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - [[package]] name = "darling" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.104", + "darling_core", + "darling_macro", ] [[package]] @@ -1424,24 +1388,13 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.104", -] - [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.21.3", + "darling_core", "quote", "syn 2.0.104", ] @@ -1473,12 +1426,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -1535,14 +1488,15 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "diesel" -version = "2.2.12" +version = "2.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229850a212cd9b84d4f0290ad9d294afc0ae70fccaa8949dbe8b43ffafa1e20c" +checksum = "9940fb8467a0a06312218ed384185cb8536aa10d8ec017d0ce7fad2c1bd882d5" dependencies = [ "bitflags 2.9.1", "byteorder", "chrono", "diesel_derives", + "downcast-rs", "itoa", "pq-sys", "serde_json", @@ -1551,14 +1505,15 @@ dependencies = [ [[package]] name = "diesel-async" -version = "0.5.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a307ac00f7c23f526a04a77761a0519b9f0eb2838ebf5b905a58580095bdcb" +checksum = "b95864e58597509106f1fddfe0600de7e589e1fddddd87f54eee0a49fd111bbc" dependencies = [ - "async-trait", - "bb8 0.8.6", + "bb8", "diesel", + "futures-core", "futures-util", + "pin-project-lite", "scoped-futures", "tokio", "tokio-postgres", @@ -1566,9 +1521,9 @@ dependencies = [ [[package]] name = "diesel_derives" -version = "2.2.7" +version = "2.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b96984c469425cb577bf6f17121ecb3e4fe1e81de5d8f780dd372802858d756" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", @@ -1579,9 +1534,9 @@ dependencies = [ [[package]] name = "diesel_migrations" -version = "2.2.0" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a73ce704bad4231f001bff3314d91dce4aba0770cee8b233991859abc15c1f6" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" dependencies = [ "diesel", "migrations_internals", @@ -1590,9 +1545,9 @@ dependencies = [ [[package]] name = "diesel_table_macro_syntax" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ "syn 2.0.104", ] @@ -1698,13 +1653,19 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dsl_auto_type" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling 0.20.11", + "darling", "either", "heck 0.5.0", "proc-macro2", @@ -3322,9 +3283,9 @@ dependencies = [ [[package]] name = "migrations_internals" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bda1634d70d5bd53553cf15dca9842a396e8c799982a3ad22998dc44d961f24" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" dependencies = [ "serde", "toml 0.9.8", @@ -3332,9 +3293,9 @@ dependencies = [ [[package]] name = "migrations_macros" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb161cc72176cb37aa47f1fc520d3ef02263d67d661f44f05d05a079e1237fd" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" dependencies = [ "migrations_internals", "proc-macro2", @@ -3520,9 +3481,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-format" @@ -4012,9 +3973,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4394,9 +4355,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.3", @@ -5142,9 +5103,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -5376,7 +5337,7 @@ version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.104", @@ -5885,9 +5846,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", @@ -5991,30 +5952,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", diff --git a/Makefile b/Makefile index ff8e1a81..64213f41 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ SHELL := bash BUN_PATH := $(HOME)/.bun/bin:$(PATH) +CARGO ?= cargo KUBE_VERSION ?= 1.31.0 export PATH := $(HOME)/.cargo/bin:$(HOME)/.bun/bin:$(HOME)/.local/bin:$(HOME)/go/bin:$(CURDIR)/node_modules/.bin:$(PATH) +CARGO_AUDIT_IGNORES := --ignore RUSTSEC-2023-0071 define ensure_tool @command -v $(1) >/dev/null 2>&1 || { \ @@ -40,9 +42,10 @@ OPENAPI_SPEC ?= spec/openapi.json # Place one consolidated PHONY declaration near the top of the file .PHONY: all clean be fe fe-build openapi gen docker-up docker-down fmt lint test test-rust test-frontend typecheck deps lockfile lint-specs \ - check-fmt markdownlint markdownlint-docs mermaid-lint nixie yamllint audit \ + check-fmt markdownlint markdownlint-docs mermaid-lint nixie yamllint audit audit-node rust-audit \ lint-rust lint-frontend lint-asyncapi lint-openapi lint-makefile lint-actions \ lint-architecture workspace-sync +.PHONY: audit audit-node rust-audit workspace-sync: ./scripts/sync_workspace_members.py @@ -208,11 +211,17 @@ $(NODE_MODULES_STAMP): $(PNPM_LOCK_FILE) package.json typecheck: deps ; for dir in $(TS_WORKSPACES); do $(call exec_or_bunx,tsc,--noEmit -p $$dir/tsconfig.json,typescript@$(TSC_VERSION)) || exit 1; done -audit: deps +audit: audit-node rust-audit + +audit-node: deps pnpm -r install pnpm -r --if-present run audit pnpm run audit:validate +rust-audit: + # RUSTSEC-2023-0071 is in SQLx's optional MySQL support; this workspace only enables PostgreSQL. + $(CARGO) audit --file Cargo.lock $(CARGO_AUDIT_IGNORES) + lockfile: pnpm install --lockfile-only git diff --exit-code pnpm-lock.yaml diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 51208501..2757f90f 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -37,9 +37,9 @@ apalis-postgres = "1.0.0-rc.6" sqlx = { version = "0.8", default-features = false, features = ["postgres", "runtime-tokio-rustls"] } # Diesel ORM with PostgreSQL and async support -diesel = { version = "2.2", features = ["postgres", "uuid", "chrono", "serde_json"] } -diesel-async = { version = "0.5", features = ["bb8", "postgres"] } -diesel_migrations = "2.2" +diesel = { version = "2.3", features = ["postgres", "uuid", "chrono", "serde_json"] } +diesel-async = { version = "0.8", features = ["bb8", "postgres"] } +diesel_migrations = "2.3" chrono = { version = "0.4", features = ["serde"] } mockable = { version = "0.3", features = ["clock"] } wildside-data = { git = "https://github.com/leynos/wildside-engine.git", package = "wildside-data", rev = "2db6cbfb8ec68b611bc74fef6504f044c377b851" } @@ -103,12 +103,12 @@ libc = "0.2" tempfile = "3" [[bin]] -name = "openapi-dump" -path = "src/bin/openapi_dump.rs" +name = "ingest-osm" +path = "src/bin/ingest_osm.rs" [[bin]] -name = "er-snapshots" -path = "src/bin/er_snapshots.rs" +name = "ingest-osm" +path = "src/bin/ingest_osm.rs" [[bin]] name = "ingest-osm" diff --git a/docs/repository-structure.md b/docs/repository-structure.md index dfad30fe..02f7491c 100644 --- a/docs/repository-structure.md +++ b/docs/repository-structure.md @@ -518,11 +518,13 @@ docker-down: cd deploy && docker compose down ``` -Use `make audit` to validate the audit exception allowlist against its schema -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. +Use `make audit` to audit frontend and Rust dependencies. The frontend audit +validates the audit exception allowlist against its schema 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. +The Rust audit runs `cargo audit` against the consolidated root `Cargo.lock`. +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/package.json b/package.json index a72f601a..7d52e116 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "devDependencies": { "@biomejs/biome": "^2.3.1", - "@mermaid-js/mermaid-cli": "^11.12.0", - "ajv": "^8.18.0", + "@mermaid-js/mermaid-cli": "^11.15.0", + "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "markdownlint-cli": "^0.45.0", + "markdownlint-cli": "^0.48.0", "puppeteer": "^23.11.1", "validator": "^13.15.23", "vite": "^7.3.2", @@ -42,8 +42,8 @@ "overrides": { "@isaacs/brace-expansion": "5.0.1", "brace-expansion@<5.0.6": "5.0.6", - "ajv": "8.18.0", "fast-uri": "3.1.2", + "ajv": "8.20.0", "glob": "11.1.0", "js-yaml": "4.1.1", "lodash": "4.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a3aca44..bfb5e630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: overrides: '@isaacs/brace-expansion': 5.0.1 brace-expansion@<5.0.6: 5.0.6 - ajv: 8.18.0 fast-uri: 3.1.2 + ajv: 8.20.0 glob: 11.1.0 js-yaml: 4.1.1 lodash: 4.18.1 @@ -42,17 +42,17 @@ importers: specifier: ^2.3.1 version: 2.3.1 '@mermaid-js/mermaid-cli': - specifier: ^11.12.0 - version: 11.12.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2)) + specifier: ^11.15.0 + version: 11.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2)) ajv: - specifier: 8.18.0 - version: 8.18.0 + specifier: 8.20.0 + version: 8.20.0 ajv-formats: specifier: ^3.0.1 - version: 3.0.1(ajv@8.18.0) + version: 3.0.1(ajv@8.20.0) markdownlint-cli: - specifier: ^0.45.0 - version: 0.45.0 + specifier: ^0.48.0 + version: 0.48.0 puppeteer: specifier: ^23.11.1 version: 23.11.1(typescript@5.9.2) @@ -493,6 +493,10 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@fortawesome/fontawesome-free@7.2.0': + resolution: {integrity: sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==} + engines: {node: '>=6'} + '@gerrit0/mini-shiki@3.21.0': resolution: {integrity: sha512-9PrsT5DjZA+w3lur/aOIx3FlDeHdyCEFlv9U+fmsVyjPZh61G5SYURQ/1ebe2U63KbDmI2V8IhIUegWb8hjOyg==} @@ -571,12 +575,22 @@ packages: peerDependencies: tslib: '2' - '@mermaid-js/mermaid-cli@11.12.0': - resolution: {integrity: sha512-a0swOS6PByXKi0dZnLQQIhbtUEu7ubc6bojmIqXqvUPq7mIJukCNEvVBTv6IAbuEWqB3Ti8QntupoGdz3ej+kg==} + '@mermaid-js/layout-elk@0.2.1': + resolution: {integrity: sha512-MX9jwhMyd5zDcFsYcl3duDUkKhjVRUCGEQrdCeNV5hCIR6+3FuDDbRbFmvVbAu15K1+juzsYGG+K8MDvCY1Amg==} + peerDependencies: + mermaid: 11.15.0 + + '@mermaid-js/layout-tidy-tree@0.2.2': + resolution: {integrity: sha512-8RmjDXjKJBxqTS1mICStm8zWRM45fSzs0SOrkp28+KsOGS2YEMFMVTwwRU8CsC6M1L+pDYZVjf1m9AC1c9Wndg==} + peerDependencies: + mermaid: 11.15.0 + + '@mermaid-js/mermaid-cli@11.15.0': + resolution: {integrity: sha512-rmz9ELKtmKQvRcYJGI2e509FK9yCBvmEVfHeRSYkleGqo6qqh8LFooxRPCqq04uVx3JHMp9g/vmM85gi/QFFlQ==} engines: {node: ^18.19 || >=20.0} hasBin: true peerDependencies: - puppeteer: ^23 + puppeteer: ^23 || ^24 '@mermaid-js/mermaid-zenuml@0.2.2': resolution: {integrity: sha512-sUjwk4NWUpy9uaHypYSIGJDks10ZaZo5CHH9lx9xcmyqv9w7yvd4vecUmlUQxmlHStYO+aqSkYKX5/gFjDfypw==} @@ -1087,7 +1101,7 @@ packages: ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: - ajv: 8.18.0 + ajv: 8.20.0 peerDependenciesMeta: ajv: optional: true @@ -1095,13 +1109,13 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: - ajv: 8.18.0 + ajv: 8.20.0 peerDependenciesMeta: ajv: optional: true - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} @@ -1343,14 +1357,14 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} - commander@14.0.0: - resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} - engines: {node: '>=20'} - commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1651,6 +1665,9 @@ packages: electron-to-chromium@1.5.215: resolution: {integrity: sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1811,6 +1828,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2166,10 +2187,19 @@ packages: engines: {node: '>=20'} hasBin: true + markdownlint-cli@0.48.0: + resolution: {integrity: sha512-NkZQNu2E0Q5qLEEHwWj674eYISTLD4jMHkBzDobujXd1kv+yCxi8jOaD/rZoQNW1FBBMMGQpuW5So8B51N/e0A==} + engines: {node: '>=20'} + hasBin: true + markdownlint@0.38.0: resolution: {integrity: sha512-xaSxkaU7wY/0852zGApM8LdlIfGCW8ETZ0Rr62IQtAnUMlMuifsg09vWJcNYeL4f0anvr8Vo4ZQar8jGpV0btQ==} engines: {node: '>=20'} + markdownlint@0.40.0: + resolution: {integrity: sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg==} + engines: {node: '>=20'} + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -2357,6 +2387,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + p-locate@6.0.0: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2775,6 +2809,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -3444,6 +3482,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@fortawesome/fontawesome-free@7.2.0': {} + '@gerrit0/mini-shiki@3.21.0': dependencies: '@shikijs/engine-oniguruma': 3.21.0 @@ -3537,14 +3577,32 @@ snapshots: '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) tslib: 2.8.1 - '@mermaid-js/mermaid-cli@11.12.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2))': + '@mermaid-js/layout-elk@0.2.1(mermaid@11.15.0)': dependencies: + d3: 7.9.0 + elkjs: 0.9.3 + mermaid: 11.15.0 + + '@mermaid-js/layout-tidy-tree@0.2.2(mermaid@11.15.0)': + dependencies: + d3: 7.9.0 + mermaid: 11.15.0 + optional: true + + '@mermaid-js/mermaid-cli@11.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(puppeteer@23.11.1(typescript@5.9.2))': + dependencies: + '@fortawesome/fontawesome-free': 7.2.0 + '@mermaid-js/layout-elk': 0.2.1(mermaid@11.15.0) '@mermaid-js/mermaid-zenuml': 0.2.2(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.24)(mermaid@11.15.0) chalk: 5.6.2 - commander: 14.0.0 + commander: 13.1.0 import-meta-resolve: 4.2.0 + katex: 0.16.47 mermaid: 11.15.0 + p-limit: 6.2.0 puppeteer: 23.11.1(typescript@5.9.2) + optionalDependencies: + '@mermaid-js/layout-tidy-tree': 0.2.2(mermaid@11.15.0) transitivePeerDependencies: - '@babel/core' - '@babel/template' @@ -3835,9 +3893,9 @@ snapshots: '@scalar/json-magic': 0.9.0 '@scalar/openapi-types': 0.5.3 '@scalar/openapi-upgrader': 0.1.7 - ajv: 8.18.0 - ajv-draft-04: 1.0.0(ajv@8.18.0) - ajv-formats: 3.0.1(ajv@8.18.0) + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-formats: 3.0.1(ajv@8.20.0) jsonpointer: 5.0.1 leven: 4.1.0 yaml: 2.8.3 @@ -4188,15 +4246,15 @@ snapshots: agent-base@7.1.4: {} - ajv-draft-04@1.0.0(ajv@8.18.0): + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: - ajv: 8.18.0 + ajv: 8.20.0 - ajv-formats@3.0.1(ajv@8.18.0): + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: - ajv: 8.18.0 + ajv: 8.20.0 - ajv@8.18.0: + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.2 @@ -4424,10 +4482,10 @@ snapshots: commander@13.1.0: {} - commander@14.0.0: {} - commander@14.0.2: {} + commander@14.0.3: {} + commander@4.1.1: {} commander@7.2.0: {} @@ -4737,6 +4795,8 @@ snapshots: electron-to-chromium@1.5.215: {} + elkjs@0.9.3: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -4913,6 +4973,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5238,6 +5300,23 @@ snapshots: transitivePeerDependencies: - supports-color + markdownlint-cli@0.48.0: + dependencies: + commander: 14.0.3 + deep-extend: 0.6.0 + ignore: 7.0.5 + js-yaml: 4.1.1 + jsonc-parser: 3.3.1 + jsonpointer: 5.0.1 + markdown-it: 14.1.1 + markdownlint: 0.40.0 + minimatch: 10.2.3 + run-con: 1.3.2 + smol-toml: 1.6.1 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + markdownlint@0.38.0: dependencies: micromark: 4.0.2 @@ -5251,6 +5330,20 @@ snapshots: transitivePeerDependencies: - supports-color + markdownlint@0.40.0: + dependencies: + micromark: 4.0.2 + micromark-core-commonmark: 2.0.3 + micromark-extension-directive: 4.0.0 + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-math: 3.1.0 + micromark-util-types: 2.0.2 + string-width: 8.1.0 + transitivePeerDependencies: + - supports-color + marked@16.4.2: {} marked@4.3.0: {} @@ -5570,6 +5663,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@6.0.0: dependencies: p-limit: 4.0.0 @@ -6029,6 +6126,11 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 From df5275b17385d252d9dfa463a7dc2123009e0897 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 20 May 2026 19:53:04 +0200 Subject: [PATCH 02/30] Update shared actions pin Point the CI Rust setup action at the current `leynos/shared-actions` default-branch commit so the workflow uses the latest shared action. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 546f7c6c..167a047a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: restore-keys: ${{ runner.os }}-bun- - name: Install Rust toolchain - uses: leynos/shared-actions/.github/actions/setup-rust@c2b856998a4438bfdaa71c90cde1b03044e5d260 + uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 - name: Install uv id: setup-uv From 1a6386802851ae8a7d48b5e2a71b74c92450e6ba Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 20 May 2026 22:06:06 +0200 Subject: [PATCH 03/30] Restore backend binary targets after rebase Restore the `openapi-dump` and `er-snapshots` binary entries that were collapsed into duplicate `ingest-osm` targets during conflict resolution. Keep the rebased manifest aligned with `origin/main` while preserving the audit branch changes. --- backend/Cargo.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2757f90f..cead0eec 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -103,12 +103,12 @@ libc = "0.2" tempfile = "3" [[bin]] -name = "ingest-osm" -path = "src/bin/ingest_osm.rs" +name = "openapi-dump" +path = "src/bin/openapi_dump.rs" [[bin]] -name = "ingest-osm" -path = "src/bin/ingest_osm.rs" +name = "er-snapshots" +path = "src/bin/er_snapshots.rs" [[bin]] name = "ingest-osm" From 2bab62ae00573b2571e3cd27a6a5a1293dee9fc3 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 23 May 2026 14:06:13 +0200 Subject: [PATCH 04/30] Document audit and Corepack setup Add `make audit` to the documented quality gates and note that the local setup expects Corepack to enable `pnpm`. This keeps the developer guide aligned with the repository-structure guidance. --- docs/developers-guide.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8d9f4dad..afd6c27d 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -23,6 +23,7 @@ All suites run through the same quality gateways: - `make check-fmt` - `make lint` +- `make audit` - `make test` ## Front-end development @@ -64,9 +65,13 @@ TypeScript, tokens, and documentation gates aligned: make deps make fmt make lint +make audit make test ``` +`make audit` checks frontend and Rust dependencies. It expects Corepack to be +enabled so `pnpm` is available locally and in CI. + The front-end package uses Bun-compatible workspace scripts, Vite `^7.3.2`, React 19, React DOM 18, TanStack Query 5, Tailwind CSS `^3`, DaisyUI `^4`, Zod 3, TypeScript 5, Vitest 3, and Orval 8. TanStack Router, Radix UI, From 67bc11c1f70667b22a31430d25a3a2032b3eb7d1 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 23 May 2026 14:18:54 +0200 Subject: [PATCH 05/30] Fix audit review findings Remove the redundant node audit install, add an explicit `cargo-audit` availability check, and pin the CI install to `cargo-audit@0.22.1`. Thread audit I/O and expiry-date dependencies through the audit helpers so fallible process and network work is explicit at the boundary. Add required `#[expect]` reasons and roadmap links for still-valid lint suppressions. Update the `qs` override to `6.15.2` and refresh the pnpm lockfile to clear the current `GHSA-q8mj-m7cp-5q26` audit violation. --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- backend/src/outbound/persistence/models.rs | 15 +++++++-- backend/tests/example_data_runs_bdd.rs | 8 ++++- package.json | 2 +- pnpm-lock.yaml | 10 +++--- security/audit-utils.js | 36 +++++++++++++--------- security/validate-audit.js | 4 +-- 8 files changed, 51 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 167a047a..be8c1940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: bun --version - name: Install cargo-audit - run: cargo binstall --no-confirm cargo-audit + run: cargo binstall --no-confirm cargo-audit@0.22.1 - name: Audit run: make audit diff --git a/Makefile b/Makefile index 64213f41..3c4d4534 100644 --- a/Makefile +++ b/Makefile @@ -214,11 +214,11 @@ typecheck: deps ; for dir in $(TS_WORKSPACES); do $(call exec_or_bunx,tsc,--noEm audit: audit-node rust-audit audit-node: deps - pnpm -r install pnpm -r --if-present run audit pnpm run audit:validate rust-audit: + @command -v cargo-audit >/dev/null 2>&1 || { echo "Error: cargo-audit is required. Install it with 'cargo binstall --no-confirm cargo-audit@0.22.1'."; exit 1; } # RUSTSEC-2023-0071 is in SQLx's optional MySQL support; this workspace only enables PostgreSQL. $(CARGO) audit --file Cargo.lock $(CARGO_AUDIT_IGNORES) diff --git a/backend/src/outbound/persistence/models.rs b/backend/src/outbound/persistence/models.rs index 9a047f7a..c7e3e65d 100644 --- a/backend/src/outbound/persistence/models.rs +++ b/backend/src/outbound/persistence/models.rs @@ -21,7 +21,10 @@ pub(crate) struct UserRow { pub id: Uuid, pub display_name: String, pub created_at: DateTime, - #[expect(dead_code, reason = "schema field for future audit trail support")] + #[expect( + dead_code, + reason = "schema field for future audit trail support tracked by docs/backend-roadmap.md 3.5.1" + )] pub updated_at: DateTime, } @@ -179,9 +182,15 @@ pub(crate) struct WalkSessionRow { pub primary_stats: serde_json::Value, pub secondary_stats: serde_json::Value, pub highlighted_poi_ids: Vec, - #[expect(dead_code, reason = "schema field for auditing support")] + #[expect( + dead_code, + reason = "schema field for auditing support tracked by docs/backend-roadmap.md 3.5.1" + )] pub created_at: DateTime, - #[expect(dead_code, reason = "schema field for auditing support")] + #[expect( + dead_code, + reason = "schema field for auditing support tracked by docs/backend-roadmap.md 3.5.1" + )] pub updated_at: DateTime, } diff --git a/backend/tests/example_data_runs_bdd.rs b/backend/tests/example_data_runs_bdd.rs index c8d93b0e..31e61e83 100644 --- a/backend/tests/example_data_runs_bdd.rs +++ b/backend/tests/example_data_runs_bdd.rs @@ -32,7 +32,13 @@ struct RuntimeHandle(Arc); /// The inner field is never read directly; it exists to keep the temporary /// database alive for the duration of the test. #[derive(Clone)] -struct DatabaseHandle(#[expect(dead_code)] Arc); +struct DatabaseHandle( + #[expect( + dead_code, + reason = "keeps the temporary database alive for roadmap 2.4.3 BDD coverage" + )] + Arc, +); /// Test world holding repository and test results. #[derive(Default, ScenarioState)] diff --git a/package.json b/package.json index 7d52e116..1ae345a2 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "minimatch": "10.2.3", "lodash-es": "4.18.1", "pino": "9.13.1", - "qs": "6.14.2", + "qs": "6.15.2", "rollup": "4.59.0", "basic-ftp": "5.3.1", "dompurify": "3.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfb5e630..39394b9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ overrides: minimatch: 10.2.3 lodash-es: 4.18.1 pino: 9.13.1 - qs: 6.14.2 + qs: 6.15.2 rollup: 4.59.0 basic-ftp: 5.3.1 dompurify: 3.4.0 @@ -2588,8 +2588,8 @@ packages: deprecated: < 24.15.0 is no longer supported hasBin: true - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -5887,7 +5887,7 @@ snapshots: - typescript - utf-8-validate - qs@6.14.2: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -6339,7 +6339,7 @@ snapshots: url@0.11.4: dependencies: punycode: 1.4.1 - qs: 6.14.2 + qs: 6.15.2 use-sync-external-store@1.5.0(react@19.1.1): dependencies: diff --git a/security/audit-utils.js b/security/audit-utils.js index 21e891cc..c7311ed0 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -10,6 +10,13 @@ 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 defaultAuditIo = { + execFileSync, + fetch: (...args) => fetch(...args), + setTimeout, + clearTimeout, + spawnSync, +}; /** 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. @@ -126,8 +133,8 @@ function buildVersionMap(packageTrees) { /** 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', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'] }); +function collectInstalledPackageVersions(auditIo = defaultAuditIo) { + const result = auditIo.spawnSync('pnpm', LIST_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'] }); if (result.error) { throw result.error; } @@ -169,14 +176,14 @@ function normalizeRegistryUrl(rawRegistry) { /** 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() { +function readRegistryUrl(auditIo = defaultAuditIo) { const envRegistry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY; if (envRegistry) { return normalizeRegistryUrl(envRegistry); } try { return normalizeRegistryUrl( - execFileSync('pnpm', ['config', 'get', 'registry'], { + auditIo.execFileSync('pnpm', ['config', 'get', 'registry'], { encoding: 'utf8', }), ); @@ -262,11 +269,11 @@ function normalizeBulkAdvisories(bulkPayload) { * @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) { +async function fetchBulkAdvisories(endpoint, packageVersions, auditIo = defaultAuditIo) { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), BULK_AUDIT_TIMEOUT_MS); + const timeoutId = auditIo.setTimeout(() => controller.abort(), BULK_AUDIT_TIMEOUT_MS); try { - const response = await fetch(endpoint, { + const response = await auditIo.fetch(endpoint, { method: 'POST', headers: { accept: 'application/json', @@ -283,7 +290,7 @@ async function fetchBulkAdvisories(endpoint, packageVersions) { } throw error; } finally { - clearTimeout(timeoutId); + auditIo.clearTimeout(timeoutId); } } @@ -298,12 +305,13 @@ function toAdvisoryResult(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(); +async function runBulkAdvisoryAudit(auditIo = defaultAuditIo) { + const registryUrl = readRegistryUrl(auditIo); const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); const { response, responseText } = await fetchBulkAdvisories( endpoint, - collectInstalledPackageVersions(), + collectInstalledPackageVersions(auditIo), + auditIo, ); if (!response.ok) { @@ -320,8 +328,8 @@ async function runBulkAdvisoryAudit() { /** Run `pnpm audit --json`, falling back to the bulk advisory endpoint when needed. * @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, { +export async function runAuditJson(auditIo = defaultAuditIo) { + const result = auditIo.spawnSync('pnpm', AUDIT_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'], @@ -337,7 +345,7 @@ export async function runAuditJson() { const json = parseJsonOutput(stdout, 'pnpm audit'); if (isRetiredAuditEndpoint(json)) { - return runBulkAdvisoryAudit(); + return runBulkAdvisoryAudit(auditIo); } return { json, status }; diff --git a/security/validate-audit.js b/security/validate-audit.js index 064e64fe..49ebd0d3 100644 --- a/security/validate-audit.js +++ b/security/validate-audit.js @@ -74,8 +74,8 @@ function assertValidSchema(entries) { * }, * ]); */ -function assertNoExpired(entries) { - const today = new Date().toISOString().slice(0, 10); +function assertNoExpired(entries, currentDate = new Date()) { + const today = currentDate.toISOString().slice(0, 10); const expired = entries.filter((e) => e.expiresAt < today); const inverted = entries.filter((e) => e.addedAt > e.expiresAt); if (expired.length > 0) { From bdcda959a3fecab1948c2f4beb4c3c7d49ed2f81 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 23 May 2026 17:32:09 +0200 Subject: [PATCH 06/30] Clarify audit prerequisites in developer guide Document that `make audit` covers both frontend and Rust dependency checks, and note the `cargo-audit` prerequisite alongside Corepack so the local setup matches the Makefile and CI flow. --- docs/developers-guide.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index afd6c27d..9b1e1896 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -70,7 +70,8 @@ make test ``` `make audit` checks frontend and Rust dependencies. It expects Corepack to be -enabled so `pnpm` is available locally and in CI. +enabled so `pnpm` is available locally and in CI, and it requires +`cargo-audit` for the Rust dependency check. The front-end package uses Bun-compatible workspace scripts, Vite `^7.3.2`, React 19, React DOM 18, TanStack Query 5, Tailwind CSS `^3`, DaisyUI `^4`, From 3cf72f37885ea0ddc40d00c1b9065af780637d1c Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 23 May 2026 17:37:49 +0200 Subject: [PATCH 07/30] Test Rust audit Makefile contract Add root Vitest coverage for the `rust-audit` target, including the `cargo-audit` availability guard, command shape, and configured RustSec ignore. Run architecture linting in CI and tighten the remaining lint expectation reasons with concrete roadmap references. --- .github/workflows/ci.yml | 3 ++ backend/src/inbound/http/schemas.rs | 6 +-- backend/tests/example_data_runs_bdd.rs | 2 +- scripts/makefile-audit.test.mjs | 68 ++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 scripts/makefile-audit.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be8c1940..d9ccfd6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,9 @@ jobs: whitaker --all -- --manifest-path Cargo.toml --workspace --all-targets --all-features whitaker --all -- --manifest-path backend/Cargo.toml --all-targets --all-features + - name: Architecture lint + run: make lint-architecture + - name: Install nextest uses: taiki-e/install-action@db22c42b5af88356329b9a8056bb2c2f026d5a10 with: diff --git a/backend/src/inbound/http/schemas.rs b/backend/src/inbound/http/schemas.rs index c1b5f3f9..f26b2203 100644 --- a/backend/src/inbound/http/schemas.rs +++ b/backend/src/inbound/http/schemas.rs @@ -46,7 +46,7 @@ pub enum ErrorCodeSchema { #[schema(as = crate::domain::Error)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1" )] pub struct ErrorSchema { /// Stable machine-readable error code. @@ -69,7 +69,7 @@ pub struct ErrorSchema { #[schema(as = crate::domain::User)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1" )] pub struct UserSchema { /// Stable user identifier. @@ -124,7 +124,7 @@ pub struct InterestThemeIdSchema(pub String); #[schema(as = crate::domain::UserInterests)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1" )] pub struct UserInterestsSchema { /// Stable user identifier. diff --git a/backend/tests/example_data_runs_bdd.rs b/backend/tests/example_data_runs_bdd.rs index 31e61e83..cb6176cd 100644 --- a/backend/tests/example_data_runs_bdd.rs +++ b/backend/tests/example_data_runs_bdd.rs @@ -35,7 +35,7 @@ struct RuntimeHandle(Arc); struct DatabaseHandle( #[expect( dead_code, - reason = "keeps the temporary database alive for roadmap 2.4.3 BDD coverage" + reason = "keeps the temporary database alive for docs/backend-roadmap.md 2.4.3 BDD coverage" )] Arc, ); diff --git a/scripts/makefile-audit.test.mjs b/scripts/makefile-audit.test.mjs new file mode 100644 index 00000000..09db3e4a --- /dev/null +++ b/scripts/makefile-audit.test.mjs @@ -0,0 +1,68 @@ +/** @file Tests the Makefile audit target contracts. */ + +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; + +const makefilePath = new URL('../Makefile', import.meta.url); + +/** + * Read the repository Makefile for contract checks. + * + * @returns {Promise} The Makefile source. + */ +async function readMakefile() { + return readFile(makefilePath, 'utf8'); +} + +/** + * Extract a Make target recipe body from Makefile source. + * + * @param {string} source - The Makefile source. + * @param {string} target - The target name to extract. + * @returns {string} The target recipe body. + */ +function extractTarget(source, target) { + const match = source.match( + new RegExp(`^${target}:[^\\n]*(?:\\n\\t[^\\n]*)*`, 'm'), + ); + return match?.[0] ?? ''; +} + +describe('Makefile audit targets', () => { + it('wires the aggregate audit target through node and Rust audits', async () => { + const makefile = await readMakefile(); + + expect(makefile).toMatch(/^audit: audit-node rust-audit$/m); + }); + + it('does not reinstall node dependencies inside audit-node', async () => { + const makefile = await readMakefile(); + const target = extractTarget(makefile, 'audit-node'); + + expect(target).toContain('audit-node: deps'); + expect(target).toContain('pnpm -r --if-present run audit'); + expect(target).toContain('pnpm run audit:validate'); + expect(target).not.toContain('pnpm -r install'); + }); + + it('checks cargo-audit availability before running the Rust audit', async () => { + const makefile = await readMakefile(); + const target = extractTarget(makefile, 'rust-audit'); + + expect(target).toContain('command -v cargo-audit'); + expect(target).toContain('cargo-audit is required'); + expect(target).toContain('cargo-audit@0.22.1'); + }); + + it('runs cargo audit against Cargo.lock with the configured ignores', async () => { + const makefile = await readMakefile(); + const target = extractTarget(makefile, 'rust-audit'); + + expect(makefile).toMatch( + /^CARGO_AUDIT_IGNORES := --ignore RUSTSEC-2023-0071$/m, + ); + expect(target).toContain( + '$(CARGO) audit --file Cargo.lock $(CARGO_AUDIT_IGNORES)', + ); + }); +}); From 44be5a35c5240b8598d9edb59594e8a16beb99ca Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 24 May 2026 04:09:21 +0200 Subject: [PATCH 08/30] Stabilize embedded PostgreSQL test setup Bump `pg-embed-setup-unpriv` to `0.5.1` and refresh `Cargo.lock` so the worker subprocess receives the parent environment in CI. Pin the embedded PostgreSQL runtime version in the Rust test environment to avoid release-listing requests during archive resolution. Extract audit reporting helpers from `security/audit-utils.js` so the shared audit utility module stays below the repository file-size limit. --- .github/workflows/ci.yml | 3 + Cargo.lock | 903 ++++++++++++++++++++++-------------- backend/Cargo.toml | 2 +- security/audit-reporting.js | 56 +++ security/audit-utils.js | 60 +-- 5 files changed, 612 insertions(+), 412 deletions(-) create mode 100644 security/audit-reporting.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ccfd6e..4229c332 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,9 @@ jobs: env: # Increase GitHub API rate limits for postgresql_embedded downloads. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Pin the embedded PostgreSQL version so postgresql_archive skips release-listing queries. + # Use the semver "exact" prefix so no wildcard resolution is attempted. + POSTGRESQL_VERSION: "=16.10.0" # Keep backend selection explicit for strict PG_TEST_BACKEND validation. PG_TEST_BACKEND: postgresql_embedded # Root-path bootstrap requires the worker binary for privilege demotion. diff --git a/Cargo.lock b/Cargo.lock index 58c2fa00..feb9f2cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.9.1", + "bitflags", "bytes", "futures-core", "futures-sink", @@ -30,7 +30,7 @@ dependencies = [ "actix-service", "actix-utils", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags", "brotli", "bytes", "bytestring", @@ -39,7 +39,7 @@ dependencies = [ "flate2", "foldhash 0.1.5", "futures-core", - "h2", + "h2 0.3.27", "http 0.2.12", "httparse", "httpdate", @@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -219,7 +219,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -284,7 +284,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -410,9 +410,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "apalis-codec" @@ -438,7 +438,7 @@ dependencies = [ "futures-util", "pin-project", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tower-layer", "tower-service", "tracing", @@ -458,7 +458,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "ulid", ] @@ -473,7 +473,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -509,7 +509,7 @@ dependencies = [ "rstest", "rstest-bdd 0.4.0", "rstest-bdd-macros 0.4.0", - "syn 2.0.104", + "syn 2.0.117", "tempfile", ] @@ -578,7 +578,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -639,7 +639,7 @@ dependencies = [ "derive_more 2.0.1", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "itoa", "log", @@ -695,7 +695,7 @@ dependencies = [ "postgresql_embedded", "prometheus", "rand 0.8.5", - "reqwest", + "reqwest 0.12.24", "rstest", "rstest-bdd 0.5.0", "rstest-bdd-macros 0.5.0", @@ -704,7 +704,7 @@ dependencies = [ "sha2", "sqlx", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -802,7 +802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" dependencies = [ "futures-util", - "parking_lot 0.12.4", + "parking_lot", "portable-atomic", "tokio", ] @@ -826,12 +826,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.9.1" @@ -861,7 +855,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -948,7 +942,7 @@ dependencies = [ "io-lifetimes 2.0.4", "ipnet", "maybe-owned", - "rustix 1.1.2", + "rustix 1.1.4", "rustix-linux-procfs", "windows-sys 0.59.0", "winx", @@ -956,9 +950,9 @@ dependencies = [ [[package]] name = "cap-primitives" -version = "4.0.0" +version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2738131575dbd2ca9e8e144a102ba4e534ca726e2446f1e92090a1cc08fe283" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" dependencies = [ "ambient-authority", "fs-set-times", @@ -966,7 +960,7 @@ dependencies = [ "io-lifetimes 3.0.1", "ipnet", "maybe-owned", - "rustix 1.1.2", + "rustix 1.1.4", "rustix-linux-procfs", "windows-sys 0.61.2", "winx", @@ -982,19 +976,19 @@ dependencies = [ "cap-primitives 3.4.5", "io-extras 0.18.4", "io-lifetimes 2.0.4", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] name = "cap-std" -version = "4.0.0" +version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d9210de501a941735b489ee5c41501283259ddf3fec9884ace40def0a80a11" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" dependencies = [ - "cap-primitives 4.0.0", + "cap-primitives 4.0.2", "io-extras 0.19.0", "io-lifetimes 3.0.1", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -1020,6 +1014,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.42" @@ -1063,7 +1068,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1087,7 +1092,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1243,6 +1248,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -1313,13 +1327,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.9.1", + "bitflags", "crossterm_winapi", "derive_more 2.0.1", "document-features", "mio", - "parking_lot 0.12.4", - "rustix 1.1.2", + "parking_lot", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -1352,7 +1366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1385,7 +1399,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1396,7 +1410,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1410,7 +1424,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core 0.9.11", + "parking_lot_core", ] [[package]] @@ -1442,7 +1456,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1455,7 +1469,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1476,7 +1490,7 @@ dependencies = [ "convert_case 0.7.1", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "unicode-xid", ] @@ -1492,7 +1506,7 @@ version = "2.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9940fb8467a0a06312218ed384185cb8536aa10d8ec017d0ce7fad2c1bd882d5" dependencies = [ - "bitflags 2.9.1", + "bitflags", "byteorder", "chrono", "diesel_derives", @@ -1529,7 +1543,7 @@ dependencies = [ "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1549,7 +1563,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1629,7 +1643,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1670,7 +1684,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -1779,7 +1793,7 @@ version = "0.1.0" dependencies = [ "base-d", "camino", - "cap-std 4.0.0", + "cap-std 4.0.2", "fake", "rand 0.9.2", "rand_chacha 0.9.0", @@ -1788,7 +1802,7 @@ dependencies = [ "rstest-bdd-macros 0.5.0", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "uuid", ] @@ -1844,7 +1858,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ "atomic", - "parking_lot 0.12.4", + "parking_lot", "pear", "serde", "serde_yaml", @@ -1887,13 +1901,13 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", - "libz-rs-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1962,7 +1976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" dependencies = [ "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2031,7 +2045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes 2.0.4", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.59.0", ] @@ -2052,9 +2066,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2062,9 +2076,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" @@ -2085,37 +2099,37 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot 0.12.4", + "parking_lot", ] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -2125,9 +2139,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2137,7 +2151,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -2236,11 +2249,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.2+wasi-0.2.4", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -2262,7 +2289,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.104", + "syn 2.0.117", "textwrap", "thiserror 1.0.69", "typed-builder", @@ -2299,6 +2326,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "halfbrown" version = "0.4.0" @@ -2538,6 +2584,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2 0.4.14", "http 1.3.1", "http-body", "httparse", @@ -2633,7 +2680,7 @@ dependencies = [ "i18n-embed-impl", "intl-memoizer", "log", - "parking_lot 0.12.4", + "parking_lot", "rust-embed", "sys-locale", "thiserror 1.0.69", @@ -2650,7 +2697,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -2806,6 +2853,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2897,18 +2950,6 @@ dependencies = [ "similar", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "intl-memoizer" version = "0.5.3" @@ -2969,33 +3010,12 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" -[[package]] -name = "iri-string" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3038,10 +3058,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -3072,7 +3094,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -3090,11 +3112,17 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.175" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -3108,9 +3136,9 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.1", + "bitflags", "libc", - "redox_syscall 0.5.17", + "redox_syscall", ] [[package]] @@ -3124,15 +3152,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-rs-sys" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" -dependencies = [ - "zlib-rs", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3141,9 +3160,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3325,6 +3344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -3400,7 +3420,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3432,7 +3452,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -3485,16 +3505,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-format" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" -dependencies = [ - "arrayvec", - "itoa", -] - [[package]] name = "num-integer" version = "0.1.46" @@ -3558,7 +3568,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.9.1", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -3575,7 +3585,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3631,7 +3641,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 0.9.8", "uncased", "xdg", @@ -3655,7 +3665,7 @@ dependencies = [ "ortho_config_macros 0.7.0", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "toml 0.9.8", "tracing", "uncased", @@ -3672,7 +3682,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3684,7 +3694,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3717,7 +3727,7 @@ dependencies = [ "rstest-bdd-macros 0.5.0", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "url", ] @@ -3727,17 +3737,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - [[package]] name = "parking_lot" version = "0.12.4" @@ -3745,21 +3744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", - "parking_lot_core 0.9.11", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", + "parking_lot_core", ] [[package]] @@ -3770,7 +3755,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.17", + "redox_syscall", "smallvec", "windows-targets 0.52.6", ] @@ -3807,7 +3792,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3859,7 +3844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.17", + "thiserror 2.0.18", "ucd-trie", ] @@ -3883,7 +3868,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -3898,12 +3883,12 @@ dependencies = [ [[package]] name = "pg-embed-setup-unpriv" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3331937f47bb304d7aa1c3337c4ac52fdd9db5063c399a3e4dad5ecec274b72" +checksum = "aa77812ed248cff4ab03feaec586c99343c47077e52981d0b18202b5687f425d" dependencies = [ "camino", - "cap-std 4.0.0", + "cap-std 4.0.2", "clap", "color-eyre", "dashmap", @@ -3924,7 +3909,7 @@ dependencies = [ "serde_with", "sha2", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "uncased", @@ -3968,7 +3953,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4017,7 +4002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4074,17 +4059,16 @@ dependencies = [ [[package]] name = "postgresql_archive" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e80b519badc77389e9bb48944da8b639eaddc0ab9bb0e921be233c11348f3655" +checksum = "422bfe2de3e0776c9129d431849985cded3bf67a694d9e93ed316c93c47c1367" dependencies = [ "async-trait", "flate2", "futures-util", "hex", - "num-format", "regex-lite", - "reqwest", + "reqwest 0.13.3", "reqwest-middleware", "reqwest-retry", "reqwest-tracing", @@ -4095,37 +4079,37 @@ dependencies = [ "tar", "target-triple", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", ] [[package]] name = "postgresql_commands" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20698f9f0fddfa20bb0f8db60c47c7c1996b781c8e4bc2d09182d6cab66da25c" +checksum = "446b4cef09bfbe79023089b63505f3e5c87854cf495fd608579df2a80bdde9e0" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] [[package]] name = "postgresql_embedded" -version = "0.20.0" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c73524453412018db32b4929cc0b58ecb2d04cff679e9d397ab32880fbe7a0ca" +checksum = "78b32842157348a60f4c600603ed16dd5e3d30b159dabf70a60a9d44934f80a7" dependencies = [ "anyhow", "postgresql_archive", "postgresql_commands", - "rand 0.9.2", + "rand 0.10.1", "semver", "sqlx", "target-triple", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", @@ -4206,6 +4190,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "3.3.0" @@ -4262,7 +4256,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "version_check", "yansi", ] @@ -4277,9 +4271,9 @@ dependencies = [ "fnv", "lazy_static", "memchr", - "parking_lot 0.12.4", + "parking_lot", "protobuf", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4347,7 +4341,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.0", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -4368,7 +4362,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4403,6 +4397,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -4424,6 +4424,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4462,6 +4473,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rayon" version = "1.11.0" @@ -4505,22 +4522,13 @@ dependencies = [ "xxhash-rust", ] -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags", ] [[package]] @@ -4542,7 +4550,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4562,7 +4570,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -4590,9 +4598,9 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" @@ -4621,11 +4629,9 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4636,7 +4642,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -4645,31 +4650,72 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 1.0.6", ] +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2 0.4.14", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + [[package]] name = "reqwest-middleware" -version = "0.4.2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", "http 1.3.1", - "reqwest", + "reqwest 0.13.3", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", "tower-service", ] [[package]] name = "reqwest-retry" -version = "0.7.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c73e4195a6bfbcb174b790d9b3407ab90646976c55de58a6515da25d851178" +checksum = "fe2412db2af7d2268e7a5406be0431f37d9eb67ff390f35b395716f5f06c2eaa" dependencies = [ "anyhow", "async-trait", @@ -4677,39 +4723,38 @@ dependencies = [ "getrandom 0.2.16", "http 1.3.1", "hyper", - "parking_lot 0.11.2", - "reqwest", + "reqwest 0.13.3", "reqwest-middleware", "retry-policies", - "thiserror 1.0.69", + "thiserror 2.0.18", "tokio", "tracing", - "wasm-timer", + "wasmtimer", ] [[package]] name = "reqwest-tracing" -version = "0.5.8" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d70ea85f131b2ee9874f0b160ac5976f8af75f3c9badfe0d955880257d10bd83" +checksum = "b5e5af0cd6fc3d3c8f703d597af70b6e4e62432c63157b49419fa1ffaf481702" dependencies = [ "anyhow", "async-trait", "getrandom 0.2.16", "http 1.3.1", "matchit", - "reqwest", + "reqwest 0.13.3", "reqwest-middleware", "tracing", ] [[package]] name = "retry-policies" -version = "0.4.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5875471e6cab2871bc150ecb8c727db5113c9338cc3354dc5ee3425b6aa40a1c" +checksum = "dc05fbf560421a0357a750cbe78c7ca19d4923918490daabba313d5dbc871e47" dependencies = [ - "rand 0.8.5", + "rand 0.10.1", ] [[package]] @@ -4891,7 +4936,7 @@ dependencies = [ "regex", "rstest-bdd-patterns 0.4.0", "rstest-bdd-policy 0.4.0", - "syn 2.0.104", + "syn 2.0.117", "thiserror 1.0.69", "walkdir", ] @@ -4915,7 +4960,7 @@ dependencies = [ "regex", "rstest-bdd-patterns 0.5.0", "rstest-bdd-policy 0.5.0", - "syn 2.0.104", + "syn 2.0.117", "thiserror 1.0.69", "walkdir", ] @@ -4968,7 +5013,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.104", + "syn 2.0.117", "unicode-ident", ] @@ -4978,7 +5023,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.9.1", + "bitflags", "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink 0.9.1", @@ -5006,7 +5051,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.104", + "syn 2.0.117", "walkdir", ] @@ -5047,7 +5092,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5056,14 +5101,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.1", + "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5074,7 +5119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -5197,7 +5242,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -5222,9 +5267,9 @@ checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" [[package]] name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -5253,19 +5298,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -5340,7 +5386,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5363,7 +5409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5374,7 +5420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5606,7 +5652,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -5624,7 +5670,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5647,7 +5693,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.104", + "syn 2.0.117", "tokio", "url", ] @@ -5660,7 +5706,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags", "byteorder", "bytes", "chrono", @@ -5690,7 +5736,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "whoami", ] @@ -5703,7 +5749,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.9.1", + "bitflags", "byteorder", "chrono", "crc", @@ -5728,7 +5774,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "whoami", ] @@ -5753,7 +5799,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", ] @@ -5806,9 +5852,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -5832,7 +5878,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -5857,20 +5903,20 @@ dependencies = [ [[package]] name = "target-triple" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5880,7 +5926,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.4", "windows-sys 0.60.2", ] @@ -5912,11 +5958,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -5927,18 +5973,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6008,33 +6054,30 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", - "parking_lot 0.12.4", + "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2 0.6.0", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6060,7 +6103,7 @@ dependencies = [ "futures-channel", "futures-util", "log", - "parking_lot 0.12.4", + "parking_lot", "percent-encoding", "phf", "pin-project-lite", @@ -6213,20 +6256,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.9.1", + "bitflags", "bytes", "futures-util", "http 1.3.1", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -6243,9 +6286,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -6255,20 +6298,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -6364,7 +6407,7 @@ checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6439,7 +6482,7 @@ checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" dependencies = [ "proc-macro-hack", "quote", - "syn 2.0.104", + "syn 2.0.117", "unic-langid-impl", ] @@ -6536,9 +6579,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -6580,7 +6623,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.104", + "syn 2.0.117", "uuid", ] @@ -6694,6 +6737,24 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasite" version = "0.1.0" @@ -6702,48 +6763,32 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.104", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6751,26 +6796,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.104", - "wasm-bindgen-backend", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -6785,25 +6852,49 @@ dependencies = [ ] [[package]] -name = "wasm-timer" -version = "0.2.5" +name = "wasm-streams" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ - "futures", + "futures-util", "js-sys", - "parking_lot 0.11.2", - "pin-utils", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.12.0", + "semver", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -6899,7 +6990,7 @@ dependencies = [ "geo", "log", "osmpbf", - "reqwest", + "reqwest 0.12.24", "rusqlite", "serde", "serde_json", @@ -6975,7 +7066,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -6986,7 +7077,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7259,17 +7350,111 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.9.1", + "bitflags", "windows-sys 0.59.0", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.12.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.12.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] @@ -7285,7 +7470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.2", + "rustix 1.1.4", ] [[package]] @@ -7335,7 +7520,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "synstructure", ] @@ -7356,7 +7541,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7376,7 +7561,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", "synstructure", ] @@ -7416,7 +7601,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.117", ] [[package]] @@ -7435,9 +7620,15 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + +[[package]] +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zopfli" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index cead0eec..eec787a8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -22,7 +22,7 @@ clap = { version = "4", features = ["derive"] } async-trait = "0.1.89" color-eyre = "0.6.5" -pg_embedded_setup_unpriv = { package = "pg-embed-setup-unpriv", version = "0.5.0" } +pg_embedded_setup_unpriv = { package = "pg-embed-setup-unpriv", version = "0.5.1" } postgresql_embedded = { version = "0.20.0", features = ["tokio"] } postgres = { version = "0.19.12", features = ["with-uuid-1"] } paste = "1.0.15" diff --git a/security/audit-reporting.js b/security/audit-reporting.js new file mode 100644 index 00000000..f027f9fe --- /dev/null +++ b/security/audit-reporting.js @@ -0,0 +1,56 @@ +/** @file Reporting helpers for dependency audit advisory output. */ + +/** 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; + 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. + * @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); + const expected = []; + const unexpected = []; + for (const advisory of advisories) { + if (isExpectedAdvisory(advisory, allowed)) { + expected.push(advisory); + } else { + unexpected.push(advisory); + } + } + + 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. @example formatAdvisoryLine({ github_advisory_id: 'GHSA-1', title: 'Example' }); // "- GHSA-1: Example" + */ +function formatAdvisoryLine(advisory) { + const id = advisory.github_advisory_id ?? 'UNKNOWN'; + const suffix = advisory.title ? `: ${advisory.title}` : ''; + 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 + */ +export function reportUnexpectedAdvisories(unexpected, heading) { + if (unexpected.length === 0) { + return false; + } + + console.error(heading); + for (const advisory of unexpected) { + console.error(formatAdvisoryLine(advisory)); + } + return true; +} diff --git a/security/audit-utils.js b/security/audit-utils.js index c7311ed0..a6bc68b3 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -2,6 +2,11 @@ import { execFileSync, spawnSync } from 'node:child_process'; +export { + partitionAdvisoriesById, + reportUnexpectedAdvisories, +} from './audit-reporting.js'; + const AUDIT_ARGS = ['audit', '--json']; const LIST_ARGS = ['ls', '--json', '--depth', 'Infinity']; const BULK_ADVISORY_PATH = '-/npm/v1/security/advisories/bulk'; @@ -358,58 +363,3 @@ export async function runAuditJson(auditIo = defaultAuditIo) { 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} - * @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; - 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. - * @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); - const expected = []; - const unexpected = []; - for (const advisory of advisories) { - if (isExpectedAdvisory(advisory, allowed)) { - expected.push(advisory); - } else { - unexpected.push(advisory); - } - } - - 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. @example formatAdvisoryLine({ github_advisory_id: 'GHSA-1', title: 'Example' }); // "- GHSA-1: Example" - */ -function formatAdvisoryLine(advisory) { - const id = advisory.github_advisory_id ?? 'UNKNOWN'; - const suffix = advisory.title ? `: ${advisory.title}` : ''; - 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 - */ -export function reportUnexpectedAdvisories(unexpected, heading) { - if (unexpected.length === 0) { - return false; - } - - console.error(heading); - for (const advisory of unexpected) { - console.error(formatAdvisoryLine(advisory)); - } - return true; -} From 50dcfcdf6da3a8e08fa4a9a2f0c6bdc0d9a0cdf1 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 24 May 2026 23:45:54 +0200 Subject: [PATCH 09/30] Synchronize developer guide audit gates Add `make audit` to the remaining developer-guide commit-gate references so the required command lists match the repository-wide quality gate set. --- docs/developers-guide.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 9b1e1896..d66510db 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -153,6 +153,7 @@ front-end gates plus the repository-wide commit gates: ```bash make check-fmt make lint +make audit make test ``` @@ -273,8 +274,8 @@ When adding a new behaviour: `#[then]`. 3. Add or update the scenario binding function with `#[scenario(...)]`. 4. Keep fixture naming consistent across scenario binding and step functions. -5. Run all three gates before commit: - `make check-fmt`, `make lint`, and `make test`. +5. Run all commit gates before commit: + `make check-fmt`, `make lint`, `make audit`, and `make test`. When migrating existing suites, prefer incremental edits that preserve scenario intent and avoid broad rewrites that obscure regressions. @@ -391,10 +392,11 @@ When adding a new shared workspace crate: 9. Ensure all test files stay under 400 lines; split by feature when needed. 10. Run the repository quality gates before committing: `make check-fmt` - to verify formatting, `make lint` to verify linting, and `make test` to - run the test suites. Documentation build and validation is performed - separately via the `RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo doc -p - --no-deps` command described in step 4 above. + to verify formatting, `make lint` to verify linting, `make audit` to + verify dependency audits, and `make test` to run the test suites. + Documentation build and validation is performed separately via the + `RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo doc -p + --no-deps` command described in step 4 above. ## Redis cache adapter testing From e68cbfa784263919f1555c30257ef6fb94b84a7b Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 00:43:48 +0200 Subject: [PATCH 10/30] Handle signalled audit commands explicitly Reject signalled `pnpm audit` and `pnpm ls` runs instead of treating missing child-process statuses as success. Document the injected audit IO adapter on the affected helpers and expand the reporting module header to show its role in the audit-validation pipeline. --- frontend-pwa/scripts/audit-utils.test.mjs | 46 +++++++++++++++++-- security/audit-reporting.js | 10 +++- security/audit-utils.js | 56 ++++++++++++++++++----- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index 01ae619d..b74825fd 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -16,14 +16,15 @@ 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 {{ status?: number | null, stdout?: string, error?: Error | undefined, signal?: string | null }} [options={}] Result overrides. + * @param {number | null} [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. + * @param {string | null} [options.signal=null] Signal that terminated the process. + * @returns {{ error: Error | undefined, signal: string | null, status: number | null, stdout: string }} Mocked pnpm result object. */ -function createPnpmResult({ status = 0, stdout = '', error = undefined } = {}) { - return { error, status, stdout }; +function createPnpmResult({ status = 0, stdout = '', error = undefined, signal = null } = {}) { + return { error, signal, status, stdout }; } /** @@ -143,6 +144,41 @@ describe('runAuditJson', () => { expect(execFileSyncMock).not.toHaveBeenCalled(); }); + it.each([ + { + command: 'audit', + expectedError: 'pnpm audit was terminated by signal SIGTERM.', + firstResult: createPnpmResult({ status: null, signal: 'SIGTERM' }), + }, + { + command: 'ls', + expectedError: 'pnpm ls was terminated by signal SIGTERM.', + firstResult: 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."}', + }, + }), + }), + secondResult: createPnpmResult({ status: null, signal: 'SIGTERM' }), + }, + ])('throws when pnpm $command is signalled', async ({ + expectedError, + firstResult, + secondResult, + }) => { + spawnSyncMock.mockReturnValueOnce(firstResult); + if (secondResult) { + spawnSyncMock.mockReturnValueOnce(secondResult); + } + const { runAuditJson } = await loadAuditUtils(); + + await expect(runAuditJson()).rejects.toThrow(expectedError); + }); + it('falls back to the bulk advisory endpoint when pnpm audit hits the retired endpoint', async () => { setupRetiredPnpmAudit([ { diff --git a/security/audit-reporting.js b/security/audit-reporting.js index f027f9fe..a4d60bef 100644 --- a/security/audit-reporting.js +++ b/security/audit-reporting.js @@ -1,4 +1,12 @@ -/** @file Reporting helpers for dependency audit advisory output. */ +/** + * @file Owns advisory partitioning and stderr formatting for audit validation. + * + * Exports `partitionAdvisoriesById` for splitting expected and unexpected + * advisories, and `reportUnexpectedAdvisories` for writing validation failures + * to stderr. `security/audit-utils.js` re-exports these helpers so callers can + * use one audit utility surface while the reporting step stays isolated in the + * dependency-auditing pipeline. + */ /** 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} diff --git a/security/audit-utils.js b/security/audit-utils.js index a6bc68b3..952d1ee2 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -135,15 +135,37 @@ function buildVersionMap(packageTrees) { 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'] } +/** Ensure a child-process result exited normally. + * @param {{ error?: Error, signal?: string | null, status?: number | null }} result Spawn result from an audit command. + * @param {string} commandLabel Label used in thrown errors. + * @returns {number} Process exit status. + * @example assertCompletedProcess({ status: 0, signal: null }, 'pnpm audit'); // 0 */ -function collectInstalledPackageVersions(auditIo = defaultAuditIo) { - const result = auditIo.spawnSync('pnpm', LIST_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'] }); +function assertCompletedProcess(result, commandLabel) { if (result.error) { throw result.error; } - const status = result.status ?? 0; + if (result.signal) { + throw new Error(`${commandLabel} was terminated by signal ${result.signal}.`); + } + if (result.status === null) { + throw new Error(`${commandLabel} was terminated before reporting an exit status.`); + } + return result.status; +} + +/** Collect installed package versions from `pnpm ls` for bulk advisory lookups. + * Throws when `pnpm ls` fails, is signalled, or returns invalid JSON. + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. + * @returns {Record} Sorted installed versions keyed by package name. + * @example // Normal usage with `pnpm ls` returning one installed validator version: + * collectInstalledPackageVersions(); // { validator: ['13.15.23'] } + * @example const auditIo = { ...defaultAuditIo, spawnSync: () => ({ status: 0, stdout: '[{"dependencies":{"validator":{"version":"13.15.23"}}}]' }) }; + * collectInstalledPackageVersions(auditIo); // { validator: ['13.15.23'] } + */ +function collectInstalledPackageVersions(auditIo = defaultAuditIo) { + const result = auditIo.spawnSync('pnpm', LIST_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'] }); + const status = assertCompletedProcess(result, 'pnpm ls'); if (status !== 0) { throw new Error(`pnpm ls failed without producing a dependency tree (exit status ${status}).`); } @@ -179,7 +201,10 @@ function normalizeRegistryUrl(rawRegistry) { } /** 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/' + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. + * @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/' + * @example const auditIo = { ...defaultAuditIo, execFileSync: () => 'https://registry.npmjs.org\n' }; readRegistryUrl(auditIo); // 'https://registry.npmjs.org/' */ function readRegistryUrl(auditIo = defaultAuditIo) { const envRegistry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY; @@ -271,8 +296,10 @@ 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. + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. * @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); // '{}' + * @example const auditIo = { ...defaultAuditIo, fetch: async () => ({ text: async () => '{}' }), setTimeout: () => 1, clearTimeout: () => undefined }; await fetchBulkAdvisories(new URL('https://registry.npmjs.org/-/npm/v1/security/advisories/bulk'), {}, auditIo); // { response: ..., responseText: '{}' } */ async function fetchBulkAdvisories(endpoint, packageVersions, auditIo = defaultAuditIo) { const controller = new AbortController(); @@ -308,7 +335,11 @@ function toAdvisoryResult(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 } + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. + * @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 } + * @example const auditIo = { ...defaultAuditIo, spawnSync: () => ({ status: 0, stdout: '[{"dependencies":{}}]' }), fetch: async () => ({ ok: true, text: async () => '{}' }) }; await runBulkAdvisoryAudit(auditIo); // { json: { advisories: {} }, status: 0 } */ async function runBulkAdvisoryAudit(auditIo = defaultAuditIo) { const registryUrl = readRegistryUrl(auditIo); @@ -331,7 +362,11 @@ async function runBulkAdvisoryAudit(auditIo = defaultAuditIo) { } /** Run `pnpm audit --json`, falling back to the bulk advisory endpoint when needed. - * @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 ?? {})); + * Throws when `pnpm audit` fails to start or is signalled. + * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. + * @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 ?? {})); + * @example const auditIo = { ...defaultAuditIo, spawnSync: () => ({ status: 0, stdout: '{"advisories":{}}' }) }; await runAuditJson(auditIo); // { json: { advisories: {} }, status: 0 } */ export async function runAuditJson(auditIo = defaultAuditIo) { const result = auditIo.spawnSync('pnpm', AUDIT_ARGS, { @@ -339,10 +374,7 @@ export async function runAuditJson(auditIo = defaultAuditIo) { maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'], }); - if (result.error) { - throw result.error; - } - const status = result.status ?? 0; + const status = assertCompletedProcess(result, 'pnpm audit'); const stdout = result.stdout ? result.stdout.trim() : ''; if (!stdout) { return { json: { advisories: {} }, status }; From 4295827acee3856cd9622d6cc40a0cfec64c6164 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:23:21 +0200 Subject: [PATCH 11/30] Expand audit utility documentation Add the missing `createPnpmResult` example and replace the `audit-utils` module header with a concise description of its audit pipeline responsibilities and IO boundary. --- frontend-pwa/scripts/audit-utils.test.mjs | 1 + security/audit-utils.js | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index b74825fd..9efb10a3 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -22,6 +22,7 @@ const originalFetch = globalThis.fetch; * @param {Error | undefined} [options.error=undefined] Spawn error to surface. * @param {string | null} [options.signal=null] Signal that terminated the process. * @returns {{ error: Error | undefined, signal: string | null, status: number | null, stdout: string }} Mocked pnpm result object. + * @example createPnpmResult({ status: 1, stdout: 'foo', error: new Error('boom'), signal: null }); // { error: Error('boom'), signal: null, status: 1, stdout: 'foo' } */ function createPnpmResult({ status = 0, stdout = '', error = undefined, signal = null } = {}) { return { error, signal, status, stdout }; diff --git a/security/audit-utils.js b/security/audit-utils.js index 952d1ee2..3f23e17a 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -1,4 +1,17 @@ -/** @file Shared helpers for dependency audits and advisory filtering. */ +/** + * @file Shared helpers for dependency audits and advisory filtering. + * + * Provides the audit pipeline's JSON parsing, PNPM command execution, installed + * package-version collection, bulk-advisory fallback, and advisory flattening + * utilities. Pure helpers accept parsed JSON-shaped objects and return normalised + * maps or arrays; effectful helpers cross the IO boundary through `auditIo`, + * whose default implementation wraps filesystem, CLI, timer, and fetch effects. + * `audit-reporting.js` owns advisory partitioning and stderr formatting, while + * `validate-audit.js` applies policy to these normalised audit results. Callers + * can assume exported helpers either return parsed audit data in the documented + * shapes or throw explicit errors for failed, signalled, malformed, or + * unavailable audit inputs. + */ import { execFileSync, spawnSync } from 'node:child_process'; From c2620df72334b332a2bca9b4eba88091c0d80f2b Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:43:10 +0200 Subject: [PATCH 12/30] Split audit package data helpers Move `pnpm ls` package-tree loading and bulk advisory normalisation out of `audit-utils.js` into a focused sibling module. Keep `audit-utils.js` as the audit orchestration layer and re-export the helper APIs for existing callers. --- security/audit-package-data.js | 231 +++++++++++++++++++++++++++++++++ security/audit-utils.js | 219 ++----------------------------- 2 files changed, 244 insertions(+), 206 deletions(-) create mode 100644 security/audit-package-data.js diff --git a/security/audit-package-data.js b/security/audit-package-data.js new file mode 100644 index 00000000..bd6c2de8 --- /dev/null +++ b/security/audit-package-data.js @@ -0,0 +1,231 @@ +/** + * @file Package-tree and advisory-normalisation helpers for audit utilities. + * + * Owns `pnpm ls` serialisation, installed-version map construction, and npm + * bulk-advisory response normalisation. Callers provide parsed JSON-shaped + * objects for pure helpers or an `auditIo` adapter for command execution. + */ + +const LIST_ARGS = ['ls', '--json', '--depth', 'Infinity']; +const COMMAND_MAX_BUFFER = 64 * 1024 * 1024; +const DEPENDENCY_SECTION_NAMES = ['dependencies', 'devDependencies', 'optionalDependencies']; + +/** 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: {} } + */ +export 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(text); + } catch (error) { + error.message = `Failed to parse ${commandLabel} JSON: ${error.message}`; + throw error; + } +} + +/** 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:') || + version.startsWith('link:') || + version.startsWith('workspace:')); +} + +/** 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)) { + return; + } + const knownVersions = versionsByPackage.get(packageName) ?? new Set(); + knownVersions.add(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. + * @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') { + 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'] + */ +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 + */ +export function buildVersionMap(packageTrees) { + const versionsByPackage = new Map(); + 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; +} + +/** Load parsed package trees from `pnpm ls`. + * @param {object} auditIo Audit IO adapter. + * @param {(result: { error?: Error, signal?: string | null, status?: number | null }, commandLabel: string) => number} assertCompletedProcess Process completion validator. + * @returns {Record | unknown[]} Parsed package-tree payload. + * @example loadPackageTrees(auditIo, assertCompletedProcess); // [{ dependencies: {} }] + */ +export function loadPackageTrees(auditIo, assertCompletedProcess) { + const result = auditIo.spawnSync('pnpm', LIST_ARGS, { + encoding: 'utf8', + maxBuffer: COMMAND_MAX_BUFFER, + stdio: ['ignore', 'pipe', 'inherit'], + }); + const status = assertCompletedProcess(result, 'pnpm ls'); + 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.'); + } + return parseJsonOutput(stdout, 'pnpm ls'); +} + +/** Convert a version map to the sorted object expected by bulk advisory lookups. + * @param {Map>} versionsByPackage Installed versions keyed by package name. + * @returns {Record} Sorted installed versions keyed by package name. + * @example serializeVersionMap(new Map([['validator', new Set(['13.15.23'])]])); // { validator: ['13.15.23'] } + */ +function serializeVersionMap(versionsByPackage) { + return Object.fromEntries( + [...versionsByPackage.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([packageName, versions]) => [packageName, [...versions].sort()]), + ); +} + +/** Collect installed package versions from `pnpm ls` for bulk advisory lookups. + * Throws when `pnpm ls` fails, is signalled, or returns invalid JSON. + * @param {object} auditIo Audit IO adapter. + * @param {(result: { error?: Error, signal?: string | null, status?: number | null }, commandLabel: string) => number} assertCompletedProcess Process completion validator. + * @returns {Record} Sorted installed versions keyed by package name. + * @example collectInstalledPackageVersions(auditIo, assertCompletedProcess); // { validator: ['13.15.23'] } + */ +export function collectInstalledPackageVersions(auditIo, assertCompletedProcess) { + return serializeVersionMap(buildVersionMap(loadPackageTrees(auditIo, assertCompletedProcess))); +} + +/** 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; + } + 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. + * @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 }; +} + +/** 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); } + +/** 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()) { + 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, + package_name: packageName, + }; + } +} + +/** 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' } } + */ +export function normalizeBulkAdvisories(bulkPayload) { + if (!isPlainAdvisoryObject(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); + } + + return advisories; +} diff --git a/security/audit-utils.js b/security/audit-utils.js index 3f23e17a..0a3d1eff 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -14,19 +14,29 @@ */ import { execFileSync, spawnSync } from 'node:child_process'; +import { + collectInstalledPackageVersions, + normalizeBulkAdvisories, + parseJsonOutput, +} from './audit-package-data.js'; +export { + buildVersionMap, + collectInstalledPackageVersions, + loadPackageTrees, + normalizeBulkAdvisories, + parseJsonOutput, +} from './audit-package-data.js'; export { partitionAdvisoriesById, reportUnexpectedAdvisories, } from './audit-reporting.js'; 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 = ['dependencies', 'devDependencies', 'optionalDependencies']; const RETIRED_AUDIT_ENDPOINT_MESSAGE = 'This endpoint is being retired. Use the bulk advisory endpoint instead.'; const defaultAuditIo = { execFileSync, @@ -36,27 +46,6 @@ const defaultAuditIo = { spawnSync, }; -/** 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?.() ?? ''; - if (!text) { - if (requireNonEmpty) { - throw new Error(`Failed to parse ${commandLabel} JSON: response body was empty.`); - } - return {}; - } - try { - return JSON.parse(text); - } catch (error) { - error.message = `Failed to parse ${commandLabel} JSON: ${error.message}`; - throw error; - } -} - /** 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 @@ -68,86 +57,6 @@ function isRetiredAuditEndpoint(payload) { payload.error.message.includes(RETIRED_AUDIT_ENDPOINT_MESSAGE)); } -/** 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:') || - version.startsWith('link:') || - version.startsWith('workspace:')); -} - -/** 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)) { - return; - } - const knownVersions = versionsByPackage.get(packageName) ?? new Set(); - knownVersions.add(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. - * @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') { - 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'] - */ -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(); - 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; -} - /** Ensure a child-process result exited normally. * @param {{ error?: Error, signal?: string | null, status?: number | null }} result Spawn result from an audit command. * @param {string} commandLabel Label used in thrown errors. @@ -167,36 +76,6 @@ function assertCompletedProcess(result, commandLabel) { return result.status; } -/** Collect installed package versions from `pnpm ls` for bulk advisory lookups. - * Throws when `pnpm ls` fails, is signalled, or returns invalid JSON. - * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. - * @returns {Record} Sorted installed versions keyed by package name. - * @example // Normal usage with `pnpm ls` returning one installed validator version: - * collectInstalledPackageVersions(); // { validator: ['13.15.23'] } - * @example const auditIo = { ...defaultAuditIo, spawnSync: () => ({ status: 0, stdout: '[{"dependencies":{"validator":{"version":"13.15.23"}}}]' }) }; - * collectInstalledPackageVersions(auditIo); // { validator: ['13.15.23'] } - */ -function collectInstalledPackageVersions(auditIo = defaultAuditIo) { - const result = auditIo.spawnSync('pnpm', LIST_ARGS, { encoding: 'utf8', maxBuffer: COMMAND_MAX_BUFFER, stdio: ['ignore', 'pipe', 'inherit'] }); - const status = assertCompletedProcess(result, 'pnpm ls'); - 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.'); - } - - const packageTrees = parseJsonOutput(stdout, 'pnpm ls'); - const versionsByPackage = buildVersionMap(packageTrees); - - return Object.fromEntries( - [...versionsByPackage.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([packageName, versions]) => [packageName, [...versions].sort()]), - ); -} - /** 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 @@ -235,78 +114,6 @@ function readRegistryUrl(auditIo = defaultAuditIo) { } } -/** 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; - } - 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. - * @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 }; -} - -/** 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); } - -/** 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()) { - 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, - package_name: packageName, - }; - } -} - -/** 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' } } - */ -function normalizeBulkAdvisories(bulkPayload) { - if (!isPlainAdvisoryObject(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); - } - - return advisories; -} - /** 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. * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. @@ -359,7 +166,7 @@ async function runBulkAdvisoryAudit(auditIo = defaultAuditIo) { const endpoint = new URL(BULK_ADVISORY_PATH, registryUrl); const { response, responseText } = await fetchBulkAdvisories( endpoint, - collectInstalledPackageVersions(auditIo), + collectInstalledPackageVersions(auditIo, assertCompletedProcess), auditIo, ); From 1c377d152387ae642beddefda661c4c4482bc3e8 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:52:00 +0200 Subject: [PATCH 13/30] Reduce audit helper complexity Extract small dependency and advisory merge helpers from the audit package-data flow. Keep the helper exports unchanged while reducing the mean cyclomatic complexity below the project threshold. --- security/audit-package-data.js | 89 ++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/security/audit-package-data.js b/security/audit-package-data.js index bd6c2de8..c9ec6416 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -56,6 +56,22 @@ function addPackageVersion(versionsByPackage, packageName, version) { versionsByPackage.set(packageName, knownVersions); } +/** Process a single entry from a pnpm ls dependency section. + * @param {string} packageName Package name. + * @param {unknown} dependency Raw dependency value from the section. + * @param {Map>} versionsByPackage Collected versions keyed by package name. + * @returns {void} + */ +function processDependencyEntry(packageName, dependency, versionsByPackage) { + if (!dependency || typeof dependency !== 'object') { + return; + } + if (typeof dependency.version === 'string') { + addPackageVersion(versionsByPackage, packageName, dependency.version); + } + walkDependencies(dependency, versionsByPackage); +} + /** 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 @@ -65,13 +81,7 @@ function walkDependencySection(section, versionsByPackage) { 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); + processDependencyEntry(packageName, dependency, versionsByPackage); } } @@ -88,14 +98,6 @@ function walkDependencies(node, 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 */ @@ -103,7 +105,7 @@ export function buildVersionMap(packageTrees) { const versionsByPackage = new Map(); const trees = Array.isArray(packageTrees) ? packageTrees : [packageTrees]; for (const tree of trees) { - if (!isDependencyTreeNode(tree)) { + if (typeof tree !== 'object' || tree === null || Array.isArray(tree)) { throw new TypeError('pnpm ls returned an invalid dependency tree payload.'); } walkDependencies(tree, versionsByPackage); @@ -190,25 +192,51 @@ function deriveAdvisoryKey(packageName, advisory) { */ function isPlainAdvisoryObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** Validate and merge one advisory into the shared accumulator. + * @param {string} packageName Package name from the bulk advisory payload. + * @param {unknown} advisory Raw advisory object; must be a plain object. + * @param {number} index Position within the package advisory array. + * @param {Record} advisories Accumulator mutated in place. + * @returns {void} + */ +function mergeOneAdvisory(packageName, advisory, index, advisories) { + 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)) { + return; + } + advisories[key] = { + ...advisory, + github_advisory_id: githubAdvisoryId, + package_name: packageName, + }; +} + /** 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()) { - 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, - package_name: packageName, - }; + mergeOneAdvisory(packageName, advisory, index, advisories); + } +} + +/** Validate and merge one package's advisory array into the accumulator. + * @param {string} packageName Package name from the bulk advisory payload. + * @param {unknown} packageAdvisories Raw value for this package; must be an array. + * @param {Record} advisories Accumulator mutated in place. + * @returns {void} + */ +function mergePackageAdvisories(packageName, packageAdvisories, advisories) { + if (!Array.isArray(packageAdvisories)) { + throw new TypeError( + `Invalid bulk advisory entry for package ${packageName}: expected array, received ${JSON.stringify(packageAdvisories)}`, + ); } + addPackageAdvisories(packageName, packageAdvisories, advisories); } /** Normalize bulk advisory responses into the shared advisory object shape. @@ -221,10 +249,7 @@ export function normalizeBulkAdvisories(bulkPayload) { } 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); + mergePackageAdvisories(packageName, packageAdvisories, advisories); } return advisories; From 12865ce7814e1d4f1bc82f02a152400af1483046 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 02:34:44 +0200 Subject: [PATCH 14/30] Name audit tree-node predicate Extract the `buildVersionMap` tree-shape guard into a private `isValidTreeNode` predicate so the call site avoids the compound conditional without changing the exported audit helper API. --- security/audit-package-data.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/security/audit-package-data.js b/security/audit-package-data.js index c9ec6416..3f416acd 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -98,6 +98,18 @@ function walkDependencies(node, versionsByPackage) { } } +/** Return `true` when a value is a valid `pnpm ls` dependency tree node + * (a non-null, non-array plain object). + * @param {unknown} value Value to test. + * @returns {boolean} + * @example isValidTreeNode({ dependencies: {} }); // true + * @example isValidTreeNode(null); // false + * @example isValidTreeNode([]); // false + */ +function isValidTreeNode(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 */ @@ -105,7 +117,7 @@ export function buildVersionMap(packageTrees) { const versionsByPackage = new Map(); const trees = Array.isArray(packageTrees) ? packageTrees : [packageTrees]; for (const tree of trees) { - if (typeof tree !== 'object' || tree === null || Array.isArray(tree)) { + if (!isValidTreeNode(tree)) { throw new TypeError('pnpm ls returned an invalid dependency tree payload.'); } walkDependencies(tree, versionsByPackage); From 55246e986b0ca4b6ca4d745079833eed7b7236d0 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 02:40:37 +0200 Subject: [PATCH 15/30] Omit missing GHSA advisory IDs Only add `github_advisory_id` to normalized bulk advisories when a GHSA identifier is present. Keep fallback-key advisories keyed by package and registry advisory id without materializing an undefined property. Add a regression test for bulk advisories whose URLs do not contain a GHSA identifier. --- frontend-pwa/scripts/audit-utils.test.mjs | 33 +++++++++++++++++++++++ security/audit-package-data.js | 4 ++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index 9efb10a3..2508b0f0 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -330,4 +330,37 @@ describe('runAuditJson', () => { 'https://registry.npmjs.org/-/npm/v1/security/advisories/bulk', ); }); + + it('omits github advisory IDs when the bulk payload lacks a GHSA URL', async () => { + setupRetiredEndpointFallback(); + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => + JSON.stringify({ + validator: [ + { + id: 100000, + url: 'https://example.test/advisories/100000', + title: 'Registry advisory', + }, + ], + }), + }); + const { runAuditJson } = await loadAuditUtils(); + + const result = await runAuditJson(); + + expect(result.json.advisories).toEqual({ + 'validator:100000': expect.objectContaining({ + id: 100000, + [packageNameKey]: 'validator', + title: 'Registry advisory', + url: 'https://example.test/advisories/100000', + }), + }); + expect(result.json.advisories['validator:100000']).not.toHaveProperty(githubAdvisoryIdKey); + assertFallbackSpawnCalls(); + }); }); diff --git a/security/audit-package-data.js b/security/audit-package-data.js index 3f416acd..c0419ad2 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -221,9 +221,11 @@ function mergeOneAdvisory(packageName, advisory, index, advisories) { } advisories[key] = { ...advisory, - github_advisory_id: githubAdvisoryId, package_name: packageName, }; + if (githubAdvisoryId != null) { + advisories[key].github_advisory_id = githubAdvisoryId; + } } /** Merge advisories for one package into the shared accumulator. From b4037cf6fe8a60791340f18bf3184a546e9c0ee2 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 16:13:47 +0200 Subject: [PATCH 16/30] Reject non-plain audit tree nodes Tighten the `buildVersionMap` tree-node guard so only plain objects or null-prototype objects are accepted as parsed `pnpm ls` tree payloads. Reject class instances and collection objects through the same TypeError path used for other invalid tree values. Add a regression test for a `Map` payload to pin the stricter guard. --- frontend-pwa/scripts/audit-utils.test.mjs | 10 ++++++++++ security/audit-package-data.js | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend-pwa/scripts/audit-utils.test.mjs b/frontend-pwa/scripts/audit-utils.test.mjs index 2508b0f0..560820ed 100644 --- a/frontend-pwa/scripts/audit-utils.test.mjs +++ b/frontend-pwa/scripts/audit-utils.test.mjs @@ -63,6 +63,16 @@ async function loadAuditUtils() { return module; } +describe('buildVersionMap', () => { + it('rejects non-plain dependency tree objects', async () => { + const { buildVersionMap } = await loadAuditUtils(); + + expect(() => buildVersionMap(new Map())).toThrow( + 'pnpm ls returned an invalid dependency tree payload.', + ); + }); +}); + describe('runAuditJson', () => { beforeEach(() => { vi.resetModules(); diff --git a/security/audit-package-data.js b/security/audit-package-data.js index c0419ad2..1991235d 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -107,7 +107,11 @@ function walkDependencies(node, versionsByPackage) { * @example isValidTreeNode([]); // false */ function isValidTreeNode(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } /** Build the installed package-version map from parsed `pnpm ls` output. From 28102b154836faa44a13b46eb3e517e1d3194758 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 16:38:15 +0200 Subject: [PATCH 17/30] Use normalization spelling in audit package header Update the audit package-data module header to use the requested `normalization` spelling for advisory-normalization wording. --- security/audit-package-data.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/security/audit-package-data.js b/security/audit-package-data.js index 1991235d..121e078d 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -1,8 +1,8 @@ /** - * @file Package-tree and advisory-normalisation helpers for audit utilities. + * @file Package-tree and advisory-normalization helpers for audit utilities. * * Owns `pnpm ls` serialisation, installed-version map construction, and npm - * bulk-advisory response normalisation. Callers provide parsed JSON-shaped + * bulk-advisory response normalization. Callers provide parsed JSON-shaped * objects for pure helpers or an `auditIo` adapter for command execution. */ From 71fd7192dc2a893a3685a06d0efd9ddd2e706374 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 16:44:17 +0200 Subject: [PATCH 18/30] Remove duplicate audit phony declaration Keep the audit targets in the consolidated `.PHONY` declaration and place `audit` where the Makefile linter recognises it without restoring the duplicate declaration. --- Makefile | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 3c4d4534..595cdf7b 100644 --- a/Makefile +++ b/Makefile @@ -41,11 +41,10 @@ YAMLLINT_VERSION ?= 1.35.1 OPENAPI_SPEC ?= spec/openapi.json # Place one consolidated PHONY declaration near the top of the file -.PHONY: all clean be fe fe-build openapi gen docker-up docker-down fmt lint test test-rust test-frontend typecheck deps lockfile lint-specs \ - check-fmt markdownlint markdownlint-docs mermaid-lint nixie yamllint audit audit-node rust-audit \ - lint-rust lint-frontend lint-asyncapi lint-openapi lint-makefile lint-actions \ - lint-architecture workspace-sync -.PHONY: audit audit-node rust-audit +.PHONY: all clean be fe fe-build openapi gen docker-up docker-down fmt lint test test-rust test-frontend typecheck deps lockfile lint-specs audit \ + check-fmt markdownlint markdownlint-docs mermaid-lint nixie yamllint audit-node rust-audit \ + lint-rust lint-frontend lint-asyncapi lint-openapi lint-makefile lint-actions \ + lint-architecture workspace-sync workspace-sync: ./scripts/sync_workspace_members.py From c8339e606d8436f4c81b7e0cc418c218df941577 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 17:03:01 +0200 Subject: [PATCH 19/30] Test audit helper boundaries Add behaviour, property, and snapshot coverage for the split audit helper modules and functional dry-run tests for the Makefile audit targets. Route npm registry environment lookup through the audit IO adapter so audit orchestration stays injectable, and document the helper-module contract in the developers' guide. --- docs/developers-guide.md | 30 ++ package.json | 1 + pnpm-lock.yaml | 16 ++ scripts/makefile-audit.test.mjs | 74 ++--- scripts/security-audit-helpers.test.mjs | 323 ++++++++++++++++++++++ scripts/security-audit-reporting.test.mjs | 158 +++++++++++ security/audit-exception-policy.js | 33 +++ security/audit-utils.js | 5 +- security/validate-audit.js | 37 +-- 9 files changed, 594 insertions(+), 83 deletions(-) create mode 100644 scripts/security-audit-helpers.test.mjs create mode 100644 scripts/security-audit-reporting.test.mjs create mode 100644 security/audit-exception-policy.js diff --git a/docs/developers-guide.md b/docs/developers-guide.md index d66510db..8e834b1f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -161,6 +161,36 @@ When a phase introduces a new lint, accessibility, Playwright, or semantic CSS gate, update this guide and the corresponding phase ExecPlan under `docs/execplans/` in the same change. +### Dependency audit helper modules + +The JavaScript dependency-audit flow is split by responsibility: + +- `security/audit-utils.js` is the orchestration surface used by package + scripts. It exports `runAuditJson(auditIo?)` and + `collectAdvisories(auditJson)`, and re-exports the lower-level package-data + and reporting helpers. +- `security/audit-package-data.js` owns pure JSON parsing, `pnpm ls` package + tree handling, installed-version maps, and npm bulk-advisory normalization. + Its public helpers are `parseJsonOutput(payloadText, commandLabel, options?)`, + `loadPackageTrees(auditIo, assertCompletedProcess)`, + `buildVersionMap(packageTrees)`, + `collectInstalledPackageVersions(auditIo, assertCompletedProcess)`, and + `normalizeBulkAdvisories(bulkPayload)`. +- `security/audit-reporting.js` owns advisory partitioning and stderr output. + Its public helpers are `partitionAdvisoriesById(advisories, allowedIds)` and + `reportUnexpectedAdvisories(unexpected, heading)`. +- `security/audit-exception-policy.js` owns exception-ledger date policy. Its + public helper is `assertNoExpired(entries, currentDate?, policyIo?)`. +- `security/validate-audit.js` applies repository policy to the parsed audit + results and the exception ledger. + +Effectful audit helpers must receive external dependencies through the +`auditIo` adapter rather than reading process state directly. The default +adapter wraps `spawnSync`, `execFileSync`, `fetch`, timers, and `getEnv(name)`; +tests should inject an adapter with the same methods when they need to control +command results, registry configuration, network responses, or timeout +behaviour. + ## Embedded PostgreSQL integration tests Backend integration and behavioural suites that require PostgreSQL use the diff --git a/package.json b/package.json index 1ae345a2..7a392dc9 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "@mermaid-js/mermaid-cli": "^11.15.0", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "fast-check": "^4.8.0", "markdownlint-cli": "^0.48.0", "puppeteer": "^23.11.1", "validator": "^13.15.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39394b9a..3c56837c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: ajv-formats: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) + fast-check: + specifier: ^4.8.0 + version: 4.8.0 markdownlint-cli: specifier: ^0.48.0 version: 0.48.0 @@ -1760,6 +1763,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fast-check@4.8.0: + resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2588,6 +2595,9 @@ packages: deprecated: < 24.15.0 is no longer supported hasBin: true + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -4912,6 +4922,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@4.8.0: + dependencies: + pure-rand: 8.4.0 + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -5887,6 +5901,8 @@ snapshots: - typescript - utf-8-validate + pure-rand@8.4.0: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 diff --git a/scripts/makefile-audit.test.mjs b/scripts/makefile-audit.test.mjs index 09db3e4a..7ce87b54 100644 --- a/scripts/makefile-audit.test.mjs +++ b/scripts/makefile-audit.test.mjs @@ -1,68 +1,52 @@ -/** @file Tests the Makefile audit target contracts. */ +/** @file Functional dry-run tests for the Makefile audit target contracts. */ -import { readFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; -const makefilePath = new URL('../Makefile', import.meta.url); +const execFileAsync = promisify(execFile); +const repositoryRoot = new URL('../', import.meta.url); /** - * Read the repository Makefile for contract checks. - * - * @returns {Promise} The Makefile source. + * Ask Make to print a target's execution plan without running the recipes. + * @param {string} target Make target to dry-run. + * @returns {Promise} Commands Make would execute for the target. */ -async function readMakefile() { - return readFile(makefilePath, 'utf8'); -} - -/** - * Extract a Make target recipe body from Makefile source. - * - * @param {string} source - The Makefile source. - * @param {string} target - The target name to extract. - * @returns {string} The target recipe body. - */ -function extractTarget(source, target) { - const match = source.match( - new RegExp(`^${target}:[^\\n]*(?:\\n\\t[^\\n]*)*`, 'm'), - ); - return match?.[0] ?? ''; +async function dryRunMake(target) { + const { stdout } = await execFileAsync('make', ['--dry-run', '--always-make', target], { + cwd: repositoryRoot, + }); + return stdout; } describe('Makefile audit targets', () => { - it('wires the aggregate audit target through node and Rust audits', async () => { - const makefile = await readMakefile(); + it('executes the aggregate audit target through node and Rust audits', async () => { + const stdout = await dryRunMake('audit'); - expect(makefile).toMatch(/^audit: audit-node rust-audit$/m); + expect(stdout).toContain('pnpm -r --if-present run audit'); + expect(stdout).toContain('pnpm run audit:validate'); + expect(stdout).toContain('cargo audit --file Cargo.lock --ignore RUSTSEC-2023-0071'); }); it('does not reinstall node dependencies inside audit-node', async () => { - const makefile = await readMakefile(); - const target = extractTarget(makefile, 'audit-node'); + const stdout = await dryRunMake('audit-node'); - expect(target).toContain('audit-node: deps'); - expect(target).toContain('pnpm -r --if-present run audit'); - expect(target).toContain('pnpm run audit:validate'); - expect(target).not.toContain('pnpm -r install'); + expect(stdout).toContain('pnpm -r --if-present run audit'); + expect(stdout).toContain('pnpm run audit:validate'); + expect(stdout).not.toContain('pnpm -r install'); }); it('checks cargo-audit availability before running the Rust audit', async () => { - const makefile = await readMakefile(); - const target = extractTarget(makefile, 'rust-audit'); + const stdout = await dryRunMake('rust-audit'); - expect(target).toContain('command -v cargo-audit'); - expect(target).toContain('cargo-audit is required'); - expect(target).toContain('cargo-audit@0.22.1'); + expect(stdout).toContain('command -v cargo-audit'); + expect(stdout).toContain('cargo-audit is required'); + expect(stdout).toContain('cargo-audit@0.22.1'); }); - it('runs cargo audit against Cargo.lock with the configured ignores', async () => { - const makefile = await readMakefile(); - const target = extractTarget(makefile, 'rust-audit'); + it('runs cargo audit against Cargo.lock with configured ignores', async () => { + const stdout = await dryRunMake('rust-audit'); - expect(makefile).toMatch( - /^CARGO_AUDIT_IGNORES := --ignore RUSTSEC-2023-0071$/m, - ); - expect(target).toContain( - '$(CARGO) audit --file Cargo.lock $(CARGO_AUDIT_IGNORES)', - ); + expect(stdout).toContain('cargo audit --file Cargo.lock --ignore RUSTSEC-2023-0071'); }); }); diff --git a/scripts/security-audit-helpers.test.mjs b/scripts/security-audit-helpers.test.mjs new file mode 100644 index 00000000..17900415 --- /dev/null +++ b/scripts/security-audit-helpers.test.mjs @@ -0,0 +1,323 @@ +/** @file Unit and property tests for shared security audit helper modules. */ + +import fc from 'fast-check'; +import { describe, expect, it, vi } from 'vitest'; +import { + buildVersionMap, + collectInstalledPackageVersions, + loadPackageTrees, + normalizeBulkAdvisories, + parseJsonOutput, +} from '../security/audit-package-data.js'; +import { runAuditJson } from '../security/audit-utils.js'; + +function createCompletedResult(stdout = '[]') { + return { error: undefined, signal: null, status: 0, stdout }; +} + +function assertCompletedProcess(result) { + return result.status; +} + +function mapToSortedObject(versionMap) { + return Object.fromEntries( + [...versionMap.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([packageName, versions]) => [packageName, [...versions].sort()]), + ); +} + +describe('parseJsonOutput', () => { + it('parses trimmed object and array payloads', () => { + expect(parseJsonOutput(' {"advisories":{}} ', 'pnpm audit')).toEqual({ + advisories: {}, + }); + expect(parseJsonOutput('\n[{"name":"frontend-pwa"}]\n', 'pnpm ls')).toEqual([ + { name: 'frontend-pwa' }, + ]); + }); + + it('returns an empty object for optional blank payloads', () => { + expect(parseJsonOutput(' ', 'pnpm audit')).toEqual({}); + expect(parseJsonOutput(undefined, 'pnpm audit')).toEqual({}); + }); + + it('throws labelled errors for required blank or malformed payloads', () => { + expect(() => + parseJsonOutput('', 'bulk advisory audit', { requireNonEmpty: true }), + ).toThrow('Failed to parse bulk advisory audit JSON: response body was empty.'); + expect(() => parseJsonOutput('{', 'pnpm audit')).toThrow( + /^Failed to parse pnpm audit JSON:/, + ); + }); +}); + +describe('buildVersionMap', () => { + it('walks dependency sections recursively and skips workspace-local versions', () => { + const versions = buildVersionMap([ + { + dependencies: { + '@app/types': { version: 'link:../packages/types' }, + validator: { + version: '13.15.23', + dependencies: { + nanoid: { version: '3.3.11' }, + }, + }, + }, + devDependencies: { + vitest: { version: '3.2.4' }, + }, + optionalDependencies: { + fsevents: { version: '2.3.3' }, + }, + }, + ]); + + expect(mapToSortedObject(versions)).toEqual({ + fsevents: ['2.3.3'], + nanoid: ['3.3.11'], + validator: ['13.15.23'], + vitest: ['3.2.4'], + }); + }); + + it.each([null, new Map(), new Date()])( + 'rejects invalid dependency tree payload %#', + (payload) => { + expect(() => buildVersionMap(payload)).toThrow( + 'pnpm ls returned an invalid dependency tree payload.', + ); + }, + ); + + it('only records non-local string versions from generated dependency trees', () => { + fc.assert( + fc.property( + fc.dictionary( + fc.string({ minLength: 1 }).filter((name) => !name.includes('\0')), + fc.oneof( + fc.constantFrom('file:../pkg', 'link:../pkg', 'workspace:*', ''), + fc.string({ minLength: 1 }).filter((version) => + !['file:', 'link:', 'workspace:'].some((prefix) => version.startsWith(prefix)), + ), + ), + { maxKeys: 20 }, + ), + (versionsByName) => { + const tree = { + dependencies: Object.fromEntries( + Object.entries(versionsByName).map(([name, version]) => [ + name, + { version }, + ]), + ), + }; + + const actual = mapToSortedObject(buildVersionMap(tree)); + const expected = Object.fromEntries( + Object.entries(versionsByName) + .filter(([, version]) => + version && + !['file:', 'link:', 'workspace:'].some((prefix) => version.startsWith(prefix)), + ) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, version]) => [name, [version]]), + ); + + expect(actual).toEqual(expected); + }, + ), + ); + }); +}); + +describe('loadPackageTrees and collectInstalledPackageVersions', () => { + it('runs pnpm ls and returns parsed dependency trees', () => { + const auditIo = { + spawnSync: vi.fn(() => + createCompletedResult('[{"dependencies":{"validator":{"version":"13.15.23"}}}]'), + ), + }; + + const trees = loadPackageTrees(auditIo, assertCompletedProcess); + + expect(trees).toEqual([ + { dependencies: { validator: { version: '13.15.23' } } }, + ]); + expect(auditIo.spawnSync).toHaveBeenCalledWith( + 'pnpm', + ['ls', '--json', '--depth', 'Infinity'], + expect.objectContaining({ encoding: 'utf8' }), + ); + }); + + it('throws when pnpm ls exits non-zero or returns no tree', () => { + expect(() => + loadPackageTrees( + { spawnSync: () => createCompletedResult('[]') }, + () => 1, + ), + ).toThrow('pnpm ls failed without producing a dependency tree (exit status 1).'); + expect(() => + loadPackageTrees( + { spawnSync: () => createCompletedResult(' ') }, + assertCompletedProcess, + ), + ).toThrow('pnpm ls failed without producing a dependency tree.'); + }); + + it('serializes collected versions for the bulk advisory endpoint', () => { + const auditIo = { + spawnSync: () => + createCompletedResult( + JSON.stringify([ + { + dependencies: { + validator: { version: '13.15.23' }, + nanoid: { version: '3.3.11' }, + }, + }, + ]), + ), + }; + + expect(collectInstalledPackageVersions(auditIo, assertCompletedProcess)).toEqual({ + nanoid: ['3.3.11'], + validator: ['13.15.23'], + }); + }); +}); + +describe('normalizeBulkAdvisories', () => { + it('normalizes GHSA IDs, package names, and fallback keys', () => { + expect( + normalizeBulkAdvisories({ + validator: [ + { + id: 100000, + title: 'Validator SSRF', + url: 'https://github.com/advisories/GHSA-Vghf-HV5Q-vC2G', + }, + { + id: 100001, + title: 'Registry advisory', + url: 'https://example.test/advisories/100001', + }, + ], + }), + ).toEqual({ + 'GHSA-vghf-hv5q-vc2g': { + github_advisory_id: 'GHSA-vghf-hv5q-vc2g', + id: 100000, + package_name: 'validator', + title: 'Validator SSRF', + url: 'https://github.com/advisories/GHSA-Vghf-HV5Q-vC2G', + }, + 'validator:100001': { + id: 100001, + package_name: 'validator', + title: 'Registry advisory', + url: 'https://example.test/advisories/100001', + }, + }); + }); + + it('rejects malformed bulk advisory payloads', () => { + expect(() => normalizeBulkAdvisories(null)).toThrow( + 'Invalid bulk advisory payload: expected an object keyed by package name.', + ); + expect(() => normalizeBulkAdvisories({ validator: {} })).toThrow( + 'Invalid bulk advisory entry for package validator: expected array', + ); + expect(() => normalizeBulkAdvisories({ validator: [null] })).toThrow( + 'Invalid advisory for package validator at index 0: expected object', + ); + }); + + it('deduplicates generated advisories by canonical GHSA key', () => { + fc.assert( + fc.property( + fc.array( + fc.record({ + packageName: fc.string({ minLength: 1 }), + id: fc.integer({ min: 1, max: 1_000_000 }), + ghsa: fc.constantFrom( + 'GHSA-vghf-hv5q-vc2g', + 'GHSA-58qx-3vcg-4xpx', + 'GHSA-abcd-efgh-1234', + ), + }), + { minLength: 1, maxLength: 30 }, + ), + (entries) => { + const payload = Object.create(null); + for (const { packageName, id, ghsa } of entries) { + payload[packageName] ??= []; + payload[packageName].push({ + id, + title: `Advisory ${id}`, + url: `https://github.com/advisories/${ghsa.toUpperCase()}`, + }); + } + + const normalized = normalizeBulkAdvisories(payload); + const expectedKeys = new Set( + entries.map(({ ghsa }) => `GHSA-${ghsa.slice('GHSA-'.length).toLowerCase()}`), + ); + + expect(new Set(Object.keys(normalized))).toEqual(expectedKeys); + for (const key of expectedKeys) { + expect(normalized[key].github_advisory_id).toBe(key); + } + }, + ), + ); + }); +}); + +describe('runAuditJson audit IO boundary', () => { + it('reads npm registry settings through auditIo.getEnv before pnpm config', async () => { + const auditIo = { + clearTimeout: vi.fn(), + execFileSync: vi.fn(() => { + throw new Error('pnpm config should not be read'); + }), + fetch: vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => '{}', + })), + getEnv: vi.fn((name) => + name === 'npm_config_registry' ? 'https://registry.example.test/custom' : undefined, + ), + setTimeout: vi.fn(() => 1), + spawnSync: vi + .fn() + .mockReturnValueOnce( + createCompletedResult( + 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(createCompletedResult('[{"dependencies":{}}]')), + }; + + await expect(runAuditJson(auditIo)).resolves.toEqual({ + json: { advisories: {} }, + status: 0, + }); + + expect(auditIo.getEnv).toHaveBeenCalledWith('npm_config_registry'); + expect(auditIo.execFileSync).not.toHaveBeenCalled(); + expect(String(auditIo.fetch.mock.calls[0][0])).toBe( + 'https://registry.example.test/custom/-/npm/v1/security/advisories/bulk', + ); + }); +}); diff --git a/scripts/security-audit-reporting.test.mjs b/scripts/security-audit-reporting.test.mjs new file mode 100644 index 00000000..f775ace6 --- /dev/null +++ b/scripts/security-audit-reporting.test.mjs @@ -0,0 +1,158 @@ +/** @file Unit and property tests for audit reporting and exception policy. */ + +import fc from 'fast-check'; +import { describe, expect, it, vi } from 'vitest'; +import { assertNoExpired } from '../security/audit-exception-policy.js'; +import { + partitionAdvisoriesById, + reportUnexpectedAdvisories, +} from '../security/audit-reporting.js'; + +function advisory(id, title = `Advisory ${id}`) { + return { github_advisory_id: id, title }; +} + +function exceptionEntry({ addedAt, expiresAt, id = 'exception-1' }) { + return { + addedAt, + advisory: 'GHSA-vghf-hv5q-vc2g', + expiresAt, + id, + package: 'validator', + reason: 'Regression test fixture', + }; +} + +function throwingPolicyIo() { + return { + error: vi.fn(), + exit: vi.fn((code) => { + throw new Error(`exit ${code}`); + }), + }; +} + +describe('partitionAdvisoriesById', () => { + it('partitions advisories without reordering either group', () => { + const first = advisory('GHSA-1111-2222-3333'); + const second = advisory('GHSA-4444-5555-6666'); + const third = { title: 'Missing GHSA' }; + + expect(partitionAdvisoriesById([first, second, third], [second.github_advisory_id])).toEqual({ + expected: [second], + unexpected: [first, third], + }); + }); + + it('keeps every generated advisory in exactly one partition', () => { + fc.assert( + fc.property( + fc.uniqueArray(fc.uuid(), { minLength: 1, maxLength: 30 }), + fc.array(fc.boolean(), { minLength: 1, maxLength: 30 }), + (ids, flags) => { + const advisories = ids.map((id) => advisory(id)); + const allowedIds = ids.filter((_, index) => flags[index % flags.length]); + const { expected, unexpected } = partitionAdvisoriesById(advisories, allowedIds); + + expect(expected).toHaveLength(new Set(allowedIds).size); + expect([...expected, ...unexpected].sort((left, right) => + left.github_advisory_id.localeCompare(right.github_advisory_id), + )).toEqual([...advisories].sort((left, right) => + left.github_advisory_id.localeCompare(right.github_advisory_id), + )); + }, + ), + ); + }); +}); + +describe('reportUnexpectedAdvisories', () => { + it('returns false and writes nothing for an empty report', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(reportUnexpectedAdvisories([], 'Unexpected advisories:')).toBe(false); + expect(errorSpy).not.toHaveBeenCalled(); + + errorSpy.mockRestore(); + }); + + it('formats unexpected advisory output consistently', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect( + reportUnexpectedAdvisories( + [ + advisory('GHSA-vghf-hv5q-vc2g', 'Validator SSRF'), + { title: 'Missing identifier' }, + ], + 'pnpm audit reported vulnerabilities without exceptions:', + ), + ).toBe(true); + + expect(errorSpy.mock.calls.map(([line]) => line)).toMatchInlineSnapshot(` + [ + "pnpm audit reported vulnerabilities without exceptions:", + "- GHSA-vghf-hv5q-vc2g: Validator SSRF", + "- UNKNOWN: Missing identifier", + ] + `); + + errorSpy.mockRestore(); + }); +}); + +describe('assertNoExpired', () => { + it('allows exceptions expiring on or after the current date', () => { + fc.assert( + fc.property(fc.date({ min: new Date('2024-01-01'), max: new Date('2030-12-31') }), (date) => { + const today = date.toISOString().slice(0, 10); + const policyIo = throwingPolicyIo(); + + assertNoExpired( + [ + exceptionEntry({ + addedAt: '2024-01-01', + expiresAt: today, + }), + ], + date, + policyIo, + ); + + expect(policyIo.exit).not.toHaveBeenCalled(); + }), + ); + }); + + it('exits when an exception expires before the current date', () => { + const policyIo = throwingPolicyIo(); + + expect(() => + assertNoExpired( + [exceptionEntry({ addedAt: '2024-01-01', expiresAt: '2024-01-31' })], + new Date('2024-02-01T00:00:00.000Z'), + policyIo, + ), + ).toThrow('exit 1'); + expect(policyIo.error.mock.calls.map(([line]) => line)).toEqual([ + 'Audit exceptions have expired:', + '- exception-1 (validator) expired on 2024-01-31', + ]); + }); + + it('exits when an exception date range is inverted', () => { + const policyIo = throwingPolicyIo(); + + expect(() => + assertNoExpired( + [exceptionEntry({ addedAt: '2024-02-01', expiresAt: '2024-01-31' })], + new Date('2024-01-15T00:00:00.000Z'), + policyIo, + ), + ).toThrow('exit 1'); + expect(policyIo.error.mock.calls.map(([line]) => line)).toEqual([ + 'Audit exceptions have invalid date ranges (addedAt > expiresAt):', + '- exception-1 (validator) addedAt 2024-02-01 > expiresAt 2024-01-31', + ]); + }); +}); diff --git a/security/audit-exception-policy.js b/security/audit-exception-policy.js new file mode 100644 index 00000000..0dc166bf --- /dev/null +++ b/security/audit-exception-policy.js @@ -0,0 +1,33 @@ +/** @file Audit exception ledger policy checks shared by validator tests. */ + +const defaultPolicyIo = { + error: (...args) => console.error(...args), + exit: (code) => process.exit(code), +}; + +/** Exit with error if any audit exceptions are past their expiry date. + * @param {Array<{ id: string, package: string, addedAt: string, expiresAt: string }>} entries Entries to inspect. + * @param {Date} [currentDate=new Date()] Current date for deterministic validation. + * @param {{ error: (...args: unknown[]) => void, exit: (code: number) => never }} [policyIo=defaultPolicyIo] Policy IO adapter. + * @returns {void} + * @example assertNoExpired([{ id: '1', package: 'pkg', addedAt: '2024-01-01', expiresAt: '2099-01-01' }]); + */ +export function assertNoExpired(entries, currentDate = new Date(), policyIo = defaultPolicyIo) { + const today = currentDate.toISOString().slice(0, 10); + const expired = entries.filter((e) => e.expiresAt < today); + const inverted = entries.filter((e) => e.addedAt > e.expiresAt); + if (expired.length > 0) { + policyIo.error('Audit exceptions have expired:'); + for (const { id, package: pkg, expiresAt } of expired) { + policyIo.error(`- ${id} (${pkg}) expired on ${expiresAt}`); + } + policyIo.exit(1); + } + if (inverted.length > 0) { + policyIo.error('Audit exceptions have invalid date ranges (addedAt > expiresAt):'); + for (const { id, package: pkg, addedAt, expiresAt } of inverted) { + policyIo.error(`- ${id} (${pkg}) addedAt ${addedAt} > expiresAt ${expiresAt}`); + } + policyIo.exit(1); + } +} diff --git a/security/audit-utils.js b/security/audit-utils.js index 0a3d1eff..b789c8f9 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -41,6 +41,7 @@ const RETIRED_AUDIT_ENDPOINT_MESSAGE = 'This endpoint is being retired. Use the const defaultAuditIo = { execFileSync, fetch: (...args) => fetch(...args), + getEnv: (name) => process.env[name], setTimeout, clearTimeout, spawnSync, @@ -96,10 +97,10 @@ function normalizeRegistryUrl(rawRegistry) { * @param {object} [auditIo=defaultAuditIo] Audit IO adapter; `defaultAuditIo` is used when omitted. * @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/' - * @example const auditIo = { ...defaultAuditIo, execFileSync: () => 'https://registry.npmjs.org\n' }; readRegistryUrl(auditIo); // 'https://registry.npmjs.org/' + * @example const auditIo = { ...defaultAuditIo, getEnv: () => undefined, execFileSync: () => 'https://registry.npmjs.org\n' }; readRegistryUrl(auditIo); // 'https://registry.npmjs.org/' */ function readRegistryUrl(auditIo = defaultAuditIo) { - const envRegistry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY; + const envRegistry = auditIo.getEnv?.('npm_config_registry') ?? auditIo.getEnv?.('NPM_CONFIG_REGISTRY'); if (envRegistry) { return normalizeRegistryUrl(envRegistry); } diff --git a/security/validate-audit.js b/security/validate-audit.js index 49ebd0d3..9775c165 100644 --- a/security/validate-audit.js +++ b/security/validate-audit.js @@ -5,6 +5,7 @@ import Ajv from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { VALIDATOR_ADVISORY_ID, VALIDATOR_MIN_SAFE_VERSION } from './constants.js'; import { isValidatorPatched } from './validator-patch.js'; +import { assertNoExpired } from './audit-exception-policy.js'; import { collectAdvisories, partitionAdvisoriesById, @@ -58,42 +59,6 @@ function assertValidSchema(entries) { } } -/** - * Exit with error if any audit exceptions are past their expiry date. - * - * @param {typeof data} entries Entries to inspect. - * @example - * assertNoExpired([ - * { - * id: "1", - * package: "pkg", - * advisory: "ADV-1", - * reason: "Justified", - * addedAt: "2024-01-01", - * expiresAt: "2099-01-01", - * }, - * ]); - */ -function assertNoExpired(entries, currentDate = new Date()) { - const today = currentDate.toISOString().slice(0, 10); - const expired = entries.filter((e) => e.expiresAt < today); - const inverted = entries.filter((e) => e.addedAt > e.expiresAt); - if (expired.length > 0) { - console.error('Audit exceptions have expired:'); - for (const { id, package: pkg, expiresAt } of expired) { - console.error(`- ${id} (${pkg}) expired on ${expiresAt}`); - } - process.exit(1); - } - if (inverted.length > 0) { - console.error('Audit exceptions have invalid date ranges (addedAt > expiresAt):'); - for (const { id, package: pkg, addedAt, expiresAt } of inverted) { - console.error(`- ${id} (${pkg}) addedAt ${addedAt} > expiresAt ${expiresAt}`); - } - process.exit(1); - } -} - /** * Validate that current advisories align with the configured exceptions. * From 819993af03d38ceed4d26446ad1d61ad689e3861 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 20:27:30 +0200 Subject: [PATCH 20/30] Share audit object predicate Extract the common non-null object guard used by audit package-data helpers. Keep the dependency-tree prototype check local to `isValidTreeNode` while reusing the base predicate for advisory payload validation. --- security/audit-package-data.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/security/audit-package-data.js b/security/audit-package-data.js index 121e078d..a2c953f2 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -98,6 +98,17 @@ function walkDependencies(node, versionsByPackage) { } } +/** Return `true` when a value is a non-null, non-array object. + * @param {unknown} value Value to test. + * @returns {boolean} + * @example isNonNullObject({ dependencies: {} }); // true + * @example isNonNullObject(null); // false + * @example isNonNullObject([]); // false + */ +function isNonNullObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + /** Return `true` when a value is a valid `pnpm ls` dependency tree node * (a non-null, non-array plain object). * @param {unknown} value Value to test. @@ -107,7 +118,7 @@ function walkDependencies(node, versionsByPackage) { * @example isValidTreeNode([]); // false */ function isValidTreeNode(value) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { + if (!isNonNullObject(value)) { return false; } const prototype = Object.getPrototypeOf(value); @@ -206,7 +217,7 @@ function deriveAdvisoryKey(packageName, advisory) { * @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); } +function isPlainAdvisoryObject(value) { return isNonNullObject(value); } /** Validate and merge one advisory into the shared accumulator. * @param {string} packageName Package name from the bulk advisory payload. From bc6365a8ebc451e7ad197c580a26b84192684cbf Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 20:29:52 +0200 Subject: [PATCH 21/30] Split audit tree predicates Break the dependency-tree validation guard into smaller internal predicates so `isValidTreeNode` no longer carries the compound type check. Keep the existing tree-node semantics intact, including rejection of arrays, primitives, `null`, and class instances. --- security/audit-package-data.js | 35 ++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/security/audit-package-data.js b/security/audit-package-data.js index a2c953f2..bfbaa43b 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -101,12 +101,31 @@ function walkDependencies(node, versionsByPackage) { /** Return `true` when a value is a non-null, non-array object. * @param {unknown} value Value to test. * @returns {boolean} - * @example isNonNullObject({ dependencies: {} }); // true - * @example isNonNullObject(null); // false - * @example isNonNullObject([]); // false + * @example isNonArrayObject({ dependencies: {} }); // true + * @example isNonArrayObject(null); // false + * @example isNonArrayObject([]); // false + */ +function isNonArrayObject(value) { + return isNonNullObject(value) && !Array.isArray(value); +} + +/** Return `true` when a value has a plain-object prototype. + * @param {object} value Value to test. + * @returns {boolean} + * @example hasPlainObjectPrototype({ dependencies: {} }); // true + */ +function hasPlainObjectPrototype(value) { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * Return `true` when `value` is a non-null object. + * @param {unknown} value Value to test. + * @returns {boolean} */ function isNonNullObject(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); + return typeof value === 'object' && value !== null; } /** Return `true` when a value is a valid `pnpm ls` dependency tree node @@ -118,11 +137,7 @@ function isNonNullObject(value) { * @example isValidTreeNode([]); // false */ function isValidTreeNode(value) { - if (!isNonNullObject(value)) { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; + return isNonArrayObject(value) && hasPlainObjectPrototype(value); } /** Build the installed package-version map from parsed `pnpm ls` output. @@ -217,7 +232,7 @@ function deriveAdvisoryKey(packageName, advisory) { * @param {unknown} value Value to test. @returns {boolean} * @example isPlainAdvisoryObject({ id: 1 }); // true */ -function isPlainAdvisoryObject(value) { return isNonNullObject(value); } +function isPlainAdvisoryObject(value) { return isNonArrayObject(value); } /** Validate and merge one advisory into the shared accumulator. * @param {string} packageName Package name from the bulk advisory payload. From 4274537792762fc3e507d5720eb05fa6f74433e5 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 20:30:47 +0200 Subject: [PATCH 22/30] Fix audit package spelling Use Oxford `serialization` spelling in the audit package-data module header. --- security/audit-package-data.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/security/audit-package-data.js b/security/audit-package-data.js index bfbaa43b..afd739b5 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -1,7 +1,7 @@ /** * @file Package-tree and advisory-normalization helpers for audit utilities. * - * Owns `pnpm ls` serialisation, installed-version map construction, and npm + * Owns `pnpm ls` serialization, installed-version map construction, and npm * bulk-advisory response normalization. Callers provide parsed JSON-shaped * objects for pure helpers or an `auditIo` adapter for command execution. */ From 03952e191f1db517ca1c66a063920e0b41cd4316 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 20:36:52 +0200 Subject: [PATCH 23/30] Share audit object-shape predicates Extract shared non-null and plain-object predicates for audit package-data validation. Reuse the plain-object predicate in tree-node and advisory payload checks while keeping the existing prototype guard for dependency trees. --- security/audit-package-data.js | 39 ++++++++++++++++------------------ 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/security/audit-package-data.js b/security/audit-package-data.js index afd739b5..e1564bd0 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -98,34 +98,25 @@ function walkDependencies(node, versionsByPackage) { } } -/** Return `true` when a value is a non-null, non-array object. +/** + * Return `true` when `value` is a non-null object (any kind). * @param {unknown} value Value to test. * @returns {boolean} - * @example isNonArrayObject({ dependencies: {} }); // true - * @example isNonArrayObject(null); // false - * @example isNonArrayObject([]); // false */ -function isNonArrayObject(value) { - return isNonNullObject(value) && !Array.isArray(value); -} - -/** Return `true` when a value has a plain-object prototype. - * @param {object} value Value to test. - * @returns {boolean} - * @example hasPlainObjectPrototype({ dependencies: {} }); // true - */ -function hasPlainObjectPrototype(value) { - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; +function isNonNullObject(value) { + return typeof value === 'object' && value !== null; } /** - * Return `true` when `value` is a non-null object. + * Return `true` when `value` is a non-null, non-array object. * @param {unknown} value Value to test. * @returns {boolean} + * @example isPlainObject({ id: 1 }); // true + * @example isPlainObject(null); // false + * @example isPlainObject([]); // false */ -function isNonNullObject(value) { - return typeof value === 'object' && value !== null; +function isPlainObject(value) { + return isNonNullObject(value) && !Array.isArray(value); } /** Return `true` when a value is a valid `pnpm ls` dependency tree node @@ -137,7 +128,11 @@ function isNonNullObject(value) { * @example isValidTreeNode([]); // false */ function isValidTreeNode(value) { - return isNonArrayObject(value) && hasPlainObjectPrototype(value); + if (!isPlainObject(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } /** Build the installed package-version map from parsed `pnpm ls` output. @@ -232,7 +227,9 @@ function deriveAdvisoryKey(packageName, advisory) { * @param {unknown} value Value to test. @returns {boolean} * @example isPlainAdvisoryObject({ id: 1 }); // true */ -function isPlainAdvisoryObject(value) { return isNonArrayObject(value); } +function isPlainAdvisoryObject(value) { + return isPlainObject(value); +} /** Validate and merge one advisory into the shared accumulator. * @param {string} packageName Package name from the bulk advisory payload. From 5f4e412f9dc08001d38d0f5f2d36487dc0c219a7 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 20:50:30 +0200 Subject: [PATCH 24/30] Parameterize audit exception date tests Merge the duplicated audit exception date failure tests into one table-driven case while preserving the same generated Vitest case names and assertions. --- scripts/security-audit-reporting.test.mjs | 50 ++++++++++------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/scripts/security-audit-reporting.test.mjs b/scripts/security-audit-reporting.test.mjs index f775ace6..b8f6ebcf 100644 --- a/scripts/security-audit-reporting.test.mjs +++ b/scripts/security-audit-reporting.test.mjs @@ -124,35 +124,29 @@ describe('assertNoExpired', () => { ); }); - it('exits when an exception expires before the current date', () => { - const policyIo = throwingPolicyIo(); - - expect(() => - assertNoExpired( - [exceptionEntry({ addedAt: '2024-01-01', expiresAt: '2024-01-31' })], - new Date('2024-02-01T00:00:00.000Z'), - policyIo, - ), - ).toThrow('exit 1'); - expect(policyIo.error.mock.calls.map(([line]) => line)).toEqual([ - 'Audit exceptions have expired:', - '- exception-1 (validator) expired on 2024-01-31', - ]); - }); - - it('exits when an exception date range is inverted', () => { + it.each([ + [ + 'expires before the current date', + exceptionEntry({ addedAt: '2024-01-01', expiresAt: '2024-01-31' }), + new Date('2024-02-01T00:00:00.000Z'), + [ + 'Audit exceptions have expired:', + '- exception-1 (validator) expired on 2024-01-31', + ], + ], + [ + 'has an inverted date range', + exceptionEntry({ addedAt: '2024-02-01', expiresAt: '2024-01-31' }), + new Date('2024-01-15T00:00:00.000Z'), + [ + 'Audit exceptions have invalid date ranges (addedAt > expiresAt):', + '- exception-1 (validator) addedAt 2024-02-01 > expiresAt 2024-01-31', + ], + ], + ])('exits when an exception %s', (_description, entry, currentDate, expectedErrors) => { const policyIo = throwingPolicyIo(); - expect(() => - assertNoExpired( - [exceptionEntry({ addedAt: '2024-02-01', expiresAt: '2024-01-31' })], - new Date('2024-01-15T00:00:00.000Z'), - policyIo, - ), - ).toThrow('exit 1'); - expect(policyIo.error.mock.calls.map(([line]) => line)).toEqual([ - 'Audit exceptions have invalid date ranges (addedAt > expiresAt):', - '- exception-1 (validator) addedAt 2024-02-01 > expiresAt 2024-01-31', - ]); + expect(() => assertNoExpired([entry], currentDate, policyIo)).toThrow('exit 1'); + expect(policyIo.error.mock.calls.map(([line]) => line)).toEqual(expectedErrors); }); }); From 1117d0d4a156bccd07538e61736cb80ad2f93aa5 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 20:59:59 +0200 Subject: [PATCH 25/30] Cover audit helper behaviour Add behavioural tests for audit package-data helpers, reporting re-exports, explicit exception-date injection, and Makefile rust-audit execution. Document new security audit modules and the cargo-audit output contract, and await the bulk-advisory fallback path. --- docs/repository-structure.md | 15 +++++++- scripts/makefile-audit.test.mjs | 35 ++++++++++++++++++ scripts/security-audit-helpers.test.mjs | 45 +++++++++++++++++++++++ scripts/security-audit-reporting.test.mjs | 25 +++++++++++++ security/audit-utils.js | 2 +- 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/docs/repository-structure.md b/docs/repository-structure.md index 02f7491c..93958018 100644 --- a/docs/repository-structure.md +++ b/docs/repository-structure.md @@ -94,9 +94,16 @@ myapp/ │ ├─ openapi.json # generated by backend/CI │ └─ asyncapi.yaml # authored or generated │ -├─ security/ # dependency audit allowlist +├─ security/ # dependency audit policy and helpers │ ├─ audit-exceptions.json # time-bound audit exceptions -│ └─ audit-exceptions.schema.json # JSON Schema for the allowlist +│ ├─ audit-exceptions.schema.json # JSON Schema for the allowlist +│ ├─ audit-exception-policy.js # exception date validation +│ ├─ audit-package-data.js # package tree and advisory normalization +│ ├─ audit-reporting.js # advisory partitioning and stderr output +│ ├─ audit-utils.js # PNPM audit orchestration and re-exports +│ ├─ constants.js # shared audit constants +│ ├─ validate-audit.js # repository audit-policy validator +│ └─ validator-patch.js # audit schema validator patching │ ├─ deploy/ │ ├─ docker/ # Dockerfiles @@ -523,6 +530,10 @@ validates the audit exception allowlist against its schema 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. The Rust audit runs `cargo audit` against the consolidated root `Cargo.lock`. +It intentionally leaves cargo-audit's native text output intact so maintainers +can inspect the upstream RUSTSEC identifier, affected crate, patched versions, +and dependency path without a lossy translation layer. The Makefile pins the +audit to `Cargo.lock` and applies only the documented `CARGO_AUDIT_IGNORES`. Enable Corepack (`corepack enable` and `corepack prepare pnpm@10.15.1 --activate`) so `pnpm` is available in local and CI environments. diff --git a/scripts/makefile-audit.test.mjs b/scripts/makefile-audit.test.mjs index 7ce87b54..724f105c 100644 --- a/scripts/makefile-audit.test.mjs +++ b/scripts/makefile-audit.test.mjs @@ -1,6 +1,9 @@ /** @file Functional dry-run tests for the Makefile audit target contracts. */ import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; @@ -49,4 +52,36 @@ describe('Makefile audit targets', () => { expect(stdout).toContain('cargo audit --file Cargo.lock --ignore RUSTSEC-2023-0071'); }); + + it('executes rust-audit after checking cargo-audit availability', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'wildside-make-audit-')); + const commandLogDir = await mkdtemp(join(tmpdir(), 'wildside-audit-log-')); + const commandLog = join(commandLogDir, 'commands.log'); + const cargoPath = join(tempDir, 'cargo'); + const cargoAuditPath = join(tempDir, 'cargo-audit'); + const cargoAuditShim = '#!/usr/bin/env bash\nexit 0\n'; + const cargoShim = `#!/usr/bin/env bash +printf 'cargo %s\\n' "$*" >> "${commandLog}" +`; + + await writeFile(cargoAuditPath, cargoAuditShim, { mode: 0o755 }); + await writeFile(cargoPath, cargoShim, { mode: 0o755 }); + + try { + await execFileAsync('make', [`CARGO=${cargoPath}`, 'rust-audit'], { + cwd: repositoryRoot, + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH}`, + }, + }); + + await expect(readFile(commandLog, 'utf8')).resolves.toBe( + 'cargo audit --file Cargo.lock --ignore RUSTSEC-2023-0071\n', + ); + } finally { + await rm(tempDir, { force: true, recursive: true }); + await rm(commandLogDir, { force: true, recursive: true }); + } + }); }); diff --git a/scripts/security-audit-helpers.test.mjs b/scripts/security-audit-helpers.test.mjs index 17900415..b5801b73 100644 --- a/scripts/security-audit-helpers.test.mjs +++ b/scripts/security-audit-helpers.test.mjs @@ -50,6 +50,14 @@ describe('parseJsonOutput', () => { /^Failed to parse pnpm audit JSON:/, ); }); + + it.each([ + ['literal null', 'null', null], + ['literal false', 'false', false], + ['numeric zero', '0', 0], + ])('preserves valid JSON edge case %#: %s', (_label, payload, expected) => { + expect(parseJsonOutput(payload, 'pnpm audit')).toBe(expected); + }); }); describe('buildVersionMap', () => { @@ -82,6 +90,17 @@ describe('buildVersionMap', () => { }); }); + it('accepts a single null-prototype package tree', () => { + const tree = Object.create(null); + tree.dependencies = { + validator: { version: '13.15.23' }, + }; + + expect(mapToSortedObject(buildVersionMap(tree))).toEqual({ + validator: ['13.15.23'], + }); + }); + it.each([null, new Map(), new Date()])( 'rejects invalid dependency tree payload %#', (payload) => { @@ -223,6 +242,32 @@ describe('normalizeBulkAdvisories', () => { }); }); + it('snapshots a normalized bulk advisory transformation', () => { + expect( + normalizeBulkAdvisories({ + ws: [ + { + id: 110001, + severity: 'moderate', + title: 'Uninitialized memory disclosure', + url: 'https://github.com/advisories/GHSA-58QX-3VCG-4XPX', + }, + ], + }), + ).toMatchInlineSnapshot(` + { + "GHSA-58qx-3vcg-4xpx": { + "github_advisory_id": "GHSA-58qx-3vcg-4xpx", + "id": 110001, + "package_name": "ws", + "severity": "moderate", + "title": "Uninitialized memory disclosure", + "url": "https://github.com/advisories/GHSA-58QX-3VCG-4XPX", + }, + } + `); + }); + it('rejects malformed bulk advisory payloads', () => { expect(() => normalizeBulkAdvisories(null)).toThrow( 'Invalid bulk advisory payload: expected an object keyed by package name.', diff --git a/scripts/security-audit-reporting.test.mjs b/scripts/security-audit-reporting.test.mjs index b8f6ebcf..23ea203a 100644 --- a/scripts/security-audit-reporting.test.mjs +++ b/scripts/security-audit-reporting.test.mjs @@ -3,6 +3,10 @@ import fc from 'fast-check'; import { describe, expect, it, vi } from 'vitest'; import { assertNoExpired } from '../security/audit-exception-policy.js'; +import { + partitionAdvisoriesById as partitionAdvisoriesByIdFromUtils, + reportUnexpectedAdvisories as reportUnexpectedAdvisoriesFromUtils, +} from '../security/audit-utils.js'; import { partitionAdvisoriesById, reportUnexpectedAdvisories, @@ -33,6 +37,10 @@ function throwingPolicyIo() { } describe('partitionAdvisoriesById', () => { + it('is re-exported from the shared audit utility surface', () => { + expect(partitionAdvisoriesByIdFromUtils).toBe(partitionAdvisoriesById); + }); + it('partitions advisories without reordering either group', () => { const first = advisory('GHSA-1111-2222-3333'); const second = advisory('GHSA-4444-5555-6666'); @@ -67,6 +75,10 @@ describe('partitionAdvisoriesById', () => { }); describe('reportUnexpectedAdvisories', () => { + it('is re-exported from the shared audit utility surface', () => { + expect(reportUnexpectedAdvisoriesFromUtils).toBe(reportUnexpectedAdvisories); + }); + it('returns false and writes nothing for an empty report', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); @@ -102,6 +114,19 @@ describe('reportUnexpectedAdvisories', () => { }); describe('assertNoExpired', () => { + it('uses the injected current date instead of wall-clock time', () => { + const policyIo = throwingPolicyIo(); + + assertNoExpired( + [exceptionEntry({ addedAt: '2024-01-01', expiresAt: '2024-01-31' })], + new Date('2024-01-30T00:00:00.000Z'), + policyIo, + ); + + expect(policyIo.exit).not.toHaveBeenCalled(); + expect(policyIo.error).not.toHaveBeenCalled(); + }); + it('allows exceptions expiring on or after the current date', () => { fc.assert( fc.property(fc.date({ min: new Date('2024-01-01'), max: new Date('2030-12-31') }), (date) => { diff --git a/security/audit-utils.js b/security/audit-utils.js index b789c8f9..465ddf31 100644 --- a/security/audit-utils.js +++ b/security/audit-utils.js @@ -203,7 +203,7 @@ export async function runAuditJson(auditIo = defaultAuditIo) { const json = parseJsonOutput(stdout, 'pnpm audit'); if (isRetiredAuditEndpoint(json)) { - return runBulkAdvisoryAudit(auditIo); + return await runBulkAdvisoryAudit(auditIo); } return { json, status }; From ef395da386a4836672f0b9b5e81395b877f1491e Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 22:24:00 +0200 Subject: [PATCH 26/30] Share audit object prototype checks Extract the plain-object prototype predicate and use it for both dependency tree nodes and advisory payload objects. This keeps class instances out of both audit parsing paths while preserving null-prototype object support. --- security/audit-package-data.js | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/security/audit-package-data.js b/security/audit-package-data.js index e1564bd0..a79cd2b5 100644 --- a/security/audit-package-data.js +++ b/security/audit-package-data.js @@ -119,6 +119,17 @@ function isPlainObject(value) { return isNonNullObject(value) && !Array.isArray(value); } +/** + * Return `true` when `value`'s prototype is `Object.prototype` or `null` + * (i.e. a plain object literal or `Object.create(null)`). + * @param {object} value Non-null object to test. + * @returns {boolean} + */ +function hasPlainObjectPrototype(value) { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + /** Return `true` when a value is a valid `pnpm ls` dependency tree node * (a non-null, non-array plain object). * @param {unknown} value Value to test. @@ -128,11 +139,7 @@ function isPlainObject(value) { * @example isValidTreeNode([]); // false */ function isValidTreeNode(value) { - if (!isPlainObject(value)) { - return false; - } - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; + return isPlainObject(value) && hasPlainObjectPrototype(value); } /** Build the installed package-version map from parsed `pnpm ls` output. @@ -223,12 +230,14 @@ function deriveAdvisoryKey(packageName, advisory) { return { key, githubAdvisoryId }; } -/** Return `true` when a value is a plain (non-array, non-null) object. +/** Return `true` when a value is a plain (non-array, non-null) object with a plain + * prototype (`Object.prototype` or `null`). * @param {unknown} value Value to test. @returns {boolean} - * @example isPlainAdvisoryObject({ id: 1 }); // true + * @example isPlainAdvisoryObject({ id: 1 }); // true + * @example isPlainAdvisoryObject(new Map()); // false */ function isPlainAdvisoryObject(value) { - return isPlainObject(value); + return isPlainObject(value) && hasPlainObjectPrototype(value); } /** Validate and merge one advisory into the shared accumulator. From 6e25140a7ee90781fa7b97bcb7a40cd051453876 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 26 May 2026 00:10:11 +0200 Subject: [PATCH 27/30] Inject audit reporting IO adapters Document audit exception policy responsibilities, route advisory reporting through an injected IO adapter, cover bulk-advisory abort cleanup, and reuse the shared Makefile tool guard for cargo-audit. --- Makefile | 3 +- scripts/makefile-audit.test.mjs | 2 +- scripts/security-audit-helpers.test.mjs | 93 +++++++++++++++++++++++ scripts/security-audit-reporting.test.mjs | 30 +++----- security/audit-exception-policy.js | 22 +++++- security/audit-reporting.js | 11 ++- 6 files changed, 137 insertions(+), 24 deletions(-) diff --git a/Makefile b/Makefile index 595cdf7b..ede56749 100644 --- a/Makefile +++ b/Makefile @@ -217,8 +217,9 @@ audit-node: deps pnpm run audit:validate rust-audit: - @command -v cargo-audit >/dev/null 2>&1 || { echo "Error: cargo-audit is required. Install it with 'cargo binstall --no-confirm cargo-audit@0.22.1'."; exit 1; } + $(call ensure_tool,cargo-audit) # RUSTSEC-2023-0071 is in SQLx's optional MySQL support; this workspace only enables PostgreSQL. + # Install cargo-audit with: cargo binstall --no-confirm cargo-audit@0.22.1 $(CARGO) audit --file Cargo.lock $(CARGO_AUDIT_IGNORES) lockfile: diff --git a/scripts/makefile-audit.test.mjs b/scripts/makefile-audit.test.mjs index 724f105c..ec290820 100644 --- a/scripts/makefile-audit.test.mjs +++ b/scripts/makefile-audit.test.mjs @@ -43,7 +43,7 @@ describe('Makefile audit targets', () => { const stdout = await dryRunMake('rust-audit'); expect(stdout).toContain('command -v cargo-audit'); - expect(stdout).toContain('cargo-audit is required'); + expect(stdout).toContain('printf "Error: \'%s\' is required, but not installed\\n" "cargo-audit"'); expect(stdout).toContain('cargo-audit@0.22.1'); }); diff --git a/scripts/security-audit-helpers.test.mjs b/scripts/security-audit-helpers.test.mjs index b5801b73..2d07a183 100644 --- a/scripts/security-audit-helpers.test.mjs +++ b/scripts/security-audit-helpers.test.mjs @@ -366,3 +366,96 @@ describe('runAuditJson audit IO boundary', () => { ); }); }); + +describe('fetchBulkAdvisories timeout and abort handling', () => { + /** Build an auditIo that triggers the retired-endpoint code path. + * @param {{ fetchStub: Function, timeoutStub?: Function, clearTimeoutStub?: Function }} options Test doubles for bulk advisory IO. + * @returns {object} Audit IO adapter. + */ + function retiredEndpointAuditIo({ + fetchStub, + timeoutStub = vi.fn(() => 99), + clearTimeoutStub = vi.fn(), + }) { + return { + clearTimeout: clearTimeoutStub, + execFileSync: vi.fn(() => { + throw new Error('pnpm config should not be called'); + }), + fetch: fetchStub, + getEnv: vi.fn((name) => + name === 'npm_config_registry' ? 'https://registry.example.test/' : undefined, + ), + setTimeout: timeoutStub, + spawnSync: vi + .fn() + .mockReturnValueOnce( + createCompletedResult( + 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(createCompletedResult('[{"dependencies":{}}]')), + }; + } + + it('throws a timeout message when the fetch is aborted via AbortController', async () => { + const fetchStub = vi.fn(async () => { + throw new DOMException('The operation was aborted.', 'AbortError'); + }); + + const auditIo = retiredEndpointAuditIo({ fetchStub }); + + await expect(runAuditJson(auditIo)).rejects.toThrow( + /Bulk advisory audit timed out after \d+ms at/, + ); + expect(auditIo.clearTimeout).toHaveBeenCalledWith(99); + }); + + it('calls clearTimeout even when response.text() throws an AbortError', async () => { + const fetchStub = vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + text: async () => { + throw new DOMException('The operation was aborted.', 'AbortError'); + }, + })); + + const auditIo = retiredEndpointAuditIo({ fetchStub }); + + await expect(runAuditJson(auditIo)).rejects.toThrow( + /Bulk advisory audit timed out after \d+ms at/, + ); + expect(auditIo.clearTimeout).toHaveBeenCalledWith(99); + }); + + it('always calls clearTimeout regardless of fetch outcome', async () => { + await fc.assert( + fc.asyncProperty(fc.boolean(), async (shouldAbort) => { + const clearTimeoutStub = vi.fn(); + const fetchStub = vi.fn(async () => { + if (shouldAbort) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + return { ok: true, status: 200, statusText: 'OK', text: async () => '{}' }; + }); + + const auditIo = retiredEndpointAuditIo({ fetchStub, clearTimeoutStub }); + + try { + await runAuditJson(auditIo); + } catch { + // Timeout throws are expected in the abort path. + } + + expect(clearTimeoutStub).toHaveBeenCalledWith(99); + }), + ); + }); +}); diff --git a/scripts/security-audit-reporting.test.mjs b/scripts/security-audit-reporting.test.mjs index 23ea203a..f2c44fbf 100644 --- a/scripts/security-audit-reporting.test.mjs +++ b/scripts/security-audit-reporting.test.mjs @@ -80,36 +80,30 @@ describe('reportUnexpectedAdvisories', () => { }); it('returns false and writes nothing for an empty report', () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const reportingIo = { error: vi.fn() }; - expect(reportUnexpectedAdvisories([], 'Unexpected advisories:')).toBe(false); - expect(errorSpy).not.toHaveBeenCalled(); - - errorSpy.mockRestore(); + expect(reportUnexpectedAdvisories([], 'Unexpected advisories:', reportingIo)).toBe(false); + expect(reportingIo.error).not.toHaveBeenCalled(); }); - it('formats unexpected advisory output consistently', () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + it('reports unexpected advisories to the injected reportingIo adapter', () => { + const errorLines = []; + const reportingIo = { error: (...args) => errorLines.push(args.join(' ')) }; expect( reportUnexpectedAdvisories( - [ - advisory('GHSA-vghf-hv5q-vc2g', 'Validator SSRF'), - { title: 'Missing identifier' }, - ], - 'pnpm audit reported vulnerabilities without exceptions:', + [advisory('GHSA-1', 'Example')], + 'Unexpected advisories:', + reportingIo, ), ).toBe(true); - expect(errorSpy.mock.calls.map(([line]) => line)).toMatchInlineSnapshot(` + expect(errorLines).toMatchInlineSnapshot(` [ - "pnpm audit reported vulnerabilities without exceptions:", - "- GHSA-vghf-hv5q-vc2g: Validator SSRF", - "- UNKNOWN: Missing identifier", + "Unexpected advisories:", + "- GHSA-1: Example", ] `); - - errorSpy.mockRestore(); }); }); diff --git a/security/audit-exception-policy.js b/security/audit-exception-policy.js index 0dc166bf..eb9dd52b 100644 --- a/security/audit-exception-policy.js +++ b/security/audit-exception-policy.js @@ -1,4 +1,24 @@ -/** @file Audit exception ledger policy checks shared by validator tests. */ +/** + * @file Audit exception ledger policy: validates time-bound dependency-audit + * exception entries and enforces expiry at validation time. + * + * Owns the `assertNoExpired` export, which reads an array of exception entries + * (each carrying `id`, `package`, `addedAt`, and `expiresAt` ISO-date fields), + * computes today's date relative to an injected `currentDate`, and exits with + * status 1 when any entry has expired or carries an invalid date range + * (`addedAt` later than `expiresAt`). + * + * Relationships: + * - `security/validate-audit.js` imports `assertNoExpired` and invokes it after + * loading `security/audit-exceptions.json` through AJV schema validation. + * - Test suites (`scripts/security-audit-reporting.test.mjs`) import + * `assertNoExpired` directly with injected `policyIo` adapters to exercise + * expiry and inverted-range paths without calling `process.exit`. + * + * IO effects are isolated behind the `policyIo` adapter (`error`, `exit`); the + * `defaultPolicyIo` implementation delegates to `console.error` and + * `process.exit`. + */ const defaultPolicyIo = { error: (...args) => console.error(...args), diff --git a/security/audit-reporting.js b/security/audit-reporting.js index a4d60bef..1833f171 100644 --- a/security/audit-reporting.js +++ b/security/audit-reporting.js @@ -47,18 +47,23 @@ function formatAdvisoryLine(advisory) { return `- ${id}${suffix}`; } +const defaultReportingIo = { + error: (...args) => console.error(...args), +}; + /** 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. + * @param {{ error: (...args: unknown[]) => void }} [reportingIo=defaultReportingIo] IO adapter for stderr 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) { +export function reportUnexpectedAdvisories(unexpected, heading, reportingIo = defaultReportingIo) { if (unexpected.length === 0) { return false; } - console.error(heading); + reportingIo.error(heading); for (const advisory of unexpected) { - console.error(formatAdvisoryLine(advisory)); + reportingIo.error(formatAdvisoryLine(advisory)); } return true; } From af495b3c693e115a0f9dad5675b4963355b5eb67 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 26 May 2026 11:22:43 +0200 Subject: [PATCH 28/30] Reference issue 360 in audit suppressions (#360) Add the tracked GitHub issue to the roadmap 3.5.1 dead-code expectations for generated schema and persistence timestamp fields. Keep the cleanup obligation traceable from the source without changing runtime behaviour. --- backend/src/inbound/http/schemas.rs | 6 +++--- backend/src/outbound/persistence/models.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/inbound/http/schemas.rs b/backend/src/inbound/http/schemas.rs index f26b2203..dfad28aa 100644 --- a/backend/src/inbound/http/schemas.rs +++ b/backend/src/inbound/http/schemas.rs @@ -46,7 +46,7 @@ pub enum ErrorCodeSchema { #[schema(as = crate::domain::Error)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub struct ErrorSchema { /// Stable machine-readable error code. @@ -69,7 +69,7 @@ pub struct ErrorSchema { #[schema(as = crate::domain::User)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub struct UserSchema { /// Stable user identifier. @@ -124,7 +124,7 @@ pub struct InterestThemeIdSchema(pub String); #[schema(as = crate::domain::UserInterests)] #[expect( dead_code, - reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1" + reason = "Used only for OpenAPI schema generation via utoipa; tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub struct UserInterestsSchema { /// Stable user identifier. diff --git a/backend/src/outbound/persistence/models.rs b/backend/src/outbound/persistence/models.rs index c7e3e65d..edb400b1 100644 --- a/backend/src/outbound/persistence/models.rs +++ b/backend/src/outbound/persistence/models.rs @@ -23,7 +23,7 @@ pub(crate) struct UserRow { pub created_at: DateTime, #[expect( dead_code, - reason = "schema field for future audit trail support tracked by docs/backend-roadmap.md 3.5.1" + reason = "schema field for future audit trail support tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub updated_at: DateTime, } @@ -184,12 +184,12 @@ pub(crate) struct WalkSessionRow { pub highlighted_poi_ids: Vec, #[expect( dead_code, - reason = "schema field for auditing support tracked by docs/backend-roadmap.md 3.5.1" + reason = "schema field for auditing support tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub created_at: DateTime, #[expect( dead_code, - reason = "schema field for auditing support tracked by docs/backend-roadmap.md 3.5.1" + reason = "schema field for auditing support tracked by docs/backend-roadmap.md 3.5.1 (see `#360`)" )] pub updated_at: DateTime, } From 352b4a1f9f48bad87e860e8a6b2b37a24486577c Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 26 May 2026 16:43:02 +0200 Subject: [PATCH 29/30] Assert audit timeout property outcomes Replace the catch-all in the bulk advisory timeout property test with explicit expectations for each generated case. The aborting path must reject with the timeout message, while the successful path must resolve with a clean audit status. --- scripts/security-audit-helpers.test.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/security-audit-helpers.test.mjs b/scripts/security-audit-helpers.test.mjs index 2d07a183..a74494ad 100644 --- a/scripts/security-audit-helpers.test.mjs +++ b/scripts/security-audit-helpers.test.mjs @@ -448,10 +448,12 @@ describe('fetchBulkAdvisories timeout and abort handling', () => { const auditIo = retiredEndpointAuditIo({ fetchStub, clearTimeoutStub }); - try { - await runAuditJson(auditIo); - } catch { - // Timeout throws are expected in the abort path. + if (shouldAbort) { + await expect(runAuditJson(auditIo)).rejects.toThrow( + /Bulk advisory audit timed out after \d+ms at/, + ); + } else { + await expect(runAuditJson(auditIo)).resolves.toMatchObject({ status: 0 }); } expect(clearTimeoutStub).toHaveBeenCalledWith(99); From 3052769acb6e3e8db88828daa1de957f0593b5d8 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 26 May 2026 20:34:04 +0200 Subject: [PATCH 30/30] Document reporting IO adapter signature Update the developer guide to match the current `reportUnexpectedAdvisories` signature and describe the optional stderr adapter used by tests. --- docs/developers-guide.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8e834b1f..16f036d8 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -178,7 +178,10 @@ The JavaScript dependency-audit flow is split by responsibility: `normalizeBulkAdvisories(bulkPayload)`. - `security/audit-reporting.js` owns advisory partitioning and stderr output. Its public helpers are `partitionAdvisoriesById(advisories, allowedIds)` and - `reportUnexpectedAdvisories(unexpected, heading)`. + `reportUnexpectedAdvisories(unexpected, heading, reportingIo = defaultReportingIo)`. + The optional `reportingIo` adapter must expose an `error(...args)` method; + pass a custom adapter in tests to capture output without writing to stderr. + When omitted, `defaultReportingIo` delegates to `console.error`. - `security/audit-exception-policy.js` owns exception-ledger date policy. Its public helper is `assertNoExpired(entries, currentDate?, policyIo?)`. - `security/validate-audit.js` applies repository policy to the parsed audit