hullproof 0.0.0-stage → 0.5.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/LICENSE +222 -0
- package/NOTICE +32 -0
- package/README.md +313 -2
- package/checks/db.firebase-rules-not-open.json +24 -0
- package/checks/db.rls-every-table.json +25 -0
- package/checks/log.no-credential-in-logs.json +30 -0
- package/checks/secrets.no-env-file-committed.json +31 -0
- package/checks/secrets.no-secret-in-public-env.json +37 -0
- package/checks/storage.firebase-rules-not-open.json +22 -0
- package/checks/supply.no-untrusted-event-in-run.json +23 -0
- package/checks/web.cors-no-reflected-origin.json +31 -0
- package/detectors/ai.json +25 -0
- package/detectors/auth.json +27 -0
- package/detectors/ci.json +19 -0
- package/detectors/containers.json +8 -0
- package/detectors/data_stores.json +38 -0
- package/detectors/frameworks.json +94 -0
- package/detectors/hosting.json +44 -0
- package/detectors/iac.json +43 -0
- package/detectors/languages.json +48 -0
- package/detectors/mobile.json +22 -0
- package/detectors/package_managers.json +39 -0
- package/detectors/payments.json +19 -0
- package/detectors/runtimes.json +17 -0
- package/dist/checks/code/admin-role.js +46 -0
- package/dist/checks/code/owner-filter.js +91 -0
- package/dist/checks/code/shared.js +84 -0
- package/dist/checks/code/webhook-verify.js +77 -0
- package/dist/checks/context.js +64 -0
- package/dist/checks/map/config-checks.js +74 -0
- package/dist/checks/map/entry-checks.js +97 -0
- package/dist/checks/map/shared.js +44 -0
- package/dist/checks/ops.js +307 -0
- package/dist/checks/pro-map.js +1 -0
- package/dist/checks/run.js +92 -0
- package/dist/checks/types.js +1 -0
- package/dist/ci/comment.js +44 -0
- package/dist/ci/sarif-check.js +157 -0
- package/dist/ci/sarif.js +61 -0
- package/dist/cli.js +358 -0
- package/dist/commands/ci.js +115 -0
- package/dist/commands/git.js +58 -0
- package/dist/commands/hooks.js +30 -0
- package/dist/commands/hunt-pro.js +1 -0
- package/dist/commands/hunt.js +86 -0
- package/dist/commands/map.js +33 -0
- package/dist/commands/mcp.js +17 -0
- package/dist/commands/pack.js +48 -0
- package/dist/commands/prove-pro.js +1 -0
- package/dist/commands/prove.js +173 -0
- package/dist/commands/report-gate.js +92 -0
- package/dist/commands/scan.js +158 -0
- package/dist/confirm/agreement.js +18 -0
- package/dist/confirm/chain.js +84 -0
- package/dist/confirm/confirm.js +112 -0
- package/dist/confirm/honesty.js +75 -0
- package/dist/confirm/lead.js +92 -0
- package/dist/confirm/merge.js +88 -0
- package/dist/confirm/query.js +145 -0
- package/dist/confirm/rule.js +86 -0
- package/dist/confirm/store.js +42 -0
- package/dist/confirm/summary.js +6 -0
- package/dist/confirm/synthesis.js +28 -0
- package/dist/discover/detectors.js +50 -0
- package/dist/discover/discover.js +270 -0
- package/dist/discover/extract.js +282 -0
- package/dist/discover/git.js +40 -0
- package/dist/discover/kit-files.js +47 -0
- package/dist/discover/walk.js +110 -0
- package/dist/env.js +57 -0
- package/dist/gate/gate.js +173 -0
- package/dist/hooks/install.js +153 -0
- package/dist/hooks/run.js +112 -0
- package/dist/map/analyze.js +201 -0
- package/dist/map/build.js +253 -0
- package/dist/map/candidates.js +49 -0
- package/dist/map/cblocks.js +77 -0
- package/dist/map/config.js +291 -0
- package/dist/map/dotnet.js +242 -0
- package/dist/map/entry.js +78 -0
- package/dist/map/expo.js +190 -0
- package/dist/map/go.js +292 -0
- package/dist/map/js-express.js +143 -0
- package/dist/map/js-misc.js +295 -0
- package/dist/map/js-next.js +292 -0
- package/dist/map/laravel.js +389 -0
- package/dist/map/lex-more.js +305 -0
- package/dist/map/lex.js +425 -0
- package/dist/map/light-index.js +191 -0
- package/dist/map/light-profiles.js +324 -0
- package/dist/map/light.js +574 -0
- package/dist/map/local-vocab.js +27 -0
- package/dist/map/mobile.js +326 -0
- package/dist/map/py-django.js +281 -0
- package/dist/map/py.js +391 -0
- package/dist/map/rails.js +391 -0
- package/dist/map/repo.js +178 -0
- package/dist/map/rust.js +312 -0
- package/dist/map/spring.js +207 -0
- package/dist/map/taint.js +96 -0
- package/dist/map/touches.js +355 -0
- package/dist/map/vocab.js +88 -0
- package/dist/mapping/controls.js +34 -0
- package/dist/mapping/dependency-policy.js +44 -0
- package/dist/mapping/map.js +44 -0
- package/dist/mapping/maps/gitleaks.json +12 -0
- package/dist/mapping/maps/osv-scanner.json +7 -0
- package/dist/mapping/maps/trivy.json +13 -0
- package/dist/mcp/confine.js +59 -0
- package/dist/mcp/protocol.js +140 -0
- package/dist/mcp/schema.js +43 -0
- package/dist/mcp/server.js +37 -0
- package/dist/mcp/tools.js +238 -0
- package/dist/packs/coverage.js +79 -0
- package/dist/packs/manifest.js +36 -0
- package/dist/packs/validate.js +271 -0
- package/dist/prove/container.js +276 -0
- package/dist/prove/exec-main.js +20 -0
- package/dist/prove/executor.js +149 -0
- package/dist/prove/local.js +131 -0
- package/dist/prove/probes.js +65 -0
- package/dist/prove/recipe.js +278 -0
- package/dist/prove/run.js +136 -0
- package/dist/prove/runner.js +1 -0
- package/dist/prove/safety.js +126 -0
- package/dist/prove/spec.js +357 -0
- package/dist/prove/store.js +65 -0
- package/dist/report/build.js +297 -0
- package/dist/report/entry-points.js +75 -0
- package/dist/report/hunt-section.js +71 -0
- package/dist/report/kit.js +41 -0
- package/dist/report/load.js +35 -0
- package/dist/report/messages.js +273 -0
- package/dist/report/prove-section.js +65 -0
- package/dist/report/report.js +156 -0
- package/dist/report/surface.js +21 -0
- package/dist/report/test-code.js +51 -0
- package/dist/report/tiers.js +44 -0
- package/dist/scanners/common.js +162 -0
- package/dist/scanners/gitleaks.js +90 -0
- package/dist/scanners/index.js +113 -0
- package/dist/scanners/osv-scanner.js +77 -0
- package/dist/scanners/run-targets.js +34 -0
- package/dist/scanners/secret-noise.js +89 -0
- package/dist/scanners/semgrep.js +142 -0
- package/dist/scanners/trivy.js +65 -0
- package/dist/scanners/types.js +1 -0
- package/dist/types/check.js +1 -0
- package/dist/types/codemap.js +1 -0
- package/dist/types/findings.js +5 -0
- package/dist/types/hunt.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/prove.js +1 -0
- package/dist/types/results.js +2 -0
- package/dist/types/scan.js +1 -0
- package/dist/types/stack.js +5 -0
- package/dist/util.js +71 -0
- package/dist/version.js +2 -0
- package/package.json +46 -4
- package/packs/android/fixture-result.json +43 -0
- package/packs/android/pack.json +56 -0
- package/packs/dotnet/fixture-result.json +62 -0
- package/packs/dotnet/pack.json +88 -0
- package/packs/expo/answer-key.json +51 -0
- package/packs/expo/fixture-result.json +62 -0
- package/packs/expo/pack.json +79 -0
- package/packs/firebase/answer-key.json +27 -0
- package/packs/firebase/fixture-result.json +43 -0
- package/packs/firebase/pack.json +58 -0
- package/packs/go/answer-key.json +101 -0
- package/packs/go/fixture-result.json +64 -0
- package/packs/go/pack.json +99 -0
- package/packs/ios/fixture-result.json +43 -0
- package/packs/ios/pack.json +55 -0
- package/packs/laravel/answer-key.json +84 -0
- package/packs/laravel/fixture-result.json +60 -0
- package/packs/laravel/pack.json +91 -0
- package/packs/node-ts/answer-key.json +220 -0
- package/packs/node-ts/fixture-result.json +114 -0
- package/packs/node-ts/pack.json +134 -0
- package/packs/python/answer-key.json +195 -0
- package/packs/python/fixture-result.json +119 -0
- package/packs/python/pack.json +121 -0
- package/packs/rails/answer-key.json +84 -0
- package/packs/rails/fixture-result.json +60 -0
- package/packs/rails/pack.json +78 -0
- package/packs/rust/fixture-result.json +43 -0
- package/packs/rust/pack.json +69 -0
- package/packs/spring/fixture-result.json +63 -0
- package/packs/spring/pack.json +103 -0
- package/packs/supabase/answer-key.json +52 -0
- package/packs/supabase/fixture-result.json +58 -0
- package/packs/supabase/pack.json +60 -0
- package/rules/gitleaks.toml +256 -0
- package/rules/semgrep/api-route-no-auth.yaml +31 -0
- package/rules/semgrep/cors-reflect-origin.yaml +48 -0
- package/rules/semgrep/eval-or-new-function.yaml +185 -0
- package/rules/semgrep/log-session-token.yaml +48 -0
- package/rules/semgrep/math-random-tokens.yaml +24 -0
- package/rules/semgrep/next-public-secret-name.yaml +32 -0
- package/rules/semgrep/postgrest-filter-injection.yaml +78 -0
- package/rules/semgrep/raw-sql-concat.yaml +42 -0
- package/rules/semgrep/read-by-id-no-owner.yaml +49 -0
- package/rules/semgrep/server-action-no-auth.yaml +26 -0
- package/rules/semgrep/sql-variants.yaml +99 -0
- package/rules/semgrep/ssrf-unguarded-url.yaml +137 -0
- package/rules/semgrep/subprocess-injection.yaml +163 -0
- package/rules/semgrep/taint-go.yaml +333 -0
- package/rules/semgrep/taint-java.yaml +317 -0
- package/rules/semgrep/taint-javascript.yaml +412 -0
- package/rules/semgrep/taint-php.yaml +361 -0
- package/rules/semgrep/taint-python.yaml +414 -0
- package/rules/semgrep/tool-endpoint-admin-client.yaml +86 -0
- package/rules/semgrep/user-metadata-authz.yaml +22 -0
- package/rules/semgrep/verify-result-unused.yaml +62 -0
- package/rules/semgrep/webhook-parse-before-verify.yaml +29 -0
- package/rules/test-artifact-ids.json +4 -0
- package/rules/tiers.json +427 -0
- package/schemas/check-results.schema.json +55 -0
- package/schemas/check.schema.json +41 -0
- package/schemas/codemap.schema.json +216 -0
- package/schemas/findings.schema.json +1048 -0
- package/schemas/hunt-output.schema.json +46 -0
- package/schemas/hunt-result.schema.json +58 -0
- package/schemas/results.schema.json +17 -0
- package/schemas/scan-hits.schema.json +32 -0
- package/schemas/scan-meta.schema.json +25 -0
- package/schemas/stack.schema.json +93 -0
- package/src/checks/README.md +32 -0
- package/src/checks/code/admin-role.ts +49 -0
- package/src/checks/code/owner-filter.ts +95 -0
- package/src/checks/code/shared.ts +66 -0
- package/src/checks/code/webhook-verify.ts +75 -0
- package/src/checks/context.ts +72 -0
- package/src/checks/map/config-checks.ts +79 -0
- package/src/checks/map/entry-checks.ts +116 -0
- package/src/checks/map/shared.ts +63 -0
- package/src/checks/ops.ts +280 -0
- package/src/checks/pro-map.ts +4 -0
- package/src/checks/run.ts +80 -0
- package/src/checks/types.ts +12 -0
- package/src/ci/comment.ts +52 -0
- package/src/ci/sarif-check.ts +100 -0
- package/src/ci/sarif.ts +72 -0
- package/src/cli.ts +273 -0
- package/src/commands/ci.ts +104 -0
- package/src/commands/git.ts +59 -0
- package/src/commands/hooks.ts +27 -0
- package/src/commands/hunt-pro.ts +4 -0
- package/src/commands/hunt.ts +68 -0
- package/src/commands/map.ts +27 -0
- package/src/commands/mcp.ts +18 -0
- package/src/commands/pack.ts +34 -0
- package/src/commands/prove-pro.ts +4 -0
- package/src/commands/prove.ts +126 -0
- package/src/commands/report-gate.ts +75 -0
- package/src/commands/scan.ts +156 -0
- package/src/confirm/agreement.ts +23 -0
- package/src/confirm/chain.ts +67 -0
- package/src/confirm/confirm.ts +107 -0
- package/src/confirm/honesty.ts +65 -0
- package/src/confirm/lead.ts +76 -0
- package/src/confirm/merge.ts +89 -0
- package/src/confirm/query.ts +126 -0
- package/src/confirm/rule.ts +76 -0
- package/src/confirm/store.ts +42 -0
- package/src/confirm/summary.ts +9 -0
- package/src/confirm/synthesis.ts +33 -0
- package/src/discover/detectors.ts +71 -0
- package/src/discover/discover.ts +253 -0
- package/src/discover/extract.ts +235 -0
- package/src/discover/git.ts +31 -0
- package/src/discover/kit-files.ts +37 -0
- package/src/discover/walk.ts +88 -0
- package/src/env.ts +51 -0
- package/src/gate/gate.ts +169 -0
- package/src/hooks/install.ts +142 -0
- package/src/hooks/run.ts +121 -0
- package/src/map/analyze.ts +219 -0
- package/src/map/build.ts +206 -0
- package/src/map/candidates.ts +47 -0
- package/src/map/cblocks.ts +55 -0
- package/src/map/config.ts +207 -0
- package/src/map/dotnet.ts +205 -0
- package/src/map/entry.ts +80 -0
- package/src/map/expo.ts +171 -0
- package/src/map/go.ts +271 -0
- package/src/map/js-express.ts +135 -0
- package/src/map/js-misc.ts +221 -0
- package/src/map/js-next.ts +282 -0
- package/src/map/laravel.ts +327 -0
- package/src/map/lex-more.ts +152 -0
- package/src/map/lex.ts +274 -0
- package/src/map/light-index.ts +134 -0
- package/src/map/light-profiles.ts +363 -0
- package/src/map/light.ts +484 -0
- package/src/map/local-vocab.ts +30 -0
- package/src/map/mobile.ts +263 -0
- package/src/map/py-django.ts +239 -0
- package/src/map/py.ts +329 -0
- package/src/map/rails.ts +291 -0
- package/src/map/repo.ts +150 -0
- package/src/map/rust.ts +256 -0
- package/src/map/spring.ts +185 -0
- package/src/map/taint.ts +86 -0
- package/src/map/touches.ts +296 -0
- package/src/map/vocab.ts +106 -0
- package/src/mapping/controls.ts +47 -0
- package/src/mapping/dependency-policy.ts +55 -0
- package/src/mapping/map.ts +59 -0
- package/src/mapping/maps/gitleaks.json +12 -0
- package/src/mapping/maps/osv-scanner.json +7 -0
- package/src/mapping/maps/trivy.json +13 -0
- package/src/mcp/confine.ts +44 -0
- package/src/mcp/protocol.ts +144 -0
- package/src/mcp/schema.ts +35 -0
- package/src/mcp/server.ts +32 -0
- package/src/mcp/tools.ts +235 -0
- package/src/packs/coverage.ts +98 -0
- package/src/packs/manifest.ts +97 -0
- package/src/packs/validate.ts +204 -0
- package/src/prove/container.ts +237 -0
- package/src/prove/exec-main.ts +16 -0
- package/src/prove/executor.ts +130 -0
- package/src/prove/local.ts +100 -0
- package/src/prove/probes.ts +68 -0
- package/src/prove/recipe.ts +215 -0
- package/src/prove/run.ts +124 -0
- package/src/prove/runner.ts +28 -0
- package/src/prove/safety.ts +98 -0
- package/src/prove/spec.ts +256 -0
- package/src/prove/store.ts +48 -0
- package/src/report/build.ts +304 -0
- package/src/report/entry-points.ts +78 -0
- package/src/report/hunt-section.ts +67 -0
- package/src/report/kit.ts +59 -0
- package/src/report/load.ts +49 -0
- package/src/report/messages.ts +270 -0
- package/src/report/prove-section.ts +61 -0
- package/src/report/report.ts +160 -0
- package/src/report/surface.ts +32 -0
- package/src/report/test-code.ts +56 -0
- package/src/report/tiers.ts +50 -0
- package/src/scanners/common.ts +149 -0
- package/src/scanners/gitleaks.ts +89 -0
- package/src/scanners/index.ts +103 -0
- package/src/scanners/osv-scanner.ts +90 -0
- package/src/scanners/run-targets.ts +35 -0
- package/src/scanners/secret-noise.ts +80 -0
- package/src/scanners/semgrep.ts +139 -0
- package/src/scanners/trivy.ts +66 -0
- package/src/scanners/types.ts +98 -0
- package/src/types/check.ts +52 -0
- package/src/types/codemap.ts +142 -0
- package/src/types/findings.ts +107 -0
- package/src/types/hunt.ts +97 -0
- package/src/types/index.ts +10 -0
- package/src/types/prove.ts +83 -0
- package/src/types/results.ts +15 -0
- package/src/types/scan.ts +63 -0
- package/src/types/stack.ts +61 -0
- package/src/util.ts +59 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// The container backend. It speaks the docker command line, so Colima, Docker Desktop and Podman work through the same commands.
|
|
2
|
+
// Safety rules 4 and 6 of the design are built into runArgs() and checked again by assertSafeRunArgs() before any container starts:
|
|
3
|
+
// non root, read only root filesystem, no host mounts (the copy goes in at build time, nothing is mounted), no capabilities, no
|
|
4
|
+
// privilege escalation, memory, cpu, process and time limits, and no network while tests run. The build phase may reach the package
|
|
5
|
+
// registry (lockfile install, no install scripts). A Postgres store runs on an internal network that has no route out.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import type { Recipe } from '../types/index.ts';
|
|
12
|
+
import { assertSandboxTarget } from './safety.ts';
|
|
13
|
+
import type { Exec, Instance, Limits, PrepareRequest, PrepareResult, Prepared, Runner, SpecRun, StartResult, Teardown } from './runner.ts';
|
|
14
|
+
|
|
15
|
+
export interface ContainerRuntime { readonly bin: string; run(args: string[], o?: { input?: string; timeoutMs?: number }): Promise<Exec> }
|
|
16
|
+
|
|
17
|
+
const MAX_OUT = 4 * 1024 * 1024;
|
|
18
|
+
export class CliRuntime implements ContainerRuntime {
|
|
19
|
+
readonly bin: string;
|
|
20
|
+
constructor(bin: string = 'docker') { this.bin = bin; }
|
|
21
|
+
run(args: string[], o: { input?: string; timeoutMs?: number } = {}): Promise<Exec> {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
let out = ''; let err = ''; let timedOut = false; let done = false;
|
|
24
|
+
const fin = (e: Exec): void => { if (!done) { done = true; resolve(e); } };
|
|
25
|
+
let child;
|
|
26
|
+
try { child = spawn(this.bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); } catch (e) { return fin({ code: null, stdout: '', stderr: (e as Error).message, timedOut: false }); }
|
|
27
|
+
const timer = o.timeoutMs ? setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, o.timeoutMs) : null;
|
|
28
|
+
child.stdout.on('data', (d: Buffer) => { if (out.length < MAX_OUT) out += d.toString('utf8'); });
|
|
29
|
+
child.stderr.on('data', (d: Buffer) => { if (err.length < MAX_OUT) err += d.toString('utf8'); });
|
|
30
|
+
child.on('error', (e: Error) => { if (timer) clearTimeout(timer); fin({ code: null, stdout: out, stderr: (e as NodeJS.ErrnoException).code === 'ENOENT' ? `${this.bin}: command not found` : e.message, timedOut }); });
|
|
31
|
+
child.on('close', (code: number | null) => { if (timer) clearTimeout(timer); fin({ code, stdout: out, stderr: err, timedOut }); });
|
|
32
|
+
child.stdin.on('error', () => undefined);
|
|
33
|
+
child.stdin.end(o.input ?? '');
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const LABEL = 'hullproof.prove';
|
|
39
|
+
export const APP_USER = '10001:10001';
|
|
40
|
+
export const EXEC_DIR = '/app/.hullproof-exec';
|
|
41
|
+
const EXEC_FILES = ['spec.ts', 'executor.ts', 'exec-main.ts'];
|
|
42
|
+
// The step executor runs inside the container as TypeScript, so its sources are always read from src/prove (shipped with the package),
|
|
43
|
+
// also when the engine itself runs from dist/.
|
|
44
|
+
const HERE = fileURLToPath(new URL('../../src/prove/', import.meta.url));
|
|
45
|
+
const PG_IMAGE = 'postgres:16-alpine';
|
|
46
|
+
const PG_USER = '70:70';
|
|
47
|
+
const clip = (s: string, n = 600): string => { const t = s.trim().replace(/\s+/g, ' '); return t.length <= n ? t : '…' + t.slice(t.length - n); };
|
|
48
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
|
|
50
|
+
// ---- commands, as pure functions so they can be tested without a runtime --------------------------------------------------------
|
|
51
|
+
// A Python app runs in a Python image that also holds the node binary, because the step executor and the health probe are Node scripts
|
|
52
|
+
// that run inside the sandbox. The binary is copied from the same image the Node recipe uses, at build time, so nothing else is fetched.
|
|
53
|
+
export const NODE_IMAGE = 'node:22-slim';
|
|
54
|
+
export function dockerfile(r: Recipe): string {
|
|
55
|
+
const py = r.stack === 'python-flask';
|
|
56
|
+
return [
|
|
57
|
+
...(py ? [`FROM ${NODE_IMAGE} AS node`] : []),
|
|
58
|
+
`FROM ${r.base_image}`,
|
|
59
|
+
...(py ? ['COPY --from=node /usr/local/bin/node /usr/local/bin/node', 'ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1 HOME=/tmp'] : []),
|
|
60
|
+
'WORKDIR /app', 'COPY . /app', ...(r.install ? [`RUN ${r.install.join(' ')}`] : []), `USER ${APP_USER}`, '',
|
|
61
|
+
].join('\n');
|
|
62
|
+
}
|
|
63
|
+
export const buildArgs = (tag: string, runId: string, ctx: string): string[] => ['build', '--label', `${LABEL}=${runId}`, '--tag', tag, '--file', join(ctx, 'Dockerfile.hullproof'), ctx];
|
|
64
|
+
|
|
65
|
+
export interface RunSpecArgs { name: string; runId: string; image: string; network: string; user: string; tmpfs: string[]; env: Record<string, string>; limits: Limits; cmd: string[] }
|
|
66
|
+
export function runArgs(a: RunSpecArgs): string[] {
|
|
67
|
+
const args = [
|
|
68
|
+
'run', '--detach', '--init', '--name', a.name, '--label', `${LABEL}=${a.runId}`,
|
|
69
|
+
'--network', a.network, '--read-only', '--user', a.user, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges',
|
|
70
|
+
'--pids-limit', String(a.limits.pids), '--memory', a.limits.memory, '--memory-swap', a.limits.memory, '--cpus', a.limits.cpus,
|
|
71
|
+
'--ulimit', 'nofile=1024:1024', '--ulimit', 'core=0',
|
|
72
|
+
...a.tmpfs.flatMap((t) => ['--tmpfs', t]),
|
|
73
|
+
...Object.entries(a.env).flatMap(([k, v]) => ['--env', `${k}=${v}`]),
|
|
74
|
+
a.image, ...a.cmd,
|
|
75
|
+
];
|
|
76
|
+
assertSafeRunArgs(args.slice(0, args.length - 1 - a.cmd.length), a.network);
|
|
77
|
+
return args;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FORBIDDEN = new Set(['--privileged', '-v', '--volume', '--volumes-from', '--mount', '-p', '--publish', '-P', '--publish-all', '--device', '--cap-add', '--pid', '--ipc', '--uts', '--userns', '--add-host', '--env-file', '--cgroupns', '--group-add', '--sysctl']);
|
|
81
|
+
// Refuses anything that would weaken the sandbox. `head` is the argument list up to, and not including, the image name. The builder
|
|
82
|
+
// calls this on its own output; tests call it on bad input.
|
|
83
|
+
export function assertSafeRunArgs(head: string[], allowedNetwork: string): void {
|
|
84
|
+
const bad: string[] = [];
|
|
85
|
+
const val = (flag: string): string | undefined => { const i = head.indexOf(flag); return i >= 0 ? head[i + 1] : undefined; };
|
|
86
|
+
for (const a of head) if (FORBIDDEN.has(a) || /^--(?:privileged|volume|mount|publish|device|cap-add|pid|ipc|uts|userns|add-host|env-file)=/.test(a)) bad.push(a);
|
|
87
|
+
if (head.some((a) => /unconfined/.test(a))) bad.push('unconfined security option');
|
|
88
|
+
if (!head.includes('--read-only')) bad.push('missing --read-only');
|
|
89
|
+
if (val('--cap-drop') !== 'ALL') bad.push('capabilities are not all dropped');
|
|
90
|
+
if (!head.some((a, i) => a === '--security-opt' && head[i + 1] === 'no-new-privileges')) bad.push('missing no-new-privileges');
|
|
91
|
+
for (const f of ['--pids-limit', '--memory', '--cpus']) if (!val(f)) bad.push(`missing ${f}`);
|
|
92
|
+
const user = val('--user');
|
|
93
|
+
if (!user || /^(?:0|root)(?::|$)/.test(user)) bad.push('the container would run as root');
|
|
94
|
+
const net = val('--network');
|
|
95
|
+
if (net !== 'none' && net !== allowedNetwork) bad.push(`network ${net ?? 'default'} is not none or the internal network`);
|
|
96
|
+
if (net !== 'none' && !/^hp-prove-net-/.test(net ?? '')) bad.push('a network other than none must be a hullproof internal network');
|
|
97
|
+
if (bad.length) throw new Error(`unsafe container arguments refused: ${bad.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const healthScript = (port: number, path: string): string => `fetch('http://127.0.0.1:${port}${path}',{signal:AbortSignal.timeout(2000)}).then(r=>process.exit(r.status<500?0:2),()=>process.exit(1))`;
|
|
101
|
+
|
|
102
|
+
export class ContainerRunner implements Runner {
|
|
103
|
+
readonly name = 'container';
|
|
104
|
+
private readonly rt: ContainerRuntime;
|
|
105
|
+
private readonly o: { sleepMs?: number };
|
|
106
|
+
constructor(rt: ContainerRuntime, o: { sleepMs?: number } = {}) { this.rt = rt; this.o = o; }
|
|
107
|
+
|
|
108
|
+
async available(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
109
|
+
const r = await this.rt.run(['info'], { timeoutMs: 20_000 });
|
|
110
|
+
if (r.code === 0) return { ok: true };
|
|
111
|
+
const missing = /not found|ENOENT/.test(r.stderr);
|
|
112
|
+
return { ok: false, reason: missing ? `no container runtime: ${this.rt.bin} was not found. Install one, for example: brew install colima docker, then colima start.` : `the container runtime is not running (${clip(r.stderr, 160) || 'no answer'}). For Colima run: colima start.` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async prepare(req: PrepareRequest): Promise<PrepareResult> {
|
|
116
|
+
const runId = randomBytes(5).toString('hex');
|
|
117
|
+
const image = `hullproof-prove-${runId}`;
|
|
118
|
+
mkdirSync(join(req.copyDir, '.hullproof-exec'), { recursive: true });
|
|
119
|
+
for (const f of EXEC_FILES) copyFileSync(join(HERE, f), join(req.copyDir, '.hullproof-exec', f));
|
|
120
|
+
writeFileSync(join(req.copyDir, 'Dockerfile.hullproof'), dockerfile(req.recipe));
|
|
121
|
+
const b = await this.rt.run(buildArgs(image, runId, req.copyDir), { timeoutMs: 600_000 });
|
|
122
|
+
if (b.code !== 0) { await this.rt.run(['rmi', '-f', image]); return { ok: false, code: 'build_failed', reason: `the image did not build${b.timedOut ? ' in time' : ''}: ${clip(b.stderr || b.stdout)}` }; }
|
|
123
|
+
return { ok: true, prepared: new ContainerPrepared(this.rt, runId, image, req, this.o.sleepMs ?? 500) };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class ContainerPrepared implements Prepared {
|
|
128
|
+
private n = 0;
|
|
129
|
+
private readonly live = new Set<ContainerInstance>();
|
|
130
|
+
private readonly rt: ContainerRuntime;
|
|
131
|
+
private readonly runId: string;
|
|
132
|
+
private readonly image: string;
|
|
133
|
+
private readonly req: PrepareRequest;
|
|
134
|
+
private readonly pause: number;
|
|
135
|
+
constructor(rt: ContainerRuntime, runId: string, image: string, req: PrepareRequest, pause: number) { this.rt = rt; this.runId = runId; this.image = image; this.req = req; this.pause = pause; }
|
|
136
|
+
|
|
137
|
+
async start(): Promise<StartResult> {
|
|
138
|
+
const { recipe, limits } = this.req;
|
|
139
|
+
const id = `${this.runId}-${++this.n}`;
|
|
140
|
+
const name = `hp-prove-${id}`;
|
|
141
|
+
const inst = new ContainerInstance(this.rt, name, recipe, limits);
|
|
142
|
+
this.live.add(inst);
|
|
143
|
+
const fail = async (code: 'start_failed' | 'health_failed' | 'build_failed', reason: string): Promise<StartResult> => { const logs = await this.rt.run(['logs', '--tail', '15', name]); await inst.stop(); this.live.delete(inst); return { ok: false, code, reason: `${reason}${logs.stdout + logs.stderr ? `. Last output: ${clip(logs.stdout + ' ' + logs.stderr, 400)}` : ''}` }; };
|
|
144
|
+
const data = (s: string): string => s.replace(/\{DATA\}/g, '/data');
|
|
145
|
+
let network = 'none';
|
|
146
|
+
const env: Record<string, string> = {};
|
|
147
|
+
const tmpfs = ['/tmp:rw,noexec,nosuid,nodev,size=64m'];
|
|
148
|
+
if (recipe.data_store.kind === 'sqlite') tmpfs.push(`/data:rw,noexec,nosuid,nodev,size=64m,uid=10001,gid=10001`);
|
|
149
|
+
if (recipe.data_store.kind === 'postgres') {
|
|
150
|
+
network = `hp-prove-net-${id}`;
|
|
151
|
+
const pg = `hp-prove-pg-${id}`;
|
|
152
|
+
const net = await this.rt.run(['network', 'create', '--internal', '--label', `${LABEL}=${this.runId}`, network]);
|
|
153
|
+
if (net.code !== 0) { this.live.delete(inst); return { ok: false, code: 'start_failed', reason: `the internal network could not be created: ${clip(net.stderr)}` }; }
|
|
154
|
+
inst.network = network;
|
|
155
|
+
inst.extra.push(pg);
|
|
156
|
+
const pgRun = await this.rt.run(runArgs({ name: pg, runId: this.runId, image: PG_IMAGE, network, user: PG_USER, tmpfs: ['/var/lib/postgresql/data:rw,nosuid,nodev,size=128m,uid=70,gid=70', '/var/run/postgresql:rw,nosuid,nodev,size=8m,uid=70,gid=70', '/tmp:rw,nosuid,nodev,size=32m'], env: { POSTGRES_PASSWORD: 'dummy', POSTGRES_DB: 'app' }, limits, cmd: [] }));
|
|
157
|
+
if (pgRun.code !== 0) return fail('start_failed', `Postgres did not start: ${clip(pgRun.stderr)}`);
|
|
158
|
+
let up = false;
|
|
159
|
+
for (let t = Date.now(); Date.now() - t < limits.healthMs && !up; await this.sleep()) up = (await this.rt.run(['exec', pg, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres', '-d', 'app'], { timeoutMs: 5000 })).code === 0;
|
|
160
|
+
if (!up) return fail('health_failed', 'Postgres did not become ready in time');
|
|
161
|
+
for (const f of [...recipe.data_store.migrations, ...(recipe.data_store.seed_sql ? [recipe.data_store.seed_sql] : [])]) {
|
|
162
|
+
const sql = readFileSync(join(this.req.sourceDir, f), 'utf8');
|
|
163
|
+
const r = await this.rt.run(['exec', '-i', pg, 'psql', '-v', 'ON_ERROR_STOP=1', '-q', '-U', 'postgres', '-d', 'app'], { input: sql, timeoutMs: 60_000 });
|
|
164
|
+
if (r.code !== 0) return fail('start_failed', `${f} did not apply: ${clip(r.stderr, 300)}`);
|
|
165
|
+
}
|
|
166
|
+
env.__PGURL = `postgres://postgres:dummy@${pg}:5432/app`;
|
|
167
|
+
}
|
|
168
|
+
for (const [k, v] of Object.entries(recipe.env)) env[k] = data(v).replace(/\{PGURL\}/g, env.__PGURL ?? '');
|
|
169
|
+
delete env.__PGURL;
|
|
170
|
+
const script = `${recipe.seed ? recipe.seed + ' && ' : ''}exec ${recipe.start}`;
|
|
171
|
+
const run = await this.rt.run(runArgs({ name, runId: this.runId, image: this.image, network, user: APP_USER, tmpfs, env, limits, cmd: ['sh', '-c', script] }));
|
|
172
|
+
if (run.code !== 0) return fail('start_failed', `the container did not start: ${clip(run.stderr)}`);
|
|
173
|
+
for (let t = Date.now(); Date.now() - t < limits.healthMs; await this.sleep()) {
|
|
174
|
+
const h = await this.rt.run(['exec', name, 'node', '-e', healthScript(recipe.port, recipe.health_path)], { timeoutMs: 6000 });
|
|
175
|
+
if (h.code === 0) { inst.arm(); return { ok: true, instance: inst }; }
|
|
176
|
+
const st = await this.rt.run(['inspect', '--format', '{{.State.Running}}', name]);
|
|
177
|
+
if (st.stdout.trim() === 'false') return fail('start_failed', 'the app stopped before it answered (the seed step or the start command failed)');
|
|
178
|
+
}
|
|
179
|
+
return fail('health_failed', `the app did not answer on ${recipe.health_path} within ${Math.round(limits.healthMs / 1000)} seconds`);
|
|
180
|
+
}
|
|
181
|
+
private sleep(): Promise<void> { return sleep(this.pause); }
|
|
182
|
+
|
|
183
|
+
async dispose(): Promise<Teardown> {
|
|
184
|
+
for (const i of this.live) await i.stop();
|
|
185
|
+
await this.rt.run(['rmi', '-f', this.image]);
|
|
186
|
+
const left: string[] = [];
|
|
187
|
+
for (const [what, args] of [['container', ['ps', '-a', '-q']], ['image', ['images', '-q']], ['network', ['network', 'ls', '-q']], ['volume', ['volume', 'ls', '-q']]] as const) {
|
|
188
|
+
const r = await this.rt.run([...args, '--filter', `label=${LABEL}=${this.runId}`]);
|
|
189
|
+
for (const l of r.stdout.split('\n').map((x) => x.trim()).filter(Boolean)) left.push(`${what} ${l}`);
|
|
190
|
+
}
|
|
191
|
+
return { left_behind: left };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
class ContainerInstance implements Instance {
|
|
196
|
+
readonly baseUrl: string;
|
|
197
|
+
network: string | null = null;
|
|
198
|
+
readonly extra: string[] = [];
|
|
199
|
+
private stopped = false;
|
|
200
|
+
private timer: NodeJS.Timeout | null = null;
|
|
201
|
+
private readonly rt: ContainerRuntime;
|
|
202
|
+
private readonly name: string;
|
|
203
|
+
private readonly recipe: Recipe;
|
|
204
|
+
private readonly limits: Limits;
|
|
205
|
+
constructor(rt: ContainerRuntime, name: string, recipe: Recipe, limits: Limits) {
|
|
206
|
+
this.rt = rt; this.name = name; this.recipe = recipe; this.limits = limits;
|
|
207
|
+
this.baseUrl = `http://127.0.0.1:${recipe.port}`;
|
|
208
|
+
}
|
|
209
|
+
arm(): void { this.timer = setTimeout(() => { void this.stop(); }, this.limits.instanceMs); this.timer.unref(); }
|
|
210
|
+
|
|
211
|
+
async exec(cmd: string[], o: { input?: string; timeoutMs?: number } = {}): Promise<Exec> {
|
|
212
|
+
if (this.stopped) return { code: null, stdout: '', stderr: 'the instance time limit was reached or it was stopped', timedOut: false };
|
|
213
|
+
const ms = o.timeoutMs ?? this.limits.execMs;
|
|
214
|
+
// `timeout -s KILL` inside the container ends the process there. Killing the docker client alone would leave it running.
|
|
215
|
+
return this.rt.run(['exec', '-i', this.name, 'timeout', '-s', 'KILL', String(Math.max(1, Math.ceil(ms / 1000))), ...cmd], { input: o.input, timeoutMs: ms + 10_000 });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async runSpec(spec: import('../types/index.ts').Spec): Promise<SpecRun> {
|
|
219
|
+
assertSandboxTarget(this.baseUrl, this.recipe.port);
|
|
220
|
+
const r = await this.exec(['node', '--experimental-strip-types', `${EXEC_DIR}/exec-main.ts`, this.baseUrl], { input: JSON.stringify(spec) });
|
|
221
|
+
if (r.timedOut || r.code === 137) return { ok: false, reason: 'the step executor ran past the time limit and was killed' };
|
|
222
|
+
const line = r.stdout.trim().split('\n').pop() ?? '';
|
|
223
|
+
try {
|
|
224
|
+
const j = JSON.parse(line) as { ok: boolean; error?: string; outcome?: SpecRun extends { ok: true; outcome: infer O } ? O : never; detail?: string; steps?: import('../types/index.ts').StepRecord[] };
|
|
225
|
+
if (!j.ok) return { ok: false, reason: j.error ?? 'the step executor failed' };
|
|
226
|
+
return { ok: true, outcome: j.outcome!, detail: j.detail ?? '', steps: j.steps ?? [] };
|
|
227
|
+
} catch { return { ok: false, reason: `the step executor gave no result (${clip(r.stderr, 200) || 'no output'})` }; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async stop(): Promise<Teardown> {
|
|
231
|
+
if (this.timer) clearTimeout(this.timer);
|
|
232
|
+
this.stopped = true;
|
|
233
|
+
for (const c of [this.name, ...this.extra]) await this.rt.run(['rm', '-f', '--volumes', c]);
|
|
234
|
+
if (this.network) await this.rt.run(['network', 'rm', this.network]);
|
|
235
|
+
return { left_behind: [] };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Runs inside the sandbox: `node --experimental-strip-types /hp/exec-main.ts http://127.0.0.1:PORT` with a spec as JSON on standard
|
|
2
|
+
// input. Prints one JSON line: the outcome, the detail and the redacted step records. Refuses any address but the sandbox's own loopback.
|
|
3
|
+
import { executeSpec } from './executor.ts';
|
|
4
|
+
import { validateSpec } from './spec.ts';
|
|
5
|
+
|
|
6
|
+
const chunks: Buffer[] = [];
|
|
7
|
+
for await (const c of process.stdin) chunks.push(c as Buffer);
|
|
8
|
+
try {
|
|
9
|
+
const v = validateSpec(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
10
|
+
if (!v.ok) { process.stdout.write(JSON.stringify({ ok: false, error: `invalid spec: ${v.errors.join('; ')}` }) + '\n'); process.exit(0); }
|
|
11
|
+
const r = await executeSpec(v.spec, { baseUrl: process.argv[2] ?? '' });
|
|
12
|
+
process.stdout.write(JSON.stringify({ ok: true, ...r }) + '\n');
|
|
13
|
+
} catch (e) {
|
|
14
|
+
process.stdout.write(JSON.stringify({ ok: false, error: (e as Error).message.slice(0, 300) }) + '\n');
|
|
15
|
+
}
|
|
16
|
+
process.exit(0);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The step executor. It turns a validated spec into HTTP requests against one local address and records what came back. It runs
|
|
2
|
+
// inside the sandbox (through exec-main.ts), next to the app, so the request never leaves the sandbox network. Imports only
|
|
3
|
+
// node built ins and spec.ts. Responses are stored in redacted form and every assertion is judged on that stored form, so a replay
|
|
4
|
+
// from the stored transcript reaches the same verdict with no sandbox.
|
|
5
|
+
import type { Json } from '../types/hunt.ts';
|
|
6
|
+
import type { RunOutcome, Spec, SpecStep, StepRecord } from '../types/prove.ts';
|
|
7
|
+
import { canon, evalAssertion, fill, fillJson, pathGet, SECRET_NAME, sha256, type Vars } from './spec.ts';
|
|
8
|
+
|
|
9
|
+
export const LIMITS = { requestMs: 8000, bodyChars: 16384, textStore: 4096 } as const;
|
|
10
|
+
const HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
11
|
+
|
|
12
|
+
// The one place an address is accepted. Anything that is not the sandbox's own loopback is refused before a request is built.
|
|
13
|
+
export function loopbackBase(url: string): URL {
|
|
14
|
+
let u: URL;
|
|
15
|
+
try { u = new URL(url); } catch { throw new Error(`refused: ${JSON.stringify(url).slice(0, 80)} is not an address`); }
|
|
16
|
+
if (u.protocol !== 'http:' || !HOSTS.has(u.hostname) || u.username || u.password || !u.port || u.pathname !== '/' || u.search || u.hash) {
|
|
17
|
+
throw new Error(`refused: ${u.origin} is not a loopback address of the sandbox (http://127.0.0.1:PORT only)`);
|
|
18
|
+
}
|
|
19
|
+
return u;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*/g;
|
|
23
|
+
const LONG = /\b[A-Fa-f0-9]{40,}\b|\b[A-Za-z0-9_]{32,}\b/g;
|
|
24
|
+
export const redactText = (s: string): string => s.replace(JWT, '[redacted]').replace(LONG, '[redacted]');
|
|
25
|
+
export function redactJson(v: Json, key = ''): Json {
|
|
26
|
+
if (SECRET_NAME.test(key) && v !== null && typeof v !== 'object') return '[redacted]';
|
|
27
|
+
if (typeof v === 'string') return redactText(v);
|
|
28
|
+
if (Array.isArray(v)) return v.map((x) => redactJson(x, key));
|
|
29
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, redactJson(x, k)]));
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// What is kept of a response header: a short allow list. No cookie, no authorization, nothing that could hold a credential.
|
|
34
|
+
const KEEP = new Set(['content-type', 'location', 'cache-control', 'www-authenticate', 'access-control-allow-origin', 'x-content-type-options']);
|
|
35
|
+
|
|
36
|
+
function judgeStep(spec: Spec, s: SpecStep, rec: StepRecord, vars: Vars, secrets: ReadonlySet<string>): void {
|
|
37
|
+
rec.assertions = rec.response ? s.assert.map((a) => evalAssertion(rec.response!, a, vars, secrets)) : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Judgement { done: boolean; outcome: RunOutcome | null; detail: string }
|
|
41
|
+
|
|
42
|
+
// One verdict function for the live run and for a replay. Reads only the stored records.
|
|
43
|
+
export function judge(spec: Spec, recs: StepRecord[]): Judgement & { recs: StepRecord[] } {
|
|
44
|
+
const vars: Vars = {};
|
|
45
|
+
const secrets = new Set<string>();
|
|
46
|
+
const out: StepRecord[] = [];
|
|
47
|
+
for (let i = 0; i < recs.length; i++) {
|
|
48
|
+
const s = spec.steps[i];
|
|
49
|
+
const rec = recs[i];
|
|
50
|
+
if (!s || rec.id !== s.id) return { done: true, outcome: 'error', detail: `the stored step ${i + 1} does not match the spec`, recs: out };
|
|
51
|
+
for (const c of s.captures ?? []) if (SECRET_NAME.test(c.name) || SECRET_NAME.test(c.path ?? '')) secrets.add(c.name);
|
|
52
|
+
const r = { ...rec, assertions: [...rec.assertions] };
|
|
53
|
+
judgeStep(spec, s, r, vars, secrets);
|
|
54
|
+
out.push(r);
|
|
55
|
+
Object.assign(vars, rec.captures);
|
|
56
|
+
const failed = r.assertions.find((a) => !a.ok);
|
|
57
|
+
if (rec.error && !(failed && s.phase !== 'attack')) return { done: true, outcome: 'error', detail: `step ${s.id}: ${rec.error}`, recs: out };
|
|
58
|
+
if (s.phase === 'setup' && failed) return { done: true, outcome: 'invalid', detail: `setup step ${s.id} did not hold (${failed.desc}), so the harness or the seed is not working`, recs: out };
|
|
59
|
+
if (s.phase === 'control' && failed) return { done: true, outcome: 'invalid', detail: `positive control failed at step ${s.id} (${failed.desc}), so nothing can be said about the flaw`, recs: out };
|
|
60
|
+
if (s.phase === 'attack' && failed) return { done: true, outcome: 'not_proven', detail: `the app refused as it should at step ${s.id} (${failed.desc} did not hold)`, recs: out };
|
|
61
|
+
}
|
|
62
|
+
if (recs.length === spec.steps.length) return { done: true, outcome: 'proven', detail: 'the positive control passed and every attack assertion held', recs: out };
|
|
63
|
+
return { done: false, outcome: null, detail: '', recs: out };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parseJson = (t: string): Json | undefined => { try { return JSON.parse(t) as Json; } catch { return undefined; } };
|
|
67
|
+
|
|
68
|
+
export interface ExecOptions { baseUrl: string; fetchFn?: typeof fetch; requestMs?: number }
|
|
69
|
+
|
|
70
|
+
// Runs the steps in order and stops as soon as the verdict is known. Returns the redacted records and the verdict.
|
|
71
|
+
export async function executeSpec(spec: Spec, o: ExecOptions): Promise<{ outcome: RunOutcome; detail: string; steps: StepRecord[] }> {
|
|
72
|
+
const base = loopbackBase(o.baseUrl);
|
|
73
|
+
const f = o.fetchFn ?? fetch;
|
|
74
|
+
const vars: Vars = {};
|
|
75
|
+
const secrets = new Set<string>();
|
|
76
|
+
const recs: StepRecord[] = [];
|
|
77
|
+
for (const s of spec.steps) {
|
|
78
|
+
for (const c of s.captures ?? []) if (SECRET_NAME.test(c.name) || SECRET_NAME.test(c.path ?? '')) secrets.add(c.name);
|
|
79
|
+
const rec: StepRecord = { id: s.id, phase: s.phase, actor: s.actor, error: null, request: { method: s.method, path: s.path, headers: {}, body: null }, response: null, captures: {}, assertions: [] };
|
|
80
|
+
try {
|
|
81
|
+
const path = fill(s.path, vars, secrets, false);
|
|
82
|
+
if (!path.startsWith('/') || path.startsWith('//')) throw new Error('the path became something other than a path on the app');
|
|
83
|
+
// The actor's headers apply once every variable in them has been captured (an actor that is not signed in yet sends none).
|
|
84
|
+
const headers: Record<string, string> = {};
|
|
85
|
+
for (const [k, v] of Object.entries(spec.actors[s.actor]?.headers ?? {})) { try { headers[k] = fill(v, vars, secrets, false); } catch { /* not signed in yet */ } }
|
|
86
|
+
for (const [k, v] of Object.entries(s.headers ?? {})) headers[k] = fill(v, vars, secrets, false);
|
|
87
|
+
const body = s.body ? fillJson(s.body, vars, secrets, false) : null;
|
|
88
|
+
if (body && !headers['content-type']) headers['content-type'] = 'application/json';
|
|
89
|
+
rec.request = {
|
|
90
|
+
method: s.method, path: fill(s.path, vars, secrets, true),
|
|
91
|
+
headers: Object.fromEntries(Object.entries({ ...(spec.actors[s.actor]?.headers ?? {}), ...(s.headers ?? {}) }).map(([k, v]) => [k, SECRET_NAME.test(k) ? '[redacted]' : v]).sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
92
|
+
body: s.body ? redactJson(fillJson(s.body, vars, secrets, true)) : null,
|
|
93
|
+
};
|
|
94
|
+
const res = await f(new URL(path, base), { method: s.method, headers, body: body ? JSON.stringify(body) : undefined, redirect: 'manual', signal: AbortSignal.timeout(o.requestMs ?? LIMITS.requestMs) });
|
|
95
|
+
const full = (await res.text()).slice(0, LIMITS.bodyChars + 1);
|
|
96
|
+
const truncated = full.length > LIMITS.bodyChars;
|
|
97
|
+
const text = full.slice(0, LIMITS.bodyChars);
|
|
98
|
+
const rawJson = truncated ? undefined : parseJson(text);
|
|
99
|
+
const rh: Record<string, string> = {};
|
|
100
|
+
res.headers.forEach((v, k) => { if (KEEP.has(k.toLowerCase())) rh[k.toLowerCase()] = redactText(v); });
|
|
101
|
+
rec.response = {
|
|
102
|
+
status: res.status, headers: rh, truncated,
|
|
103
|
+
json: rawJson === undefined ? undefined : redactJson(rawJson),
|
|
104
|
+
text: rawJson === undefined ? redactText(text).slice(0, LIMITS.textStore) : undefined,
|
|
105
|
+
};
|
|
106
|
+
// Captures read the raw response. What is stored is redacted. A capture that finds nothing is an error, but a failed assertion
|
|
107
|
+
// on the same step is reported first (a failed sign in is an invalid setup, not a broken run).
|
|
108
|
+
for (const c of s.captures ?? []) {
|
|
109
|
+
const v: Json | undefined = c.from === 'status' ? res.status : c.from === 'header' ? (res.headers.get(c.path!) ?? undefined) : pathGet(rawJson, c.path!);
|
|
110
|
+
if (v === undefined || (typeof v === 'object' && v !== null)) { rec.error ??= `capture ${c.name} found nothing at ${c.from} ${c.path ?? ''}`.trim(); continue; }
|
|
111
|
+
vars[c.name] = v;
|
|
112
|
+
rec.captures[c.name] = secrets.has(c.name) ? '[redacted]' : redactJson(v);
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
rec.error = (e as Error).name === 'TimeoutError' ? 'the request timed out' : (e as Error).message.slice(0, 200);
|
|
116
|
+
}
|
|
117
|
+
recs.push(rec);
|
|
118
|
+
const j = judge(spec, recs);
|
|
119
|
+
recs[recs.length - 1] = j.recs[j.recs.length - 1];
|
|
120
|
+
if (j.done) return { outcome: j.outcome!, detail: j.detail, steps: recs };
|
|
121
|
+
}
|
|
122
|
+
const j = judge(spec, recs);
|
|
123
|
+
return { outcome: j.outcome ?? 'error', detail: j.detail, steps: recs };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The record stored for one run. The hash covers every other field.
|
|
127
|
+
export function runRecord(spec: Spec, specSha: string, run: number, r: { outcome: RunOutcome; detail: string; steps: StepRecord[] }): import('../types/prove.ts').RunRecord {
|
|
128
|
+
const body = { schema_version: '1.0.0' as const, spec_id: spec.spec_id, spec_sha256: specSha, run, outcome: r.outcome, detail: r.detail, steps: r.steps };
|
|
129
|
+
return { ...body, transcript_sha256: sha256(canon(body)) };
|
|
130
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// UNSAFE. A plain local process backend, for the engine's own fixture apps in CI and nothing else. There is no isolation here: the
|
|
2
|
+
// app runs as the current user on the host. For that reason this runner refuses any repository that is not inside a fixture folder of
|
|
3
|
+
// this engine, and the command line never offers it. It is labelled unsafe in its name, in the result file and in the report.
|
|
4
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
5
|
+
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
|
|
6
|
+
import { createServer } from 'node:net';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, sep } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { executeSpec } from './executor.ts';
|
|
11
|
+
import { assertSandboxTarget } from './safety.ts';
|
|
12
|
+
import { validateSpec } from './spec.ts';
|
|
13
|
+
import type { Exec, Instance, PrepareRequest, PrepareResult, Prepared, Runner, SpecRun, StartResult, Teardown } from './runner.ts';
|
|
14
|
+
|
|
15
|
+
const ENGINE = fileURLToPath(new URL('../../', import.meta.url));
|
|
16
|
+
export const FIXTURE_ROOTS = [join(ENGINE, 'test/fixtures/prove'), join(ENGINE, 'bench/fixtures')];
|
|
17
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
|
|
19
|
+
const freePort = (): Promise<number> => new Promise((res, rej) => { const s = createServer(); s.listen(0, '127.0.0.1', () => { const p = (s.address() as { port: number }).port; s.close(() => res(p)); }); s.on('error', rej); });
|
|
20
|
+
const inside = (p: string, root: string): boolean => p === root || p.startsWith(root + sep);
|
|
21
|
+
|
|
22
|
+
export class LocalProcessRunner implements Runner {
|
|
23
|
+
readonly name = 'local-process-unsafe';
|
|
24
|
+
private readonly roots: string[];
|
|
25
|
+
constructor(o: { acknowledgeUnsafe: true; fixtureRoots?: string[] }) {
|
|
26
|
+
if (o.acknowledgeUnsafe !== true) throw new Error('the local process runner is unsafe and must be created with acknowledgeUnsafe: true');
|
|
27
|
+
this.roots = (o.fixtureRoots ?? FIXTURE_ROOTS).map((r) => { try { return realpathSync(r); } catch { return r; } });
|
|
28
|
+
}
|
|
29
|
+
async available(): Promise<{ ok: true }> { return { ok: true }; }
|
|
30
|
+
|
|
31
|
+
async prepare(req: PrepareRequest): Promise<PrepareResult> {
|
|
32
|
+
let src: string;
|
|
33
|
+
try { src = realpathSync(req.sourceDir); } catch { return { ok: false, code: 'refused_unsafe_backend', reason: 'the source folder does not exist' }; }
|
|
34
|
+
if (!this.roots.some((r) => inside(src, r))) return { ok: false, code: 'refused_unsafe_backend', reason: 'the local process backend has no isolation, so it only runs the fixture apps of this engine and refuses any other folder' };
|
|
35
|
+
if (req.recipe.install) return { ok: false, code: 'refused_unsafe_backend', reason: 'the local process backend installs nothing' };
|
|
36
|
+
if (req.recipe.data_store.kind === 'postgres') return { ok: false, code: 'refused_unsafe_backend', reason: 'the local process backend has no Postgres' };
|
|
37
|
+
return { ok: true, prepared: new LocalPrepared(req) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class LocalPrepared implements Prepared {
|
|
42
|
+
private readonly req: PrepareRequest;
|
|
43
|
+
private readonly live = new Set<LocalInstance>();
|
|
44
|
+
constructor(req: PrepareRequest) { this.req = req; }
|
|
45
|
+
|
|
46
|
+
// A free port is chosen first and the app binds it a moment later, so another process can take it in between. Start again on a new
|
|
47
|
+
// port, twice at most, when the app exits before it answers.
|
|
48
|
+
async start(): Promise<StartResult> {
|
|
49
|
+
let last = await this.startOnce();
|
|
50
|
+
for (let i = 0; i < 2 && !last.ok && last.code === 'start_failed' && this.req.recipe.port_from_env; i++) last = await this.startOnce();
|
|
51
|
+
return last;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async startOnce(): Promise<StartResult> {
|
|
55
|
+
const { recipe, copyDir, limits } = this.req;
|
|
56
|
+
const data = mkdtempSync(join(tmpdir(), 'hp-local-data-'));
|
|
57
|
+
const port = recipe.port_from_env ? await freePort() : recipe.port;
|
|
58
|
+
const env: Record<string, string> = { PATH: process.env.PATH ?? '', HOME: data, TMPDIR: data };
|
|
59
|
+
for (const [k, v] of Object.entries(recipe.env)) env[k] = v.replace(/\{DATA\}/g, data);
|
|
60
|
+
if (recipe.port_from_env) env.PORT = String(port);
|
|
61
|
+
let log = '';
|
|
62
|
+
const child = spawn('sh', ['-c', `${recipe.seed ? recipe.seed + ' && ' : ''}exec ${recipe.start}`], { cwd: copyDir, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
63
|
+
child.stdout.on('data', (d: Buffer) => { log = (log + d.toString()).slice(-2000); });
|
|
64
|
+
child.stderr.on('data', (d: Buffer) => { log = (log + d.toString()).slice(-2000); });
|
|
65
|
+
let exited = false;
|
|
66
|
+
child.on('exit', () => { exited = true; });
|
|
67
|
+
const inst = new LocalInstance(child, data, port);
|
|
68
|
+
this.live.add(inst);
|
|
69
|
+
const deadline = Date.now() + limits.healthMs;
|
|
70
|
+
while (Date.now() < deadline && !exited) {
|
|
71
|
+
try { const r = await fetch(`http://127.0.0.1:${port}${recipe.health_path}`, { signal: AbortSignal.timeout(1500) }); if (r.status < 500) return { ok: true, instance: inst }; } catch { /* not up yet */ }
|
|
72
|
+
await sleep(100);
|
|
73
|
+
}
|
|
74
|
+
await inst.stop();
|
|
75
|
+
return { ok: false, code: exited ? 'start_failed' : 'health_failed', reason: `${exited ? 'the app exited before it answered' : 'the app did not answer in time'}: ${log.trim().replace(/\s+/g, ' ').slice(-300)}` };
|
|
76
|
+
}
|
|
77
|
+
async dispose(): Promise<Teardown> { for (const i of this.live) await i.stop(); return { left_behind: [...this.live].flatMap((i) => i.alive() ? [`process ${i.pid}`] : []) }; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class LocalInstance implements Instance {
|
|
81
|
+
readonly baseUrl: string;
|
|
82
|
+
readonly pid: number;
|
|
83
|
+
private readonly child: ChildProcess;
|
|
84
|
+
private readonly data: string;
|
|
85
|
+
private readonly port: number;
|
|
86
|
+
constructor(child: ChildProcess, data: string, port: number) { this.child = child; this.data = data; this.port = port; this.pid = child.pid ?? 0; this.baseUrl = `http://127.0.0.1:${port}`; }
|
|
87
|
+
alive(): boolean { try { process.kill(this.pid, 0); return true; } catch { return false; } }
|
|
88
|
+
async exec(): Promise<Exec> { return { code: null, stdout: '', stderr: 'the local process backend does not run commands', timedOut: false }; }
|
|
89
|
+
async runSpec(spec: import('../types/index.ts').Spec): Promise<SpecRun> {
|
|
90
|
+
const v = validateSpec(spec);
|
|
91
|
+
if (!v.ok) return { ok: false, reason: `invalid spec: ${v.errors.join('; ')}` };
|
|
92
|
+
try { assertSandboxTarget(this.baseUrl, this.port); return { ok: true, ...(await executeSpec(v.spec, { baseUrl: this.baseUrl })) }; } catch (e) { return { ok: false, reason: (e as Error).message }; }
|
|
93
|
+
}
|
|
94
|
+
async stop(): Promise<Teardown> {
|
|
95
|
+
try { process.kill(-this.pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
96
|
+
for (let i = 0; i < 20 && this.alive(); i++) await sleep(25);
|
|
97
|
+
rmSync(this.data, { recursive: true, force: true });
|
|
98
|
+
return { left_behind: this.alive() ? [`process ${this.pid}`] : [] };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Escape attempts. Each probe is a short script run inside a started sandbox instance with the same limits as the app. The sandbox
|
|
2
|
+
// has held when every probe reports ok. They are the exit test of step 4a and `hullproof prove --self-test` runs them on demand.
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import type { Recipe } from '../types/index.ts';
|
|
8
|
+
import type { Instance } from './runner.ts';
|
|
9
|
+
|
|
10
|
+
export interface ProbeResult { name: string; ok: boolean; detail: string }
|
|
11
|
+
|
|
12
|
+
const NET = `
|
|
13
|
+
const net=require('net'),dns=require('dns');
|
|
14
|
+
const conn=(h,p)=>new Promise(r=>{const s=net.connect({host:h,port:p,timeout:3000});s.on('connect',()=>{s.destroy();r(true)});s.on('error',()=>r(false));s.on('timeout',()=>{s.destroy();r(false)})});
|
|
15
|
+
(async()=>{const o={};o.tcp_cloudflare=await conn('1.1.1.1',443);o.tcp_google=await conn('8.8.8.8',53);o.dns=await new Promise(r=>dns.lookup('example.com',e=>r(!e)));
|
|
16
|
+
o.fetch=await fetch('http://example.com',{signal:AbortSignal.timeout(3000)}).then(()=>true,()=>false);console.log(JSON.stringify(o));})()`;
|
|
17
|
+
const FILES = `
|
|
18
|
+
const fs=require('fs');const out={};
|
|
19
|
+
for(const p of [process.argv[1],'/Users','/host','/mnt/host','/var/run/docker.sock','/run/docker.sock','/root/.ssh']){try{fs.readFileSync(p);out[p]='READ';}catch(e){try{const l=fs.readdirSync(p);out[p]='LISTED:'+l.length;}catch(e2){out[p]=e2.code;}}}
|
|
20
|
+
console.log(JSON.stringify(out))`;
|
|
21
|
+
const WRITES = `
|
|
22
|
+
const fs=require('fs');const out={};
|
|
23
|
+
for(const p of ['/app/hp_probe','/etc/hp_probe','/usr/hp_probe','/hp_probe','/tmp/hp_probe']){try{fs.writeFileSync(p,'x');out[p]='WROTE';}catch(e){out[p]=e.code;}}
|
|
24
|
+
out.uid=process.getuid();console.log(JSON.stringify(out))`;
|
|
25
|
+
const FORK = `
|
|
26
|
+
const {spawn}=require('child_process'),fs=require('fs');let refused=0,peak=0;const kids=[];
|
|
27
|
+
const rd=(f)=>{try{return fs.readFileSync(f,'utf8').trim()}catch{return null}};
|
|
28
|
+
const cur=()=>Number(rd('/sys/fs/cgroup/pids.current')||rd('/sys/fs/cgroup/pids/pids.current')||0);
|
|
29
|
+
const max=rd('/sys/fs/cgroup/pids.max')||rd('/sys/fs/cgroup/pids/pids.max');
|
|
30
|
+
const go=async()=>{for(let i=0;i<400;i++){const k=spawn(process.execPath,['-e','setTimeout(()=>{},60000)'],{stdio:'ignore'});k.on('error',()=>refused++);kids.push(k);peak=Math.max(peak,cur());await new Promise(r=>setTimeout(r,8));}
|
|
31
|
+
await new Promise(r=>setTimeout(r,800));console.log(JSON.stringify({attempted:400,refused,peak_processes:peak,limit:max}));kids.forEach(k=>{try{k.kill('SIGKILL')}catch{}});process.exit(0);};go()`;
|
|
32
|
+
const MEM = `const a=[];try{for(let i=0;i<64;i++)a.push(Buffer.alloc(64*1024*1024,1));console.log('ALLOCATED');}catch(e){console.log('REFUSED');}`;
|
|
33
|
+
|
|
34
|
+
const j = (s: string): Record<string, unknown> => { try { return JSON.parse(s.trim().split('\n').pop() ?? '{}') as Record<string, unknown>; } catch { return {}; } };
|
|
35
|
+
|
|
36
|
+
export async function runEscapeProbes(inst: Instance, hostMarker: string): Promise<ProbeResult[]> {
|
|
37
|
+
const out: ProbeResult[] = [];
|
|
38
|
+
const net = j((await inst.exec(['node', '-e', NET], { timeoutMs: 30_000 })).stdout);
|
|
39
|
+
out.push({ name: 'network call to the internet', ok: Object.keys(net).length === 4 && Object.values(net).every((v) => v === false), detail: JSON.stringify(net) });
|
|
40
|
+
const files = j((await inst.exec(['node', '-e', FILES, hostMarker], { timeoutMs: 15_000 })).stdout);
|
|
41
|
+
out.push({ name: 'host file read', ok: Object.keys(files).length > 0 && Object.values(files).every((v) => v === 'ENOENT' || v === 'EACCES'), detail: JSON.stringify(files) });
|
|
42
|
+
const w = j((await inst.exec(['node', '-e', WRITES], { timeoutMs: 15_000 })).stdout);
|
|
43
|
+
const ro = ['/app/hp_probe', '/etc/hp_probe', '/usr/hp_probe', '/hp_probe'].every((p) => w[p] === 'EROFS' || w[p] === 'EACCES');
|
|
44
|
+
out.push({ name: 'write to the root filesystem, and run as root', ok: ro && typeof w.uid === 'number' && w.uid !== 0, detail: JSON.stringify(w) });
|
|
45
|
+
const fork = j((await inst.exec(['node', '-e', FORK], { timeoutMs: 40_000 })).stdout);
|
|
46
|
+
const alive = (await inst.exec(['node', '-e', 'console.log(1)'], { timeoutMs: 15_000 })).code === 0;
|
|
47
|
+
out.push({ name: 'fork bomb', ok: typeof fork.peak_processes === 'number' && fork.peak_processes > 0 && fork.peak_processes <= Number(fork.limit) && alive, detail: `${JSON.stringify(fork)}; sandbox still answers: ${alive}` });
|
|
48
|
+
const t0 = Date.now();
|
|
49
|
+
const loop = await inst.exec(['node', '-e', 'while(true){}'], { timeoutMs: 3000 });
|
|
50
|
+
const after = (await inst.exec(['node', '-e', 'console.log(1)'], { timeoutMs: 15_000 })).code === 0;
|
|
51
|
+
out.push({ name: 'long loop', ok: (loop.timedOut || loop.code === 137) && Date.now() - t0 < 30_000 && after, detail: `ended after ${Date.now() - t0} ms with code ${loop.code}; sandbox still answers: ${after}` });
|
|
52
|
+
const mem = await inst.exec(['node', '-e', MEM], { timeoutMs: 30_000 });
|
|
53
|
+
out.push({ name: 'memory limit', ok: !mem.stdout.includes('ALLOCATED') && (await inst.exec(['node', '-e', 'console.log(1)'], { timeoutMs: 15_000 })).code === 0, detail: `exit code ${mem.code}` });
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A small app and a recipe for `hullproof prove --self-test`: it needs nothing installed and reads nothing from the user's repository.
|
|
58
|
+
export function selfTestApp(): { dir: string; recipe: Recipe; marker: string; cleanup(): void } {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), 'hp-selftest-'));
|
|
60
|
+
const outside = mkdtempSync(join(tmpdir(), 'hp-marker-'));
|
|
61
|
+
const marker = join(outside, `secret-${randomBytes(4).toString('hex')}.txt`);
|
|
62
|
+
writeFileSync(marker, 'HOST-SECRET-MARKER');
|
|
63
|
+
mkdirSync(join(dir, 'app'), { recursive: true });
|
|
64
|
+
writeFileSync(join(dir, 'server.js'), "require('http').createServer((q,s)=>{s.setHeader('content-type','application/json');s.end(JSON.stringify({ok:true}))}).listen(3000);\n");
|
|
65
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"hp-selftest","version":"0.0.0","main":"server.js"}\n');
|
|
66
|
+
const recipe: Recipe = { schema_version: '1.0.0', stack: 'node-express', base_image: 'node:22-slim', install: null, vendored_modules: false, seed: null, start: 'node server.js', port: 3000, port_from_env: false, health_path: '/', data_store: { kind: 'none' }, env: {}, notes: [] };
|
|
67
|
+
return { dir, recipe, marker, cleanup: () => { rmSync(dir, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); } };
|
|
68
|
+
}
|