@gpzhang2001/sharpkit-sandbox 0.2.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/LICENSE +201 -0
- package/README.md +50 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/lib/index.d.ts +321 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1145 -0
- package/lib/index.js.map +1 -0
- package/package.json +48 -0
- package/src/brand.ts +24 -0
- package/src/caido.ts +257 -0
- package/src/index.ts +366 -0
- package/src/mounts.ts +197 -0
- package/src/session.ts +444 -0
- package/src/spec.ts +263 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
6
|
+
import z from "@deepseek-ai/schemastery";
|
|
7
|
+
//#region src/caido.ts
|
|
8
|
+
/** Exact login mutation body strix posts (caido_bootstrap.py `_LOGIN_AS_GUEST_BODY`). */
|
|
9
|
+
const LOGIN_AS_GUEST_QUERY = "mutation LoginAsGuest { loginAsGuest { token { accessToken } } }";
|
|
10
|
+
/** Minimal CreateProject mutation (error identified by typename only — the
|
|
11
|
+
* payload error union has no shared `code` field in Caido 0.56.0's schema). */
|
|
12
|
+
const CREATE_PROJECT_DOC = "mutation CreateProject($input: CreateProjectInput!) { createProject(input: $input) { error { __typename } project { id name temporary } } }";
|
|
13
|
+
/** Minimal SelectProject mutation (typename-only error, same schema reason). */
|
|
14
|
+
const SELECT_PROJECT_DOC = "mutation SelectProject($id: ID!) { selectProject(id: $id) { currentProject { project { id } } error { __typename } } }";
|
|
15
|
+
/** Project identity strix creates in every sandbox (protocol constant, not a tunable). */
|
|
16
|
+
const PROJECT_NAME = "sandbox";
|
|
17
|
+
/**
|
|
18
|
+
* Build the container-internal curl login command (strix caido_bootstrap.py:46-57).
|
|
19
|
+
* @param containerBaseUrl - the in-container Caido base URL (`http://127.0.0.1:48080`).
|
|
20
|
+
* @returns the shell command string for exec.
|
|
21
|
+
*/
|
|
22
|
+
function loginCurlCommand(containerBaseUrl) {
|
|
23
|
+
const body = JSON.stringify({ query: LOGIN_AS_GUEST_QUERY });
|
|
24
|
+
return `curl -fsS -X POST -H "Content-Type: application/json" -d ${JSON.stringify(body)} ${containerBaseUrl}/graphql`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Extract the guest token from a login response payload.
|
|
28
|
+
* @param stdout - raw curl stdout.
|
|
29
|
+
* @returns the access token.
|
|
30
|
+
* @throws when the payload is unparseable or carries no token (strix error wording).
|
|
31
|
+
*/
|
|
32
|
+
function parseLoginToken(stdout) {
|
|
33
|
+
let payload;
|
|
34
|
+
try {
|
|
35
|
+
payload = JSON.parse(stdout);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new Error(`unparseable response: ${String(error)}: '${stdout}'`);
|
|
38
|
+
}
|
|
39
|
+
const token = pathOf(payload, [
|
|
40
|
+
"data",
|
|
41
|
+
"loginAsGuest",
|
|
42
|
+
"token",
|
|
43
|
+
"accessToken"
|
|
44
|
+
]);
|
|
45
|
+
if (typeof token !== "string" || token === "") throw new Error(`loginAsGuest returned no token: ${JSON.stringify(payload)}`);
|
|
46
|
+
return token;
|
|
47
|
+
}
|
|
48
|
+
/** Best-effort nested property lookup on an unknown JSON value. */
|
|
49
|
+
function pathOf(value, path) {
|
|
50
|
+
let current = value;
|
|
51
|
+
for (const key of path) {
|
|
52
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
53
|
+
current = current[key];
|
|
54
|
+
}
|
|
55
|
+
return current;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Run the guest login with retries (strix `_login_as_guest`: attempts with
|
|
59
|
+
* capped linear backoff 2,4,6,8,8… seconds; per-attempt exec timeout).
|
|
60
|
+
* @param exec - container exec channel.
|
|
61
|
+
* @param containerBaseUrl - in-container Caido base URL.
|
|
62
|
+
* @param options - attempts/timeout/backoff knobs and injected sleep.
|
|
63
|
+
* @returns the access token.
|
|
64
|
+
* @throws when every attempt fails (strix error wording).
|
|
65
|
+
*/
|
|
66
|
+
async function loginAsGuest(exec, containerBaseUrl, options) {
|
|
67
|
+
const command = loginCurlCommand(containerBaseUrl);
|
|
68
|
+
let lastError = "no attempt made";
|
|
69
|
+
for (let attempt = 1; attempt <= options.attempts; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await exec(command, options.timeoutMs);
|
|
72
|
+
if (result.ok) return parseLoginToken(result.stdout);
|
|
73
|
+
lastError = `curl exit ${result.exitCode === null ? "unknown" : result.exitCode}: ${result.stderr.slice(0, 200)}`;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
lastError = String(error instanceof Error ? error.message : error);
|
|
76
|
+
}
|
|
77
|
+
if (attempt < options.attempts) await options.sleep(Math.min(2e3 * attempt, 8e3));
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`loginAsGuest failed after ${options.attempts} attempts: ${lastError}`);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* POST one GraphQL document with bearer auth and return the `data` object.
|
|
83
|
+
* @param fetchFn - fetch channel.
|
|
84
|
+
* @param baseUrl - host-side Caido base URL.
|
|
85
|
+
* @param token - bearer token.
|
|
86
|
+
* @param doc - the GraphQL document.
|
|
87
|
+
* @param variables - operation variables.
|
|
88
|
+
* @param signal - cancellation for teardown.
|
|
89
|
+
*/
|
|
90
|
+
async function graphql(fetchFn, baseUrl, token, doc, variables, signal) {
|
|
91
|
+
const response = await fetchFn(`${baseUrl}/graphql`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
Authorization: `Bearer ${token}`
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
query: doc,
|
|
99
|
+
variables
|
|
100
|
+
}),
|
|
101
|
+
signal
|
|
102
|
+
});
|
|
103
|
+
const text = await response.text();
|
|
104
|
+
if (response.status !== 200) throw new Error(`caido graphql HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
105
|
+
let payload;
|
|
106
|
+
try {
|
|
107
|
+
payload = JSON.parse(text);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw new Error(`caido graphql unparseable response: ${String(error)}`);
|
|
110
|
+
}
|
|
111
|
+
const data = pathOf(payload, ["data"]);
|
|
112
|
+
if (typeof data !== "object" || data === null) {
|
|
113
|
+
const errors = pathOf(payload, ["errors"]);
|
|
114
|
+
const detail = Array.isArray(errors) ? JSON.stringify(errors).slice(0, 300) : text.slice(0, 200);
|
|
115
|
+
throw new Error(`caido graphql carried no data: ${detail}`);
|
|
116
|
+
}
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
/** Extract the sibling error of an operation result, for error messages. */
|
|
120
|
+
function errorOf(entry) {
|
|
121
|
+
if (typeof entry !== "object" || entry === null) return void 0;
|
|
122
|
+
const error = entry["error"];
|
|
123
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
124
|
+
return error;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Full bootstrap: guest login, then create the temporary sandbox project and
|
|
128
|
+
* select it (strix `bootstrap_caido`).
|
|
129
|
+
* @param exec - container exec channel (login curl runs in-container).
|
|
130
|
+
* @param fetchFn - host-side fetch channel (project calls).
|
|
131
|
+
* @param urls - container and host base URLs.
|
|
132
|
+
* @param options - retry/timeout knobs and injected sleep.
|
|
133
|
+
* @returns the ready endpoint.
|
|
134
|
+
*/
|
|
135
|
+
async function bootstrapCaido(exec, fetchFn, urls, options) {
|
|
136
|
+
const token = await loginAsGuest(exec, urls.containerBaseUrl, options);
|
|
137
|
+
const createData = await graphql(fetchFn, urls.hostBaseUrl, token, CREATE_PROJECT_DOC, { input: {
|
|
138
|
+
name: PROJECT_NAME,
|
|
139
|
+
temporary: true
|
|
140
|
+
} }, options.signal);
|
|
141
|
+
const createError = errorOf(createData["createProject"]);
|
|
142
|
+
if (createError !== void 0) throw new Error(`createProject failed: ${createError.__typename}${createError.code === void 0 ? "" : ` (${createError.code})`}`);
|
|
143
|
+
const projectId = pathOf(createData["createProject"], ["project", "id"]);
|
|
144
|
+
if (typeof projectId !== "string" || projectId === "") throw new Error("createProject returned no project id");
|
|
145
|
+
const selectError = errorOf((await graphql(fetchFn, urls.hostBaseUrl, token, SELECT_PROJECT_DOC, { id: projectId }, options.signal))["selectProject"]);
|
|
146
|
+
if (selectError !== void 0) throw new Error(`selectProject failed: ${selectError.__typename}${selectError.code === void 0 ? "" : ` (${selectError.code})`}`);
|
|
147
|
+
return {
|
|
148
|
+
baseUrl: urls.hostBaseUrl,
|
|
149
|
+
token,
|
|
150
|
+
projectId
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Shared, lazily-resolved bootstrap task (strix `CaidoBootstrapHandle`): the
|
|
155
|
+
* promise is created once and shared, so individual consumer cancellations
|
|
156
|
+
* cannot cancel the shared bootstrap; `close()` aborts it for teardown.
|
|
157
|
+
*/
|
|
158
|
+
var CaidoBootstrap = class {
|
|
159
|
+
endpoint;
|
|
160
|
+
controller = new AbortController();
|
|
161
|
+
settled;
|
|
162
|
+
constructor(start) {
|
|
163
|
+
this.endpoint = start(this.controller.signal).then((endpoint) => {
|
|
164
|
+
this.settled = endpoint;
|
|
165
|
+
return endpoint;
|
|
166
|
+
}, (error) => {
|
|
167
|
+
throw error;
|
|
168
|
+
});
|
|
169
|
+
this.endpoint.catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
/** Resolve the endpoint; rejects with the bootstrap failure, shared by all callers. */
|
|
172
|
+
get() {
|
|
173
|
+
return this.endpoint;
|
|
174
|
+
}
|
|
175
|
+
/** The resolved endpoint, or undefined while pending/failed (strix `peek`). */
|
|
176
|
+
peek() {
|
|
177
|
+
return this.settled;
|
|
178
|
+
}
|
|
179
|
+
/** Abort a pending bootstrap; failures are swallowed (teardown path). */
|
|
180
|
+
close() {
|
|
181
|
+
this.controller.abort();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/mounts.ts
|
|
186
|
+
/** Metadata names that get their own read-only overlay mount. */
|
|
187
|
+
const PROTECTED_METADATA_NAMES = [
|
|
188
|
+
".git",
|
|
189
|
+
".agents",
|
|
190
|
+
".codex"
|
|
191
|
+
];
|
|
192
|
+
/**
|
|
193
|
+
* Whether `child` is `parent` itself or underneath it, POSIX-style.
|
|
194
|
+
* @param parent - candidate ancestor path, already canonical.
|
|
195
|
+
* @param child - candidate descendant path, already canonical.
|
|
196
|
+
*/
|
|
197
|
+
function isSubpath(parent, child) {
|
|
198
|
+
return child === parent || child.startsWith(`${parent}/`);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Parse a git worktree `.git` pointer file for its `gitdir:` line.
|
|
202
|
+
* @param content - the raw pointer file text.
|
|
203
|
+
* @param base - directory of the pointer file, for relative gitdir values.
|
|
204
|
+
* @param resolve - canonicalizer for the candidate path.
|
|
205
|
+
* @returns the resolved gitdir, or null when absent/malformed.
|
|
206
|
+
*/
|
|
207
|
+
function parseGitdirPointer(content, base, resolve) {
|
|
208
|
+
for (const rawLine of content.split("\n")) {
|
|
209
|
+
const line = rawLine.trimEnd();
|
|
210
|
+
const separator = line.indexOf(":");
|
|
211
|
+
if (separator === -1) continue;
|
|
212
|
+
if (line.slice(0, separator).trim() !== "gitdir") continue;
|
|
213
|
+
const value = line.slice(separator + 1).trim();
|
|
214
|
+
if (value === "") continue;
|
|
215
|
+
return resolve(value.startsWith("/") ? value : `${base}/${value}`);
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Build the read-only metadata overlay mounts for one source tree (strix
|
|
221
|
+
* `_metadata_mounts`): each protected name that exists in the tree is mounted
|
|
222
|
+
* read-only at `<target>/<name>`; a file-shaped `.git` (worktree pointer)
|
|
223
|
+
* additionally gets its resolved gitdir mounted read-only when the gitdir
|
|
224
|
+
* stays inside the tree.
|
|
225
|
+
* @param tree - canonical host path of the source tree.
|
|
226
|
+
* @param target - container mount target of the tree (e.g. `/workspace/app`).
|
|
227
|
+
* @param probe - filesystem facts.
|
|
228
|
+
* @returns the overlay mounts (possibly empty).
|
|
229
|
+
*/
|
|
230
|
+
function metadataMounts(tree, target, probe) {
|
|
231
|
+
const mounts = [];
|
|
232
|
+
for (const name of PROTECTED_METADATA_NAMES) {
|
|
233
|
+
const path = `${tree}/${name}`;
|
|
234
|
+
if (!probe.exists(path)) continue;
|
|
235
|
+
const isDir = probe.isDirectory(path);
|
|
236
|
+
if (!isDir && !probe.isFile(path)) continue;
|
|
237
|
+
const resolved = probe.resolve(path);
|
|
238
|
+
if (!isSubpath(tree, resolved)) continue;
|
|
239
|
+
mounts.push({
|
|
240
|
+
source: resolved,
|
|
241
|
+
target: `${target}/${name}`,
|
|
242
|
+
readOnly: true
|
|
243
|
+
});
|
|
244
|
+
if (!isDir) {
|
|
245
|
+
const content = probe.readTextFile(path);
|
|
246
|
+
if (content === null) continue;
|
|
247
|
+
const gitdir = parseGitdirPointer(content, path.substring(0, path.lastIndexOf("/")), probe.resolve);
|
|
248
|
+
if (gitdir === null || !probe.exists(gitdir) || !isSubpath(tree, gitdir)) continue;
|
|
249
|
+
const relative = gitdir.slice(tree.length + 1);
|
|
250
|
+
mounts.push({
|
|
251
|
+
source: gitdir,
|
|
252
|
+
target: `${target}/${relative}`,
|
|
253
|
+
readOnly: true
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return mounts;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Build every bind mount for the session's sources: workspace mounts plus
|
|
261
|
+
* metadata overlays, sorted shallowest-target-first so nested targets land on
|
|
262
|
+
* top (strix sort by `/` count).
|
|
263
|
+
* @param sources - the source specs; entries missing either path part are skipped.
|
|
264
|
+
* @param probe - filesystem facts.
|
|
265
|
+
* @param workspaceRoot - container workspace root (default `/workspace`).
|
|
266
|
+
* @returns the sorted mounts.
|
|
267
|
+
*/
|
|
268
|
+
function buildBindMounts(sources, probe, workspaceRoot) {
|
|
269
|
+
const mounts = [];
|
|
270
|
+
for (const source of sources) {
|
|
271
|
+
if (source.workspaceSubdir === "" || source.sourcePath === "") continue;
|
|
272
|
+
const resolved = probe.resolve(source.sourcePath);
|
|
273
|
+
const target = `${workspaceRoot}/${source.workspaceSubdir}`;
|
|
274
|
+
mounts.push({
|
|
275
|
+
source: resolved,
|
|
276
|
+
target,
|
|
277
|
+
readOnly: false
|
|
278
|
+
});
|
|
279
|
+
if (source.protectMetadata === true) mounts.push(...metadataMounts(resolved, target, probe));
|
|
280
|
+
}
|
|
281
|
+
return mounts.sort((a, b) => targetDepth(a.target) - targetDepth(b.target) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
|
|
282
|
+
}
|
|
283
|
+
/** Path depth = number of `/` separators (strix ordering metric). */
|
|
284
|
+
function targetDepth(target) {
|
|
285
|
+
let depth = 0;
|
|
286
|
+
for (const char of target) if (char === "/") depth++;
|
|
287
|
+
return depth;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Validate an extra file's container path (strix `_extra_file_rel_path`):
|
|
291
|
+
* must live under the workspace root, have no empty/`.`/`..` segments, and
|
|
292
|
+
* carry no control characters.
|
|
293
|
+
* @param containerPath - the requested absolute container path.
|
|
294
|
+
* @param workspaceRoot - container workspace root (default `/workspace`).
|
|
295
|
+
* @returns the workspace-relative path, or null when invalid.
|
|
296
|
+
*/
|
|
297
|
+
function extraFileRelPath(containerPath, workspaceRoot) {
|
|
298
|
+
const prefix = `${workspaceRoot}/`;
|
|
299
|
+
if (!containerPath.startsWith(prefix)) return null;
|
|
300
|
+
const rel = containerPath.slice(prefix.length).replace(/^\/+/, "");
|
|
301
|
+
if (rel === "") return null;
|
|
302
|
+
const segments = rel.split("/");
|
|
303
|
+
for (const segment of segments) {
|
|
304
|
+
if (segment === "" || segment === "." || segment === "..") return null;
|
|
305
|
+
for (const char of segment) {
|
|
306
|
+
const code = char.codePointAt(0);
|
|
307
|
+
if (code === void 0 || code < 32 || code === 127) return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return rel;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Whether a candidate workspace-relative path collides with any source root
|
|
314
|
+
* or previously placed extra file (strix `_collides_with_source_root`,
|
|
315
|
+
* ancestor relationships included).
|
|
316
|
+
* @param rel - candidate workspace-relative path.
|
|
317
|
+
* @param roots - existing roots (subdirs and placed extra files), workspace-relative.
|
|
318
|
+
*/
|
|
319
|
+
function collidesWithRoots(rel, roots) {
|
|
320
|
+
return roots.some((root) => rel === root || rel.startsWith(`${root}/`) || root.startsWith(`${rel}/`));
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Sanitize a scan id for a staging directory name (strix keeps `[alnum]-_.`,
|
|
324
|
+
* everything else becomes `-`).
|
|
325
|
+
* @param scanId - the raw scan id.
|
|
326
|
+
* @returns the sanitized name fragment (empty collapses to a single `-`).
|
|
327
|
+
*/
|
|
328
|
+
function stagingDirName(scanId) {
|
|
329
|
+
let safe = "";
|
|
330
|
+
for (const char of scanId) safe += /[A-Za-z0-9]/.test(char) || char === "-" || char === "_" || char === "." ? char : "-";
|
|
331
|
+
return safe === "" ? "-" : safe;
|
|
332
|
+
}
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/spec.ts
|
|
335
|
+
/** Values that disable the json-file log rotation opts (strix `_apply_log_limits`). */
|
|
336
|
+
const LOG_DISABLED_SIZES = /* @__PURE__ */ new Set([
|
|
337
|
+
"0",
|
|
338
|
+
"off",
|
|
339
|
+
"none",
|
|
340
|
+
"unlimited"
|
|
341
|
+
]);
|
|
342
|
+
/**
|
|
343
|
+
* Build the `docker create` argv for a sandbox spec. Env/hosts/labels are
|
|
344
|
+
* emitted in sorted-key order for deterministic tests; mounts keep their
|
|
345
|
+
* (pre-sorted) order.
|
|
346
|
+
* @param spec - the assembled create spec.
|
|
347
|
+
* @returns the full argv, `["docker","create",…flags,image,…command]`.
|
|
348
|
+
*/
|
|
349
|
+
function buildCreateArgv(spec) {
|
|
350
|
+
const argv = ["docker", "create"];
|
|
351
|
+
for (const cap of spec.caps ?? []) argv.push("--cap-add", cap);
|
|
352
|
+
for (const key of Object.keys(spec.extraHosts ?? {}).sort()) argv.push("--add-host", `${key}=${spec.extraHosts?.[key]}`);
|
|
353
|
+
for (const key of Object.keys(spec.env).sort()) argv.push("-e", `${key}=${spec.env[key]}`);
|
|
354
|
+
for (const mount of spec.bindMounts) argv.push("-v", mount.readOnly ? `${mount.source}:${mount.target}:ro` : `${mount.source}:${mount.target}`);
|
|
355
|
+
if (spec.network !== void 0 && spec.network !== "") argv.push("--network", spec.network);
|
|
356
|
+
else argv.push("-p", `127.0.0.1::${spec.caidoPort}`);
|
|
357
|
+
const limits = spec.resourceLimits;
|
|
358
|
+
if (limits?.memLimit !== void 0 && limits.memLimit !== "") argv.push("--memory", limits.memLimit);
|
|
359
|
+
if (limits?.shmSize !== void 0 && limits.shmSize !== "") argv.push("--shm-size", limits.shmSize);
|
|
360
|
+
if (limits?.cpus !== void 0 && limits.cpus > 0) argv.push("--cpus", String(limits.cpus));
|
|
361
|
+
if (limits?.pidsLimit !== void 0 && Number.isInteger(limits.pidsLimit) && limits.pidsLimit > 0) argv.push("--pids-limit", String(limits.pidsLimit));
|
|
362
|
+
if (logRotationEnabled(spec.logMaxSize)) argv.push("--log-driver", "json-file", "--log-opt", `max-size=${spec.logMaxSize}`, "--log-opt", `max-file=${spec.logMaxFile ?? 3}`);
|
|
363
|
+
for (const key of Object.keys(spec.labels ?? {}).sort()) argv.push("--label", `${key}=${spec.labels?.[key]}`);
|
|
364
|
+
argv.push(spec.image, ...spec.command);
|
|
365
|
+
return argv;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Whether json-file rotation opts should be emitted for a max-size value.
|
|
369
|
+
* @param logMaxSize - the configured max-size; absent disables (docker default, unbounded).
|
|
370
|
+
* @returns true when rotation opts must be emitted.
|
|
371
|
+
*/
|
|
372
|
+
function logRotationEnabled(logMaxSize) {
|
|
373
|
+
return logMaxSize !== void 0 && logMaxSize !== "" && !LOG_DISABLED_SIZES.has(logMaxSize.toLowerCase());
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Build the container environment (strix session_manager.py:316-329 parity).
|
|
377
|
+
* Proxy vars point at the in-container Caido so all container HTTP traffic is
|
|
378
|
+
* interceptable; NO_PROXY keeps CDP/localhost traffic out of the proxy.
|
|
379
|
+
* @param options - ports/identity inputs; uid/gid only on Linux (ownership remap).
|
|
380
|
+
* @returns the env record in stable insertion order.
|
|
381
|
+
*/
|
|
382
|
+
function buildContainerEnv(options) {
|
|
383
|
+
const proxy = `http://127.0.0.1:${options.caidoPort}`;
|
|
384
|
+
const env = {
|
|
385
|
+
PYTHONUNBUFFERED: "1",
|
|
386
|
+
HOST_GATEWAY: "host.docker.internal",
|
|
387
|
+
http_proxy: proxy,
|
|
388
|
+
https_proxy: proxy,
|
|
389
|
+
ALL_PROXY: proxy,
|
|
390
|
+
NO_PROXY: "localhost,127.0.0.1"
|
|
391
|
+
};
|
|
392
|
+
if (options.platform === "linux" && options.uid !== void 0 && options.uid > 0) {
|
|
393
|
+
env.SHARPKIT_HOST_UID = String(options.uid);
|
|
394
|
+
env.SHARPKIT_HOST_GID = String(options.gid ?? options.uid);
|
|
395
|
+
}
|
|
396
|
+
return env;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Build the non-interactive exec argv: a fresh login shell per call
|
|
400
|
+
* (`bash -lc`), matching the S1 spike and the image's login-shell PATH fixups.
|
|
401
|
+
*/
|
|
402
|
+
function buildExecArgv(options) {
|
|
403
|
+
const argv = [
|
|
404
|
+
"docker",
|
|
405
|
+
"exec",
|
|
406
|
+
"-i"
|
|
407
|
+
];
|
|
408
|
+
if (options.cwd !== void 0) argv.push("-w", options.cwd);
|
|
409
|
+
argv.push(options.containerId, "bash", "-lc", options.command);
|
|
410
|
+
return argv;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Build the PTY exec argv (`docker exec -it`); the host-side PTY is provided
|
|
414
|
+
* by `ctx.subprocess.spawnTerminal` and Ctrl-C is delivered as `\x03`
|
|
415
|
+
* (spike finding D1.1).
|
|
416
|
+
*/
|
|
417
|
+
function buildExecTtyArgv(options) {
|
|
418
|
+
const argv = [
|
|
419
|
+
"docker",
|
|
420
|
+
"exec",
|
|
421
|
+
"-it"
|
|
422
|
+
];
|
|
423
|
+
if (options.cwd !== void 0) argv.push("-w", options.cwd);
|
|
424
|
+
argv.push(options.containerId, "bash", "-lc", options.command);
|
|
425
|
+
return argv;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Build the argv resolving a published port's host endpoint (`docker port`).
|
|
429
|
+
*/
|
|
430
|
+
function buildPortArgv(containerId, port) {
|
|
431
|
+
return [
|
|
432
|
+
"docker",
|
|
433
|
+
"port",
|
|
434
|
+
containerId,
|
|
435
|
+
String(port)
|
|
436
|
+
];
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Parse `docker port` output into endpoints, preferring IPv4 (strix publishes
|
|
440
|
+
* to 127.0.0.1). IPv6 literals arrive bracketed and are returned bracket-free
|
|
441
|
+
* with `bracketedIPv6` only when the raw host contains `:`.
|
|
442
|
+
* @param output - the raw `docker port` stdout (zero or more lines).
|
|
443
|
+
* @returns endpoints, IPv4 entries first; empty when nothing is published.
|
|
444
|
+
*/
|
|
445
|
+
function parsePortOutput(output) {
|
|
446
|
+
const endpoints = [];
|
|
447
|
+
for (const rawLine of output.split("\n")) {
|
|
448
|
+
const line = rawLine.trim();
|
|
449
|
+
if (line === "") continue;
|
|
450
|
+
const lastColon = line.lastIndexOf(":");
|
|
451
|
+
if (lastColon === -1) continue;
|
|
452
|
+
const port = Number.parseInt(line.slice(lastColon + 1), 10);
|
|
453
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
|
|
454
|
+
let host = line.slice(0, lastColon);
|
|
455
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
456
|
+
if (host === "") continue;
|
|
457
|
+
endpoints.push({
|
|
458
|
+
host,
|
|
459
|
+
port
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const preferred = endpoints.filter((endpoint) => !endpoint.host.includes(":"));
|
|
463
|
+
const ipv6 = endpoints.filter((endpoint) => endpoint.host.includes(":"));
|
|
464
|
+
return [...preferred, ...ipv6];
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Build the argv resolving the container's IP on a sandbox network (strix
|
|
468
|
+
* `StrixDockerSandboxSession._resolve_exposed_port`, network mode).
|
|
469
|
+
*/
|
|
470
|
+
function buildNetworkIpArgv(containerId, network) {
|
|
471
|
+
return [
|
|
472
|
+
"docker",
|
|
473
|
+
"inspect",
|
|
474
|
+
"--format",
|
|
475
|
+
`{{.NetworkSettings.Networks.${network}.IPAddress}}`,
|
|
476
|
+
containerId
|
|
477
|
+
];
|
|
478
|
+
}
|
|
479
|
+
/** Container-side path reference for `docker cp` (`<id>:<path>`). */
|
|
480
|
+
function containerRef(containerId, containerPath) {
|
|
481
|
+
return `${containerId}:${containerPath}`;
|
|
482
|
+
}
|
|
483
|
+
/** Build the `docker cp` argv copying a host file into the container. */
|
|
484
|
+
function buildPutFileArgv(hostPath, containerId, containerPath) {
|
|
485
|
+
return [
|
|
486
|
+
"docker",
|
|
487
|
+
"cp",
|
|
488
|
+
hostPath,
|
|
489
|
+
containerRef(containerId, containerPath)
|
|
490
|
+
];
|
|
491
|
+
}
|
|
492
|
+
/** Build the `docker cp` argv copying a container file out to a host path. */
|
|
493
|
+
function buildGetFileArgv(containerId, containerPath, hostPath) {
|
|
494
|
+
return [
|
|
495
|
+
"docker",
|
|
496
|
+
"cp",
|
|
497
|
+
containerRef(containerId, containerPath),
|
|
498
|
+
hostPath
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
/** Build the graceful stop argv (`docker stop -t <seconds>`). */
|
|
502
|
+
function buildStopArgv(containerId, graceMs) {
|
|
503
|
+
const seconds = Math.max(0, Math.round(graceMs / 1e3));
|
|
504
|
+
return [
|
|
505
|
+
"docker",
|
|
506
|
+
"stop",
|
|
507
|
+
"-t",
|
|
508
|
+
String(seconds),
|
|
509
|
+
containerId
|
|
510
|
+
];
|
|
511
|
+
}
|
|
512
|
+
/** Build the plain remove argv; the caller escalates to force-remove on failure. */
|
|
513
|
+
function buildRmArgv(containerId) {
|
|
514
|
+
return [
|
|
515
|
+
"docker",
|
|
516
|
+
"rm",
|
|
517
|
+
containerId
|
|
518
|
+
];
|
|
519
|
+
}
|
|
520
|
+
/** Build the force-remove argv (fallback path and failure cleanup). */
|
|
521
|
+
function buildRmForceArgv(containerId) {
|
|
522
|
+
return [
|
|
523
|
+
"docker",
|
|
524
|
+
"rm",
|
|
525
|
+
"-f",
|
|
526
|
+
containerId
|
|
527
|
+
];
|
|
528
|
+
}
|
|
529
|
+
/** Build the argv checking whether an image is present locally. */
|
|
530
|
+
function buildImageInspectArgv(image) {
|
|
531
|
+
return [
|
|
532
|
+
"docker",
|
|
533
|
+
"image",
|
|
534
|
+
"inspect",
|
|
535
|
+
image
|
|
536
|
+
];
|
|
537
|
+
}
|
|
538
|
+
/** Build the pull argv (strix pulls only when the image is missing). */
|
|
539
|
+
function buildPullArgv(image) {
|
|
540
|
+
return [
|
|
541
|
+
"docker",
|
|
542
|
+
"pull",
|
|
543
|
+
image
|
|
544
|
+
];
|
|
545
|
+
}
|
|
546
|
+
/** Build the start argv. */
|
|
547
|
+
function buildStartArgv(containerId) {
|
|
548
|
+
return [
|
|
549
|
+
"docker",
|
|
550
|
+
"start",
|
|
551
|
+
containerId
|
|
552
|
+
];
|
|
553
|
+
}
|
|
554
|
+
//#endregion
|
|
555
|
+
//#region src/brand.ts
|
|
556
|
+
/** Brand a raw string as a {@link SandboxSessionId}. */
|
|
557
|
+
function SandboxSessionId(id) {
|
|
558
|
+
return id;
|
|
559
|
+
}
|
|
560
|
+
/** Brand a raw string as a {@link SandboxProcessId}. */
|
|
561
|
+
function SandboxProcessId(id) {
|
|
562
|
+
return id;
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/session.ts
|
|
566
|
+
/**
|
|
567
|
+
* One docker-CLI sandbox session: exec/PTY command execution, file transfer
|
|
568
|
+
* via `docker cp`, lazy Caido endpoint, and strix-parity teardown. The
|
|
569
|
+
* subprocess seam arrives as a structural interface so the session is
|
|
570
|
+
* drivable from tests exactly like the S1 spike drove it. Teardown follows
|
|
571
|
+
* strix session_manager.cleanup order (staging → caido → container), each
|
|
572
|
+
* step best-effort with logging; host-side PTY trees are terminated first
|
|
573
|
+
* (spike finding D1.4: host terminate cannot reach daemon-owned container
|
|
574
|
+
* processes, so `docker rm -f` is the authoritative reaper).
|
|
575
|
+
* @module @gpzhang2001/sharpkit-sandbox/session
|
|
576
|
+
*/
|
|
577
|
+
/** Collected output read helper: reader text or ''. */
|
|
578
|
+
function readerText(handle, stream) {
|
|
579
|
+
return handle.collected[stream]?.readFrom(0).text ?? "";
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Run one argv to completion with collected output and a hard timeout that
|
|
583
|
+
* terminates (and joins) the tree — the primitive every docker CLI call in
|
|
584
|
+
* the session goes through. A timeout and a caller abort terminate the host
|
|
585
|
+
* tree identically but are reported separately (`timedOut` vs `aborted`);
|
|
586
|
+
* per spike finding D1.4, host-side termination cannot reach daemon-owned
|
|
587
|
+
* container processes — a timed-out `docker exec` may leave its command
|
|
588
|
+
* running inside the container until the session stops and reaps it.
|
|
589
|
+
* @param subprocess - the subprocess seam.
|
|
590
|
+
* @param argv - full argv, argv[0] a program (never shell-interpreted).
|
|
591
|
+
* @param options - timeout and terminate grace.
|
|
592
|
+
* @returns the collected outcome.
|
|
593
|
+
*/
|
|
594
|
+
async function runCollectArgv(subprocess, argv, options) {
|
|
595
|
+
const handle = subprocess.spawn({
|
|
596
|
+
argv,
|
|
597
|
+
cwd: process.cwd(),
|
|
598
|
+
stdio: {
|
|
599
|
+
stdin: "ignore",
|
|
600
|
+
stdout: { maxBytes: options.collectMaxBytes },
|
|
601
|
+
stderr: { maxBytes: options.collectMaxBytes }
|
|
602
|
+
},
|
|
603
|
+
graceMs: options.graceMs
|
|
604
|
+
});
|
|
605
|
+
let timedOut = false;
|
|
606
|
+
let aborted = false;
|
|
607
|
+
let timer;
|
|
608
|
+
let onAbort;
|
|
609
|
+
const deadline = new Promise((resolve) => {
|
|
610
|
+
timer = setTimeout(() => {
|
|
611
|
+
timedOut = true;
|
|
612
|
+
handle.terminate();
|
|
613
|
+
resolve("deadline");
|
|
614
|
+
}, options.timeoutMs);
|
|
615
|
+
const signal = options.signal;
|
|
616
|
+
if (signal !== void 0) {
|
|
617
|
+
onAbort = () => {
|
|
618
|
+
aborted = true;
|
|
619
|
+
handle.terminate();
|
|
620
|
+
resolve("deadline");
|
|
621
|
+
};
|
|
622
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
const winner = await Promise.race([handle.done.then((outcome) => ({ outcome })), deadline.then(() => "deadline")]);
|
|
626
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
627
|
+
if (onAbort !== void 0) options.signal?.removeEventListener("abort", onAbort);
|
|
628
|
+
const outcome = winner === "deadline" ? await handle.done : winner.outcome;
|
|
629
|
+
return {
|
|
630
|
+
exitCode: outcome.exitCode,
|
|
631
|
+
signal: outcome.signal,
|
|
632
|
+
stdout: readerText(handle, "stdout"),
|
|
633
|
+
stderr: readerText(handle, "stderr"),
|
|
634
|
+
timedOut,
|
|
635
|
+
aborted
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The docker-CLI-backed {@link PentestSandboxSession}. Constructed by the
|
|
640
|
+
* service after the container is created and started; never constructed
|
|
641
|
+
* directly by consumers.
|
|
642
|
+
*/
|
|
643
|
+
var DockerCliSandboxSession = class {
|
|
644
|
+
sessionId;
|
|
645
|
+
scanId;
|
|
646
|
+
containerId;
|
|
647
|
+
deps;
|
|
648
|
+
ttyProcesses = /* @__PURE__ */ new Map();
|
|
649
|
+
stopped = false;
|
|
650
|
+
constructor(deps) {
|
|
651
|
+
this.deps = deps;
|
|
652
|
+
this.sessionId = crypto.randomUUID();
|
|
653
|
+
this.scanId = deps.scanId;
|
|
654
|
+
this.containerId = deps.containerId;
|
|
655
|
+
}
|
|
656
|
+
async ready() {
|
|
657
|
+
await this.deps.bootstrap.get();
|
|
658
|
+
}
|
|
659
|
+
async caidoEndpoint() {
|
|
660
|
+
return this.deps.bootstrap.get();
|
|
661
|
+
}
|
|
662
|
+
async exec(command, options) {
|
|
663
|
+
if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`);
|
|
664
|
+
const timeoutMs = options?.timeoutMs ?? this.deps.defaultExecTimeoutMs;
|
|
665
|
+
return runCollectArgv(this.deps.subprocess, buildExecArgv({
|
|
666
|
+
containerId: this.containerId,
|
|
667
|
+
command,
|
|
668
|
+
cwd: options?.cwd
|
|
669
|
+
}), {
|
|
670
|
+
timeoutMs,
|
|
671
|
+
graceMs: this.deps.graceMs,
|
|
672
|
+
collectMaxBytes: this.deps.collectMaxBytes,
|
|
673
|
+
signal: options?.signal
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Start a cancellable long-running exec with no timeout (jobs own the
|
|
678
|
+
* lifetime): the caller polls {@link SandboxExecProcess.readOutput} deltas
|
|
679
|
+
* and terminates on cancel.
|
|
680
|
+
*/
|
|
681
|
+
execJob(command, options) {
|
|
682
|
+
if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`);
|
|
683
|
+
const handle = this.deps.subprocess.spawn({
|
|
684
|
+
argv: buildExecArgv({
|
|
685
|
+
containerId: this.containerId,
|
|
686
|
+
command,
|
|
687
|
+
cwd: options?.cwd
|
|
688
|
+
}),
|
|
689
|
+
cwd: process.cwd(),
|
|
690
|
+
stdio: {
|
|
691
|
+
stdin: "ignore",
|
|
692
|
+
stdout: { maxBytes: this.deps.collectMaxBytes },
|
|
693
|
+
stderr: { maxBytes: this.deps.collectMaxBytes }
|
|
694
|
+
},
|
|
695
|
+
graceMs: this.deps.graceMs
|
|
696
|
+
});
|
|
697
|
+
let offset = 0;
|
|
698
|
+
return {
|
|
699
|
+
processId: SandboxProcessId(crypto.randomUUID()),
|
|
700
|
+
done: handle.done.then((outcome) => {
|
|
701
|
+
const stdout = handle.collected.stdout?.readFrom(0).text ?? "";
|
|
702
|
+
const stderr = handle.collected.stderr?.readFrom(0).text ?? "";
|
|
703
|
+
return {
|
|
704
|
+
exitCode: outcome.exitCode,
|
|
705
|
+
signal: outcome.signal,
|
|
706
|
+
stdout,
|
|
707
|
+
stderr,
|
|
708
|
+
timedOut: false,
|
|
709
|
+
aborted: false
|
|
710
|
+
};
|
|
711
|
+
}),
|
|
712
|
+
readOutput: () => {
|
|
713
|
+
const read = handle.collected.stdout?.readFrom(offset);
|
|
714
|
+
if (read === void 0) return "";
|
|
715
|
+
offset = read.nextOffset;
|
|
716
|
+
return read.text;
|
|
717
|
+
},
|
|
718
|
+
terminate: () => {
|
|
719
|
+
handle.terminate();
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
async execTty(command, options) {
|
|
724
|
+
if (this.stopped) throw new Error(`sandbox session for scan ${this.scanId} is stopped`);
|
|
725
|
+
const handle = await this.deps.subprocess.spawnTerminal({
|
|
726
|
+
argv: buildExecTtyArgv({
|
|
727
|
+
containerId: this.containerId,
|
|
728
|
+
command,
|
|
729
|
+
cwd: options?.cwd
|
|
730
|
+
}),
|
|
731
|
+
cwd: process.cwd(),
|
|
732
|
+
rows: options?.rows ?? 24,
|
|
733
|
+
cols: options?.cols ?? 80,
|
|
734
|
+
graceMs: this.deps.graceMs
|
|
735
|
+
});
|
|
736
|
+
const id = SandboxProcessId(crypto.randomUUID());
|
|
737
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
738
|
+
const decoder = new TextDecoder();
|
|
739
|
+
handle.output.on("data", (chunk) => {
|
|
740
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
741
|
+
for (const listener of listeners) listener(text);
|
|
742
|
+
});
|
|
743
|
+
const done = handle.done.then((outcome) => ({
|
|
744
|
+
exitCode: outcome.exitCode,
|
|
745
|
+
signal: outcome.signal,
|
|
746
|
+
stdout: "",
|
|
747
|
+
stderr: "",
|
|
748
|
+
timedOut: false,
|
|
749
|
+
aborted: false
|
|
750
|
+
}));
|
|
751
|
+
const record = {
|
|
752
|
+
handle,
|
|
753
|
+
listeners,
|
|
754
|
+
process: {
|
|
755
|
+
id,
|
|
756
|
+
command,
|
|
757
|
+
write: (chars) => handle.write(chars),
|
|
758
|
+
subscribe: (listener) => {
|
|
759
|
+
listeners.add(listener);
|
|
760
|
+
return () => {
|
|
761
|
+
listeners.delete(listener);
|
|
762
|
+
};
|
|
763
|
+
},
|
|
764
|
+
done,
|
|
765
|
+
terminate: () => handle.terminate()
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
this.ttyProcesses.set(id, record);
|
|
769
|
+
done.then(() => {
|
|
770
|
+
this.ttyProcesses.delete(id);
|
|
771
|
+
}, () => {
|
|
772
|
+
this.ttyProcesses.delete(id);
|
|
773
|
+
});
|
|
774
|
+
return record.process;
|
|
775
|
+
}
|
|
776
|
+
async writeStdin(processId, chars) {
|
|
777
|
+
const record = this.ttyProcesses.get(processId);
|
|
778
|
+
if (record === void 0) throw new Error(`write_stdin: no live interactive process ${processId} in scan ${this.scanId}`);
|
|
779
|
+
await record.handle.write(chars);
|
|
780
|
+
}
|
|
781
|
+
async putFile(hostPath, containerPath) {
|
|
782
|
+
const result = await runCollectArgv(this.deps.subprocess, buildPutFileArgv(hostPath, this.containerId, containerPath), {
|
|
783
|
+
timeoutMs: this.deps.defaultExecTimeoutMs,
|
|
784
|
+
graceMs: this.deps.graceMs,
|
|
785
|
+
collectMaxBytes: this.deps.collectMaxBytes
|
|
786
|
+
});
|
|
787
|
+
if (result.exitCode !== 0) throw new Error(`putFile failed (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`);
|
|
788
|
+
}
|
|
789
|
+
async getFile(containerPath) {
|
|
790
|
+
const name = basename(containerPath);
|
|
791
|
+
if (name === "" || name === "/" || name === ".") throw new Error(`getFile: container path must name a file: ${containerPath}`);
|
|
792
|
+
const dir = await mkdtemp(join(tmpdir(), "sharpkit-getfile-"));
|
|
793
|
+
try {
|
|
794
|
+
const hostPath = join(dir, name);
|
|
795
|
+
const result = await runCollectArgv(this.deps.subprocess, buildGetFileArgv(this.containerId, containerPath, hostPath), {
|
|
796
|
+
timeoutMs: this.deps.defaultExecTimeoutMs,
|
|
797
|
+
graceMs: this.deps.graceMs,
|
|
798
|
+
collectMaxBytes: this.deps.collectMaxBytes
|
|
799
|
+
});
|
|
800
|
+
if (result.exitCode !== 0) throw new Error(`getFile failed (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`);
|
|
801
|
+
return new Uint8Array(await readFile(hostPath));
|
|
802
|
+
} finally {
|
|
803
|
+
await rm(dir, {
|
|
804
|
+
recursive: true,
|
|
805
|
+
force: true
|
|
806
|
+
}).catch(() => {});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async stop() {
|
|
810
|
+
if (this.stopped) return;
|
|
811
|
+
this.stopped = true;
|
|
812
|
+
const log = this.deps.logger;
|
|
813
|
+
for (const record of this.ttyProcesses.values()) try {
|
|
814
|
+
await record.handle.terminate();
|
|
815
|
+
} catch (error) {
|
|
816
|
+
log.debug(`stop(${this.scanId}): tty terminate raised: ${String(error)}`);
|
|
817
|
+
}
|
|
818
|
+
this.ttyProcesses.clear();
|
|
819
|
+
if (this.deps.stagingDir !== void 0) await rm(this.deps.stagingDir, {
|
|
820
|
+
recursive: true,
|
|
821
|
+
force: true
|
|
822
|
+
}).catch(() => {});
|
|
823
|
+
this.deps.bootstrap.close();
|
|
824
|
+
try {
|
|
825
|
+
const stopped = await runCollectArgv(this.deps.subprocess, buildStopArgv(this.containerId, this.deps.graceMs), {
|
|
826
|
+
timeoutMs: this.deps.defaultExecTimeoutMs,
|
|
827
|
+
graceMs: this.deps.graceMs,
|
|
828
|
+
collectMaxBytes: this.deps.collectMaxBytes
|
|
829
|
+
});
|
|
830
|
+
if (stopped.exitCode !== 0) log.warn(`stop(${this.scanId}): docker stop exit ${stopped.exitCode}: ${stopped.stderr.slice(0, 200)}`);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
log.debug(`stop(${this.scanId}): docker stop raised: ${String(error)}`);
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
if ((await runCollectArgv(this.deps.subprocess, buildRmArgv(this.containerId), {
|
|
836
|
+
timeoutMs: this.deps.defaultExecTimeoutMs,
|
|
837
|
+
graceMs: this.deps.graceMs,
|
|
838
|
+
collectMaxBytes: this.deps.collectMaxBytes
|
|
839
|
+
})).exitCode !== 0) await runCollectArgv(this.deps.subprocess, buildRmForceArgv(this.containerId), {
|
|
840
|
+
timeoutMs: this.deps.defaultExecTimeoutMs,
|
|
841
|
+
graceMs: this.deps.graceMs,
|
|
842
|
+
collectMaxBytes: this.deps.collectMaxBytes
|
|
843
|
+
});
|
|
844
|
+
} catch (error) {
|
|
845
|
+
log.error(`stop(${this.scanId}): container removal raised; container may need manual reaping: ${String(error)}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
/**
|
|
850
|
+
* Stage extra files for bind mounting (strix `build_extra_file_bind_mounts`):
|
|
851
|
+
* one numbered subdir per file, content written, mounted read-only at its
|
|
852
|
+
* workspace path.
|
|
853
|
+
* @param stagingDir - the session staging directory (already created).
|
|
854
|
+
* @param items - validated extra files: rel path + bytes.
|
|
855
|
+
* @param workspaceRoot - container workspace root (default `/workspace`).
|
|
856
|
+
* @returns the mounts, in placement order.
|
|
857
|
+
*/
|
|
858
|
+
async function stageExtraFiles(stagingDir, items, workspaceRoot) {
|
|
859
|
+
const mounts = [];
|
|
860
|
+
let index = 0;
|
|
861
|
+
for (const item of items) {
|
|
862
|
+
const staged = join(stagingDir, String(index), basename(item.rel));
|
|
863
|
+
await mkdir(dirname(staged), { recursive: true });
|
|
864
|
+
await writeFile(staged, item.content);
|
|
865
|
+
mounts.push({
|
|
866
|
+
source: staged,
|
|
867
|
+
target: `${workspaceRoot}/${item.rel}`,
|
|
868
|
+
readOnly: true
|
|
869
|
+
});
|
|
870
|
+
index++;
|
|
871
|
+
}
|
|
872
|
+
return mounts;
|
|
873
|
+
}
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/index.ts
|
|
876
|
+
/**
|
|
877
|
+
* Docker sandbox capability for the sharpkit pentest suite: the
|
|
878
|
+
* `ctx.pentestSandbox` service (decision D1: `ctx.subprocess` + docker CLI,
|
|
879
|
+
* argv built by the pure spec module). Port of strix runtime/session_manager
|
|
880
|
+
* `create_or_reuse` + `cleanup` semantics: sessions cached by scan id,
|
|
881
|
+
* container lifecycle over the CLI, extra files staged under the temp dir
|
|
882
|
+
* (a remote docker daemon resolves bind sources on its own filesystem), and
|
|
883
|
+
* a lazy Caido bootstrap that runs concurrently with scan start. Teardown is
|
|
884
|
+
* registered once via ctx.effect and stops every live session best-effort.
|
|
885
|
+
* @module @gpzhang2001/sharpkit-sandbox
|
|
886
|
+
*/
|
|
887
|
+
/** Container-side Caido port (protocol constant with the image, not a tunable). */
|
|
888
|
+
const CAIDO_PORT = 48080;
|
|
889
|
+
/** Keep-alive command (protocol with the image entrypoint, which execs it). */
|
|
890
|
+
const KEEPALIVE_COMMAND = [
|
|
891
|
+
"tail",
|
|
892
|
+
"-f",
|
|
893
|
+
"/dev/null"
|
|
894
|
+
];
|
|
895
|
+
/**
|
|
896
|
+
* The pentest sandbox service: creates, caches, and reuses docker-CLI
|
|
897
|
+
* sandbox sessions. Load as a plugin after a subprocess provider; it
|
|
898
|
+
* registers as `ctx.pentestSandbox` (one per context).
|
|
899
|
+
*/
|
|
900
|
+
var PentestSandboxService = class extends Service {
|
|
901
|
+
static inject = ["subprocess"];
|
|
902
|
+
static Config = z.object({
|
|
903
|
+
image: z.string().default("ghcr.io/gpzhang2001/sharpkit-sandbox:1.0.0-fork2"),
|
|
904
|
+
containerGraceMs: z.number().default(1e4),
|
|
905
|
+
workspaceRoot: z.string().default("/workspace"),
|
|
906
|
+
network: z.string(),
|
|
907
|
+
memLimit: z.string(),
|
|
908
|
+
shmSize: z.string(),
|
|
909
|
+
cpus: z.number(),
|
|
910
|
+
pidsLimit: z.number(),
|
|
911
|
+
logMaxSize: z.string().default("50m"),
|
|
912
|
+
logMaxFile: z.number().default(3),
|
|
913
|
+
runLabelId: z.string(),
|
|
914
|
+
runLabelType: z.string(),
|
|
915
|
+
caidoLoginAttempts: z.number().default(10),
|
|
916
|
+
caidoLoginTimeoutMs: z.number().default(15e3),
|
|
917
|
+
defaultExecTimeoutMs: z.number().default(12e4),
|
|
918
|
+
execCollectMaxBytes: z.number().default(1048576)
|
|
919
|
+
});
|
|
920
|
+
config;
|
|
921
|
+
sessions = /* @__PURE__ */ new Map();
|
|
922
|
+
/** node:fs-backed mount facts; a handful of sync calls on a few paths. */
|
|
923
|
+
probe = {
|
|
924
|
+
resolve: (path) => realpathSync(path.startsWith("~/") ? join(homedir(), path.slice(2)) : path),
|
|
925
|
+
exists: (path) => existsSync(path),
|
|
926
|
+
isDirectory: (path) => {
|
|
927
|
+
try {
|
|
928
|
+
return statSync(path).isDirectory();
|
|
929
|
+
} catch {
|
|
930
|
+
return false;
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
isFile: (path) => {
|
|
934
|
+
try {
|
|
935
|
+
return statSync(path).isFile();
|
|
936
|
+
} catch {
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
},
|
|
940
|
+
readTextFile: (path) => {
|
|
941
|
+
try {
|
|
942
|
+
return readFileSync(path, "utf8");
|
|
943
|
+
} catch {
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
constructor(ctx, config = {}) {
|
|
949
|
+
super(ctx, "pentestSandbox");
|
|
950
|
+
this.config = config;
|
|
951
|
+
ctx.effect(() => async () => {
|
|
952
|
+
for (const session of this.sessions.values()) await session.stop();
|
|
953
|
+
this.sessions.clear();
|
|
954
|
+
}, "pentest-sandbox session teardown");
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Create (or reuse) the sandbox session for a scan id — strix
|
|
958
|
+
* `create_or_reuse` parity, including lazy Caido bootstrap.
|
|
959
|
+
* @param options - scan identity, sources, extra files.
|
|
960
|
+
* @returns the live session.
|
|
961
|
+
*/
|
|
962
|
+
async createSession(options) {
|
|
963
|
+
const cached = this.sessions.get(options.scanId);
|
|
964
|
+
if (cached !== void 0) {
|
|
965
|
+
this.ctx.logger.debug(`pentest-sandbox: reusing session for scan ${options.scanId}`);
|
|
966
|
+
return cached;
|
|
967
|
+
}
|
|
968
|
+
const config = this.config;
|
|
969
|
+
const subprocess = this.ctx.subprocess;
|
|
970
|
+
const cli = {
|
|
971
|
+
timeoutMs: config.defaultExecTimeoutMs,
|
|
972
|
+
graceMs: config.containerGraceMs,
|
|
973
|
+
collectMaxBytes: config.execCollectMaxBytes
|
|
974
|
+
};
|
|
975
|
+
let stagingDir;
|
|
976
|
+
let containerId;
|
|
977
|
+
try {
|
|
978
|
+
const mounts = buildBindMounts(options.sources ?? [], this.probe, config.workspaceRoot);
|
|
979
|
+
const extraMounts = await this.stageExtraFiles(options, config.workspaceRoot);
|
|
980
|
+
if (extraMounts.stagingDir !== void 0) stagingDir = extraMounts.stagingDir;
|
|
981
|
+
const allMounts = [...mounts, ...extraMounts.mounts].sort((a, b) => a.target.split("/").length - b.target.split("/").length || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0));
|
|
982
|
+
const labels = {};
|
|
983
|
+
if (config.runLabelId !== void 0) labels["sharpkit-run-id"] = config.runLabelId;
|
|
984
|
+
if (config.runLabelType !== void 0) labels["sharpkit-run-type"] = config.runLabelType;
|
|
985
|
+
const spec = {
|
|
986
|
+
image: config.image,
|
|
987
|
+
command: KEEPALIVE_COMMAND,
|
|
988
|
+
env: buildContainerEnv({
|
|
989
|
+
caidoPort: CAIDO_PORT,
|
|
990
|
+
platform: process.platform,
|
|
991
|
+
uid: typeof process.getuid === "function" ? process.getuid() : void 0,
|
|
992
|
+
gid: typeof process.getgid === "function" ? process.getgid() : void 0
|
|
993
|
+
}),
|
|
994
|
+
bindMounts: allMounts,
|
|
995
|
+
caidoPort: CAIDO_PORT,
|
|
996
|
+
network: config.network,
|
|
997
|
+
caps: ["NET_ADMIN", "NET_RAW"],
|
|
998
|
+
extraHosts: { "host.docker.internal": "host-gateway" },
|
|
999
|
+
resourceLimits: {
|
|
1000
|
+
memLimit: config.memLimit,
|
|
1001
|
+
shmSize: config.shmSize,
|
|
1002
|
+
cpus: config.cpus,
|
|
1003
|
+
pidsLimit: config.pidsLimit
|
|
1004
|
+
},
|
|
1005
|
+
logMaxSize: config.logMaxSize,
|
|
1006
|
+
logMaxFile: config.logMaxFile,
|
|
1007
|
+
labels
|
|
1008
|
+
};
|
|
1009
|
+
await this.ensureImage(spec.image, cli);
|
|
1010
|
+
const created = await runCollectArgv(subprocess, buildCreateArgv(spec), cli);
|
|
1011
|
+
if (created.exitCode !== 0) throw new Error(`docker create failed (exit ${created.exitCode}): ${created.stderr.slice(0, 500)}`);
|
|
1012
|
+
containerId = created.stdout.trim().split("\n").at(-1)?.trim() ?? "";
|
|
1013
|
+
if (containerId === "") throw new Error("docker create produced no container id");
|
|
1014
|
+
const started = await runCollectArgv(subprocess, buildStartArgv(containerId), cli);
|
|
1015
|
+
if (started.exitCode !== 0) throw new Error(`docker start failed (exit ${started.exitCode}): ${started.stderr.slice(0, 500)}`);
|
|
1016
|
+
const hostBaseUrl = await this.resolveCaidoHostUrl(containerId, cli);
|
|
1017
|
+
const id = containerId;
|
|
1018
|
+
const bootstrap = new CaidoBootstrap((signal) => bootstrapCaido((command, timeoutMs) => runCollectArgv(subprocess, [
|
|
1019
|
+
"docker",
|
|
1020
|
+
"exec",
|
|
1021
|
+
"-i",
|
|
1022
|
+
id,
|
|
1023
|
+
"bash",
|
|
1024
|
+
"-lc",
|
|
1025
|
+
command
|
|
1026
|
+
], {
|
|
1027
|
+
...cli,
|
|
1028
|
+
timeoutMs
|
|
1029
|
+
}).then((result) => ({
|
|
1030
|
+
ok: result.exitCode === 0,
|
|
1031
|
+
exitCode: result.exitCode,
|
|
1032
|
+
stdout: result.stdout,
|
|
1033
|
+
stderr: result.stderr
|
|
1034
|
+
})), fetch, {
|
|
1035
|
+
containerBaseUrl: `http://127.0.0.1:${CAIDO_PORT}`,
|
|
1036
|
+
hostBaseUrl
|
|
1037
|
+
}, {
|
|
1038
|
+
attempts: config.caidoLoginAttempts,
|
|
1039
|
+
timeoutMs: config.caidoLoginTimeoutMs,
|
|
1040
|
+
sleep: (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)),
|
|
1041
|
+
signal
|
|
1042
|
+
}));
|
|
1043
|
+
const session = new DockerCliSandboxSession({
|
|
1044
|
+
subprocess,
|
|
1045
|
+
logger: this.ctx.logger,
|
|
1046
|
+
containerId,
|
|
1047
|
+
scanId: options.scanId,
|
|
1048
|
+
containerCaidoBaseUrl: `http://127.0.0.1:${CAIDO_PORT}`,
|
|
1049
|
+
hostCaidoBaseUrl: hostBaseUrl,
|
|
1050
|
+
bootstrap,
|
|
1051
|
+
stagingDir,
|
|
1052
|
+
graceMs: config.containerGraceMs,
|
|
1053
|
+
defaultExecTimeoutMs: config.defaultExecTimeoutMs,
|
|
1054
|
+
collectMaxBytes: config.execCollectMaxBytes
|
|
1055
|
+
});
|
|
1056
|
+
this.sessions.set(options.scanId, session);
|
|
1057
|
+
return session;
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
if (stagingDir !== void 0) await rm(stagingDir, {
|
|
1060
|
+
recursive: true,
|
|
1061
|
+
force: true
|
|
1062
|
+
}).catch(() => {});
|
|
1063
|
+
if (containerId !== void 0) await runCollectArgv(subprocess, [
|
|
1064
|
+
"docker",
|
|
1065
|
+
"rm",
|
|
1066
|
+
"-f",
|
|
1067
|
+
containerId
|
|
1068
|
+
], cli).catch(() => void 0);
|
|
1069
|
+
throw error;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/** Stop and forget one session (idempotent; strix `cleanup`). */
|
|
1073
|
+
async destroySession(scanId) {
|
|
1074
|
+
const session = this.sessions.get(scanId);
|
|
1075
|
+
if (session === void 0) {
|
|
1076
|
+
this.ctx.logger.debug(`pentest-sandbox: no session to clean for scan ${scanId}`);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
this.sessions.delete(scanId);
|
|
1080
|
+
await session.stop();
|
|
1081
|
+
}
|
|
1082
|
+
/** Validate + stage extra files (strix skip-and-warn semantics). */
|
|
1083
|
+
async stageExtraFiles(options, workspaceRoot) {
|
|
1084
|
+
const extraFiles = options.extraFiles ?? [];
|
|
1085
|
+
if (extraFiles.length === 0) return { mounts: [] };
|
|
1086
|
+
const sourceRoots = options.sources?.map((source) => source.workspaceSubdir) ?? [];
|
|
1087
|
+
const placed = [];
|
|
1088
|
+
const items = [];
|
|
1089
|
+
for (const file of extraFiles) {
|
|
1090
|
+
const rel = extraFileRelPath(file.containerPath, workspaceRoot);
|
|
1091
|
+
if (rel === null) {
|
|
1092
|
+
this.ctx.logger.warn(`pentest-sandbox: skipping invalid extra file path ${file.containerPath}`);
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (collidesWithRoots(rel, [...sourceRoots, ...placed])) {
|
|
1096
|
+
this.ctx.logger.warn(`pentest-sandbox: skipping colliding extra file ${file.containerPath}`);
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
placed.push(rel);
|
|
1100
|
+
items.push({
|
|
1101
|
+
rel,
|
|
1102
|
+
content: typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
if (items.length === 0) return { mounts: [] };
|
|
1106
|
+
const stagingDir = await mkdtemp(`${tmpdir()}/pentest-extra-files-${stagingDirName(options.scanId)}-`);
|
|
1107
|
+
return {
|
|
1108
|
+
mounts: await stageExtraFiles(stagingDir, items, workspaceRoot),
|
|
1109
|
+
stagingDir
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
/** Pull the image when missing (strix image_exists → pull). */
|
|
1113
|
+
async ensureImage(image, cli) {
|
|
1114
|
+
if ((await runCollectArgv(this.ctx.subprocess, buildImageInspectArgv(image), {
|
|
1115
|
+
...cli,
|
|
1116
|
+
timeoutMs: 6e4
|
|
1117
|
+
})).exitCode === 0) return;
|
|
1118
|
+
this.ctx.logger.info(`pentest-sandbox: pulling image ${image}`);
|
|
1119
|
+
const pulled = await runCollectArgv(this.ctx.subprocess, buildPullArgv(image), {
|
|
1120
|
+
...cli,
|
|
1121
|
+
timeoutMs: 18e5
|
|
1122
|
+
});
|
|
1123
|
+
if (pulled.exitCode !== 0) throw new Error(`docker pull failed (exit ${pulled.exitCode}): ${pulled.stderr.slice(0, 500)}`);
|
|
1124
|
+
}
|
|
1125
|
+
/** Resolve the host-side Caido base URL for a started container. */
|
|
1126
|
+
async resolveCaidoHostUrl(containerId, cli) {
|
|
1127
|
+
const config = this.config;
|
|
1128
|
+
if (config.network !== void 0 && config.network !== "") {
|
|
1129
|
+
const inspected = await runCollectArgv(this.ctx.subprocess, buildNetworkIpArgv(containerId, config.network), cli);
|
|
1130
|
+
if (inspected.exitCode !== 0) throw new Error(`docker inspect (network ip) failed (exit ${inspected.exitCode}): ${inspected.stderr.slice(0, 500)}`);
|
|
1131
|
+
const ip = inspected.stdout.trim();
|
|
1132
|
+
if (ip === "") throw new Error(`container has no address on network ${config.network}`);
|
|
1133
|
+
return `http://${ip.includes(":") ? `[${ip}]` : ip}:${CAIDO_PORT}`;
|
|
1134
|
+
}
|
|
1135
|
+
const port = await runCollectArgv(this.ctx.subprocess, buildPortArgv(containerId, CAIDO_PORT), cli);
|
|
1136
|
+
if (port.exitCode !== 0) throw new Error(`docker port failed (exit ${port.exitCode}): ${port.stderr.slice(0, 500)}`);
|
|
1137
|
+
const endpoint = parsePortOutput(port.stdout)[0];
|
|
1138
|
+
if (endpoint === void 0) throw new Error(`caido port ${CAIDO_PORT} is not published for container ${containerId}`);
|
|
1139
|
+
return `http://${endpoint.host}:${endpoint.port}`;
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
//#endregion
|
|
1143
|
+
export { PentestSandboxService, PentestSandboxService as default, SandboxProcessId, SandboxSessionId };
|
|
1144
|
+
|
|
1145
|
+
//# sourceMappingURL=index.js.map
|