Skip to content

Commit 974125d

Browse files
feat(security): production security audit hardening and test suite expansion (31/31 passed)
1 parent 5704023 commit 974125d

15 files changed

Lines changed: 466 additions & 117 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,17 @@ cloudsync fetch http://192.168.1.5:8095/share/abc1234 --password mysecret
7777
cloudsync doctor
7878
```
7979

80-
Full documentation & CLI command reference: [cloudsync-cli/README.md](cloudsync-cli/README.md)
80+
## 🌐 Universal Deployment: Where & When to Use
81+
82+
CloudSync-CLI enables **anywhere-to-anywhere synchronization** across trusted and untrusted environments without compromising credentials:
83+
84+
| Environment & Use-Case | Scenario & Threat Model | How CloudSync-CLI Protects You |
85+
|---|---|---|
86+
| 🤖 **Untrusted AI Agent Sandboxes** | Transferring code, logs, and datasets to/from ephemeral AI agent sandboxes (Docker, VM, E2B, Modal) where you **cannot risk storing permanent SSH private keys or cloud credentials**. | Use ephemeral **`cloudsync share`** and **`cloudsync fetch`** with user-defined passwords. The sandbox connects over an encrypted, time-limited tunnel with zero exposure of your permanent credentials. |
87+
| ☁️ **Cloud Server & VPS Sync** | Synchronizing `.env`, microservice certificates, and database configs between staging and production VPS instances (AWS, GCP, DigitalOcean). | Direct **pure SSH2 encrypted tunnels** with memory-only stream transfers. No sensitive configuration data ever touches third-party public Git repositories. |
88+
| 🛡️ **Air-Gapped & High-Security Nodes** | Maintaining version history on machines with restricted or no internet access. | Native **AES-256-GCM encrypted local history snapshots** with Scrypt key derivation. Staged changes are stored as encrypted blobs on disk requiring `--passphrase` to unpack. |
89+
| ⚡ **CI/CD Build Pipelines & Runners** | Transferring large pre-built binaries or caches between distributed CI workers without vendor lock-in. | Multi-stream parallel concurrency (**`-j, --concurrency`**) with streaming 64KB chunk SHA-256 integrity verification. |
90+
| 👥 **Peer-to-Peer Developer Handoff** | Sharing database dumps, debug logs, or staging configs directly between team members behind NATs or firewalls. | Ephemeral HTTP sharing with rate limiting (60 req/min), CORS protection, security headers, and SHA-256 password authentication. |
8191

8292
---
8393

‎cloudsync-cli/README.md‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,37 @@
4040

4141
### Our Solution
4242

43-
CloudSync-CLI brings **Git-like version control** to sensitive configuration files and environment data, with **enterprise-grade security** and **multiple transport options** that Git simply wasn't designed for.
43+
CloudSync-CLI brings **Git-like version control** to sensitive configuration files, environment data, and arbitrary payloads with **enterprise-grade security** and **multiple transport options** that Git simply wasn't designed for.
44+
45+
---
46+
47+
## 🌐 Universal Deployment: Where & When to Use CloudSync-CLI
48+
49+
CloudSync-CLI is built for **anywhere-to-anywhere synchronization** across trusted and untrusted environments without credential leakage:
50+
51+
```text
52+
┌─────────────────────────┐ Encrypted Tunnel / P2P ┌─────────────────────────┐
53+
│ AI Agent Sandboxes │ ◄────────────────────────────────► │ Local Workstation │
54+
│ (Docker, VM, WebVM, CI) │ (Password Authenticated) │ (macOS, Windows, Linux) │
55+
└────────────┬────────────┘ └────────────┬────────────┘
56+
│ │
57+
│ AES-256-GCM Encrypted Snapshots │
58+
▼ ▼
59+
┌─────────────────────────┐ Pure SSH2 Multi-Stream ┌─────────────────────────┐
60+
│ Remote Cloud Servers │ ◄────────────────────────────────► │ Air-Gapped Infrastruct. │
61+
│ (AWS, GCP, Azure, VPS) │ (Zero-Tracking) │ (On-Premises Nodes) │
62+
└─────────────────────────┘ └─────────────────────────┘
63+
```
64+
65+
### 🎯 Primary Use-Case Scenarios
66+
67+
| Environment & Use-Case | Scenario & Threat Model | How CloudSync-CLI Protects You |
68+
|---|---|---|
69+
| 🤖 **Untrusted AI Agent Sandboxes** | Transferring code, logs, and artifacts to/from ephemeral AI agent sandboxes (Docker, VM, E2B, Modal) where you **cannot risk storing permanent SSH private keys or cloud credentials**. | Use ephemeral **`cloudsync share`** and **`cloudsync fetch`** with user-defined session passwords. The sandbox only receives an encrypted, time-limited tunnel with zero exposure of your permanent cloud credentials. |
70+
| ☁️ **Cloud Server & VPS Sync** | Synchronizing `.env`, microservice certs, and database configs between staging and production VPS instances (AWS, GCP, DigitalOcean). | Direct **pure SSH2 encrypted tunnels** with memory-only stream transfers. No sensitive configuration data ever touches third-party public Git repositories. |
71+
| 🛡️ **Air-Gapped & High-Security Nodes** | Maintaining version history on machines with restricted or no internet access. | Native **AES-256-GCM encrypted local history snapshots** with Scrypt key derivation. Staged changes are stored as encrypted blobs on disk requiring `--passphrase` to unpack. |
72+
| ⚡ **CI/CD Build Pipelines & Runners** | Transferring large pre-built binaries or dependency caches between distributed CI runners without vendor lock-in. | Multi-stream parallel concurrency (**`-j, --concurrency`**) with streaming 64KB chunk SHA-256 integrity verification. |
73+
| 👥 **Peer-to-Peer Developer Handoff** | Sharing database dumps, debug logs, or staging configs directly between team members behind NATs or firewalls. | Ephemeral HTTP sharing with rate limiting (60 req/min), CORS protection, security headers, and SHA-256 password authentication. |
4474

4575
---
4676

@@ -625,7 +655,7 @@ MIT License - see [LICENSE](LICENSE) for details.
625655
- [Commander.js](https://www.npmjs.com/package/commander) - CLI framework
626656
- [ssh2](https://www.npmjs.com/package/ssh2) - SSH client
627657
- [Archiver](https://www.npmjs.com/package/archiver) - ZIP compression
628-
- [diff-match-patch](https://www.npmjs.com/package/diff-match-patch) - Text diffing
658+
- [Chalk](https://www.npmjs.com/package/chalk) - Terminal styling
629659

630660
---
631661

‎cloudsync-cli/src/cli/commands/clone.js‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
/**
2-
* clone.js - Clone a remote workspace
3-
*/
4-
51
import { Command } from 'commander';
62
import chalk from 'chalk';
73
import { existsSync, mkdirSync, writeFileSync } from 'fs';
84
import { join } from 'path';
9-
10-
5+
import { isValidHost, isValidUsername, sanitizeInput } from '../../utils/security.js';
116

127
const cloneCommand = new Command('clone')
138
.description('📥 Clone a remote workspace to local')
@@ -23,7 +18,17 @@ const cloneCommand = new Command('clone')
2318
const parsed = parseRemote(remote);
2419

2520
if (!parsed) {
26-
console.log(chalk.red('❌ Invalid remote format. Use: user@host:path'));
21+
console.log(chalk.red('❌ Invalid remote format. Use: user@host:path or host:path'));
22+
return;
23+
}
24+
25+
if (!isValidHost(parsed.host)) {
26+
console.log(chalk.red(`❌ Invalid remote host: "${parsed.host}"`));
27+
return;
28+
}
29+
30+
if (parsed.user && !isValidUsername(parsed.user)) {
31+
console.log(chalk.red(`❌ Invalid username: "${parsed.user}"`));
2732
return;
2833
}
2934

‎cloudsync-cli/src/cli/commands/commit.js‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,15 @@ const commitCommand = new Command('commit')
7575
// Apply AES-256-GCM encryption if requested
7676
let isEncrypted = false;
7777
if (options.encrypt || options.passphrase) {
78-
const passphrase = options.passphrase || process.env.CLOUDSYNC_KEY_PASSWORD || 'cloudsync-default';
79-
const { encryptData } = await import('../../core/crypto/index.js');
80-
const plaintext = readFileSync(archivePath);
81-
const encrypted = encryptData(plaintext, passphrase);
82-
writeFileSync(archivePath, encrypted);
78+
const passphrase = options.passphrase || process.env.CLOUDSYNC_KEY_PASSWORD;
79+
if (!passphrase) {
80+
console.log(chalk.red('❌ Encryption requires a passphrase.'));
81+
console.log(chalk.gray(' Use: cloudsync commit --encrypt --passphrase <secret>'));
82+
console.log(chalk.gray(' Or set CLOUDSYNC_KEY_PASSWORD environment variable'));
83+
return;
84+
}
85+
const { encryptFile } = await import('../../core/crypto/index.js');
86+
await encryptFile(archivePath, passphrase);
8387
isEncrypted = true;
8488
}
8589

‎cloudsync-cli/src/cli/commands/diff.js‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,20 @@ const diffCommand = new Command('diff')
2626

2727
const history = safeJsonParse(readFileSync(indexFile, 'utf8'), []);
2828

29-
// Default to last 2 versions
29+
// Default to last 2 versions or compare 1 version with its predecessor
3030
if (versions.length === 0) {
3131
versions = [history[1]?.id, history[0]?.id].filter(Boolean);
32+
} else if (versions.length === 1) {
33+
const idx = history.findIndex(h => h.id === versions[0]);
34+
if (idx >= 0 && history[idx + 1]) {
35+
versions = [history[idx + 1].id, versions[0]];
36+
} else if (history[0] && history[0].id !== versions[0]) {
37+
versions = [versions[0], history[0].id];
38+
}
3239
}
3340

3441
if (versions.length < 2) {
35-
console.log(chalk.yellow('⚠️ Need at least 2 versions to compare'));
42+
console.log(chalk.yellow('⚠️ Need at least 2 versions to compare. Make more commits with `cloudsync commit`'));
3643
return;
3744
}
3845

‎cloudsync-cli/src/cli/commands/fetch.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const fetchCommand = new Command('fetch')
9999
}
100100

101101
const outFile = join(outDir, `shared_${shareId}.zip`);
102-
writeFileSync(outFile, Buffer.from(downloadRes.body, 'binary'));
102+
writeFileSync(outFile, downloadRes.rawBuffer);
103103

104104
console.log(chalk.green(`\n✅ Download complete!`));
105105
console.log(chalk.white(` Saved: ${chalk.cyan(outFile)}`));
@@ -144,7 +144,8 @@ function httpGet(targetUrl, timeout = 10000, customHeaders = {}, showProgress =
144144
resolve({
145145
statusCode: res.statusCode,
146146
headers: res.headers,
147-
body: buffer.toString('binary')
147+
body: buffer.toString('utf8'),
148+
rawBuffer: buffer
148149
});
149150
});
150151
});

‎cloudsync-cli/src/cli/commands/port.js‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
/**
2-
* port.js - SSH tunnel/port forwarding management
3-
*/
4-
51
import { Command } from 'commander';
62
import chalk from 'chalk';
73
import { readFileSync, existsSync } from 'fs';
84
import { join } from 'path';
9-
import { safeJsonParse } from '../../utils/security.js';
10-
5+
import { safeJsonParse, isValidPort, isValidHost } from '../../utils/security.js';
116

127
const portCommand = new Command('port')
138
.description('🔌 Create SSH tunnel/port forwarding')
@@ -26,10 +21,22 @@ const portCommand = new Command('port')
2621
}
2722

2823
// Parse port mapping
29-
const [localPort, remotePort] = mapping.split(':').map(p => parseInt(p, 10));
24+
const parts = mapping.split(':');
25+
if (parts.length !== 2) {
26+
console.log(chalk.red('❌ Invalid port mapping format. Use: local:remote (e.g., 3000:3000)'));
27+
return;
28+
}
29+
30+
const localPort = parseInt(parts[0], 10);
31+
const remotePort = parseInt(parts[1], 10);
3032

31-
if (isNaN(localPort) || isNaN(remotePort)) {
32-
console.log(chalk.red('❌ Invalid port mapping. Use format: local:remote (e.g., 3000:3000)'));
33+
if (!isValidPort(localPort) || !isValidPort(remotePort)) {
34+
console.log(chalk.red(`❌ Invalid port numbers: "${parts[0]}:${parts[1]}" (ports must be integers 1-65535)`));
35+
return;
36+
}
37+
38+
if (options.host && !isValidHost(options.host)) {
39+
console.log(chalk.red(`❌ Invalid bind host: "${options.host}"`));
3340
return;
3441
}
3542

‎cloudsync-cli/src/cli/commands/share.js‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,17 @@ async function startShareServer(session, options, verbose) {
169169
return;
170170
}
171171

172+
// Check password if session is protected
173+
if (session.password) {
174+
const reqPwd = req.headers['x-share-password'] || parsedUrl.query.pwd;
175+
const hashedReq = reqPwd ? createHash('sha256').update(String(reqPwd)).digest('hex') : null;
176+
if (!hashedReq || hashedReq !== session.password) {
177+
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
178+
res.end('<html><body style="font-family:sans-serif;text-align:center;padding:60px"><h2>\uD83D\uDD12 Password Required</h2><p>This share session is password-protected. Add <code>?pwd=YOUR_PASSWORD</code> to the URL.</p></body></html>');
179+
return;
180+
}
181+
}
182+
172183
// Serve share page
173184
const html = generateSharePage(session, verbose);
174185
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -230,18 +241,28 @@ async function startShareServer(session, options, verbose) {
230241

231242
return new Promise((resolve) => {
232243
server.listen(options.port, () => {
233-
console.log(chalk.cyan('\n🚀 Sharing server running!'));
244+
console.log(chalk.cyan('\n\uD83D\uDE80 Sharing server running!'));
234245

235246
if (verbose) {
236-
console.log(chalk.gray('\n📊 Connection Status:'));
247+
console.log(chalk.gray('\n\uD83D\uDCCA Connection Status:'));
237248
console.log(chalk.gray(` Access count: ${session.accessCount}`));
238249
console.log(chalk.gray(` Session ID: ${session.id}`));
239250
}
240251

241-
console.log(chalk.cyan('\n👀 Press Ctrl+C to stop sharing...\n'));
252+
// Fix #9: Auto-expiry timer — shut down server when session expires
253+
const ttl = new Date(session.expiresAt) - Date.now();
254+
if (ttl > 0) {
255+
setTimeout(() => {
256+
console.log(chalk.yellow('\n\u23F0 Share session expired. Server shutting down.'));
257+
server.close();
258+
process.exit(0);
259+
}, ttl);
260+
}
261+
262+
console.log(chalk.cyan('\n\uD83D\uDC40 Press Ctrl+C to stop sharing...\n'));
242263

243264
process.on('SIGINT', () => {
244-
console.log(chalk.yellow('\n\n🔒 Stopping share server...'));
265+
console.log(chalk.yellow('\n\n\uD83D\uDD12 Stopping share server...'));
245266
server.close();
246267
process.exit(0);
247268
});

‎cloudsync-cli/src/cli/commands/upload.js‎

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { Command } from 'commander';
66
import chalk from 'chalk';
7-
import { readFileSync, existsSync, readdirSync, createWriteStream, writeFileSync, mkdirSync } from 'fs';
7+
import { readFileSync, existsSync, readdirSync, createWriteStream, createReadStream, writeFileSync, mkdirSync } from 'fs';
88
import { join, relative } from 'path';
99
import { homedir } from 'os';
1010
import { ZipArchive } from 'archiver';
@@ -90,6 +90,15 @@ const uploadCommand = new Command('upload')
9090

9191
if (verbose) console.log(chalk.gray(`\n📝 Version ID: ${versionId}`));
9292

93+
// Compute streaming SHA-256 checksum (prevents OOM on large archives)
94+
const checksum = await new Promise((resolve) => {
95+
const hash = crypto.createHash('sha256');
96+
const stream = createReadStream(archivePath);
97+
stream.on('data', chunk => hash.update(chunk));
98+
stream.on('end', () => resolve(hash.digest('hex')));
99+
stream.on('error', () => resolve(''));
100+
});
101+
93102
// Save to history
94103
const historyEntry = {
95104
id: versionId,
@@ -98,7 +107,7 @@ const uploadCommand = new Command('upload')
98107
files: filesToUpload.map(f => relative(workspace, f)),
99108
timestamp: new Date().toISOString(),
100109
protocol: options.protocol,
101-
checksum: crypto.createHash('sha256').update(readFileSync(archivePath)).digest('hex')
110+
checksum
102111
};
103112

104113
saveHistory(historyEntry, verbose);
@@ -227,6 +236,9 @@ async function uploadWithProtocol(profile, archivePath, options, verbose) {
227236
const port = profile.port || 22;
228237
const username = profile.user;
229238
const keyPath = profile.key || join(homedir(), '.ssh', 'id_rsa');
239+
const remotePath = profile.path || '~/.cloudsync/uploads';
240+
const { basename: bn } = await import('path');
241+
const remoteFile = `${remotePath}/${bn(archivePath)}`;
230242

231243
if (verbose) {
232244
console.log(chalk.gray(`\n🔌 Connecting to ${username}@${host}:${port}`));
@@ -236,36 +248,62 @@ async function uploadWithProtocol(profile, archivePath, options, verbose) {
236248
const conn = new SSHClient();
237249

238250
conn.on('ready', () => {
239-
if (verbose) console.log(chalk.gray('Connected to SSH server'));
251+
if (verbose) console.log(chalk.gray(' Connected to SSH server'));
240252

241-
// Execute remote commands via exec
242-
conn.exec('mkdir -p ~/.cloudsync/uploads && cd ~/.cloudsync/uploads && pwd', (err, stream) => {
253+
// Step 1: Create remote directory
254+
conn.exec(`mkdir -p ${remotePath}`, (err, stream) => {
243255
if (err) {
244256
conn.end();
245-
return reject(err);
257+
return reject(new Error(`Remote mkdir failed: ${err.message}`));
246258
}
247-
259+
248260
stream.on('close', () => {
249-
conn.end();
250-
resolve();
251-
});
252-
253-
stream.on('data', (data) => {
254-
if (verbose) console.log(chalk.gray(`Remote: ${data}`));
261+
// Step 2: Open SFTP channel and transfer the archive
262+
conn.sftp((sftpErr, sftp) => {
263+
if (sftpErr) {
264+
conn.end();
265+
return reject(new Error(`SFTP channel failed: ${sftpErr.message}`));
266+
}
267+
268+
if (verbose) console.log(chalk.gray(` SFTP channel open, uploading to ${remoteFile}`));
269+
270+
const readStream = createReadStream(archivePath);
271+
const writeStream = sftp.createWriteStream(remoteFile);
272+
273+
let transferred = 0;
274+
readStream.on('data', (chunk) => {
275+
transferred += chunk.length;
276+
if (verbose && transferred % (5 * 1024 * 1024) < chunk.length) {
277+
console.log(chalk.gray(` 📤 ${(transferred / (1024 * 1024)).toFixed(1)} MB transferred`));
278+
}
279+
});
280+
281+
writeStream.on('close', () => {
282+
if (verbose) console.log(chalk.green(` ✅ Transfer complete: ${(transferred / 1024).toFixed(1)} KB`));
283+
conn.end();
284+
resolve();
285+
});
286+
287+
writeStream.on('error', (e) => {
288+
conn.end();
289+
reject(new Error(`SFTP write failed: ${e.message}`));
290+
});
291+
292+
readStream.pipe(writeStream);
293+
});
255294
});
256-
295+
257296
stream.stderr.on('data', (data) => {
258-
if (verbose) console.log(chalk.red(`Remote Error: ${data}`));
297+
if (verbose) console.log(chalk.red(` Remote Error: ${data}`));
259298
});
260299
});
261300
});
262301

263302
conn.on('error', (err) => {
264-
if (verbose) console.log(chalk.red(`SSH Error: ${err.message}`));
265-
// Simulate success for demo purposes when SSH isn't available
266-
console.log(chalk.yellow('\n⚠️ SSH connection not available (demo mode)'));
267-
console.log(chalk.gray(' In production, files would be transferred via:'));
268-
console.log(chalk.cyan(` scp "${archivePath}" ${username}@${host}:~/.cloudsync/uploads/`));
303+
if (verbose) console.log(chalk.gray(` SSH unavailable: ${err.message}`));
304+
console.log(chalk.yellow('\n⚠️ SSH connection not available — archive saved locally'));
305+
console.log(chalk.gray(' To transfer manually, run:'));
306+
console.log(chalk.cyan(` scp "${archivePath}" ${username}@${host}:${remotePath}/`));
269307
resolve();
270308
});
271309

@@ -277,10 +315,13 @@ async function uploadWithProtocol(profile, archivePath, options, verbose) {
277315
port,
278316
username,
279317
privateKey,
280-
readyTimeout: 30000
318+
readyTimeout: 30000,
319+
keepaliveInterval: 10000
281320
});
282321
} catch (e) {
283-
console.log(chalk.yellow('\n⚠️ SSH key not found, running in simulation mode'));
322+
console.log(chalk.yellow('\n⚠️ SSH key not found — archive saved locally'));
323+
console.log(chalk.gray(' To transfer manually, run:'));
324+
console.log(chalk.cyan(` scp "${archivePath}" ${username}@${host}:${remotePath}/`));
284325
resolve();
285326
}
286327
});

0 commit comments

Comments
 (0)