Repository navigation
Classify System Composer names by own key, and draw entry uuids from crypto.getRandomValues - #73
Merged
Merged
Conversation
… type classificationOf asked `catalog.interfaces[name] || catalog.modeledDataTypes[name]` of plain objects, so a name Object.prototype carries was answered by the interface bag with the inherited function and the modeled bag was never asked. toString, constructor, valueOf, hasOwnProperty, isPrototypeOf, propertyIsEnumerable and toLocaleString are all valid MATLAB names. A struct type of any of those names read as Kind "Data Interface", classification undefined, isStructType false, in a compressed-binary and in a text .sldd alike. A numeric, enum or alias type of such a name lost its classification too, and kept its Kind only through the class fallback, which Simulink.Bus does not have. An interface was unaffected: its own key shadows the inherited one. And a catalogued type spelled "constructor" classified as the Object function, not as null. Every lookup is now of an own key, in both bags and in the type map, and both readers build prototype-less bags (emptyCatalogBag, a module export the barrel does not carry), so a definition named `__proto__` is recorded rather than dropped by the setter. catalogFromDefinitions now chooses the bag by comparing `container` rather than indexing with it. That is code-scanning alert 4, js/prototype-polluting-assignment at ScCatalog.ts:142. No file could reach it, because both readers pass a literal container. But the function is public, and a JS caller passing container '__proto__' wrote the definition onto Object.prototype. A container outside the union is now skipped. CodeQL 2.27.2's code-scanning suite over this tree: that query's result goes from 1 to 0, and nothing else changes. test/scCatalog.test.ts adds 9 cases. At origin/main 9 fail and 18 pass; here 27 of 27 pass. They cover five such names in both formats, a rename to toString through the UI path, own-key classification of a catalog that has a prototype again (host-built, structuredClone'd, JSON-cloned), `__proto__` as a name, and `__proto__` as a container.
generateUuid drew its 32 nibbles from Math.random. The uuid is an entry's identity, not a secret: it tells two same-named entries apart, and the file stores it in plain text. But each uuid published 128 bits of V8's xorshift128+ output, and two uuids drawn back to back are enough to solve for the generator's state. From the first two uuids of a fresh Node 24 process, the third was predicted exactly in 5 of 5 runs, by a GF(2) solve over the nibbles. It now takes 16 bytes from crypto.getRandomValues. That is a global in Node 19+ (so in every VS Code extension host), in browsers and in the web extension's worker, and it needs no secure context. The shape is unchanged: 32 lowercase hex nibbles as 8-4-4-4-12, none of them fixed. That is deliberately not crypto.randomUUID, which pins the version nibble to 4 and the variant nibble to 8-b. The exported signature and the declarations do not change. That is code-scanning alert 3, js/insecure-randomness at SectionNode.ts:184. It fired because the value is stored as `uuid`, which the query's account-information heuristic matches (a `uid` after a `u`). CodeQL 2.27.2's code-scanning suite: that query's result goes from 1 to 0. test/sectionNode.test.ts raises the uniqueness case from 200 uuids to 10,000 and adds two cases. One asserts that each of the 32 positions takes all 16 values over 10,000 uuids, which a switch to crypto.randomUUID fails. The other asserts that Math.random is never called, which fails at origin/main (32 calls) and passes here.
…by own key
Nothing pinned SlddNode._parseSystemComposer's switch to emptyCatalogBag. With both of its bags
put back to a plain {}, every test still passed. A plain {} loses only one name: once any other
inherited name is written there it is an own key, and the own-key lookup reads it correctly.
`__proto__` is the exception, because writing it calls the prototype setter, which drops a
string. So two cases now rename a struct type and a data interface to `__proto__` in the text
dictionary. Each asserts that the catalog keeps the name, that the entry classifies as
StructType or DataInterface, and that the text catalog equals the binary one. With both bags
put back to {}, the full suite fails exactly those two (2 failed, 6615 passed). With either one
put back alone, only that bag's case fails.
The own-key case also checked a catalog with a prototype only through clones of a reader's
catalog. It now checks one built by hand, too: { toString: <struct type> } as an object
literal, and all five names through Object.fromEntries. Both come back null from main's
classificationOf.
The two helpers take the name to rename from, instead of always StructType. At origin/main the
file has 11 failures and 18 passes; here all 29 pass.
This was referenced Oct 11, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes two CodeQL code-scanning alerts, one commit each, each with its rebuilt
dist/:js/prototype-polluting-assignment,src/datamodel/parser/ScCatalog.ts:142js/insecure-randomness,src/datamodel/node/container/SectionNode.ts:184A third commit only adds tests.
Neither alert is a real vulnerability in how this package is used. The first one sat on top of a real correctness bug, and fixing it is most of this PR.
Alert 4: prototype-polluting assignment (CodeQL: medium; effective: low)
The flagged line was
catalog[def.container][def.name] = def.typeincatalogFromDefinitions. No file can reach the polluting form, because both readers (scanScXmlandscanScJsonText) only ever pass the literal'interfaces'or'modeledDataTypes'ascontainer. But the function is exported from the barrel, and a JS caller is not held to the type union. Passingcontainer: '__proto__'really did write the definition ontoObject.prototype, and the new test for that fails onmain. So the alert is reachable only by a caller of the exported API, not by an input file.catalogFromDefinitionsnow picks the bag by comparingcontainerto the two literals instead of indexing with it. A container outside the union is skipped.The correctness bug underneath
All catalog lookups went through plain
{}objects:catalog.interfaces[name] || catalog.modeledDataTypes[name], thenSC_TYPE_TO_CLASSIFICATION[scType]. A name thatObject.prototypecarries makes the interface lookup return the inherited function, which is truthy, so the modeled-data-type bag was never consulted.toString,constructor,valueOf,hasOwnProperty,isPrototypeOf,propertyIsEnumerableandtoLocaleStringare all valid MATLAB names.I measured this on the
arch_binary.sldd/arch_binary_as_text.sldd/arch.slddfixtures, with the entry and its catalog definition renamed together:main.slddData Interface,classificationundefined,isStructTypefalseStruct Type,classificationStructType,isStructTypetrue.sldd; enum and alias in text.slddonly, becausearch_binary.slddhas no enum or alias type to renameclassificationundefinedclassificationNumericType/EnumType/AliasType"constructor"classificationOfreturns theObjectfunctionnull__proto__, throughcatalogFromDefinitionsor the text readerObject.keys)DataInterface/StructTypecatalogFromDefinitionswithcontainer: '__proto__'Object.prototypeA struct type is the only one whose Kind changes, because
Simulink.Busis the only class with no 1:1 class fallback.The fix:
classificationOfreads only own keys, in both bags and in the type map.Both readers build prototype-less bags with a new
emptyCatalogBag(): the binary path throughcatalogFromDefinitions, and the text path inSlddNode.emptyCatalogBag()is a module-level export ofScCatalog.tsthat the barrel does not re-export, sodist/index.d.tsis byte-identical.The
SystemComposerCatalogtype is unchanged. A host that builds or clones the catalog itself (which gives it a prototype again) is still classified correctly, because the lookups no longer depend on the bags being prototype-less.One visible difference for a consumer: a bag is now
Object.create(null), so it inherits no methods. Three things behave differently:bag.hasOwnProperty(x)called as a method throwsTypeError: bag.hasOwnProperty is not a function.${bag},String(bag)andbag + ''throwTypeError: Cannot convert object to primitive value.toStrictEqualagainst an object literal fails.toEqualandtoMatchObject(which data-explorer-vscode's tests use on these bags) accept it; I checked that with a vitest probe.dex-core already classifies these names correctly, because its catalog is a hash map with no inherited keys. This PR brings the reference into agreement with the port.
Alert 3: insecure randomness (CodeQL: high; effective: low)
CodeQL fired because the value is stored under the property
uuid. ItsmaybeAccountInfoheuristic includes([uU]|^|_|[a-z](?=U))([uU][iI][dD]), which matches theuidinuuid. The value is an entry's identity, not a secret. It only has to be unique (it tells two same-named entries apart, seeentrySelector.ts), and the file stores it in plain text.The real reason to change it anyway: a
Math.randomuuid published the generator's state. Every nibble was four bits of V8's xorshift128+ output, and two uuids drawn back to back are enough to solve for the 128-bit state over GF(2). I ran this onmain'sdist, in a fresh Node 24.14.1 process each time: from the first two uuids, the third was predicted exactly in 5 of 5 runs.generateUuidnow takes 16 bytes fromcrypto.getRandomValues. That is a global in Node 19+ (so every VS Code extension host), in browsers and in the web extension's worker, and it needs no secure context. The output format is unchanged: 32 lowercase hex nibbles as 8-4-4-4-12, with no version or variant nibble fixed. That is deliberately notcrypto.randomUUID, which would pin the 13th nibble to4and the 17th to one of8–b; dex-core's parity depends on the existing shape. The exported signature andSectionNode.d.tsare unchanged;npm run buildchanges onlySectionNode.jsand its two maps.One new runtime requirement:
cryptois a global only from Node 19 on, and core'spackage.jsonhas noenginesfield, so nothing declares that floor. Node 18 is end-of-life, CI and release both run Node 22 (.github/workflows/ci.ymlandrelease.yml), and every VS Code extension host has it (data-explorer-vscode requires VS Code^1.106.0). This PR does not add anenginesfield.Tests
test/scCatalog.test.tsadds 11 cases: the five names in both formats, a rename totoStringthrough the UI path, own-key classification of a catalog that has a prototype (hand-built from object literals,structuredCloned, or JSON-cloned),__proto__as a name throughcatalogFromDefinitions,__proto__as a struct-type name and as a data-interface name through the text reader, and__proto__as a container. Onmain: 11 failed, 18 passed. With this PR: 29 of 29 pass.SlddNode._parseSystemComposer's own prototype-less bags, which nothing else covers. Every other inherited name is an own key once it is written, so the own-key lookup reads it correctly even from a plain{}. Only__proto__is lost, because the setter drops it. With both bags put back to{}, the full suite fails exactly those two cases (2 failed, 6615 passed, 26 skipped). With either one put back alone, only that bag's case fails.main'sclassificationOfon its own: both the{ toString: … }literal and the five-nameObject.fromEntriescatalog come backnullthere.test/sectionNode.test.tsalready had agenerateUuidblock (format, plus uniqueness over 200 calls). This PR raises uniqueness to 10,000 calls and adds two cases:crypto.randomUUID()fails this case and nothing else.Math.random": onmainit fails (Math.randomcalled 32 times); with this PR the file passes 50 of 50.Verification
All run locally on Node 24.14.1. CI runs Node 22; I had no Node 22 available to try.
npm run verifyon each commit's tree, exit 0 all three times:dist/is current at each commit. For commits 1 and 2, I checked out each one, rannpm run build, andgit statuswas empty. Commit 3 changes onlytest/scCatalog.test.ts, and the build inside itsnpm run verifyleftdist/unchanged.javascript-code-scanning.qls(89 queries), on fresh databases:main: 3 results: prototype-polluting @ ScCatalog.ts:142, insecure-randomness @ SectionNode.ts:184, polynomial-redos @ ScCatalog.ts:185Merge order with the ReDoS branch
This PR changes
src/datamodel/parser/ScCatalog.tsandtest/scCatalog.test.ts, which the parallel ReDoS branch (fix-redos-file-scanners) also changes. Whichever merges second needsmainmerged in anddist/rebuilt (npm run build). Expect the single-linedist/datamodel/parser/ScCatalog.*.mapfiles to conflict in that merge.A dry run of that branch's source diff over this branch applies cleanly (3 hunks, 32-line offset). The test file conflicts only because both branches append a
describeblock at the end oftest/scCatalog.test.ts; keep both.Follow-ups, not in this PR
The same inherited-key lookup pattern exists elsewhere. I reproduced each case on this branch:
src/datamodel/node/data/NodeClassMap.ts:112(getClass) and:141(CLASS_MAP[obj._array_class]): an entry whose class istoString,constructor,valueOforhasOwnPropertymakes the whole dictionary fail to open withTypeError: NodeClass.parse is not a function. An unknown class such asToasteropens fine.src/datamodel/kindMap.ts:101-107(kindForClass): returns a function instead of a Kind string for those class names, for examplekindForClass('constructor')returnsObject.src/datamodel/parser/MatParser.ts:757((fields[fn] as MatVariable[]).push(child), after theif (!fields[fn])guard on 756): a struct array (1x2 here) with a field namedconstructor,toString,valueOforhasOwnPropertymakes the.matfail to open withTypeError: fields[fn].push is not a function. A 1x1 struct with the same field reads fine.