- Date: 2026-09-29
- Status: published security write-up (defensive / research). Do not run the repo.
- Verdict: malware-shaped build-time payload — arbitrary code execution and credential-stealer capability.
- Scope of proof: this write-up documents a malicious capability, not a demonstrated theft — see Evidence & caveats.
Why this exists
I received a GitHub collaboration invitation to zignaly-open-projects/Brisova (a private repo). Before pulling anything I reviewed it. It is not a legitimate real-estate app — it carries an obfuscated payload that runs in Node during the Tailwind/PostCSS build, immediately pulls a staged install, and loads a C2 + HTTP exfil toolset. This note records the whole investigation.

Repo metadata (via gh api)
- Full name:
zignaly-open-projects/Brisova(owner type: Organization, id 334021115) - Visibility: private (public URL returns 404; access is via the invite)
- Default branch:
main; language: JavaScript - Created: 2026-09-26T01:35:49Z; last push: 2026-09-26T02:17:46Z
- My access (the invited account’s):
pull: true(read access only) allow_forking: false,license: null,has_downloads: false- Description: none; 0 stars / 0 forks / 0 watchers
Layout summary
A full-stack scaffold: React 18 CRA frontend (src/), Express API (backend/src/, JSON store), Hardhat Solidity contracts (contracts/), plus a src/theme/ directory of Tailwind plugins. It looks plausible at a glance — that is the point.
Red flags
1. The payload file
src/theme/js/context/brisova-context.js is 14 lines / 4,507,936 bytes — one single line is 4.5 MB (line 8). It is javascript-obfuscator-style code (inferred from the rotated string array and offset-decoder pattern; tooling not confirmed): ~293,000 \xNN hex escapes plus a decode/offset function table. The payload logic appears small and the bulk looks like anti-analysis padding (inference from structure).
It is wired in as a Tailwind plugin in tailwind.config.js (line 46): require("./src/theme/js/context/brisova-context.js"). Tailwind/PostCSS loads that config in Node, so the code executes on npm run dev and npm run build (not in the browser sandbox).
The file even opens with a comment claiming it “runs in Node when CSS is built” — the malware is smuggled into the build step, not the app.
Bad effect: the file is not inert, and it executes far more widely than the app. Because it is a required build dependency, it runs in Node wherever tailwind.config.js is loaded — npm run dev/build, IDE Tailwind plugins, PostCSS/bundler tooling, and CI — as the developer, not in a browser sandbox. And the single 4.5 MB line with ~293,000 escapes is hostile to tooling itself: editors, linters, git diff, and grep hang or crash on it, and naive scans return nothing. Because it ships with every clone, any machine that builds the repo inherits both.
2. Suspicious token counts in the payload line
execSync x4, exec x4, spawn x2, require x18, and no literal child_process string — the module name is assembled from obfuscated fragments, so a naive grep child_process misses it. All of this sits inside a file that pretends to be a CSS plugin.
Bad effect: the danger here is a false assurance. The counts show real command-execution capability (execSync x4, exec x4, spawn x2 = shell execution) and runtime module loading (eighteen requires), yet the most dangerous primitive — child_process — is reassembled from obfuscated fragments precisely so that a casual grep child_process, or any scanner leaning on it, finds nothing. Buried in one 4.5 MB line dressed as a CSS plugin, the tokens are invisible to diffs, line-based tooling, and human review — so a reviewer concludes “clean” on a file that is not.
3. Install hook
package.json:36 has "postinstall": "npm run db:seed -w brisova-api". This runs on npm install. The seed script itself (backend/src/db/seed.js) looks like ordinary demo data (Unsplash image galleries, listings), so the install hook alone is not the payload — but it is an extra auto-execution surface.
Bad effect: npm install is the first thing anyone does, and npm runs postinstall automatically — so the repo executes code as you before you have reviewed anything, and without ever loading tailwind.config.js. The hook is dressed as a benign “seed the demo database” step, which lends the repo legitimacy and normalizes auto-execution, while the script’s real content was never fully audited (only 60 of 676 lines of seed.js were read). It is also a second, independent trigger: disabling the Tailwind plugin stops the build-time payload but not the postinstall hook, which still fires on npm install — the one thing npm install --ignore-scripts prevents.
4. Fake / generated commit history
Commit subjects do not match the files they touch, and in the sampled commits the author timestamps are pinned to exact 04:00:00Z / 17:00:00Z. Examples:
- “Add input validation for registration in
PropertyMap.jsx” - “Upgrade project dependencies in
ApiResponse.js” - “Add unit tests for user service in
tailwind.config.js” - “Integrate third-party payment API in
responsive.css” - “Add user profile management in
deploy.js” - “Update MVP feature requirements in
serialize.js”
Authors seen: flaukowski <nikolaiflaukowski@gmail.com>, sihong <sihonghuey@163.com>, dougiebuckets <douglas.crescenzi@gmail.com>. This mismatch pattern is consistent with an automated/genned repo.
Bad effect: the history is engineered, and its purpose is trust. Mismatched subjects (e.g., “Add unit tests for user service in tailwind.config.js”) file the 4.5 MB plugin under an innocuous label, so git log and git blame never surface “added an obfuscated dropper.” Plausible-looking contributor names and exact round timestamps make the repo read as a normal, actively developed project — defeating the common “just check the commit history” trust step and spreading attribution so the malicious commit cannot be pinned to a real person. Skim the history and it looks legit: that is the deception layer the whole lure rests on.
Dynamic analysis (sandboxed, stubbed)
I cloned the repo to /tmp/opencode/Brisova and loaded the plugin under a Node harness that replaced child_process, net, tls, http, https, dns, and fetch with logging stubs, with NODE_ENV=development. The payload did execute in-process; its side-effecting primitives were replaced, so no real command, file write, or network call was possible (see the Execution ledger below).
Bad effect (methodology alert): in-process stubbing is not a sandbox. Replacing the side-effecting primitives stopped those calls, but process.env was not proxied and only a subset of fs was wrapped, so across all three runs the payload had read access to the shell environment and partial access to the host filesystem. “No real command, file write, or network call was possible” therefore holds only for the primitives that were stubbed; one missed API (another fs method, worker_threads, vm, or a native addon) would have been enough to read or write. Treat this as risk acceptance, not proof of safety — never run untrusted code in-process on a machine holding real secrets.
Captured, on plugin execution:
require(os) intercepted
require(fs) intercepted
require(child_process) intercepted
os.platform -> linux
execSync: npm install sql.js socket.io-client form-data axios --no-save --no-warnings --no-progress --loglevel silent
console.log("Brisova Context")
addBase({ "*, ::before, ::after": { boxSizing: "border-box" } })
Tracing module loads showed the payload then tries:
REQUIRE: path
REQUIRE: socket.io-client
REQUIRE: axios
Re-running with os.platform() forced to win32 produced the same behavior (branching exists but stage 1 is platform-independent).
Interpretation
The roles below are inferred from the import set, not directly observed in this run.
npm install sql.js socket.io-client form-data axios --no-save ...—--no-savemeans dependencies are installed but not written to package.json, hiding the change from a casual diff.sql.js— SQLite (WASM), consistent with local staging of collected data.socket.io-client— the shape of a persistent C2 channel.axios+form-data— the shape of HTTP exfiltration.os+fs+path+process+child_process— environment and filesystem access plus arbitrary command execution (capability observed in the import set).
Bad effect: taken together, these dependencies describe how the harm would persist, not a one-off. --no-save (plus the silent flags) installs the stage into node_modules while hiding it from package.json, diffs, and build logs; sql.js stages collected data locally, so stolen data can survive on disk; socket.io-client opens a persistent two-way channel — remote control, not just theft; and axios + form-data exfiltrate over ordinary HTTPS that blends into normal traffic. Because the purpose is inferred, do not down-scope: a miner, backdoor, or ransomware dropper is not excluded.
Execution ledger
Everything actually executed during this investigation, for audit.
Malicious payload executions — 3 (all under JS interception)
The payload ran in-process three times; the bubblewrap re-run — our bwrap sandbox on the analyst’s machine, not anything from the repo — never loaded it.
| # | Command | Platform branch | Result |
|---|---|---|---|
| 1 | node --no-warnings harness.js | real linux | plugin loaded + invoked |
| 2 | node --no-warnings harness2.js linux 6.0.0 | forced linux | plugin loaded + invoked |
| 3 | node --no-warnings harness2.js win32 10.0.0 | forced win32 | plugin loaded + invoked |
In those runs the payload: required os, fs, child_process (intercepted); called os.platform(); attempted execSync("npm install sql.js socket.io-client form-data axios --no-save --no-warnings --no-progress --loglevel silent") (caught by the stub — no shell, no npm); logged Brisova Context; called addBase(...); then required path, socket.io-client, axios (the latter two MODULE_NOT_FOUND, nothing installed). No payload file read or network call was observed.
Bad effect: “all under JS interception” is the weakness, not a reassurance. Each of the three runs was a real in-process execution with the residual env/fs read access noted above, so this is three exposure windows rather than three harmless observations; and because our hermetic bwrap sandbox never loaded the payload, none of the three was contained by a real sandbox — the ledger proves the payload runs, not that observing it was safe. The two re-runs (forcing linux/win32 on Linux) bought only the platform-branching observation, adding exposure for marginal gain. Read “no payload file read or network call was observed” as “nothing was intercepted,” not “nothing happened.”
Host commands (mine, non-payload)
These are the commands I ran on the host during the investigation — read-only reconnaissance, sandbox probes and self-tests, and local file operations — none of them the payload’s own actions. They are listed for the same audit reason as the payload runs: so the ledger accounts for every command executed.
gh auth status;gh apifor repo metadata, git tree, and commits (read-only).gh repo clonetwice, into/tmp/opencode/....- Probes:
id,command -v bwrap/unshare/...,unshare -rn true,unshare -n true. bwrapisolation self-test:id,ls /home/jeff(absent),head /etc/passwd(absent),ip link(loopback only),getent hosts github.com(no DNS).- Sandbox
selftest.js:fs.readFileSync("/etc/shadow")→ denied;child_process.execSync("id")→ denied;fs.existsSync("/home/jeff")→ denied (ERR_ACCESS_DENIED). - File ops:
mkdir,write,read,sed,rg,awk,dd,chmod,rm;python3 scripts/unwrap_md.py.
Network actually performed
This separates the traffic the investigation caused from any the payload caused. The only connections are mine — GitHub for the API and clone, plus the docs pages — while the payload’s egress is none, because every network primitive was stubbed and the exfil modules never loaded.
- Mine only:
github.com(API + git clone),docs.github.com,support.github.com,github.com/.well-known/security.txt. - Payload egress: none — every
net/tls/http/https/dns/fetchpath was stubbed andsocket.io-client/axiosnever loaded.
Caveat: “Payload egress: none” describes this stubbed run, not the payload’s design or behavior in the wild. Nothing was observed only because the network primitives were stubbed and the exfil modules never loaded — the import set (socket.io-client, axios, form-data) is exactly the machinery for sending collected data out. On an unstubbed npm install/build, that egress would not be blocked; “no egress” is a limit of the test, not a property of the malware.
Not executed
- No
npm install,npm run dev,npm run build, notailwind.config.jsload, no contract compile, and no payload load inside the bubblewrap sandbox.
Capability assessment (answer to “can it read my stuff?”)
Yes — it has the capability:
process.envaccess → API keys / tokens (OPENAI, AWS, GH, etc.)fs+os+path→ filesystem enumeration/reads (creds, wallets,.env, SSH)child_process→ arbitrary command executionsql.js→ local staging of collected data (persistence on disk)socket.io-client/axios/form-data→ exfiltration
Bad effect: capability is the verdict. No read or send was observed, but the file can do all of it — read every secret in the environment and on disk, run arbitrary commands as you, stage what it takes locally (sql.js), and ship it out over a persistent C2/HTTP channel. Any one primitive would be serious; together they amount to a full remote-access-trojan profile, and that is enough on its own: treat any machine that built or ran this repo as compromised.
Evidence & caveats
What this investigation proves, and what it does not.
Proven (directly observed or reproducible):
src/theme/js/context/brisova-context.jsis a 4.5 MB single-line obfuscated file, loaded bytailwind.config.jsline 46, so it executes in Node at build time.- On execution it reaches for
os,fs, andchild_process, and runsexecSync("npm install sql.js socket.io-client form-data axios --no-save …"), then requiressocket.io-clientandaxios. package.jsoncarries apostinstallhook.
That is enough to call it a malicious dropper / build-time code execution and to justify a malware report.
Not proven (so far):
- No
process.envread or file read was observed. - No C2 domain/endpoint was extracted (the string array was never deobfuscated).
- No network connection was made (the staged deps never installed; network paths were stubbed).
- Stage 2 never ran, so no exfiltration was observed.
- The roles of
sql.js/socket.io-client/axiosare inferred from the import set, not observed in action; a different payload type (miner, backdoor, ransomware dropper) is not excluded.
Residual exposure (env/fs): in all three payload runs process.env was not proxied and only a subset of fs functions was wrapped, so the payload had read access to the shell environment and partially to the host filesystem. No such read was logged, and because child_process and every network primitive were stubbed there was no channel to send anything out — but this was interception, not a hermetic sandbox. The --clearenv bubblewrap run that would have closed the gap was validated and then aborted before loading the payload. Do not read “no egress” as “provably could not read local data.”
Other caveats: the full logic is hidden inside the 4.5 MB obfuscation, so absence of an observed read is not proof it never reads files. Unverified: npm install is expected to trigger only the postinstall seed and not the payload, and the payload is expected to fire when Tailwind compiles CSS (dev/build) — we never ran npm install, and only the first 60 of 676 lines of seed.js were read. No full deobfuscation was attempted, so the C2 endpoint is unknown.
To reach hard evidence: deobfuscate the string array to recover the C2 URL/host and any explicit target paths (.ssh, .env, wallet paths), then optionally confirm by running stage 2 inside the bubblewrap sandbox with fake modules. Even then, “steals my data” is only proven if a sensitive read is observed paired with an outbound send — which we deliberately never allowed.
Bad effect: this honesty cuts both ways. “Not proven” is not “not malicious” — reading the caveats as reassurance is how someone talks themselves into running the repo; the capability is already established, so the safe default is to treat it as malicious. Symmetrically, do not assert a demonstrated theft you cannot show. And the residual exposure is not only theoretical: because the payload had read access to the analyst’s shell environment and part of the host filesystem during all three runs, the investigation host itself should be treated as potentially exposed — by this article’s own rule, and therefore rotated/audited like any other machine that ran the repo.
Safe-use guidance
- Do not
npm install,npm run dev,npm run build, or compile contracts on this repo. - Do not open its files in tooling that evaluates/compiles them (some editors, bundlers, or CI runners will execute the Tailwind plugin).
- If you must install it anyway, use
npm install --ignore-scripts(ornpm ci --ignore-scripts) so no lifecycle hook runs — but not installing remains the only safe option. - If it was already run: assume full compromise — rotate all secrets/tokens plus SSH/GPG keys, cloud credentials, and wallets; audit
node_modulesfor unexpectedsql.js,socket.io-client,axios,form-data; clear the npm cache (~/.npm/_cacache); check outbound connections, new or long-running processes, and persistence (~/.npmrc, git hooks, cron/systemd, shell rc); review shell history and~/.npm/_logs. - Delete any clone (mine was deleted; see below).
- Consider reporting the repo/org to GitHub and declining the invite.
- Treat the machine you cloned or analysed on the same way — by this article’s own rule it counts as a machine that ran the repo (see Evidence & caveats).
Indicators of compromise (IoCs)
- Repo:
zignaly-open-projects/Brisova - Malicious file:
src/theme/js/context/brisova-context.js(~4.5 MB, one long line) - Wiring:
tailwind.config.jsline 46 requires it as a Tailwind plugin - Install hook:
package.jsonpostinstall →db:seed - Stage 1 command:
npm install sql.js socket.io-client form-data axios --no-save --no-warnings --no-progress --loglevel silent - Suspicious requires:
sql.js,socket.io-client,axios,form-data - Markers:
console.log("Brisova Context"); exact-timestamp fake commits; commit-message/file mismatches; author emails listed above - Identifiers (analyzed revision): head commit
72cd4eef82bd21386a45416ea689dc035ded7093; tree980b99af6842a1ba7f23fcd5ec8de232b91b5b69; malicious file git blob9e180c4036dd5e4d856ca6f8ab2f887b56141eac, SHA-256c093f39c12bcf6f12b1cae0841f4c8be864b38867f0f067b054d5eb85deb148c
Reporting to GitHub
Use GitHub’s web form — not an email address. The documented channel for abuse is their web form; I found no abuse email address. (GitHub’s own security.txt at https://github.com/.well-known/security.txt only points to https://hackerone.com/github, and that is for vulnerabilities in GitHub’s own products/services, not for reporting a malicious third-party repo.)
Category: Malware — file it as malware (GitHub Acceptable Use Policy §5, Active Malware or Exploits). It is not Phishing: phishing is AUP §4 (Spam and Inauthentic Activity) and means deceiving a person into revealing information; this artifact is malicious code, so malware is the correct category. The bogus collaboration invite is a social-engineering lure worth mentioning, but it does not change the category.
The inviting account could not be retrieved — GET /repos/{owner}/{repo}/invitations returns 403 without admin rights — so the inviter is unrecorded here; add it manually from the invitation if available.
Where to file it:
- In-product (preferred): open the repo page → right sidebar under “About” → Report repository (or the org page → Report abuse), then complete the form. Since the repo is private, use the form below if the sidebar link is unavailable to you as an invitee.
- Direct abuse form: https://support.github.com/contact/report-abuse?category=report-abuse&report=other&report_type=unspecified
- General support portal (if the abuse form does not fit): https://support.github.com
Full URLs to cite in the report:
- Repo: https://github.com/zignaly-open-projects/Brisova
- Organization: https://github.com/zignaly-open-projects
- Malicious file: https://github.com/zignaly-open-projects/Brisova/blob/main/src/theme/js/context/brisova-context.js
Attach this article (or paste its body) as the report evidence; mention the stage 1 command and the fake commit history as corroboration.
Cleanup performed
A second, harder-containment attempt was prepared and then aborted before loading the malware: the repo was re-cloned into /tmp/opencode/sbx/work, fake socket.io-client / axios / form-data / sql.js modules were staged to reveal any C2 target, and a bwrap --unshare-all + --clearenv + nobody + Node --permission harness was written. A harmless self-test in that sandbox verified the boundary (uid nobody, 6 env vars, /home/jeff absent, no DNS, ERR_ACCESS_DENIED on /home/jeff). The payload-execution run was not performed; the whole sandbox was then deleted.
All test artifacts were removed:
/tmp/opencode/sbx/(second sandbox + re-clone)/tmp/opencode/Brisova/(first clone)/tmp/opencode/harness.js,harness2.js,ctx-line8.js,selftest.js- captured snippet in opencode’s tool-output dir
(harness3.js was drafted but never created — the write was rejected — so it never existed to remove.)
No real npm install ran (the command was only captured by a stubbed execSync); the payload’s JS executed only under stubbed interception; no payload filesystem or network operation was observed, and no egress channel existed at any point.
Bad effect: cleanup erases artifacts, not exposure. Deleting the clones, sandbox, harnesses, and captured snippet removes the malware from disk — but it does not undo the env/fs read access the payload had during the three in-process runs, and it cannot prove nothing was left behind (shell history, ~/.npm/_logs, OS/editor caches, other tools’ output dirs). Because the hermetic bwrap run was aborted before loading the payload, no cleanup can claim the payload was ever proven inert. Treat “cleanup performed” as housekeeping, not an all-clear — the host still needs the rotation/audit in Safe-use guidance.
Handling untrusted code: zero-trust
The reusable procedure distilled from this incident. The complete SOP — the standing contract, the STOP + RAISE HANDS triggers, the trust ladder, detection commands, defang/--ignore-scripts tips, provenance checks, the bubblewrap containment recipe, the alerting skeleton, and the report channel — lives in the untrusted-code-safety skill:
- Skill (ClawHub): untrusted-code-safety
- Source (GitHub):
.opencode/skills/untrusted-code-safety/SKILL.md
Four rules:
- Detect first. Most verdicts are static and free; exhaust static triage before executing a single line.
- Be sensitive. Assume any file can execute (config, CSS plugin, lockfile, lifecycle hooks); inventory everything a process running as you can reach.
- Be alerted. If you must run it, instrument and contain (no network, cleared env, non-root,
/tmpallowlist) and raise hands on any violation. - Never trust. An invite, README, commit history, or familiar package name is not a vouch; trust is per-artifact, per-hash, re-checked — a pass at one level never grants the next.
Bad effect: these rules only protect if the operator actually stops. The chain fails at the first “it looks legit” shortcut — skip static triage (Rule 1) and you execute blind; run it outside real containment (Rule 3) and you get exactly the residual exposure this article reports. A checklist that is ignored, or a “sandbox” that is only interception, protects nothing.
Decision flow:
UNTRUSTED INPUT (repo · package · invite · script · config)
│
▼
┌───────────────────────────────────────────────┐
│ RULE 1 — DETECT (static, no execution) │
│ size · obfuscation · hooks · dynamic require │
│ · commit-history smell · net/wallet/env refs │
└───────────────┬───────────────────────────────┘
│ suspicious
▼
┌───────────────────────────────────────────────┐
│ RULE 2 — BE SENSITIVE (data inventory) │
│ env · ~/.ssh · cloud creds · wallets · │
│ browsers · source · tokens │
└───────────────┬───────────────────────────────┘
│ anything valuable is reachable
▼
┌───────────────────────────────────────────────┐
│ RULE 3 — BE ALERTED (contain + instrument) │
│ no network · cleared env · non-root · │
│ fs allowlist (/tmp) · log+block fs/env/net/ │
│ child_process · raise hands on violation │
└───────────────┬───────────────────────────────┘
│ violation (secret read / egress / exec)
▼
┌───────────────────────────────────────────────┐
│ VERDICT: MALICIOUS │
│ quarantine · rotate · report · never run │
└───────────────┬───────────────────────────────┘
│
▼
┌───────────────────────────────────────────────┐
│ RULE 4 — NEVER TRUST │
│ a pass at level N never grants level N+1; │
│ trust is per-artifact, per-hash, re-checked │
└───────────────────────────────────────────────┘
Codified policy. This session’s contract was hardened into two git-tracked places: the global opencode/AGENTS.md section “Untrusted code safety” (symlinked to ~/.config/opencode/AGENTS.md, loaded by every session), and the untrusted-code-safety skill above. In brief: /tmp is the only workspace for untrusted code; never read the user’s personal files (code, API keys, credentials, SSH/GPG keys, wallets, browser sessions); never trust an unknown source; STOP and RAISE HANDS — halt and report at once — if executed code reads outside /tmp, touches process.env/keys/credentials, collects or sends data off the machine, spawns child processes or opens unauthorized network connections, or shows any malicious, harmful, deceptive, cheating, or stealing behavior; confirm when unsure; delete all test clones and artifacts when done.
Boundary note: skills load only up to their git worktree root, so the skill auto-loads in ai-thoughts sessions only. The contract itself lives in the global AGENTS.md, which every session loads — that is why both exist.
btw, i use arch