megit-app 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/SECURITY.md +60 -0
- package/dist/assets/{ConflictView-D9Dqg-ka.js → ConflictView-DQ18uChu.js} +1 -1
- package/dist/assets/{DiffView-Bl_RtWm9.js → DiffView-BEICOows.js} +1 -1
- package/dist/assets/{TerminalPanel-BMFn7MA6.js → TerminalPanel-CX-U7wb1.js} +1 -1
- package/dist/assets/{index-JAmzg-th.js → index-_ttj49Ya.js} +4 -4
- package/dist/index.html +1 -1
- package/dist-server/avatars.js +18 -10
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,19 @@ surface, and the HTTP API may change in any minor release.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.11.1] - 2026-09-08
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Author avatar lookups no longer read a `gh` CLI token. They were anonymous
|
|
17
|
+
already whenever `gh` was absent, and the on-disk cache means one request per
|
|
18
|
+
author, so the 60 req/h unauthenticated limit is not a constraint — while
|
|
19
|
+
reading a credential before an outbound request is a pattern worth not having.
|
|
20
|
+
- `SECURITY.md` now ships in the published package, so its threat model and
|
|
21
|
+
dependency-alert triage travel with the tarball an auditor actually reads.
|
|
22
|
+
- Every external host megit contacts is named in a header comment in the two
|
|
23
|
+
files that contact them (`server/avatars.ts`, `src/avatar.ts`).
|
|
24
|
+
|
|
12
25
|
## [0.11.0] - 2026-09-04
|
|
13
26
|
|
|
14
27
|
### Added
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| 0.11.x | yes |
|
|
8
|
+
| < 0.11 | no |
|
|
9
|
+
|
|
10
|
+
## Reporting a vulnerability
|
|
11
|
+
|
|
12
|
+
Report privately through [GitHub's security advisory form](https://github.com/vuongvu1/megit/security/advisories/new). Please don't open a public issue for a vulnerability.
|
|
13
|
+
|
|
14
|
+
Expect a first reply within a week. If a fix is warranted it ships in the next patch release, credited unless you'd rather not be.
|
|
15
|
+
|
|
16
|
+
## Threat model
|
|
17
|
+
|
|
18
|
+
megit is a local, single-user tool. It runs a server on your own machine, shells out to your own `git`, and reads your own repositories. There is no account system and no remote backend. The only outbound traffic is author avatar resolution: the GitHub commits API maps a commit's author email to an account, the photo itself comes from `avatars.githubusercontent.com`, and anything GitHub can't resolve falls back to a Gravatar lookup keyed by the SHA-256 of the email. Turning off **author avatars** in Settings stops all of it; nothing else leaves the machine.
|
|
19
|
+
|
|
20
|
+
Those requests are anonymous. megit reads no credential of yours and sends none — not a `gh` token, not your git config, and not the author email itself, since the Gravatar lookup is keyed by its SHA-256. Results persist to `~/.config/megit/avatars.json`, so a given author costs one request ever and the feature stays inside GitHub's 60 req/h unauthenticated limit.
|
|
21
|
+
|
|
22
|
+
The boundary megit defends is **the browser**: a web page you happen to have open should not be able to reach the local API, read your repositories, or get a shell.
|
|
23
|
+
|
|
24
|
+
### What is enforced
|
|
25
|
+
|
|
26
|
+
- **Loopback bind.** The server listens on `127.0.0.1` only, never `0.0.0.0`, so it is not reachable from the network.
|
|
27
|
+
- **Host header pinning.** Every request must carry a `Host` of `localhost`, `127.0.0.1`, or `[::1]`. Loopback binding alone does not stop DNS rebinding: an attacker page can repoint `attacker.tld` at `127.0.0.1`, at which point the browser treats the API as same-origin and CORS never applies. A rebound request still carries the attacker's hostname, so pinning `Host` rejects it.
|
|
28
|
+
- **WebSocket origin check.** `/api/term` hands out a PTY, and WebSockets bypass CORS entirely. The upgrade is refused unless `Origin` is a loopback URL. A missing `Origin` is allowed, since that means a non-browser client already running on the machine.
|
|
29
|
+
- **Repository allow-list.** Every repo-scoped route goes through a guard that rejects any path not registered in `~/.config/megit/config.json`. Pointing megit at a repository is always a deliberate act; a path alone is never enough.
|
|
30
|
+
- **Revisions are allow-listed, not escaped.** git treats a leading-dash revision as an option, and options like `git diff --output=<file>` write files. Client-supplied revs must match a hex-hash pattern rather than being escaped.
|
|
31
|
+
- **Path containment on blob reads.** Serving worktree file contents resolves through `realpath` and rejects anything landing outside the repository root — a cloned repo can contain a symlink pointing elsewhere, and git will materialise it on checkout. File types are also restricted to a small MIME allow-list.
|
|
32
|
+
- **No credentials are read.** The two files that touch the network — `server/avatars.ts` and `src/avatar.ts` — name every external host they know in a header comment, and neither reads a token, keychain entry or credential helper. Earlier releases called `gh auth token` to raise the avatar rate limit; 0.11.1 dropped it, because reading a credential and then making an outbound request is a shape worth not having, and the on-disk cache made the higher limit moot.
|
|
33
|
+
- **No dynamic code execution.** megit's own code contains no `eval`, no `new Function`, no base64-decoded payloads and no computed module specifiers. The one dynamic `import()` is the literal `'node-pty'`.
|
|
34
|
+
- **No blocking credential prompts.** Git runs with `GIT_TERMINAL_PROMPT=0` and stub askpass helpers, so a passphrase-protected key or expired token fails fast instead of hanging a request on stdin nobody is attached to.
|
|
35
|
+
|
|
36
|
+
### What is not
|
|
37
|
+
|
|
38
|
+
- **There is no authentication.** Anything that can already run code as your user on your machine can talk to the server, and the terminal panel is a full shell with your privileges. On a shared or multi-user machine, treat a running megit as equivalent to an open terminal. Don't run it on a host you don't trust the local users of.
|
|
39
|
+
- **megit runs your git, with your config.** Hooks, aliases, `core.fsmonitor`, credential helpers — all of it applies. Opening a repository is as safe, or unsafe, as running `git log` in it.
|
|
40
|
+
- **Write operations are real.** Reset, rebase, force-delete and discard do what they say. Destructive items are marked in the UI, but there is no undo beyond git's own reflog.
|
|
41
|
+
|
|
42
|
+
## Dependency alerts
|
|
43
|
+
|
|
44
|
+
megit's runtime dependency tree is three packages: `ws`, plus the optional `node-pty` and its own `node-addon-api`. Everything else — React, Vite, xterm.js, diff2html — is a `devDependency` that Vite inlines into `dist/` at build time and never installs on a user's machine. That is deliberate, and it means supply-chain scanners have very little to report.
|
|
45
|
+
|
|
46
|
+
What they do report is mostly a description of what megit is. A git GUI with an embedded terminal genuinely uses **shell access**, **filesystem access**, **environment variable access** and **native code** (the PTY binding), and it genuinely runs an **install script** (`node-gyp` building that binding). `node-pty` loads through a dynamic `import()` so it costs nothing until you open the terminal, which also shows up as **dynamic require**. **Network access** in megit itself is the avatar resolution described above; in the dependencies it is `ws`, which is a WebSocket library. None of these are findings so much as an accurate inventory.
|
|
47
|
+
|
|
48
|
+
The one class worth spelling out is [Socket](https://socket.dev/npm/package/megit-app)'s `gptAnomaly`, "AI-detected potential code anomaly", because the name suggests more than the contents. It was last triaged in detail at 0.8.0, which reported three instances — all in dependency files, none in megit's own code:
|
|
49
|
+
|
|
50
|
+
- `ws` → `lib/event-target.js`. Socket's own analysis text concludes it is a standard EventTarget mixin with "no suspicious patterns such as dynamic code execution, hardcoded secrets, or network activity".
|
|
51
|
+
- `node-addon-api` → `tools/clang-format.js`, reached transitively through `node-pty`. Socket's text again concludes it is a "legitimate formatting helper" with no malicious behaviour. It is a repo tooling script that never executes at runtime.
|
|
52
|
+
- `node-pty` → `deps/winpty/misc/FontSurvey.cc`. This one is a real command injection — argv interpolated into `sprintf` and handed to `system()` — in a vendored Windows console debugging tool. The file is not referenced by `node-pty`'s `binding.gyp`, so it is never compiled and never ships as a binary; it is dead source in the tarball. It is gone in `node-pty` 1.2.0, which drops winpty entirely, and megit will pick that up when 1.2.0 leaves beta.
|
|
53
|
+
|
|
54
|
+
Clearing the first two from megit's side would mean dropping `ws` for a hand-rolled RFC 6455 implementation, and clearing the third would mean dropping the terminal. Neither trade is worth making for findings whose own evidence says they are benign. Given the enforcement notes above, an anomaly reported against a file megit itself ships would be a genuine surprise — please report one if you see it.
|
|
55
|
+
|
|
56
|
+
Socket's **URL strings** alert is an inventory of every URL literal in the published tarball, and most of its entries are not megit's: React's `react.dev/errors/`, XML namespace constants, highlight.js issue links, and the GitHub links in diff2html's syntax themes all arrive inside the bundled `dist/`. The ones megit itself contributes are `127.0.0.1` for the loopback bind and the CLI's liveness probe, `http://localhost` as the required base argument to `new URL(req.url, base)` when parsing a relative request path, `github.com/vuongvu1/megit` for the link in Settings, and the avatar endpoints listed in the threat model. The alert reports that URLs exist in the source, not that any of them is contacted — only the avatar endpoints ever are, and only with avatars on.
|
|
57
|
+
|
|
58
|
+
A related false positive worth knowing about if you scan the tarball yourself: dotted-quad matches such as `1.23.82.72` or `3.3.6.6` are not IP addresses. They are runs of SVG path coordinates in the bundled icons, which an IPv4 regex cannot tell apart from an address.
|
|
59
|
+
|
|
60
|
+
If you find a dependency alert that *isn't* covered above, please report it — through the advisory form if it looks exploitable, or as a normal issue if you just want it triaged.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{d as e,i as t,n,r}from"./index-
|
|
1
|
+
import{d as e,i as t,n,r}from"./index-_ttj49Ya.js";var i=e(),a=/^<<<<<<< ?(.*)$/,o=/^\|\|\|\|\|\|\| ?(.*)$/,s=/^=======$/,c=/^>>>>>>> ?(.*)$/,l=e=>e.replace(/\r?\n$/,``);function u(e){let t=e.split(/(?<=\n)/),n=[],r=[],i=null,u=`context`,d=()=>{r.length&&n.push({kind:`context`,lines:r}),r=[]};for(let e of t){let t=l(e),f=a.exec(t);if(f){if(u!==`context`)return null;d(),i={ours:[],base:null,theirs:[],oursLabel:f[1].trim(),theirsLabel:``},u=`ours`;continue}if(u===`context`){r.push(e);continue}if(o.exec(t)){if(u!==`ours`)return null;i.base=[],u=`base`;continue}if(s.test(t)){if(u!==`ours`&&u!==`base`)return null;u=`theirs`;continue}let p=c.exec(t);if(p){if(u!==`theirs`)return null;i.theirsLabel=p[1].trim(),n.push({kind:`conflict`,block:i}),i=null,u=`context`;continue}u===`ours`?i.ours.push(e):u===`base`?i.base.push(e):i.theirs.push(e)}return u===`context`?(d(),n.some(e=>e.kind===`conflict`)?n:null):null}function d(e,t){let n=[];return e.forEach((e,r)=>{if(e.kind===`context`){n.push(...e.lines);return}let i=t.get(r);if(!i)throw Error(`no pick for conflict at segment ${r}`);(i===`ours`||i===`both`)&&n.push(...e.block.ours),(i===`theirs`||i===`both`)&&n.push(...e.block.theirs)}),n.join(``)}var f=n(),p=[[`ours`,`Use ours`],[`theirs`,`Use theirs`],[`both`,`Use both`]];function m({note:e,busy:t,onPick:n,onMarkResolved:r}){return(0,f.jsxs)(`div`,{className:`cf-card`,children:[(0,f.jsx)(`div`,{className:`cf-card-note`,children:e}),(0,f.jsxs)(`div`,{className:`cf-card-actions`,children:[r&&(0,f.jsx)(`button`,{className:`primary`,disabled:t,onClick:r,children:`Mark resolved`}),(0,f.jsx)(`button`,{disabled:t,onClick:()=>n(`ours`),children:`Keep ours`}),(0,f.jsx)(`button`,{disabled:t,onClick:()=>n(`theirs`),children:`Keep theirs`}),(0,f.jsx)(`button`,{className:`danger`,disabled:t,onClick:()=>n(`delete`),children:`Delete file`})]})]})}function h({repo:e,file:n,onResolved:a}){let[o,s]=(0,i.useState)(null),[c,l]=(0,i.useState)(new Map),[h,g]=(0,i.useState)(``),[_,v]=(0,i.useState)(!1),y=`repo=${encodeURIComponent(e)}`;(0,i.useEffect)(()=>{s(null),l(new Map),g(``),r(`/api/conflict?${y}&file=${encodeURIComponent(n)}`).then(s).catch(e=>g(e.message))},[e,n]);let b=(0,i.useMemo)(()=>o?.content?u(o.content):null,[o]),x=(0,i.useMemo)(()=>b?.filter(e=>e.kind===`conflict`).length??0,[b]);(0,i.useEffect)(()=>{!b||!x||c.size<x||(v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:`resolve`,file:n,content:d(b,c)})).then(a).catch(e=>{g(e.message),v(!1)}))},[c,b,x]);let S=e=>{v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:e,file:n})).then(a).catch(e=>{g(e.message),v(!1)})},C=()=>{v(!0),r(`/api/conflict?${y}`,t(`POST`,{action:`resolve`,file:n,content:o?.content??``})).then(a).catch(e=>{g(e.message),v(!1)})},w=(e,t)=>l(n=>new Map(n).set(e,t)),T=e=>l(t=>{let n=new Map(t);return n.delete(e),n});return h?(0,f.jsx)(`div`,{className:`diffview error`,children:h}):o?o.tooLarge?(0,f.jsxs)(`div`,{className:`diffview empty`,children:[`File too large to resolve here (`,Math.round((o.size??0)/1024),` KB) — use the terminal`]}):o.binary?(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`Binary file — there is nothing to merge line by line.`,busy:_,onPick:S})}):o.missing?(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`Deleted on one side and modified on the other.`,busy:_,onPick:S})}):b?(0,f.jsxs)(`div`,{className:`cf-view`,children:[(0,f.jsxs)(`div`,{className:`cf-progress`,children:[c.size,` of `,x,` resolved`]}),b.map((e,t)=>{if(e.kind===`context`)return(0,f.jsx)(`pre`,{className:`cf-context`,children:e.lines.join(``)},t);let n=c.get(t);return(0,f.jsxs)(`div`,{className:`cf-block${n?` picked`:``}`,children:[(0,f.jsxs)(`div`,{className:`cf-bar`,children:[(0,f.jsx)(`span`,{className:`cf-side-name`,children:e.block.oursLabel||`ours`}),n?(0,f.jsxs)(f.Fragment,{children:[(0,f.jsxs)(`span`,{className:`cf-chosen`,children:[`took `,n]}),(0,f.jsx)(`button`,{disabled:_,onClick:()=>T(t),children:`Reset`})]}):p.map(([e,n])=>(0,f.jsx)(`button`,{disabled:_,onClick:()=>w(t,e),children:n},e))]}),(!n||n===`ours`||n===`both`)&&(0,f.jsx)(`pre`,{className:`cf-ours`,children:e.block.ours.join(``)}),!n&&(0,f.jsx)(`div`,{className:`cf-mid`,children:e.block.theirsLabel||`theirs`}),(!n||n===`theirs`||n===`both`)&&(0,f.jsx)(`pre`,{className:`cf-theirs`,children:e.block.theirs.join(``)})]},t)})]}):(0,f.jsx)(`div`,{className:`cf-view`,children:(0,f.jsx)(m,{note:`No conflict markers left in this file — it looks already resolved.`,busy:_,onPick:S,onMarkResolved:C})}):(0,f.jsx)(`div`,{className:`diffview empty`,children:`Loading…`})}export{h as default};
|