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,256 @@
|
|
|
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
|
+
import type { Json } from '../types/hunt.ts';
|
|
5
|
+
import type { Assertion, Spec, SpecStep, StepRecord } from '../types/prove.ts';
|
|
6
|
+
|
|
7
|
+
export const SPEC_LIMITS = { steps: 24, actors: 6, assertions: 8, captures: 4, body: 4096, header: 512, text: 300, path: 300, idLen: 40 } as const;
|
|
8
|
+
const ID = /^[a-z][a-z0-9_]{0,39}$/;
|
|
9
|
+
const VAR = /\{\{([a-z][a-z0-9_]{0,39})\}\}/g;
|
|
10
|
+
// A path on the app itself: a slash first, no scheme, no host, no dot dot, no backslash. Templates are allowed.
|
|
11
|
+
const PATH = /^\/(?!\/)[A-Za-z0-9._~!$&'()*+,;=:@%/?#\-{}]*$/;
|
|
12
|
+
const METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
13
|
+
const HEADER_DENY = new Set(['host', 'connection', 'content-length', 'transfer-encoding', 'expect', 'upgrade', 'te', 'trailer', 'keep-alive']);
|
|
14
|
+
const HEADER_DENY_PREFIX = ['proxy-', 'sec-', 'x-forwarded-', 'x-real-ip'];
|
|
15
|
+
// 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.
|
|
16
|
+
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;
|
|
17
|
+
export const SECRET_NAME = /(?:pass(?:word|wd)?|secret|token|api[_-]?key|authorization|cookie|session|bearer|jwt|credential|private)/i;
|
|
18
|
+
|
|
19
|
+
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
20
|
+
|
|
21
|
+
// Sorted keys, no spaces: the form every hash in this phase is taken over.
|
|
22
|
+
export function canon(v: unknown): string {
|
|
23
|
+
const sort = (x: unknown): unknown => {
|
|
24
|
+
if (Array.isArray(x)) return x.map(sort);
|
|
25
|
+
if (isObj(x)) { const o: Record<string, unknown> = {}; for (const k of Object.keys(x).sort()) if (x[k] !== undefined) o[k] = sort(x[k]); return o; }
|
|
26
|
+
return x;
|
|
27
|
+
};
|
|
28
|
+
return JSON.stringify(sort(v));
|
|
29
|
+
}
|
|
30
|
+
export const sha256 = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
31
|
+
export const specSha256 = (s: Spec): string => sha256(canon(s));
|
|
32
|
+
|
|
33
|
+
export function varsIn(text: string): string[] { return [...text.matchAll(VAR)].map((m) => m[1]); }
|
|
34
|
+
|
|
35
|
+
function strings(v: unknown, out: string[] = []): string[] {
|
|
36
|
+
if (typeof v === 'string') out.push(v);
|
|
37
|
+
else if (Array.isArray(v)) v.forEach((x) => strings(x, out));
|
|
38
|
+
else if (isObj(v)) for (const [k, x] of Object.entries(v)) { out.push(k); strings(x, out); }
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function keysOf(v: unknown, out: string[] = []): string[] {
|
|
42
|
+
if (Array.isArray(v)) v.forEach((x) => keysOf(x, out));
|
|
43
|
+
else if (isObj(v)) for (const [k, x] of Object.entries(v)) { out.push(k); keysOf(x, out); }
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SpecCheck = { ok: true; spec: Spec } | { ok: false; errors: string[] };
|
|
48
|
+
|
|
49
|
+
function checkHeaders(h: unknown, where: string, err: (m: string) => void): Record<string, string> | undefined {
|
|
50
|
+
if (h === undefined) return undefined;
|
|
51
|
+
if (!isObj(h)) { err(`${where}: headers must be an object`); return undefined; }
|
|
52
|
+
const out: Record<string, string> = {};
|
|
53
|
+
for (const [k, v] of Object.entries(h)) {
|
|
54
|
+
const name = k.toLowerCase();
|
|
55
|
+
if (!/^[a-z][a-z0-9-]{0,39}$/.test(name)) { err(`${where}: header name ${JSON.stringify(k).slice(0, 40)} is not allowed`); continue; }
|
|
56
|
+
if (HEADER_DENY.has(name) || HEADER_DENY_PREFIX.some((p) => name.startsWith(p))) { err(`${where}: header ${name} may not be set`); continue; }
|
|
57
|
+
if (typeof v !== 'string' || v.length > SPEC_LIMITS.header || /[\u0000-\u001f\u007f]/.test(v)) { err(`${where}: header ${name} must be a short single line string`); continue; }
|
|
58
|
+
out[name] = v;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkAssertion(a: unknown, where: string, err: (m: string) => void): Assertion | null {
|
|
64
|
+
if (!isObj(a) || typeof a.kind !== 'string') { err(`${where}: an assertion needs a kind`); return null; }
|
|
65
|
+
const text = (x: unknown): x is string => typeof x === 'string' && x.length > 0 && x.length <= SPEC_LIMITS.text;
|
|
66
|
+
if (a.kind === 'status') {
|
|
67
|
+
const ok = (n: unknown): boolean => Number.isInteger(n) && (n as number) >= 100 && (n as number) <= 599;
|
|
68
|
+
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))) { err(`${where}: a status assertion needs op eq, ne or in and status codes`); return null; }
|
|
69
|
+
return { kind: 'status', op: a.op as 'eq' | 'ne' | 'in', value: a.value as number | number[] };
|
|
70
|
+
}
|
|
71
|
+
if (a.kind === 'json') {
|
|
72
|
+
if (typeof a.path !== 'string' || !/^[A-Za-z0-9_$-]+(?:\.[A-Za-z0-9_$-]+)*$/.test(a.path) || a.path.length > 120) { err(`${where}: a json assertion needs a dotted path such as items.0.owner`); return null; }
|
|
73
|
+
if (SECRET_NAME.test(a.path)) { err(`${where}: an assertion may not read a field named like a secret (${a.path}); it is redacted in the transcript`); return null; }
|
|
74
|
+
if (!['eq', 'ne', 'contains', 'exists', 'absent'].includes(String(a.op))) { err(`${where}: a json assertion needs op eq, ne, contains, exists or absent`); return null; }
|
|
75
|
+
const needs = a.op === 'eq' || a.op === 'ne' || a.op === 'contains';
|
|
76
|
+
const v = a.value;
|
|
77
|
+
if (needs && !(typeof v === 'string' ? v.length <= SPEC_LIMITS.text : typeof v === 'number' || typeof v === 'boolean' || v === null)) { err(`${where}: a json ${a.op} assertion needs a value`); return null; }
|
|
78
|
+
return { kind: 'json', path: a.path, op: a.op as 'eq', ...(needs ? { value: v as string } : {}) };
|
|
79
|
+
}
|
|
80
|
+
if (a.kind === 'text') {
|
|
81
|
+
if ((a.op !== 'contains' && a.op !== 'absent') || !text(a.value)) { err(`${where}: a text assertion needs op contains or absent and a value`); return null; }
|
|
82
|
+
return { kind: 'text', op: a.op, value: a.value };
|
|
83
|
+
}
|
|
84
|
+
if (a.kind === 'header') {
|
|
85
|
+
if (typeof a.name !== 'string' || !/^[A-Za-z][A-Za-z0-9-]{0,39}$/.test(a.name) || SECRET_NAME.test(a.name)) { err(`${where}: a header assertion needs a plain header name that is not a secret`); return null; }
|
|
86
|
+
if (!['eq', 'contains', 'exists', 'absent'].includes(String(a.op))) { err(`${where}: a header assertion needs op eq, contains, exists or absent`); return null; }
|
|
87
|
+
const needs = a.op === 'eq' || a.op === 'contains';
|
|
88
|
+
if (needs && !text(a.value)) { err(`${where}: a header ${a.op} assertion needs a value`); return null; }
|
|
89
|
+
return { kind: 'header', name: a.name.toLowerCase(), op: a.op as 'eq', ...(needs ? { value: a.value as string } : {}) };
|
|
90
|
+
}
|
|
91
|
+
err(`${where}: unknown assertion kind`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// True when an attack assertion shows something got through: a 2xx status or a value that is there.
|
|
96
|
+
const exposes = (a: Assertion): boolean =>
|
|
97
|
+
(a.kind === 'status' && (a.op === 'eq' || a.op === 'in') && [a.value].flat().every((n) => n >= 200 && n < 300)) ||
|
|
98
|
+
(a.kind === 'json' && (a.op === 'eq' || a.op === 'contains' || a.op === 'exists')) || (a.kind === 'text' && a.op === 'contains');
|
|
99
|
+
const succeeds = (a: Assertion): boolean => a.kind === 'status' && (a.op === 'eq' || a.op === 'in') && [a.value].flat().every((n) => n >= 200 && n < 300);
|
|
100
|
+
|
|
101
|
+
// Validates a spec read from JSON. Returns a clean copy (unknown keys dropped) or every problem found. The positive control rule is
|
|
102
|
+
// here: a spec with no control step that must succeed, placed before the attack, is invalid.
|
|
103
|
+
export function validateSpec(raw: unknown): SpecCheck {
|
|
104
|
+
const errors: string[] = [];
|
|
105
|
+
const err = (m: string): void => { errors.push(m); };
|
|
106
|
+
if (!isObj(raw)) return { ok: false, errors: ['the spec must be a JSON object'] };
|
|
107
|
+
if (raw.schema_version !== '1.0.0') err('schema_version must be "1.0.0"');
|
|
108
|
+
const str = (k: string, max: number): string => { const v = raw[k]; if (typeof v !== 'string' || !v.trim() || v.length > max) { err(`${k} is missing or too long`); return ''; } return v.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim(); };
|
|
109
|
+
const spec_id = str('spec_id', SPEC_LIMITS.idLen);
|
|
110
|
+
if (spec_id && !ID.test(spec_id)) err('spec_id must be lowercase letters, digits and underscores');
|
|
111
|
+
const title = str('title', 160);
|
|
112
|
+
const claim = str('claim', 500);
|
|
113
|
+
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);
|
|
114
|
+
const t = raw.target;
|
|
115
|
+
let target = { entry_point: '', file: '', line: 1 };
|
|
116
|
+
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 as number) >= 1) target = { entry_point: t.entry_point, file: t.file, line: t.line as number };
|
|
117
|
+
else err('target needs entry_point, file and line');
|
|
118
|
+
const req = raw.requirement_ids;
|
|
119
|
+
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 as string[])].sort() : (err('requirement_ids must be a list of at most 6 ids like SEC-API-001 (it may be empty)'), []);
|
|
120
|
+
|
|
121
|
+
const actors: Spec['actors'] = {};
|
|
122
|
+
if (!isObj(raw.actors) || !Object.keys(raw.actors).length || Object.keys(raw.actors).length > SPEC_LIMITS.actors) err(`actors needs 1 to ${SPEC_LIMITS.actors} entries`);
|
|
123
|
+
else {
|
|
124
|
+
for (const [name, a] of Object.entries(raw.actors)) {
|
|
125
|
+
if (!ID.test(name)) { err(`actor name ${JSON.stringify(name).slice(0, 30)} is not allowed`); continue; }
|
|
126
|
+
if (!isObj(a)) { err(`actor ${name} must be an object`); continue; }
|
|
127
|
+
const h = checkHeaders(a.headers, `actor ${name}`, err);
|
|
128
|
+
actors[name] = h ? { headers: h } : {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const steps: SpecStep[] = [];
|
|
133
|
+
if (!Array.isArray(raw.steps) || raw.steps.length < 2 || raw.steps.length > SPEC_LIMITS.steps) err(`steps needs 2 to ${SPEC_LIMITS.steps} entries`);
|
|
134
|
+
else {
|
|
135
|
+
const ids = new Set<string>();
|
|
136
|
+
raw.steps.forEach((s, i) => {
|
|
137
|
+
const w = `step ${i + 1}`;
|
|
138
|
+
if (!isObj(s)) return err(`${w}: must be an object`);
|
|
139
|
+
if (typeof s.id !== 'string' || !ID.test(s.id) || ids.has(s.id)) return err(`${w}: id must be unique lowercase letters, digits and underscores`);
|
|
140
|
+
ids.add(s.id);
|
|
141
|
+
if (s.phase !== 'setup' && s.phase !== 'control' && s.phase !== 'attack') return err(`${w}: phase must be setup, control or attack`);
|
|
142
|
+
if (typeof s.actor !== 'string' || !(s.actor in actors)) return err(`${w}: actor is not declared in actors`);
|
|
143
|
+
if (typeof s.method !== 'string' || !METHODS.includes(s.method)) return err(`${w}: method must be one of ${METHODS.join(', ')}`);
|
|
144
|
+
if (typeof s.path !== 'string' || s.path.length > SPEC_LIMITS.path || !PATH.test(s.path) || s.path.includes('..')) return err(`${w}: path must start with one slash and name a path on the app, never a host or address`);
|
|
145
|
+
const headers = checkHeaders(s.headers, w, err);
|
|
146
|
+
let body: SpecStep['body'] = null;
|
|
147
|
+
if (s.body !== undefined && s.body !== null) {
|
|
148
|
+
if (!isObj(s.body) || canon(s.body).length > SPEC_LIMITS.body) return err(`${w}: body must be a small JSON object`);
|
|
149
|
+
body = s.body as { [k: string]: Json };
|
|
150
|
+
if (s.method === 'GET' || s.method === 'HEAD') return err(`${w}: a ${s.method} request has no body`);
|
|
151
|
+
}
|
|
152
|
+
const captures: SpecStep['captures'] = [];
|
|
153
|
+
if (s.captures !== undefined) {
|
|
154
|
+
if (!Array.isArray(s.captures) || s.captures.length > SPEC_LIMITS.captures) return err(`${w}: captures needs at most ${SPEC_LIMITS.captures} entries`);
|
|
155
|
+
for (const c of s.captures) {
|
|
156
|
+
if (!isObj(c) || typeof c.name !== 'string' || !ID.test(c.name) || !['json', 'header', 'status'].includes(String(c.from))) return err(`${w}: a capture needs a name and from json, header or status`);
|
|
157
|
+
if (c.from !== 'status' && (typeof c.path !== 'string' || !c.path || c.path.length > 120)) return err(`${w}: a ${String(c.from)} capture needs a path`);
|
|
158
|
+
captures.push({ name: c.name, from: c.from as 'json', ...(c.from === 'status' ? {} : { path: c.path as string }) });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(s.assert) || s.assert.length > SPEC_LIMITS.assertions) return err(`${w}: assert needs a list of at most ${SPEC_LIMITS.assertions} assertions`);
|
|
162
|
+
const assert = s.assert.map((a) => checkAssertion(a, w, err)).filter((a): a is Assertion => a !== null);
|
|
163
|
+
steps.push({ id: s.id, phase: s.phase, actor: s.actor, method: s.method as SpecStep['method'], path: s.path, ...(headers ? { headers } : {}), ...(body ? { body } : {}), ...(captures.length ? { captures } : {}), assert });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Only words the spec can supply from an earlier capture: a variable must be captured before the step that uses it.
|
|
168
|
+
const known = new Set<string>();
|
|
169
|
+
const secretVars = new Set<string>();
|
|
170
|
+
for (const s of steps) {
|
|
171
|
+
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) : []))];
|
|
172
|
+
for (const v of new Set(used)) if (!known.has(v)) errors.push(`step ${s.id}: {{${v}}} is used before any step captures it`);
|
|
173
|
+
for (const a of s.assert) if ('value' in a && typeof a.value === 'string') for (const v of varsIn(a.value)) if (secretVars.has(v)) errors.push(`step ${s.id}: an assertion may not compare with {{${v}}}, a secret that is redacted in the transcript`);
|
|
174
|
+
for (const c of s.captures ?? []) { known.add(c.name); if (SECRET_NAME.test(c.name) || SECRET_NAME.test(c.path ?? '')) secretVars.add(c.name); }
|
|
175
|
+
}
|
|
176
|
+
for (const [n, a] of Object.entries(actors)) for (const v of Object.values(a.headers ?? {}).flatMap(varsIn)) if (!steps.some((s) => s.captures?.some((c) => c.name === v))) errors.push(`actor ${n}: {{${v}}} is never captured`);
|
|
177
|
+
|
|
178
|
+
// Harmless payloads only.
|
|
179
|
+
for (const s of steps) {
|
|
180
|
+
const all = [s.path, ...Object.values(s.headers ?? {}), ...strings(s.body), ...s.assert.flatMap((a) => ('value' in a && typeof a.value === 'string' ? [a.value] : []))];
|
|
181
|
+
if (all.some((x) => HARMFUL.test(x))) 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.`);
|
|
182
|
+
for (const k of keysOf(s.body)) if (HARMFUL.test(k)) errors.push(`step ${s.id}: payload_not_harmless in a body key`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The positive control rule.
|
|
186
|
+
const firstAttack = steps.findIndex((s) => s.phase === 'attack');
|
|
187
|
+
const firstControl = steps.findIndex((s) => s.phase === 'control');
|
|
188
|
+
if (steps.length && firstAttack < 0) errors.push('a spec needs at least one attack step');
|
|
189
|
+
if (steps.length && (firstControl < 0 || (firstAttack >= 0 && firstControl > firstAttack))) errors.push('positive control missing: a control step that must succeed has to come before the first attack step');
|
|
190
|
+
else if (!steps.some((s) => s.phase === 'control' && s.assert.some(succeeds))) errors.push('positive control missing: a control step needs a status assertion with a 2xx code, so a working harness is shown first');
|
|
191
|
+
for (const s of steps) if ((s.phase === 'control' || s.phase === 'attack') && !s.assert.length) errors.push(`step ${s.id}: a ${s.phase} step needs at least one assertion`);
|
|
192
|
+
if (steps.some((s) => s.phase === 'attack') && !steps.filter((s) => s.phase === 'attack').some((s) => s.assert.some(exposes))) 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');
|
|
193
|
+
if (steps.length && steps.some((s, i) => s.phase === 'setup' && steps.slice(0, i).some((p) => p.phase !== 'setup'))) errors.push('setup steps come first');
|
|
194
|
+
if (!isObj(raw.expect) || raw.expect.outcome !== 'flaw_present') err('expect.outcome must be "flaw_present"');
|
|
195
|
+
|
|
196
|
+
if (errors.length) return { ok: false, errors };
|
|
197
|
+
return { ok: true, spec: { schema_version: '1.0.0', spec_id, title, claim, lead_key, target, requirement_ids, actors, steps, expect: { outcome: 'flaw_present' } } };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- templates ---------------------------------------------------------------------------------------------------------------------
|
|
201
|
+
export type Vars = Record<string, Json>;
|
|
202
|
+
// Replaces {{name}} with the captured value. A name that is not captured is an error. With `redact`, a secret variable shows as [redacted].
|
|
203
|
+
export function fill(text: string, vars: Vars, secrets: ReadonlySet<string>, redact: boolean): string {
|
|
204
|
+
return text.replace(VAR, (_m, n: string) => {
|
|
205
|
+
if (!(n in vars)) throw new Error(`unresolved variable ${n}`);
|
|
206
|
+
if (redact && secrets.has(n)) return '[redacted]';
|
|
207
|
+
const v = vars[n];
|
|
208
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
export function fillJson(v: Json, vars: Vars, secrets: ReadonlySet<string>, redact: boolean): Json {
|
|
212
|
+
if (typeof v === 'string') return fill(v, vars, secrets, redact);
|
|
213
|
+
if (Array.isArray(v)) return v.map((x) => fillJson(x, vars, secrets, redact));
|
|
214
|
+
if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fillJson(x, vars, secrets, redact)]));
|
|
215
|
+
return v;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- assertions, evaluated on the stored form of a response (so a replay gives the same answer) -------------------------------------
|
|
219
|
+
export function pathGet(v: Json | undefined, path: string): Json | undefined {
|
|
220
|
+
let cur: Json | undefined = v;
|
|
221
|
+
for (const part of path.split('.')) {
|
|
222
|
+
if (cur === null || cur === undefined) return undefined;
|
|
223
|
+
if (Array.isArray(cur)) { if (!/^\d+$/.test(part)) return undefined; cur = cur[Number(part)]; }
|
|
224
|
+
else if (typeof cur === 'object') cur = Object.prototype.hasOwnProperty.call(cur, part) ? cur[part] : undefined;
|
|
225
|
+
else return undefined;
|
|
226
|
+
}
|
|
227
|
+
return cur;
|
|
228
|
+
}
|
|
229
|
+
const same = (a: Json | undefined, b: Json): boolean => canon(a) === canon(b);
|
|
230
|
+
|
|
231
|
+
export function evalAssertion(r: NonNullable<StepRecord['response']>, a: Assertion, vars: Vars, secrets: ReadonlySet<string>): { ok: boolean; desc: string } {
|
|
232
|
+
const want = (s: string): string => fill(s, vars, secrets, true);
|
|
233
|
+
if (a.kind === 'status') {
|
|
234
|
+
const ok = a.op === 'eq' ? r.status === a.value : a.op === 'ne' ? r.status !== a.value : (a.value as number[]).includes(r.status);
|
|
235
|
+
return { ok, desc: `status ${a.op} ${JSON.stringify(a.value)}` };
|
|
236
|
+
}
|
|
237
|
+
if (a.kind === 'json') {
|
|
238
|
+
const got = pathGet(r.json, a.path);
|
|
239
|
+
const w = a.value === undefined ? undefined : typeof a.value === 'string' ? want(a.value) : a.value;
|
|
240
|
+
const ok = a.op === 'exists' ? got !== undefined : a.op === 'absent' ? got === undefined
|
|
241
|
+
: got === undefined ? a.op === 'ne' && r.json !== undefined
|
|
242
|
+
: a.op === 'eq' ? same(got, w as Json) || (typeof got === 'number' && typeof w === 'string' && String(got) === w)
|
|
243
|
+
: a.op === 'ne' ? !(same(got, w as Json) || (typeof got === 'number' && typeof w === 'string' && String(got) === w))
|
|
244
|
+
: typeof got === 'string' ? got.includes(String(w)) : Array.isArray(got) && got.some((x) => same(x, w as Json));
|
|
245
|
+
return { ok, desc: `json ${a.path} ${a.op}${w === undefined ? '' : ' ' + JSON.stringify(w)}` };
|
|
246
|
+
}
|
|
247
|
+
if (a.kind === 'text') {
|
|
248
|
+
const hay = r.text ?? (r.json === undefined ? '' : canon(r.json));
|
|
249
|
+
const has = hay.includes(want(a.value));
|
|
250
|
+
return { ok: a.op === 'contains' ? has : !has, desc: `body text ${a.op} ${JSON.stringify(a.value)}` };
|
|
251
|
+
}
|
|
252
|
+
const got = r.headers[a.name];
|
|
253
|
+
const w = a.value === undefined ? undefined : want(a.value);
|
|
254
|
+
const ok = a.op === 'exists' ? got !== undefined : a.op === 'absent' ? got === undefined : got !== undefined && (a.op === 'eq' ? got === w : got.includes(w!));
|
|
255
|
+
return { ok, desc: `header ${a.name} ${a.op}${w === undefined ? '' : ' ' + JSON.stringify(w)}` };
|
|
256
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Where proof files live: <out>/<commit>/prove/. Specs go in, run transcripts and the result come out. Written through a temporary
|
|
2
|
+
// file and a rename, with the engine's one JSON writer, so the same value is the same bytes.
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type { ProveMeta, ProveResult, Recipe, RunRecord, Spec } from '../types/index.ts';
|
|
6
|
+
import { cmp, stableJson } from '../util.ts';
|
|
7
|
+
import { validateSpec } from './spec.ts';
|
|
8
|
+
|
|
9
|
+
export const proveDir = (scanDir: string): string => join(scanDir, 'prove');
|
|
10
|
+
const write = (p: string, text: string): void => { mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(`${p}.tmp`, text); renameSync(`${p}.tmp`, p); };
|
|
11
|
+
const readJson = <T>(p: string): T => { try { return JSON.parse(readFileSync(p, 'utf8')) as T; } catch (e) { throw new Error(`${p} cannot be read: ${(e as Error).message}`); } };
|
|
12
|
+
|
|
13
|
+
export const specFile = (dir: string, id: string): string => join(dir, 'specs', `${id}.json`);
|
|
14
|
+
export const writeSpec = (dir: string, s: Spec): void => write(specFile(dir, s.spec_id), stableJson(s));
|
|
15
|
+
export const runFile = (dir: string, id: string, run: number): string => join(dir, 'runs', id, `run-${run}.json`);
|
|
16
|
+
export const writeRun = (dir: string, r: RunRecord): void => write(runFile(dir, r.spec_id, r.run), stableJson(r));
|
|
17
|
+
export const clearRuns = (dir: string): void => rmSync(join(dir, 'runs'), { recursive: true, force: true });
|
|
18
|
+
export const writeMeta = (dir: string, m: ProveMeta): void => write(join(dir, 'meta.json'), stableJson(m));
|
|
19
|
+
export const readMeta = (dir: string): ProveMeta | null => (existsSync(join(dir, 'meta.json')) ? readJson<ProveMeta>(join(dir, 'meta.json')) : null);
|
|
20
|
+
export const writeRecipe = (dir: string, r: Recipe): void => write(join(dir, 'recipe.json'), stableJson(r));
|
|
21
|
+
export const writeProveResult = (dir: string, r: ProveResult): void => write(join(dir, 'result.json'), stableJson(r));
|
|
22
|
+
export const readProveResult = (dir: string): ProveResult | null => (existsSync(join(dir, 'result.json')) ? readJson<ProveResult>(join(dir, 'result.json')) : null);
|
|
23
|
+
|
|
24
|
+
// Every spec file, validated. A file that fails validation is dropped and counted, with the reason.
|
|
25
|
+
export function loadSpecs(dir: string): { specs: Spec[]; dropped: { file: string; reason: string }[] } {
|
|
26
|
+
const specs: Spec[] = [];
|
|
27
|
+
const dropped: { file: string; reason: string }[] = [];
|
|
28
|
+
const d = join(dir, 'specs');
|
|
29
|
+
if (!existsSync(d)) return { specs, dropped };
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
for (const f of readdirSync(d).filter((n) => n.endsWith('.json')).sort(cmp)) {
|
|
32
|
+
let raw: unknown;
|
|
33
|
+
try { raw = JSON.parse(readFileSync(join(d, f), 'utf8')); } catch { dropped.push({ file: f, reason: 'not valid JSON' }); continue; }
|
|
34
|
+
const v = validateSpec(raw);
|
|
35
|
+
if (!v.ok) { dropped.push({ file: f, reason: v.errors.slice(0, 3).join('; ') }); continue; }
|
|
36
|
+
if (`${v.spec.spec_id}.json` !== f) { dropped.push({ file: f, reason: `spec_id ${v.spec.spec_id} does not match the file name` }); continue; }
|
|
37
|
+
if (seen.has(v.spec.spec_id)) { dropped.push({ file: f, reason: 'duplicate spec_id' }); continue; }
|
|
38
|
+
seen.add(v.spec.spec_id);
|
|
39
|
+
specs.push(v.spec);
|
|
40
|
+
}
|
|
41
|
+
return { specs, dropped };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readRuns(dir: string, id: string): RunRecord[] {
|
|
45
|
+
const d = join(dir, 'runs', id);
|
|
46
|
+
if (!existsSync(d)) return [];
|
|
47
|
+
return readdirSync(d).filter((n) => /^run-\d+\.json$/.test(n)).sort(cmp).map((n) => readJson<RunRecord>(join(d, n))).sort((a, b) => a.run - b.run);
|
|
48
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Builds results.json (one row per requirement in scope) and the findings record (schema 1.1.0) from the check results
|
|
2
|
+
// and scanner hits. FAIL comes only from a FAIL check or a mapped rule hit; PASS (static) only from a check on a repo
|
|
3
|
+
// authority requirement; every other requirement in scope is NOT ASSESSED with the sub state that closes it.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { cmp, stableJson, stableJsonCompact } from '../util.ts';
|
|
6
|
+
import { ENGINE_VERSION } from '../version.ts';
|
|
7
|
+
import type {
|
|
8
|
+
CheckResult, CheckResultsFile, FailRecord, FileLineNote, FindingsFile, FindingRecord, Hit, ObservationRecord, ResultRow,
|
|
9
|
+
ResultWord, RouteState, Severity, ToolMeta,
|
|
10
|
+
} from '../types/index.ts';
|
|
11
|
+
import { checkMessage, ruleMessage } from './messages.ts';
|
|
12
|
+
import { highest, inScope, type Control, type Kit, type Scope } from './kit.ts';
|
|
13
|
+
import { TEST_ARTIFACT_IDS, testCodeReason } from './test-code.ts';
|
|
14
|
+
import { isSecretHit, leadReason, leadTier, leadTierForCheck, loadTiers } from './tiers.ts';
|
|
15
|
+
|
|
16
|
+
export interface GitState { commit: string; tree: string; clean: boolean }
|
|
17
|
+
export interface BuildInput {
|
|
18
|
+
kit: Kit; scope: Scope; git: GitState; checks: CheckResultsFile; hits: Hit[]; metas: ToolMeta[];
|
|
19
|
+
checkShas: Record<string, string>; // check id -> sha256 of its definition file
|
|
20
|
+
rulesDirs: string[]; // Semgrep rule folders (engine, then the rule pack), for the plain English text
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAX_NOTE = 600;
|
|
24
|
+
// Checks whose FAIL means a control is missing everywhere searched (class missing_control, with the search recorded).
|
|
25
|
+
const ABSENCE_CHECKS = new Set(['check.web.security-headers-configured', 'check.auth.login-rate-limit', 'check.supply.lockfile-committed']);
|
|
26
|
+
// A fingerprint path has no spaces or colons.
|
|
27
|
+
const fpPath = (f: string): string => f.replace(/[\s:]/g, '_');
|
|
28
|
+
const clip = (s: string, n: number): string => (s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…');
|
|
29
|
+
const slug = (s: string, max = 12): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').split('-').filter(Boolean).slice(0, max).join('-') || 'x';
|
|
30
|
+
const where = (e: { file: string; line?: number }): string => (e.line ? `${e.file}:${e.line}` : e.file);
|
|
31
|
+
|
|
32
|
+
// The sub state for a requirement nothing settled, from its authority and how it is verified.
|
|
33
|
+
export function routeFor(c: Control): RouteState {
|
|
34
|
+
if (c.authority === 'dashboard') return 'NEEDS DASHBOARD';
|
|
35
|
+
if (c.authority === 'runtime') return 'NEEDS DYNAMIC TEST';
|
|
36
|
+
return 'UNKNOWN';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const OWNER_ACTION: Record<RouteState, string> = {
|
|
40
|
+
'NEEDS DASHBOARD': 'Export the provider setting (screenshot or export, dated, naming the project) and record it.',
|
|
41
|
+
'NEEDS BUILD': 'Run a production build from this commit and scan the output.',
|
|
42
|
+
'NEEDS DYNAMIC TEST': 'Test the running app or a staging copy and record the requests and responses.',
|
|
43
|
+
'ASK OWNER': 'Answer the question in writing.',
|
|
44
|
+
'ATTESTATION': 'Sign and date a statement.',
|
|
45
|
+
'UNKNOWN': 'No engine check or rule settles this from the files. A code review (a person, or the Hullproof audit) must read the code and record the result.',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
interface Fail { source: string; sec_ids: string[]; evidence: { file: string; line?: number; note: string }[] }
|
|
49
|
+
|
|
50
|
+
// A finding kept for the report and never in results.json or the gate: test code (src/report/test-code.ts), or a lead
|
|
51
|
+
// from a rule whose confidence tier is lead (src/report/tiers.ts).
|
|
52
|
+
interface TestItem { hit?: Hit; check?: CheckResult; ids: string[]; reason: string; kind: 'test' | 'lead'; total?: number }
|
|
53
|
+
|
|
54
|
+
// Splits hits and failed checks into what can fail a requirement and what is only reported. In order:
|
|
55
|
+
// 1. A secret (a Gitleaks hit or a rule mapped to a SEC-SECRETS ID) stays in the gate wherever it is, unless the scanner marked the hit as an
|
|
56
|
+
// example value (h.noise: src/scanners/secret-noise.ts), which moves to an example value item.
|
|
57
|
+
// 2. In a test path, a requirement about shipped test material (TEST_ARTIFACT_IDS) keeps failing; every other ID moves
|
|
58
|
+
// to a test code item.
|
|
59
|
+
// 3. A hit of a rule whose tier is lead moves to a lead item.
|
|
60
|
+
export function separateTestCode(input: BuildInput): { input: BuildInput; test: TestItem[] } {
|
|
61
|
+
const test: TestItem[] = [];
|
|
62
|
+
const hits: Hit[] = [];
|
|
63
|
+
const tiers = loadTiers(input.rulesDirs ?? []);
|
|
64
|
+
for (const h of input.hits) {
|
|
65
|
+
// A secret stays in the gate, unless the scanner found the value to be an example (a placeholder, or a shapeless rule in docs or fixtures).
|
|
66
|
+
if (isSecretHit(h) && h.noise) { test.push({ hit: h, ids: h.sec_ids, reason: h.noise, kind: 'test' }); continue; }
|
|
67
|
+
const reason = isSecretHit(h) ? null : testCodeReason(h.file);
|
|
68
|
+
if (reason) {
|
|
69
|
+
const keep = h.sec_ids.filter((id) => TEST_ARTIFACT_IDS.has(id));
|
|
70
|
+
if (keep.length) hits.push({ ...h, sec_ids: keep });
|
|
71
|
+
if (keep.length < h.sec_ids.length || !h.sec_ids.length) test.push({ hit: h, ids: h.sec_ids.filter((id) => !TEST_ARTIFACT_IDS.has(id)), reason, kind: 'test' });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const lead = leadTier(h, tiers);
|
|
75
|
+
if (lead) { test.push({ hit: h, ids: h.sec_ids, reason: leadReason(lead, tiers), kind: 'lead' }); continue; }
|
|
76
|
+
hits.push(h);
|
|
77
|
+
}
|
|
78
|
+
const results: CheckResult[] = [];
|
|
79
|
+
for (const r of input.checks.results) {
|
|
80
|
+
// A check that reads the code map is a lead until it is measured: its FAIL is reported and never fails a requirement.
|
|
81
|
+
const leadCheck = r.result === 'FAIL' ? leadTierForCheck(r.check_id, tiers) : null;
|
|
82
|
+
if (leadCheck) {
|
|
83
|
+
const byFile = new Map<string, typeof r.evidence>();
|
|
84
|
+
for (const e of r.evidence) byFile.set(e.file, [...(byFile.get(e.file) ?? []), e]);
|
|
85
|
+
const reason = leadReason(leadCheck, tiers);
|
|
86
|
+
// Past COLLAPSE_FILES files the check gets one record for all its places (the list stays in checks.json), so a repository
|
|
87
|
+
// with a thousand routes does not push the findings block past the kit limit.
|
|
88
|
+
if (byFile.size > COLLAPSE_FILES) test.push({ check: r, ids: r.sec_ids, reason, kind: 'lead', total: r.evidence.length });
|
|
89
|
+
else for (const f of [...byFile.keys()].sort(cmp)) test.push({ check: { ...r, evidence: byFile.get(f)! }, ids: r.sec_ids, reason, kind: 'lead' });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const secret = r.sec_ids.some((id) => id.startsWith('SEC-SECRETS-'));
|
|
93
|
+
const inTest = r.result === 'FAIL' && !secret && !r.sec_ids.some((id) => TEST_ARTIFACT_IDS.has(id)) ? r.evidence.filter((e) => testCodeReason(e.file)) : [];
|
|
94
|
+
const shipped = r.evidence.filter((e) => !inTest.includes(e));
|
|
95
|
+
if (inTest.length) test.push({ check: { ...r, evidence: inTest }, ids: r.sec_ids, reason: testCodeReason(inTest[0].file)!, kind: 'test' });
|
|
96
|
+
// A FAIL whose evidence is all in test code no longer fails; one with no evidence (an absence check) still does.
|
|
97
|
+
if (!inTest.length || shipped.length) results.push(inTest.length ? { ...r, evidence: shipped } : r);
|
|
98
|
+
}
|
|
99
|
+
return { input: { ...input, hits, checks: { ...input.checks, results } }, test };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function failsFor(input: BuildInput): Fail[] {
|
|
103
|
+
const out: Fail[] = [];
|
|
104
|
+
for (const r of input.checks.results) if (r.result === 'FAIL') out.push({ source: r.check_id, sec_ids: r.sec_ids, evidence: r.evidence });
|
|
105
|
+
for (const h of input.hits) if (h.sec_ids.length) out.push({ source: `${h.tool} rule ${h.rule_id}`, sec_ids: h.sec_ids, evidence: [{ file: h.file, line: h.line || undefined, note: h.rule_id }] });
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildResults(all: BuildInput): ResultRow[] {
|
|
110
|
+
const input = separateTestCode(all).input;
|
|
111
|
+
const { kit, scope } = input;
|
|
112
|
+
const fails = failsFor(input);
|
|
113
|
+
const checkWords = new Map<string, CheckResult[]>();
|
|
114
|
+
for (const r of input.checks.results) for (const id of r.sec_ids) checkWords.set(id, [...(checkWords.get(id) ?? []), r]);
|
|
115
|
+
const skippedBy = new Map<string, string[]>();
|
|
116
|
+
for (const m of input.metas) if (m.status !== 'ran') for (const id of m.not_assessed_sec_ids) skippedBy.set(id, [...(skippedBy.get(id) ?? []), `${m.tool} (${m.status})`]);
|
|
117
|
+
|
|
118
|
+
const rows: ResultRow[] = [];
|
|
119
|
+
for (const c of kit.controls.filter((x) => inScope(x, scope)).sort((a, b) => cmp(a.id, b.id))) {
|
|
120
|
+
const mine = fails.filter((f) => f.sec_ids.includes(c.id));
|
|
121
|
+
if (mine.length) {
|
|
122
|
+
const where1 = mine.flatMap((f) => f.evidence.slice(0, 3).map((e) => `${f.source} at ${where(e)}`));
|
|
123
|
+
rows.push({ id: c.id, result: 'FAIL', notes: clip(`Failed by ${[...new Set(where1)].join('; ')}.`, MAX_NOTE) });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const words = checkWords.get(c.id) ?? [];
|
|
127
|
+
const pass = words.find((w) => w.result === 'PASS (static)');
|
|
128
|
+
if (pass && c.authority === 'repo') {
|
|
129
|
+
rows.push({ id: c.id, result: 'PASS (static)', notes: clip(`Settled by ${pass.check_id} on the files of this commit.`, MAX_NOTE) });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// A check that ran and found nothing wrong names the sub state that would settle the rest.
|
|
133
|
+
const routed = words.map((w) => w.result).find((w) => w.startsWith('NOT ASSESSED: ')) as ResultWord | undefined;
|
|
134
|
+
const state = (routed ? routed.slice('NOT ASSESSED: '.length) : routeFor(c)) as RouteState;
|
|
135
|
+
const parts = [routed ? `${words.map((w) => w.check_id).join(', ')} found nothing wrong in the files; that cannot settle it.` : '', OWNER_ACTION[state]];
|
|
136
|
+
const skipped = skippedBy.get(c.id);
|
|
137
|
+
if (skipped) parts.push(`Not run: ${skipped.join(', ')}.`);
|
|
138
|
+
rows.push({ id: c.id, result: `NOT ASSESSED: ${state}`, notes: clip(parts.filter(Boolean).join(' '), MAX_NOTE) });
|
|
139
|
+
}
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Requirement IDs of a fail split into those in scope (they make a fail record) and the rest.
|
|
144
|
+
function split(ids: string[], input: BuildInput): { scoped: string[]; known: string[]; unknown: string[] } {
|
|
145
|
+
const known = ids.filter((id) => input.kit.byId.has(id));
|
|
146
|
+
return {
|
|
147
|
+
scoped: known.filter((id) => inScope(input.kit.byId.get(id)!, input.scope)).sort(cmp),
|
|
148
|
+
known: known.sort(cmp),
|
|
149
|
+
unknown: ids.filter((id) => !input.kit.byId.has(id)).sort(cmp),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const refs = (ev: { file: string; line?: number; note: string }[]): FileLineNote[] =>
|
|
154
|
+
ev.slice(0, 8).map((e) => ({ file: e.file, line: e.line && e.line > 0 ? e.line : 1, note: clip(e.note, 300) }));
|
|
155
|
+
|
|
156
|
+
export interface Dropped { source: string; ids: string[]; where: string }
|
|
157
|
+
|
|
158
|
+
// Past the kit's size warning even on one line, a rule that matched more than COLLAPSE_FILES files gets one record for
|
|
159
|
+
// all its files instead of one per file; every hit stays in scan/<tool>.json, and the record says how many files.
|
|
160
|
+
// ponytail: fixed file count per rule; lower it or group per folder if a repository still passes the kit limit.
|
|
161
|
+
const COLLAPSE_FILES = 20;
|
|
162
|
+
export function buildFindings(all: BuildInput): { file: FindingsFile; dropped: Dropped[] } {
|
|
163
|
+
const first = buildFindingsWith(all, null);
|
|
164
|
+
return stableJsonCompact(first.file).length <= KIT_WARN_BLOCK_CHARS ? first : buildFindingsWith(all, COLLAPSE_FILES);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildFindingsWith(all: BuildInput, collapse: number | null): { file: FindingsFile; dropped: Dropped[] } {
|
|
168
|
+
const { input, test } = separateTestCode(all);
|
|
169
|
+
// tool and rule -> files it matched, in shipped code and in test code apart.
|
|
170
|
+
const filesOf = new Map<string, Set<string>>();
|
|
171
|
+
const note = (k: string, f: string): void => { filesOf.set(k, (filesOf.get(k) ?? new Set()).add(f)); };
|
|
172
|
+
for (const h of input.hits) note(`${h.tool}\u0000${h.rule_id}`, h.file);
|
|
173
|
+
for (const t of test) if (t.hit) note(`${t.kind}\u0000${t.hit.tool}\u0000${t.hit.rule_id}`, t.hit.file);
|
|
174
|
+
const many = (k: string): number => (collapse !== null && (filesOf.get(k)?.size ?? 0) > collapse ? filesOf.get(k)!.size : 0);
|
|
175
|
+
const records: FindingRecord[] = [];
|
|
176
|
+
const dropped: Dropped[] = [];
|
|
177
|
+
const add = (o: {
|
|
178
|
+
source: string; ruleId: string; tool: string; toolVersion: string; configSha: string | null; ids: string[];
|
|
179
|
+
evidence: { file: string; line?: number; note: string }[]; msg: ReturnType<typeof checkMessage>; absence: CheckResult['absence_search']; line?: number;
|
|
180
|
+
}): void => {
|
|
181
|
+
const s = split(o.ids, input);
|
|
182
|
+
const file0 = fpPath(o.evidence[0]?.file ?? '-');
|
|
183
|
+
if (s.unknown.length) dropped.push({ source: o.source, ids: s.unknown, where: where(o.evidence[0] ?? { file: '-' }) });
|
|
184
|
+
if (s.scoped.length === 0) {
|
|
185
|
+
// Outside this run's edition or stage, or unmapped: shown as an observation, never in the gate.
|
|
186
|
+
const obs: ObservationRecord = {
|
|
187
|
+
fingerprint: `OBS:${file0}:${slug(`${o.ruleId} ${o.line ?? ''}`)}`, kind: 'observation', sec_ids: s.known, title: clip(o.msg.title, 160),
|
|
188
|
+
where: refs(o.evidence).slice(0, 4),
|
|
189
|
+
note: clip(s.known.length ? `Outside this run's scope (${input.scope.edition} edition, ${input.scope.stage} stage): ${s.known.join(', ')}. ${o.msg.fix}` : `No requirement is mapped to ${o.source}${s.unknown.length ? ` in this kit (named: ${s.unknown.join(', ')})` : ''}. ${o.msg.fix}`, 400),
|
|
190
|
+
raised_by: ['hullproof-engine'],
|
|
191
|
+
};
|
|
192
|
+
records.push(obs);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const sev: Severity = highest(s.scoped.map((id) => input.kit.byId.get(id)!.severity));
|
|
196
|
+
const rec: FailRecord = {
|
|
197
|
+
fingerprint: `${s.scoped[0]}:${file0}:${slug(`${o.ruleId} ${o.line ?? ''}`)}`,
|
|
198
|
+
kind: 'fail', sec_ids: s.scoped, title: clip(o.msg.title, 160),
|
|
199
|
+
class: o.tool === 'hullproof-check' && ABSENCE_CHECKS.has(o.ruleId) && o.absence ? 'missing_control' : 'finding',
|
|
200
|
+
gap_in_standard: false, severity: sev, confidence: 'SUSPECTED', demonstrated: false,
|
|
201
|
+
root_cause: clip(o.msg.root, 120), raised_by: ['hullproof-engine'], impact_statement: clip(o.msg.impact, 600),
|
|
202
|
+
remediation: { strategy: clip(o.msg.fix, 800), confirm: clip(o.msg.confirm, 400) },
|
|
203
|
+
evidence: refs(o.evidence),
|
|
204
|
+
machine_evidence: { tool: o.tool, rule_id: o.ruleId, tool_version: o.toolVersion.replace(/[^A-Za-z0-9._+-]/g, '-') || 'unknown', config_sha256: o.configSha },
|
|
205
|
+
};
|
|
206
|
+
if (rec.class === 'missing_control') {
|
|
207
|
+
rec.absence_search = { command: clip(`${o.ruleId}: ${o.absence!.pattern} over ${o.absence!.file_globs.join(' ') || 'the selected files'}`, 300), files_searched: o.absence!.files_searched, count: o.absence!.matches, practice: 'present_unsafe' };
|
|
208
|
+
}
|
|
209
|
+
records.push(rec);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
for (const r of input.checks.results) {
|
|
213
|
+
if (r.result !== 'FAIL') continue;
|
|
214
|
+
add({ source: r.check_id, ruleId: r.check_id, tool: 'hullproof-check', toolVersion: ENGINE_VERSION, configSha: input.checkShas[r.check_id] ?? null,
|
|
215
|
+
ids: r.sec_ids, evidence: r.evidence, msg: checkMessage(r.check_id), absence: r.absence_search });
|
|
216
|
+
}
|
|
217
|
+
// Scanner hits: one record per tool, rule and file, with up to 8 lines.
|
|
218
|
+
const groups = new Map<string, Hit[]>();
|
|
219
|
+
for (const h of input.hits) {
|
|
220
|
+
const k = `${h.tool}\u0000${h.rule_id}\u0000${many(`${h.tool}\u0000${h.rule_id}`) ? '*' : h.file}`;
|
|
221
|
+
groups.set(k, [...(groups.get(k) ?? []), h]);
|
|
222
|
+
}
|
|
223
|
+
for (const k of [...groups.keys()].sort(cmp)) {
|
|
224
|
+
const hs = groups.get(k)!;
|
|
225
|
+
const h = hs[0];
|
|
226
|
+
const n = many(`${h.tool}\u0000${h.rule_id}`);
|
|
227
|
+
const meta = input.metas.find((m) => m.tool === h.tool);
|
|
228
|
+
const label = n ? `${h.tool} rule ${h.rule_id}, which matched ${n} files (all in scan/${h.tool}.json)` : `${h.tool} rule ${h.rule_id}`;
|
|
229
|
+
add({ source: `${h.tool} rule ${h.rule_id}`, ruleId: h.rule_id, tool: h.tool, toolVersion: meta?.version ?? 'unknown', configSha: meta?.config_sha256 ?? null,
|
|
230
|
+
ids: [...new Set(hs.flatMap((x) => x.sec_ids))], evidence: hs.map((x) => ({ file: x.file, line: x.line || undefined, note: clip(label, 300) })),
|
|
231
|
+
msg: ruleMessage(h.tool, h.rule_id, input.rulesDirs), absence: null, line: n ? undefined : h.line });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Test code and leads: one observation per tool, rule and file, marked by a fingerprint slug that starts with
|
|
235
|
+
// test-code or lead.
|
|
236
|
+
const testGroups = new Map<string, TestItem[]>();
|
|
237
|
+
for (const t of test) {
|
|
238
|
+
const k = t.hit
|
|
239
|
+
? `${t.kind}\u0000${t.hit.tool}\u0000${t.hit.rule_id}\u0000${many(`${t.kind}\u0000${t.hit.tool}\u0000${t.hit.rule_id}`) ? '*' : t.hit.file}`
|
|
240
|
+
: `${t.kind}\u0000hullproof-check\u0000${t.check!.check_id}\u0000${t.check!.evidence[0].file}`;
|
|
241
|
+
testGroups.set(k, [...(testGroups.get(k) ?? []), t]);
|
|
242
|
+
}
|
|
243
|
+
for (const k of [...testGroups.keys()].sort(cmp)) {
|
|
244
|
+
const ts = testGroups.get(k)!;
|
|
245
|
+
const [kind, tool, ruleId, key] = k.split('\u0000');
|
|
246
|
+
const file = key === '*' ? ts[0].hit!.file : key;
|
|
247
|
+
const msg = ts[0].hit ? ruleMessage(tool, ruleId, input.rulesDirs) : checkMessage(ruleId);
|
|
248
|
+
const ev = ts.flatMap((t) => (t.hit ? [{ file: t.hit.file, line: t.hit.line || undefined, note: `${tool} rule ${ruleId}` }] : t.check!.evidence));
|
|
249
|
+
const ids = [...new Set(ts.flatMap((t) => t.ids))].filter((id) => input.kit.byId.has(id)).sort(cmp);
|
|
250
|
+
const isLead = kind === 'lead';
|
|
251
|
+
const isExample = !isLead && ts.every((t) => !!t.hit?.noise);
|
|
252
|
+
records.push({
|
|
253
|
+
fingerprint: `OBS:${fpPath(file)}:${slug(`${isLead ? 'lead' : 'test code'} ${ruleId} ${ts[0].hit?.line ?? ''}`, 14)}`, kind: 'observation', sec_ids: ids,
|
|
254
|
+
title: clip(`${isLead ? 'Lead' : isExample ? 'Example value' : 'Test code'}: ${msg.title}`, 160), where: refs(ev).slice(0, 4),
|
|
255
|
+
note: clip(isLead
|
|
256
|
+
? `Lead from ${ts[0].hit ? 'rule' : 'check'} ${ruleId}, whose confidence tier is lead: ${ts[0].reason}. Read the code; it never holds the gate.${ids.length ? ` If real it fails ${ids.join(', ')}.` : ''}${ts[0].total ? ` ${ts[0].total} places in all; the first 4 are listed here and every one is in checks.json.` : ''}`
|
|
257
|
+
: `${isExample ? 'Example value' : 'Test code'} (${ts[0].reason}): kept as a lead, never in the gate.${ids.length ? ` In shipped code it would fail ${ids.join(', ')}.` : ''} ${msg.fix}`, 400),
|
|
258
|
+
raised_by: ['hullproof-engine'],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Fingerprints are unique: a second record with the same tail gets a short hash of its source.
|
|
263
|
+
const seen = new Set<string>();
|
|
264
|
+
for (const r of records) {
|
|
265
|
+
let fp = r.fingerprint;
|
|
266
|
+
const tail = fp.slice(fp.indexOf(':') + 1);
|
|
267
|
+
if (seen.has(tail)) fp = `${fp}-${createHash('sha256').update(JSON.stringify(r)).digest('hex').slice(0, 6).replace(/[^a-z0-9]/g, 'a')}`;
|
|
268
|
+
seen.add(fp.slice(fp.indexOf(':') + 1));
|
|
269
|
+
r.fingerprint = fp;
|
|
270
|
+
}
|
|
271
|
+
records.sort((a, b) => cmp(a.fingerprint, b.fingerprint));
|
|
272
|
+
const needsVerifier = records.some((r) => r.kind === 'fail' && (r.severity === 'BLOCKER' || r.severity === 'CRITICAL' || r.sec_ids.some((id) => ['BLOCKER', 'CRITICAL'].includes(input.kit.byId.get(id)!.severity))));
|
|
273
|
+
const file: FindingsFile = {
|
|
274
|
+
schema_version: '1.1.0',
|
|
275
|
+
report: {
|
|
276
|
+
commit: input.git.commit, tree: input.git.tree, tree_clean: input.git.clean, stage: input.scope.stage, profile: 'standard', scope: 'full',
|
|
277
|
+
// No model and no verifier run in Phase 1. A BLOCKER or CRITICAL record needs a verifier outcome in a complete run,
|
|
278
|
+
// so such a run is recorded incomplete with the kit's reason for exactly this: deterministic_only (schema 1.1.0).
|
|
279
|
+
run_status: needsVerifier ? 'incomplete' : 'complete',
|
|
280
|
+
...(needsVerifier ? { incomplete_reason: 'deterministic_only' as const } : {}),
|
|
281
|
+
agent_calls: 0, budget: null,
|
|
282
|
+
},
|
|
283
|
+
records,
|
|
284
|
+
};
|
|
285
|
+
return { file, dropped: dropped.sort((a, b) => cmp(a.source + a.where, b.source + b.where)) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The kit validator warns on a findings block over 150000 characters and rejects one over 200000 (rule F3). Indented JSON
|
|
289
|
+
// is written while it stays under the warning size; past it the same sorted JSON goes on one line, which is about 40
|
|
290
|
+
// percent shorter on the targets measured (juice-shop with a large rule pack: 207103 indented).
|
|
291
|
+
// ponytail: compact JSON only raises the ceiling; group records per rule instead of per file if a repository passes it.
|
|
292
|
+
export const KIT_WARN_BLOCK_CHARS = 150000;
|
|
293
|
+
export function findingsJson(f: FindingsFile): string {
|
|
294
|
+
const pretty = stableJson(f);
|
|
295
|
+
return pretty.length <= KIT_WARN_BLOCK_CHARS ? pretty : stableJsonCompact(f);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function findingsMarkdown(f: FindingsFile, json: string): string {
|
|
299
|
+
return [
|
|
300
|
+
'# Findings record', '',
|
|
301
|
+
`Written by the Hullproof Engine ${ENGINE_VERSION} for commit ${f.report.commit}. ${f.records.some((r) => r.raised_by.includes('hullproof-hunt')) ? 'Every record comes from a check, a scanner rule or a lead the engine confirmed with evidence it can re run; no model decides any record.' : 'Every record comes from a check or a scanner rule, with no model.'} Validate it with tools/hullproof/ci/validate-findings.mjs from the kit.`, '',
|
|
302
|
+
'```hullproof-findings', json.trimEnd(), '```', '',
|
|
303
|
+
].join('\n');
|
|
304
|
+
}
|