instabox 0.5.4 → 0.6.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.
@@ -0,0 +1,82 @@
1
+ # One-time config copying, run as the sandbox user. This is not Secret delivery.
2
+ import json
3
+ import os
4
+ import stat
5
+ import sys
6
+
7
+ HOME = '/data/home'
8
+ MARKER = 'INSTABOX-BASIC-SETUP '
9
+ DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
10
+
11
+
12
+ def valid_path(path):
13
+ return (isinstance(path, str) and 0 < len(path) <= 255
14
+ and not any(ord(c) < 32 or ord(c) == 127 or c == '\\' for c in path)
15
+ and all(p and p != '.' and not p.startswith('..') for p in path.split('/')))
16
+
17
+
18
+ def install_files(files, home=HOME):
19
+ results = []
20
+ for entry in files:
21
+ path = entry.get('path')
22
+ result = {'path': path, 'status': 'failed'}
23
+ results.append(result)
24
+ if not valid_path(path) or not isinstance(entry.get('text'), str):
25
+ continue
26
+ data = entry['text'].encode('utf-8')
27
+ if len(data) > 64 * 1024 or b'\x00' in data:
28
+ continue
29
+ fd = None
30
+ temporary = None
31
+ try:
32
+ fd = os.open(home, DIRECTORY)
33
+ parts = path.split('/')
34
+ for part in parts[:-1]:
35
+ try:
36
+ os.mkdir(part, 0o700, dir_fd=fd)
37
+ except FileExistsError:
38
+ pass
39
+ child = os.open(part, DIRECTORY, dir_fd=fd)
40
+ os.close(fd)
41
+ fd = child
42
+ name = parts[-1]
43
+ try:
44
+ current = os.stat(name, dir_fd=fd, follow_symlinks=False)
45
+ if not stat.S_ISREG(current.st_mode):
46
+ continue
47
+ except FileNotFoundError:
48
+ pass
49
+ temporary = '.instabox-setup-' + os.urandom(8).hex()
50
+ output = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd)
51
+ with os.fdopen(output, 'wb') as stream:
52
+ stream.write(data)
53
+ stream.flush()
54
+ os.fsync(stream.fileno())
55
+ os.fchmod(stream.fileno(), 0o600)
56
+ os.rename(temporary, name, src_dir_fd=fd, dst_dir_fd=fd)
57
+ temporary = None
58
+ result['status'] = 'written'
59
+ except Exception:
60
+ # The remote shell and filesystem are untrusted. Return only fixed
61
+ # outcomes; exception text and config contents never reach logs.
62
+ pass
63
+ finally:
64
+ if fd is not None:
65
+ if temporary:
66
+ try:
67
+ os.unlink(temporary, dir_fd=fd)
68
+ except OSError:
69
+ pass
70
+ os.close(fd)
71
+ return results
72
+
73
+
74
+ if __name__ == '__main__':
75
+ try:
76
+ payload = json.load(sys.stdin)
77
+ result = install_files(payload['files'])
78
+ sys.stdout.write(MARKER + json.dumps({'files': result}) + '\n')
79
+ sys.exit(0 if all(item['status'] == 'written' for item in result) else 1)
80
+ except Exception:
81
+ sys.stdout.write(MARKER + '{"error":"config copy failed"}\n')
82
+ sys.exit(1)
@@ -3,6 +3,7 @@
3
3
  // The one rule every function here keeps: a credential's value is never
4
4
  // returned to the agent, stored in a file or printed. Code that looks at a
5
5
  // secret returns names, places and sizes only.
6
+ import { isIP } from 'node:net';
6
7
  import { spawnSync } from 'node:child_process';
7
8
  import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
8
9
  import { fileURLToPath } from 'node:url';
@@ -82,9 +83,23 @@ export const WELL_KNOWN = new Set([
82
83
  'DIGITALOCEAN_ACCESS_TOKEN',
83
84
  ]);
84
85
 
85
- // Names the Vault refuses for env items (docs/vault.md).
86
- const RESERVED = new Set(['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS']);
87
- export function vaultEnvNameOK(name) {
86
+ // Reserved Secret variable names (internal/secrets/validate.go).
87
+ const RESERVED = new Set([
88
+ 'HOME',
89
+ 'PATH',
90
+ 'USER',
91
+ 'LOGNAME',
92
+ 'SHELL',
93
+ 'TERM',
94
+ 'XDG_RUNTIME_DIR',
95
+ 'DBUS_SESSION_BUS_ADDRESS',
96
+ 'SSL_CERT_FILE',
97
+ 'SSL_CERT_DIR',
98
+ 'REQUESTS_CA_BUNDLE',
99
+ 'CURL_CA_BUNDLE',
100
+ 'NODE_EXTRA_CA_CERTS',
101
+ ]);
102
+ export function secretNameOK(name) {
88
103
  return (
89
104
  /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
90
105
  name.length <= 128 &&
@@ -100,12 +115,13 @@ export const isSecretName = (name) => SECRET_NAME.test(name);
100
115
  export const MACOS_ONLY =
101
116
  /\/opt\/homebrew|\/usr\/local\/Cellar|\/usr\/local\/Homebrew|\/Applications\/|\/Library\/|\bosxkeychain\b|\bUseKeychain\b|\bpbcopy\b|\bpbpaste\b|\bosascript\b|\breattach-to-user-namespace\b|\bterminal-notifier\b|\bafplay\b|\/System\/|\.orbstack\/|1password.*agent\.sock|\bsecurity (?:find|add)-[a-z-]*password\b/i;
102
117
 
103
- // The Vault's limits (docs/vault.md, pkg/instaboxapi MaxVaultValueBytes); the server's own
104
- // come from `instabox usage --json` (.limits.vault) when it answers.
105
- export const VAULT_MAX_VALUE = 64 * 1024;
106
- export const VAULT_MAX_TOTAL = 256 * 1024;
107
- export const VAULT_MAX_ITEMS = 100;
118
+ // Org Secret limits; the server may override these in .limits.vault (legacy wire name).
119
+ export const SECRET_MAX_VALUE = 64 * 1024;
120
+ export const SECRET_MAX_TOTAL = 256 * 1024;
121
+ export const SECRET_MAX_ITEMS = 100;
108
122
 
123
+ export const FILE_MAX_VALUE = 64 * 1024;
124
+ export const FILE_MAX_TOTAL = 512 * 1024;
109
125
  export const BOX_HOME = '/data/home';
110
126
 
111
127
  // Runs a command without a shell; never throws. stdout is returned to the caller only:
@@ -184,43 +200,49 @@ export function human(bytes) {
184
200
  return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
185
201
  }
186
202
 
187
- // The Vault item name for a file item: its path with / as -, as `instabox vault set-file` does.
188
- export function vaultFileName(path) {
189
- return path
190
- .replace(/^\./, '')
191
- .replaceAll('/', '-')
192
- .replace(/[^A-Za-z0-9._-]/g, '-')
193
- .replace(/^-+|-+$/g, '')
194
- .slice(0, 128);
195
- }
196
-
197
- // A Vault file path must be relative, clean, without .. components (docs/vault.md).
198
- export function vaultPathOK(p) {
199
- if (!p || p.startsWith('/') || p.length > 255 || /[\0\\\n]/.test(p)) return false;
203
+ // Paths are relative to the guest home. Config copying never follows symlinks.
204
+ export function configPathOK(p) {
205
+ if (typeof p !== 'string' || !p || p.startsWith('/') || p.length > 255 || /[\x00-\x1f\x7f\\]/.test(p)) return false;
200
206
  return p.split('/').every((c) => c !== '' && c !== '.' && !c.startsWith('..'));
201
207
  }
202
208
 
203
- // A selector as the plan writes it: `*` (every box) or `key=value,key=value`.
204
- export function parseSelector(s) {
205
- s = s.trim();
206
- if (s === '*' || s === '') return {};
207
- const out = {};
208
- for (const part of s.split(',')) {
209
- const m = part.trim().match(/^([A-Za-z0-9_.-]{1,63})=([^,]{0,512})$/);
210
- if (!m) throw new Error(`selector ${JSON.stringify(s)}: write * or key=value,key=value`);
211
- out[m[1]] = m[2];
212
- }
213
- if (Object.keys(out).length > 32) throw new Error('selector: 32 labels at most');
214
- return out;
209
+ export function deliveryPolicy(delivery, hosts = []) {
210
+ if (!['env', 'proxy'].includes(delivery) || !Array.isArray(hosts)) throw new Error('delivery must be env or proxy');
211
+ if (delivery === 'env' && hosts.length) throw new Error('env delivery cannot enforce hosts; use -');
212
+ if (delivery === 'proxy' && (!hosts.length || hosts.length > 32))
213
+ throw new Error('proxy delivery requires 1-32 exact hosts');
214
+ const normalized = hosts.map((name) => {
215
+ if (typeof name !== 'string') throw new Error('hosts must be exact public DNS names');
216
+ const h = name.toLowerCase().replace(/\.$/, '');
217
+ if (
218
+ h.length > 253 ||
219
+ !h.includes('.') ||
220
+ isIP(h) ||
221
+ /(?:^|\.)(?:local|localhost)$/.test(h) ||
222
+ h.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
223
+ ) {
224
+ throw new Error('hosts must be exact public DNS names without a scheme, port, path, IP address or wildcard');
225
+ }
226
+ return h;
227
+ });
228
+ return { delivery, hosts: [...new Set(normalized)].sort() };
215
229
  }
216
230
 
217
- export function selectorString(sel) {
218
- const e = Object.entries(sel || {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
219
- return e.length ? e.map(([k, v]) => `${k}=${v}`).join(',') : '*';
231
+ export function validID(s) {
232
+ return typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(s);
220
233
  }
221
-
222
- export function sameSelector(a, b) {
223
- return selectorString(a) === selectorString(b);
234
+ export function secretIdentity(s) {
235
+ return {
236
+ id: s.id,
237
+ name: s.name,
238
+ orgId: s.orgId,
239
+ version: s.version,
240
+ delivery: s.delivery,
241
+ hosts: [...(s.hosts || [])].sort(),
242
+ };
243
+ }
244
+ export function sameSecret(a, b) {
245
+ return !!a && !!b && JSON.stringify(secretIdentity(a)) === JSON.stringify(secretIdentity(b));
224
246
  }
225
247
 
226
248
  // True when the module is the script node was started with (not imported by a test).