Skip to content

Releases: maximilianfeix/hexvero

v0.11.1

Choose a tag to compare

@github-actions github-actions released this 18 Oct 18:31

Fixed

  • IPC: a channel-forwarding helper's parameter is not bound where a nearer declaration of the name hides it (#227). function reg(o) { { const o = "t:inner"; ipcMain.on(o, h); } } reg("t:p"); gave a false t:p row (via wrapper); the helper is now a forwarder only where the IPC call means its parameter by scope, the rule #225 applies to loop variables: a block's let/const/class/function, a pattern or for … of head, a catch (o) or a parameter of a function in between hides it, a var of the helper itself is the same binding (var o = … writes it), and a declaration in another block, before or after the call, does not hide it. In plain code the call then reads t:inner from the const, as bundled and minified builds did. The same holds for wrapper methods of classes and objects (where any declaration of the name in the method made it no wrapper, a var o or a declaration in another block no longer does) and for parameters the resolver binds from call sites (a catch (o) gave t:p via resolved). Where it cannot be decided (a nesting too deep to read) the helper is kept and the new limitation ipc_wrapper_param_undecided names it, file-scoped. JS_SCAN_SCHEMA 35.

Install: npm install -g hexvero@0.11.1 · Run: npx hexvero@0.11.1 ui

v0.11.0

Choose a tag to compare

@github-actions github-actions released this 18 Oct 17:57

Added

  • switches: process-wide security settings, read from tokens, literal-first. One row per app.commandLine.appendSwitch(name[, value]) and appendArgument(arg) (name and value as a literal or constant, else null with valueRead: "unknown"), per certificate-error listener that accepts (callback(true) or !0: accepts always as a statement of the handler's own body, conditional behind a condition, a return or in a nested function, unknown when not read; preventsDefault recorded; callback(false) only gives no row), per setCertificateVerifyProc (callback(0) accepts; -2, -3 and constants of them reject) and per write of ELECTRON_DISABLE_SECURITY_WARNINGS (process.env, window). targetKind: "app" only where the receiver is Electron's app.commandLine by scope (require, import, interop helpers, webpack's module of require("electron"), const cl = app.commandLine, const { commandLine } = app); any other receiver (a parameter, a commandLine not read, ["appendSwitch"] on one) gives a row with targetKind: "unknown", a dangerous name there dangerous: null; only a local object literal gives none. Dangerous: ignore-certificate-errors, ignore-certificate-errors-spki-list, allow-insecure-localhost, disable-web-security, allow-running-insecure-content, no-sandbox, disable-gpu-sandbox, disable-site-isolation-trials, remote-debugging-port, remote-debugging-pipe, unsafely-treat-insecure-origin-as-secure, disable-setuid-sandbox, allow-file-access-from-files, and disable-features listing IsolateOrigins/SitePerProcess (also *X, X<Trial, X:param); each justified from Electron's or Chromium's switch documentation. Names are trimmed, stripped of leading dashes, split at = and lowercased for the check; a name with other characters than a-z0-9- is undecided, never safe, and disable-features values are compared case-insensitively. The callback is read by position: a rest parameter at or before it, a pattern at it, or a short parameter list with arguments in the body is unknown; a non-zero number, a non-empty string or a constant holding true accepts; a BigInt, a separated number (1_0), an unparsable literal or an argument whose end is not found is not read (unknown). A commandLine reached through an object literal of the file gives an unknown row unless the path ends at the literal's own function. Rows past the cap cost only their reading, so padding cannot hide a later dangerous call. Listeners: on, once, addListener, prependListener, prependOnceListener. Finding electron.unsafe-switch; limitation switches_truncated (200 per file, 2,000 in all, 2,000,000 steps per file; the cap drops only rows known to be harmless, and a truncated side makes the rule undecidable). Discord 0.0.414: 12 switch rows, none dangerous, disable-features with an unread value and two loops with unread names; Claude: 7 switch rows (none dangerous; three with unread names, and two on the commandLine handed to a helper, e.appendSwitch(t, …) and e.appendSwitch("blink-settings", …), with targetKind: "unknown") and the local-pairing verify proc with accepts: "unknown" (it passes only the constants FEr/PEr, -2 and -3, which are not resolved in that 13 MB chunk; the same shape in a small file reads never); Electron Fiddle 0.38/0.39: none. JS_SCAN_SCHEMA 34. See docs/electron-findings.md ("Command-line switches and certificate checks").
  • Policy rule new-unsafe-switch (warn when a policy does not list it): a dangerous switch, accepted certificate errors or an accepting verify proc that the before build does not have; a row whose danger is not read is an unknown finding. Policy schema, examples/policy.json, SARIF rule NewUnsafeSwitch, docs/cli.md (seven rules).

Fixed

  • Comparing builds: the limitations that say bridge values or page calls of generic forwarders were not read now apply only in the files they name (#219), not to every file of the app. context_bridge_module_unread names the exposing entry and the module of the value (at preload.js:3, at api.js:1; when a module was not located or lies deeper than is read, the other build may hold the members in any file, and it applies anywhere), bridge_calls_capped and bridge_calls_unread the page scripts not read (at page.js:1), and bridge_forwarder_ambiguous the page scripts read that name the api. One of these that names no file applies anywhere, as before, unless its message ends with "No page calls are missing.", which is written only where that is established: an ambiguous api no page script names, and files not read that are only workers that were not located (their calls are read as a page's). A one-sided item in those files is unknown, an item elsewhere (a main-process handler) is added or removed again; a message that names only some files (… and N more) still applies anywhere, and ipc_wrappers_capped stays app-wide. bridge_global_written stays out of the map. A test walks every limitation code the core can emit and fails when one is neither mapped nor in a commented exemption list.
  • IPC: a loop variable is not bound where a nearer declaration of the name hides it (#225). function reg(o) { for (o of Object.keys(T)) { const o = "t:inner"; ipcMain.on(o, h); } } gave T's keys as channels (via resolved); the loop variable is now bound only where the binding lookup gives that loop's variable (for a head without a declaration, the binding it assigns), so the row has no channel in plain code, and t:inner where a bundler or minifier renames the inner name; hidden at some places of the read range and not at others, the loop is not read. The same holds for loops over lists (for (const o of ["a", "b"]) { const o = …; }). For a var head, every var of the name in the same function is that binding (two for (var o of …) loops in one function or file, a var o = 1 after the loop): only a parameter, a catch, or a block's let/const/class/function hides it. Scan schema 33.

Install: npm install -g hexvero@0.11.0 · Run: npx hexvero@0.11.0 ui

v0.10.1

Choose a tag to compare

@github-actions github-actions released this 18 Oct 16:39

Fixed

  • Comparing generic bridge forwarders (#221): a forwarder's allow-list (allowed on the contextBridge row) is compared as a set, so the same channels written in another order or twice are no change; a channel added to or taken out of the list stays a change of every member that checks it (and of a page call it now blocks, ipc allowed yes → no). The minified form of an allow-list check that returns the call, i.includes(e)?n.invoke(e,r):Promise.reject(…) (esbuild's form of if (LIST.includes(ch)) return ipcRenderer.invoke(…); return …), is read as the check, so a minified request member has allowed like the plain one instead of none. A page call of a channel the allow-list refuses is allowed: "no" only when nothing else in the member can pass the channel on: with another use of it (guard: "unknown") only the exit-first form (if (!LIST.includes(ch)) return; before every call) keeps no; behind a check around the call (if (check) return call; return fwd(ch), check && call; fwd(ch), check ? call : require("electron").ipcRenderer.invoke(ch) or ipcRenderer["invoke"](ch)) the other path may still reach the call, so the call is unknown (it was no). Lines added around the forwarders give no contextBridge or IPC change, plain, bundled and minified. On holdout8 the three changes ~shopping.send, ~shopping.request, ~shopping.receive between Gristmill v1 and v2 remain: v2 renames shopping:clear to shopping:clear-checked in that allow-list, which these rules count as a change, while the set's expectation does not. Electron Fiddle 0.38.0 → 0.39.0, Discord and Claude.app: contextBridge and IPC rows unchanged. JS_SCAN_SCHEMA 31.
  • IPC: a table's keys are channels (via: "table") only where the name means that table's loop variable (#209). for (let o of Object.keys(t)) f(o); followed by another o — a callback parameter (Object.values(t).forEach((o) => ipcMain.on(o, …))), a function's parameter, an outer binding, or a read after the loop (also of a var or of a loop variable declared elsewhere) — gave the keys of t as channels; esbuild's minifier writes such reused names. The loop variable is now tied to its loop by scope: the use must be in that loop's body, and the resolver's binding lookup must give that variable there, not a parameter, a nested loop's variable or another declaration of the name. The same holds for for (const [name, …] of LIST) over a list of pairs and for for (const k in T); if (k in T) is no loop. A loop's body is read as one statement: a block, a braceless if … else …, a nested loop, do … while, try, switch, a labeled statement, or expressions joined with commas (for (…) f(o), ipcMain.on(o, h);, as esbuild minifies) up to ; or, in code without semicolons, the line break before the next statement; so the values of Object.entries and Object.values loops in a comma body are found too, and a call on the line after a braceless body (for (const ch of ["a"]) h(ch)⏎ipcMain.on(ch, h)) no longer reads the loop's values. for await (…) is read as for (…) is. A braceless loop as the last statement of a file is read to the end of the file; a line that starts with a prefix ++, --, ! or ~ after a complete value, a line break after return or yield, and a line break after an arrow's block body end the loop's body. No change on the corpus, Discord, Claude or Electron Fiddle 0.38/0.39. Scan schema 32.

Install: npm install -g hexvero@0.10.1 · Run: npx hexvero@0.10.1 ui

v0.10.0

Choose a tag to compare

@github-actions github-actions released this 18 Oct 15:43

Added

  • The Electron runtime of a packaged build (runtime list, data.runtime, a Runtime block in analyze): for a macOS .app, a Windows or Linux install directory and a release ZIP, the Electron, Chromium and Node versions read from the bytes of the Electron binary (Chrome/… Electron/… and Node's release URL; known only when every occurrence agrees, so a universal binary's two slices must match), the fuses decoded from the wire after Electron's sentinel and named by Electron's schema version 1 (runAsNode … wasmTrapHandlers; a position past the schema, another schema version or a disagreeing wire is unknown), the ASAR integrity record (macOS ElectronAsarIntegrity in Info.plist, Windows the .exe resource Integrity/ElectronAsar; whether it covers the archive Electron loads, and whether each entry's hash matches that archive's header; each archive hashed once, at most 64 MiB of headers per build), and whether the Electron line is supported or past its end of support by an offline snapshot of Electron's release data (dated, packages/core/src/data/electronReleases.ts; refreshed by hand with scripts/electron-releases.mjs, never in CI; never a claim about vulnerabilities). Each row names the file and offset it was read from. On Windows and Linux the binary is the executable the packaging names after the app's package.json (or, on Windows, the .exe with the integrity resource); the 3 largest unnamed PE or ELF files are scanned too, and only when no named file holds markers is one taken by its markers (up to 8 scanned; it needs both a user agent and a fuse wire); any scanned files whose markers disagree make the runtime unknown. At most 4,096 strings that start like a marker are examined per kind (a file of repeats stays one pass). Support is supported, past end of support (from the end-of-life day itself) or prerelease, with the day it was judged on. In a ZIP a candidate is inflated only when its first bytes show a PE or ELF file, and all candidates inflated count against the target's size limit. A fuse wire of an unknown schema version makes the fuses row unknown. Bounded: a binary over 1 GiB is not scanned, at most 64 occurrences per marker are kept (more make the value unknown), metadata files have their own caps; a missing binary gives unknown rows and the limitation electron_runtime_not_read. An .asar or app directory alone has no runtime list (shipped: false, "not shipped in this input"). Measured on Claude 2.42304.0 (Electron 44.7.2, Chromium 152.0.7977.149, Node 24.21.0, fuses 010011011, integrity covers app.asar), Discord 0.0.414 (42.11.8, 001000011, the record covers only default_app.asar) and LM Studio 0.3.31 (37.7.1, 10110001, past end of support since 2026-01-15). docs/cli.md, "The Electron runtime"; docs/electron-findings.md, "Runtime of a packaged build".
  • Policy rule fuse-weakened (warn when a policy does not list it): between two packaged builds, a fuse that moved to its less safe state (runAsNode, nodeOptions, nodeCliInspect, grantFileProtocolExtraPrivileges on; embeddedAsarIntegrityValidation, onlyLoadAppFromAsar, cookieEncryption off), integrity coverage of the loaded archive lost, or the runtime gone past end of support; a state not read on one side, or a watched fuse the after build's wire does not hold, is an unknown finding, one packaged build only is collection_missing. Policy schema, examples/policy.json, SARIF rule FuseWeakened, docs/cli.md.
  • remoteEnabled: @electron/remote turned on, read from tokens. One row per enable(…) and initialize() call on the main module (require("@electron/remote/main"), import, the bundler interop forms, destructured and renamed imports, require(…).enable(…) as Joplin writes it, (0, R.enable)(…)), with the target named as navigation handlers name it (window, app inside web-contents-created, unknown for a helper's parameter, else the argument as written), and one use row per file that loads @electron/remote itself. A parameter or another local of the same name, and a name written after its declaration, give none. The finding electron.remote-enabled names the targets, or says "enable not found in the files read" when only loads are seen. Policy rule remote-enabled: a target newly enabled; a policy that does not list it runs it at warn. See docs/electron-findings.md ("Remote module turned on").
  • contextBridge: a bridge object built in another module is read there, member by member (R3): an imported export (exposeInMainWorld("SignalContext", MinimalSignalContext)), members named after objects or functions of the file or of other modules ({ auth, platform } gives auth.loginRequest, three levels deep), spreads of namespaces ({ ...Misc, ...Library }, also esbuild's __export getters), and an object a function of the file returns (createApi(new IpcClient("padwright"))). An object some module may change, an export that is not defined once, and a module that is not among the files leave the entry as it was and are named in context_bridge_module_unread. holdout7 bridge members 11 of 52 → 52 of 52. See docs/electron-findings.md ("Bridge objects of other modules and functions").
  • contextBridge and IPC: generic forwarders (R1). A member that passes its parameter on as the channel (invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), electron-react-boilerplate's nested ipcRenderer.sendMessage) says forwards, with allowed / prefix only for a check that is a statement of the function's own body and decides every IPC call (if (!LIST.includes(ch)) return;, if (LIST.includes(ch)) { … }, LIST.includes(ch) && call, and what esbuild makes of them; a check in a nested block, a try, a loop or a switch, an inverted one, or a list some call may change is guard: "unknown", as is any other use of the channel); the page scripts' calls of it (window.electron.ipcRenderer.sendMessage("x"), const { ipcRenderer } = window, aliases, destructured members) are ipc rows via: "bridge" with member and allowed. A page script that may replace the api as a global (window.api = …, window["api"] = …, Object.assign(window, …), Object.defineProperty(window, "api", …), a classic script's var api = …) is named in bridge_global_written, the page rows listed as read; worker scripts, files that take ipcMain from electron and files with a with statement are not read (bridge_calls_unread); no rows when another exposure of the api was not read. New limitations bridge_forwarder_ambiguous, bridge_calls_capped. See docs/electron-findings.md ("Generic forwarders and the page's calls of them").
  • IPC: channel-forwarding functions of another module give rows at their calls (R4), as Heroic (addHandler), Insomnia (ipcMainHandle), Rocket.Chat (handle as handleOnMain) and a curried makeHandlerInvoker("x") write them, with via: "wrapper" and the module and export in wrapper.

Fixed

  • The app inside a packaged build follows Electron's order since Electron 15.0.0-beta.6 (electron#30667, backport #30900): app.asar, then app/, then default_app.asar (shell/common/node_bindings.cc; app/ came first before). Before, hexvero read app/ when both were there, which Electron 15 and later do not run when app.asar loads. When the candidates would give different apps under the two orders or with the fuse onlyLoadAppFromAsar on, hexvero reads the fuse wire and the version from the runtime binary and picks by them (a schema 1 wire of 6 or more positions: app.asar first; fewer: app/ first and no such fuse; without a wire the version, with 15.0.0 prereleases before beta.6 and 15/16 nightlies left open; Electron 14 and earlier have no such fuse; a wire and a version that disagree leave the order open) (with the fuse on and no loadable app.asar, Electron runs no app: electron_app_not_read); electron_app_pick_unverified is left for a version or fuse that could not be read, and says what Electron would run otherwise. The pick in a ZIP passes on its warning to target.info.container.warning too. A package.json that is JSON but not an object is judged by the version (#216): Electron 45 and later pass over it; earlier versions run a list, string or number, and null makes them run no app; with the version unread and the pick depending on it, the pick stays open. fuse-weakened treats a fuse marked removed (r) before as not known to move (an unknown finding), also when it is not read afterwards.
  • Caps that dropped data without a word now say so. A helper called at more than 64 places (the call sites the resolver reads) gave the values of the first 64 as if they were all, e.g. 64 of 70 IPC channels with no limitation; the call now also stays open (a row without a channel, ipc_channel_unresolved), a storage wrapper called that often, also through another helper called that often, gets its unknown row at the wrapper's key, a window options parameter is not followed, and a fetch, new WebSocket or other request whose address is read through such call sites keeps an unknown row next to the addresses read. A function that takes ipcMain or ipcRenderer as a parameter and is called at more than 64 places (by name or through an export key) gives no row from the first 64 alone, and says so with ipc_wrappers_capped, which a comparison now reads as IPC values not all listed (an IPC item missing on that side is unknown, not removed); so does the search for such calls when it runs out of its budget, which left the rows out without a word. The per-file limitations list keeps 200 entries and always the first of each code, so 200 of one code (webpreferences_unattached) no longer push out scan_truncated, ipc_wrappers_capped and the others the blind spots and the comparison read; the rest are counted in one entry per code. coverage.partial counts every file read only up to the token budget, als...
Read more

v0.9.9

Choose a tag to compare

@github-actions github-actions released this 18 Oct 13:01

Security

  • MCP hexvero_export and hexvero_recover: the write policy judges every component of a path by where it leads, also a symbolic link whose target does not exist yet (a link in a sticky directory only when you or root own it), and is checked again when an export is opened (the opened file must be the one it resolved) and before every directory and file a recovery creates, each recovered file also opened and compared before its content is written; directories on the way that other users can change (owned by another user than you or root, world-writable, or group-writable unless the group is provably your user-private group on Linux, or HEXVERO_TRUST_GROUP_WRITABLE=1 is set) are refused unless sticky, including the ancestors of the root; paths are compared in their canonical form (native realpath: stored case, Windows mount points and junctions), so a path written in another case inside a root is no longer refused; before, such a link was not resolved, and hexvero_export with overwrite: true could write through it outside the allowed roots. Exports (MCP and CLI) never write through a symbolic link at the path itself: a new file is created exclusively and an existing one is opened without following links (O_NOFOLLOW where the platform has it). compare --report, check --out and check --update-baseline refuse a symbolic link at their path the same way, also with --force; so does init, which now writes through the same code (init --out - writes the policy to standard output). - writes to standard output, and /dev/stdout, /dev/stderr, /dev/null and /dev/fd/N (process substitution) are written to as the shell connected them (a terminal, a pipe, or the file of a redirection such as --out /dev/stdout > out.json), opened once. For MCP exports, a file with other hard links is not replaced.
  • Release workflow: verify also runs the end-to-end flows, the site demo check and the corpus gates, without a dependency cache, and a Windows and macOS test job must pass before anything is published, so a tag no longer publishes without the checks CI runs. The Review workflow builds and runs the pull request's code in a job with a read-only token; a separate job without that code posts the report, cut at 60,000 characters, and updates only a comment it wrote itself. The MCP registry publish follows only a Release run started by a tag push, not a rehearsal started by hand. Checkouts that do not push keep no credentials (persist-credentials: false).

Fixed

  • Output to standard output stays parseable: with --out -, --out /dev/stdout, export <path> -, check --format sarif --out - or compare --report -, the summary and the confirmation line go to stderr; exports end with a newline; a closed pipe (| head) ends the output quietly. A FIFO or device at a path that is not a stream name is refused instead of blocking, and an MCP export refuses a stream name outright.
  • compare <a> <b> without a collection prints the window, setting and values of changed windows, the lists only one side has, partial gaps, and the recover summary through the same escaping as the other output, so a line break or escape sequence in an archive entry name cannot add lines of its own.
  • Suggested commands (analyze next steps in text and in --json next[].command, the demo summary, the baseline hint of check) quote paths for a POSIX shell in single quotes, as the reproduce lines of reports already did; before, they used JSON string quoting, which a shell does not read the same way ($(…), backticks and $VAR inside it are expanded). A path is no longer read as a replacement pattern when it is put into a suggestion. On Windows they use double quotes, and only for paths of plain ASCII characters that neither cmd.exe nor PowerShell expands; any other path, and on every platform a path with control characters or bidi or zero-width marks, is shown as <path> rather than altered. A path starting with - gets ./. Reproduce lines in reports stay POSIX on every platform, so a report is the same bytes everywhere.
  • check (CLI, MCP hexvero_check): a build with no JavaScript to read, an empty directory, a directory without JavaScript, or one whose JavaScript files are only symbolic links out of it, is refused with exit 1 instead of passing with exit 0; the message names an .asar found at the top or in resources/. An app without package.json at the top is still checked and gets a warning in the new warnings list (text, JSON and Markdown output). With a baseline, app directories get a content digest in a.sha256 / b.sha256 (it was empty), over the files the analysis lists and within its limits, with paths in NFC, so a baseline written against other builds warns for them too; a partial digest is named in the baseline warnings.
  • recover and hexvero_recover: module and source names from the analyzed bundle no longer create hidden files or directories in the output directory; a leading dot in a path segment becomes _ (./.ssh/authorized_keys → _ssh/authorized_keys.js), as docs/mcp.md already said. A segment named after a Windows device (con, nul, aux, prn, com1–com9, lpt1–lpt9, also with an extension) gets a leading _ on every platform, each recovered file lists the name it was written for (written[].name), and the source-map manifest maps every source to its file (files). An output directory that is a symbolic link to a missing location is refused as invalid_input instead of failing with internal.
  • After the second review of #207: an exposing function called more than once (if (EARLY) expose(); api.c = …; if (!EARLY) expose();) counted only the writes before its first call and dropped api.c without a word; a write between the first and the last call is now one unreadable entry, and one after the last call is not exposed. In the wrapper's own file (one bundle), a replacement through a computed key that is a string constant (const k = "handle"; a[k] = f, Ipc.prototype[k] = f) or a loop over a list of strings (for (const m of ["handle", "on"]) Ipc.prototype[m] = f) blocks those methods with wrapper_method_replaced, as the plain build read across modules does; a key that cannot be read (a[process.env.M]) leaves the file unchecked.
  • contextBridge members, after review of #207: an exposure inside a function that runs from the top of the file (function expose() {…} with expose() there, setTimeout(later), a DOMContentLoaded or process.once("loaded") listener, an IIFE) lost the members written at the top before it (an unreadable entry instead); they are read again, and a write after a listener's registration is one unreadable entry. A write in a setup() called before the exposure, or in an IIFE before it, is a member again. A write in a loop that also holds the exposure (for (…) { if (i === 1) expose(…); api.x = …; }, while/break) was dropped without a word; it is one unreadable entry now. A whole new object assigned in a comma sequence or a declaration list as minifiers write them (var { … } = require("electron"), n = {…}; n = {…}; n.more = …) is read in the minified build as in the source.
  • webPreferences, after review of #207: [SB]: true inside function make(SB) beside const SB = "sandbox" was read as sandbox: true in the plain source when the file calls make(process.env.X) (the bundles said computed): a computed key is now read only where its name is bound to that constant by scope, and the resolver no longer lets a parameter whose argument is not known, a for … of variable or a catch binding fall through to a constant of the same name (also ipcMain[M](…) inside for (const M of ["on"]) beside const M = "handle", which this release read as ipcMain.handle). get [k]() {…}, async [k]() {}, *[k]() {} (already before this release) and an unread computed key in a later argument of webPreferences: Object.assign({ nodeIntegration: false }, { [k]: true }) left the earlier nodeIntegration: false standing; they make it computed now, and get [NI]() / get ["nodeIntegration"]() make that key computed. "preload": "p.js" and ["preload"]: "p.js" are read as the preload script, as the minified build already did. A getter or quoted key in an object nested in the settings (inner: { get nodeIntegration() {…} }) is no longer taken for a setting.
  • Real apps with these fixes (#199–#202): Discord, Claude.app and Electron Fiddle 0.38.0 and 0.39.0 give the same rows in every column. The in-file check reads only files where a call of a wrapper method gives a row, and there only the writes that can replace a wrapper method (its name, a computed member, Object/Reflect calls), within 200,000 steps; it finishes on every file of these apps (the largest needs 86,000 steps, about 0.1–0.6 s per app). A computed member write in the wrapper's own file (this[t]=i in the setter of Fiddle's AppState, this[M]=0 in got's Request class, this[t]=n in Claude.app's index.chunk-muI2IV6k.js) may replace a method but proves nothing: Fiddle's main and renderer bundles and that file of Claude.app are named by wrapper_replacements_unchecked, and their rows are read (counting it as a replacement dropped Fiddle's 62 AppState.save/retrieve storage rows). Corpus: no row changed in any set. JS_SCAN_SCHEMA 26.
  • A block's own const ipcMain = new EventEmitter() passed to register(ipc) { ipc.handle("r2:x", f) } was taken for Electron's ipcMain (the name alone decided), and gave a false r2:x row via parameter in the plain source (probe I; the bundles rename the local and were right). The name now counts only where its declaration there is not provably another object (new X(), a literal, an object literal, a function); const ipcMain = getIpc() is read as before.
  • ipcMain["handle"]("x", f) and ipcMain[M]("x", f) with const M = "handle" outside a loop gave ipcMain.[unknown] without a channel in ...
Read more

v0.9.8

Choose a tag to compare

@github-actions github-actions released this 17 Oct 22:06

Electron analysis

  • Storage keys passed through a wrapper class or object that another module defines are read at each call in the importing module: import { local } from "./web-store.js"; local.get(LAST_PROJECT) with local = { get: (name) => localStorage.getItem(key(name)) } there gives localStorage read tidemark.lastProject at the call, via: "local.get". The wrapper's own key expression is evaluated in its module with the argument read at the call (a literal, a constant, another module's constant such as a compiled string enum member). The receiver must be proven an instance (import, require + new, module.exports = new Store(), new (require(…).C)(), re-exports, one esbuild bundle plain or minified); a shadowing parameter, local, pattern, for … of or catch, an assignment to the binding, or a member assignment of the method name gives no row; a call whose key is not read whole is an unknown row at the call. Uses the cross-module mechanism of the IPC wrapper classes. Corpus: holdout5 (development set) storage keys 7 → 16 found and 17 → 8 missed (tidemark plain, minified and v2), holdout6 22 → 33 found and 19 → 8 missed (snapdeck plain and v2, and the v2 change +localStorage snapdeck:ocrLanguage), no new false rows; Electron Fiddle 0.38.0 → 0.39.0 storage, endpoints and IPC unchanged. Not followed: plain functions as cross-module wrappers, and endpoints through a wrapper of another module. Stored scans are read again (JS_SCAN_SCHEMA 21).
  • Wrapper look-alikes that 0.9.6 read as calls (IPC and storage): a static method called on an instance (also by the in-file storage rules), a method that a module importing the wrapper (also through module.exports = require(…) in any form) replaces or removes on the class, its prototype (also const P = Store.prototype, Object.getPrototypeOf(s), s.constructor.prototype, s.__proto__) or an instance (s.get = f, s.get ||= f, [s.get] = …, delete s.get, Object.assign, Object.defineProperty, Reflect.set, Object.setPrototypeOf), or a write to the export itself (m.store = new Proxy(…)); limitation wrapper_method_replaced, while a write to another object of the same name changes nothing. Not seen: replacements through a global, require.cache, require(n) and an instance handed to a module that does not import the wrapper. For every other reading, module.exports = require("./x") now passes ./x on like export * only when it is the file's one write of the exports, a top-level statement of its own that ends there (not conditional, not in a function, not followed by a tagged template or another continuation on the next line, not replaced or wrapped later, also not through module["exports"] or an alias of module); the replacement scan has one budget per application, read in an order that a file growing between builds does not change (defining modules first, then the rest, by path); a module past it makes the storage wrapper methods it imports count as replaced (flagged), while IPC wrapper calls are read as before with the limitation wrapper_replacements_unchecked, a name that a module's own export shadows over export * or that two export * sources give, and a parameter the callee assigns or that another call passes something else for. These give no row now.

Install: npm install -g hexvero@0.9.8 · Run: npx hexvero@0.9.8 ui

v0.9.7

Choose a tag to compare

@github-actions github-actions released this 17 Oct 21:10

Release policies

  • New rule new-bridge-exposure: a privileged object a preload newly hands to pages as it is
    (ipcRenderer or one of its methods, shell, the electron module, a Node module such as fs,
    process, require), read from bridgeExposures. The same exposure in another preload file of
    the before build is not new; an exposure hexvero could not decide is an unknown finding. A
    policy that does not list the rule runs it at warn, so upgrading never turns a passing check
    red; examples/policy.json lists it at fail. SARIF rule NewBridgeExposure, schema updated.
    hexvero 0.9.6 and older reject a policy that lists the rule (exit 2, unknown rule); keep it
    out of a policy that an older pinned version reads. A narrowed exposure (ipcRenderer →
    ipcRenderer.send) is a different object and is reported as new.

CLI

  • Commands hexvero suggests (Next after demo and analyze, the hints under lists and
    comparisons) are written as npx hexvero … when npx started it, so they can be pasted as they
    are; --json output is unchanged. Found by a first-time-user run of 0.9.4.

Web UI

  • The compare view explains the ipc group (the calls whose payload changed on an existing
    channel), like every other list; before, the group had no line saying what it lists.

Performance

  • Analysis of a large single-bundle app uses less CPU than 0.9.6, with identical results: the
    window-options evaluation shares the scan's constants of the file instead of reading them again,
    the bracket depth and parent tables are built in one pass, and the reassigned-client and
    privileged-binding checks visit only the names they ask about. Discord analyze --fresh CPU time
    1,990 → 1,760 ms (0.8.0: 1,560 ms); Claude.app unchanged (median, interleaved, on a shared machine
    at load 28 to 71; docs/benchmarks.md has the method and the raw runs).

Electron analysis

  • IPC calls with optional chaining give the rows of the plain call (channel, api, sender or receiver, args): ipcRenderer?.send(…), ipcRenderer.send?.(…), ipcMain?.handle(…), event.sender?.send(…), event?.reply(…), win?.webContents?.send(…), contextBridge?.exposeInMainWorld(…), and the forms esbuild, TypeScript and Babel lower them to for older targets, plain and minified ((_a = ipcRenderer) == null ? void 0 : _a.send(…), (a = ipcRenderer.send) == null || a.call(ipcRenderer, …), null == e || e.send(…)). The lowered forms only where the temporary is assigned from the receiver in the same expression; another name after the null test, another receiver of .call, and .apply give no row. holdout (time-tracker, plain, bundled and minified): widget?.webContents?.send("theme:changed", …) is now read, IPC channels 66 → 69 of 72 and senders and receivers 107 → 110, 0 false; holdout5 and holdout6 unchanged (no such call there). Stored scans are read again (JS_SCAN_SCHEMA 20, the bump below).
  • A window whose options spread something unreadable before a whole webPreferences is no longer read as computed: in new BrowserWindow({ ...display.bounds, frame: false, webPreferences }) with const webPreferences = { contextIsolation: true, sandbox: true } the later key replaces anything the spread brought, so every setting is known (an unwritten one takes Electron's default) and the window is no longer named in window_options_unresolved. Only for a webPreferences written in the window's own options whose object (a literal, a followed name, the objects every caller passes) nothing unreadable reaches; a spread inside it or in the declaration it names, a condition, and a spread after it ({ webPreferences: {…}, ...opts }) stay computed. A member of a window's options that may write webPreferences without the reading naming it — a quoted "webPreferences": …, a computed key [k]: …, a method or accessor — now counts like an unknown spread at its place: the keys written before it read as computed. Up to 0.9.6 such a later member was ignored, and the settings before it read as written ({ ...o, webPreferences: { sandbox: true }, ["webPreferences"]: o.p } gave sandbox: true). This also holds for a window without any spread: new BrowserWindow({ webPreferences: { sandbox: true, … }, [k]: 1 }) read safe up to 0.9.6 though k may be "webPreferences"; it now reads computed. The evaluation of an object reads a computed key whose value is one known string as that key ([KEY]: { sandbox: false } with KEY = "webPreferences" replaces the earlier object; 0.9.6 read the window as conflicting) and any other computed key, get [k]() and *gen() as possibly replacing every key before it; get NAME() / set NAME() / async NAME() name NAME. __proto__: … never replaces an own key, but a key the object does not write is taken from the prototype: in a window's options without an own webPreferences (new BrowserWindow({ __proto__: evil })) every setting reads as computed, and in webPreferences: { __proto__: x, sandbox: true } the keys other than sandbox read as computed instead of not written (both read safe before). An accessor or async member that cannot be named (async *gen(), get [k](), get 1()) may replace any earlier key.
  • IPC channel tables (const CH = { … }; ipcRenderer.send(CH.x)) read a later member that may replace an entry: [K]: "v" with K a constant is the entry K (it gave the earlier value, which is not the one sent), any other computed key, a spread, *gen() or an unnamed accessor make the earlier entries unknown, and get x() / x() {} make x unknown; the call then has no channel and is named in ipc_channel_unresolved. A handler table with a prototype ({ __proto__: base, own: fn } read through Object.entries) no longer lists __proto__ as a channel. holdout6 overlay window: nodeIntegration/contextIsolation/sandbox computed → false/true/true in the plain, bundled and minified builds (the rows were already listed; the corpus score is unchanged). Also new n(t) under a minifier's short name for BrowserWindow counts as the window taking t, as new BrowserWindow(t) does, instead of handing the object on (which made every followed key computed). Stored scans are read again (JS_SCAN_SCHEMA 20).

Install: npm install -g hexvero@0.9.7 · Run: npx hexvero@0.9.7 ui

v0.9.6

Choose a tag to compare

@github-actions github-actions released this 17 Oct 18:12

Performance

  • Analysis of large apps is faster than in 0.9.5, with identical results: a per-file text test, the
    URL constructor table, class-field owners and scoped enums are read once per file instead of per
    call or per resolver. Discord analyze --fresh 995 → 953 ms, Claude.app 8.4 → 7.5 s (median,
    interleaved, registry 0.9.5 against this build; docs/benchmarks.md has the method).

Install: npm install -g hexvero@0.9.6 · Run: npx hexvero@0.9.6 ui

v0.9.5

Choose a tag to compare

@github-actions github-actions released this 17 Oct 17:34

Electron analysis

  • IPC payload shapes (case-study miss M1): the ipc row of a call that sends (ipcRenderer.send / invoke / sendSync / postMessage, webContents.send, event.reply, event.sender.send) carries args, the arguments after the channel as written: ({dir, packageManager}, ...) names the sorted property names of an object literal argument, … ends a list whose names a ...spread or a computed key leaves unknown, ... is a spread argument and _ any other value; not read when the call's brackets do not close. compare reports a changed args as a changed call (the identity stays module+api+channel; repeats of one channel in a file that differ only there are still paired, not removed and added), and the release report lists those calls. Electron Fiddle 0.38.0 → 0.39.0: compare … ipc goes from 157 unchanged to 1 changed — preload.js::ipcRenderer.invoke::NPM_ADD_MODULES, args: "({dir, packageManager}, ...)" → "({dir, packageManager, useSocketFirewall}, ...)", the one change of src/preload/preload.ts. Not read: calls through wrapper methods, helpers and names of other files (no args). Keys are compared as written: {1.5: x} / {"1.5": x}, {0x10: x} / {16: x}, {["a"]: 1} / {a: 1} and a transpiler's spread helper (_objectSpread({}, o, {a}), (_)) against {...o, a} differ, and __proto__: p counts as an unknown name. An export written by 0.9.4 compared with a new scan shows args on every sending call as a change (the 0.9.4 rows have no args). Brackets of different kinds that meet (a cut-off file) make the call not read, and a template literal is one argument whatever commas its ${…} gaps hold. Stored scans are read again (JS_SCAN_SCHEMA 19).

Wording

  • One minified bundle or native addon is named in the singular ("1 minified bundle", not "1 minified
    bundles"); found by a first-time-user run of 0.9.4.

Install: npm install -g hexvero@0.9.5 · Run: npx hexvero@0.9.5 ui

v0.9.4

Choose a tag to compare

@github-actions github-actions released this 17 Oct 17:04

Electron analysis

  • Two false client rows that 0.9.2 also reported are gone (#157). A reassigned client binding (let h = require("https"); h = fake; h.get("https://…"), also h ??= fake, and calls before the assignment) no longer falls back to the name rule: where the name means that declaration the call is an unknown row, not the address; an assignment of another client module (if (x) https = require("http")) keeps it a client. A webpack local const r = n(7) is the client only when module 7 of the same file passes a client module on (7(e) { e.exports = require("https") }, now also a proven binding under any name); when module 7 is not in the file, the call is an unknown row instead of the address, and when it passes another module (fs) there is no row. A local bound by require to another module is that module, except under a client's own name (const axios = require("./http") stays a client). Probes against 0.9.3: every other row unchanged; const r = n(5); r.get("https://wp-real.example/") with module 5 outside the file is now an unknown row (it cannot be decided there). Electron Fiddle, holdout5 and holdout6 unchanged.

Install: npm install -g hexvero@0.9.4 · Run: npx hexvero@0.9.4 ui