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,149 @@
|
|
|
1
|
+
import { canon, evalAssertion, fill, fillJson, pathGet, SECRET_NAME, sha256 } from './spec.js';
|
|
2
|
+
export const LIMITS = { requestMs: 8000, bodyChars: 16384, textStore: 4096 };
|
|
3
|
+
const HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
4
|
+
// The one place an address is accepted. Anything that is not the sandbox's own loopback is refused before a request is built.
|
|
5
|
+
export function loopbackBase(url) {
|
|
6
|
+
let u;
|
|
7
|
+
try {
|
|
8
|
+
u = new URL(url);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error(`refused: ${JSON.stringify(url).slice(0, 80)} is not an address`);
|
|
12
|
+
}
|
|
13
|
+
if (u.protocol !== 'http:' || !HOSTS.has(u.hostname) || u.username || u.password || !u.port || u.pathname !== '/' || u.search || u.hash) {
|
|
14
|
+
throw new Error(`refused: ${u.origin} is not a loopback address of the sandbox (http://127.0.0.1:PORT only)`);
|
|
15
|
+
}
|
|
16
|
+
return u;
|
|
17
|
+
}
|
|
18
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*/g;
|
|
19
|
+
const LONG = /\b[A-Fa-f0-9]{40,}\b|\b[A-Za-z0-9_]{32,}\b/g;
|
|
20
|
+
export const redactText = (s) => s.replace(JWT, '[redacted]').replace(LONG, '[redacted]');
|
|
21
|
+
export function redactJson(v, key = '') {
|
|
22
|
+
if (SECRET_NAME.test(key) && v !== null && typeof v !== 'object')
|
|
23
|
+
return '[redacted]';
|
|
24
|
+
if (typeof v === 'string')
|
|
25
|
+
return redactText(v);
|
|
26
|
+
if (Array.isArray(v))
|
|
27
|
+
return v.map((x) => redactJson(x, key));
|
|
28
|
+
if (v && typeof v === 'object')
|
|
29
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, redactJson(x, k)]));
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
// What is kept of a response header: a short allow list. No cookie, no authorization, nothing that could hold a credential.
|
|
33
|
+
const KEEP = new Set(['content-type', 'location', 'cache-control', 'www-authenticate', 'access-control-allow-origin', 'x-content-type-options']);
|
|
34
|
+
function judgeStep(spec, s, rec, vars, secrets) {
|
|
35
|
+
rec.assertions = rec.response ? s.assert.map((a) => evalAssertion(rec.response, a, vars, secrets)) : [];
|
|
36
|
+
}
|
|
37
|
+
// One verdict function for the live run and for a replay. Reads only the stored records.
|
|
38
|
+
export function judge(spec, recs) {
|
|
39
|
+
const vars = {};
|
|
40
|
+
const secrets = new Set();
|
|
41
|
+
const out = [];
|
|
42
|
+
for (let i = 0; i < recs.length; i++) {
|
|
43
|
+
const s = spec.steps[i];
|
|
44
|
+
const rec = recs[i];
|
|
45
|
+
if (!s || rec.id !== s.id)
|
|
46
|
+
return { done: true, outcome: 'error', detail: `the stored step ${i + 1} does not match the spec`, recs: out };
|
|
47
|
+
for (const c of s.captures ?? [])
|
|
48
|
+
if (SECRET_NAME.test(c.name) || SECRET_NAME.test(c.path ?? ''))
|
|
49
|
+
secrets.add(c.name);
|
|
50
|
+
const r = { ...rec, assertions: [...rec.assertions] };
|
|
51
|
+
judgeStep(spec, s, r, vars, secrets);
|
|
52
|
+
out.push(r);
|
|
53
|
+
Object.assign(vars, rec.captures);
|
|
54
|
+
const failed = r.assertions.find((a) => !a.ok);
|
|
55
|
+
if (rec.error && !(failed && s.phase !== 'attack'))
|
|
56
|
+
return { done: true, outcome: 'error', detail: `step ${s.id}: ${rec.error}`, recs: out };
|
|
57
|
+
if (s.phase === 'setup' && failed)
|
|
58
|
+
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)
|
|
60
|
+
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 };
|
|
61
|
+
if (s.phase === 'attack' && failed)
|
|
62
|
+
return { done: true, outcome: 'not_proven', detail: `the app refused as it should at step ${s.id} (${failed.desc} did not hold)`, recs: out };
|
|
63
|
+
}
|
|
64
|
+
if (recs.length === spec.steps.length)
|
|
65
|
+
return { done: true, outcome: 'proven', detail: 'the positive control passed and every attack assertion held', recs: out };
|
|
66
|
+
return { done: false, outcome: null, detail: '', recs: out };
|
|
67
|
+
}
|
|
68
|
+
const parseJson = (t) => { try {
|
|
69
|
+
return JSON.parse(t);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
} };
|
|
74
|
+
// Runs the steps in order and stops as soon as the verdict is known. Returns the redacted records and the verdict.
|
|
75
|
+
export async function executeSpec(spec, o) {
|
|
76
|
+
const base = loopbackBase(o.baseUrl);
|
|
77
|
+
const f = o.fetchFn ?? fetch;
|
|
78
|
+
const vars = {};
|
|
79
|
+
const secrets = new Set();
|
|
80
|
+
const recs = [];
|
|
81
|
+
for (const s of spec.steps) {
|
|
82
|
+
for (const c of s.captures ?? [])
|
|
83
|
+
if (SECRET_NAME.test(c.name) || SECRET_NAME.test(c.path ?? ''))
|
|
84
|
+
secrets.add(c.name);
|
|
85
|
+
const rec = { 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: [] };
|
|
86
|
+
try {
|
|
87
|
+
const path = fill(s.path, vars, secrets, false);
|
|
88
|
+
if (!path.startsWith('/') || path.startsWith('//'))
|
|
89
|
+
throw new Error('the path became something other than a path on the app');
|
|
90
|
+
// The actor's headers apply once every variable in them has been captured (an actor that is not signed in yet sends none).
|
|
91
|
+
const headers = {};
|
|
92
|
+
for (const [k, v] of Object.entries(spec.actors[s.actor]?.headers ?? {})) {
|
|
93
|
+
try {
|
|
94
|
+
headers[k] = fill(v, vars, secrets, false);
|
|
95
|
+
}
|
|
96
|
+
catch { /* not signed in yet */ }
|
|
97
|
+
}
|
|
98
|
+
for (const [k, v] of Object.entries(s.headers ?? {}))
|
|
99
|
+
headers[k] = fill(v, vars, secrets, false);
|
|
100
|
+
const body = s.body ? fillJson(s.body, vars, secrets, false) : null;
|
|
101
|
+
if (body && !headers['content-type'])
|
|
102
|
+
headers['content-type'] = 'application/json';
|
|
103
|
+
rec.request = {
|
|
104
|
+
method: s.method, path: fill(s.path, vars, secrets, true),
|
|
105
|
+
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))),
|
|
106
|
+
body: s.body ? redactJson(fillJson(s.body, vars, secrets, true)) : null,
|
|
107
|
+
};
|
|
108
|
+
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) });
|
|
109
|
+
const full = (await res.text()).slice(0, LIMITS.bodyChars + 1);
|
|
110
|
+
const truncated = full.length > LIMITS.bodyChars;
|
|
111
|
+
const text = full.slice(0, LIMITS.bodyChars);
|
|
112
|
+
const rawJson = truncated ? undefined : parseJson(text);
|
|
113
|
+
const rh = {};
|
|
114
|
+
res.headers.forEach((v, k) => { if (KEEP.has(k.toLowerCase()))
|
|
115
|
+
rh[k.toLowerCase()] = redactText(v); });
|
|
116
|
+
rec.response = {
|
|
117
|
+
status: res.status, headers: rh, truncated,
|
|
118
|
+
json: rawJson === undefined ? undefined : redactJson(rawJson),
|
|
119
|
+
text: rawJson === undefined ? redactText(text).slice(0, LIMITS.textStore) : undefined,
|
|
120
|
+
};
|
|
121
|
+
// Captures read the raw response. What is stored is redacted. A capture that finds nothing is an error, but a failed assertion
|
|
122
|
+
// on the same step is reported first (a failed sign in is an invalid setup, not a broken run).
|
|
123
|
+
for (const c of s.captures ?? []) {
|
|
124
|
+
const v = c.from === 'status' ? res.status : c.from === 'header' ? (res.headers.get(c.path) ?? undefined) : pathGet(rawJson, c.path);
|
|
125
|
+
if (v === undefined || (typeof v === 'object' && v !== null)) {
|
|
126
|
+
rec.error ??= `capture ${c.name} found nothing at ${c.from} ${c.path ?? ''}`.trim();
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
vars[c.name] = v;
|
|
130
|
+
rec.captures[c.name] = secrets.has(c.name) ? '[redacted]' : redactJson(v);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
rec.error = e.name === 'TimeoutError' ? 'the request timed out' : e.message.slice(0, 200);
|
|
135
|
+
}
|
|
136
|
+
recs.push(rec);
|
|
137
|
+
const j = judge(spec, recs);
|
|
138
|
+
recs[recs.length - 1] = j.recs[j.recs.length - 1];
|
|
139
|
+
if (j.done)
|
|
140
|
+
return { outcome: j.outcome, detail: j.detail, steps: recs };
|
|
141
|
+
}
|
|
142
|
+
const j = judge(spec, recs);
|
|
143
|
+
return { outcome: j.outcome ?? 'error', detail: j.detail, steps: recs };
|
|
144
|
+
}
|
|
145
|
+
// The record stored for one run. The hash covers every other field.
|
|
146
|
+
export function runRecord(spec, specSha, run, r) {
|
|
147
|
+
const body = { schema_version: '1.0.0', spec_id: spec.spec_id, spec_sha256: specSha, run, outcome: r.outcome, detail: r.detail, steps: r.steps };
|
|
148
|
+
return { ...body, transcript_sha256: sha256(canon(body)) };
|
|
149
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
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 } 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.js';
|
|
11
|
+
import { assertSandboxTarget } from './safety.js';
|
|
12
|
+
import { validateSpec } from './spec.js';
|
|
13
|
+
const ENGINE = fileURLToPath(new URL('../../', import.meta.url));
|
|
14
|
+
export const FIXTURE_ROOTS = [join(ENGINE, 'test/fixtures/prove'), join(ENGINE, 'bench/fixtures')];
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
const freePort = () => new Promise((res, rej) => { const s = createServer(); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); s.on('error', rej); });
|
|
17
|
+
const inside = (p, root) => p === root || p.startsWith(root + sep);
|
|
18
|
+
export class LocalProcessRunner {
|
|
19
|
+
name = 'local-process-unsafe';
|
|
20
|
+
roots;
|
|
21
|
+
constructor(o) {
|
|
22
|
+
if (o.acknowledgeUnsafe !== true)
|
|
23
|
+
throw new Error('the local process runner is unsafe and must be created with acknowledgeUnsafe: true');
|
|
24
|
+
this.roots = (o.fixtureRoots ?? FIXTURE_ROOTS).map((r) => { try {
|
|
25
|
+
return realpathSync(r);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return r;
|
|
29
|
+
} });
|
|
30
|
+
}
|
|
31
|
+
async available() { return { ok: true }; }
|
|
32
|
+
async prepare(req) {
|
|
33
|
+
let src;
|
|
34
|
+
try {
|
|
35
|
+
src = realpathSync(req.sourceDir);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { ok: false, code: 'refused_unsafe_backend', reason: 'the source folder does not exist' };
|
|
39
|
+
}
|
|
40
|
+
if (!this.roots.some((r) => inside(src, r)))
|
|
41
|
+
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' };
|
|
42
|
+
if (req.recipe.install)
|
|
43
|
+
return { ok: false, code: 'refused_unsafe_backend', reason: 'the local process backend installs nothing' };
|
|
44
|
+
if (req.recipe.data_store.kind === 'postgres')
|
|
45
|
+
return { ok: false, code: 'refused_unsafe_backend', reason: 'the local process backend has no Postgres' };
|
|
46
|
+
return { ok: true, prepared: new LocalPrepared(req) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
class LocalPrepared {
|
|
50
|
+
req;
|
|
51
|
+
live = new Set();
|
|
52
|
+
constructor(req) { this.req = req; }
|
|
53
|
+
// 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
|
|
54
|
+
// port, twice at most, when the app exits before it answers.
|
|
55
|
+
async start() {
|
|
56
|
+
let last = await this.startOnce();
|
|
57
|
+
for (let i = 0; i < 2 && !last.ok && last.code === 'start_failed' && this.req.recipe.port_from_env; i++)
|
|
58
|
+
last = await this.startOnce();
|
|
59
|
+
return last;
|
|
60
|
+
}
|
|
61
|
+
async startOnce() {
|
|
62
|
+
const { recipe, copyDir, limits } = this.req;
|
|
63
|
+
const data = mkdtempSync(join(tmpdir(), 'hp-local-data-'));
|
|
64
|
+
const port = recipe.port_from_env ? await freePort() : recipe.port;
|
|
65
|
+
const env = { PATH: process.env.PATH ?? '', HOME: data, TMPDIR: data };
|
|
66
|
+
for (const [k, v] of Object.entries(recipe.env))
|
|
67
|
+
env[k] = v.replace(/\{DATA\}/g, data);
|
|
68
|
+
if (recipe.port_from_env)
|
|
69
|
+
env.PORT = String(port);
|
|
70
|
+
let log = '';
|
|
71
|
+
const child = spawn('sh', ['-c', `${recipe.seed ? recipe.seed + ' && ' : ''}exec ${recipe.start}`], { cwd: copyDir, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
72
|
+
child.stdout.on('data', (d) => { log = (log + d.toString()).slice(-2000); });
|
|
73
|
+
child.stderr.on('data', (d) => { log = (log + d.toString()).slice(-2000); });
|
|
74
|
+
let exited = false;
|
|
75
|
+
child.on('exit', () => { exited = true; });
|
|
76
|
+
const inst = new LocalInstance(child, data, port);
|
|
77
|
+
this.live.add(inst);
|
|
78
|
+
const deadline = Date.now() + limits.healthMs;
|
|
79
|
+
while (Date.now() < deadline && !exited) {
|
|
80
|
+
try {
|
|
81
|
+
const r = await fetch(`http://127.0.0.1:${port}${recipe.health_path}`, { signal: AbortSignal.timeout(1500) });
|
|
82
|
+
if (r.status < 500)
|
|
83
|
+
return { ok: true, instance: inst };
|
|
84
|
+
}
|
|
85
|
+
catch { /* not up yet */ }
|
|
86
|
+
await sleep(100);
|
|
87
|
+
}
|
|
88
|
+
await inst.stop();
|
|
89
|
+
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)}` };
|
|
90
|
+
}
|
|
91
|
+
async dispose() { for (const i of this.live)
|
|
92
|
+
await i.stop(); return { left_behind: [...this.live].flatMap((i) => i.alive() ? [`process ${i.pid}`] : []) }; }
|
|
93
|
+
}
|
|
94
|
+
class LocalInstance {
|
|
95
|
+
baseUrl;
|
|
96
|
+
pid;
|
|
97
|
+
child;
|
|
98
|
+
data;
|
|
99
|
+
port;
|
|
100
|
+
constructor(child, data, port) { this.child = child; this.data = data; this.port = port; this.pid = child.pid ?? 0; this.baseUrl = `http://127.0.0.1:${port}`; }
|
|
101
|
+
alive() { try {
|
|
102
|
+
process.kill(this.pid, 0);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
} }
|
|
108
|
+
async exec() { return { code: null, stdout: '', stderr: 'the local process backend does not run commands', timedOut: false }; }
|
|
109
|
+
async runSpec(spec) {
|
|
110
|
+
const v = validateSpec(spec);
|
|
111
|
+
if (!v.ok)
|
|
112
|
+
return { ok: false, reason: `invalid spec: ${v.errors.join('; ')}` };
|
|
113
|
+
try {
|
|
114
|
+
assertSandboxTarget(this.baseUrl, this.port);
|
|
115
|
+
return { ok: true, ...(await executeSpec(v.spec, { baseUrl: this.baseUrl })) };
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return { ok: false, reason: e.message };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async stop() {
|
|
122
|
+
try {
|
|
123
|
+
process.kill(-this.pid, 'SIGKILL');
|
|
124
|
+
}
|
|
125
|
+
catch { /* already gone */ }
|
|
126
|
+
for (let i = 0; i < 20 && this.alive(); i++)
|
|
127
|
+
await sleep(25);
|
|
128
|
+
rmSync(this.data, { recursive: true, force: true });
|
|
129
|
+
return { left_behind: this.alive() ? [`process ${this.pid}`] : [] };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
const NET = `
|
|
8
|
+
const net=require('net'),dns=require('dns');
|
|
9
|
+
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)})});
|
|
10
|
+
(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)));
|
|
11
|
+
o.fetch=await fetch('http://example.com',{signal:AbortSignal.timeout(3000)}).then(()=>true,()=>false);console.log(JSON.stringify(o));})()`;
|
|
12
|
+
const FILES = `
|
|
13
|
+
const fs=require('fs');const out={};
|
|
14
|
+
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;}}}
|
|
15
|
+
console.log(JSON.stringify(out))`;
|
|
16
|
+
const WRITES = `
|
|
17
|
+
const fs=require('fs');const out={};
|
|
18
|
+
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;}}
|
|
19
|
+
out.uid=process.getuid();console.log(JSON.stringify(out))`;
|
|
20
|
+
const FORK = `
|
|
21
|
+
const {spawn}=require('child_process'),fs=require('fs');let refused=0,peak=0;const kids=[];
|
|
22
|
+
const rd=(f)=>{try{return fs.readFileSync(f,'utf8').trim()}catch{return null}};
|
|
23
|
+
const cur=()=>Number(rd('/sys/fs/cgroup/pids.current')||rd('/sys/fs/cgroup/pids/pids.current')||0);
|
|
24
|
+
const max=rd('/sys/fs/cgroup/pids.max')||rd('/sys/fs/cgroup/pids/pids.max');
|
|
25
|
+
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));}
|
|
26
|
+
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()`;
|
|
27
|
+
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');}`;
|
|
28
|
+
const j = (s) => { try {
|
|
29
|
+
return JSON.parse(s.trim().split('\n').pop() ?? '{}');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
} };
|
|
34
|
+
export async function runEscapeProbes(inst, hostMarker) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const net = j((await inst.exec(['node', '-e', NET], { timeoutMs: 30_000 })).stdout);
|
|
37
|
+
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) });
|
|
38
|
+
const files = j((await inst.exec(['node', '-e', FILES, hostMarker], { timeoutMs: 15_000 })).stdout);
|
|
39
|
+
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) });
|
|
40
|
+
const w = j((await inst.exec(['node', '-e', WRITES], { timeoutMs: 15_000 })).stdout);
|
|
41
|
+
const ro = ['/app/hp_probe', '/etc/hp_probe', '/usr/hp_probe', '/hp_probe'].every((p) => w[p] === 'EROFS' || w[p] === 'EACCES');
|
|
42
|
+
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) });
|
|
43
|
+
const fork = j((await inst.exec(['node', '-e', FORK], { timeoutMs: 40_000 })).stdout);
|
|
44
|
+
const alive = (await inst.exec(['node', '-e', 'console.log(1)'], { timeoutMs: 15_000 })).code === 0;
|
|
45
|
+
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}` });
|
|
46
|
+
const t0 = Date.now();
|
|
47
|
+
const loop = await inst.exec(['node', '-e', 'while(true){}'], { timeoutMs: 3000 });
|
|
48
|
+
const after = (await inst.exec(['node', '-e', 'console.log(1)'], { timeoutMs: 15_000 })).code === 0;
|
|
49
|
+
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}` });
|
|
50
|
+
const mem = await inst.exec(['node', '-e', MEM], { timeoutMs: 30_000 });
|
|
51
|
+
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}` });
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
// A small app and a recipe for `hullproof prove --self-test`: it needs nothing installed and reads nothing from the user's repository.
|
|
55
|
+
export function selfTestApp() {
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), 'hp-selftest-'));
|
|
57
|
+
const outside = mkdtempSync(join(tmpdir(), 'hp-marker-'));
|
|
58
|
+
const marker = join(outside, `secret-${randomBytes(4).toString('hex')}.txt`);
|
|
59
|
+
writeFileSync(marker, 'HOST-SECRET-MARKER');
|
|
60
|
+
mkdirSync(join(dir, 'app'), { recursive: true });
|
|
61
|
+
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");
|
|
62
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"hp-selftest","version":"0.0.0","main":"server.js"}\n');
|
|
63
|
+
const 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: [] };
|
|
64
|
+
return { dir, recipe, marker, cleanup: () => { rmSync(dir, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); } };
|
|
65
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Start recipes: how to build and start an app in the sandbox, read from stack.json and the code map and from the repository itself.
|
|
2
|
+
// Two recipes: a Node app on Express with a local SQLite file or an ephemeral Postgres, and (Phase 6d) a Python app on Flask with a
|
|
3
|
+
// local SQLite file. Anything else, or anything that needs a hosted service, comes back as "not provable here" with the reason. A
|
|
4
|
+
// recipe is never guessed.
|
|
5
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { cmp } from '../util.js';
|
|
8
|
+
import { dummyValue, envNames } from './safety.js';
|
|
9
|
+
import { canon, sha256 } from './spec.js';
|
|
10
|
+
export const BASE_IMAGE = 'node:22-slim';
|
|
11
|
+
export const PYTHON_IMAGE = 'python:3.12-slim';
|
|
12
|
+
const HOSTED_DATA = new Set(['supabase', 'firebase-realtime-database', 'firestore', 'dynamodb', 'azure-cosmos-db', 'neon', 'planetscale', 'turso', 'aws-rds', 'google-cloud-sql', 'azure-database-mysql', 'azure-database-postgres', 'azure-sql']);
|
|
13
|
+
const HOSTED_AUTH = new Set(['auth0', 'clerk', 'firebase-auth', 'supabase-auth', 'aws-cognito', 'kinde', 'workos', 'stytch', 'okta', 'microsoft-entra']);
|
|
14
|
+
const LOCAL_DATA = new Set(['sqlite', 'postgres']);
|
|
15
|
+
const NATIVE = ['better-sqlite3', 'sqlite3', 'bcrypt', 'sharp', 'canvas', 'argon2', 'node-sass', 'grpc', 'libsql'];
|
|
16
|
+
const DB_VARS = /^(?:DATABASE_URL|DB_PATH|DATABASE_PATH|SQLITE_PATH|SQLITE_FILE|DB_FILE|DATABASE_FILE)$/;
|
|
17
|
+
const HEALTH = ['/health', '/healthz', '/api/health', '/ping', '/status', '/'];
|
|
18
|
+
const nice = (id) => id.replace(/-/g, ' ');
|
|
19
|
+
const no = (code, reason) => ({ ok: false, code, reason });
|
|
20
|
+
const ids = (xs) => xs.map((x) => x.id);
|
|
21
|
+
function sourceFiles(repo, depth = 0, out = []) {
|
|
22
|
+
if (depth > 6)
|
|
23
|
+
return out;
|
|
24
|
+
for (const n of readdirSync(repo).sort(cmp)) {
|
|
25
|
+
if (n === 'node_modules' || n === '.git' || n === '.hullproof')
|
|
26
|
+
continue;
|
|
27
|
+
const p = join(repo, n);
|
|
28
|
+
const st = lstatSync(p);
|
|
29
|
+
if (st.isSymbolicLink())
|
|
30
|
+
continue;
|
|
31
|
+
if (st.isDirectory())
|
|
32
|
+
sourceFiles(p, depth + 1, out);
|
|
33
|
+
else if (/\.(?:[cm]?js|ts)$/.test(n) && st.size < 1_000_000)
|
|
34
|
+
out.push(p);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
// The port the app listens on: a number in listen(), or the number after process.env.PORT. Returns null when no listen() call is found.
|
|
39
|
+
export function detectPort(text) {
|
|
40
|
+
const m = /\.listen\(\s*([^,)\n]+)/.exec(text);
|
|
41
|
+
if (!m)
|
|
42
|
+
return null;
|
|
43
|
+
let expr = m[1].trim();
|
|
44
|
+
if (/^[A-Za-z_$][\w$]*$/.test(expr)) {
|
|
45
|
+
const d = new RegExp(`(?:const|let|var)\\s+${expr}\\s*=\\s*([^;\\n]+)`).exec(text);
|
|
46
|
+
if (d)
|
|
47
|
+
expr = d[1];
|
|
48
|
+
}
|
|
49
|
+
const fromEnv = /process\.env\.PORT|process\.env\[\s*['"]PORT['"]\s*\]/.test(expr);
|
|
50
|
+
const n = /\b(\d{2,5})\b/.exec(expr.replace(/process\.env\.PORT/g, ''));
|
|
51
|
+
if (n && Number(n[1]) <= 65535)
|
|
52
|
+
return { port: Number(n[1]), fromEnv };
|
|
53
|
+
return fromEnv ? { port: 3000, fromEnv } : null;
|
|
54
|
+
}
|
|
55
|
+
export function deriveRecipe(repo, stack, map, opts = {}) {
|
|
56
|
+
const hosted = [...stack.data_stores.filter((i) => HOSTED_DATA.has(i.id)), ...stack.auth.filter((i) => HOSTED_AUTH.has(i.id))];
|
|
57
|
+
if (hosted.length)
|
|
58
|
+
return no('hosted_service', `the app uses ${hosted.map((i) => nice(i.id)).join(' and ')}, a hosted service that cannot run locally and has no local stand in in this version. It is not faked.`);
|
|
59
|
+
const otherData = stack.data_stores.filter((i) => !LOCAL_DATA.has(i.id));
|
|
60
|
+
if (otherData.length)
|
|
61
|
+
return no('unsupported_datastore', `the app uses ${otherData.map((i) => nice(i.id)).join(' and ')}. Only a local SQLite file and an ephemeral Postgres are supported.`);
|
|
62
|
+
const fw = ids(stack.frameworks);
|
|
63
|
+
if (!fw.includes('express') && fw.includes('flask'))
|
|
64
|
+
return deriveFlask(repo, stack, map, opts);
|
|
65
|
+
if (!fw.includes('express'))
|
|
66
|
+
return no('unsupported_stack', `the proof sandbox has a recipe for Node on Express and for Python on Flask in this version${fw.length ? ` (found ${fw.slice(0, 4).join(', ')})` : ''}.`);
|
|
67
|
+
if (fw.includes('nextjs'))
|
|
68
|
+
return no('unsupported_stack', 'the app also uses Next.js, which needs a production build step that this version has no recipe for.');
|
|
69
|
+
const pjPath = join(repo, 'package.json');
|
|
70
|
+
if (!existsSync(pjPath))
|
|
71
|
+
return no('no_package_json', 'there is no package.json at the repository root.');
|
|
72
|
+
let pj;
|
|
73
|
+
try {
|
|
74
|
+
pj = JSON.parse(readFileSync(pjPath, 'utf8'));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return no('no_package_json', 'package.json cannot be read as JSON.');
|
|
78
|
+
}
|
|
79
|
+
const deps = { ...(pj.dependencies ?? {}), ...(pj.devDependencies ?? {}) };
|
|
80
|
+
const notes = [];
|
|
81
|
+
// Build phase: a lockfile install without install scripts, or nothing at all when the repository carries its own modules.
|
|
82
|
+
const hasDeps = Object.keys(deps).length > 0;
|
|
83
|
+
const native = NATIVE.filter((n) => n in deps);
|
|
84
|
+
let install = null;
|
|
85
|
+
let vendored = false;
|
|
86
|
+
if (hasDeps) {
|
|
87
|
+
if (native.length && !opts.allowInstallScripts)
|
|
88
|
+
return no('needs_install_scripts', `${native.join(', ')} needs an install script to build or download a native part, and the sandbox installs without install scripts so none can run. Pass --allow-install-scripts to let them run in the build container.`);
|
|
89
|
+
if (!existsSync(join(repo, 'package-lock.json')))
|
|
90
|
+
return no('no_lockfile', `there is no package-lock.json${existsSync(join(repo, 'yarn.lock')) || existsSync(join(repo, 'pnpm-lock.yaml')) ? ' (only npm lockfiles are installed in this version)' : ''}, and the sandbox installs from a lockfile only.`);
|
|
91
|
+
install = ['npm', 'ci', ...(opts.allowInstallScripts ? [] : ['--ignore-scripts']), '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org/'];
|
|
92
|
+
if (opts.allowInstallScripts)
|
|
93
|
+
notes.push('install scripts run inside the build container, which has network access to the package registry');
|
|
94
|
+
}
|
|
95
|
+
else if (existsSync(join(repo, 'node_modules'))) {
|
|
96
|
+
vendored = true;
|
|
97
|
+
notes.push('the repository carries its own node_modules and has no dependencies to install, so the build phase needs no network');
|
|
98
|
+
}
|
|
99
|
+
const start = pj.scripts?.start ? 'npm start' : pj.main && existsSync(join(repo, pj.main)) ? `node ${pj.main}` : ['server.js', 'index.js', 'app.js', 'src/server.js', 'src/index.js'].find((f) => existsSync(join(repo, f))) ? `node ${['server.js', 'index.js', 'app.js', 'src/server.js', 'src/index.js'].find((f) => existsSync(join(repo, f)))}` : null;
|
|
100
|
+
if (!start)
|
|
101
|
+
return no('no_start_command', 'there is no start script, no main file and no server.js, index.js or app.js.');
|
|
102
|
+
let port = null;
|
|
103
|
+
for (const f of sourceFiles(repo)) {
|
|
104
|
+
port = detectPort(readFileSync(f, 'utf8'));
|
|
105
|
+
if (port)
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (!port)
|
|
109
|
+
return no('port_unknown', 'no listen() call with a port was found in the source.');
|
|
110
|
+
const names = envNames(repo);
|
|
111
|
+
const env = {};
|
|
112
|
+
if (port.fromEnv)
|
|
113
|
+
env.PORT = String(port.port);
|
|
114
|
+
const sqliteUsed = stack.data_stores.some((i) => i.id === 'sqlite') || sourceFiles(repo).some((f) => /node:sqlite|better-sqlite3|require\(['"]sqlite3['"]\)/.test(readFileSync(f, 'utf8')));
|
|
115
|
+
const pgUsed = stack.data_stores.some((i) => i.id === 'postgres');
|
|
116
|
+
let data = { kind: 'none' };
|
|
117
|
+
if (pgUsed && !sqliteUsed) {
|
|
118
|
+
const url = names.find((n) => n === 'DATABASE_URL' || n === 'POSTGRES_URL');
|
|
119
|
+
if (!url)
|
|
120
|
+
return no('unsupported_datastore', 'the app uses Postgres but does not read DATABASE_URL or POSTGRES_URL, so the sandbox cannot point it at its own database.');
|
|
121
|
+
const sqlIn = (d) => (existsSync(join(repo, d)) ? readdirSync(join(repo, d)).filter((f) => f.endsWith('.sql')).sort(cmp).map((f) => `${d}/${f}`) : []);
|
|
122
|
+
const migrations = [...sqlIn('migrations'), ...sqlIn('db/migrations')];
|
|
123
|
+
const seedSql = ['seed.sql', 'db/seed.sql'].find((f) => existsSync(join(repo, f))) ?? null;
|
|
124
|
+
if (!migrations.length && !seedSql)
|
|
125
|
+
return no('no_seed', 'the app uses Postgres and the repository has no migrations folder or seed.sql to build the schema and data from.');
|
|
126
|
+
data = { kind: 'postgres', migrations, seed_sql: seedSql };
|
|
127
|
+
env[url] = '{PGURL}';
|
|
128
|
+
notes.push('Postgres runs as a second container on an internal network with no route out');
|
|
129
|
+
}
|
|
130
|
+
else if (sqliteUsed) {
|
|
131
|
+
const dbVars = names.filter((n) => DB_VARS.test(n));
|
|
132
|
+
if (!dbVars.length)
|
|
133
|
+
return no('db_path_hardcoded', 'the app uses SQLite but does not read its file path from an environment variable (DATABASE_URL, DB_PATH, SQLITE_PATH and similar). The sandbox root filesystem is read only, so a path written into the code cannot be redirected to a writable place.');
|
|
134
|
+
for (const v of dbVars)
|
|
135
|
+
env[v] = v.endsWith('_URL') ? 'file:{DATA}/app.db' : '{DATA}/app.db';
|
|
136
|
+
data = { kind: 'sqlite', env: dbVars };
|
|
137
|
+
}
|
|
138
|
+
for (const n of names)
|
|
139
|
+
if (!(n in env) && n !== 'PORT')
|
|
140
|
+
env[n] = dummyValue(n);
|
|
141
|
+
let seed = null;
|
|
142
|
+
if (pj.scripts?.seed)
|
|
143
|
+
seed = 'npm run seed';
|
|
144
|
+
else {
|
|
145
|
+
const f = ['seed.js', 'db/seed.js', 'scripts/seed.js', 'seed.mjs'].find((x) => existsSync(join(repo, x)));
|
|
146
|
+
if (f)
|
|
147
|
+
seed = `node ${f}`;
|
|
148
|
+
else
|
|
149
|
+
notes.push('no seed step found; the specs rely on data the app creates itself');
|
|
150
|
+
}
|
|
151
|
+
const gets = new Set(map.entry_points.filter((e) => e.kind === 'http_route' && e.method === 'GET' && e.path).map((e) => e.path));
|
|
152
|
+
const health = HEALTH.find((h) => gets.has(h)) ?? '/';
|
|
153
|
+
if (health === '/' && !gets.has('/'))
|
|
154
|
+
notes.push('no health route found; the app counts as up when it answers any request with a status below 500');
|
|
155
|
+
return { ok: true, recipe: { schema_version: '1.0.0', stack: 'node-express', base_image: BASE_IMAGE, ...(opts.allowInstallScripts && install ? { install_scripts: true } : {}), install, vendored_modules: vendored, seed, start, port: port.port, port_from_env: port.fromEnv, health_path: health, data_store: data, env: Object.fromEntries(Object.entries(env).sort(([a], [b]) => cmp(a, b))), notes } };
|
|
156
|
+
}
|
|
157
|
+
// ---- Python on Flask (Phase 6d) ---------------------------------------------------------------------------------------------------------
|
|
158
|
+
function pyFiles(repo, depth = 0, out = []) {
|
|
159
|
+
if (depth > 6)
|
|
160
|
+
return out;
|
|
161
|
+
for (const n of readdirSync(repo).sort(cmp)) {
|
|
162
|
+
if (['node_modules', '.git', '.hullproof', '__pycache__', '.venv', 'venv'].includes(n))
|
|
163
|
+
continue;
|
|
164
|
+
const p = join(repo, n);
|
|
165
|
+
const st = lstatSync(p);
|
|
166
|
+
if (st.isSymbolicLink())
|
|
167
|
+
continue;
|
|
168
|
+
if (st.isDirectory())
|
|
169
|
+
pyFiles(p, depth + 1, out);
|
|
170
|
+
else if (n.endsWith('.py') && st.size < 1_000_000)
|
|
171
|
+
out.push(p);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
// The port a Flask app listens on: app.run(port=...) with a number, or int(os.environ.get("PORT", "5000")). The flask command defaults to 5000.
|
|
176
|
+
export function detectPyPort(text) {
|
|
177
|
+
const run = /\.run\s*\(/.exec(text);
|
|
178
|
+
if (!run)
|
|
179
|
+
return null;
|
|
180
|
+
// The text of the argument list, up to the bracket that closes it.
|
|
181
|
+
let depth = 1;
|
|
182
|
+
let i = run.index + run[0].length;
|
|
183
|
+
const from = i;
|
|
184
|
+
for (; i < text.length && depth > 0; i++) {
|
|
185
|
+
if (text[i] === '(')
|
|
186
|
+
depth++;
|
|
187
|
+
else if (text[i] === ')')
|
|
188
|
+
depth--;
|
|
189
|
+
}
|
|
190
|
+
const args = text.slice(from, i - 1);
|
|
191
|
+
const at = /\bport\s*=\s*/.exec(args);
|
|
192
|
+
if (!at)
|
|
193
|
+
return { port: 5000, fromEnv: false };
|
|
194
|
+
let d = 0;
|
|
195
|
+
let k = at.index + at[0].length;
|
|
196
|
+
const start = k;
|
|
197
|
+
for (; k < args.length; k++) {
|
|
198
|
+
if (args[k] === '(')
|
|
199
|
+
d++;
|
|
200
|
+
else if (args[k] === ')')
|
|
201
|
+
d--;
|
|
202
|
+
else if (args[k] === ',' && d === 0)
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
const expr = args.slice(start, k);
|
|
206
|
+
const fromEnv = /os\.environ|getenv/.test(expr) && /PORT/.test(expr);
|
|
207
|
+
const n = /(\d{2,5})/.exec(expr.replace(/PORT/g, ''));
|
|
208
|
+
if (n && Number(n[1]) <= 65535)
|
|
209
|
+
return { port: Number(n[1]), fromEnv };
|
|
210
|
+
return fromEnv ? { port: 5000, fromEnv } : null;
|
|
211
|
+
}
|
|
212
|
+
function deriveFlask(repo, stack, map, opts) {
|
|
213
|
+
const files = pyFiles(repo);
|
|
214
|
+
const text = new Map(files.map((f) => [f, readFileSync(f, 'utf8')]));
|
|
215
|
+
const notes = [];
|
|
216
|
+
const reqPath = join(repo, 'requirements.txt');
|
|
217
|
+
if (!existsSync(reqPath))
|
|
218
|
+
return no('no_lockfile', 'there is no requirements.txt, and the sandbox installs from pinned requirements only (Pipfile, Poetry and uv are not read in this version).');
|
|
219
|
+
const lines = readFileSync(reqPath, 'utf8').split(/\r?\n/).map((l) => l.replace(/\s+#.*$/, '').trim()).filter((l) => l && !l.startsWith('#'));
|
|
220
|
+
const loose = lines.filter((l) => !/^[A-Za-z0-9_.\[\]-]+\s*==\s*[\w.!+*-]+(?:\s*;.*)?(?:\s+--hash=.*)?$/.test(l) && !/^--hash=/.test(l));
|
|
221
|
+
if (loose.length)
|
|
222
|
+
return no('no_lockfile', `requirements.txt has lines that are not pinned with == (${loose.slice(0, 3).join(', ')}), and the sandbox installs from pinned requirements only.`);
|
|
223
|
+
if (lines.some((l) => /^(?:-e|git\+|https?:|\.\/|\/|--(?:index-url|extra-index-url|find-links))/.test(l)))
|
|
224
|
+
return no('no_lockfile', 'requirements.txt points at a source other than the package index, and the sandbox installs from the index only.');
|
|
225
|
+
const sqlite = stack.data_stores.some((i) => i.id === 'sqlite') || [...text.values()].some((t) => /\bimport\s+(?:[\w.]+\s*,\s*)*sqlite3\b|\bfrom\s+sqlite3\b/.test(t));
|
|
226
|
+
const otherDb = lines.some((l) => /^(?:psycopg2?|psycopg|pymysql|mysqlclient|pymongo|redis|sqlalchemy|django)\b/i.test(l));
|
|
227
|
+
if (otherDb && !sqlite)
|
|
228
|
+
return no('unsupported_datastore', 'the app uses a database package other than the built in sqlite3. Only a local SQLite file is supported for Python in this version.');
|
|
229
|
+
const names = envNames(repo);
|
|
230
|
+
const dbVars = names.filter((n) => DB_VARS.test(n));
|
|
231
|
+
if (sqlite && !dbVars.length)
|
|
232
|
+
return no('db_path_hardcoded', 'the app uses SQLite but does not read its file path from an environment variable (DATABASE_URL, DB_PATH, SQLITE_PATH and similar). The sandbox root filesystem is read only, so a path written into the code cannot be redirected to a writable place.');
|
|
233
|
+
if (!sqlite)
|
|
234
|
+
notes.push('no data store was found; the app starts with no database');
|
|
235
|
+
const appFile = files.find((f) => /\bFlask\s*\(\s*__name__/.test(text.get(f)));
|
|
236
|
+
if (!appFile)
|
|
237
|
+
return no('no_start_command', 'no file creates the app with Flask(__name__).');
|
|
238
|
+
const rel = (f) => f.slice(repo.length + 1);
|
|
239
|
+
const runner = files.find((f) => /if\s+__name__\s*==\s*['"]__main__['"]/.test(text.get(f)) && /\.run\s*\(/.test(text.get(f)) && (f === appFile || /from\s+\w+\s+import|import\s+\w+/.test(text.get(f))));
|
|
240
|
+
let start;
|
|
241
|
+
let port;
|
|
242
|
+
if (runner) {
|
|
243
|
+
const pt = detectPyPort(text.get(runner));
|
|
244
|
+
if (!pt)
|
|
245
|
+
return no('port_unknown', 'app.run() is called but its port could not be read.');
|
|
246
|
+
port = pt;
|
|
247
|
+
start = `python ${rel(runner)}`;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
const mod = rel(appFile).replace(/\.py$/, '').replace(/\//g, '.');
|
|
251
|
+
port = { port: 5000, fromEnv: false };
|
|
252
|
+
start = `python -m flask --app ${mod} run --host 127.0.0.1 --port 5000`;
|
|
253
|
+
notes.push('the app has no app.run() under __main__, so it starts with the flask command on port 5000');
|
|
254
|
+
}
|
|
255
|
+
const env = {};
|
|
256
|
+
if (port.fromEnv)
|
|
257
|
+
env.PORT = String(port.port);
|
|
258
|
+
for (const v of dbVars)
|
|
259
|
+
env[v] = v.endsWith('_URL') ? 'file:{DATA}/app.db' : '{DATA}/app.db';
|
|
260
|
+
for (const n of names)
|
|
261
|
+
if (!(n in env) && n !== 'PORT')
|
|
262
|
+
env[n] = dummyValue(n);
|
|
263
|
+
const seedFile = ['seed.py', 'db/seed.py', 'scripts/seed.py', 'init_db.py', 'db/init_db.py'].find((x) => existsSync(join(repo, x)));
|
|
264
|
+
if (!seedFile)
|
|
265
|
+
notes.push('no seed step found; the specs rely on data the app creates itself');
|
|
266
|
+
const gets = new Set(map.entry_points.filter((e) => e.kind === 'http_route' && e.method === 'GET' && e.path).map((e) => e.path));
|
|
267
|
+
const health = HEALTH.find((h) => gets.has(h)) ?? '/';
|
|
268
|
+
if (health === '/' && !gets.has('/'))
|
|
269
|
+
notes.push('no health route found; the app counts as up when it answers any request with a status below 500');
|
|
270
|
+
const install = ['pip', 'install', '--no-cache-dir', ...(opts.allowInstallScripts ? [] : ['--only-binary=:all:']), '--disable-pip-version-check', '-r', 'requirements.txt'];
|
|
271
|
+
if (opts.allowInstallScripts)
|
|
272
|
+
notes.push('a package with no wheel builds from source inside the build container, which has network access to the package index');
|
|
273
|
+
else
|
|
274
|
+
notes.push('packages install from wheels only, so no package build script runs; a package that has no wheel makes the app not provable here');
|
|
275
|
+
notes.push('requirements are pinned by version; the sandbox does not check package hashes');
|
|
276
|
+
return { ok: true, recipe: { schema_version: '1.0.0', stack: 'python-flask', base_image: PYTHON_IMAGE, ...(opts.allowInstallScripts ? { install_scripts: true } : {}), install, vendored_modules: false, seed: seedFile ? `python ${seedFile}` : null, start, port: port.port, port_from_env: port.fromEnv, health_path: health, data_store: sqlite ? { kind: 'sqlite', env: dbVars } : { kind: 'none' }, env: Object.fromEntries(Object.entries(env).sort(([a], [b]) => cmp(a, b))), notes } };
|
|
277
|
+
}
|
|
278
|
+
export const recipeSha256 = (r) => sha256(canon(r));
|