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,136 @@
|
|
|
1
|
+
import { ENGINE_VERSION } from '../version.js';
|
|
2
|
+
import { cmp } from '../util.js';
|
|
3
|
+
import { judge, runRecord } from './executor.js';
|
|
4
|
+
import { deriveRecipe, recipeSha256 } from './recipe.js';
|
|
5
|
+
import { makeSandboxCopy } from './safety.js';
|
|
6
|
+
import { DEFAULT_LIMITS } from './runner.js';
|
|
7
|
+
import { canon, sha256, specSha256 } from './spec.js';
|
|
8
|
+
import { clearRuns, loadSpecs, readMeta, readRuns, writeMeta, writeProveResult, writeRecipe, writeRun } from './store.js';
|
|
9
|
+
export const RUNS_REQUIRED = 3;
|
|
10
|
+
function verifyRun(spec, specSha, rr) {
|
|
11
|
+
const bad = (detail) => ({ run: rr.run, outcome: 'error', transcript_sha256: rr.transcript_sha256, detail });
|
|
12
|
+
if (rr.spec_sha256 !== specSha)
|
|
13
|
+
return bad('the spec changed after this run, so its transcript no longer belongs to it');
|
|
14
|
+
const { transcript_sha256: _h, ...body } = rr;
|
|
15
|
+
if (sha256(canon(body)) !== rr.transcript_sha256)
|
|
16
|
+
return bad('the stored transcript does not match its hash; it was changed after the run');
|
|
17
|
+
if (!rr.steps.length)
|
|
18
|
+
return rr.outcome === 'error' ? { run: rr.run, outcome: 'error', transcript_sha256: rr.transcript_sha256, detail: rr.detail } : bad('a run with no steps can only be an error');
|
|
19
|
+
const j = judge(spec, rr.steps);
|
|
20
|
+
if (!j.done || !j.outcome)
|
|
21
|
+
return bad('the stored transcript stops before the spec reaches a verdict');
|
|
22
|
+
if (j.outcome !== rr.outcome)
|
|
23
|
+
return bad(`the stored outcome (${rr.outcome}) does not match what the transcript shows (${j.outcome})`);
|
|
24
|
+
return { run: rr.run, outcome: j.outcome, transcript_sha256: rr.transcript_sha256, detail: j.detail };
|
|
25
|
+
}
|
|
26
|
+
function combine(runs, required) {
|
|
27
|
+
if (runs.length < required)
|
|
28
|
+
return { outcome: 'error', reason: `${runs.length} of ${required} runs finished, so nothing is proven` };
|
|
29
|
+
const set = new Set(runs.map((r) => r.outcome));
|
|
30
|
+
if (set.size > 1)
|
|
31
|
+
return { outcome: 'unstable', reason: `the runs disagreed (${runs.map((r) => r.outcome).join(', ')}), so the lead stays a lead` };
|
|
32
|
+
const o = runs[0].outcome;
|
|
33
|
+
return { outcome: o, reason: o === 'proven' ? `${required} of ${required} runs: the positive control passed and the flaw showed` : runs[0].detail };
|
|
34
|
+
}
|
|
35
|
+
export function buildResult(meta, specs, dropped, runsOf) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const s of specs.slice().sort((a, b) => cmp(a.spec_id, b.spec_id))) {
|
|
38
|
+
const sha = specSha256(s);
|
|
39
|
+
const base = { spec_id: s.spec_id, spec_sha256: sha, lead_key: s.lead_key, title: s.title, claim: s.claim, entry_point: s.target.entry_point, requirement_ids: s.requirement_ids, sink: { file: s.target.file, line: s.target.line } };
|
|
40
|
+
if (meta.not_provable) {
|
|
41
|
+
out.push({ ...base, outcome: 'not_provable', reason: `not provable here (${meta.not_provable.code}): ${meta.not_provable.reason}`, runs: [] });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const runs = runsOf(s.spec_id).map((r) => verifyRun(s, sha, r));
|
|
45
|
+
out.push({ ...base, ...combine(runs, meta.runs_required), runs });
|
|
46
|
+
}
|
|
47
|
+
const n = (o) => out.filter((x) => x.outcome === o).length;
|
|
48
|
+
return {
|
|
49
|
+
schema_version: '1.0.0', engine_version: ENGINE_VERSION, commit: meta.commit, runner: meta.runner, runs_required: meta.runs_required,
|
|
50
|
+
recipe_sha256: meta.recipe_sha256, not_provable: meta.not_provable, specs: out, dropped, left_behind: meta.left_behind, copy_excluded: meta.copy_excluded,
|
|
51
|
+
counts: { specs: out.length, proven: n('proven'), not_proven: n('not_proven'), invalid: n('invalid'), unstable: n('unstable'), error: n('error'), not_provable: n('not_provable'), dropped: dropped.length },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const finish = (i, meta, specs, dropped) => {
|
|
55
|
+
writeMeta(i.proveDir, meta);
|
|
56
|
+
const r = buildResult(meta, specs, dropped, (id) => readRuns(i.proveDir, id));
|
|
57
|
+
writeProveResult(i.proveDir, r);
|
|
58
|
+
return r;
|
|
59
|
+
};
|
|
60
|
+
export async function runProve(i) {
|
|
61
|
+
const limits = i.limits ?? DEFAULT_LIMITS;
|
|
62
|
+
const required = i.runs ?? RUNS_REQUIRED;
|
|
63
|
+
const { specs, dropped } = loadSpecs(i.proveDir);
|
|
64
|
+
clearRuns(i.proveDir);
|
|
65
|
+
const meta = { schema_version: '1.0.0', commit: i.commit, runner: i.runner.name, runs_required: required, recipe_sha256: null, not_provable: null, left_behind: 0, copy_excluded: [] };
|
|
66
|
+
const stop = (np) => finish(i, { ...meta, not_provable: np }, specs, dropped);
|
|
67
|
+
if (!specs.length) {
|
|
68
|
+
i.log('hullproof prove: no valid specs to run');
|
|
69
|
+
return finish(i, meta, specs, dropped);
|
|
70
|
+
}
|
|
71
|
+
const rec = deriveRecipe(i.repo, i.stack, i.map, { allowInstallScripts: i.allowInstallScripts });
|
|
72
|
+
if (!rec.ok)
|
|
73
|
+
return stop({ code: rec.code, reason: rec.reason });
|
|
74
|
+
const recipe = rec.recipe;
|
|
75
|
+
meta.recipe_sha256 = recipeSha256(recipe);
|
|
76
|
+
writeRecipe(i.proveDir, recipe);
|
|
77
|
+
const av = await i.runner.available();
|
|
78
|
+
if (!av.ok)
|
|
79
|
+
return stop({ code: 'no_runtime', reason: av.reason });
|
|
80
|
+
const copy = makeSandboxCopy(i.repo, { keepNodeModules: recipe.vendored_modules });
|
|
81
|
+
if ('error' in copy)
|
|
82
|
+
return stop({ code: 'too_large', reason: copy.error });
|
|
83
|
+
meta.copy_excluded = copy.excluded;
|
|
84
|
+
let notProvable = null;
|
|
85
|
+
let leftBehind = 0;
|
|
86
|
+
try {
|
|
87
|
+
i.log(`hullproof prove: ${i.runner.name}, ${copy.files} files copied, ${copy.excluded.length} left out, preparing the image`);
|
|
88
|
+
const prep = await i.runner.prepare({ recipe, copyDir: copy.dir, sourceDir: i.repo, limits });
|
|
89
|
+
if (!prep.ok) {
|
|
90
|
+
notProvable = { code: prep.code, reason: prep.reason };
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
try {
|
|
94
|
+
outer: for (const spec of specs) {
|
|
95
|
+
const sha = specSha256(spec);
|
|
96
|
+
for (let run = 1; run <= required; run++) {
|
|
97
|
+
const st = await prep.prepared.start();
|
|
98
|
+
if (!st.ok) {
|
|
99
|
+
notProvable = { code: st.code, reason: st.reason };
|
|
100
|
+
break outer;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const r = await st.instance.runSpec(spec);
|
|
104
|
+
const body = r.ok ? { outcome: r.outcome, detail: r.detail, steps: r.steps } : { outcome: 'error', detail: r.reason, steps: [] };
|
|
105
|
+
writeRun(i.proveDir, runRecord(spec, sha, run, body));
|
|
106
|
+
i.log(`hullproof prove: ${spec.spec_id} run ${run}/${required}: ${body.outcome}`);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
leftBehind += (await st.instance.stop()).left_behind.length;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
const t = await prep.prepared.dispose();
|
|
116
|
+
leftBehind += t.left_behind.length;
|
|
117
|
+
if (t.left_behind.length)
|
|
118
|
+
i.log(`hullproof prove: WARNING: left behind after teardown: ${t.left_behind.join(', ')}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
copy.cleanup();
|
|
124
|
+
}
|
|
125
|
+
return finish(i, { ...meta, not_provable: notProvable, left_behind: leftBehind }, specs, dropped);
|
|
126
|
+
}
|
|
127
|
+
// Rebuilds result.json from the saved specs and run files. No sandbox, no app, no model.
|
|
128
|
+
export function replayProve(proveDir) {
|
|
129
|
+
const meta = readMeta(proveDir);
|
|
130
|
+
if (!meta)
|
|
131
|
+
return `no saved proof run in ${proveDir}`;
|
|
132
|
+
const { specs, dropped } = loadSpecs(proveDir);
|
|
133
|
+
const r = buildResult(meta, specs, dropped, (id) => readRuns(proveDir, id));
|
|
134
|
+
writeProveResult(proveDir, r);
|
|
135
|
+
return r;
|
|
136
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_LIMITS = { memory: '512m', cpus: '1', pids: 128, instanceMs: 180_000, execMs: 60_000, healthMs: 30_000 };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The safety rules of the proof sandbox that do not depend on a container runtime: the typed confirmation, the loopback only
|
|
2
|
+
// target rule, the copy of the repository without secrets, and the dummy environment. Design: docs/PHASE-4-DESIGN.md, safety rules 1 to 3.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, copyFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, relative, sep } from 'node:path';
|
|
7
|
+
import { loopbackBase } from './executor.js';
|
|
8
|
+
export const CONFIRM_PHRASE = 'I own this app and approve running it';
|
|
9
|
+
// Rule 1: an explicit flag and the exact typed phrase. Nothing else counts: no environment variable, no default, no CI setting.
|
|
10
|
+
export function checkConfirmation(allowRun, typed) {
|
|
11
|
+
if (!allowRun)
|
|
12
|
+
return { ok: false, reason: 'hullproof prove runs the app, so it needs --allow-run' };
|
|
13
|
+
if (typed === null)
|
|
14
|
+
return { ok: false, reason: `hullproof prove needs the owner's typed confirmation: type "${CONFIRM_PHRASE}"` };
|
|
15
|
+
if (typed.trim() !== CONFIRM_PHRASE)
|
|
16
|
+
return { ok: false, reason: `the confirmation does not match. Type exactly: ${CONFIRM_PHRASE}` };
|
|
17
|
+
return { ok: true };
|
|
18
|
+
}
|
|
19
|
+
// Rule 2: the only address a request may go to is the sandbox's own loopback on the recipe's port.
|
|
20
|
+
export function assertSandboxTarget(url, port) {
|
|
21
|
+
const u = loopbackBase(url);
|
|
22
|
+
if (Number(u.port) !== port)
|
|
23
|
+
throw new Error(`refused: ${u.origin} is not the sandbox app port ${port}`);
|
|
24
|
+
}
|
|
25
|
+
// Rule 3: names that never go into the copy. Environment files, keys, credentials, tool folders, git history.
|
|
26
|
+
const SECRET_FILE = /^(?:\.env(?:\..*)?|\.npmrc|\.netrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|credentials(?:\..*)?|.*\.(?:pem|key|p12|pfx|jks|keystore|kdbx)|service-account.*\.json|.*secrets?\.(?:json|ya?ml|toml))$/i;
|
|
27
|
+
const SKIP_DIR = new Set(['.git', '.hullproof', '.DS_Store', '.next', '.cache', 'coverage']);
|
|
28
|
+
export const isSecretFile = (name) => SECRET_FILE.test(name);
|
|
29
|
+
const MAX_FILE = 10 * 1024 * 1024;
|
|
30
|
+
const MAX_TOTAL = 200 * 1024 * 1024;
|
|
31
|
+
// Copies the repository to a fresh temporary folder, leaving out secrets and (unless the repository vendors its modules) node_modules.
|
|
32
|
+
// Symbolic links are never followed or copied. Returns what was left out so the report can say so.
|
|
33
|
+
export function makeSandboxCopy(repo, o) {
|
|
34
|
+
const root = realpathSync(repo);
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), 'hp-prove-'));
|
|
36
|
+
const excluded = [];
|
|
37
|
+
let files = 0;
|
|
38
|
+
let total = 0;
|
|
39
|
+
const walk = (from) => {
|
|
40
|
+
for (const name of readdirSync(from).sort()) {
|
|
41
|
+
const p = join(from, name);
|
|
42
|
+
const rel = relative(root, p).split(sep).join('/');
|
|
43
|
+
const st = lstatSync(p);
|
|
44
|
+
if (st.isSymbolicLink()) {
|
|
45
|
+
excluded.push(`${rel} (symbolic link)`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (st.isDirectory()) {
|
|
49
|
+
if (SKIP_DIR.has(name))
|
|
50
|
+
continue;
|
|
51
|
+
if (name === 'node_modules' && !o.keepNodeModules) {
|
|
52
|
+
excluded.push(`${rel}/ (dependencies are installed in the build phase)`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
mkdirSync(join(dir, rel), { recursive: true });
|
|
56
|
+
const e = walk(p);
|
|
57
|
+
if (e)
|
|
58
|
+
return e;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!st.isFile())
|
|
62
|
+
continue;
|
|
63
|
+
if (isSecretFile(name)) {
|
|
64
|
+
excluded.push(`${rel} (secret file)`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (st.size > MAX_FILE) {
|
|
68
|
+
excluded.push(`${rel} (over 10 MB)`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
total += st.size;
|
|
72
|
+
if (total > MAX_TOTAL)
|
|
73
|
+
return 'the repository is over 200 MB, which is too large to copy into a sandbox';
|
|
74
|
+
mkdirSync(join(dir, rel, '..'), { recursive: true });
|
|
75
|
+
copyFileSync(p, join(dir, rel));
|
|
76
|
+
files++;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
const err = walk(root);
|
|
81
|
+
if (err) {
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
return { error: err };
|
|
84
|
+
}
|
|
85
|
+
return { dir, files, excluded: excluded.sort(), cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
86
|
+
}
|
|
87
|
+
// The environment variable names the repository reads: process.env.NAME and process.env['NAME'] in source files, and the names
|
|
88
|
+
// (not the values) in .env.example. A real .env file is never opened.
|
|
89
|
+
const SRC = /\.(?:[cm]?[jt]sx?)$/;
|
|
90
|
+
const PY_SRC = /\.py$/;
|
|
91
|
+
export function envNames(repo) {
|
|
92
|
+
const names = new Set();
|
|
93
|
+
const walk = (d, depth) => {
|
|
94
|
+
if (depth > 8)
|
|
95
|
+
return;
|
|
96
|
+
for (const n of readdirSync(d)) {
|
|
97
|
+
if (n === 'node_modules' || n === '.git' || n === '.hullproof' || n === '.next' || n === '__pycache__' || n === '.venv' || n === 'venv')
|
|
98
|
+
continue;
|
|
99
|
+
const p = join(d, n);
|
|
100
|
+
const st = lstatSync(p);
|
|
101
|
+
if (st.isSymbolicLink())
|
|
102
|
+
continue;
|
|
103
|
+
if (st.isDirectory())
|
|
104
|
+
walk(p, depth + 1);
|
|
105
|
+
else if (st.isFile() && st.size < 1_000_000) {
|
|
106
|
+
if (SRC.test(n))
|
|
107
|
+
for (const m of readFileSync(p, 'utf8').matchAll(/process\.env(?:\.([A-Z][A-Z0-9_]*)|\[\s*['"]([A-Z][A-Z0-9_]*)['"]\s*\])/g))
|
|
108
|
+
names.add(m[1] ?? m[2]);
|
|
109
|
+
else if (PY_SRC.test(n))
|
|
110
|
+
for (const m of readFileSync(p, 'utf8').matchAll(/os\.(?:environ(?:\.get)?\s*[[(]\s*|getenv\s*\(\s*)['"]([A-Z][A-Z0-9_]*)['"]/g))
|
|
111
|
+
names.add(m[1]);
|
|
112
|
+
else if (n === '.env.example')
|
|
113
|
+
for (const l of readFileSync(p, 'utf8').split(/\r?\n/)) {
|
|
114
|
+
const m = /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/.exec(l);
|
|
115
|
+
if (m)
|
|
116
|
+
names.add(m[1]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
if (existsSync(repo))
|
|
122
|
+
walk(repo, 0);
|
|
123
|
+
return [...names].sort();
|
|
124
|
+
}
|
|
125
|
+
// A fixed dummy value per name, so two runs get the same environment. It looks like what it stands for and opens nothing.
|
|
126
|
+
export const dummyValue = (name) => `dummy-${name.toLowerCase().replace(/_/g, '-')}-${createHash('sha256').update(name).digest('hex').slice(0, 8)}`;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// The test spec language of the proof sandbox: validation, templates and assertions. A spec is data. The engine runs it; no model
|
|
2
|
+
// written code ever runs. This file has no imports beyond types, because the same file runs inside the sandbox (see exec-main.ts).
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
export const SPEC_LIMITS = { steps: 24, actors: 6, assertions: 8, captures: 4, body: 4096, header: 512, text: 300, path: 300, idLen: 40 };
|
|
5
|
+
const ID = /^[a-z][a-z0-9_]{0,39}$/;
|
|
6
|
+
const VAR = /\{\{([a-z][a-z0-9_]{0,39})\}\}/g;
|
|
7
|
+
// A path on the app itself: a slash first, no scheme, no host, no dot dot, no backslash. Templates are allowed.
|
|
8
|
+
const PATH = /^\/(?!\/)[A-Za-z0-9._~!$&'()*+,;=:@%/?#\-{}]*$/;
|
|
9
|
+
const METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
10
|
+
const HEADER_DENY = new Set(['host', 'connection', 'content-length', 'transfer-encoding', 'expect', 'upgrade', 'te', 'trailer', 'keep-alive']);
|
|
11
|
+
const HEADER_DENY_PREFIX = ['proxy-', 'sec-', 'x-forwarded-', 'x-real-ip'];
|
|
12
|
+
// Strings that look built to damage data, read files or run code. A guard against a bad spec, not a boundary: the sandbox is the boundary.
|
|
13
|
+
const HARMFUL = /\b(?:drop|truncate|alter)\s+(?:table|database|schema)\b|\bdelete\s+from\b|\bunion\s+select\b|\brm\s+-|\bshutdown\b|;\s*--|\$\(|`|\.\.[\\/]|<script|\bexec\s*\(|\bcurl\s|\bwget\s/i;
|
|
14
|
+
export const SECRET_NAME = /(?:pass(?:word|wd)?|secret|token|api[_-]?key|authorization|cookie|session|bearer|jwt|credential|private)/i;
|
|
15
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
16
|
+
// Sorted keys, no spaces: the form every hash in this phase is taken over.
|
|
17
|
+
export function canon(v) {
|
|
18
|
+
const sort = (x) => {
|
|
19
|
+
if (Array.isArray(x))
|
|
20
|
+
return x.map(sort);
|
|
21
|
+
if (isObj(x)) {
|
|
22
|
+
const o = {};
|
|
23
|
+
for (const k of Object.keys(x).sort())
|
|
24
|
+
if (x[k] !== undefined)
|
|
25
|
+
o[k] = sort(x[k]);
|
|
26
|
+
return o;
|
|
27
|
+
}
|
|
28
|
+
return x;
|
|
29
|
+
};
|
|
30
|
+
return JSON.stringify(sort(v));
|
|
31
|
+
}
|
|
32
|
+
export const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
33
|
+
export const specSha256 = (s) => sha256(canon(s));
|
|
34
|
+
export function varsIn(text) { return [...text.matchAll(VAR)].map((m) => m[1]); }
|
|
35
|
+
function strings(v, out = []) {
|
|
36
|
+
if (typeof v === 'string')
|
|
37
|
+
out.push(v);
|
|
38
|
+
else if (Array.isArray(v))
|
|
39
|
+
v.forEach((x) => strings(x, out));
|
|
40
|
+
else if (isObj(v))
|
|
41
|
+
for (const [k, x] of Object.entries(v)) {
|
|
42
|
+
out.push(k);
|
|
43
|
+
strings(x, out);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
function keysOf(v, out = []) {
|
|
48
|
+
if (Array.isArray(v))
|
|
49
|
+
v.forEach((x) => keysOf(x, out));
|
|
50
|
+
else if (isObj(v))
|
|
51
|
+
for (const [k, x] of Object.entries(v)) {
|
|
52
|
+
out.push(k);
|
|
53
|
+
keysOf(x, out);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function checkHeaders(h, where, err) {
|
|
58
|
+
if (h === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!isObj(h)) {
|
|
61
|
+
err(`${where}: headers must be an object`);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [k, v] of Object.entries(h)) {
|
|
66
|
+
const name = k.toLowerCase();
|
|
67
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(name)) {
|
|
68
|
+
err(`${where}: header name ${JSON.stringify(k).slice(0, 40)} is not allowed`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (HEADER_DENY.has(name) || HEADER_DENY_PREFIX.some((p) => name.startsWith(p))) {
|
|
72
|
+
err(`${where}: header ${name} may not be set`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (typeof v !== 'string' || v.length > SPEC_LIMITS.header || /[\u0000-\u001f\u007f]/.test(v)) {
|
|
76
|
+
err(`${where}: header ${name} must be a short single line string`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
out[name] = v;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
function checkAssertion(a, where, err) {
|
|
84
|
+
if (!isObj(a) || typeof a.kind !== 'string') {
|
|
85
|
+
err(`${where}: an assertion needs a kind`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const text = (x) => typeof x === 'string' && x.length > 0 && x.length <= SPEC_LIMITS.text;
|
|
89
|
+
if (a.kind === 'status') {
|
|
90
|
+
const ok = (n) => Number.isInteger(n) && n >= 100 && n <= 599;
|
|
91
|
+
if (a.op === 'in' ? !(Array.isArray(a.value) && a.value.length > 0 && a.value.length <= 8 && a.value.every(ok)) : !((a.op === 'eq' || a.op === 'ne') && ok(a.value))) {
|
|
92
|
+
err(`${where}: a status assertion needs op eq, ne or in and status codes`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return { kind: 'status', op: a.op, value: a.value };
|
|
96
|
+
}
|
|
97
|
+
if (a.kind === 'json') {
|
|
98
|
+
if (typeof a.path !== 'string' || !/^[A-Za-z0-9_$-]+(?:\.[A-Za-z0-9_$-]+)*$/.test(a.path) || a.path.length > 120) {
|
|
99
|
+
err(`${where}: a json assertion needs a dotted path such as items.0.owner`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (SECRET_NAME.test(a.path)) {
|
|
103
|
+
err(`${where}: an assertion may not read a field named like a secret (${a.path}); it is redacted in the transcript`);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (!['eq', 'ne', 'contains', 'exists', 'absent'].includes(String(a.op))) {
|
|
107
|
+
err(`${where}: a json assertion needs op eq, ne, contains, exists or absent`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const needs = a.op === 'eq' || a.op === 'ne' || a.op === 'contains';
|
|
111
|
+
const v = a.value;
|
|
112
|
+
if (needs && !(typeof v === 'string' ? v.length <= SPEC_LIMITS.text : typeof v === 'number' || typeof v === 'boolean' || v === null)) {
|
|
113
|
+
err(`${where}: a json ${a.op} assertion needs a value`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'json', path: a.path, op: a.op, ...(needs ? { value: v } : {}) };
|
|
117
|
+
}
|
|
118
|
+
if (a.kind === 'text') {
|
|
119
|
+
if ((a.op !== 'contains' && a.op !== 'absent') || !text(a.value)) {
|
|
120
|
+
err(`${where}: a text assertion needs op contains or absent and a value`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return { kind: 'text', op: a.op, value: a.value };
|
|
124
|
+
}
|
|
125
|
+
if (a.kind === 'header') {
|
|
126
|
+
if (typeof a.name !== 'string' || !/^[A-Za-z][A-Za-z0-9-]{0,39}$/.test(a.name) || SECRET_NAME.test(a.name)) {
|
|
127
|
+
err(`${where}: a header assertion needs a plain header name that is not a secret`);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (!['eq', 'contains', 'exists', 'absent'].includes(String(a.op))) {
|
|
131
|
+
err(`${where}: a header assertion needs op eq, contains, exists or absent`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const needs = a.op === 'eq' || a.op === 'contains';
|
|
135
|
+
if (needs && !text(a.value)) {
|
|
136
|
+
err(`${where}: a header ${a.op} assertion needs a value`);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return { kind: 'header', name: a.name.toLowerCase(), op: a.op, ...(needs ? { value: a.value } : {}) };
|
|
140
|
+
}
|
|
141
|
+
err(`${where}: unknown assertion kind`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
// True when an attack assertion shows something got through: a 2xx status or a value that is there.
|
|
145
|
+
const exposes = (a) => (a.kind === 'status' && (a.op === 'eq' || a.op === 'in') && [a.value].flat().every((n) => n >= 200 && n < 300)) ||
|
|
146
|
+
(a.kind === 'json' && (a.op === 'eq' || a.op === 'contains' || a.op === 'exists')) || (a.kind === 'text' && a.op === 'contains');
|
|
147
|
+
const succeeds = (a) => a.kind === 'status' && (a.op === 'eq' || a.op === 'in') && [a.value].flat().every((n) => n >= 200 && n < 300);
|
|
148
|
+
// Validates a spec read from JSON. Returns a clean copy (unknown keys dropped) or every problem found. The positive control rule is
|
|
149
|
+
// here: a spec with no control step that must succeed, placed before the attack, is invalid.
|
|
150
|
+
export function validateSpec(raw) {
|
|
151
|
+
const errors = [];
|
|
152
|
+
const err = (m) => { errors.push(m); };
|
|
153
|
+
if (!isObj(raw))
|
|
154
|
+
return { ok: false, errors: ['the spec must be a JSON object'] };
|
|
155
|
+
if (raw.schema_version !== '1.0.0')
|
|
156
|
+
err('schema_version must be "1.0.0"');
|
|
157
|
+
const str = (k, max) => { const v = raw[k]; if (typeof v !== 'string' || !v.trim() || v.length > max) {
|
|
158
|
+
err(`${k} is missing or too long`);
|
|
159
|
+
return '';
|
|
160
|
+
} return v.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim(); };
|
|
161
|
+
const spec_id = str('spec_id', SPEC_LIMITS.idLen);
|
|
162
|
+
if (spec_id && !ID.test(spec_id))
|
|
163
|
+
err('spec_id must be lowercase letters, digits and underscores');
|
|
164
|
+
const title = str('title', 160);
|
|
165
|
+
const claim = str('claim', 500);
|
|
166
|
+
const lead_key = raw.lead_key === null || raw.lead_key === undefined ? null : typeof raw.lead_key === 'string' && raw.lead_key.length <= 400 ? raw.lead_key : (err('lead_key must be a string or null'), null);
|
|
167
|
+
const t = raw.target;
|
|
168
|
+
let target = { entry_point: '', file: '', line: 1 };
|
|
169
|
+
if (isObj(t) && typeof t.entry_point === 'string' && t.entry_point && t.entry_point.length <= 300 && typeof t.file === 'string' && t.file && t.file.length <= 300 && Number.isInteger(t.line) && t.line >= 1)
|
|
170
|
+
target = { entry_point: t.entry_point, file: t.file, line: t.line };
|
|
171
|
+
else
|
|
172
|
+
err('target needs entry_point, file and line');
|
|
173
|
+
const req = raw.requirement_ids;
|
|
174
|
+
const requirement_ids = Array.isArray(req) && req.length <= 6 && req.every((r) => typeof r === 'string' && /^SEC-[A-Z]+-\d{3}$/.test(r)) ? [...new Set(req)].sort() : (err('requirement_ids must be a list of at most 6 ids like SEC-API-001 (it may be empty)'), []);
|
|
175
|
+
const actors = {};
|
|
176
|
+
if (!isObj(raw.actors) || !Object.keys(raw.actors).length || Object.keys(raw.actors).length > SPEC_LIMITS.actors)
|
|
177
|
+
err(`actors needs 1 to ${SPEC_LIMITS.actors} entries`);
|
|
178
|
+
else {
|
|
179
|
+
for (const [name, a] of Object.entries(raw.actors)) {
|
|
180
|
+
if (!ID.test(name)) {
|
|
181
|
+
err(`actor name ${JSON.stringify(name).slice(0, 30)} is not allowed`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (!isObj(a)) {
|
|
185
|
+
err(`actor ${name} must be an object`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const h = checkHeaders(a.headers, `actor ${name}`, err);
|
|
189
|
+
actors[name] = h ? { headers: h } : {};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const steps = [];
|
|
193
|
+
if (!Array.isArray(raw.steps) || raw.steps.length < 2 || raw.steps.length > SPEC_LIMITS.steps)
|
|
194
|
+
err(`steps needs 2 to ${SPEC_LIMITS.steps} entries`);
|
|
195
|
+
else {
|
|
196
|
+
const ids = new Set();
|
|
197
|
+
raw.steps.forEach((s, i) => {
|
|
198
|
+
const w = `step ${i + 1}`;
|
|
199
|
+
if (!isObj(s))
|
|
200
|
+
return err(`${w}: must be an object`);
|
|
201
|
+
if (typeof s.id !== 'string' || !ID.test(s.id) || ids.has(s.id))
|
|
202
|
+
return err(`${w}: id must be unique lowercase letters, digits and underscores`);
|
|
203
|
+
ids.add(s.id);
|
|
204
|
+
if (s.phase !== 'setup' && s.phase !== 'control' && s.phase !== 'attack')
|
|
205
|
+
return err(`${w}: phase must be setup, control or attack`);
|
|
206
|
+
if (typeof s.actor !== 'string' || !(s.actor in actors))
|
|
207
|
+
return err(`${w}: actor is not declared in actors`);
|
|
208
|
+
if (typeof s.method !== 'string' || !METHODS.includes(s.method))
|
|
209
|
+
return err(`${w}: method must be one of ${METHODS.join(', ')}`);
|
|
210
|
+
if (typeof s.path !== 'string' || s.path.length > SPEC_LIMITS.path || !PATH.test(s.path) || s.path.includes('..'))
|
|
211
|
+
return err(`${w}: path must start with one slash and name a path on the app, never a host or address`);
|
|
212
|
+
const headers = checkHeaders(s.headers, w, err);
|
|
213
|
+
let body = null;
|
|
214
|
+
if (s.body !== undefined && s.body !== null) {
|
|
215
|
+
if (!isObj(s.body) || canon(s.body).length > SPEC_LIMITS.body)
|
|
216
|
+
return err(`${w}: body must be a small JSON object`);
|
|
217
|
+
body = s.body;
|
|
218
|
+
if (s.method === 'GET' || s.method === 'HEAD')
|
|
219
|
+
return err(`${w}: a ${s.method} request has no body`);
|
|
220
|
+
}
|
|
221
|
+
const captures = [];
|
|
222
|
+
if (s.captures !== undefined) {
|
|
223
|
+
if (!Array.isArray(s.captures) || s.captures.length > SPEC_LIMITS.captures)
|
|
224
|
+
return err(`${w}: captures needs at most ${SPEC_LIMITS.captures} entries`);
|
|
225
|
+
for (const c of s.captures) {
|
|
226
|
+
if (!isObj(c) || typeof c.name !== 'string' || !ID.test(c.name) || !['json', 'header', 'status'].includes(String(c.from)))
|
|
227
|
+
return err(`${w}: a capture needs a name and from json, header or status`);
|
|
228
|
+
if (c.from !== 'status' && (typeof c.path !== 'string' || !c.path || c.path.length > 120))
|
|
229
|
+
return err(`${w}: a ${String(c.from)} capture needs a path`);
|
|
230
|
+
captures.push({ name: c.name, from: c.from, ...(c.from === 'status' ? {} : { path: c.path }) });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!Array.isArray(s.assert) || s.assert.length > SPEC_LIMITS.assertions)
|
|
234
|
+
return err(`${w}: assert needs a list of at most ${SPEC_LIMITS.assertions} assertions`);
|
|
235
|
+
const assert = s.assert.map((a) => checkAssertion(a, w, err)).filter((a) => a !== null);
|
|
236
|
+
steps.push({ id: s.id, phase: s.phase, actor: s.actor, method: s.method, path: s.path, ...(headers ? { headers } : {}), ...(body ? { body } : {}), ...(captures.length ? { captures } : {}), assert });
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// Only words the spec can supply from an earlier capture: a variable must be captured before the step that uses it.
|
|
240
|
+
const known = new Set();
|
|
241
|
+
const secretVars = new Set();
|
|
242
|
+
for (const s of steps) {
|
|
243
|
+
const used = [...varsIn(s.path), ...Object.values(s.headers ?? {}).flatMap(varsIn), ...strings(s.body).flatMap(varsIn), ...s.assert.flatMap((a) => ('value' in a && typeof a.value === 'string' ? varsIn(a.value) : []))];
|
|
244
|
+
for (const v of new Set(used))
|
|
245
|
+
if (!known.has(v))
|
|
246
|
+
errors.push(`step ${s.id}: {{${v}}} is used before any step captures it`);
|
|
247
|
+
for (const a of s.assert)
|
|
248
|
+
if ('value' in a && typeof a.value === 'string')
|
|
249
|
+
for (const v of varsIn(a.value))
|
|
250
|
+
if (secretVars.has(v))
|
|
251
|
+
errors.push(`step ${s.id}: an assertion may not compare with {{${v}}}, a secret that is redacted in the transcript`);
|
|
252
|
+
for (const c of s.captures ?? []) {
|
|
253
|
+
known.add(c.name);
|
|
254
|
+
if (SECRET_NAME.test(c.name) || SECRET_NAME.test(c.path ?? ''))
|
|
255
|
+
secretVars.add(c.name);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const [n, a] of Object.entries(actors))
|
|
259
|
+
for (const v of Object.values(a.headers ?? {}).flatMap(varsIn))
|
|
260
|
+
if (!steps.some((s) => s.captures?.some((c) => c.name === v)))
|
|
261
|
+
errors.push(`actor ${n}: {{${v}}} is never captured`);
|
|
262
|
+
// Harmless payloads only.
|
|
263
|
+
for (const s of steps) {
|
|
264
|
+
const all = [s.path, ...Object.values(s.headers ?? {}), ...strings(s.body), ...s.assert.flatMap((a) => ('value' in a && typeof a.value === 'string' ? [a.value] : []))];
|
|
265
|
+
if (all.some((x) => HARMFUL.test(x)))
|
|
266
|
+
errors.push(`step ${s.id}: payload_not_harmless: the request holds text built to damage data, read files or run code. Use canary strings and read style requests.`);
|
|
267
|
+
for (const k of keysOf(s.body))
|
|
268
|
+
if (HARMFUL.test(k))
|
|
269
|
+
errors.push(`step ${s.id}: payload_not_harmless in a body key`);
|
|
270
|
+
}
|
|
271
|
+
// The positive control rule.
|
|
272
|
+
const firstAttack = steps.findIndex((s) => s.phase === 'attack');
|
|
273
|
+
const firstControl = steps.findIndex((s) => s.phase === 'control');
|
|
274
|
+
if (steps.length && firstAttack < 0)
|
|
275
|
+
errors.push('a spec needs at least one attack step');
|
|
276
|
+
if (steps.length && (firstControl < 0 || (firstAttack >= 0 && firstControl > firstAttack)))
|
|
277
|
+
errors.push('positive control missing: a control step that must succeed has to come before the first attack step');
|
|
278
|
+
else if (!steps.some((s) => s.phase === 'control' && s.assert.some(succeeds)))
|
|
279
|
+
errors.push('positive control missing: a control step needs a status assertion with a 2xx code, so a working harness is shown first');
|
|
280
|
+
for (const s of steps)
|
|
281
|
+
if ((s.phase === 'control' || s.phase === 'attack') && !s.assert.length)
|
|
282
|
+
errors.push(`step ${s.id}: a ${s.phase} step needs at least one assertion`);
|
|
283
|
+
if (steps.some((s) => s.phase === 'attack') && !steps.filter((s) => s.phase === 'attack').some((s) => s.assert.some(exposes)))
|
|
284
|
+
errors.push('an attack step has to assert what got through (a 2xx status or a value that is there); asserting a denial proves nothing');
|
|
285
|
+
if (steps.length && steps.some((s, i) => s.phase === 'setup' && steps.slice(0, i).some((p) => p.phase !== 'setup')))
|
|
286
|
+
errors.push('setup steps come first');
|
|
287
|
+
if (!isObj(raw.expect) || raw.expect.outcome !== 'flaw_present')
|
|
288
|
+
err('expect.outcome must be "flaw_present"');
|
|
289
|
+
if (errors.length)
|
|
290
|
+
return { ok: false, errors };
|
|
291
|
+
return { ok: true, spec: { schema_version: '1.0.0', spec_id, title, claim, lead_key, target, requirement_ids, actors, steps, expect: { outcome: 'flaw_present' } } };
|
|
292
|
+
}
|
|
293
|
+
// Replaces {{name}} with the captured value. A name that is not captured is an error. With `redact`, a secret variable shows as [redacted].
|
|
294
|
+
export function fill(text, vars, secrets, redact) {
|
|
295
|
+
return text.replace(VAR, (_m, n) => {
|
|
296
|
+
if (!(n in vars))
|
|
297
|
+
throw new Error(`unresolved variable ${n}`);
|
|
298
|
+
if (redact && secrets.has(n))
|
|
299
|
+
return '[redacted]';
|
|
300
|
+
const v = vars[n];
|
|
301
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
export function fillJson(v, vars, secrets, redact) {
|
|
305
|
+
if (typeof v === 'string')
|
|
306
|
+
return fill(v, vars, secrets, redact);
|
|
307
|
+
if (Array.isArray(v))
|
|
308
|
+
return v.map((x) => fillJson(x, vars, secrets, redact));
|
|
309
|
+
if (v && typeof v === 'object')
|
|
310
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fillJson(x, vars, secrets, redact)]));
|
|
311
|
+
return v;
|
|
312
|
+
}
|
|
313
|
+
// ---- assertions, evaluated on the stored form of a response (so a replay gives the same answer) -------------------------------------
|
|
314
|
+
export function pathGet(v, path) {
|
|
315
|
+
let cur = v;
|
|
316
|
+
for (const part of path.split('.')) {
|
|
317
|
+
if (cur === null || cur === undefined)
|
|
318
|
+
return undefined;
|
|
319
|
+
if (Array.isArray(cur)) {
|
|
320
|
+
if (!/^\d+$/.test(part))
|
|
321
|
+
return undefined;
|
|
322
|
+
cur = cur[Number(part)];
|
|
323
|
+
}
|
|
324
|
+
else if (typeof cur === 'object')
|
|
325
|
+
cur = Object.prototype.hasOwnProperty.call(cur, part) ? cur[part] : undefined;
|
|
326
|
+
else
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
return cur;
|
|
330
|
+
}
|
|
331
|
+
const same = (a, b) => canon(a) === canon(b);
|
|
332
|
+
export function evalAssertion(r, a, vars, secrets) {
|
|
333
|
+
const want = (s) => fill(s, vars, secrets, true);
|
|
334
|
+
if (a.kind === 'status') {
|
|
335
|
+
const ok = a.op === 'eq' ? r.status === a.value : a.op === 'ne' ? r.status !== a.value : a.value.includes(r.status);
|
|
336
|
+
return { ok, desc: `status ${a.op} ${JSON.stringify(a.value)}` };
|
|
337
|
+
}
|
|
338
|
+
if (a.kind === 'json') {
|
|
339
|
+
const got = pathGet(r.json, a.path);
|
|
340
|
+
const w = a.value === undefined ? undefined : typeof a.value === 'string' ? want(a.value) : a.value;
|
|
341
|
+
const ok = a.op === 'exists' ? got !== undefined : a.op === 'absent' ? got === undefined
|
|
342
|
+
: got === undefined ? a.op === 'ne' && r.json !== undefined
|
|
343
|
+
: a.op === 'eq' ? same(got, w) || (typeof got === 'number' && typeof w === 'string' && String(got) === w)
|
|
344
|
+
: a.op === 'ne' ? !(same(got, w) || (typeof got === 'number' && typeof w === 'string' && String(got) === w))
|
|
345
|
+
: typeof got === 'string' ? got.includes(String(w)) : Array.isArray(got) && got.some((x) => same(x, w));
|
|
346
|
+
return { ok, desc: `json ${a.path} ${a.op}${w === undefined ? '' : ' ' + JSON.stringify(w)}` };
|
|
347
|
+
}
|
|
348
|
+
if (a.kind === 'text') {
|
|
349
|
+
const hay = r.text ?? (r.json === undefined ? '' : canon(r.json));
|
|
350
|
+
const has = hay.includes(want(a.value));
|
|
351
|
+
return { ok: a.op === 'contains' ? has : !has, desc: `body text ${a.op} ${JSON.stringify(a.value)}` };
|
|
352
|
+
}
|
|
353
|
+
const got = r.headers[a.name];
|
|
354
|
+
const w = a.value === undefined ? undefined : want(a.value);
|
|
355
|
+
const ok = a.op === 'exists' ? got !== undefined : a.op === 'absent' ? got === undefined : got !== undefined && (a.op === 'eq' ? got === w : got.includes(w));
|
|
356
|
+
return { ok, desc: `header ${a.name} ${a.op}${w === undefined ? '' : ' ' + JSON.stringify(w)}` };
|
|
357
|
+
}
|