dsh-pentester 0.0.1 → 1.0.0
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/README.md +39 -5
- package/agents/impact/profile.yml +19 -0
- package/agents/recon/profile.yml +23 -0
- package/agents/reporting/REPORT_TEMPLATE.md +333 -0
- package/agents/reporting/profile.yml +45 -0
- package/agents/threat-model/profile.yml +19 -0
- package/agents/validation/profile.yml +20 -0
- package/agents/vulnerability/profile.yml +19 -0
- package/agents/web/profile.yml +19 -0
- package/cordis.dev.patch.yml +16 -0
- package/cordis.patch.yml +2 -0
- package/docker/README.md +60 -0
- package/docker/kali/Dockerfile +881 -0
- package/docker/kali/README.md +104 -0
- package/docker/kali/REPORT_TEMPLATE.md +333 -0
- package/docker/kali/TOOL_PROMPT.md +362 -0
- package/docker/kali/bin/clone-kb +39 -0
- package/docker/kali/bin/entrypoint.sh +9 -0
- package/docker/kali/bin/gen-tools-json.sh +246 -0
- package/docker/kali/bin/record-traffic.sh +32 -0
- package/docker/kali/bin/tool-info +31 -0
- package/docker/kali/bin/tool-list +17 -0
- package/lib/catalog-BmeOyr6n.js +231 -0
- package/lib/catalog-BmeOyr6n.js.map +1 -0
- package/lib/catalog-DhE_r18k.js +231 -0
- package/lib/catalog-DhE_r18k.js.map +1 -0
- package/lib/client.js +15234 -0
- package/lib/client.js.map +7 -0
- package/lib/container-listing-BI6Xj8l2.js +56 -0
- package/lib/container-listing-BI6Xj8l2.js.map +1 -0
- package/lib/container-listing-BWZoBnN_.js +56 -0
- package/lib/container-listing-BWZoBnN_.js.map +1 -0
- package/lib/container-listing-CE5t-Y_H.js +56 -0
- package/lib/container-listing-CE5t-Y_H.js.map +1 -0
- package/lib/container-listing-DZPBvrLD.js +56 -0
- package/lib/container-listing-DZPBvrLD.js.map +1 -0
- package/lib/docker-tar-_wf8UrSj.js +66 -0
- package/lib/docker-tar-_wf8UrSj.js.map +1 -0
- package/lib/engagement-container-store-B9D8g0wq.js +641 -0
- package/lib/engagement-container-store-B9D8g0wq.js.map +1 -0
- package/lib/engagement-container-store-Cf-h0B4F.js +641 -0
- package/lib/engagement-container-store-Cf-h0B4F.js.map +1 -0
- package/lib/engagement-container-store-Cu1wuur1.js +639 -0
- package/lib/engagement-container-store-Cu1wuur1.js.map +1 -0
- package/lib/engagement-container-store-L_Gms-zi.js +632 -0
- package/lib/engagement-container-store-L_Gms-zi.js.map +1 -0
- package/lib/index.d.ts +41 -0
- package/lib/index.js +3200 -0
- package/lib/index.js.map +1 -0
- package/lib/model-CZoiogVs.js +149 -0
- package/lib/model-CZoiogVs.js.map +1 -0
- package/lib/model-DKZ5Rjik.js +145 -0
- package/lib/model-DKZ5Rjik.js.map +1 -0
- package/lib/worker-events-jFvaBp9x.js +351 -0
- package/lib/worker-events-jFvaBp9x.js.map +1 -0
- package/lib/worker-events-juSf0EDW.js +347 -0
- package/lib/worker-events-juSf0EDW.js.map +1 -0
- package/package.json +89 -8
- package/presets/pentester/agent.cordis.yml +194 -0
- package/presets/pentester/preset.yml +2 -0
- package/presets/pentester/run-state.mjs +210 -0
- package/index.js +0 -5
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { request } from "node:http";
|
|
5
|
+
//#region src/domain/tools.ts
|
|
6
|
+
const DEFAULT_TOOL_OUTPUT_LIMITS = {
|
|
7
|
+
maxInlineOutputBytes: 8192,
|
|
8
|
+
maxTotalCapturedBytes: 1048576
|
|
9
|
+
};
|
|
10
|
+
const DEFAULT_TOOL_BROKER_LIMITS = {
|
|
11
|
+
maxPerEngagement: 4,
|
|
12
|
+
maxPerAgentRun: 2
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/tools/errors.ts
|
|
16
|
+
var ToolBrokerError = class extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ToolBrokerError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/tools/persist.ts
|
|
26
|
+
function toolsRuntimeDir(engagementDir) {
|
|
27
|
+
return `${engagementDir}/runtime/tools`;
|
|
28
|
+
}
|
|
29
|
+
async function writeJsonAtomic(target, value) {
|
|
30
|
+
await mkdir(dirname(target), { recursive: true });
|
|
31
|
+
const tmp = `${target}.tmp-${randomUUID()}`;
|
|
32
|
+
try {
|
|
33
|
+
await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
34
|
+
await rename(tmp, target);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
try {
|
|
37
|
+
await unlink(tmp);
|
|
38
|
+
} catch {}
|
|
39
|
+
throw new ToolBrokerError("persist_failed", error instanceof Error ? error.message : `Failed to write ${target}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function readJsonFile(path) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code === "ENOENT") return null;
|
|
47
|
+
if (error instanceof SyntaxError) return null;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/tools/output-capture.ts
|
|
53
|
+
var BoundedCapture = class {
|
|
54
|
+
truncated = false;
|
|
55
|
+
captureLimitReached = false;
|
|
56
|
+
buffered = 0;
|
|
57
|
+
remaining;
|
|
58
|
+
stdoutParts = [];
|
|
59
|
+
stderrParts = [];
|
|
60
|
+
constructor(maxTotalCapturedBytes) {
|
|
61
|
+
this.remaining = maxTotalCapturedBytes;
|
|
62
|
+
}
|
|
63
|
+
get full() {
|
|
64
|
+
return this.remaining <= 0;
|
|
65
|
+
}
|
|
66
|
+
accept(stream, chunk) {
|
|
67
|
+
if (chunk.length === 0) return;
|
|
68
|
+
if (this.remaining <= 0) {
|
|
69
|
+
this.captureLimitReached = true;
|
|
70
|
+
this.truncated = true;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let take = chunk;
|
|
74
|
+
if (chunk.length > this.remaining) {
|
|
75
|
+
take = chunk.subarray(0, this.remaining);
|
|
76
|
+
this.captureLimitReached = true;
|
|
77
|
+
this.truncated = true;
|
|
78
|
+
}
|
|
79
|
+
this.remaining -= take.length;
|
|
80
|
+
this.buffered += take.length;
|
|
81
|
+
if (stream === "stderr") this.stderrParts.push(take);
|
|
82
|
+
else this.stdoutParts.push(take);
|
|
83
|
+
}
|
|
84
|
+
stdout() {
|
|
85
|
+
return Buffer.concat(this.stdoutParts);
|
|
86
|
+
}
|
|
87
|
+
stderr() {
|
|
88
|
+
return Buffer.concat(this.stderrParts);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var DockerMuxCapture = class {
|
|
92
|
+
inner;
|
|
93
|
+
leftover = Buffer.alloc(0);
|
|
94
|
+
constructor(maxTotalCapturedBytes) {
|
|
95
|
+
this.inner = new BoundedCapture(maxTotalCapturedBytes);
|
|
96
|
+
}
|
|
97
|
+
pushMuxed(chunk) {
|
|
98
|
+
this.leftover = Buffer.concat([this.leftover, chunk]);
|
|
99
|
+
while (this.leftover.length >= 8) {
|
|
100
|
+
const type = this.leftover[0];
|
|
101
|
+
if (type !== 1 && type !== 2 && type !== 0) {
|
|
102
|
+
this.inner.accept("stdout", this.leftover);
|
|
103
|
+
this.leftover = Buffer.alloc(0);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const size = this.leftover.readUInt32BE(4);
|
|
107
|
+
if (this.leftover.length < 8 + size) return;
|
|
108
|
+
const payload = this.leftover.subarray(8, 8 + size);
|
|
109
|
+
this.leftover = this.leftover.subarray(8 + size);
|
|
110
|
+
this.inner.accept(type === 2 ? "stderr" : "stdout", payload);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
function isBinaryBuffer(buf) {
|
|
115
|
+
if (buf.includes(0)) return true;
|
|
116
|
+
const sample = buf.subarray(0, Math.min(buf.length, 4096));
|
|
117
|
+
try {
|
|
118
|
+
return new TextDecoder("utf8", { fatal: true }).decode(sample).includes("�");
|
|
119
|
+
} catch {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function applyCaptureLimits(streams, limits) {
|
|
124
|
+
let truncated = false;
|
|
125
|
+
let captureLimitReached = false;
|
|
126
|
+
let remaining = limits.maxTotalCapturedBytes;
|
|
127
|
+
const take = (buf, name) => {
|
|
128
|
+
if (remaining <= 0) {
|
|
129
|
+
captureLimitReached = true;
|
|
130
|
+
truncated = true;
|
|
131
|
+
return Buffer.alloc(0);
|
|
132
|
+
}
|
|
133
|
+
if (buf.length > remaining) {
|
|
134
|
+
captureLimitReached = true;
|
|
135
|
+
truncated = true;
|
|
136
|
+
const slice = buf.subarray(0, remaining);
|
|
137
|
+
remaining = 0;
|
|
138
|
+
return slice;
|
|
139
|
+
}
|
|
140
|
+
remaining -= buf.length;
|
|
141
|
+
return buf;
|
|
142
|
+
};
|
|
143
|
+
const stdout = take(streams.stdout, "stdout");
|
|
144
|
+
const stderr = take(streams.stderr, "stderr");
|
|
145
|
+
const stdoutBinary = isBinaryBuffer(stdout) && stdout.length > 0;
|
|
146
|
+
const stderrBinary = isBinaryBuffer(stderr) && stderr.length > 0;
|
|
147
|
+
const files = {};
|
|
148
|
+
const binaryFiles = {};
|
|
149
|
+
const inline = {};
|
|
150
|
+
const put = (name, buf, binary) => {
|
|
151
|
+
if (buf.length === 0) return;
|
|
152
|
+
if (binary) {
|
|
153
|
+
binaryFiles[`${name}.bin`] = buf;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const text = buf.toString("utf8");
|
|
157
|
+
files[`${name}.txt`] = text;
|
|
158
|
+
if (buf.length <= limits.maxInlineOutputBytes && !truncated) inline[name] = text;
|
|
159
|
+
};
|
|
160
|
+
put("stdout", stdout, stdoutBinary);
|
|
161
|
+
put("stderr", stderr, stderrBinary);
|
|
162
|
+
return {
|
|
163
|
+
files,
|
|
164
|
+
binaryFiles,
|
|
165
|
+
inline,
|
|
166
|
+
truncated,
|
|
167
|
+
captureLimitReached,
|
|
168
|
+
binary: stdoutBinary || stderrBinary
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/tools/docker-engine.ts
|
|
173
|
+
/**
|
|
174
|
+
* The only extra capabilities a Toolbox container may receive. nmap's file
|
|
175
|
+
* capabilities (`cap_net_bind_service,cap_net_admin,cap_net_raw=eip`) require
|
|
176
|
+
* these to be present in the container's capability set, or exec fails with
|
|
177
|
+
* `Operation not permitted`. This stays a strict whitelist: arbitrary capAdd
|
|
178
|
+
* (e.g. SYS_ADMIN, DAC_OVERRIDE) remains forbidden.
|
|
179
|
+
*/
|
|
180
|
+
const ALLOWED_CONTAINER_CAP_ADD = [
|
|
181
|
+
"NET_ADMIN",
|
|
182
|
+
"NET_RAW",
|
|
183
|
+
"NET_BIND_SERVICE"
|
|
184
|
+
];
|
|
185
|
+
function assertSafeHostConfig(config) {
|
|
186
|
+
if (config.privileged === true) throw new Error("privileged containers are not allowed");
|
|
187
|
+
if (config.networkMode === "host") throw new Error("host network is not allowed");
|
|
188
|
+
if (config.pidMode === "host") throw new Error("host pid namespace is not allowed");
|
|
189
|
+
if (config.ipcMode === "host") throw new Error("host ipc namespace is not allowed");
|
|
190
|
+
for (const cap of config.capAdd ?? []) if (!ALLOWED_CONTAINER_CAP_ADD.includes(cap)) throw new Error(`capability ${cap} is not allowed`);
|
|
191
|
+
for (const bind of config.binds ?? []) {
|
|
192
|
+
if (/docker\.sock/i.test(bind) || bind.includes(":/") && bind.startsWith("/:")) throw new Error("forbidden bind");
|
|
193
|
+
if (bind === "/" || bind.startsWith("/:") || /(^|:)\/:/.test(bind)) throw new Error("forbidden bind");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const SAFE_CREATE_HOST_CONFIG = {
|
|
197
|
+
privileged: false,
|
|
198
|
+
networkMode: "bridge",
|
|
199
|
+
pidMode: "",
|
|
200
|
+
ipcMode: "",
|
|
201
|
+
capAdd: [...ALLOWED_CONTAINER_CAP_ADD]
|
|
202
|
+
};
|
|
203
|
+
function dockerSocketPath() {
|
|
204
|
+
return process.env.DOCKER_HOST?.startsWith("unix://") ? process.env.DOCKER_HOST.slice(7) : "/var/run/docker.sock";
|
|
205
|
+
}
|
|
206
|
+
var UnixDockerEngine = class {
|
|
207
|
+
kind = "unix";
|
|
208
|
+
firewallObjectsCreated = 0;
|
|
209
|
+
killExecStarts = 0;
|
|
210
|
+
lastKillPid;
|
|
211
|
+
socketPath;
|
|
212
|
+
api;
|
|
213
|
+
inflight = /* @__PURE__ */ new Map();
|
|
214
|
+
constructor(socketPath = dockerSocketPath(), api = "v1.41") {
|
|
215
|
+
this.socketPath = socketPath;
|
|
216
|
+
this.api = api;
|
|
217
|
+
}
|
|
218
|
+
async inspectImage(ref) {
|
|
219
|
+
const encoded = encodeURIComponent(ref);
|
|
220
|
+
const res = await this.json("GET", `/images/${encoded}/json`);
|
|
221
|
+
if (res.status === 404) return;
|
|
222
|
+
if (res.status >= 400) throw new Error(`inspect image failed: ${res.status}`);
|
|
223
|
+
const body = res.body;
|
|
224
|
+
return {
|
|
225
|
+
id: body.Id ?? ref,
|
|
226
|
+
digest: body.RepoDigests?.[0]
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async buildImage(input) {
|
|
230
|
+
const { packDockerContext } = await import("./docker-tar-_wf8UrSj.js");
|
|
231
|
+
const tar = await packDockerContext(input.contextDir, input.dockerfile);
|
|
232
|
+
const res = await this.raw("POST", `/build?t=${encodeURIComponent(input.tag)}&dockerfile=${encodeURIComponent(input.dockerfile)}`, tar, "application/x-tar");
|
|
233
|
+
if (res.status >= 400) throw new Error(`build failed: ${res.status} ${res.body.toString("utf8").slice(0, 200)}`);
|
|
234
|
+
const inspect = await this.inspectImage(input.tag);
|
|
235
|
+
if (inspect === void 0) throw new Error("build completed but image missing");
|
|
236
|
+
return inspect;
|
|
237
|
+
}
|
|
238
|
+
async pullImage(image) {
|
|
239
|
+
const [name, tag] = image.includes(":") ? image.split(/:(?=[^:]+$)/) : [image, "latest"];
|
|
240
|
+
const res = await this.raw("POST", `/images/create?fromImage=${encodeURIComponent(name ?? image)}&tag=${encodeURIComponent(tag ?? "latest")}`);
|
|
241
|
+
if (res.status >= 400) throw new Error(`pull failed: ${res.status}`);
|
|
242
|
+
const inspect = await this.inspectImage(image);
|
|
243
|
+
if (inspect === void 0) throw new Error("pull completed but image missing");
|
|
244
|
+
return inspect;
|
|
245
|
+
}
|
|
246
|
+
async createContainer(input) {
|
|
247
|
+
assertSafeHostConfig(input.hostConfig);
|
|
248
|
+
const res = await this.json("POST", `/containers/create${input.name !== void 0 ? `?name=${encodeURIComponent(input.name)}` : ""}`, {
|
|
249
|
+
Image: input.image,
|
|
250
|
+
Cmd: input.cmd ?? ["sleep", "infinity"],
|
|
251
|
+
Env: input.env ?? [],
|
|
252
|
+
Labels: input.labels ?? {},
|
|
253
|
+
HostConfig: {
|
|
254
|
+
Privileged: false,
|
|
255
|
+
NetworkMode: input.hostConfig.networkMode ?? "bridge",
|
|
256
|
+
PidMode: input.hostConfig.pidMode ?? "",
|
|
257
|
+
IpcMode: input.hostConfig.ipcMode ?? "",
|
|
258
|
+
CapAdd: [...input.hostConfig.capAdd ?? []],
|
|
259
|
+
Binds: input.hostConfig.binds ?? [],
|
|
260
|
+
SecurityOpt: ["no-new-privileges"]
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
if (res.status >= 400) throw new Error(`create container failed: ${res.status} ${JSON.stringify(res.body)}`);
|
|
264
|
+
return { containerId: res.body.Id };
|
|
265
|
+
}
|
|
266
|
+
async inspectContainer(containerId) {
|
|
267
|
+
const res = await this.json("GET", `/containers/${encodeURIComponent(containerId)}/json`);
|
|
268
|
+
if (res.status === 404) return;
|
|
269
|
+
if (res.status >= 400) throw new Error(`inspect container failed: ${res.status}`);
|
|
270
|
+
const body = res.body;
|
|
271
|
+
return {
|
|
272
|
+
id: body.Id,
|
|
273
|
+
running: body.State?.Running === true,
|
|
274
|
+
image: body.Config?.Image ?? body.Image ?? ""
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
async startContainer(containerId) {
|
|
278
|
+
const res = await this.raw("POST", `/containers/${encodeURIComponent(containerId)}/start`);
|
|
279
|
+
if (res.status >= 400 && res.status !== 304) throw new Error(`start container failed: ${res.status}`);
|
|
280
|
+
}
|
|
281
|
+
async stopContainer(containerId) {
|
|
282
|
+
const res = await this.raw("POST", `/containers/${encodeURIComponent(containerId)}/stop`);
|
|
283
|
+
if (res.status >= 400 && res.status !== 304 && res.status !== 404) throw new Error(`stop container failed: ${res.status}`);
|
|
284
|
+
}
|
|
285
|
+
async removeContainer(containerId) {
|
|
286
|
+
const res = await this.raw("DELETE", `/containers/${encodeURIComponent(containerId)}?force=true`);
|
|
287
|
+
if (res.status >= 400 && res.status !== 404) throw new Error(`remove container failed: ${res.status}`);
|
|
288
|
+
}
|
|
289
|
+
async listContainers(input) {
|
|
290
|
+
const query = new URLSearchParams();
|
|
291
|
+
if (input?.all === true) query.set("all", "1");
|
|
292
|
+
if (input?.label !== void 0) query.set("filters", JSON.stringify({ label: [input.label] }));
|
|
293
|
+
const suffix = query.toString().length > 0 ? `?${query.toString()}` : "";
|
|
294
|
+
const res = await this.json("GET", `/containers/json${suffix}`);
|
|
295
|
+
if (res.status >= 400) throw new Error(`list containers failed: ${res.status}`);
|
|
296
|
+
return res.body.map((row) => ({
|
|
297
|
+
id: row.Id ?? "",
|
|
298
|
+
name: row.Names?.[0]?.replace(/^\//, "") ?? "",
|
|
299
|
+
image: row.Image ?? "",
|
|
300
|
+
running: row.State === "running",
|
|
301
|
+
...row.Labels === void 0 ? {} : { labels: row.Labels }
|
|
302
|
+
})).filter((item) => item.id.length > 0);
|
|
303
|
+
}
|
|
304
|
+
async exec(input) {
|
|
305
|
+
const created = await this.json("POST", `/containers/${encodeURIComponent(input.containerId)}/exec`, {
|
|
306
|
+
AttachStdout: true,
|
|
307
|
+
AttachStderr: true,
|
|
308
|
+
Cmd: input.argv,
|
|
309
|
+
WorkingDir: input.cwd,
|
|
310
|
+
Env: input.env
|
|
311
|
+
});
|
|
312
|
+
if (created.status >= 400) throw new Error(`exec create failed: ${created.status}`);
|
|
313
|
+
const execId = created.body.Id;
|
|
314
|
+
input.onExecCreated?.(execId);
|
|
315
|
+
const abort = new AbortController();
|
|
316
|
+
this.inflight.set(execId, {
|
|
317
|
+
abort,
|
|
318
|
+
containerId: input.containerId
|
|
319
|
+
});
|
|
320
|
+
if (input.abort !== void 0) input.abort.addEventListener("abort", () => abort.abort());
|
|
321
|
+
const limit = input.maxTotalCapturedBytes ?? DEFAULT_TOOL_OUTPUT_LIMITS.maxTotalCapturedBytes;
|
|
322
|
+
try {
|
|
323
|
+
const started = await this.streamExecStart(execId, abort.signal, limit);
|
|
324
|
+
if (started.status >= 400) throw new Error(`exec start failed: ${started.status}`);
|
|
325
|
+
return {
|
|
326
|
+
execId,
|
|
327
|
+
exitCode: (await this.json("GET", `/exec/${encodeURIComponent(execId)}/json`)).body.ExitCode ?? 0,
|
|
328
|
+
stdout: started.stdout,
|
|
329
|
+
stderr: started.stderr,
|
|
330
|
+
truncated: started.truncated,
|
|
331
|
+
captureLimitReached: started.captureLimitReached
|
|
332
|
+
};
|
|
333
|
+
} finally {
|
|
334
|
+
this.inflight.delete(execId);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async cancelExec(execId) {
|
|
338
|
+
const current = this.inflight.get(execId);
|
|
339
|
+
const pid = (await this.json("GET", `/exec/${encodeURIComponent(execId)}/json`)).body.Pid;
|
|
340
|
+
if (typeof pid === "number" && pid > 0 && current !== void 0) {
|
|
341
|
+
this.lastKillPid = pid;
|
|
342
|
+
const kill = await this.json("POST", `/containers/${encodeURIComponent(current.containerId)}/exec`, {
|
|
343
|
+
AttachStdout: false,
|
|
344
|
+
AttachStderr: false,
|
|
345
|
+
Cmd: [
|
|
346
|
+
"kill",
|
|
347
|
+
"-TERM",
|
|
348
|
+
String(pid)
|
|
349
|
+
]
|
|
350
|
+
});
|
|
351
|
+
if (kill.status < 400) {
|
|
352
|
+
const killId = kill.body.Id;
|
|
353
|
+
if (typeof killId === "string" && killId.length > 0) {
|
|
354
|
+
await this.raw("POST", `/exec/${encodeURIComponent(killId)}/start`, Buffer.from(JSON.stringify({
|
|
355
|
+
Detach: true,
|
|
356
|
+
Tty: false
|
|
357
|
+
})), "application/json");
|
|
358
|
+
this.killExecStarts += 1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
current.abort.abort();
|
|
362
|
+
return "requested";
|
|
363
|
+
}
|
|
364
|
+
current?.abort.abort();
|
|
365
|
+
return current !== void 0 ? "requested" : "unknown";
|
|
366
|
+
}
|
|
367
|
+
streamExecStart(execId, signal, maxTotalCapturedBytes) {
|
|
368
|
+
return new Promise((resolve, reject) => {
|
|
369
|
+
const capture = new DockerMuxCapture(maxTotalCapturedBytes);
|
|
370
|
+
const body = Buffer.from(JSON.stringify({
|
|
371
|
+
Detach: false,
|
|
372
|
+
Tty: false
|
|
373
|
+
}));
|
|
374
|
+
const req = request({
|
|
375
|
+
socketPath: this.socketPath,
|
|
376
|
+
path: `/${this.api}/exec/${encodeURIComponent(execId)}/start`,
|
|
377
|
+
method: "POST",
|
|
378
|
+
headers: {
|
|
379
|
+
Host: "localhost",
|
|
380
|
+
"Content-Type": "application/json",
|
|
381
|
+
"Content-Length": body.length
|
|
382
|
+
}
|
|
383
|
+
}, (res) => {
|
|
384
|
+
res.on("data", (chunk) => {
|
|
385
|
+
capture.pushMuxed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
386
|
+
});
|
|
387
|
+
res.on("end", () => {
|
|
388
|
+
resolve({
|
|
389
|
+
status: res.statusCode ?? 0,
|
|
390
|
+
stdout: capture.inner.stdout(),
|
|
391
|
+
stderr: capture.inner.stderr(),
|
|
392
|
+
truncated: capture.inner.truncated,
|
|
393
|
+
captureLimitReached: capture.inner.captureLimitReached
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
req.on("error", reject);
|
|
398
|
+
signal.addEventListener("abort", () => {
|
|
399
|
+
req.destroy(/* @__PURE__ */ new Error("aborted"));
|
|
400
|
+
});
|
|
401
|
+
req.write(body);
|
|
402
|
+
req.end();
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
json(method, path, body) {
|
|
406
|
+
const payload = body === void 0 ? void 0 : Buffer.from(JSON.stringify(body));
|
|
407
|
+
return this.raw(method, path, payload, "application/json").then((res) => ({
|
|
408
|
+
status: res.status,
|
|
409
|
+
body: res.body.length === 0 ? {} : JSON.parse(res.body.toString("utf8"))
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
raw(method, path, body, contentType, signal) {
|
|
413
|
+
return new Promise((resolve, reject) => {
|
|
414
|
+
const req = request({
|
|
415
|
+
socketPath: this.socketPath,
|
|
416
|
+
path: `/${this.api}${path}`,
|
|
417
|
+
method,
|
|
418
|
+
headers: {
|
|
419
|
+
Host: "localhost",
|
|
420
|
+
...contentType !== void 0 ? { "Content-Type": contentType } : {},
|
|
421
|
+
...body !== void 0 ? { "Content-Length": body.length } : {}
|
|
422
|
+
}
|
|
423
|
+
}, (res) => {
|
|
424
|
+
const chunks = [];
|
|
425
|
+
res.on("data", (chunk) => {
|
|
426
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
427
|
+
});
|
|
428
|
+
res.on("end", () => {
|
|
429
|
+
resolve({
|
|
430
|
+
status: res.statusCode ?? 0,
|
|
431
|
+
body: Buffer.concat(chunks)
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
req.on("error", reject);
|
|
436
|
+
signal?.addEventListener("abort", () => {
|
|
437
|
+
req.destroy(/* @__PURE__ */ new Error("aborted"));
|
|
438
|
+
});
|
|
439
|
+
if (body !== void 0) req.write(body);
|
|
440
|
+
req.end();
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
//#endregion
|
|
445
|
+
//#region src/tools/engagement-container-store.ts
|
|
446
|
+
/**
|
|
447
|
+
* P0-B: ONE persistent execution container per Engagement × runtime-profile.
|
|
448
|
+
*
|
|
449
|
+
* Container ownership changed: `agentRunId`/`taskId` NEVER participate in the
|
|
450
|
+
* container identity. The key is `engagementId + runtimeProfile` (the factory
|
|
451
|
+
* Kali image is the single runtime profile in the MVP), so every Worker /
|
|
452
|
+
* Task / Stage of an Engagement reuses the SAME container. Provenance
|
|
453
|
+
* (taskId / agentRunId / toolCallId) stays in the audit log only.
|
|
454
|
+
*
|
|
455
|
+
* Lifecycle:
|
|
456
|
+
* - lazy create on first execution (ensure)
|
|
457
|
+
* - Task completed / AgentRun completed / Session ended → KEEP
|
|
458
|
+
* - Engagement CLOSED → stop + remove
|
|
459
|
+
* - explicit reset → stop + remove + recreate
|
|
460
|
+
* - plugin restart → recover/reuse (DockerBindingStore read + engine inspect)
|
|
461
|
+
* - orphan/stale managed containers (no live Engagement) → GC removes
|
|
462
|
+
*
|
|
463
|
+
* Concurrency: `ensure` is singleflighted per key — N concurrent first-time
|
|
464
|
+
* callers create exactly ONE container and all receive the same id.
|
|
465
|
+
*/
|
|
466
|
+
const PENTESTER_MANAGED_LABEL = "dsh.pentester.managed";
|
|
467
|
+
const PENTESTER_ENGAGEMENT_LABEL = "dsh.pentester.engagement";
|
|
468
|
+
const PENTESTER_RUNTIME_PROFILE_LABEL = "dsh.pentester.runtime-profile";
|
|
469
|
+
const PENTESTER_WORKSPACE_LABEL = "dsh.pentester.workspace";
|
|
470
|
+
const PENTESTER_MANAGED_TRUE = "true";
|
|
471
|
+
/** MVP runtime profile: the single factory Kali image. */
|
|
472
|
+
const RUNTIME_PROFILE_KALI_DEFAULT = "kali-default";
|
|
473
|
+
var EngagementContainerStore = class EngagementContainerStore {
|
|
474
|
+
path;
|
|
475
|
+
bindings;
|
|
476
|
+
/** singleflight: key → in-flight ensure promise. */
|
|
477
|
+
inflight = /* @__PURE__ */ new Map();
|
|
478
|
+
constructor(path, bindings) {
|
|
479
|
+
this.path = path;
|
|
480
|
+
this.bindings = bindings;
|
|
481
|
+
}
|
|
482
|
+
static keyOf(engagementId, profile) {
|
|
483
|
+
return `${engagementId}::${profile}`;
|
|
484
|
+
}
|
|
485
|
+
static async open(engagementDir) {
|
|
486
|
+
const path = `${engagementDir}/runtime/engagement-containers.json`;
|
|
487
|
+
const raw = await readJsonFile(path);
|
|
488
|
+
const bindings = raw !== null && typeof raw === "object" && !Array.isArray(raw) && "bindings" in raw ? raw.bindings ?? {} : {};
|
|
489
|
+
return new EngagementContainerStore(path, bindings);
|
|
490
|
+
}
|
|
491
|
+
list() {
|
|
492
|
+
return Object.values(this.bindings);
|
|
493
|
+
}
|
|
494
|
+
get(key) {
|
|
495
|
+
return this.bindings[key];
|
|
496
|
+
}
|
|
497
|
+
/** Remove a binding record (after the container itself is removed). */
|
|
498
|
+
async remove(key) {
|
|
499
|
+
if (this.bindings[key] === void 0) return;
|
|
500
|
+
const next = { ...this.bindings };
|
|
501
|
+
delete next[key];
|
|
502
|
+
this.bindings = next;
|
|
503
|
+
await writeJsonAtomic(this.path, { bindings: this.bindings });
|
|
504
|
+
}
|
|
505
|
+
async put(binding) {
|
|
506
|
+
const key = EngagementContainerStore.keyOf(binding.engagementId, binding.profile);
|
|
507
|
+
this.bindings = {
|
|
508
|
+
...this.bindings,
|
|
509
|
+
[key]: binding
|
|
510
|
+
};
|
|
511
|
+
await writeJsonAtomic(this.path, { bindings: this.bindings });
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Ensure exactly ONE running container exists for the profile. Concurrency
|
|
515
|
+
* safe: concurrent first-time callers singleflight to a single create.
|
|
516
|
+
* Returns the container id.
|
|
517
|
+
*/
|
|
518
|
+
ensure(input) {
|
|
519
|
+
const key = EngagementContainerStore.keyOf(input.profile.engagementId, input.profile.profile);
|
|
520
|
+
const inflight = this.inflight.get(key);
|
|
521
|
+
if (inflight !== void 0) return inflight;
|
|
522
|
+
const run = this.ensureInner(key, input);
|
|
523
|
+
this.inflight.set(key, run);
|
|
524
|
+
run.finally(() => {
|
|
525
|
+
this.inflight.delete(key);
|
|
526
|
+
});
|
|
527
|
+
return run;
|
|
528
|
+
}
|
|
529
|
+
async ensureInner(key, input) {
|
|
530
|
+
const existing = this.bindings[key];
|
|
531
|
+
if (existing !== void 0) {
|
|
532
|
+
const inspect = await input.engine.inspectContainer(existing.containerId);
|
|
533
|
+
if (inspect === void 0) return this.createAndRecord(key, input);
|
|
534
|
+
if (!inspect.running) await input.engine.startContainer(existing.containerId);
|
|
535
|
+
return existing.containerId;
|
|
536
|
+
}
|
|
537
|
+
return this.createAndRecord(key, input);
|
|
538
|
+
}
|
|
539
|
+
async createAndRecord(key, input) {
|
|
540
|
+
const profile = input.profile;
|
|
541
|
+
const binds = [];
|
|
542
|
+
if (profile.workspacePath !== void 0 && profile.workspacePath.length > 0) {
|
|
543
|
+
const { mkdir } = await import("node:fs/promises");
|
|
544
|
+
await mkdir(profile.workspacePath, { recursive: true });
|
|
545
|
+
binds.push(`${profile.workspacePath}:/workspace`);
|
|
546
|
+
if (profile.artifactsPath !== void 0 && profile.artifactsPath.length > 0) {
|
|
547
|
+
await mkdir(profile.artifactsPath, { recursive: true });
|
|
548
|
+
binds.push(`${profile.artifactsPath}:/artifacts`);
|
|
549
|
+
}
|
|
550
|
+
} else if (profile.artifactsPath !== void 0 && profile.artifactsPath.length > 0) {
|
|
551
|
+
const { mkdir } = await import("node:fs/promises");
|
|
552
|
+
await mkdir(profile.artifactsPath, { recursive: true });
|
|
553
|
+
binds.push(`${profile.artifactsPath}:/workspace`);
|
|
554
|
+
}
|
|
555
|
+
const created = await input.engine.createContainer({
|
|
556
|
+
image: profile.image,
|
|
557
|
+
name: engagementContainerName(profile.engagementId, profile.profile, profile.nameSlug),
|
|
558
|
+
cmd: ["sleep", "infinity"],
|
|
559
|
+
env: [],
|
|
560
|
+
labels: {
|
|
561
|
+
[PENTESTER_MANAGED_LABEL]: PENTESTER_MANAGED_TRUE,
|
|
562
|
+
[PENTESTER_ENGAGEMENT_LABEL]: profile.engagementId,
|
|
563
|
+
[PENTESTER_RUNTIME_PROFILE_LABEL]: profile.profile,
|
|
564
|
+
...profile.workspaceLabel === void 0 ? {} : { [PENTESTER_WORKSPACE_LABEL]: profile.workspaceLabel }
|
|
565
|
+
},
|
|
566
|
+
hostConfig: {
|
|
567
|
+
...SAFE_CREATE_HOST_CONFIG,
|
|
568
|
+
binds
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
await input.engine.startContainer(created.containerId);
|
|
572
|
+
const binding = {
|
|
573
|
+
engagementId: profile.engagementId,
|
|
574
|
+
profile: profile.profile,
|
|
575
|
+
image: profile.image,
|
|
576
|
+
containerId: created.containerId,
|
|
577
|
+
imageId: input.resolvedImage.id,
|
|
578
|
+
...input.resolvedImage.digest === void 0 ? {} : { imageSource: input.imageSource },
|
|
579
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
580
|
+
};
|
|
581
|
+
const raced = this.bindings[key];
|
|
582
|
+
if (raced !== void 0 && raced.containerId !== created.containerId) {
|
|
583
|
+
if (await input.engine.inspectContainer(raced.containerId) !== void 0) {
|
|
584
|
+
try {
|
|
585
|
+
await input.engine.removeContainer(created.containerId);
|
|
586
|
+
} catch {}
|
|
587
|
+
return raced.containerId;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
await this.put(binding);
|
|
591
|
+
return created.containerId;
|
|
592
|
+
}
|
|
593
|
+
/** Remove + recreate the container for a profile (explicit reset). */
|
|
594
|
+
async reset(key, input) {
|
|
595
|
+
const existing = this.bindings[key];
|
|
596
|
+
if (existing !== void 0) {
|
|
597
|
+
try {
|
|
598
|
+
await input.engine.stopContainer(existing.containerId);
|
|
599
|
+
} catch {}
|
|
600
|
+
try {
|
|
601
|
+
await input.engine.removeContainer(existing.containerId);
|
|
602
|
+
} catch {}
|
|
603
|
+
await this.remove(key);
|
|
604
|
+
}
|
|
605
|
+
return this.ensure(input);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Engagement CLOSED: stop + remove the managed container and forget the
|
|
609
|
+
* binding. Returns the removed container id, if any.
|
|
610
|
+
*/
|
|
611
|
+
async closeEngagement(engagementId, engine) {
|
|
612
|
+
const keys = Object.keys(this.bindings).filter((key) => key.startsWith(`${engagementId}::`));
|
|
613
|
+
let removed;
|
|
614
|
+
for (const key of keys) {
|
|
615
|
+
const binding = this.bindings[key];
|
|
616
|
+
if (binding === void 0) continue;
|
|
617
|
+
try {
|
|
618
|
+
await engine.stopContainer(binding.containerId);
|
|
619
|
+
} catch {}
|
|
620
|
+
try {
|
|
621
|
+
await engine.removeContainer(binding.containerId);
|
|
622
|
+
} catch {}
|
|
623
|
+
removed = binding.containerId;
|
|
624
|
+
await this.remove(key);
|
|
625
|
+
}
|
|
626
|
+
return removed;
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
/** Deterministic container name: readable slug + profile (never agentRun/taskId,
|
|
630
|
+
* never a UUID). E.g. `pentest-192-168-107-5-kali`. Falls back to a short
|
|
631
|
+
* engagement hash only when no readable slug is available. */
|
|
632
|
+
function engagementContainerName(engagementId, profile, slug) {
|
|
633
|
+
return `pentest-${slug !== void 0 && slug.length > 0 ? safeName(slug) : `eng-${safeName(engagementId).slice(0, 12)}`}-${profile === "kali-default" ? "kali" : safeName(profile)}`;
|
|
634
|
+
}
|
|
635
|
+
function safeName(value) {
|
|
636
|
+
return value.replace(/[^a-zA-Z0-9_.-]/g, "").slice(0, 40) || "x";
|
|
637
|
+
}
|
|
638
|
+
//#endregion
|
|
639
|
+
export { PENTESTER_RUNTIME_PROFILE_LABEL as a, applyCaptureLimits as c, writeJsonAtomic as d, ToolBrokerError as f, PENTESTER_MANAGED_TRUE as i, readJsonFile as l, DEFAULT_TOOL_OUTPUT_LIMITS as m, PENTESTER_ENGAGEMENT_LABEL as n, RUNTIME_PROFILE_KALI_DEFAULT as o, DEFAULT_TOOL_BROKER_LIMITS as p, PENTESTER_MANAGED_LABEL as r, UnixDockerEngine as s, EngagementContainerStore as t, toolsRuntimeDir as u };
|
|
640
|
+
|
|
641
|
+
//# sourceMappingURL=engagement-container-store-B9D8g0wq.js.map
|