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,173 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// The release gate of STANDARD.md (G-1 to G-8 and the three outcomes), on top of the kit's own checklist gate.
|
|
10
|
+
// The checklist part (G-3 to G-5 on the results rows) and the record cross checks are the kit's functions from
|
|
11
|
+
// tools/hullproof/ci/hullproof-gate.mjs, imported, not copied. This file adds what the kit gate leaves out: G-1, G-2,
|
|
12
|
+
// G-6, G-7, G-8, written risk acceptance within the kit's limits, and the verdict.
|
|
13
|
+
// It blocks only on FAIL rows (from FAIL checks and mapped rule hits, never unmapped observations) and on requirements
|
|
14
|
+
// nothing settled, exactly as the kit gate counts them.
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
import { pathToFileURL } from 'node:url';
|
|
18
|
+
import { cmp } from '../util.js';
|
|
19
|
+
import { GATE_REL, inScope, SEV_RANK, VALIDATOR_REL } from '../report/kit.js';
|
|
20
|
+
export async function loadKitGate(kitDir) {
|
|
21
|
+
const g = await import(__rewriteRelativeImportExtension(pathToFileURL(join(kitDir, GATE_REL)).href));
|
|
22
|
+
const v = await import(__rewriteRelativeImportExtension(pathToFileURL(join(kitDir, VALIDATOR_REL)).href));
|
|
23
|
+
return { evaluate: g.evaluate, collapseRows: g.collapseRows, crossChecks: g.crossChecks, isStaticPass: g.isStaticPass, validateFindingsText: v.validateFindingsText };
|
|
24
|
+
}
|
|
25
|
+
// Hullproof policy (STANDARD.md, Protected classes): no acceptance for credentials and secrets, authentication,
|
|
26
|
+
// tenant isolation or payments. The class is read from the control's domains; authorization counts as tenant isolation.
|
|
27
|
+
const PROTECTED = new Set(['secrets', 'auth', 'authorization', 'tenancy', 'payments']);
|
|
28
|
+
const DAY = 86_400_000;
|
|
29
|
+
const days = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / DAY);
|
|
30
|
+
const isDate = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s));
|
|
31
|
+
function currentAcceptances(list, byId, today) {
|
|
32
|
+
const ok = new Map();
|
|
33
|
+
const problems = [];
|
|
34
|
+
let critical = 0;
|
|
35
|
+
for (const a of [...list].sort((x, y) => cmp(x.id, y.id))) {
|
|
36
|
+
const c = byId.get(a.id);
|
|
37
|
+
const bad = (why) => { problems.push(`${a.id}: acceptance not current (${why})`); };
|
|
38
|
+
if (!c) {
|
|
39
|
+
bad('not a requirement in scope');
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (c.severity === 'BLOCKER') {
|
|
43
|
+
bad('a BLOCKER is never accepted');
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (c.severity !== 'CRITICAL' && c.severity !== 'HIGH') {
|
|
47
|
+
bad('only CRITICAL and HIGH need acceptance');
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (c.domains.some((d) => PROTECTED.has(d))) {
|
|
51
|
+
bad('protected class: secrets, authentication, tenant isolation or payments');
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (![a.owner, a.approver, a.compensating_control].every((s) => typeof s === 'string' && s.trim())) {
|
|
55
|
+
bad('owner, approver and compensating control are all needed');
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (a.owner.trim().toLowerCase() === a.approver.trim().toLowerCase()) {
|
|
59
|
+
bad('the approver must not be the risk owner');
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!isDate(a.effective) || !isDate(a.expires)) {
|
|
63
|
+
bad('effective and expires must be YYYY-MM-DD dates');
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const limit = c.severity === 'CRITICAL' ? 90 : 180;
|
|
67
|
+
if (days(a.effective, a.expires) > limit) {
|
|
68
|
+
bad(`lasts more than ${limit} days`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (a.effective > today || a.expires < today) {
|
|
72
|
+
bad(a.expires < today ? 'expired' : 'not in effect yet');
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (c.severity === 'CRITICAL' && ++critical > 3) {
|
|
76
|
+
bad('more than 3 CRITICAL acceptances for one release');
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
ok.set(a.id, a);
|
|
80
|
+
}
|
|
81
|
+
return { ok, problems };
|
|
82
|
+
}
|
|
83
|
+
const isGated = (c) => c.severity === 'BLOCKER' || c.severity === 'CRITICAL';
|
|
84
|
+
export function evaluateGate(g) {
|
|
85
|
+
const scoped = g.controls.filter((c) => inScope(c, g.scope));
|
|
86
|
+
const byId = new Map(scoped.map((c) => [c.id, c]));
|
|
87
|
+
const problems = [];
|
|
88
|
+
// Rows: the engine's, then the owner's for anything the engine did not FAIL.
|
|
89
|
+
const engine = new Map(g.rows.map((r) => [r.id, r]));
|
|
90
|
+
for (const r of g.owner?.results ?? []) {
|
|
91
|
+
if (engine.get(r.id)?.result === 'FAIL')
|
|
92
|
+
continue;
|
|
93
|
+
engine.set(r.id, r);
|
|
94
|
+
}
|
|
95
|
+
const rows = [...engine.values()].sort((a, b) => cmp(a.id, b.id)).map((r) => [r.id, r.result, r.notes]);
|
|
96
|
+
// The kit's checklist gate on the in scope controls; scope 'all' because the stage filter is already applied.
|
|
97
|
+
const { counts, ok } = g.kitGate.evaluate(scoped, rows, 'all');
|
|
98
|
+
const checklistLine = `hullproof checklist (${g.scope.edition}, ${g.scope.stage}): checked ${counts.checked}, pass ${counts.pass}, not applicable ${counts.notApplicable}, fail ${counts.fail}, not assessed ${counts.notAssessed} (missing rows ${counts.missing}, PASS (static) rejected ${counts.passStaticRejected}, NOT APPLICABLE without a reason ${counts.naWithoutReason}), duplicate rows ${counts.duplicates}, unknown ids ${counts.unknownIds}`;
|
|
99
|
+
// The findings record: validated by the kit, then cross checked against the rows by the kit.
|
|
100
|
+
// Validation and cross checks see every known requirement: an observation may name one outside this run's scope.
|
|
101
|
+
const all = new Map(g.controls.map((c) => [c.id, c]));
|
|
102
|
+
const v = g.kitGate.validateFindingsText(g.findingsText, { controls: all });
|
|
103
|
+
if (v.c.errorCount)
|
|
104
|
+
problems.push(...v.c.lines().map((l) => `findings ${l}`));
|
|
105
|
+
const findings = v.data;
|
|
106
|
+
const { got } = g.kitGate.collapseRows(rows);
|
|
107
|
+
if (findings && !v.c.errorCount)
|
|
108
|
+
problems.push(...g.kitGate.crossChecks(all, got, findings).problems);
|
|
109
|
+
const runSupportsReady = !!findings && findings.report.run_status === 'complete' && findings.report.scope === 'full' && findings.report.profile !== 'quick';
|
|
110
|
+
// Open gated items, the same way the kit's evaluate counts them.
|
|
111
|
+
const open = [];
|
|
112
|
+
for (const c of scoped.filter(isGated)) {
|
|
113
|
+
const r = got.get(c.id);
|
|
114
|
+
const settled = r !== undefined && ((r.state === 'PASS' && !(g.kitGate.isStaticPass(r.raw) && c.authority !== 'repo')) || (r.state === 'NOT APPLICABLE' && r.note !== ''));
|
|
115
|
+
if (!settled)
|
|
116
|
+
open.push({ id: c.id, severity: c.severity, state: r?.raw ?? 'missing row', accepted: false });
|
|
117
|
+
}
|
|
118
|
+
if ((open.length === 0) !== ok)
|
|
119
|
+
problems.push('internal: the open item list does not agree with the kit gate');
|
|
120
|
+
const acc = currentAcceptances(g.owner?.acceptances ?? [], byId, g.today);
|
|
121
|
+
for (const o of open)
|
|
122
|
+
if (o.severity === 'CRITICAL' && acc.ok.has(o.id))
|
|
123
|
+
o.accepted = true;
|
|
124
|
+
const highFails = (findings?.records ?? []).filter((r) => r.kind === 'fail' && r.severity === 'HIGH')
|
|
125
|
+
.flatMap((r) => (r.kind === 'fail' ? r.sec_ids : [])).filter((id) => byId.get(id)?.severity === 'HIGH');
|
|
126
|
+
const highOpen = [...new Set(highFails)].filter((id) => !acc.ok.has(id)).sort(cmp);
|
|
127
|
+
const used = [...new Set([...open.filter((o) => o.accepted).map((o) => o.id), ...highFails.filter((id) => acc.ok.has(id))])].sort(cmp);
|
|
128
|
+
const stagePath = join(g.repo, 'docs/security/STAGE.md');
|
|
129
|
+
const inRepo = (p) => { if (!p)
|
|
130
|
+
return null; const abs = resolve(g.repo, p); return abs.startsWith(resolve(g.repo)) && existsSync(abs) ? abs : null; };
|
|
131
|
+
const report = inRepo(g.owner?.audit_report);
|
|
132
|
+
const rollback = inRepo(g.owner?.rollback);
|
|
133
|
+
const ranOk = (t) => g.metas.some((m) => m.tool === t && m.status === 'ran' && m.files_scanned !== 0);
|
|
134
|
+
const g7missing = ['gitleaks', 'semgrep', 'osv-scanner'].filter((t) => !ranOk(t));
|
|
135
|
+
const blockerOpen = open.filter((o) => o.severity === 'BLOCKER');
|
|
136
|
+
const blockerFail = blockerOpen.filter((o) => o.state.toUpperCase().startsWith('FAIL'));
|
|
137
|
+
const criticalOpen = open.filter((o) => o.severity === 'CRITICAL' && !o.accepted);
|
|
138
|
+
const conditions = [
|
|
139
|
+
{ id: 'G-1', met: existsSync(stagePath) && g.scope.stageSource === 'STAGE.md', why: existsSync(stagePath) ? (g.scope.stageSource === 'STAGE.md' ? `stage ${g.scope.stage} recorded in docs/security/STAGE.md` : 'docs/security/STAGE.md names no stage') : 'docs/security/STAGE.md is missing' },
|
|
140
|
+
{ id: 'G-2', met: !!report && readFileSync(report, 'utf8').includes(g.commit), why: report ? (readFileSync(report, 'utf8').includes(g.commit) ? 'audit report names this commit' : 'the audit report does not name this commit') : 'no audit report supplied (owner file, audit_report)' },
|
|
141
|
+
{ id: 'G-3', met: blockerFail.length === 0, why: blockerFail.length ? `${blockerFail.length} BLOCKER requirement(s) FAIL` : 'no BLOCKER requirement FAILs' },
|
|
142
|
+
{ id: 'G-4', met: blockerOpen.length === 0, why: blockerOpen.length ? `${blockerOpen.length} BLOCKER requirement(s) open (FAIL or NOT ASSESSED)` : 'every BLOCKER requirement is settled' },
|
|
143
|
+
{ id: 'G-5', met: criticalOpen.length === 0, why: criticalOpen.length ? `${criticalOpen.length} CRITICAL requirement(s) open and not under a current acceptance` : 'every CRITICAL requirement is settled or accepted' },
|
|
144
|
+
g.scope.edition === 'free'
|
|
145
|
+
? { id: 'G-6', met: true, why: 'outside the free scope' }
|
|
146
|
+
: { id: 'G-6', met: highOpen.length === 0, why: highOpen.length ? `${highOpen.length} HIGH requirement(s) with a fail record and no current acceptance` : 'no open HIGH finding' },
|
|
147
|
+
{ id: 'G-7', met: g7missing.length === 0, why: g7missing.length ? `did not run on this commit: ${g7missing.join(', ')}` : 'gitleaks, semgrep and osv-scanner ran on this commit' },
|
|
148
|
+
{ id: 'G-8', met: !!rollback, why: rollback ? 'rollback path supplied' : 'no rollback path supplied (owner file, rollback)' },
|
|
149
|
+
];
|
|
150
|
+
const allMet = conditions.every((c) => c.met) && runSupportsReady;
|
|
151
|
+
const verdict = !allMet ? 'NOT READY' : used.length ? 'READY WITH ACCEPTED RISK' : 'READY';
|
|
152
|
+
const label = verdict !== 'NOT READY' && g.scope.edition === 'free' ? `${verdict} (FREE SCOPE)` : verdict;
|
|
153
|
+
return {
|
|
154
|
+
verdict, label, exit: problems.length ? 2 : verdict === 'NOT READY' ? 1 : 0, problems, conditions, counts,
|
|
155
|
+
open: open.sort((a, b) => SEV_RANK[b.severity] - SEV_RANK[a.severity] || cmp(a.id, b.id)), acceptancesUsed: used, acceptanceProblems: acc.problems,
|
|
156
|
+
runSupportsReady, checklistLine,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export function gateLines(o) {
|
|
160
|
+
const lines = [o.checklistLine];
|
|
161
|
+
for (const c of o.conditions)
|
|
162
|
+
lines.push(`${c.id}: ${c.met ? 'met' : 'not met'}, ${c.why}`);
|
|
163
|
+
if (!o.runSupportsReady)
|
|
164
|
+
lines.push('note: this run cannot support READY (the findings record says the run is incomplete, partial or quick)');
|
|
165
|
+
for (const p of o.acceptanceProblems)
|
|
166
|
+
lines.push(`note: ${p}`);
|
|
167
|
+
if (o.problems.length) {
|
|
168
|
+
lines.push('RESULT: stopped, the records or a cross check need attention (exit 2)');
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
lines.push(`RESULT: ${o.label}`);
|
|
172
|
+
return lines;
|
|
173
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// hullproof hooks install and uninstall: a git pre-commit hook for plain git, a Husky entry, or a pre-commit framework entry.
|
|
2
|
+
// The hook only calls `hullproof hooks run` (src/hooks/run.ts). Only exit code 1 from it blocks a commit; a missing command or an
|
|
3
|
+
// engine error lets the commit through, because the hook must never be the reason a team cannot work. `git commit --no-verify` skips it.
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
export const MARKER_BEGIN = '# >>> hullproof >>>';
|
|
8
|
+
export const MARKER_END = '# <<< hullproof <<<';
|
|
9
|
+
const HOOK_TAG = '# hullproof pre-commit hook';
|
|
10
|
+
const sh = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
11
|
+
const git = (repo, ...a) => {
|
|
12
|
+
const r = spawnSync('git', ['-C', repo, ...a], { encoding: 'utf8' });
|
|
13
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
14
|
+
};
|
|
15
|
+
export function detect(repo) {
|
|
16
|
+
const root = git(repo, 'rev-parse', '--show-toplevel');
|
|
17
|
+
if (!root)
|
|
18
|
+
return `${resolve(repo)} is not inside a git repository`;
|
|
19
|
+
const hooksPath = git(root, 'config', '--get', 'core.hooksPath');
|
|
20
|
+
let pkgHusky = false;
|
|
21
|
+
try {
|
|
22
|
+
const p = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
23
|
+
pkgHusky = !!(p.devDependencies?.husky || p.dependencies?.husky);
|
|
24
|
+
}
|
|
25
|
+
catch { /* no package.json */ }
|
|
26
|
+
const husky = existsSync(join(root, '.husky')) || pkgHusky || (hooksPath ?? '').replace(/^\.\//, '').startsWith('.husky');
|
|
27
|
+
const framework = existsSync(join(root, '.pre-commit-config.yaml'));
|
|
28
|
+
return { root, husky, framework, hooksPath, huskyV8: existsSync(join(root, '.husky/_/husky.sh')), mode: husky ? 'husky' : framework ? 'pre-commit' : 'git' };
|
|
29
|
+
}
|
|
30
|
+
// How the hook calls the engine: `hullproof` when it is on PATH now, else this very engine through node, by absolute path.
|
|
31
|
+
export function runner() {
|
|
32
|
+
const onPath = spawnSync('sh', ['-c', 'command -v hullproof'], { encoding: 'utf8' });
|
|
33
|
+
if (onPath.status === 0 && onPath.stdout.trim())
|
|
34
|
+
return 'hullproof';
|
|
35
|
+
let cli = process.argv[1] ?? '';
|
|
36
|
+
try {
|
|
37
|
+
cli = realpathSync(cli);
|
|
38
|
+
}
|
|
39
|
+
catch { /* keep as given */ }
|
|
40
|
+
return `${sh(process.execPath)} ${sh(cli)}`;
|
|
41
|
+
}
|
|
42
|
+
const callLines = (cmd) => [
|
|
43
|
+
`${cmd} hooks run`,
|
|
44
|
+
'status=$?',
|
|
45
|
+
// Only a block (1) stops the commit. 127 (command not found) and any other code let it through.
|
|
46
|
+
'if [ "$status" -eq 1 ]; then exit 1; fi',
|
|
47
|
+
];
|
|
48
|
+
export const plainHook = (cmd) => ['#!/bin/sh', `${HOOK_TAG}. Installed by hullproof hooks install, removed by hullproof hooks uninstall.`, '# Skip it once with: git commit --no-verify', ...callLines(cmd), 'exit 0', ''].join('\n');
|
|
49
|
+
export const huskyBlock = (cmd) => [MARKER_BEGIN, ...callLines(cmd), MARKER_END, ''].join('\n');
|
|
50
|
+
export const frameworkBlock = (cmd) => [
|
|
51
|
+
` ${MARKER_BEGIN}`,
|
|
52
|
+
' - repo: local',
|
|
53
|
+
' hooks:',
|
|
54
|
+
' - id: hullproof',
|
|
55
|
+
' name: hullproof (secrets and blocking findings in staged files)',
|
|
56
|
+
` entry: ${cmd} hooks run`,
|
|
57
|
+
' language: system',
|
|
58
|
+
' pass_filenames: false',
|
|
59
|
+
' always_run: true',
|
|
60
|
+
` ${MARKER_END}`, '',
|
|
61
|
+
].join('\n');
|
|
62
|
+
const stripBlock = (text) => {
|
|
63
|
+
const re = new RegExp(`\\n?[ \\t]*${MARKER_BEGIN.replace(/[>]/g, '\\>')}[\\s\\S]*?${MARKER_END.replace(/[<]/g, '\\<')}\\n?`, 'g');
|
|
64
|
+
return text.replace(re, '\n').replace(/\n{3,}/g, '\n\n');
|
|
65
|
+
};
|
|
66
|
+
export function installHooks(o) {
|
|
67
|
+
const d = detect(o.repo);
|
|
68
|
+
if (typeof d === 'string')
|
|
69
|
+
return { code: 2, lines: [`hullproof hooks: ${d}`] };
|
|
70
|
+
const mode = o.mode ?? d.mode;
|
|
71
|
+
const lines = [`hullproof hooks: mode ${mode}${o.mode ? '' : ` (detected: Husky ${d.husky ? 'yes' : 'no'}, pre-commit framework ${d.framework ? 'yes' : 'no'})`}`];
|
|
72
|
+
const cmd = runner();
|
|
73
|
+
const write = (path, text, exec = false) => {
|
|
74
|
+
if (o.dryRun) {
|
|
75
|
+
lines.push(`would write ${path}`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
79
|
+
writeFileSync(path, text);
|
|
80
|
+
if (exec)
|
|
81
|
+
chmodSync(path, 0o755);
|
|
82
|
+
lines.push(`wrote ${path}`);
|
|
83
|
+
};
|
|
84
|
+
if (mode === 'git') {
|
|
85
|
+
const hooksDir = git(d.root, 'rev-parse', '--git-path', 'hooks');
|
|
86
|
+
if (!hooksDir)
|
|
87
|
+
return { code: 2, lines: [...lines, 'hullproof hooks: could not find the git hooks folder'] };
|
|
88
|
+
const path = resolve(d.root, hooksDir, 'pre-commit');
|
|
89
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
90
|
+
if (o.uninstall) {
|
|
91
|
+
if (current === null)
|
|
92
|
+
lines.push('no pre-commit hook to remove');
|
|
93
|
+
else if (!current.includes(HOOK_TAG))
|
|
94
|
+
return { code: 2, lines: [...lines, `${path} is not a Hullproof hook, so it was left alone`] };
|
|
95
|
+
else if (o.dryRun)
|
|
96
|
+
lines.push(`would remove ${path}`);
|
|
97
|
+
else {
|
|
98
|
+
rmSync(path);
|
|
99
|
+
lines.push(`removed ${path}`);
|
|
100
|
+
}
|
|
101
|
+
return { code: 0, lines };
|
|
102
|
+
}
|
|
103
|
+
if (current !== null && !current.includes(HOOK_TAG)) {
|
|
104
|
+
return { code: 2, lines: [...lines, `${path} already exists and is not a Hullproof hook. It was not changed.`, 'Add this to it, or use the Husky or pre-commit framework mode:', ...callLines(cmd)] };
|
|
105
|
+
}
|
|
106
|
+
write(path, plainHook(cmd), true);
|
|
107
|
+
return { code: 0, lines };
|
|
108
|
+
}
|
|
109
|
+
if (mode === 'husky') {
|
|
110
|
+
const path = join(d.root, '.husky/pre-commit');
|
|
111
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
112
|
+
if (o.uninstall) {
|
|
113
|
+
if (current === null || !current.includes(MARKER_BEGIN))
|
|
114
|
+
lines.push('no Hullproof entry in .husky/pre-commit');
|
|
115
|
+
else
|
|
116
|
+
write(path, stripBlock(current));
|
|
117
|
+
return { code: 0, lines };
|
|
118
|
+
}
|
|
119
|
+
if (current?.includes(MARKER_BEGIN)) {
|
|
120
|
+
lines.push('.husky/pre-commit already has the Hullproof entry');
|
|
121
|
+
return { code: 0, lines };
|
|
122
|
+
}
|
|
123
|
+
const head = current ?? `#!/usr/bin/env sh\n${d.huskyV8 ? '. "$(dirname -- "$0")/_/husky.sh"\n' : ''}`;
|
|
124
|
+
write(path, `${head.replace(/\n*$/, '\n')}${huskyBlock(cmd)}`, true);
|
|
125
|
+
return { code: 0, lines };
|
|
126
|
+
}
|
|
127
|
+
// pre-commit framework
|
|
128
|
+
const path = join(d.root, '.pre-commit-config.yaml');
|
|
129
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
130
|
+
if (o.uninstall) {
|
|
131
|
+
if (current === null || !current.includes(MARKER_BEGIN))
|
|
132
|
+
lines.push('no Hullproof entry in .pre-commit-config.yaml');
|
|
133
|
+
else
|
|
134
|
+
write(path, stripBlock(current));
|
|
135
|
+
return { code: 0, lines };
|
|
136
|
+
}
|
|
137
|
+
if (current?.includes(MARKER_BEGIN)) {
|
|
138
|
+
lines.push('.pre-commit-config.yaml already has the Hullproof entry');
|
|
139
|
+
return { code: 0, lines };
|
|
140
|
+
}
|
|
141
|
+
const block = frameworkBlock(cmd);
|
|
142
|
+
// The entry is appended at the end of the file, which is only right when `repos:` is the last top level key and a block list.
|
|
143
|
+
const topKeys = (current ?? '').split('\n').filter((l) => /^[A-Za-z_][\w-]*\s*:/.test(l));
|
|
144
|
+
const last = topKeys.at(-1) ?? '';
|
|
145
|
+
const appendable = current === null || (/^repos\s*:\s*$/.test(last) && last === topKeys.find((k) => k.startsWith('repos')));
|
|
146
|
+
if (!appendable) {
|
|
147
|
+
return { code: 0, lines: [...lines, 'could not add the entry safely (the file has keys after `repos:` or a flow style list). Add this under `repos:` yourself:', '', block.replace(/^ {2}# .*\n/gm, '')] };
|
|
148
|
+
}
|
|
149
|
+
const base = current ?? 'repos:\n';
|
|
150
|
+
write(path, `${base.replace(/\n*$/, '\n')}${block}`);
|
|
151
|
+
lines.push('run `pre-commit install` once so the framework runs it on each commit');
|
|
152
|
+
return { code: 0, lines };
|
|
153
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// hullproof hooks run: the fast check the pre-commit hook calls. It looks at the staged files only, for two things: a secret, and a
|
|
2
|
+
// finding of the highest severity (BLOCKER) that a deterministic check or a mapped Semgrep rule raises in a staged file.
|
|
3
|
+
// Nothing leaves the machine: no network, no model, no upload. The staged content is exported from the index into a temporary
|
|
4
|
+
// folder, so the check sees what the commit will hold and not the working tree. Findings in files that are not staged never block.
|
|
5
|
+
// Exit 0 pass, 1 blocked. Any error is reported and lets the commit through (fail open), unless HULLPROOF_HOOK_STRICT=1.
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { makeContext } from '../checks/context.js';
|
|
11
|
+
import { checkSha, loadChecks, runChecks } from '../checks/run.js';
|
|
12
|
+
import { discover } from '../discover/discover.js';
|
|
13
|
+
import { buildFindings } from '../report/build.js';
|
|
14
|
+
import { loadKit, readStage, resolveKitDir } from '../report/kit.js';
|
|
15
|
+
import { RULES_DIR } from '../scanners/common.js';
|
|
16
|
+
import { runScanner } from '../scanners/index.js';
|
|
17
|
+
import { adapter as gitleaks } from '../scanners/gitleaks.js';
|
|
18
|
+
import { adapter as semgrep } from '../scanners/semgrep.js';
|
|
19
|
+
import { SEV_RANK } from '../report/kit.js';
|
|
20
|
+
const SOURCE_FILES = /\.(js|mjs|cjs|ts|jsx|tsx|py|rb|php|go|java|kt|swift|yaml|yml|json|sql|tf|html)$/;
|
|
21
|
+
const git = (repo, args, extra = {}) => {
|
|
22
|
+
// --no-ext-diff and --no-textconv are given where they matter, so a driver named in the repository configuration cannot run.
|
|
23
|
+
const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', ...extra } });
|
|
24
|
+
return { ok: r.status === 0, out: r.stdout ?? '' };
|
|
25
|
+
};
|
|
26
|
+
export async function runHook(o) {
|
|
27
|
+
const started = Date.now();
|
|
28
|
+
const top = git(o.repo, ['rev-parse', '--show-toplevel']);
|
|
29
|
+
if (!top.ok)
|
|
30
|
+
return { code: 2, lines: ['hullproof: not inside a git repository'] };
|
|
31
|
+
const root = top.out.trim();
|
|
32
|
+
const staged = git(root, ['diff', '--cached', '--name-only', '--diff-filter=ACMR', '--no-ext-diff', '--no-textconv', '-z']).out.split('\0').filter(Boolean).sort();
|
|
33
|
+
if (staged.length === 0)
|
|
34
|
+
return { code: 0, lines: [] };
|
|
35
|
+
const stagedSet = new Set(staged);
|
|
36
|
+
const tmp = mkdtempSync(join(tmpdir(), 'hullproof-hook-'));
|
|
37
|
+
try {
|
|
38
|
+
const tree = join(tmp, 'tree');
|
|
39
|
+
mkdirSync(tree);
|
|
40
|
+
// The index as it will be committed, exported to a folder; its index file is copied so checks that ask which files git tracks
|
|
41
|
+
// get the right answer.
|
|
42
|
+
const exp = git(root, ['checkout-index', '-a', '-f', `--prefix=${tree}/`]);
|
|
43
|
+
if (!exp.ok)
|
|
44
|
+
throw new Error('could not read the staged files from the git index');
|
|
45
|
+
const idxPath = resolve(root, git(root, ['rev-parse', '--git-path', 'index']).out.trim());
|
|
46
|
+
mkdirSync(join(tree, '.git'));
|
|
47
|
+
if (existsSync(idxPath))
|
|
48
|
+
copyFileSync(idxPath, join(tree, '.git/index'));
|
|
49
|
+
writeFileSync(join(tree, '.git/HEAD'), 'ref: refs/heads/hook\n');
|
|
50
|
+
const kitDir = resolveKitDir(root, o.kit);
|
|
51
|
+
const kit = kitDir ? loadKit(kitDir) : null;
|
|
52
|
+
const edition = kit?.controls.some((c) => c.edition === 'pro') ? 'pro' : 'free';
|
|
53
|
+
const stack = discover(tree);
|
|
54
|
+
const controlsPath = kitDir ? join(kitDir, 'docs/hullproof/security-controls.json') : undefined;
|
|
55
|
+
const scanOpts = (extra = {}) => ({ repo: tree, out: join(tmp, 'out'), stack, allowNetwork: false, edition, history: false, kit: kitDir, controlsPath, ...extra });
|
|
56
|
+
const metas = [];
|
|
57
|
+
const hits = [];
|
|
58
|
+
const notes = [];
|
|
59
|
+
const run = (a, extra = {}) => {
|
|
60
|
+
const s = runScanner(a, scanOpts(extra));
|
|
61
|
+
metas.push(s.meta);
|
|
62
|
+
hits.push(...s.hits.hits);
|
|
63
|
+
if (s.meta.status !== 'ran')
|
|
64
|
+
notes.push(`${a.tool} did not run (${s.meta.status})`);
|
|
65
|
+
};
|
|
66
|
+
// 1. Secrets, which is the quick part: Gitleaks over the exported tree, kept to the staged files.
|
|
67
|
+
run(gitleaks);
|
|
68
|
+
const secretHits = hits.filter((h) => stagedSet.has(h.file));
|
|
69
|
+
const secretsBlock = blockers(secretHits, EMPTY, kit, edition, tree, metas, stagedSet);
|
|
70
|
+
if (secretsBlock.length || (secretHits.length && !kit)) {
|
|
71
|
+
const lines = secretsBlock.length ? render(secretsBlock) : secretHits.map((h) => ` BLOCKER ${h.file}:${h.line} a secret shaped value (rule ${h.rule_id})`);
|
|
72
|
+
return done(1, lines, staged.length, started, notes);
|
|
73
|
+
}
|
|
74
|
+
if (!kit)
|
|
75
|
+
return done(0, [], staged.length, started, [...notes, 'no kit was found (pass --kit or set HULLPROOF_KIT), so only secrets were checked']);
|
|
76
|
+
// 2. Blocking findings from the deterministic checks and, when it is on, Semgrep on the staged source files.
|
|
77
|
+
const sources = staged.filter((f) => SOURCE_FILES.test(f));
|
|
78
|
+
if (o.semgrep && sources.length)
|
|
79
|
+
run(semgrep, { targets: sources });
|
|
80
|
+
const ctx = makeContext(tree, stack, hits, kitDir);
|
|
81
|
+
const checkDefs = loadChecks();
|
|
82
|
+
const checks = runChecks(ctx, checkDefs);
|
|
83
|
+
const found = blockers(hits.filter((h) => stagedSet.has(h.file)), checks, kit, edition, tree, metas, stagedSet, checkDefs);
|
|
84
|
+
return found.length ? done(1, render(found), staged.length, started, notes) : done(0, [], staged.length, started, notes);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const EMPTY = { schema_version: '1.0.0', commit: null, results: [], not_run: [] };
|
|
91
|
+
// Fail records of BLOCKER severity that point at a staged file. The same record builder the scan uses, so a rule or a check gives
|
|
92
|
+
// the same title, requirement and fix here as in the report.
|
|
93
|
+
function blockers(hits, checks, kit, edition, tree, metas, staged, defs = []) {
|
|
94
|
+
if (!kit)
|
|
95
|
+
return [];
|
|
96
|
+
const input = {
|
|
97
|
+
kit, scope: { edition, stage: readStage(tree).stage, stageSource: 'default' }, git: { commit: '0'.repeat(40), tree: '0'.repeat(40), clean: false },
|
|
98
|
+
checks, hits, metas, checkShas: Object.fromEntries(defs.map((c) => [c.id, checkSha(c)])), rulesDirs: [join(RULES_DIR, 'semgrep')],
|
|
99
|
+
};
|
|
100
|
+
const { file } = buildFindings(input);
|
|
101
|
+
return file.records.filter((r) => r.kind === 'fail' && SEV_RANK[r.severity] >= SEV_RANK.BLOCKER && r.evidence.some((e) => staged.has(e.file)));
|
|
102
|
+
}
|
|
103
|
+
const render = (fs) => fs.flatMap((f) => {
|
|
104
|
+
const e = f.evidence.find((x) => x.file) ?? f.evidence[0];
|
|
105
|
+
return [` ${f.severity} ${e ? `${e.file}:${e.line}` : ''} ${f.title} (${f.sec_ids.join(', ')})`, ` Fix: ${f.remediation.strategy}`];
|
|
106
|
+
});
|
|
107
|
+
function done(code, problems, n, started, notes) {
|
|
108
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
109
|
+
const lines = [...(code === 1 ? [`hullproof: BLOCKED, ${problems.filter((l) => l.startsWith(' ') && !l.startsWith(' ')).length} problem(s) in the staged files`, ...problems] : []), ...notes.map((x) => `hullproof: note: ${x}`),
|
|
110
|
+
`hullproof: checked ${n} staged file${n === 1 ? '' : 's'} in ${secs}s on this machine, nothing was sent anywhere.${code === 1 ? ' To commit anyway: git commit --no-verify' : ''}`];
|
|
111
|
+
return { code, lines };
|
|
112
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { scanFile } from '../checks/code/webhook-verify.js';
|
|
2
|
+
import { EXITS, ifBody } from '../checks/code/shared.js';
|
|
3
|
+
import { dataTouches, outboundCalls } from './touches.js';
|
|
4
|
+
import { taintOf } from './taint.js';
|
|
5
|
+
import { AUDIT, AUTH_PACKAGES, RATE, RATE_PACKAGES, ROLE_JS, ROLE_PY, SESSION_JS, SESSION_PY, SIGNATURE, VALIDATION_JS, VALIDATION_PY, } from './vocab.js';
|
|
6
|
+
const usesText = (re) => /['"`]/.test(re.source);
|
|
7
|
+
const CONF_RANK = { high: 0, medium: 1, low: 2 };
|
|
8
|
+
const minConf = (a, b) => (CONF_RANK[a] >= CONF_RANK[b] ? a : b);
|
|
9
|
+
function facts(kind, pats, view, from, to, scope, upgrade, viaPrefix = '') {
|
|
10
|
+
const out = [];
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const masked = view.masked.slice(from, to);
|
|
13
|
+
const text = view.text.slice(from, to);
|
|
14
|
+
for (const p of pats) {
|
|
15
|
+
p.re.lastIndex = 0;
|
|
16
|
+
const m = p.re.exec(usesText(p.re) ? text : masked);
|
|
17
|
+
if (!m || seen.has(p.via))
|
|
18
|
+
continue;
|
|
19
|
+
const ln = view.lines.at(from + m.index);
|
|
20
|
+
if (out.some((o) => o.line === ln))
|
|
21
|
+
continue;
|
|
22
|
+
seen.add(p.via);
|
|
23
|
+
const conf = upgrade && p.confidence === 'medium' ? 'high' : p.confidence;
|
|
24
|
+
out.push({ kind, scope, file: view.file, line: ln, via: viaPrefix + p.via, enforced: null, confidence: viaPrefix ? minConf(conf, 'medium') : conf });
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
// Whether a failed check provably stops the request. true: the check sits in an if whose body exits, or it is a throwing
|
|
29
|
+
// guard (require..., a decorator). false: it is tested and the body does not exit. null: cannot tell.
|
|
30
|
+
function enforcedFor(view, at, via) {
|
|
31
|
+
const { text } = view;
|
|
32
|
+
const lineStart = text.lastIndexOf('\n', at) + 1;
|
|
33
|
+
const lineEnd = text.indexOf('\n', at) < 0 ? text.length : text.indexOf('\n', at);
|
|
34
|
+
const before = text.slice(lineStart, at);
|
|
35
|
+
if (/^@|decorator|Depends|permission_classes|login/.test(via) && /(^|\n)\s*@|Depends\s*\(|permission_classes/.test(text.slice(lineStart, lineEnd)))
|
|
36
|
+
return true;
|
|
37
|
+
const ifAt = /\b(?:if|unless|elif|assert)\b/.exec(before);
|
|
38
|
+
if (ifAt) {
|
|
39
|
+
const body = view.lang === 'py' ? text.slice(lineStart + ifAt.index, Math.min(text.length, lineEnd + 400)).split('\n').slice(0, 4).join('\n') : ifBody(text, lineStart + ifAt.index);
|
|
40
|
+
return EXITS.test(body);
|
|
41
|
+
}
|
|
42
|
+
if ((/\b(?:require|ensure|assert)\w*/i.test(via) || /^local (?:auth|role) helper$/.test(via)) && !/=/.test(before))
|
|
43
|
+
return true;
|
|
44
|
+
const lhs = /(?:\b(?:const|let|var|val)\s+)?(\{[^}]*(?:\{[^}]*\}[^}]*)*\}|\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*(?:await\s+)?[\w.$]*\s*$/.exec(before);
|
|
45
|
+
if (!lhs)
|
|
46
|
+
return null;
|
|
47
|
+
const names = [...lhs[1].matchAll(/[A-Za-z_$][\w$]*/g)].map((x) => x[0]).filter((n) => !['const', 'let', 'var', 'data', 'error', 'session'].includes(n) || lhs[1] === n);
|
|
48
|
+
const rest = text.slice(lineEnd, Math.min(text.length, lineEnd + 1500));
|
|
49
|
+
for (const n of names) {
|
|
50
|
+
const guard = view.lang === 'py'
|
|
51
|
+
? new RegExp(`\\bif\\s+(?:not\\s+${n}\\b|${n}\\s+is\\s+None|${n}\\s*==\\s*None)`)
|
|
52
|
+
: new RegExp(`\\bif\\s*\\(\\s*(?:!\\s*${n}\\b|${n}\\s*(?:===?|==)\\s*(?:null|undefined)\\b|!\\s*${n}\\s*\\|\\||!${n}\\?)`);
|
|
53
|
+
const g = guard.exec(rest);
|
|
54
|
+
if (!g)
|
|
55
|
+
continue;
|
|
56
|
+
const body = view.lang === 'py' ? rest.slice(g.index, g.index + 300).split('\n').slice(0, 4).join('\n') : ifBody(rest, g.index);
|
|
57
|
+
return EXITS.test(body);
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const READS = [
|
|
62
|
+
['body', /\b(?:req|request|ctx\.request|c\.req)\s*\.\s*(?:body|json|get_json|data|text)\b|\bawait\s+\w+\.json\s*\(\)|\bJSON\.parse\s*\(\s*(?:raw|body|await)/, false],
|
|
63
|
+
['query', /\b(?:req|request)\.(?:query|args|GET|query_params|nextUrl)\b|\bsearchParams\b|\bc\.req\.query\b|\bctx\.query\b|\bparse_qs\b/, false],
|
|
64
|
+
['params', /\b(?:req|request|ctx|c\.req)\.params?\b|\bparams\s*[.\[]|\bc\.req\.param\b|\bview_args\b|\bkwargs\b|\bPath\s*\(/, false],
|
|
65
|
+
['headers', /\b(?:req|request)\.headers\b|\bheaders\s*\(\s*\)|\bc\.req\.header\b|\bgetHeader\s*\(|\brequest\.META\b/, false],
|
|
66
|
+
['cookies', /\b(?:req|request)\.cookies\b|\bcookies\s*\(\s*\)|\bgetCookie\s*\(|\bc\.req\.cookie\b/, false],
|
|
67
|
+
['form', /\bformData\b|\brequest\.form\b|\brequest\.POST\b|\bFormData\s*\(|\bmultipart\b/i, false],
|
|
68
|
+
['file', /\.get\s*\(\s*['"](?:file|files|upload|image|avatar|attachment|document|photo)s?['"]\s*\)|\bas\s+File\b|\bmulter\b|\breq\.files?\b|\brequest\.files\b|\bformidable\b|\bbusboy\b|\binstanceof\s+File\b|\bUploadFile\b|\brequest\.FILES\b|\.file\s*\(\s*['"]|\bupload\.(?:single|array|fields|any)\b|\bFileInterceptor\b|\bFilesInterceptor\b|\bgetAll\s*\(\s*['"]file/, false],
|
|
69
|
+
];
|
|
70
|
+
function readsOf(view, from, to) {
|
|
71
|
+
const masked = view.masked.slice(from, to);
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const [source, re] of READS) {
|
|
74
|
+
const m = re.exec(masked);
|
|
75
|
+
if (m)
|
|
76
|
+
out.push({ source, line: view.lines.at(from + m.index) });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
const PARSE_CALL = /\bawait\s+\w+\.json\s*\(\)|\b(?:req|request)\.(?:json|get_json)\s*\(|\bJSON\.parse\s*\(|\bjson\.loads\s*\(|\.formData\s*\(\)|\brequest\.(?:json|form)\b/;
|
|
81
|
+
// A helper body that turns a request away: an error status, a throw, an abort or a redirect.
|
|
82
|
+
const REJECT = /\b(?:401|403|unauthori[sz]ed|forbidden)\b|\bthrow\b|\braise\b|\babort\s*\(|\bnext\s*\(\s*(?:new\s+)?\w*Error|\bcreateError\b|\bHTTPException\b|\bPermissionDenied\b|\bAuthenticationFailed\b|\bredirect\s*\(|\bstatus\s*\(\s*4\d\d|\bdenyAll\b/i;
|
|
83
|
+
const CALL = /(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g;
|
|
84
|
+
const SKIP_CALLS = new Set(['if', 'for', 'while', 'switch', 'function', 'return', 'catch', 'await', 'async', 'typeof', 'new', 'super', 'require', 'import', 'print', 'len', 'str', 'int', 'dict', 'list', 'set', 'tuple', 'range', 'isinstance', 'type', 'super', 'sorted', 'map', 'filter', 'json', 'Number', 'String', 'Boolean', 'Array', 'Object', 'Date', 'Promise', 'Error', 'parseInt', 'parseFloat', 'setTimeout', 'fetch', 'def', 'class', 'with', 'except', 'elif', 'lambda', 'not', 'and', 'or']);
|
|
85
|
+
export function analyze(view, spec, helpers) {
|
|
86
|
+
const lang = view.lang;
|
|
87
|
+
const { start, end } = spec;
|
|
88
|
+
const upgradeSession = AUTH_PACKAGES.test(view.imports);
|
|
89
|
+
const upgradeRate = RATE_PACKAGES.test(view.imports);
|
|
90
|
+
const local = helpers.local ?? null;
|
|
91
|
+
const sessionPats = [...(lang === 'js' ? SESSION_JS : SESSION_PY), ...(local?.auth ? [local.auth] : [])];
|
|
92
|
+
const rolePats = [...(lang === 'js' ? ROLE_JS : ROLE_PY), ...(local?.role ? [local.role] : [])];
|
|
93
|
+
const valPats = lang === 'js' ? VALIDATION_JS : VALIDATION_PY;
|
|
94
|
+
const prot = [];
|
|
95
|
+
const own = (kind, pats, upgrade) => {
|
|
96
|
+
for (const f of facts(kind, pats, view, start, end, 'handler', upgrade)) {
|
|
97
|
+
prot.push({ ...f, enforced: kind === 'session_read' || kind === 'role_check' ? enforcedFor(view, firstIndex(view, start, end, pats, f.via, start), f.via) : null });
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
own('session_read', sessionPats, upgradeSession);
|
|
101
|
+
own('role_check', rolePats, false);
|
|
102
|
+
own('rate_limit', RATE, upgradeRate);
|
|
103
|
+
own('input_validation', valPats, false);
|
|
104
|
+
own('audit_log', AUDIT, false);
|
|
105
|
+
// Signature checks: where it is, whether a failed check stops the request, and whether the body is parsed first.
|
|
106
|
+
const sig = facts('signature_verify', SIGNATURE, view, start, end, 'handler', false);
|
|
107
|
+
if (sig.length) {
|
|
108
|
+
const region = view.text.slice(start, end);
|
|
109
|
+
const bad = scanFile(view.file, region);
|
|
110
|
+
const onlyMissing = bad.length > 0 && bad.every((b) => /^no call that checks/.test(b.note));
|
|
111
|
+
const enforced = bad.length === 0 ? true : onlyMissing ? null : false;
|
|
112
|
+
const first = sig.reduce((a, b) => (a.line <= b.line ? a : b));
|
|
113
|
+
const parse = PARSE_CALL.exec(view.masked.slice(start, end));
|
|
114
|
+
const parseLine = parse ? view.lines.at(start + parse.index) : null;
|
|
115
|
+
for (const f of sig)
|
|
116
|
+
prot.push({ ...f, enforced, after_parse: parseLine !== null && parseLine < first.line });
|
|
117
|
+
}
|
|
118
|
+
// Decorators, middleware and wrappers outside the handler body.
|
|
119
|
+
const extra = new Set(spec.extraCalls ?? []);
|
|
120
|
+
for (const d of spec.decorators ?? []) {
|
|
121
|
+
const dv = d.view;
|
|
122
|
+
const dl = dv.lang;
|
|
123
|
+
const keep = (kind, pats, up) => { for (const f of facts(kind, pats, dv, d.start, d.end, d.scope, up))
|
|
124
|
+
prot.push({ ...f, enforced: kind === 'session_read' || kind === 'role_check' ? (f.confidence === 'high' ? true : null) : null }); };
|
|
125
|
+
keep('session_read', [...(dl === 'js' ? SESSION_JS : SESSION_PY), ...(local?.auth ? [local.auth] : [])], AUTH_PACKAGES.test(dv.imports));
|
|
126
|
+
keep('role_check', [...(dl === 'js' ? ROLE_JS : ROLE_PY), ...(local?.role ? [local.role] : [])], false);
|
|
127
|
+
keep('rate_limit', RATE, RATE_PACKAGES.test(dv.imports));
|
|
128
|
+
keep('input_validation', dl === 'js' ? VALIDATION_JS : VALIDATION_PY, false);
|
|
129
|
+
keep('audit_log', AUDIT, false);
|
|
130
|
+
for (const f of facts('signature_verify', SIGNATURE, dv, d.start, d.end, d.scope, false))
|
|
131
|
+
prot.push({ ...f, enforced: null });
|
|
132
|
+
for (const m of dv.masked.slice(d.start, d.end).matchAll(CALL))
|
|
133
|
+
if (!SKIP_CALLS.has(m[1]))
|
|
134
|
+
extra.add(m[1]);
|
|
135
|
+
}
|
|
136
|
+
// One hop into the repository's own helper functions the handler calls.
|
|
137
|
+
const have = (k) => prot.some((p) => p.kind === k);
|
|
138
|
+
const names = new Set();
|
|
139
|
+
for (const m of view.masked.slice(start, end).matchAll(CALL))
|
|
140
|
+
if (!SKIP_CALLS.has(m[1]))
|
|
141
|
+
names.add(m[1]);
|
|
142
|
+
for (const n of extra)
|
|
143
|
+
names.add(n);
|
|
144
|
+
let hops = 0;
|
|
145
|
+
for (const name of [...names].sort()) {
|
|
146
|
+
if (hops >= 30)
|
|
147
|
+
break;
|
|
148
|
+
const h = helpers.find(name, view.file);
|
|
149
|
+
if (!h)
|
|
150
|
+
continue;
|
|
151
|
+
hops++;
|
|
152
|
+
const hv = h.view;
|
|
153
|
+
const hf = [
|
|
154
|
+
['session_read', [...(hv.lang === 'js' ? SESSION_JS : SESSION_PY), ...(local?.auth ? [local.auth] : [])], AUTH_PACKAGES.test(hv.imports)],
|
|
155
|
+
['role_check', [...(hv.lang === 'js' ? ROLE_JS : ROLE_PY), ...(local?.role ? [local.role] : [])], false], ['rate_limit', RATE, RATE_PACKAGES.test(hv.imports)],
|
|
156
|
+
['input_validation', hv.lang === 'js' ? VALIDATION_JS : VALIDATION_PY, false], ['audit_log', AUDIT, false],
|
|
157
|
+
];
|
|
158
|
+
for (const [kind, pats, up] of hf) {
|
|
159
|
+
if (kind === 'session_read' && have('session_read'))
|
|
160
|
+
continue;
|
|
161
|
+
if (have(kind) && kind !== 'audit_log')
|
|
162
|
+
continue;
|
|
163
|
+
const found = facts(kind, pats.filter((p) => p.confidence !== 'low'), hv, h.start, h.end, 'handler', up, `helper:${name} `);
|
|
164
|
+
if (found.length) {
|
|
165
|
+
const idx = view.masked.slice(start, end).search(new RegExp(`(?<![.\\w$])${name.replace(/\$/g, '\\$')}\\s*\\(`));
|
|
166
|
+
const at = idx < 0 ? view.lines.at(start) : view.lines.at(start + idx);
|
|
167
|
+
const rejects = REJECT.test(hv.text.slice(h.start, h.end));
|
|
168
|
+
const enforced = rejects ? true : spec.gate && (kind === 'session_read' || kind === 'role_check') ? false : null;
|
|
169
|
+
prot.push({ ...found[0], file: view.file, line: at, via: `helper:${name} ${found[0].via.replace(`helper:${name} `, '')}`, enforced });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Taint, then data touches and outbound calls.
|
|
174
|
+
const maskedBody = view.masked.slice(start, end);
|
|
175
|
+
const textBody = view.text.slice(start, end);
|
|
176
|
+
const taint = taintOf(maskedBody, textBody, spec.seeds, lang, local?.auth ? [local.auth] : []);
|
|
177
|
+
const region = { masked: maskedBody, text: textBody, base: start, line: (i) => view.lines.at(start + i) };
|
|
178
|
+
const data = dataTouches(region, taint, lang);
|
|
179
|
+
const outbound = outboundCalls(region, taint, lang, spec.returnsJson ?? true, view.imports);
|
|
180
|
+
// owner_filter: a data touch that names an owner is a protection on that read or write.
|
|
181
|
+
for (const d of data)
|
|
182
|
+
if (d.owner_scoped && (d.by_id || d.request_derived || d.op !== 'read'))
|
|
183
|
+
prot.push({ kind: 'owner_filter', scope: 'handler', file: view.file, line: d.line, via: `${d.store} ${d.target ?? ''}`.trim(), enforced: null, confidence: d.store === 'sql' ? 'medium' : 'medium' });
|
|
184
|
+
prot.sort((a, b) => a.line - b.line || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0) || (a.via < b.via ? -1 : a.via > b.via ? 1 : 0));
|
|
185
|
+
return { protections: dedupe(prot), reads: readsOf(view, start, end), data, outbound, taint };
|
|
186
|
+
}
|
|
187
|
+
function dedupe(ps) {
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
return ps.filter((p) => { const k = `${p.kind}|${p.scope}|${p.line}|${p.via}`; if (seen.has(k))
|
|
190
|
+
return false; seen.add(k); return true; });
|
|
191
|
+
}
|
|
192
|
+
// The character index of the first match of the pattern with this `via` inside [from, to), for enforcedFor.
|
|
193
|
+
function firstIndex(view, from, to, pats, via, fallback) {
|
|
194
|
+
const p = pats.find((x) => x.via === via);
|
|
195
|
+
if (!p)
|
|
196
|
+
return fallback;
|
|
197
|
+
p.re.lastIndex = 0;
|
|
198
|
+
const src = usesText(p.re) ? view.text.slice(from, to) : view.masked.slice(from, to);
|
|
199
|
+
const m = p.re.exec(src);
|
|
200
|
+
return m ? from + m.index : fallback;
|
|
201
|
+
}
|