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,65 @@
|
|
|
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 { cmp, stableJson } from '../util.js';
|
|
6
|
+
import { validateSpec } from './spec.js';
|
|
7
|
+
export const proveDir = (scanDir) => join(scanDir, 'prove');
|
|
8
|
+
const write = (p, text) => { mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(`${p}.tmp`, text); renameSync(`${p}.tmp`, p); };
|
|
9
|
+
const readJson = (p) => { try {
|
|
10
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
throw new Error(`${p} cannot be read: ${e.message}`);
|
|
14
|
+
} };
|
|
15
|
+
export const specFile = (dir, id) => join(dir, 'specs', `${id}.json`);
|
|
16
|
+
export const writeSpec = (dir, s) => write(specFile(dir, s.spec_id), stableJson(s));
|
|
17
|
+
export const runFile = (dir, id, run) => join(dir, 'runs', id, `run-${run}.json`);
|
|
18
|
+
export const writeRun = (dir, r) => write(runFile(dir, r.spec_id, r.run), stableJson(r));
|
|
19
|
+
export const clearRuns = (dir) => rmSync(join(dir, 'runs'), { recursive: true, force: true });
|
|
20
|
+
export const writeMeta = (dir, m) => write(join(dir, 'meta.json'), stableJson(m));
|
|
21
|
+
export const readMeta = (dir) => (existsSync(join(dir, 'meta.json')) ? readJson(join(dir, 'meta.json')) : null);
|
|
22
|
+
export const writeRecipe = (dir, r) => write(join(dir, 'recipe.json'), stableJson(r));
|
|
23
|
+
export const writeProveResult = (dir, r) => write(join(dir, 'result.json'), stableJson(r));
|
|
24
|
+
export const readProveResult = (dir) => (existsSync(join(dir, 'result.json')) ? readJson(join(dir, 'result.json')) : null);
|
|
25
|
+
// Every spec file, validated. A file that fails validation is dropped and counted, with the reason.
|
|
26
|
+
export function loadSpecs(dir) {
|
|
27
|
+
const specs = [];
|
|
28
|
+
const dropped = [];
|
|
29
|
+
const d = join(dir, 'specs');
|
|
30
|
+
if (!existsSync(d))
|
|
31
|
+
return { specs, dropped };
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
for (const f of readdirSync(d).filter((n) => n.endsWith('.json')).sort(cmp)) {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = JSON.parse(readFileSync(join(d, f), 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
dropped.push({ file: f, reason: 'not valid JSON' });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const v = validateSpec(raw);
|
|
43
|
+
if (!v.ok) {
|
|
44
|
+
dropped.push({ file: f, reason: v.errors.slice(0, 3).join('; ') });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (`${v.spec.spec_id}.json` !== f) {
|
|
48
|
+
dropped.push({ file: f, reason: `spec_id ${v.spec.spec_id} does not match the file name` });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (seen.has(v.spec.spec_id)) {
|
|
52
|
+
dropped.push({ file: f, reason: 'duplicate spec_id' });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
seen.add(v.spec.spec_id);
|
|
56
|
+
specs.push(v.spec);
|
|
57
|
+
}
|
|
58
|
+
return { specs, dropped };
|
|
59
|
+
}
|
|
60
|
+
export function readRuns(dir, id) {
|
|
61
|
+
const d = join(dir, 'runs', id);
|
|
62
|
+
if (!existsSync(d))
|
|
63
|
+
return [];
|
|
64
|
+
return readdirSync(d).filter((n) => /^run-\d+\.json$/.test(n)).sort(cmp).map((n) => readJson(join(d, n))).sort((a, b) => a.run - b.run);
|
|
65
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
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.js';
|
|
6
|
+
import { ENGINE_VERSION } from '../version.js';
|
|
7
|
+
import { checkMessage, ruleMessage } from './messages.js';
|
|
8
|
+
import { highest, inScope } from './kit.js';
|
|
9
|
+
import { TEST_ARTIFACT_IDS, testCodeReason } from './test-code.js';
|
|
10
|
+
import { isSecretHit, leadReason, leadTier, leadTierForCheck, loadTiers } from './tiers.js';
|
|
11
|
+
const MAX_NOTE = 600;
|
|
12
|
+
// Checks whose FAIL means a control is missing everywhere searched (class missing_control, with the search recorded).
|
|
13
|
+
const ABSENCE_CHECKS = new Set(['check.web.security-headers-configured', 'check.auth.login-rate-limit', 'check.supply.lockfile-committed']);
|
|
14
|
+
// A fingerprint path has no spaces or colons.
|
|
15
|
+
const fpPath = (f) => f.replace(/[\s:]/g, '_');
|
|
16
|
+
const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1).trimEnd() + '…');
|
|
17
|
+
const slug = (s, max = 12) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').split('-').filter(Boolean).slice(0, max).join('-') || 'x';
|
|
18
|
+
const where = (e) => (e.line ? `${e.file}:${e.line}` : e.file);
|
|
19
|
+
// The sub state for a requirement nothing settled, from its authority and how it is verified.
|
|
20
|
+
export function routeFor(c) {
|
|
21
|
+
if (c.authority === 'dashboard')
|
|
22
|
+
return 'NEEDS DASHBOARD';
|
|
23
|
+
if (c.authority === 'runtime')
|
|
24
|
+
return 'NEEDS DYNAMIC TEST';
|
|
25
|
+
return 'UNKNOWN';
|
|
26
|
+
}
|
|
27
|
+
export const OWNER_ACTION = {
|
|
28
|
+
'NEEDS DASHBOARD': 'Export the provider setting (screenshot or export, dated, naming the project) and record it.',
|
|
29
|
+
'NEEDS BUILD': 'Run a production build from this commit and scan the output.',
|
|
30
|
+
'NEEDS DYNAMIC TEST': 'Test the running app or a staging copy and record the requests and responses.',
|
|
31
|
+
'ASK OWNER': 'Answer the question in writing.',
|
|
32
|
+
'ATTESTATION': 'Sign and date a statement.',
|
|
33
|
+
'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.',
|
|
34
|
+
};
|
|
35
|
+
// Splits hits and failed checks into what can fail a requirement and what is only reported. In order:
|
|
36
|
+
// 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
|
|
37
|
+
// example value (h.noise: src/scanners/secret-noise.ts), which moves to an example value item.
|
|
38
|
+
// 2. In a test path, a requirement about shipped test material (TEST_ARTIFACT_IDS) keeps failing; every other ID moves
|
|
39
|
+
// to a test code item.
|
|
40
|
+
// 3. A hit of a rule whose tier is lead moves to a lead item.
|
|
41
|
+
export function separateTestCode(input) {
|
|
42
|
+
const test = [];
|
|
43
|
+
const hits = [];
|
|
44
|
+
const tiers = loadTiers(input.rulesDirs ?? []);
|
|
45
|
+
for (const h of input.hits) {
|
|
46
|
+
// 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).
|
|
47
|
+
if (isSecretHit(h) && h.noise) {
|
|
48
|
+
test.push({ hit: h, ids: h.sec_ids, reason: h.noise, kind: 'test' });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const reason = isSecretHit(h) ? null : testCodeReason(h.file);
|
|
52
|
+
if (reason) {
|
|
53
|
+
const keep = h.sec_ids.filter((id) => TEST_ARTIFACT_IDS.has(id));
|
|
54
|
+
if (keep.length)
|
|
55
|
+
hits.push({ ...h, sec_ids: keep });
|
|
56
|
+
if (keep.length < h.sec_ids.length || !h.sec_ids.length)
|
|
57
|
+
test.push({ hit: h, ids: h.sec_ids.filter((id) => !TEST_ARTIFACT_IDS.has(id)), reason, kind: 'test' });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const lead = leadTier(h, tiers);
|
|
61
|
+
if (lead) {
|
|
62
|
+
test.push({ hit: h, ids: h.sec_ids, reason: leadReason(lead, tiers), kind: 'lead' });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
hits.push(h);
|
|
66
|
+
}
|
|
67
|
+
const results = [];
|
|
68
|
+
for (const r of input.checks.results) {
|
|
69
|
+
// A check that reads the code map is a lead until it is measured: its FAIL is reported and never fails a requirement.
|
|
70
|
+
const leadCheck = r.result === 'FAIL' ? leadTierForCheck(r.check_id, tiers) : null;
|
|
71
|
+
if (leadCheck) {
|
|
72
|
+
const byFile = new Map();
|
|
73
|
+
for (const e of r.evidence)
|
|
74
|
+
byFile.set(e.file, [...(byFile.get(e.file) ?? []), e]);
|
|
75
|
+
const reason = leadReason(leadCheck, tiers);
|
|
76
|
+
// Past COLLAPSE_FILES files the check gets one record for all its places (the list stays in checks.json), so a repository
|
|
77
|
+
// with a thousand routes does not push the findings block past the kit limit.
|
|
78
|
+
if (byFile.size > COLLAPSE_FILES)
|
|
79
|
+
test.push({ check: r, ids: r.sec_ids, reason, kind: 'lead', total: r.evidence.length });
|
|
80
|
+
else
|
|
81
|
+
for (const f of [...byFile.keys()].sort(cmp))
|
|
82
|
+
test.push({ check: { ...r, evidence: byFile.get(f) }, ids: r.sec_ids, reason, kind: 'lead' });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const secret = r.sec_ids.some((id) => id.startsWith('SEC-SECRETS-'));
|
|
86
|
+
const inTest = r.result === 'FAIL' && !secret && !r.sec_ids.some((id) => TEST_ARTIFACT_IDS.has(id)) ? r.evidence.filter((e) => testCodeReason(e.file)) : [];
|
|
87
|
+
const shipped = r.evidence.filter((e) => !inTest.includes(e));
|
|
88
|
+
if (inTest.length)
|
|
89
|
+
test.push({ check: { ...r, evidence: inTest }, ids: r.sec_ids, reason: testCodeReason(inTest[0].file), kind: 'test' });
|
|
90
|
+
// A FAIL whose evidence is all in test code no longer fails; one with no evidence (an absence check) still does.
|
|
91
|
+
if (!inTest.length || shipped.length)
|
|
92
|
+
results.push(inTest.length ? { ...r, evidence: shipped } : r);
|
|
93
|
+
}
|
|
94
|
+
return { input: { ...input, hits, checks: { ...input.checks, results } }, test };
|
|
95
|
+
}
|
|
96
|
+
function failsFor(input) {
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const r of input.checks.results)
|
|
99
|
+
if (r.result === 'FAIL')
|
|
100
|
+
out.push({ source: r.check_id, sec_ids: r.sec_ids, evidence: r.evidence });
|
|
101
|
+
for (const h of input.hits)
|
|
102
|
+
if (h.sec_ids.length)
|
|
103
|
+
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 }] });
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
export function buildResults(all) {
|
|
107
|
+
const input = separateTestCode(all).input;
|
|
108
|
+
const { kit, scope } = input;
|
|
109
|
+
const fails = failsFor(input);
|
|
110
|
+
const checkWords = new Map();
|
|
111
|
+
for (const r of input.checks.results)
|
|
112
|
+
for (const id of r.sec_ids)
|
|
113
|
+
checkWords.set(id, [...(checkWords.get(id) ?? []), r]);
|
|
114
|
+
const skippedBy = new Map();
|
|
115
|
+
for (const m of input.metas)
|
|
116
|
+
if (m.status !== 'ran')
|
|
117
|
+
for (const id of m.not_assessed_sec_ids)
|
|
118
|
+
skippedBy.set(id, [...(skippedBy.get(id) ?? []), `${m.tool} (${m.status})`]);
|
|
119
|
+
const rows = [];
|
|
120
|
+
for (const c of kit.controls.filter((x) => inScope(x, scope)).sort((a, b) => cmp(a.id, b.id))) {
|
|
121
|
+
const mine = fails.filter((f) => f.sec_ids.includes(c.id));
|
|
122
|
+
if (mine.length) {
|
|
123
|
+
const where1 = mine.flatMap((f) => f.evidence.slice(0, 3).map((e) => `${f.source} at ${where(e)}`));
|
|
124
|
+
rows.push({ id: c.id, result: 'FAIL', notes: clip(`Failed by ${[...new Set(where1)].join('; ')}.`, MAX_NOTE) });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const words = checkWords.get(c.id) ?? [];
|
|
128
|
+
const pass = words.find((w) => w.result === 'PASS (static)');
|
|
129
|
+
if (pass && c.authority === 'repo') {
|
|
130
|
+
rows.push({ id: c.id, result: 'PASS (static)', notes: clip(`Settled by ${pass.check_id} on the files of this commit.`, MAX_NOTE) });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// A check that ran and found nothing wrong names the sub state that would settle the rest.
|
|
134
|
+
const routed = words.map((w) => w.result).find((w) => w.startsWith('NOT ASSESSED: '));
|
|
135
|
+
const state = (routed ? routed.slice('NOT ASSESSED: '.length) : routeFor(c));
|
|
136
|
+
const parts = [routed ? `${words.map((w) => w.check_id).join(', ')} found nothing wrong in the files; that cannot settle it.` : '', OWNER_ACTION[state]];
|
|
137
|
+
const skipped = skippedBy.get(c.id);
|
|
138
|
+
if (skipped)
|
|
139
|
+
parts.push(`Not run: ${skipped.join(', ')}.`);
|
|
140
|
+
rows.push({ id: c.id, result: `NOT ASSESSED: ${state}`, notes: clip(parts.filter(Boolean).join(' '), MAX_NOTE) });
|
|
141
|
+
}
|
|
142
|
+
return rows;
|
|
143
|
+
}
|
|
144
|
+
// Requirement IDs of a fail split into those in scope (they make a fail record) and the rest.
|
|
145
|
+
function split(ids, input) {
|
|
146
|
+
const known = ids.filter((id) => input.kit.byId.has(id));
|
|
147
|
+
return {
|
|
148
|
+
scoped: known.filter((id) => inScope(input.kit.byId.get(id), input.scope)).sort(cmp),
|
|
149
|
+
known: known.sort(cmp),
|
|
150
|
+
unknown: ids.filter((id) => !input.kit.byId.has(id)).sort(cmp),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const refs = (ev) => ev.slice(0, 8).map((e) => ({ file: e.file, line: e.line && e.line > 0 ? e.line : 1, note: clip(e.note, 300) }));
|
|
154
|
+
// Past the kit's size warning even on one line, a rule that matched more than COLLAPSE_FILES files gets one record for
|
|
155
|
+
// all its files instead of one per file; every hit stays in scan/<tool>.json, and the record says how many files.
|
|
156
|
+
// ponytail: fixed file count per rule; lower it or group per folder if a repository still passes the kit limit.
|
|
157
|
+
const COLLAPSE_FILES = 20;
|
|
158
|
+
export function buildFindings(all) {
|
|
159
|
+
const first = buildFindingsWith(all, null);
|
|
160
|
+
return stableJsonCompact(first.file).length <= KIT_WARN_BLOCK_CHARS ? first : buildFindingsWith(all, COLLAPSE_FILES);
|
|
161
|
+
}
|
|
162
|
+
function buildFindingsWith(all, collapse) {
|
|
163
|
+
const { input, test } = separateTestCode(all);
|
|
164
|
+
// tool and rule -> files it matched, in shipped code and in test code apart.
|
|
165
|
+
const filesOf = new Map();
|
|
166
|
+
const note = (k, f) => { filesOf.set(k, (filesOf.get(k) ?? new Set()).add(f)); };
|
|
167
|
+
for (const h of input.hits)
|
|
168
|
+
note(`${h.tool}\u0000${h.rule_id}`, h.file);
|
|
169
|
+
for (const t of test)
|
|
170
|
+
if (t.hit)
|
|
171
|
+
note(`${t.kind}\u0000${t.hit.tool}\u0000${t.hit.rule_id}`, t.hit.file);
|
|
172
|
+
const many = (k) => (collapse !== null && (filesOf.get(k)?.size ?? 0) > collapse ? filesOf.get(k).size : 0);
|
|
173
|
+
const records = [];
|
|
174
|
+
const dropped = [];
|
|
175
|
+
const add = (o) => {
|
|
176
|
+
const s = split(o.ids, input);
|
|
177
|
+
const file0 = fpPath(o.evidence[0]?.file ?? '-');
|
|
178
|
+
if (s.unknown.length)
|
|
179
|
+
dropped.push({ source: o.source, ids: s.unknown, where: where(o.evidence[0] ?? { file: '-' }) });
|
|
180
|
+
if (s.scoped.length === 0) {
|
|
181
|
+
// Outside this run's edition or stage, or unmapped: shown as an observation, never in the gate.
|
|
182
|
+
const obs = {
|
|
183
|
+
fingerprint: `OBS:${file0}:${slug(`${o.ruleId} ${o.line ?? ''}`)}`, kind: 'observation', sec_ids: s.known, title: clip(o.msg.title, 160),
|
|
184
|
+
where: refs(o.evidence).slice(0, 4),
|
|
185
|
+
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),
|
|
186
|
+
raised_by: ['hullproof-engine'],
|
|
187
|
+
};
|
|
188
|
+
records.push(obs);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const sev = highest(s.scoped.map((id) => input.kit.byId.get(id).severity));
|
|
192
|
+
const rec = {
|
|
193
|
+
fingerprint: `${s.scoped[0]}:${file0}:${slug(`${o.ruleId} ${o.line ?? ''}`)}`,
|
|
194
|
+
kind: 'fail', sec_ids: s.scoped, title: clip(o.msg.title, 160),
|
|
195
|
+
class: o.tool === 'hullproof-check' && ABSENCE_CHECKS.has(o.ruleId) && o.absence ? 'missing_control' : 'finding',
|
|
196
|
+
gap_in_standard: false, severity: sev, confidence: 'SUSPECTED', demonstrated: false,
|
|
197
|
+
root_cause: clip(o.msg.root, 120), raised_by: ['hullproof-engine'], impact_statement: clip(o.msg.impact, 600),
|
|
198
|
+
remediation: { strategy: clip(o.msg.fix, 800), confirm: clip(o.msg.confirm, 400) },
|
|
199
|
+
evidence: refs(o.evidence),
|
|
200
|
+
machine_evidence: { tool: o.tool, rule_id: o.ruleId, tool_version: o.toolVersion.replace(/[^A-Za-z0-9._+-]/g, '-') || 'unknown', config_sha256: o.configSha },
|
|
201
|
+
};
|
|
202
|
+
if (rec.class === 'missing_control') {
|
|
203
|
+
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' };
|
|
204
|
+
}
|
|
205
|
+
records.push(rec);
|
|
206
|
+
};
|
|
207
|
+
for (const r of input.checks.results) {
|
|
208
|
+
if (r.result !== 'FAIL')
|
|
209
|
+
continue;
|
|
210
|
+
add({ source: r.check_id, ruleId: r.check_id, tool: 'hullproof-check', toolVersion: ENGINE_VERSION, configSha: input.checkShas[r.check_id] ?? null,
|
|
211
|
+
ids: r.sec_ids, evidence: r.evidence, msg: checkMessage(r.check_id), absence: r.absence_search });
|
|
212
|
+
}
|
|
213
|
+
// Scanner hits: one record per tool, rule and file, with up to 8 lines.
|
|
214
|
+
const groups = new Map();
|
|
215
|
+
for (const h of input.hits) {
|
|
216
|
+
const k = `${h.tool}\u0000${h.rule_id}\u0000${many(`${h.tool}\u0000${h.rule_id}`) ? '*' : h.file}`;
|
|
217
|
+
groups.set(k, [...(groups.get(k) ?? []), h]);
|
|
218
|
+
}
|
|
219
|
+
for (const k of [...groups.keys()].sort(cmp)) {
|
|
220
|
+
const hs = groups.get(k);
|
|
221
|
+
const h = hs[0];
|
|
222
|
+
const n = many(`${h.tool}\u0000${h.rule_id}`);
|
|
223
|
+
const meta = input.metas.find((m) => m.tool === h.tool);
|
|
224
|
+
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}`;
|
|
225
|
+
add({ source: `${h.tool} rule ${h.rule_id}`, ruleId: h.rule_id, tool: h.tool, toolVersion: meta?.version ?? 'unknown', configSha: meta?.config_sha256 ?? null,
|
|
226
|
+
ids: [...new Set(hs.flatMap((x) => x.sec_ids))], evidence: hs.map((x) => ({ file: x.file, line: x.line || undefined, note: clip(label, 300) })),
|
|
227
|
+
msg: ruleMessage(h.tool, h.rule_id, input.rulesDirs), absence: null, line: n ? undefined : h.line });
|
|
228
|
+
}
|
|
229
|
+
// Test code and leads: one observation per tool, rule and file, marked by a fingerprint slug that starts with
|
|
230
|
+
// test-code or lead.
|
|
231
|
+
const testGroups = new Map();
|
|
232
|
+
for (const t of test) {
|
|
233
|
+
const k = t.hit
|
|
234
|
+
? `${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}`
|
|
235
|
+
: `${t.kind}\u0000hullproof-check\u0000${t.check.check_id}\u0000${t.check.evidence[0].file}`;
|
|
236
|
+
testGroups.set(k, [...(testGroups.get(k) ?? []), t]);
|
|
237
|
+
}
|
|
238
|
+
for (const k of [...testGroups.keys()].sort(cmp)) {
|
|
239
|
+
const ts = testGroups.get(k);
|
|
240
|
+
const [kind, tool, ruleId, key] = k.split('\u0000');
|
|
241
|
+
const file = key === '*' ? ts[0].hit.file : key;
|
|
242
|
+
const msg = ts[0].hit ? ruleMessage(tool, ruleId, input.rulesDirs) : checkMessage(ruleId);
|
|
243
|
+
const ev = ts.flatMap((t) => (t.hit ? [{ file: t.hit.file, line: t.hit.line || undefined, note: `${tool} rule ${ruleId}` }] : t.check.evidence));
|
|
244
|
+
const ids = [...new Set(ts.flatMap((t) => t.ids))].filter((id) => input.kit.byId.has(id)).sort(cmp);
|
|
245
|
+
const isLead = kind === 'lead';
|
|
246
|
+
const isExample = !isLead && ts.every((t) => !!t.hit?.noise);
|
|
247
|
+
records.push({
|
|
248
|
+
fingerprint: `OBS:${fpPath(file)}:${slug(`${isLead ? 'lead' : 'test code'} ${ruleId} ${ts[0].hit?.line ?? ''}`, 14)}`, kind: 'observation', sec_ids: ids,
|
|
249
|
+
title: clip(`${isLead ? 'Lead' : isExample ? 'Example value' : 'Test code'}: ${msg.title}`, 160), where: refs(ev).slice(0, 4),
|
|
250
|
+
note: clip(isLead
|
|
251
|
+
? `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.` : ''}`
|
|
252
|
+
: `${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),
|
|
253
|
+
raised_by: ['hullproof-engine'],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// Fingerprints are unique: a second record with the same tail gets a short hash of its source.
|
|
257
|
+
const seen = new Set();
|
|
258
|
+
for (const r of records) {
|
|
259
|
+
let fp = r.fingerprint;
|
|
260
|
+
const tail = fp.slice(fp.indexOf(':') + 1);
|
|
261
|
+
if (seen.has(tail))
|
|
262
|
+
fp = `${fp}-${createHash('sha256').update(JSON.stringify(r)).digest('hex').slice(0, 6).replace(/[^a-z0-9]/g, 'a')}`;
|
|
263
|
+
seen.add(fp.slice(fp.indexOf(':') + 1));
|
|
264
|
+
r.fingerprint = fp;
|
|
265
|
+
}
|
|
266
|
+
records.sort((a, b) => cmp(a.fingerprint, b.fingerprint));
|
|
267
|
+
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))));
|
|
268
|
+
const file = {
|
|
269
|
+
schema_version: '1.1.0',
|
|
270
|
+
report: {
|
|
271
|
+
commit: input.git.commit, tree: input.git.tree, tree_clean: input.git.clean, stage: input.scope.stage, profile: 'standard', scope: 'full',
|
|
272
|
+
// No model and no verifier run in Phase 1. A BLOCKER or CRITICAL record needs a verifier outcome in a complete run,
|
|
273
|
+
// so such a run is recorded incomplete with the kit's reason for exactly this: deterministic_only (schema 1.1.0).
|
|
274
|
+
run_status: needsVerifier ? 'incomplete' : 'complete',
|
|
275
|
+
...(needsVerifier ? { incomplete_reason: 'deterministic_only' } : {}),
|
|
276
|
+
agent_calls: 0, budget: null,
|
|
277
|
+
},
|
|
278
|
+
records,
|
|
279
|
+
};
|
|
280
|
+
return { file, dropped: dropped.sort((a, b) => cmp(a.source + a.where, b.source + b.where)) };
|
|
281
|
+
}
|
|
282
|
+
// The kit validator warns on a findings block over 150000 characters and rejects one over 200000 (rule F3). Indented JSON
|
|
283
|
+
// is written while it stays under the warning size; past it the same sorted JSON goes on one line, which is about 40
|
|
284
|
+
// percent shorter on the targets measured (juice-shop with a large rule pack: 207103 indented).
|
|
285
|
+
// ponytail: compact JSON only raises the ceiling; group records per rule instead of per file if a repository passes it.
|
|
286
|
+
export const KIT_WARN_BLOCK_CHARS = 150000;
|
|
287
|
+
export function findingsJson(f) {
|
|
288
|
+
const pretty = stableJson(f);
|
|
289
|
+
return pretty.length <= KIT_WARN_BLOCK_CHARS ? pretty : stableJsonCompact(f);
|
|
290
|
+
}
|
|
291
|
+
export function findingsMarkdown(f, json) {
|
|
292
|
+
return [
|
|
293
|
+
'# Findings record', '',
|
|
294
|
+
`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.`, '',
|
|
295
|
+
'```hullproof-findings', json.trimEnd(), '```', '',
|
|
296
|
+
].join('\n');
|
|
297
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { cmp } from '../util.js';
|
|
2
|
+
export const MAX_TABLE_ROWS = 300;
|
|
3
|
+
const cell = (s) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
4
|
+
const real = (ep, kind) => ep.protections.filter((p) => p.kind === kind);
|
|
5
|
+
// One cell for one protection kind: yes with its confidence, ignored (the code checks and carries on), weak, no.
|
|
6
|
+
export function protectionCell(ep, kind) {
|
|
7
|
+
const ps = real(ep, kind);
|
|
8
|
+
const good = ps.filter((p) => p.confidence !== 'low' && p.enforced !== false);
|
|
9
|
+
if (good.length) {
|
|
10
|
+
const best = good.some((p) => p.confidence === 'high') ? 'high' : 'medium';
|
|
11
|
+
return `yes (${best})`;
|
|
12
|
+
}
|
|
13
|
+
if (ps.some((p) => p.enforced === false))
|
|
14
|
+
return 'ignored';
|
|
15
|
+
if (ps.length)
|
|
16
|
+
return 'weak';
|
|
17
|
+
return 'no';
|
|
18
|
+
}
|
|
19
|
+
function rowFor(ep) {
|
|
20
|
+
const state = ep.tags.includes('state_changing');
|
|
21
|
+
const admin = ep.tags.includes('admin');
|
|
22
|
+
const byId = ep.data.filter((d) => d.by_id);
|
|
23
|
+
const owner = !byId.length ? '-' : byId.every((d) => d.owner_scoped) ? 'yes' : 'no';
|
|
24
|
+
const isWebhook = ep.kind === 'webhook';
|
|
25
|
+
const readsBody = ep.reads.some((r) => r.source === 'body' || r.source === 'form');
|
|
26
|
+
const session = ep.kind === 'ai_tool' || ep.kind === 'job' || ep.kind === 'page' || isWebhook ? '-' : protectionCell(ep, 'session_read');
|
|
27
|
+
return [
|
|
28
|
+
session,
|
|
29
|
+
admin ? protectionCell(ep, 'role_check') : '-',
|
|
30
|
+
owner,
|
|
31
|
+
isWebhook ? protectionCell(ep, 'signature_verify') : '-',
|
|
32
|
+
ep.tags.includes('auth_endpoint') && state ? protectionCell(ep, 'rate_limit') : '-',
|
|
33
|
+
state && readsBody ? protectionCell(ep, 'input_validation') : '-',
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
const label = (ep) => `${ep.method && ep.method !== 'ANY' ? ep.method + ' ' : ''}${ep.path ?? ep.name ?? ep.file}`.trim();
|
|
37
|
+
export function renderEntryPoints(map) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const p = (...l) => { out.push(...l); };
|
|
40
|
+
const eps = map.entry_points;
|
|
41
|
+
const shipped = eps.filter((e) => !e.test_code);
|
|
42
|
+
p('## Entry points and what protects them', '');
|
|
43
|
+
p('The code map lists every entry point the engine could find by reading the code: HTTP routes, webhooks, server actions, jobs, AI tools and similar. It comes from pattern reading, not from running the app, so each cell below is a place to look and not a proof. "yes" means the engine found that protection on the entry point, with the confidence of the match. "ignored" means the code checks and then carries on. "weak" means only a sign such as a cookie being present. "-" means the column does not apply.', '');
|
|
44
|
+
p('| Language | Files | Status | Entry points | Note |', '|---|---|---|---|---|');
|
|
45
|
+
if (map.coverage.length === 0)
|
|
46
|
+
p('| none | 0 | | 0 | No source language was found. |');
|
|
47
|
+
for (const c of map.coverage)
|
|
48
|
+
p(`| ${c.language} | ${c.files} | ${c.status === 'mapped' ? 'mapped' : c.status === 'partial' ? 'partly mapped' : 'not mapped'} | ${c.entry_points} | ${cell(c.note)} |`);
|
|
49
|
+
p('');
|
|
50
|
+
const incomplete = map.coverage.filter((c) => c.status !== 'mapped');
|
|
51
|
+
if (incomplete.length)
|
|
52
|
+
p(`The code map is incomplete for ${incomplete.map((c) => c.language).join(', ')}. Entry points in those files are not listed, and the checks that read the map did not look at them.`, '');
|
|
53
|
+
const kinds = Object.entries(map.counts.by_kind).map(([k, n]) => `${n} ${k.replace(/_/g, ' ')}`).join(', ');
|
|
54
|
+
const state = shipped.filter((e) => e.tags.includes('state_changing') && ['http_route', 'server_action', 'graphql_resolver', 'rpc'].includes(e.kind));
|
|
55
|
+
const noSession = state.filter((e) => !e.tags.includes('auth_endpoint') && !e.tags.includes('public') && protectionCell(e, 'session_read') !== 'yes (high)' && protectionCell(e, 'session_read') !== 'yes (medium)');
|
|
56
|
+
const admins = shipped.filter((e) => e.tags.includes('admin') && ['http_route', 'server_action', 'graphql_resolver', 'rpc'].includes(e.kind));
|
|
57
|
+
const noRole = admins.filter((e) => !protectionCell(e, 'role_check').startsWith('yes'));
|
|
58
|
+
const hooks = shipped.filter((e) => e.kind === 'webhook');
|
|
59
|
+
const badHooks = hooks.filter((e) => !protectionCell(e, 'signature_verify').startsWith('yes'));
|
|
60
|
+
p(`1. Entry points: ${map.counts.entry_points}${kinds ? ` (${kinds})` : ''}. In test code and left out of the checks: ${eps.length - shipped.length}.`);
|
|
61
|
+
p(`2. State changing entry points: ${state.length}. Without a session read the engine can see: ${noSession.length}.`);
|
|
62
|
+
p(`3. Entry points in an admin area: ${admins.length}. Without a role check the engine can see: ${noRole.length}.`);
|
|
63
|
+
p(`4. Webhooks: ${hooks.length}. Without a signature check that stops the request: ${badHooks.length}.`);
|
|
64
|
+
p('5. Gates (middleware, guards, router dependencies): ' + map.gates.length + '.', '');
|
|
65
|
+
p('| Entry point | Kind | Where | Session | Role | Owner | Signature | Rate limit | Schema |', '|---|---|---|---|---|---|---|---|---|');
|
|
66
|
+
const rows = eps.slice().sort((a, b) => cmp(a.file, b.file) || a.line - b.line || cmp(a.id, b.id)).slice(0, MAX_TABLE_ROWS);
|
|
67
|
+
for (const ep of rows) {
|
|
68
|
+
const r = rowFor(ep);
|
|
69
|
+
p(`| ${cell(label(ep))}${ep.test_code ? ' (test code)' : ''} | ${ep.kind.replace(/_/g, ' ')} | \`${ep.file}:${ep.line}\` | ${r.join(' | ')} |`);
|
|
70
|
+
}
|
|
71
|
+
if (eps.length > rows.length)
|
|
72
|
+
p('', `${eps.length - rows.length} more entry points are in codemap.json.`);
|
|
73
|
+
p('');
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readableKey } from '../confirm/agreement.js';
|
|
2
|
+
import { cmp } from '../util.js';
|
|
3
|
+
const loc = (e) => `\`${e.file}:${e.line}\``;
|
|
4
|
+
const METHOD = { map_query: 'a query over the code map', semgrep_rule: 'a generated Semgrep rule and a negative control', sandbox_test: 'a test run in a local sandbox, 3 of 3 runs' };
|
|
5
|
+
export function renderHunt(h, merged) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const p = (...l) => { out.push(...l); };
|
|
8
|
+
if (!h.result) {
|
|
9
|
+
if (h.skipped)
|
|
10
|
+
p('## Hunt', '', `Hunt: not run, ${h.skipped}.`, '');
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
const r = h.result;
|
|
14
|
+
const syn = new Map((h.synthesis?.explanations ?? []).map((e) => [e.key, e.text]));
|
|
15
|
+
const gateSet = new Set((merged?.inGate ?? []).map((c) => c.key));
|
|
16
|
+
const provenSet = new Set((merged?.proven ?? []).map((c) => c.key));
|
|
17
|
+
p('## Hunt: leads and confirmed findings', '');
|
|
18
|
+
p('A model proposed these and the engine checked them. Only a lead that the engine confirmed with evidence a machine can re run is a finding. Every other lead is listed for a person to read and can never hold or release the gate. The number of runs that raised a lead only orders the list.', '');
|
|
19
|
+
p(`1. Runs: ${r.runs}. Entry points hunted: ${r.entries_hunted}. Calls: ${r.calls.complete} complete, ${r.calls.incomplete} incomplete, ${r.calls.missing} never saved.`);
|
|
20
|
+
p(`2. Confirmed findings: ${r.confirmed.length}. Leads not confirmed: ${r.leads.length}.`);
|
|
21
|
+
p(`3. Hallucinations (leads that cited code that is not there, dropped): ${r.hallucinations.count}. Malformed leads (dropped): ${r.malformed.count}.`);
|
|
22
|
+
p(`4. Tokens used, reported and not limited: ${r.usage.input_tokens} in, ${r.usage.output_tokens} out, over ${r.usage.calls} calls.`, '');
|
|
23
|
+
if (h.synthesis) {
|
|
24
|
+
p('### Summary of all runs', '');
|
|
25
|
+
p(h.synthesis.status === 'complete' ? (h.synthesis.summary || 'The synthesis step returned no summary.') : `The synthesis step did not finish (${h.synthesis.stop ?? 'stopped'}). It only explains, so no finding is affected.`, '');
|
|
26
|
+
}
|
|
27
|
+
const conf = (c, i) => {
|
|
28
|
+
const e = c.confirmation;
|
|
29
|
+
p(`#### H-${i + 1} ${c.claim}${c.claim_truncated ? ' (claim shortened to the limit)' : ''}`, '');
|
|
30
|
+
p(`Where: ${c.path.map(loc).join(' to ')}. Requirements: ${c.requirement_ids.join(', ') || 'none named'}. Raised in ${c.runs_raised} of ${r.runs} runs.`);
|
|
31
|
+
if (e.status === 'confirmed')
|
|
32
|
+
p(`Evidence: confirmed by ${METHOD[e.method]} (${e.tool} ${e.tool_version}, ${e.rule_id}). ${e.detail}.`);
|
|
33
|
+
if (provenSet.has(c.key))
|
|
34
|
+
p('Proven by a test in the sandbox: yes, see the proof sandbox section.');
|
|
35
|
+
p(gateSet.has(c.key) ? 'Counts in the gate: yes, as a fail record with machine evidence.' : 'Counts in the gate: no, none of its requirements is in this run\'s scope (or it names none).');
|
|
36
|
+
if (syn.has(c.key))
|
|
37
|
+
p(`Explanation: ${syn.get(c.key)}`);
|
|
38
|
+
p('');
|
|
39
|
+
};
|
|
40
|
+
if (r.confirmed.length) {
|
|
41
|
+
p('### Confirmed findings', '');
|
|
42
|
+
r.confirmed.forEach(conf);
|
|
43
|
+
}
|
|
44
|
+
if (r.leads.length) {
|
|
45
|
+
p('### Leads (not confirmed, never in the gate)', '');
|
|
46
|
+
p('| Raised in | Where | Requirements | Claim | Why not confirmed |', '|---|---|---|---|---|');
|
|
47
|
+
for (const l of r.leads) {
|
|
48
|
+
const why = provenSet.has(l.key) ? 'proven by a test in the sandbox, see the proof sandbox section' : l.confirmation.status === 'confirmed' ? '' : l.confirmation.detail;
|
|
49
|
+
p(`| ${l.runs_raised} of ${r.runs} runs | ${loc(l.sink)} | ${l.requirement_ids.join(', ') || 'none named'} | ${l.claim_truncated ? '(claim shortened) ' : ''}${l.claim.replace(/\|/g, '\\|')}${syn.has(l.key) ? ' Explanation: ' + syn.get(l.key).replace(/\|/g, '\\|') : ''} | ${why.replace(/\|/g, '\\|')} |`);
|
|
50
|
+
}
|
|
51
|
+
p('');
|
|
52
|
+
}
|
|
53
|
+
if (r.incomplete.length) {
|
|
54
|
+
p('### Incomplete hunts', '', 'These calls did not finish. A provider stop (a refusal, a safety stop, a rate limit, a timeout) is recorded and never retried around, so the entry point below was not fully hunted in that run.', '');
|
|
55
|
+
p('| Run | Entry point | Why |', '|---|---|---|');
|
|
56
|
+
for (const i of r.incomplete.slice().sort((a, b) => a.run - b.run || cmp(a.entry_id, b.entry_id)))
|
|
57
|
+
p(`| ${i.run} | ${i.entry_id.replace(/\|/g, '\\|')} | ${i.stop}${i.detail ? ': ' + i.detail.replace(/\|/g, '\\|') : ''} |`);
|
|
58
|
+
p('');
|
|
59
|
+
}
|
|
60
|
+
if (r.hallucinations.count) {
|
|
61
|
+
p('### Dropped as hallucinations', '');
|
|
62
|
+
for (const x of r.hallucinations.items.slice(0, 30))
|
|
63
|
+
p(`- Run ${x.run}, ${readableKey(x.entry_id)}: ${x.reason}.`);
|
|
64
|
+
if (r.hallucinations.count > 30)
|
|
65
|
+
p(`- and ${r.hallucinations.count - 30} more (all are in hunt/result.json).`);
|
|
66
|
+
p('');
|
|
67
|
+
}
|
|
68
|
+
if (merged?.outOfScope.length)
|
|
69
|
+
p(`${merged.outOfScope.length} confirmed finding(s) name no requirement in this run's scope, so they are shown above and are not in the gate.`, '');
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Finds the Hullproof kit (docs/hullproof and tools/hullproof) and reads the controls file, the source of truth for
|
|
2
|
+
// severity, authority, edition and stage. The engine never copies severities into its own code.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
export const CONTROLS_REL = 'docs/hullproof/security-controls.json';
|
|
8
|
+
export const GATE_REL = 'tools/hullproof/ci/hullproof-gate.mjs';
|
|
9
|
+
export const VALIDATOR_REL = 'tools/hullproof/ci/validate-findings.mjs';
|
|
10
|
+
// The Hullproof Free kit in a folder called hullproof-free next to this repository, used when nothing else names a kit
|
|
11
|
+
// (same default as src/mapping).
|
|
12
|
+
const SIBLING_KIT = fileURLToPath(new URL('../../../hullproof-free/kit/', import.meta.url));
|
|
13
|
+
// Order: --kit, HULLPROOF_KIT, the scanned repository when the kit is installed in it, then the kit next to this repository.
|
|
14
|
+
export function resolveKitDir(repo, flag) {
|
|
15
|
+
const tries = [flag, process.env.HULLPROOF_KIT ?? null, repo, SIBLING_KIT].filter((x) => !!x).map((d) => resolve(d));
|
|
16
|
+
return tries.find((d) => existsSync(join(d, CONTROLS_REL))) ?? null;
|
|
17
|
+
}
|
|
18
|
+
export function loadKit(dir) {
|
|
19
|
+
const raw = readFileSync(join(dir, CONTROLS_REL));
|
|
20
|
+
const doc = JSON.parse(raw.toString('utf8'));
|
|
21
|
+
const controls = doc.controls.filter((c) => c.status === 'active' || c.status === undefined);
|
|
22
|
+
return {
|
|
23
|
+
dir, controls, byId: new Map(controls.map((c) => [c.id, c])),
|
|
24
|
+
controlsSha256: createHash('sha256').update(raw).digest('hex'), standardVersion: String(doc.standard_version ?? 'unknown'),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export const STAGES = ['LAUNCH', 'GROWTH', 'SCALE'];
|
|
28
|
+
// A requirement is in scope when its edition is in the run's edition and its stage is at or below the declared stage.
|
|
29
|
+
export function inScope(c, s) {
|
|
30
|
+
return (s.edition === 'pro' || c.edition === 'free') && STAGES.indexOf(c.stage) <= STAGES.indexOf(s.stage);
|
|
31
|
+
}
|
|
32
|
+
// The stage from docs/security/STAGE.md ("Stage: GROWTH" or a heading or table cell naming one stage), else LAUNCH.
|
|
33
|
+
export function readStage(repo) {
|
|
34
|
+
const p = join(repo, 'docs/security/STAGE.md');
|
|
35
|
+
if (!existsSync(p))
|
|
36
|
+
return { stage: 'LAUNCH', source: 'default' };
|
|
37
|
+
const m = /\b(?:stage|declared stage)\b[^\n]{0,20}?\b(LAUNCH|GROWTH|SCALE)\b/i.exec(readFileSync(p, 'utf8'));
|
|
38
|
+
return m ? { stage: m[1].toUpperCase(), source: 'STAGE.md' } : { stage: 'LAUNCH', source: 'default' };
|
|
39
|
+
}
|
|
40
|
+
export const SEV_RANK = { BLOCKER: 5, CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
|
|
41
|
+
export const highest = (sevs) => sevs.reduce((a, b) => (SEV_RANK[b] > SEV_RANK[a] ? b : a), sevs[0]);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Reads a scan folder (<out>/<commit>/) back. The report and the gate read only these files, never the source.
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { cmp } from '../util.js';
|
|
5
|
+
import { huntDir, readResult, readSkipped, readSynthesis } from '../confirm/store.js';
|
|
6
|
+
import { loadProveFiles } from './prove-section.js';
|
|
7
|
+
const json = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
|
8
|
+
export function loadScan(dir) {
|
|
9
|
+
for (const f of ['scan.json', 'stack.json', 'checks.json', 'results.json', 'report.findings.md', 'surface.json']) {
|
|
10
|
+
if (!existsSync(join(dir, f)))
|
|
11
|
+
throw new Error(`${join(dir, f)} is missing. Run hullproof scan first.`);
|
|
12
|
+
}
|
|
13
|
+
const findingsText = readFileSync(join(dir, 'report.findings.md'), 'utf8');
|
|
14
|
+
const block = /```hullproof-findings\n([\s\S]*?)\n```/.exec(findingsText);
|
|
15
|
+
if (!block)
|
|
16
|
+
throw new Error('report.findings.md holds no hullproof-findings block');
|
|
17
|
+
const scanDir = join(dir, 'scan');
|
|
18
|
+
const metas = existsSync(scanDir)
|
|
19
|
+
? readdirSync(scanDir).filter((n) => n.endsWith('.meta.json')).sort(cmp).map((n) => json(join(scanDir, n)))
|
|
20
|
+
: [];
|
|
21
|
+
const reasons = {};
|
|
22
|
+
for (const m of metas) {
|
|
23
|
+
const p = join(scanDir, `${m.tool}.detail.json`);
|
|
24
|
+
reasons[m.tool] = existsSync(p) ? json(p).not_run_reason : null;
|
|
25
|
+
}
|
|
26
|
+
const hd = huntDir(dir);
|
|
27
|
+
return {
|
|
28
|
+
hunt: { result: readResult(hd), synthesis: readSynthesis(hd), skipped: readSkipped(hd) },
|
|
29
|
+
prove: loadProveFiles(dir),
|
|
30
|
+
reasons, dir, info: json(join(dir, 'scan.json')), stack: json(join(dir, 'stack.json')), checks: json(join(dir, 'checks.json')),
|
|
31
|
+
results: json(join(dir, 'results.json')), findingsText, findings: JSON.parse(block[1]), metas,
|
|
32
|
+
surface: json(join(dir, 'surface.json')),
|
|
33
|
+
codemap: existsSync(join(dir, 'codemap.json')) ? json(join(dir, 'codemap.json')) : null,
|
|
34
|
+
};
|
|
35
|
+
}
|