From 1ad27e5b9bd6b0696dd3389a26da33d6e41f547d Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:13:38 +0800 Subject: [PATCH 1/6] Fix shared create response ordering without overwriting subscribe models --- COMPATIBILITY.md | 14 ++ README.md | 14 ++ ...ass-replacement.node24-ignore-scripts.json | 10 +- docs/releases/web-console-1.6.171.md | 54 +++++ package-lock.json | 10 +- package.json | 4 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 4 +- .../check-ui-ember-api-store-fetch-upgrade | 40 +++- scripts/node24-lock-smoke.js | 38 ++- .../vendor/api-store-create-order-test.js | 225 ++++++++++++++++++ vendor/ember-api-store-compat/UPSTREAM.md | 10 + .../addon/mixins/type.js | 11 + .../addon/services/store.js | 20 +- .../ember-api-store-2.8.5-pasturestack.5.tgz | Bin 0 -> 23020 bytes vendor/ember-api-store-compat/package.json | 2 +- 17 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 docs/releases/web-console-1.6.171.md create mode 100644 tests/unit/vendor/api-store-create-order-test.js create mode 100644 vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 27ffc44e07..403711b3d4 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,20 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +Candidate `1.6.171` confines create-response adoption to ID-less POST/201 and an +existing exact-ID/concrete-type canonical model in the same Store, generation +and API base. It does not re-import stale scalar or nested create fields over +that model. The original draft-save completion identity and subtype/base aliases +remain intact. Resource IDs are not normalized. Missing schemas grant no access. +GET, PUT, action POST (including reused options), uncached creates, non-201, +204 and error paths retain normal processing. No API authorization, session, +MFA, payload, resource lifecycle or backend changes are introduced. +Revision 5 is a new archive; revision 4 is not overwritten. Focused Chrome +validation passed 36/36, including ten new cases and 100 barrier iterations; +failure, skip and todo counts are zero. Official validation, publication and +packaged fresh-volume acceptance remain pending, not full-matrix PASS. +See the [release note](docs/releases/web-console-1.6.171.md). + Published `1.6.170` accepts null only for the optional expanded `mounts` projection while retaining the real complete empty pool relationship and full scoped mount-cache proof. It preserves nonempty raw ID binding, current-project diff --git a/README.md b/README.md index e55a6f115f..7afdf0e348 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,20 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Candidate `1.6.171` repairs a shared Store ordering defect: a delayed initial +create response could overwrite a newer subscribe model and leave a successfully +created local Volume stuck in its initial state. Only ID-less create POST/201 +uses an existing exact-ID, concrete-type canonical model in the same Store, +generation and API base. Ordinary reads, updates, actions and backend permissions +keep their existing contracts. API-store compatibility revision 5 replaces +revision 4 without changing the dependency graph; earlier archives are retained. +Focused Chrome validation passed 36/36 tests, including ten new regressions and +100 deterministic subscribe-before-201 barrier iterations, with no failures, +skips or todo. Official validation, immutable publication and packaged fresh-volume +acceptance are separate pending gates. The complete permission / +resource / locale matrix remains INCOMPLETE. See the +[release note](docs/releases/web-console-1.6.171.md). + Published `1.6.170` corrects an optional `mounts: null` projection being mistaken for a real allocation in the shared local-volume list. It preserves the complete advertised pool relationship, full scoped mount cache, exact-volume diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 3f22097502..c5d74e292d 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" @@ -33,7 +33,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", @@ -9051,8 +9051,8 @@ }, "node_modules/ember-api-store": { "version": "2.8.5", - "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", - "integrity": "sha512-Z/ZLyAm2ne25B17gONI/s/ufRRz1uH4CfOZ3VbUItBwXnSpW+ckZKub+2vC82fr9YOtgrgIsqirBMf3yfWo2Zw==", + "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "integrity": "sha512-m+IpOrSUqogl3EP8DqefpDuO/9leZ4Bycge7MLwqYASOz75V/J6ay1bFGaOWd2ckaohymODeOlkVzyVzmWLupw==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/docs/releases/web-console-1.6.171.md b/docs/releases/web-console-1.6.171.md new file mode 100644 index 0000000000..a411dc2abd --- /dev/null +++ b/docs/releases/web-console-1.6.171.md @@ -0,0 +1,54 @@ +# Web Console 1.6.171 + +Candidate shared Store fix; publication and packaged QA remain separate gates. + +## Root cause and changes + +In packaged native QA, subscribe delivered an inactive created local Volume +before the browser received its original HTTP 201 response. Importing that +initial response replaced the newer canonical model with registering fields. +The backend creation succeeded, but the frontend never reached the expected +stable model state. This is not fixed by relaxing allocation or loading checks. + +`vendor/ember-api-store-compat/addon/mixins/type.js` marks only an ID-less new +record's POST with its concrete type, Store generation and API base. Existing +record saves and actions discard a reused marker. The marker is internal request +metadata, not JSON payload. The existing save merge and canonical alias logic +preserve the saved draft's identity. + +`vendor/ember-api-store-compat/addon/services/store.js` uses a canonical model +already present for the exact opaque ID and concrete type only for a matching +create POST/201 in that same Store/generation/API base. It does not typeify the +old response's fields or nested resources over that model. HTTP status and xhr +metadata retain their contracts. Uncached create, GET, PUT, action, non-201, +204 and errors retain the existing path. This is not a general timestamp-based +ordering rule for all updates. + +Compatibility revision 5 uses a new immutable archive; revision 4 is unchanged. +The lockfile's dependency versions/graph remain unchanged. No authentication, +backend, authorization, data migration or production configuration changes. + +## Verification boundary + +Ten regression tests use the installed Store/Resource/Schema/Collection package, +not an alternate handwritten store. A deferred HTTP barrier repeats the +subscribe-before-201 race 100 times without sleeps. Adjacent cases cover +uncached creation, subtype/base aliases, stale nested fields, case-sensitive +IDs, distinct stores, reset generation, changed base, ordinary methods, +204/errors, existing-save option reuse and action option reuse. + +Focused local Chrome 153 validation passed 36/36 tests with zero failure, skip +or todo, including all ten new cases and 100 deferred-barrier iterations. +Adjacent Store/schema/reference, allocation-proof, route and subscribe-session +cases remain passing. The installed revision-5 archive matches the runtime source. +Exact-source official validation, signed numeric release and packaged native +fresh-volume create/cancel/refresh/denial/removal remain pending. +Historical failed QA receipts stay HOLD; the complete +permission/resource/locale matrix remains INCOMPLETE. + +## Upgrade and rollback + +Use the separately released Server patch that packages this exact component. +Retain existing Compose environment, persistent volumes and the previous +immutable image. No database migration or runtime patch is required. This work +does not authorize company deployment or a change to HAProxy/OIDC settings. diff --git a/package-lock.json b/package-lock.json index 3f22097502..c5d74e292d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" @@ -33,7 +33,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", @@ -9051,8 +9051,8 @@ }, "node_modules/ember-api-store": { "version": "2.8.5", - "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", - "integrity": "sha512-Z/ZLyAm2ne25B17gONI/s/ufRRz1uH4CfOZ3VbUItBwXnSpW+ckZKub+2vC82fr9YOtgrgIsqirBMf3yfWo2Zw==", + "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "integrity": "sha512-m+IpOrSUqogl3EP8DqefpDuO/9leZ4Bycge7MLwqYASOz75V/J6ay1bFGaOWd2ckaohymODeOlkVzyVzmWLupw==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 1902dab79e..28758ebccb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { @@ -76,7 +76,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 5c58ee80b0..d78bc35a33 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.170" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.170" +if [[ "$version" != "1.6.171" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.171" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 3d3469751a..60fc4ade01 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.170 browser-session broker-broadcast + 1.6.171 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 894541c21c..e17e99f4f7 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -53,7 +53,7 @@ package = json.loads(package_path.read_text(encoding="utf-8")) ci_source = ci_path.read_text(encoding="utf-8") if "npm audit --audit-level=high" not in ci_source: fail("live npm Critical/High audit gate is missing from scripts/ci") -api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz" +api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz" lock_bytes = lock_path.read_bytes() baseline_bytes = baseline_path.read_bytes() if lock_bytes != baseline_bytes: @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.170": +if package.get("version") != "1.6.171": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/scripts/check-ui-ember-api-store-fetch-upgrade b/scripts/check-ui-ember-api-store-fetch-upgrade index 8dfd0b6cfc..de21614f27 100755 --- a/scripts/check-ui-ember-api-store-fetch-upgrade +++ b/scripts/check-ui-ember-api-store-fetch-upgrade @@ -13,7 +13,7 @@ package = json.loads(package_path.read_text(encoding="utf-8")) lock = json.loads(lock_path.read_text(encoding="utf-8")) packages = lock.get("packages", {}) compat_spec = "file:vendor/ember-fetch-compat/ember-fetch-5.1.3-pasturestack.6.tgz" -api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz" +api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz" def fail(message): @@ -110,24 +110,24 @@ with tarfile.open(archive_path, "r:gz") as archive: fail("ember-fetch compatibility source and install archive differ") api_store_compat_dir = repo / "vendor/ember-api-store-compat" -api_store_archive = api_store_compat_dir / "ember-api-store-2.8.5-pasturestack.4.tgz" +api_store_archive = api_store_compat_dir / "ember-api-store-2.8.5-pasturestack.5.tgz" api_store_package_path = api_store_compat_dir / "package.json" api_store_license_path = api_store_compat_dir / "LICENSE" api_store_upstream_path = api_store_compat_dir / "UPSTREAM.md" for required in [api_store_archive, api_store_package_path, api_store_license_path, api_store_upstream_path]: if not required.is_file(): fail(f"reviewed ember-api-store compatibility file missing: {required.relative_to(repo)}") -if hashlib.sha256(api_store_archive.read_bytes()).hexdigest() != "32120c02f8e8f8fbe98ad643420c2eb5d48382e674e7561631dd5015c28c6fec": +if hashlib.sha256(api_store_archive.read_bytes()).hexdigest() != "90da9ebdc36a8069629086d011e799691ace8f88c13d9c9df1333c77015a2ab8": fail("reviewed ember-api-store compatibility archive hash changed") if hashlib.sha256(api_store_license_path.read_bytes()).hexdigest() != "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594": fail("ember-api-store upstream Apache-2.0 license changed") api_store_package = json.loads(api_store_package_path.read_text(encoding="utf-8")) if api_store_package.get("dependencies") != expected_api_store_deps: fail("ember-api-store compatibility source metadata changed") -if api_store_package.get("pasturestackCompatibility", {}).get("revision") != 4: +if api_store_package.get("pasturestackCompatibility", {}).get("revision") != 5: fail("ember-api-store compatibility revision marker is missing") with tarfile.open(api_store_archive, "r:gz") as archive: - for relative in ["package.json", "LICENSE", "UPSTREAM.md", "addon/services/store.js"]: + for relative in ["package.json", "LICENSE", "UPSTREAM.md", "addon/services/store.js", "addon/mixins/type.js"]: archived = archive.extractfile(f"package/{relative}") source = api_store_compat_dir / relative if archived is None or archived.read() != source.read_bytes(): @@ -148,6 +148,34 @@ for marker in ["actual bulk cache and inherited Resource.schema", "mixed-case ca if marker not in schema_lookup_tests: fail(f"API-store schema lookup regression missing: {marker}") +type_runtime = (api_store_compat_dir / "addon/mixins/type.js").read_text(encoding="utf-8") +create_save = type_runtime.split(" save: function(opt) {", 1)[1] +action_dispatch = type_runtime.split(" doAction: function(name, data, opt) {", 1)[1].split(" save: function(opt) {", 1)[0] +if "delete opt.createIdentity;" not in action_dispatch: + fail("action POST must clear any reused create identity") +request_success = api_store_runtime.split(" _requestSuccess(xhr,opt) {", 1)[1].split(" _requestFailed(xhr,opt) {", 1)[0] +for marker in ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "opt.createIdentity = {", "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')"]: + if marker not in create_save: + fail(f"create-only save identity marker missing: {marker}") +for marker in ["xhr.status === 201 && opt.method === 'POST' && creation", "creation.generation === get(this, 'generation')", "creation.baseUrl === get(this, 'baseUrl')", "cached.get('id') === xhr.body.id", "get(cached, 'store') === this && this.hasRecord(cached)", "response = response || this._typeify(xhr.body);"]: + if marker not in request_success: + fail(f"same-store create response adoption marker missing: {marker}") +create_order_tests = (repo / "tests/unit/vendor/api-store-create-order-test.js").read_text(encoding="utf-8") +for marker in [ + "delayed 201 cannot overwrite the newer subscribe model, repeated with deterministic barriers 100 times", + "uncached creates retain the original response import path", + "subtype and base-type aliases adopt one saved model without regressing the subscribe fields", + "cached create adoption does not run stale mangleIn or nested resource imports", + "opaque case-sensitive IDs and exact concrete types do not borrow another canonical model", + "another project store, reset generation and changed API base cannot use create adoption", + "GET, PUT, action POST and non-201 responses preserve normal imports", + "204 and errors keep their HTTP semantics without importing a model", + "reusing save options cannot carry a create marker into an existing record save", + "action POST cannot reuse an old create marker even when the action returns 201", +]: + if marker not in create_order_tests: + fail(f"API-store create response order regression missing: {marker}") + for forbidden in [ "node_modules/ember-network", "node_modules/babel-traverse", @@ -426,7 +454,7 @@ for marker in [ deprecated = [path for path, item in packages.items() if item.get("deprecated")] print( "ui-ember-api-store-fetch-upgrade-ok " - f"version=2.8.5 api_store_compat_revision=4 ember-fetch=5.1.3 fetch_compat_revision=6 initializer_compat_revision=2 reference_compat_revision=2 " + f"version=2.8.5 api_store_compat_revision=5 ember-fetch=5.1.3 fetch_compat_revision=6 initializer_compat_revision=2 reference_compat_revision=2 " f"ember6_template_compat_revision=1 terminal_reconnect_revision=2 " f"deprecated_count={len(deprecated)} package_count={len(packages)}" ) diff --git a/scripts/node24-lock-smoke.js b/scripts/node24-lock-smoke.js index 3b8e675c6b..9f85ad89c4 100644 --- a/scripts/node24-lock-smoke.js +++ b/scripts/node24-lock-smoke.js @@ -701,7 +701,7 @@ function expectEmberApiStoreFetchUpgrade() { if (JSON.stringify(apiStoreInfo.dependencies) !== JSON.stringify(expectedApiStoreDependencies)) { fail(`ember-api-store reviewed dependency boundary changed: ${JSON.stringify(apiStoreInfo.dependencies)}`); } - if (!apiStoreInfo.pasturestackCompatibility || apiStoreInfo.pasturestackCompatibility.revision !== 4) { + if (!apiStoreInfo.pasturestackCompatibility || apiStoreInfo.pasturestackCompatibility.revision !== 5) { fail("ember-api-store compatibility revision is missing"); } if (!emberFetchInfo.pasturestackCompatibility || emberFetchInfo.pasturestackCompatibility.revision !== 6) { @@ -738,6 +738,40 @@ function expectEmberApiStoreFetchUpgrade() { fail("ember-api-store deferred request initialization fix is missing"); } + expectVendoredFileSha256("vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "90da9ebdc36a8069629086d011e799691ace8f88c13d9c9df1333c77015a2ab8"); + const typeMixin = fs.readFileSync(path.join(apiStoreDir, "addon/mixins/type.js"), "utf8"); + const actionDispatch = typeMixin.split(" doAction: function(name, data, opt) {")[1].split(" save: function(opt) {")[0]; + if (!actionDispatch.includes("delete opt.createIdentity;")) { + fail("ember-api-store action POST must clear any reused create identity"); + } + for (const marker of ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "opt.createIdentity = {", + "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')"]) { + if (!typeMixin.includes(marker)) { + fail(`ember-api-store create-only save identity marker missing: ${marker}`); + } + } + for (const marker of ["xhr.status === 201 && opt.method === 'POST' && creation", + "creation.generation === get(this, 'generation')", "creation.baseUrl === get(this, 'baseUrl')", + "cached.get('id') === xhr.body.id", "get(cached, 'store') === this && this.hasRecord(cached)", + "response = response || this._typeify(xhr.body);"]) { + if (!storeService.includes(marker)) { + fail(`ember-api-store same-store create response adoption marker missing: ${marker}`); + } + } + const createOrderTests = fs.readFileSync("tests/unit/vendor/api-store-create-order-test.js", "utf8"); + for (const marker of ["delayed 201 cannot overwrite the newer subscribe model", + "cached create adoption does not run stale mangleIn or nested resource imports", + "another project store, reset generation and changed API base cannot use create adoption", + "GET, PUT, action POST and non-201 responses preserve normal imports", + "204 and errors keep their HTTP semantics without importing a model", + "reusing save options cannot carry a create marker into an existing record save", + "action POST cannot reuse an old create marker even when the action returns 201"]) { + if (!createOrderTests.includes(marker)) { + fail(`ember-api-store create response order regression missing: ${marker}`); + } + } + const fetchRuntimePath = path.join(emberFetchDir, "vendor/ember-fetch.js"); const fetchRuntime = fs.readFileSync(fetchRuntimePath, "utf8"); for (const marker of [ @@ -797,7 +831,7 @@ function expectEmberApiStoreFetchUpgrade() { fail("ember-fetch native production wrapper smoke failed"); } - console.log("ember-api-store-fetch-upgrade-smoke-ok version=2.8.5 api_store_compat_revision=4 ember-fetch=5.1.3 fetch_compat_revision=6 native_fetch=ok legacy_build_graph=absent"); + console.log("ember-api-store-fetch-upgrade-smoke-ok version=2.8.5 api_store_compat_revision=5 ember-fetch=5.1.3 fetch_compat_revision=6 native_fetch=ok legacy_build_graph=absent"); } function expectBrowserGlobalBundle(file, globalName, expectedVersion) { diff --git a/tests/unit/vendor/api-store-create-order-test.js b/tests/unit/vendor/api-store-create-order-test.js new file mode 100644 index 0000000000..ec45c59669 --- /dev/null +++ b/tests/unit/vendor/api-store-create-order-test.js @@ -0,0 +1,225 @@ +import { module, test } from 'qunit'; +import { setOwner } from '@ember/application'; +import { run } from '@ember/runloop'; +import { defer, resolve } from 'rsvp'; +import Store from 'ember-api-store/services/store'; +import Resource from 'ember-api-store/models/resource'; +import Schema from 'ember-api-store/models/schema'; +import Collection from 'ember-api-store/models/collection'; + +// Real installed compatibility package and Type.save. Only the HTTP boundary +// is deferred: subscribe import must complete before the original 201 arrives. +function fixture(project = '1a-test') { + const objects = new Set(); + const requests = []; + const store = Store.create({ baseUrl: `/v2-beta/projects/${project}` }); + setOwner(store, { lookup(name) { + if ( name === 'service:fastboot' ) { return { isFastBoot: false }; } + const Factory = name === 'model:schema' ? Schema : + name === 'model:collection' ? Collection : Resource; + const object = Factory.create(); + objects.add(object); + return object; + } }); + const createRecord = store.createRecord.bind(store); + store.createRecord = (...args) => { + const object = createRecord(...args); + objects.add(object); + return object; + }; + const response = defer(); + store.rawRequest = (options) => { requests.push(options); return response.promise; }; + store._bulkAdd('schema', ['volume', 'loadBalancerService', 'service'].map(id => ({ + type: 'schema', id, resourceFields: {}, collectionMethods: ['GET', 'POST'], + links: { collection: `${store.baseUrl}/${id}s` }, + }))); + return { store, requests, response, + destroy() { + store.all('schema').forEach(object => objects.add(object)); + run(() => { + objects.forEach(object => { if ( !object.isDestroyed ) { object.destroy(); } }); + store.destroy(); + }); + }, + }; +} + +const initial = (type = 'volume', id = 'Opaque-ID') => ({ + type, id, accountId: '1a-test', name: 'created', state: 'registering', externalId: null, +}); +const current = (type = 'volume', id = 'Opaque-ID') => ({ + ...initial(type, id), state: 'inactive', externalId: 'created', +}); + +module('Unit | Vendor | API store create response order', function() { + test('delayed 201 cannot overwrite the newer subscribe model, repeated with deterministic barriers 100 times', async function(assert) { + for ( let index = 0; index < 100; index++ ) { + const f = fixture(); + try { + const draft = f.store.createRecord({type: 'volume', name: 'created'}); + const saving = run(() => draft.save()); + assert.strictEqual(f.requests.length, 1, 'one create dispatch, no extra GET'); + const live = run(() => f.store._typeify(current())); + const xhr = {status: 201, body: initial()}; + run(() => f.response.resolve(xhr)); + const saved = await saving; + assert.strictEqual(saved, draft, 'existing save completion identity retained'); + assert.strictEqual(saved.get('state'), 'inactive'); + assert.strictEqual(saved.get('externalId'), 'created'); + assert.strictEqual(f.store.getById('volume', 'Opaque-ID'), draft); + assert.strictEqual(f.store.all('volume').get('length'), 1, 'no duplicate canonical resource'); + assert.strictEqual(live.get('xhr'), xhr, 'actual HTTP metadata remains attached'); + assert.strictEqual(f.requests[0].responseStatus, 201); + assert.notOk(Object.hasOwn(f.requests[0].data, 'createIdentity'), 'internal marker is not payload'); + } finally { f.destroy(); } + } + }); + + test('uncached creates retain the original response import path', async function(assert) { + const f = fixture(); + try { + const draft = f.store.createRecord({type: 'volume', name: 'created'}); + const saving = run(() => draft.save()); + run(() => f.response.resolve({status: 201, body: initial()})); + assert.strictEqual(await saving, draft); + assert.strictEqual(draft.get('state'), 'registering'); + assert.strictEqual(f.store.getById('volume', 'Opaque-ID'), draft); + assert.strictEqual(f.requests.length, 1); + } finally { f.destroy(); } + }); + + test('subtype and base-type aliases adopt one saved model without regressing the subscribe fields', async function(assert) { + const f = fixture(); + try { + const draft = f.store.createRecord({type: 'loadBalancerService', baseType: 'service', name: 'created'}); + const saving = run(() => draft.save()); + run(() => f.store._typeify({...current('loadBalancerService'), baseType: 'service'})); + run(() => f.response.resolve({status: 201, body: {...initial('loadBalancerService'), baseType: 'service'}})); + assert.strictEqual(await saving, draft); + assert.strictEqual(draft.get('state'), 'inactive'); + assert.strictEqual(f.store.getById('loadBalancerService', 'Opaque-ID'), draft); + assert.strictEqual(f.store.getById('service', 'Opaque-ID'), draft); + assert.strictEqual(f.store.all('loadBalancerService').get('length'), 1); + assert.strictEqual(f.store.all('service').get('length'), 1); + } finally { f.destroy(); } + }); + + test('cached create adoption does not run stale mangleIn or nested resource imports', function(assert) { + const f = fixture(); + try { + const schema = f.store.getById('schema', 'volume'); + schema.set('resourceFields', { nested: {type: 'service'} }); + schema.notifyPropertyChange('typeifyFields'); + const nested = run(() => f.store._typeify({...current('service', 'Nested-ID'), state: 'active'})); + const live = run(() => f.store._typeify({...current(), nested})); + let conversionCalls = 0; + const createRecord = f.store.createRecord; + f.store.createRecord = (...args) => { conversionCalls++; return createRecord(...args); }; + const options = {method: 'POST', createIdentity: {type: 'volume', + generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}}; + const response = f.store._requestSuccess({status: 201, + body: {...initial(), nested: {...initial('service', 'Nested-ID'), state: 'creating'}}}, options); + assert.strictEqual(response, live); + assert.strictEqual(conversionCalls, 0, 'neither mangleIn nor nested typeify is invoked'); + assert.strictEqual(nested.get('state'), 'active'); + assert.strictEqual(live.get('nested'), nested); + } finally { f.destroy(); } + }); + + test('opaque case-sensitive IDs and exact concrete types do not borrow another canonical model', function(assert) { + const f = fixture(); + try { + const other = f.store._typeify(current('volume', 'opaque-id')); + const options = {method: 'POST', createIdentity: {type: 'volume', + generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}}; + const response = f.store._requestSuccess({status: 201, body: initial()}, options); + assert.notStrictEqual(response, other); + assert.strictEqual(response.get('id'), 'Opaque-ID'); + assert.strictEqual(response.get('state'), 'registering'); + const concrete = f.store._typeify({...current('loadBalancerService', 'Sub-ID'), baseType: 'service'}); + const base = f.store._requestSuccess({status: 201, body: initial('service', 'Sub-ID')}, + {...options, createIdentity: {...options.createIdentity, type: 'service'}}); + assert.strictEqual(base.get('type'), 'service', 'base alias goes through the normal import'); + assert.strictEqual(concrete.get('state'), 'registering', 'the new rule did not adopt the different concrete type'); + } finally { f.destroy(); } + }); + + test('another project store, reset generation and changed API base cannot use create adoption', function(assert) { + const f = fixture(); + const other = fixture('1a-other'); + try { + const foreign = other.store._typeify(current()); + const marker = {type: 'volume', generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}; + const response = f.store._requestSuccess({status: 201, body: initial()}, {method: 'POST', createIdentity: marker}); + assert.notStrictEqual(response, foreign); + assert.strictEqual(foreign.get('state'), 'inactive'); + run(() => f.store.reset()); + const afterReset = f.store._typeify(current()); + f.store._requestSuccess({status: 201, body: initial()}, {method: 'POST', createIdentity: marker}); + assert.strictEqual(afterReset.get('state'), 'registering', 'old generation does not opt into the new rule'); + const beforeBaseChange = {type: 'volume', generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}; + f.store.set('baseUrl', '/v2-beta/projects/1a-changed'); + afterReset.set('state', 'inactive'); + f.store._requestSuccess({status: 201, body: initial()}, {method: 'POST', createIdentity: beforeBaseChange}); + assert.strictEqual(afterReset.get('state'), 'registering', 'different base preserves prior import behavior'); + } finally { f.destroy(); other.destroy(); } + }); + + test('GET, PUT, action POST and non-201 responses preserve normal imports', function(assert) { + const f = fixture(); + try { + for ( const [method, status, marked] of [['GET', 201, true], ['PUT', 201, true], + ['POST', 200, true], ['POST', 201, false]] ) { + const live = f.store._typeify(current()); + const options = {method}; + if ( marked ) { options.createIdentity = {type: 'volume', + generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}; } + const response = f.store._requestSuccess({status, body: initial()}, options); + assert.strictEqual(response, live); + assert.strictEqual(response.get('state'), 'registering', `${method}/${status}/${marked} unchanged`); + } + } finally { f.destroy(); } + }); + + test('204 and errors keep their HTTP semantics without importing a model', async function(assert) { + const f = fixture(); + try { + const options = {method: 'POST', createIdentity: {type: 'volume', + generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}}; + const live = f.store._typeify(current()); + assert.strictEqual(f.store._requestSuccess({status: 204}, options), undefined); + assert.strictEqual(options.responseStatus, 204); + assert.strictEqual(live.get('state'), 'inactive'); + f.store.rawRequest = () => Promise.reject({status: 403, body: {type: 'error', status: 403, message: 'Forbidden'}}); + try { await f.store.request({...options, url: 'volume'}); assert.ok(false); } + catch (error) { assert.strictEqual(error.get('status'), 403); } + assert.strictEqual(live.get('state'), 'inactive'); + } finally { f.destroy(); } + }); + + test('reusing save options cannot carry a create marker into an existing record save', async function(assert) { + const f = fixture(); + try { + const record = f.store._typeify({...current(), links: {self: `${f.store.baseUrl}/volumes/Opaque-ID`}}); + const options = {createIdentity: {type: 'volume', generation: f.store.get('generation'), baseUrl: f.store.baseUrl}}; + f.store.rawRequest = request => { f.requests.push(request); return resolve({status: 200, body: initial()}); }; + await run(() => record.save(options)); + assert.strictEqual(f.requests[0].method, 'PUT'); + assert.notOk(Object.hasOwn(f.requests[0], 'createIdentity')); + assert.strictEqual(record.get('state'), 'registering'); + } finally { f.destroy(); } + }); + + test('action POST cannot reuse an old create marker even when the action returns 201', async function(assert) { + const f = fixture(); + try { + const record = f.store._typeify({...current(), actionLinks: {reconcile: '/actions/reconcile'}}); + const options = {createIdentity: {type: 'volume', generation: f.store.get('generation'), baseUrl: f.store.baseUrl}}; + f.store.rawRequest = request => { f.requests.push(request); return resolve({status: 201, body: initial()}); }; + assert.strictEqual(await run(() => record.doAction('reconcile', null, options)), record); + assert.strictEqual(f.requests[0].method, 'POST'); + assert.notOk(Object.hasOwn(f.requests[0], 'createIdentity')); + assert.strictEqual(record.get('state'), 'registering', 'action response still imports normally'); + } finally { f.destroy(); } + }); +}); diff --git a/vendor/ember-api-store-compat/UPSTREAM.md b/vendor/ember-api-store-compat/UPSTREAM.md index 8c60925d85..35113d99fb 100644 --- a/vendor/ember-api-store-compat/UPSTREAM.md +++ b/vendor/ember-api-store-compat/UPSTREAM.md @@ -28,3 +28,13 @@ stores remain separate. Resource names, server schemas, authorization and reques methods are unchanged. This compatibility packaging preserves upstream authorship. PastureStack does not claim authorship of the imported runtime source. + +Compatibility revision 5 prevents an initial HTTP 201 create snapshot from +overwriting a newer subscribe model for the same generated resource ID. Only +an ID-less `Type.save` POST opts into canonical model adoption, and only within +its captured store generation and API base URL. Opaque resource IDs and concrete +types must match; cached nested relationships are not re-imported from the older +body. Save completion, base-type aliases, HTTP metadata and errors retain their +existing contracts. GET, PUT, actions, non-201 responses and uncached creates +continue through the original import path. This does not order resource states +or event timestamps, grant permissions, change API responses, or add requests. diff --git a/vendor/ember-api-store-compat/addon/mixins/type.js b/vendor/ember-api-store-compat/addon/mixins/type.js index b552a2611a..e240df0752 100644 --- a/vendor/ember-api-store-compat/addon/mixins/type.js +++ b/vendor/ember-api-store-compat/addon/mixins/type.js @@ -130,6 +130,7 @@ var Type = Mixin.create(Serializable,{ } opt = opt || {}; + delete opt.createIdentity; opt.method = 'POST'; opt.url = opt.url || url; if ( data ) { @@ -144,6 +145,7 @@ var Type = Mixin.create(Serializable,{ var self = this; var store = get(this, 'store'); opt = opt || {}; + delete opt.createIdentity; var id = get(this, 'id'); var type = normalizeType(get(this, 'type')); @@ -159,6 +161,15 @@ var Type = Mixin.create(Serializable,{ opt.method = opt.method || 'POST'; opt.url = opt.url || type; + // A generated ID may arrive over subscribe before its original 201. + // Bind this create-only adoption to the store that started the request. + if ( opt.method === 'POST' ) { + opt.createIdentity = { + type, + generation: get(store, 'generation'), + baseUrl: get(store, 'baseUrl'), + }; + } } if ( opt.qp ) { diff --git a/vendor/ember-api-store-compat/addon/services/store.js b/vendor/ember-api-store-compat/addon/services/store.js index 915df6b8f5..8f3700eecd 100644 --- a/vendor/ember-api-store-compat/addon/services/store.js +++ b/vendor/ember-api-store-compat/addon/services/store.js @@ -516,7 +516,25 @@ var Store = Service.extend({ } if ( xhr.body && typeof xhr.body === 'object' ) { - let response = this._typeify(xhr.body); + let response; + const creation = opt.createIdentity; + // Only a new-record save can use this rule. Its 201 is the initial + // snapshot; the same generated ID already in this store has arrived + // through subscribe while that response was in flight. Do not import + // its stale fields (including nested resources) over the live model. + if ( xhr.status === 201 && opt.method === 'POST' && creation && + creation.generation === get(this, 'generation') && + creation.baseUrl === get(this, 'baseUrl') && + typeof xhr.body.id === 'string' && xhr.body.id.length > 0 && + normalizeType(xhr.body.type, this) === creation.type ) { + const cached = this.getById(creation.type, xhr.body.id); + if ( cached && cached.get('id') === xhr.body.id && + normalizeType(cached.get('type'), this) === creation.type && + get(cached, 'store') === this && this.hasRecord(cached) ) { + response = cached; + } + } + response = response || this._typeify(xhr.body); delete xhr.body; Object.defineProperty(response, 'xhr', {value: xhr, configurable: true}); diff --git a/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz b/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz new file mode 100644 index 0000000000000000000000000000000000000000..02629c64c007fcd277289a99d572626d6a4dc1c5 GIT binary patch literal 23020 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 literal 0 HcmV?d00001 diff --git a/vendor/ember-api-store-compat/package.json b/vendor/ember-api-store-compat/package.json index d7a00e7fc4..b31613d17f 100644 --- a/vendor/ember-api-store-compat/package.json +++ b/vendor/ember-api-store-compat/package.json @@ -32,7 +32,7 @@ "node": ">=24" }, "pasturestackCompatibility": { - "revision": 4, + "revision": 5, "upstreamPackage": "ember-api-store", "upstreamVersion": "2.8.5", "upstreamIntegrity": "sha512-YvnBZfdNGG7hB25hecEENHObXNN+186Bbkd1wAIL7qtRXqboQsQxTU05xxP7axgW6TPYfUYMxZ+GgbHgD14g2A==" From 159356fd3b9f5fbac56c4800e2a93c36bb2eb800 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:29:05 +0800 Subject: [PATCH 2/6] ci: gate exact upstream-pending braces build advisory without weakening audit --- COMPATIBILITY.md | 8 + README.md | 9 + docs/releases/web-console-1.6.171.md | 16 ++ docs/security/npm-vendor-pending.json | 39 ++++ scripts/check-ui-critical-high-dependencies | 10 +- scripts/check-ui-npm-audit.js | 246 ++++++++++++++++++++ scripts/ci | 3 +- scripts/test-ui-npm-audit.js | 201 ++++++++++++++++ 8 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 docs/security/npm-vendor-pending.json create mode 100644 scripts/check-ui-npm-audit.js create mode 100644 scripts/test-ui-npm-audit.js diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 403711b3d4..8e401dc9be 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -18,6 +18,14 @@ failure, skip and todo counts are zero. Official validation, publication and packaged fresh-volume acceptance remain pending, not full-matrix PASS. See the [release note](docs/releases/web-console-1.6.171.md). +The live npm audit retains its Critical/High threshold. An explicit dated +vendor-pending record covers only `GHSA-vfj7-8cjw-p6xm` in the exact existing +development-only `braces@3.0.3` dependency closure, for which upstream has no +patched release. Unknown findings, changed affected nodes, runtime exposure, +audit errors and expired reviews fail closed. This is a recorded remaining High +risk, not a patched or zero-High claim; dependencies and package versions are +unchanged. See [the review record](docs/security/npm-vendor-pending.json). + Published `1.6.170` accepts null only for the optional expanded `mounts` projection while retaining the real complete empty pool relationship and full scoped mount-cache proof. It preserves nonempty raw ID binding, current-project diff --git a/README.md b/README.md index 7afdf0e348..6a6aa2a6c1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ acceptance are separate pending gates. The complete permission / resource / locale matrix remains INCOMPLETE. See the [release note](docs/releases/web-console-1.6.171.md). +The first exact-source official run stopped before tests on newly reviewed +`GHSA-vfj7-8cjw-p6xm` in build-only `braces@3.0.3`; upstream has no patched +release. It remains a High vendor-pending finding, not a zero-vulnerability +claim. The live audit preserves the High threshold and rejects unexpected +advisories, dependency drift, non-development exposure and expired reviews. +Only the exact reviewed advisory's dependency closure may remain pending until +2026-10-10. No third-party runtime patch or toolchain downgrade is applied. +See the [bounded risk record](docs/security/npm-vendor-pending.json). + Published `1.6.170` corrects an optional `mounts: null` projection being mistaken for a real allocation in the shared local-volume list. It preserves the complete advertised pool relationship, full scoped mount cache, exact-volume diff --git a/docs/releases/web-console-1.6.171.md b/docs/releases/web-console-1.6.171.md index a411dc2abd..ca3995d78c 100644 --- a/docs/releases/web-console-1.6.171.md +++ b/docs/releases/web-console-1.6.171.md @@ -46,6 +46,22 @@ fresh-volume create/cancel/refresh/denial/removal remain pending. Historical failed QA receipts stay HOLD; the complete permission/resource/locale matrix remains INCOMPLETE. +## Upstream-pending build dependency + +Official run 37092519936 stopped before QUnit on the newly reviewed +[braces stack-exhaustion advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm). +The registry's latest version remains 3.0.3 and the advisory lists no patched +release. Existing build-tool consumers remain unchanged. Do not apply the npm +suggested forced Ember CLI downgrade or privately patch third-party code. + +The [dated risk record](../security/npm-vendor-pending.json) keeps this High +finding visible. The live audit remains fail-closed at High for any other or +changed advisory, changed affected dependency nodes, non-development exposure, +expired review or audit failure. Only this exact reviewed build-only closure +may remain vendor-pending until 2026-10-10. That exception is not a claim that +the vulnerable package is patched or that the source graph has zero High +findings. The packaged static artifact must exclude the affected Node package. + ## Upgrade and rollback Use the separately released Server patch that packages this exact component. diff --git a/docs/security/npm-vendor-pending.json b/docs/security/npm-vendor-pending.json new file mode 100644 index 0000000000..bad4438d16 --- /dev/null +++ b/docs/security/npm-vendor-pending.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": 1, + "advisoryUrl": "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm", + "cve": "CVE-2026-93687", + "severity": "high", + "upstreamPatchedVersion": null, + "reviewedAt": "2026-10-03T03:22:25Z", + "reviewUntil": "2026-10-10", + "scope": "controlled-dev-build-inputs-only", + "risk": "Deeply nested brace patterns can exhaust the build Node.js stack. This High finding remains unresolved; reviewed source filenames/glob configuration are controlled build inputs, not browser/user-supplied patterns.", + "publicationBlockedIfShippedNodes": true, + "shippedNodes": [], + "boundaryEvidence": [ + "The exact reverse lock dependency closure below is dev:true and is not reachable from package-lock root production dependencies.", + "The closure enters through ember-cli, a build CLI. The gate checks literal imports in app/config/vendor JavaScript and ember-cli-build.js; only the exact build entry require('ember-cli/lib/broccoli/ember-app') may reach this closure. This static check is not packaged-browser proof.", + "CI builds an immutable checkout with npm ci --ignore-scripts; this decision does not authorize running a build on untrusted patterns or shipping these nodes.", + "This is a source inventory/build-input review, not a zero-CVE statement or runtime not-affected VEX. If a packaged browser/module inventory includes any reviewed node, publication is blocked and this decision must be re-reviewed." + ], + "nodes": [ + { "path": "node_modules/braces", "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true }, + { "path": "node_modules/micromatch", "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true }, + { "path": "node_modules/findup-sync", "version": "5.0.0", "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", "dev": true }, + { "path": "node_modules/find-yarn-workspace-root", "version": "2.0.0", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "dev": true }, + { "path": "node_modules/sane", "version": "5.0.1", "resolved": "https://registry.npmjs.org/sane/-/sane-5.0.1.tgz", "integrity": "sha512-9/0CYoRz0MKKf04OMCO3Qk3RQl1PAwWAhPSQSym4ULiLpTZnrY1JoZU0IEikHu8kdk2HvKT/VwQMq/xFZ8kh1Q==", "dev": true }, + { "path": "node_modules/broccoli", "version": "4.0.0", "resolved": "https://registry.npmjs.org/broccoli/-/broccoli-4.0.0.tgz", "integrity": "sha512-p5el5/ig0QeRGFPkLMPdm7KblkTm44eicEWfwnRTz6hncghVuRZ0+XDAtCi7ynxobeE/mey5Q7lAulFkgNzxVA==", "dev": true }, + { "path": "node_modules/ember-cli", "version": "7.2.0", "resolved": "https://registry.npmjs.org/ember-cli/-/ember-cli-7.2.0.tgz", "integrity": "sha512-EafquLJ+EVHz0nNo32NWwAfHr5UxTXn9zdlukuKZFSFrR4G6RRStmBPQ5O0bLSaQVDtU+jOe5gGTHSS4Xy3uQA==", "dev": true } + ], + "edges": [ + { "from": "node_modules/micromatch", "to": "node_modules/braces", "spec": "^3.0.3" }, + { "from": "node_modules/findup-sync", "to": "node_modules/micromatch", "spec": "^4.0.4" }, + { "from": "node_modules/find-yarn-workspace-root", "to": "node_modules/micromatch", "spec": "^4.0.2" }, + { "from": "node_modules/sane", "to": "node_modules/micromatch", "spec": "^4.0.2" }, + { "from": "node_modules/broccoli", "to": "node_modules/findup-sync", "spec": "^5.0.0" }, + { "from": "node_modules/broccoli", "to": "node_modules/sane", "spec": "^5.0.1" }, + { "from": "node_modules/ember-cli", "to": "node_modules/broccoli", "spec": "^4.0.0" }, + { "from": "node_modules/ember-cli", "to": "node_modules/find-yarn-workspace-root", "spec": "^2.0.0" }, + { "from": "node_modules/ember-cli", "to": "node_modules/sane", "spec": "^5.0.1" } + ] +} diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index e17e99f4f7..51ff79c629 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -51,8 +51,12 @@ if failures: package = json.loads(package_path.read_text(encoding="utf-8")) ci_source = ci_path.read_text(encoding="utf-8") -if "npm audit --audit-level=high" not in ci_source: - fail("live npm Critical/High audit gate is missing from scripts/ci") +for gate in ("node ./scripts/test-ui-npm-audit.js", "node ./scripts/check-ui-npm-audit.js"): + if gate not in ci_source: + fail(f"live fail-closed Critical/High audit gate is missing: {gate}") +for evidence in ("scripts/check-ui-npm-audit.js", "scripts/test-ui-npm-audit.js", "docs/security/npm-vendor-pending.json"): + if not Path(evidence).is_file(): + fail(f"reviewed live audit evidence is missing: {evidence}") api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz" lock_bytes = lock_path.read_bytes() baseline_bytes = baseline_path.read_bytes() @@ -169,7 +173,7 @@ if failures: print( "UI_CRITICAL_HIGH_DEPENDENCIES_OK " - "critical_babel_traverse=absent high_build_chain=patched " + "critical_babel_traverse=absent security_pins=retained " + " ".join(f"{name}={count}" for name, count in sorted(checked.items())) ) print("failure_count=0") diff --git a/scripts/check-ui-npm-audit.js b/scripts/check-ui-npm-audit.js new file mode 100644 index 0000000000..2b15703183 --- /dev/null +++ b/scripts/check-ui-npm-audit.js @@ -0,0 +1,246 @@ +'use strict'; + +// npm severity is not rewritten. This is a time-bounded build-input decision, +// not a fix, a runtime VEX claim, or permission to ship the affected modules. +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const ADVISORY = 'https://github.com/advisories/GHSA-vfj7-8cjw-p6xm'; +const LEVELS = ['info', 'low', 'moderate', 'high', 'critical']; +const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v); +const own = (v, k) => Object.prototype.hasOwnProperty.call(v, k); +const strings = v => Array.isArray(v) && v.every(x => typeof x === 'string' && x.length > 0) && new Set(v).size === v.length; +const sameSet = (a, b) => a.length === b.length && a.every(x => b.includes(x)); +const packageName = p => p.split('node_modules/').at(-1); +function need(ok, code) { if (!ok) throw new Error(code); } + +function resolveDependency(packages, from, name) { + let current = from; + while (true) { + const candidate = (current ? current + '/' : '') + 'node_modules/' + name; + if (own(packages, candidate)) return candidate; + if (!current) return null; + const parent = current.lastIndexOf('/node_modules/'); + current = parent < 0 ? '' : current.slice(0, parent); + } +} + +function validatePending(lock, pending, now) { + need(plain(pending) && pending.schemaVersion === 1 && pending.advisoryUrl === ADVISORY && + pending.severity === 'high' && pending.upstreamPatchedVersion === null && + pending.scope === 'controlled-dev-build-inputs-only' && pending.publicationBlockedIfShippedNodes === true && + strings(pending.shippedNodes) && pending.shippedNodes.length === 0, 'PENDING_POLICY_INVALID'); + need(pending.reviewUntil === '2026-10-10' && typeof pending.reviewedAt === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(pending.reviewedAt) && + Number.isFinite(Date.parse(pending.reviewedAt)) && Number.isFinite(now.getTime()) && + now.getTime() >= Date.parse(pending.reviewedAt) && now.getTime() < Date.parse(pending.reviewUntil + 'T00:00:00Z'), + 'PENDING_REVIEW_EXPIRED_OR_INVALID'); + need(plain(lock) && lock.lockfileVersion === 3 && plain(lock.packages) && plain(lock.packages['']), 'LOCK_SHAPE_INVALID'); + need(Array.isArray(pending.nodes) && pending.nodes.length > 0 && Array.isArray(pending.edges), 'PENDING_NODE_SHAPE_INVALID'); + const packages = lock.packages; + const pinned = pending.nodes.map(n => n.path); + need(strings(pinned) && pinned.includes('node_modules/braces'), 'PENDING_NODE_SHAPE_INVALID'); + for (const n of pending.nodes) { + need(plain(n) && /^node_modules\/(?:[^/]+\/node_modules\/)*[^/]+$/.test(n.path) && + typeof n.version === 'string' && typeof n.resolved === 'string' && typeof n.integrity === 'string' && n.dev === true, + 'PENDING_NODE_SHAPE_INVALID'); + const actual = packages[n.path]; + need(plain(actual) && ['version', 'resolved', 'integrity', 'dev'].every(k => actual[k] === n[k]) && + actual.link !== true && actual.devOptional !== true, 'LOCK_NODE_REVIEW_MISMATCH'); + } + const bracePaths = Object.keys(packages).filter(p => packageName(p) === 'braces'); + need(sameSet(bracePaths, ['node_modules/braces']) && packages['node_modules/braces'].version === '3.0.3', 'BRACES_NODE_MISMATCH'); + const edges = []; + for (const [from, node] of Object.entries(packages)) { + if (!from) continue; + for (const [name, spec] of Object.entries({ ...node.dependencies, ...node.optionalDependencies })) { + const to = resolveDependency(packages, from, name); + if (to) edges.push({ from, to, spec }); + } + } + const closure = new Set(bracePaths); + let changed = true; + while (changed) { + changed = false; + for (const e of edges) if (closure.has(e.to) && !closure.has(e.from)) { closure.add(e.from); changed = true; } + } + need(sameSet([...closure], pinned), 'LOCK_CLOSURE_REVIEW_MISMATCH'); + const edgeKey = e => JSON.stringify([e.from, e.to, e.spec]); + const actualEdges = edges.filter(e => closure.has(e.from) && closure.has(e.to)).map(edgeKey); + need(pending.edges.every(e => plain(e) && pinned.includes(e.from) && pinned.includes(e.to) && typeof e.spec === 'string') && + sameSet(actualEdges, pending.edges.map(edgeKey)) && new Set(pending.edges.map(edgeKey)).size === pending.edges.length, + 'LOCK_EDGE_REVIEW_MISMATCH'); + // dev:true is necessary but not sufficient: a production-root dependency + // reaching the pending closure also invalidates the build-only boundary. + const production = Object.keys({ ...packages[''].dependencies, ...packages[''].optionalDependencies }) + .map(n => resolveDependency(packages, '', n)).filter(Boolean); + const seen = new Set(production); + for (let i = 0; i < production.length; i++) { + const from = production[i]; + need(!closure.has(from), 'PENDING_NODE_SHIPPED'); + for (const e of edges) if (e.from === from && !seen.has(e.to)) { seen.add(e.to); production.push(e.to); } + } + return { packages, pinned, edges }; +} + +function validateReviewedImports(sources, pending) { + const names = new Set(pending.nodes.map(n => packageName(n.path))); + need(plain(sources) && Object.keys(sources).includes('ember-cli-build.js') && + Object.keys(sources).every(p => p === 'ember-cli-build.js' || /^(app|config|vendor)\/.*\.js$/.test(p)), + 'BROWSER_SOURCE_INPUT_INVALID'); + for (const [file, source] of Object.entries(sources)) { + need(typeof source === 'string', 'BROWSER_SOURCE_INPUT_INVALID'); + const imports = /\b(?:from\s*|require\s*\(\s*|import\s*\(\s*|import\s*|app\.import\s*\(\s*)['"]([^'"]+)['"]/g; + for (const match of source.matchAll(imports)) { + const spec = match[1].replace(/^node_modules\//, ''); + const name = spec.split('/')[0]; + if (!names.has(name)) continue; + need(file === 'ember-cli-build.js' && match[1] === 'ember-cli/lib/broccoli/ember-app', 'PENDING_NODE_BROWSER_IMPORT'); + } + } + // Static imports alone cannot prove artifact contents. Publication still + // requires the separate packaged-browser inventory; never emit notAffected. +} + +function readReviewedSources(repoRoot) { + const sources = { 'ember-cli-build.js': fs.readFileSync(path.join(repoRoot, 'ember-cli-build.js'), 'utf8') }; + function walk(relative) { + for (const entry of fs.readdirSync(path.join(repoRoot, relative), { withFileTypes: true })) { + const file = relative + '/' + entry.name; + need(!entry.isSymbolicLink(), 'BROWSER_SOURCE_SYMLINK_UNREVIEWED'); + if (entry.isDirectory()) walk(file); + else if (entry.isFile() && entry.name.endsWith('.js')) sources[file] = fs.readFileSync(path.join(repoRoot, file), 'utf8'); + } + } + for (const directory of ['app', 'config', 'vendor']) walk(directory); + return sources; +} + +function evaluateAudit({ audit, lock, pending, now = new Date(), npmExitCode }) { + let totals = null; + try { + const { packages, pinned } = validatePending(lock, pending, now); + need(plain(audit) && audit.auditReportVersion === 2 && !own(audit, 'error') && + plain(audit.vulnerabilities) && plain(audit.metadata) && plain(audit.metadata.vulnerabilities) && + plain(audit.metadata.dependencies), 'NPM_AUDIT_SHAPE_OR_ERROR'); + const counts = audit.metadata.vulnerabilities; + need(sameSet(Object.keys(counts), [...LEVELS, 'total']) && + [...LEVELS, 'total'].every(k => Number.isSafeInteger(counts[k]) && counts[k] >= 0) && + LEVELS.reduce((n, k) => n + counts[k], 0) === counts.total, 'NPM_AUDIT_TOTALS_INVALID'); + totals = Object.fromEntries([...LEVELS, 'total'].map(k => [k, counts[k]])); + need(sameSet(Object.keys(audit.metadata.dependencies), ['prod', 'dev', 'optional', 'peer', 'peerOptional', 'total']) && + Object.values(audit.metadata.dependencies).every(n => Number.isSafeInteger(n) && n >= 0), 'NPM_AUDIT_DEPENDENCIES_INVALID'); + const vulnerabilities = audit.vulnerabilities; + const observed = Object.fromEntries(LEVELS.map(k => [k, 0])); + const allowedKeys = ['name', 'severity', 'isDirect', 'via', 'effects', 'range', 'nodes', 'fixAvailable']; + for (const [name, v] of Object.entries(vulnerabilities)) { + need(plain(v) && Object.keys(v).every(k => allowedKeys.includes(k)) && v.name === name && LEVELS.includes(v.severity) && + typeof v.isDirect === 'boolean' && typeof v.range === 'string' && strings(v.nodes) && v.nodes.length > 0 && + strings(v.effects) && Array.isArray(v.via) && v.via.length > 0 && + (typeof v.fixAvailable === 'boolean' || (plain(v.fixAvailable) && typeof v.fixAvailable.name === 'string' && + typeof v.fixAvailable.version === 'string' && typeof v.fixAvailable.isSemVerMajor === 'boolean')), 'NPM_VULNERABILITY_SHAPE_INVALID'); + observed[v.severity]++; + for (const node of v.nodes) need(plain(packages[node]) && packageName(node) === name, 'AUDIT_NODE_LOCK_MISMATCH'); + for (const e of v.effects) need(own(vulnerabilities, e), 'AUDIT_EFFECT_REFERENCE_INVALID'); + need(new Set(v.via.map(x => typeof x === 'string' ? 'meta:' + x : 'advisory:' + x?.url)).size === v.via.length, + 'AUDIT_VIA_DUPLICATE'); + for (const via of v.via) { + if (typeof via === 'string') { + need(own(vulnerabilities, via), 'AUDIT_VIA_REFERENCE_INVALID'); + need(LEVELS.indexOf(v.severity) >= LEVELS.indexOf(vulnerabilities[via].severity), 'AUDIT_SEVERITY_INCONSISTENT'); + } + else need(plain(via) && Object.keys(via).every(k => ['source', 'name', 'dependency', 'title', 'url', 'severity', 'cwe', 'cvss', 'range'].includes(k)) && + Number.isSafeInteger(via.source) && via.source > 0 && via.name === name && + via.dependency === name && typeof via.title === 'string' && typeof via.url === 'string' && + /^https:\/\/github\.com\/advisories\/GHSA-[a-z0-9-]+$/.test(via.url) && LEVELS.includes(via.severity) && + typeof via.range === 'string' && strings(via.cwe) && plain(via.cvss) && Number.isFinite(via.cvss.score) && + (via.cvss.vectorString === null || typeof via.cvss.vectorString === 'string'), 'AUDIT_ADVISORY_SHAPE_INVALID'); + if (typeof via !== 'string') need(LEVELS.indexOf(v.severity) >= LEVELS.indexOf(via.severity), 'AUDIT_SEVERITY_INCONSISTENT'); + } + } + need(LEVELS.every(k => observed[k] === totals[k]), 'NPM_AUDIT_TOTALS_MISMATCH'); + need(npmExitCode === (totals.high + totals.critical > 0 ? 1 : 0), 'NPM_EXIT_OR_NETWORK_ERROR'); + need(totals.critical === 0, 'CRITICAL_VULNERABILITY'); + const visiting = new Set(); + const verified = new Set(); + function knownClosure(name) { + if (verified.has(name)) return; + need(!visiting.has(name), 'METAVULNERABILITY_CYCLE'); + visiting.add(name); + const v = vulnerabilities[name]; + need(v.severity === 'high' && v.nodes.every(n => pinned.includes(n)), 'UNREVIEWED_HIGH_NODE'); + for (const via of v.via) { + if (typeof via === 'string') { + need(v.nodes.every(from => { + const spec = { ...packages[from].dependencies, ...packages[from].optionalDependencies }[via]; + return typeof spec === 'string' && vulnerabilities[via].nodes.includes(resolveDependency(packages, from, via)); + }), 'METAVULNERABILITY_LOCK_EDGE_MISMATCH'); + knownClosure(via); + } else { + need(name === 'braces' && via.name === 'braces' && via.dependency === 'braces' && via.severity === 'high' && + via.url === ADVISORY && via.range === '<=3.0.3', 'UNREVIEWED_DIRECT_ADVISORY'); + } + } + visiting.delete(name); + verified.add(name); + } + const high = Object.keys(vulnerabilities).filter(n => vulnerabilities[n].severity === 'high'); + for (const name of high) knownClosure(name); + if (high.length) { + need(sameSet(high.flatMap(n => vulnerabilities[n].nodes), pinned), 'AUDIT_HIGH_CLOSURE_INCOMPLETE'); + for (const name of high) { + const parents = high.filter(n => vulnerabilities[n].via.includes(name)); + need(sameSet(vulnerabilities[name].effects, parents), 'AUDIT_METAVULNERABILITY_EFFECTS_MISMATCH'); + } + } + return { ok: true, outcome: high.length ? 'PASS_BUILD_VENDOR_PENDING' : 'PASS_HIGH_CRITICAL_CLEAN', totals, + knownPending: high.length ? { advisory: 'GHSA-vfj7-8cjw-p6xm', severity: 'high', vulnerableNodeCount: 1, + metavulnerabilityCount: high.length - 1, reviewUntil: pending.reviewUntil, upstreamPatchedVersion: null } : null, + runtimeNotAffectedClaim: false }; + } catch (error) { + return { ok: false, outcome: 'FAIL_CLOSED', totals, failureCode: /^[A-Z0-9_]+$/.test(error.message) ? error.message : 'LOCAL_EVALUATION_ERROR' }; + } +} + +function runAudit(repoRoot, runner = spawnSync) { + let lock, pending; + try { + lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8')); + pending = JSON.parse(fs.readFileSync(path.join(repoRoot, 'docs/security/npm-vendor-pending.json'), 'utf8')); + validatePending(lock, pending, new Date()); + validateReviewedImports(readReviewedSources(repoRoot), pending); + } catch (error) { + return { ok: false, outcome: 'FAIL_CLOSED', totals: null, + failureCode: /^[A-Z0-9_]+$/.test(error.message) ? error.message : 'PREFLIGHT_INPUT_ERROR' }; + } + let command = 'npm'; + let args = ['audit', '--audit-level=high', '--json']; + if (process.platform === 'win32') { + const candidates = [path.join(process.env.APPDATA || '', 'npm/node_modules/npm/bin/npm-cli.js'), + path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')]; + const cli = candidates.find(p => { + try { return fs.existsSync(p) && JSON.parse(fs.readFileSync(path.resolve(p, '../../package.json'), 'utf8')).version === '12.0.2'; } + catch (_) { return false; } + }); + if (!cli) return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_CLI_UNAVAILABLE' }; + command = process.execPath; + args = [cli, ...args]; + } + let result; + try { result = runner(command, args, { cwd: repoRoot, encoding: 'utf8', shell: false, timeout: 120000, maxBuffer: 8 * 1024 * 1024 }); } + catch (_) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_PROCESS_ERROR' }; } + if (!result || result.error || result.signal || ![0, 1].includes(result.status)) + return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_EXIT_OR_NETWORK_ERROR' }; + let audit; + try { audit = JSON.parse(result.stdout); } + catch (_) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_JSON_INVALID' }; } + return evaluateAudit({ audit, lock, pending, npmExitCode: result.status }); +} + +module.exports = { evaluateAudit, validatePending, validateReviewedImports, runAudit }; +if (require.main === module) { + const result = runAudit(path.resolve(__dirname, '..')); + console.log(JSON.stringify(result)); + process.exitCode = result.ok ? 0 : 1; +} diff --git a/scripts/ci b/scripts/ci index f1d5977ffa..8f9cded7e6 100755 --- a/scripts/ci +++ b/scripts/ci @@ -25,7 +25,8 @@ node ./scripts/check-ui-localization-quality ./scripts/check-ui-oidc-safe-activation ./scripts/check-ui-critical-high-dependencies node ./scripts/check-ui-security-patch-compat.js -npm audit --audit-level=high +node ./scripts/test-ui-npm-audit.js +node ./scripts/check-ui-npm-audit.js ./scripts/check-ui-codeql-critical-high ./scripts/check-dependency-baseline ./scripts/check-sass-replacement diff --git a/scripts/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js new file mode 100644 index 0000000000..f19e59d779 --- /dev/null +++ b/scripts/test-ui-npm-audit.js @@ -0,0 +1,201 @@ +'use strict'; + +// Deterministic npm12 report shapes; no install, audit, registry, or build. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const { evaluateAudit, validateReviewedImports, runAudit } = require('./check-ui-npm-audit'); +const root = path.resolve(__dirname, '..'); +const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); +const pending = JSON.parse(fs.readFileSync(path.join(root, 'docs/security/npm-vendor-pending.json'), 'utf8')); +const clone = value => JSON.parse(JSON.stringify(value)); +const now = new Date('2026-10-04T00:00:00Z'); +const dependencyCounts = { prod: 8, dev: 1454, optional: 18, peer: 1, peerOptional: 0, total: 1477 }; +const knownVia = { + braces: [{ source: 1240992, name: 'braces', dependency: 'braces', + title: 'braces vulnerable to stack-exhaustion denial of service through deeply nested patterns', + url: pending.advisoryUrl, severity: 'high', cwe: ['CWE-674'], + cvss: { score: 7.5, vectorString: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H' }, range: '<=3.0.3' }], + micromatch: ['braces'], + 'findup-sync': ['micromatch'], + 'find-yarn-workspace-root': ['micromatch'], + sane: ['micromatch'], + broccoli: ['findup-sync', 'sane'], + 'ember-cli': ['broccoli', 'find-yarn-workspace-root', 'sane'] +}; +function report(withHigh = true, withModerate = false) { + const vulnerabilities = {}; + if (withHigh) for (const [name, via] of Object.entries(knownVia)) vulnerabilities[name] = { + name, severity: 'high', isDirect: name === 'ember-cli', via: clone(via), + effects: Object.keys(knownVia).filter(n => knownVia[n].includes(name)), range: '*', nodes: ['node_modules/' + name], + fixAvailable: { name: 'ember-cli', version: '3.3.0', isSemVerMajor: true } + }; + if (withModerate) vulnerabilities['fast-uri'] = { + name: 'fast-uri', severity: 'moderate', isDirect: false, + via: [{ source: 1240091, name: 'fast-uri', dependency: 'fast-uri', title: 'Separate Moderate advisory', + url: 'https://github.com/advisories/GHSA-hrr3-gc8f-f4qj', severity: 'moderate', cwe: ['CWE-178'], + cvss: { score: 4.8, vectorString: null }, range: '>=3.0.0 <3.1.8' }], + effects: [], range: '3.0.0 - 3.1.7', nodes: ['node_modules/fast-uri'], fixAvailable: true + }; + return { auditReportVersion: 2, vulnerabilities, + metadata: { vulnerabilities: { info: 0, low: 0, moderate: withModerate ? 1 : 0, high: withHigh ? 7 : 0, + critical: 0, total: (withHigh ? 7 : 0) + (withModerate ? 1 : 0) }, dependencies: clone(dependencyCounts) } }; +} +function input(audit = report()) { return { audit, lock: clone(lock), pending: clone(pending), now, npmExitCode: audit.metadata.vulnerabilities.high ? 1 : 0 }; } +function fail(value, code) { + const result = evaluateAudit(value); + assert.equal(result.ok, false); + assert.equal(result.outcome, 'FAIL_CLOSED'); + if (code) assert.equal(result.failureCode, code); + return result; +} + +test('clean High/Critical report passes without claiming zero Moderate or runtime unaffected', () => { + const result = evaluateAudit(input(report(false, true))); + assert.equal(result.ok, true); + assert.equal(result.outcome, 'PASS_HIGH_CRITICAL_CLEAN'); + assert.equal(result.totals.moderate, 1); + assert.equal(result.knownPending, null); + assert.equal(result.runtimeNotAffectedClaim, false); +}); +test('exact actual seven-node npm12 closure passes and keeps raw High plus separate Moderate', () => { + const result = evaluateAudit(input(report(true, true))); + assert.equal(result.ok, true); + assert.equal(result.outcome, 'PASS_BUILD_VENDOR_PENDING'); + assert.equal(result.totals.high, 7); + assert.equal(result.totals.moderate, 1); + assert.equal(result.totals.critical, 0); + assert.equal(result.knownPending.metavulnerabilityCount, 6); + assert.equal(result.knownPending.upstreamPatchedVersion, null); +}); +test('unknown High or extra direct advisory cannot borrow the known package name', () => { + const value = input(); + value.audit.vulnerabilities.braces.via[0].url = 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc'; + fail(value, 'UNREVIEWED_DIRECT_ADVISORY'); + const extra = input(); + extra.audit.vulnerabilities.braces.via.push({ ...extra.audit.vulnerabilities.braces.via[0], source: 99, + url: 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc' }); + fail(extra, 'UNREVIEWED_DIRECT_ADVISORY'); +}); +test('Critical always blocks, including a known advisory promoted to Critical', () => { + const value = input(); + value.audit.vulnerabilities.braces.severity = 'critical'; + value.audit.metadata.vulnerabilities.high--; + value.audit.metadata.vulnerabilities.critical++; + assert.equal(fail(value, 'CRITICAL_VULNERABILITY').totals.critical, 1); +}); + +test('a Moderate wrapper cannot conceal a High or Critical direct or meta advisory', () => { + for (const severity of ['high', 'critical']) { + const value = input(report(true, true)); + value.audit.vulnerabilities['fast-uri'].via[0].severity = severity; + fail(value, 'AUDIT_SEVERITY_INCONSISTENT'); + } + const meta = input(report(true, true)); + meta.audit.vulnerabilities['fast-uri'].via = ['braces']; + fail(meta, 'AUDIT_SEVERITY_INCONSISTENT'); +}); +test('version, resolved URL, integrity, and dev flag changes each invalidate exact node review', () => { + for (const [key, val] of [['version', '3.0.4'], ['resolved', 'https://example.invalid/braces.tgz'], + ['integrity', 'sha512-different'], ['dev', false]]) { + const value = input(); value.lock.packages['node_modules/braces'][key] = val; + fail(value, 'LOCK_NODE_REVIEW_MISMATCH'); + } +}); +test('root version changes do not stale unchanged dependency review', () => { + const value = input(); value.lock.packages[''].version = '1.6.999'; + assert.equal(evaluateAudit(value).ok, true); +}); +test('production reachability or shipped node blocks publication boundary', () => { + const value = input(); value.lock.packages[''].dependencies.braces = '3.0.3'; + fail(value, 'PENDING_NODE_SHIPPED'); + const shipped = input(); shipped.pending.shippedNodes = ['node_modules/braces']; + fail(shipped, 'PENDING_POLICY_INVALID'); +}); +test('additional braces major/node, consumer closure, or changed edge rejects rather than broadening exception', () => { + const value = input(); value.lock.packages['node_modules/other/node_modules/braces'] = clone(value.lock.packages['node_modules/braces']); + fail(value, 'BRACES_NODE_MISMATCH'); + const consumer = input(); consumer.lock.packages['node_modules/other'] = { dev: true, version: '1.0.0', dependencies: { braces: '^3.0.3' } }; + fail(consumer, 'LOCK_CLOSURE_REVIEW_MISMATCH'); + const edge = input(); edge.lock.packages['node_modules/micromatch'].dependencies.braces = '*'; + fail(edge, 'LOCK_EDGE_REVIEW_MISMATCH'); +}); +test('all meta branches must resolve actual lock dependencies and exact advisory', () => { + const value = input(); value.audit.vulnerabilities.broccoli.via.push('braces'); + fail(value, 'METAVULNERABILITY_LOCK_EDGE_MISMATCH'); + const missing = input(); missing.audit.vulnerabilities.micromatch.via = ['missing-package']; + fail(missing, 'AUDIT_VIA_REFERENCE_INVALID'); +}); +test('meta cycle rejects even when synthetic lock graph supplies that edge', () => { + const value = input(); + value.lock.packages['node_modules/braces'].dependencies.micromatch = '^4.0.8'; + value.pending.edges.push({ from: 'node_modules/braces', to: 'node_modules/micromatch', spec: '^4.0.8' }); + value.audit.vulnerabilities.braces.via = ['micromatch']; + fail(value, 'METAVULNERABILITY_CYCLE'); +}); +test('missing closure node or inconsistent effects cannot manufacture a complete pending decision', () => { + const value = input(); delete value.audit.vulnerabilities['ember-cli']; + for (const v of Object.values(value.audit.vulnerabilities)) v.effects = v.effects.filter(n => n !== 'ember-cli'); + value.audit.metadata.vulnerabilities.high--; value.audit.metadata.vulnerabilities.total--; + fail(value, 'AUDIT_HIGH_CLOSURE_INCOMPLETE'); + const effects = input(); effects.audit.vulnerabilities.braces.effects = []; + fail(effects, 'AUDIT_METAVULNERABILITY_EFFECTS_MISMATCH'); +}); +test('review expiration boundary is UTC and future/invalid dates reject', () => { + for (const at of ['2026-10-10T00:00:00Z', '2026-10-11T00:00:00Z', '2026-10-02T00:00:00Z', 'invalid']) { + const value = input(); value.now = new Date(at); fail(value, 'PENDING_REVIEW_EXPIRED_OR_INVALID'); + } +}); +test('npm error, malformed report, unknown shape, missing metadata and bad totals reject safely', () => { + const error = input(); error.audit.error = { summary: 'secret-stderr-marker' }; fail(error, 'NPM_AUDIT_SHAPE_OR_ERROR'); + const shape = input(); shape.audit.auditReportVersion = 1; fail(shape, 'NPM_AUDIT_SHAPE_OR_ERROR'); + const meta = input(); delete meta.audit.metadata; fail(meta, 'NPM_AUDIT_SHAPE_OR_ERROR'); + const extra = input(); extra.audit.vulnerabilities.braces.unknown = true; fail(extra, 'NPM_VULNERABILITY_SHAPE_INVALID'); + const totals = input(); totals.audit.metadata.vulnerabilities.high = 0; totals.audit.metadata.vulnerabilities.total = 0; + fail(totals, 'NPM_AUDIT_TOTALS_MISMATCH'); +}); +test('npm non-audit failures and inconsistent exit status never become allowed pending', () => { + for (const status of [null, 2, 127, 0]) { const value = input(); value.npmExitCode = status; fail(value, 'NPM_EXIT_OR_NETWORK_ERROR'); } +}); +test('main runner truly selects high JSON audit once, never install/fix, and emits no raw stderr/error', () => { + let calls = 0; + const result = runAudit(root, (command, args, options) => { + calls++; + assert.deepEqual(args.slice(-3), ['audit', '--audit-level=high', '--json']); + assert.equal(options.shell, false); + assert.equal(options.cwd, root); + assert.equal(args.includes('fix'), false); + return { status: 1, stdout: JSON.stringify(report()), stderr: 'private stderr marker' }; + }); + assert.equal(calls, 1); assert.equal(result.ok, true); + assert.equal(JSON.stringify(result).includes('private'), false); +}); +test('runner malformed JSON, network errors and thrown process errors are finite safe codes with no retry', () => { + for (const returned of [{ status: 1, stdout: 'secret invalid body' }, + { status: 1, stdout: JSON.stringify({ error: { code: 'ENOTFOUND', detail: 'secret' } }) }, + { status: null, error: new Error('secret network failure'), stdout: '' }]) { + let calls = 0; + const result = runAudit(root, () => { calls++; return returned; }); + assert.equal(calls, 1); assert.equal(result.ok, false); + assert.equal(JSON.stringify(result).includes('secret'), false); + } + let calls = 0; + const result = runAudit(root, () => { calls++; throw new Error('secret thrown failure'); }); + assert.equal(calls, 1); assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); +}); +test('reviewed runtime import boundary refuses every pending consumer while exact Ember build entry is allowed', () => { + const sources = { 'ember-cli-build.js': "var EmberApp = require('ember-cli/lib/broccoli/ember-app');", + 'app/app.js': "import App from '@ember/application';", 'vendor/example.js': 'const braces = [1, 2];' }; + assert.doesNotThrow(() => validateReviewedImports(sources, pending)); + for (const node of pending.nodes) { + const name = node.path.slice('node_modules/'.length); + for (const content of ["import x from '" + name + "';", "require('" + name + "/index.js');", + "import('" + name + "');", "app.import('node_modules/" + name + "/index.js');"]) { + assert.throws(() => validateReviewedImports({ ...sources, 'app/changed.js': content }, pending), + { message: 'PENDING_NODE_BROWSER_IMPORT' }); + } + } + assert.throws(() => validateReviewedImports({ ...sources, 'ember-cli-build.js': "app.import('node_modules/braces/index.js');" }, pending), + { message: 'PENDING_NODE_BROWSER_IMPORT' }); +}); From b9e3f309b4950e7d9dc12349f25d25cd0478fe3d Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:32:33 +0800 Subject: [PATCH 3/6] ci: prioritize Critical rejection and preserve bounded preflight diagnostics --- scripts/check-ui-npm-audit.js | 4 +++- scripts/test-ui-npm-audit.js | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/check-ui-npm-audit.js b/scripts/check-ui-npm-audit.js index 2b15703183..40eef20885 100644 --- a/scripts/check-ui-npm-audit.js +++ b/scripts/check-ui-npm-audit.js @@ -129,6 +129,9 @@ function evaluateAudit({ audit, lock, pending, now = new Date(), npmExitCode }) [...LEVELS, 'total'].every(k => Number.isSafeInteger(counts[k]) && counts[k] >= 0) && LEVELS.reduce((n, k) => n + counts[k], 0) === counts.total, 'NPM_AUDIT_TOTALS_INVALID'); totals = Object.fromEntries([...LEVELS, 'total'].map(k => [k, counts[k]])); + // A reported Critical always blocks before meta-severity consistency; + // a newly promoted child must not be hidden by its old High wrappers. + need(totals.critical === 0, 'CRITICAL_VULNERABILITY'); need(sameSet(Object.keys(audit.metadata.dependencies), ['prod', 'dev', 'optional', 'peer', 'peerOptional', 'total']) && Object.values(audit.metadata.dependencies).every(n => Number.isSafeInteger(n) && n >= 0), 'NPM_AUDIT_DEPENDENCIES_INVALID'); const vulnerabilities = audit.vulnerabilities; @@ -161,7 +164,6 @@ function evaluateAudit({ audit, lock, pending, now = new Date(), npmExitCode }) } need(LEVELS.every(k => observed[k] === totals[k]), 'NPM_AUDIT_TOTALS_MISMATCH'); need(npmExitCode === (totals.high + totals.critical > 0 ? 1 : 0), 'NPM_EXIT_OR_NETWORK_ERROR'); - need(totals.critical === 0, 'CRITICAL_VULNERABILITY'); const visiting = new Set(); const verified = new Set(); function knownClosure(name) { diff --git a/scripts/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js index f19e59d779..d3e2bebbee 100644 --- a/scripts/test-ui-npm-audit.js +++ b/scripts/test-ui-npm-audit.js @@ -168,7 +168,7 @@ test('main runner truly selects high JSON audit once, never install/fix, and emi assert.equal(args.includes('fix'), false); return { status: 1, stdout: JSON.stringify(report()), stderr: 'private stderr marker' }; }); - assert.equal(calls, 1); assert.equal(result.ok, true); + assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.ok, true); assert.equal(JSON.stringify(result).includes('private'), false); }); test('runner malformed JSON, network errors and thrown process errors are finite safe codes with no retry', () => { @@ -177,12 +177,12 @@ test('runner malformed JSON, network errors and thrown process errors are finite { status: null, error: new Error('secret network failure'), stdout: '' }]) { let calls = 0; const result = runAudit(root, () => { calls++; return returned; }); - assert.equal(calls, 1); assert.equal(result.ok, false); + assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.ok, false); assert.equal(JSON.stringify(result).includes('secret'), false); } let calls = 0; const result = runAudit(root, () => { calls++; throw new Error('secret thrown failure'); }); - assert.equal(calls, 1); assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); + assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); }); test('reviewed runtime import boundary refuses every pending consumer while exact Ember build entry is allowed', () => { const sources = { 'ember-cli-build.js': "var EmberApp = require('ember-cli/lib/broccoli/ember-app');", From 74cc7f79769fbbc1d05c9b33d1444266664ff98d Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:34:09 +0800 Subject: [PATCH 4/6] ci: exclude cold-installed addon dependencies from owned-source import scan --- scripts/check-ui-npm-audit.js | 18 +++++++++++++----- scripts/test-ui-npm-audit.js | 28 +++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/scripts/check-ui-npm-audit.js b/scripts/check-ui-npm-audit.js index 40eef20885..abc4a04404 100644 --- a/scripts/check-ui-npm-audit.js +++ b/scripts/check-ui-npm-audit.js @@ -103,14 +103,22 @@ function validateReviewedImports(sources, pending) { // requires the separate packaged-browser inventory; never emit notAffected. } -function readReviewedSources(repoRoot) { - const sources = { 'ember-cli-build.js': fs.readFileSync(path.join(repoRoot, 'ember-cli-build.js'), 'utf8') }; +function readReviewedSources(repoRoot, io = fs) { + const sources = { 'ember-cli-build.js': io.readFileSync(path.join(repoRoot, 'ember-cli-build.js'), 'utf8') }; function walk(relative) { - for (const entry of fs.readdirSync(path.join(repoRoot, relative), { withFileTypes: true })) { + for (const entry of io.readdirSync(path.join(repoRoot, relative), { withFileTypes: true })) { const file = relative + '/' + entry.name; + // Cold npm ci installs local-addon dependencies here, including Unix + // .bin symlinks. They are audited by the full lock/report, not owned JS. + // Only that exact directory boundary is excluded; source links and + // node_modules under app/config are still refused. + if (entry.name === 'node_modules' && entry.isDirectory()) { + need(/^vendor\/[^/]+$/.test(relative), 'BROWSER_SOURCE_DEPENDENCY_BOUNDARY_INVALID'); + continue; + } need(!entry.isSymbolicLink(), 'BROWSER_SOURCE_SYMLINK_UNREVIEWED'); if (entry.isDirectory()) walk(file); - else if (entry.isFile() && entry.name.endsWith('.js')) sources[file] = fs.readFileSync(path.join(repoRoot, file), 'utf8'); + else if (entry.isFile() && entry.name.endsWith('.js')) sources[file] = io.readFileSync(path.join(repoRoot, file), 'utf8'); } } for (const directory of ['app', 'config', 'vendor']) walk(directory); @@ -240,7 +248,7 @@ function runAudit(repoRoot, runner = spawnSync) { return evaluateAudit({ audit, lock, pending, npmExitCode: result.status }); } -module.exports = { evaluateAudit, validatePending, validateReviewedImports, runAudit }; +module.exports = { evaluateAudit, validatePending, validateReviewedImports, readReviewedSources, runAudit }; if (require.main === module) { const result = runAudit(path.resolve(__dirname, '..')); console.log(JSON.stringify(result)); diff --git a/scripts/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js index d3e2bebbee..df02173642 100644 --- a/scripts/test-ui-npm-audit.js +++ b/scripts/test-ui-npm-audit.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { test } = require('node:test'); -const { evaluateAudit, validateReviewedImports, runAudit } = require('./check-ui-npm-audit'); +const { evaluateAudit, validateReviewedImports, readReviewedSources, runAudit } = require('./check-ui-npm-audit'); const root = path.resolve(__dirname, '..'); const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); const pending = JSON.parse(fs.readFileSync(path.join(root, 'docs/security/npm-vendor-pending.json'), 'utf8')); @@ -199,3 +199,29 @@ test('reviewed runtime import boundary refuses every pending consumer while exac assert.throws(() => validateReviewedImports({ ...sources, 'ember-cli-build.js': "app.import('node_modules/braces/index.js');" }, pending), { message: 'PENDING_NODE_BROWSER_IMPORT' }); }); + +test('cold-installed addon node_modules are audited packages, while owned source symlinks remain blocked', () => { + const entry = (name, kind) => ({ name, isDirectory: () => kind === 'dir', + isFile: () => kind === 'file', isSymbolicLink: () => kind === 'link' }); + const tree = { app: [entry('app.js', 'file')], config: [], + vendor: [entry('lacsso', 'dir')], + 'vendor/lacsso': [entry('index.js', 'file'), entry('node_modules', 'dir')], + 'vendor/lacsso/node_modules': [entry('.bin', 'dir')], + 'vendor/lacsso/node_modules/.bin': [entry('rimraf', 'link')] }; + const visited = []; + const io = { readFileSync: () => '', readdirSync: file => { + const relative = path.relative(root, file).split(path.sep).join('/'); + visited.push(relative); return tree[relative]; + } }; + const sources = readReviewedSources(root, io); + assert.deepEqual(Object.keys(sources), ['ember-cli-build.js', 'app/app.js', 'vendor/lacsso/index.js']); + assert.equal(visited.includes('vendor/lacsso/node_modules'), false); + tree['vendor/lacsso'].push(entry('source.js', 'link')); + assert.throws(() => readReviewedSources(root, io), { message: 'BROWSER_SOURCE_SYMLINK_UNREVIEWED' }); + tree['vendor/lacsso'].pop(); + tree.app.push(entry('node_modules', 'dir')); + assert.throws(() => readReviewedSources(root, io), { message: 'BROWSER_SOURCE_DEPENDENCY_BOUNDARY_INVALID' }); + tree.app.pop(); + tree['vendor/lacsso'][1] = entry('node_modules', 'link'); + assert.throws(() => readReviewedSources(root, io), { message: 'BROWSER_SOURCE_SYMLINK_UNREVIEWED' }); +}); From 7a14f3a95489ad32688a2729f9b2068f89d14208 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:39:44 +0800 Subject: [PATCH 5/6] fix(ci): include create-order regression source in isolated lock smoke --- scripts/check-node24-lock-baseline | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/check-node24-lock-baseline b/scripts/check-node24-lock-baseline index b9017c1f12..819895bdb0 100755 --- a/scripts/check-node24-lock-baseline +++ b/scripts/check-node24-lock-baseline @@ -32,6 +32,8 @@ docker run --rm \ cp package.json "$tmpdir/package.json" cp package-lock.json "$tmpdir/package-lock.json" cp scripts/node24-lock-smoke.js "$tmpdir/node24-lock-smoke.js" + mkdir -p "$tmpdir/tests/unit/vendor" + cp tests/unit/vendor/api-store-create-order-test.js "$tmpdir/tests/unit/vendor/api-store-create-order-test.js" mkdir -p "$tmpdir/public/licenses" cp public/licenses/qrcode-generator-MIT.txt "$tmpdir/public/licenses/qrcode-generator-MIT.txt" mkdir -p "$tmpdir/vendor" From fc37f5af9320e492bec7e7244cd62144908b720e Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:54:01 +0800 Subject: [PATCH 6/6] fix: use pinned npm on PATH without environment path or cwd probing --- scripts/check-ui-npm-audit.js | 51 ++++++++++++++------ scripts/test-ui-npm-audit.js | 91 +++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/scripts/check-ui-npm-audit.js b/scripts/check-ui-npm-audit.js index abc4a04404..4d29313e07 100644 --- a/scripts/check-ui-npm-audit.js +++ b/scripts/check-ui-npm-audit.js @@ -213,7 +213,30 @@ function evaluateAudit({ audit, lock, pending, now = new Date(), npmExitCode }) } } -function runAudit(repoRoot, runner = spawnSync) { +function npmInvocation(platform, versionOnly = false) { + // The SDK on PATH is the toolchain trust boundary on both platforms. + // Never discover executable files beneath an environment-provided root. + // cmd is needed for npm.cmd; /d disables AutoRun and every argument is literal. + if (platform === 'win32') return { command: 'cmd.exe', args: ['/d', '/s', '/c', + versionOnly ? 'npm --version' : 'npm audit --audit-level=high --json'] }; + return { command: 'npm', args: versionOnly ? ['--version'] : ['audit', '--audit-level=high', '--json'] }; +} + +function runNpm(invocation, options, runner, platform) { + if (platform !== 'win32') return runner(invocation.command, invocation.args, options); + // libuv resolves the launcher using the parent's environment; cmd then + // resolves npm in the child. A child-only env override protects only npm. + // This synchronous boundary guards both lookups and restores every exit. + const previous = process.env.NoDefaultCurrentDirectoryInExePath; + process.env.NoDefaultCurrentDirectoryInExePath = '1'; + try { return runner(invocation.command, invocation.args, options); } + finally { + if (previous === undefined) delete process.env.NoDefaultCurrentDirectoryInExePath; + else process.env.NoDefaultCurrentDirectoryInExePath = previous; + } +} + +function runAudit(repoRoot, runner = spawnSync, platform = process.platform) { let lock, pending; try { lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8')); @@ -224,21 +247,17 @@ function runAudit(repoRoot, runner = spawnSync) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: /^[A-Z0-9_]+$/.test(error.message) ? error.message : 'PREFLIGHT_INPUT_ERROR' }; } - let command = 'npm'; - let args = ['audit', '--audit-level=high', '--json']; - if (process.platform === 'win32') { - const candidates = [path.join(process.env.APPDATA || '', 'npm/node_modules/npm/bin/npm-cli.js'), - path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')]; - const cli = candidates.find(p => { - try { return fs.existsSync(p) && JSON.parse(fs.readFileSync(path.resolve(p, '../../package.json'), 'utf8')).version === '12.0.2'; } - catch (_) { return false; } - }); - if (!cli) return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_CLI_UNAVAILABLE' }; - command = process.execPath; - args = [cli, ...args]; - } + const options = { cwd: repoRoot, encoding: 'utf8', shell: false, timeout: 120000, maxBuffer: 8 * 1024 * 1024 }; + const version = npmInvocation(platform, true); + let installed; + try { installed = runNpm(version, options, runner, platform); } + catch (_) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_PROCESS_ERROR' }; } + if (!installed || installed.error || installed.signal || installed.status !== 0 || + typeof installed.stdout !== 'string' || installed.stdout.trim() !== '12.0.2') + return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_VERSION_REQUIRED' }; + const invocation = npmInvocation(platform); let result; - try { result = runner(command, args, { cwd: repoRoot, encoding: 'utf8', shell: false, timeout: 120000, maxBuffer: 8 * 1024 * 1024 }); } + try { result = runNpm(invocation, options, runner, platform); } catch (_) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_PROCESS_ERROR' }; } if (!result || result.error || result.signal || ![0, 1].includes(result.status)) return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_EXIT_OR_NETWORK_ERROR' }; @@ -248,7 +267,7 @@ function runAudit(repoRoot, runner = spawnSync) { return evaluateAudit({ audit, lock, pending, npmExitCode: result.status }); } -module.exports = { evaluateAudit, validatePending, validateReviewedImports, readReviewedSources, runAudit }; +module.exports = { evaluateAudit, validatePending, validateReviewedImports, readReviewedSources, npmInvocation, runAudit }; if (require.main === module) { const result = runAudit(path.resolve(__dirname, '..')); console.log(JSON.stringify(result)); diff --git a/scripts/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js index df02173642..868ace7302 100644 --- a/scripts/test-ui-npm-audit.js +++ b/scripts/test-ui-npm-audit.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { test } = require('node:test'); -const { evaluateAudit, validateReviewedImports, readReviewedSources, runAudit } = require('./check-ui-npm-audit'); +const { evaluateAudit, validateReviewedImports, readReviewedSources, npmInvocation, runAudit } = require('./check-ui-npm-audit'); const root = path.resolve(__dirname, '..'); const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); const pending = JSON.parse(fs.readFileSync(path.join(root, 'docs/security/npm-vendor-pending.json'), 'utf8')); @@ -159,30 +159,103 @@ test('npm non-audit failures and inconsistent exit status never become allowed p for (const status of [null, 2, 127, 0]) { const value = input(); value.npmExitCode = status; fail(value, 'NPM_EXIT_OR_NETWORK_ERROR'); } }); test('main runner truly selects high JSON audit once, never install/fix, and emits no raw stderr/error', () => { - let calls = 0; - const result = runAudit(root, (command, args, options) => { + for (const platform of ['linux', 'win32']) { + let calls = 0; + const result = runAudit(root, (command, args, options) => { calls++; - assert.deepEqual(args.slice(-3), ['audit', '--audit-level=high', '--json']); + assert.deepEqual({ command, args }, npmInvocation(platform, calls === 1)); assert.equal(options.shell, false); assert.equal(options.cwd, root); assert.equal(args.includes('fix'), false); + if (calls === 1) return { status: 0, stdout: '12.0.2\n' }; return { status: 1, stdout: JSON.stringify(report()), stderr: 'private stderr marker' }; - }); - assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.ok, true); - assert.equal(JSON.stringify(result).includes('private'), false); + }, platform); + assert.equal(calls, 2, JSON.stringify(result)); assert.equal(result.ok, true); + assert.equal(JSON.stringify(result).includes('private'), false); + } }); test('runner malformed JSON, network errors and thrown process errors are finite safe codes with no retry', () => { for (const returned of [{ status: 1, stdout: 'secret invalid body' }, { status: 1, stdout: JSON.stringify({ error: { code: 'ENOTFOUND', detail: 'secret' } }) }, { status: null, error: new Error('secret network failure'), stdout: '' }]) { let calls = 0; - const result = runAudit(root, () => { calls++; return returned; }); - assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.ok, false); + const result = runAudit(root, () => { calls++; return calls === 1 ? { status: 0, stdout: '12.0.2\n' } : returned; }); + assert.equal(calls, 2, JSON.stringify(result)); assert.equal(result.ok, false); assert.equal(JSON.stringify(result).includes('secret'), false); } let calls = 0; const result = runAudit(root, () => { calls++; throw new Error('secret thrown failure'); }); assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); + let auditCalls = 0; + const auditThrow = runAudit(root, () => { + auditCalls++; + if (auditCalls === 1) return { status: 0, stdout: '12.0.2\n' }; + throw new Error('secret audit thrown failure'); + }); + assert.equal(auditCalls, 2); assert.equal(auditThrow.failureCode, 'NPM_PROCESS_ERROR'); +}); + +test('npm version failures stop before audit on both platforms without retry or raw output', () => { + for (const platform of ['linux', 'win32']) for (const returned of [null, + { status: 1, stdout: '12.0.2' }, { status: 0, stdout: '11.0.0' }, + { status: 0, stdout: '12.0.2 secret additional output' }, { status: 0 }, + { status: 0, stdout: '12.0.2', signal: 'SIGTERM' }, + { status: 0, stdout: '12.0.2', error: new Error('secret process failure') }]) { + let calls = 0; + const result = runAudit(root, () => { calls++; return returned; }, platform); + assert.equal(calls, 1); assert.equal(result.failureCode, 'NPM_VERSION_REQUIRED'); + assert.equal(JSON.stringify(result).includes('secret'), false); + } +}); + +test('npm command mapping is literal and never probes APPDATA or executable metadata paths', () => { + assert.deepEqual(npmInvocation('linux', true), { command: 'npm', args: ['--version'] }); + assert.deepEqual(npmInvocation('linux'), { command: 'npm', args: ['audit', '--audit-level=high', '--json'] }); + assert.deepEqual(npmInvocation('win32', true), { command: 'cmd.exe', args: ['/d', '/s', '/c', 'npm --version'] }); + assert.deepEqual(npmInvocation('win32'), { command: 'cmd.exe', args: ['/d', '/s', '/c', 'npm audit --audit-level=high --json'] }); + const saved = process.env.APPDATA; + const oldExists = fs.existsSync; + try { + fs.existsSync = () => { throw new Error('Environment executable path must not be probed'); }; + for (const value of ['../../outside', 'C:\\untrusted\\npm & injected', '']) { + process.env.APPDATA = value; + let calls = 0; + const result = runAudit(root, (command, args) => { + calls++; + assert.deepEqual({ command, args }, npmInvocation('win32', calls === 1)); + return calls === 1 ? { status: 0, stdout: '12.0.2\n' } : { status: 1, stdout: JSON.stringify(report()) }; + }, 'win32'); + assert.equal(calls, 2); assert.equal(result.ok, true); + } + } finally { + fs.existsSync = oldExists; + if (saved === undefined) delete process.env.APPDATA; + else process.env.APPDATA = saved; + } +}); + +test('Windows launcher and npm skip implicit cwd while synchronous environment ownership is restored', () => { + const saved = process.env.NoDefaultCurrentDirectoryInExePath; + try { + for (const previous of [undefined, '', 'existing-value']) for (const failure of ['none', 'version', 'audit']) { + if (previous === undefined) delete process.env.NoDefaultCurrentDirectoryInExePath; + else process.env.NoDefaultCurrentDirectoryInExePath = previous; + let calls = 0; + const result = runAudit(root, () => { + calls++; + assert.equal(process.env.NoDefaultCurrentDirectoryInExePath, '1'); + if ((calls === 1 && failure === 'version') || (calls === 2 && failure === 'audit')) throw new Error('safe synthetic failure'); + return calls === 1 ? { status: 0, stdout: '12.0.2\n' } : { status: 1, stdout: JSON.stringify(report()) }; + }, 'win32'); + assert.equal(process.env.NoDefaultCurrentDirectoryInExePath, previous); + assert.equal(calls, failure === 'version' ? 1 : 2); + assert.equal(result.ok, failure === 'none'); + if (failure !== 'none') assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); + } + } finally { + if (saved === undefined) delete process.env.NoDefaultCurrentDirectoryInExePath; + else process.env.NoDefaultCurrentDirectoryInExePath = saved; + } }); test('reviewed runtime import boundary refuses every pending consumer while exact Ember build entry is allowed', () => { const sources = { 'ember-cli-build.js': "var EmberApp = require('ember-cli/lib/broccoli/ember-app');",