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,97 @@
|
|
|
1
|
+
// Checks that read entry points from the code map: what protects each one and what it touches. All start as leads
|
|
2
|
+
// (src/report/tiers.ts) until a measurement raises them. Each is a pattern over map facts, so a FAIL is a place to look.
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { ev, evAt, has, hasAuth, judged, mapCheck, stateChanging, weakAuth, where } from './shared.js';
|
|
5
|
+
const source = fileURLToPath(import.meta.url);
|
|
6
|
+
const exempt = (ep) => ep.tags.includes('auth_endpoint') || ep.tags.includes('public') || ep.tags.includes('webhook');
|
|
7
|
+
const SENSITIVE = /user|account|profile|customer|member|order|payment|invoice|billing|card|address|email|message|note|file|upload|document|ticket|booking|patient|wallet|session|token|credential|subscription|contact|secret|key|transaction|history|log|basket|cart|chat|conversation|appointment|photo|prescription/i;
|
|
8
|
+
export const mutatingRouteWithoutAuth = mapCheck({
|
|
9
|
+
id: 'check.map.mutating-route-without-auth', sec_ids: ['SEC-API-001'], name: 'mutating-route-without-auth', source,
|
|
10
|
+
pattern: 'a state changing entry point with no session read on the handler, a wrapper, the router or a gate',
|
|
11
|
+
none: 'the code map has no state changing entry point',
|
|
12
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && stateChanging(e)).length,
|
|
13
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && stateChanging(e) && !exempt(e) && !hasAuth(e)).map((e) => {
|
|
14
|
+
const weak = weakAuth(e);
|
|
15
|
+
return ev(e, `${where(e)} changes state and no session read is reachable on it${weak ? ` (only a weak sign: ${weak.via} at ${weak.file}:${weak.line})` : ''}`);
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
export const dataReadWithoutAuth = mapCheck({
|
|
19
|
+
id: 'check.map.data-read-without-auth', sec_ids: ['SEC-API-001', 'SEC-DATA-024'], name: 'data-read-without-auth', source,
|
|
20
|
+
pattern: 'a GET entry point that reads a table with a personal data name and has no session read',
|
|
21
|
+
none: 'the code map has no GET entry point that reads a data store',
|
|
22
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.kind === 'http_route' && e.method !== 'POST' && e.data.some((d) => d.op === 'read')).length,
|
|
23
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.kind === 'http_route' && (e.method === 'GET' || e.method === 'HEAD' || e.method === 'ANY' || e.method === null) && !exempt(e) && !hasAuth(e)).flatMap((e) => {
|
|
24
|
+
const d = e.data.find((x) => x.op === 'read' && x.target && SENSITIVE.test(x.target) && x.store !== 'filesystem');
|
|
25
|
+
return d ? [evAt(e, `${where(e)} reads ${d.target} and no session read is reachable on it`, d.line)] : [];
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
export const writeByIdWithoutOwner = mapCheck({
|
|
29
|
+
id: 'check.map.write-by-id-without-owner', sec_ids: ['SEC-AUTHZ-003'], name: 'write-by-id-without-owner', source,
|
|
30
|
+
pattern: 'an update or delete filtered by an id from the request with no owner, user or tenant value from the session',
|
|
31
|
+
none: 'the code map has no update or delete by id',
|
|
32
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.data.some((d) => (d.op === 'write' || d.op === 'delete') && d.by_id)).length,
|
|
33
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.kind !== 'webhook' && !has(e, 'role_check')).flatMap((e) => e.data.filter((d) => (d.op === 'write' || d.op === 'delete') && d.by_id && !d.owner_scoped).map((d) => evAt(e, `${where(e)} ${d.op === 'delete' ? 'deletes from' : 'updates'} ${d.target ?? 'a record'} by an id from the request and no owner value from the session is in the query`, d.line))),
|
|
34
|
+
});
|
|
35
|
+
export const readByIdWithoutOwner = mapCheck({
|
|
36
|
+
id: 'check.map.read-by-id-without-owner', sec_ids: ['SEC-AUTHZ-003'], name: 'read-by-id-without-owner', source,
|
|
37
|
+
fixtures: { pass: 'test/fixtures/map-idor/pass', fail: 'test/fixtures/map-idor/fail' },
|
|
38
|
+
pattern: 'a signed in entry point that reads a record with a personal data name by an id from the request, with no owner, user or tenant value from the session in the query and no role check',
|
|
39
|
+
none: 'the code map has no signed in entry point that reads a record by an id from the request',
|
|
40
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.kind !== 'webhook' && !exempt(e) && has(e, 'session_read') && e.data.some((d) => d.op === 'read' && d.by_id && d.store !== 'filesystem' && d.target && SENSITIVE.test(d.target))).length,
|
|
41
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.kind !== 'webhook' && !exempt(e) && has(e, 'session_read') && !has(e, 'role_check') && !has(e, 'owner_filter')).flatMap((e) => e.data.filter((d) => d.op === 'read' && d.by_id && !d.owner_scoped && d.store !== 'filesystem' && d.target && SENSITIVE.test(d.target)).slice(0, 1).map((d) => evAt(e, `${where(e)} reads ${d.target ?? 'a record'} by an id from the request and no owner value from the session is in the query`, d.line))),
|
|
42
|
+
});
|
|
43
|
+
export const adminRouteWithoutRole = mapCheck({
|
|
44
|
+
id: 'check.map.admin-route-without-role', sec_ids: ['SEC-AUTHZ-020', 'SEC-AUTHZ-002'], name: 'admin-route-without-role', source,
|
|
45
|
+
pattern: 'an entry point in an admin area with no role check on the handler, a wrapper, the router or a gate',
|
|
46
|
+
none: 'the code map has no entry point in an admin area',
|
|
47
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.tags.includes('admin')).length,
|
|
48
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.tags.includes('admin') && !has(e, 'role_check')).map((e) => ev(e, `${where(e)} is in an admin area and no server side role check is reachable on it${has(e, 'session_read') ? ' (a session read is not a role check)' : ''}`)),
|
|
49
|
+
});
|
|
50
|
+
export const webhookVerifyBeforeUse = mapCheck({
|
|
51
|
+
id: 'check.map.webhook-verify-before-use', sec_ids: ['SEC-API-101'], name: 'webhook-verify-before-use', source,
|
|
52
|
+
pattern: 'a webhook entry point with no signature check, a check that does not stop the request, or a body parsed first',
|
|
53
|
+
none: 'the code map has no webhook entry point',
|
|
54
|
+
candidates: (m) => m.entry_points.filter((e) => !e.test_code && e.kind === 'webhook').length,
|
|
55
|
+
fails: (m) => m.entry_points.filter((e) => !e.test_code && e.kind === 'webhook').flatMap((e) => {
|
|
56
|
+
const sigs = e.protections.filter((p) => p.kind === 'signature_verify');
|
|
57
|
+
if (!sigs.length)
|
|
58
|
+
return [ev(e, `${where(e)} is a webhook and no signature check was found on it`)];
|
|
59
|
+
if (sigs.every((p) => p.enforced === false))
|
|
60
|
+
return [{ file: sigs[0].file, line: sigs[0].line, note: `${where(e)} checks the signature but carries on when it fails` }];
|
|
61
|
+
if (sigs.some((p) => p.after_parse) && !sigs.some((p) => !p.after_parse))
|
|
62
|
+
return [{ file: sigs[0].file, line: sigs[0].line, note: `${where(e)} parses the request body before it checks the signature` }];
|
|
63
|
+
return [];
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
export const requestUrlFetched = mapCheck({
|
|
67
|
+
id: 'check.map.request-url-fetched', sec_ids: ['SEC-API-035'], name: 'request-url-fetched', source,
|
|
68
|
+
pattern: 'an outbound HTTP call whose target comes from the request, with no allowlist in the same function',
|
|
69
|
+
none: 'the code map has no outbound HTTP call in an entry point',
|
|
70
|
+
candidates: (m) => m.entry_points.filter((e) => !e.test_code && e.outbound.some((o) => o.kind === 'http')).length,
|
|
71
|
+
fails: (m) => m.entry_points.filter((e) => !e.test_code).flatMap((e) => e.outbound.filter((o) => o.kind === 'http' && o.request_derived && !o.guarded).map((o) => evAt(e, `${where(e)} fetches ${o.target}, which comes from the request, and no allowlist of hosts is checked`, o.line))),
|
|
72
|
+
});
|
|
73
|
+
export const privilegedFieldFromRequest = mapCheck({
|
|
74
|
+
id: 'check.map.privileged-field-from-request', sec_ids: ['SEC-AUTHZ-004'], name: 'privileged-field-from-request', source,
|
|
75
|
+
pattern: 'a write that sets role, plan, admin or similar columns from request data, or passes the whole request object to a create or update',
|
|
76
|
+
none: 'the code map has no write in an entry point',
|
|
77
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.data.some((d) => d.op === 'write')).length,
|
|
78
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.kind !== 'webhook' && !has(e, 'role_check')).flatMap((e) => e.data.filter((d) => d.op === 'write' && (d.sets.length > 0 || d.mass_assign)).map((d) => evAt(e, d.sets.length ? `${where(e)} sets ${d.sets.join(', ')} on ${d.target ?? 'a record'} from request data` : `${where(e)} passes the whole request object into a write on ${d.target ?? 'a record'}`, d.line))),
|
|
79
|
+
});
|
|
80
|
+
export const privilegedActionWithoutAudit = mapCheck({
|
|
81
|
+
id: 'check.map.privileged-action-without-audit', sec_ids: ['SEC-LOG-003'], name: 'privileged-action-without-audit', source,
|
|
82
|
+
pattern: 'a write or delete in an admin area with no audit record call',
|
|
83
|
+
none: 'the code map has no write or delete in an admin area',
|
|
84
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.tags.includes('admin') && e.data.some((d) => d.op === 'write' || d.op === 'delete')).length,
|
|
85
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.tags.includes('admin') && e.data.some((d) => d.op === 'write' || d.op === 'delete') && !e.protections.some((p) => p.kind === 'audit_log')).map((e) => ev(e, `${where(e)} changes data in an admin area and writes no audit record`)),
|
|
86
|
+
});
|
|
87
|
+
export const redirectFromRequest = mapCheck({
|
|
88
|
+
id: 'check.map.redirect-from-request', sec_ids: ['SEC-AUTH-030'], name: 'redirect-from-request', source,
|
|
89
|
+
pattern: 'a redirect target taken from the request with no allowlist comparison in the same function',
|
|
90
|
+
none: 'the code map has no redirect in an entry point',
|
|
91
|
+
candidates: (m) => m.entry_points.filter((e) => !e.test_code && e.outbound.some((o) => o.kind === 'redirect')).length,
|
|
92
|
+
fails: (m) => m.entry_points.filter((e) => !e.test_code).flatMap((e) => e.outbound.filter((o) => o.kind === 'redirect' && o.request_derived && !o.guarded).map((o) => evAt(e, `${where(e)} sends the user to ${o.target}, which comes from the request, and no allowlist is checked`, o.line))),
|
|
93
|
+
});
|
|
94
|
+
export const ENTRY_CHECKS = [
|
|
95
|
+
mutatingRouteWithoutAuth, dataReadWithoutAuth, writeByIdWithoutOwner, readByIdWithoutOwner, adminRouteWithoutRole, webhookVerifyBeforeUse,
|
|
96
|
+
requestUrlFetched, privilegedFieldFromRequest, privilegedActionWithoutAudit, redirectFromRequest,
|
|
97
|
+
];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { buildCodeMap } from '../../map/build.js';
|
|
2
|
+
const cache = new WeakMap();
|
|
3
|
+
export function getMap(ctx) {
|
|
4
|
+
let m = cache.get(ctx);
|
|
5
|
+
if (!m) {
|
|
6
|
+
m = buildCodeMap(ctx);
|
|
7
|
+
cache.set(ctx, m);
|
|
8
|
+
}
|
|
9
|
+
return m;
|
|
10
|
+
}
|
|
11
|
+
// A protection that counts: not a weak guess, and not one the code is known to ignore.
|
|
12
|
+
export const counts = (p) => p.confidence !== 'low' && p.enforced !== false;
|
|
13
|
+
export const has = (ep, kind) => ep.protections.some((p) => p.kind === kind && counts(p));
|
|
14
|
+
export const hasAuth = (ep) => has(ep, 'session_read') || has(ep, 'signature_verify');
|
|
15
|
+
export const weakAuth = (ep) => ep.protections.find((p) => p.kind === 'session_read' && !counts(p));
|
|
16
|
+
export const HANDLER_KINDS = new Set(['http_route', 'server_action', 'graphql_resolver', 'rpc']);
|
|
17
|
+
// Entry points the checks judge: shipped code, a kind that handles requests.
|
|
18
|
+
export const judged = (ep) => !ep.test_code && HANDLER_KINDS.has(ep.kind) && ep.framework !== 'django-admin';
|
|
19
|
+
export const stateChanging = (ep) => ep.tags.includes('state_changing') || (ep.kind === 'graphql_resolver' && ep.method === 'MUTATION') || (ep.kind === 'rpc' && ep.method === 'MUTATION');
|
|
20
|
+
// Evidence in the entry point's own file at the registration line, or, for a line inside the handler body (a data touch, an outbound
|
|
21
|
+
// call), in the file that holds the handler: a route registered in one file can have its handler in another.
|
|
22
|
+
export const ev = (ep, note) => ({ file: ep.file, line: ep.line, note });
|
|
23
|
+
export const evAt = (ep, note, line) => ({ file: ep.handler?.file ?? ep.file, line, note });
|
|
24
|
+
export const where = (ep) => `${ep.method && ep.method !== 'ANY' ? ep.method + ' ' : ''}${ep.path ?? ep.name ?? ep.file}`.trim();
|
|
25
|
+
export function mapCheck(s) {
|
|
26
|
+
return {
|
|
27
|
+
id: s.id, sec_ids: s.sec_ids, applies_when: {}, op: 'code', on_true: 'NOT ASSESSED: NEEDS DYNAMIC TEST', on_false: 'FAIL',
|
|
28
|
+
fixtures: s.fixtures ?? { pass: 'test/fixtures/map/pass', fail: 'test/fixtures/map/fail' }, source: s.source,
|
|
29
|
+
run: (ctx) => {
|
|
30
|
+
const map = getMap(ctx);
|
|
31
|
+
const n = s.candidates(map);
|
|
32
|
+
if (n === 0)
|
|
33
|
+
return { value: 'not_applicable', reason: s.none, evidence: [], absence: null };
|
|
34
|
+
const bad = s.fails(map, ctx).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || (a.line ?? 0) - (b.line ?? 0) || (a.note < b.note ? -1 : a.note > b.note ? 1 : 0));
|
|
35
|
+
return { value: bad.length === 0, evidence: bad, absence: { pattern: s.pattern, files_searched: n, file_globs: ['codemap.json'], matches: bad.length } };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// The text of an entry point's handler, with `above` lines before it (decorators and route options sit above).
|
|
40
|
+
export function handlerText(ctx, ep, above = 0) {
|
|
41
|
+
const file = ep.handler?.file ?? ep.file;
|
|
42
|
+
const start = ep.handler?.line ?? ep.line;
|
|
43
|
+
return ctx.text(file).split('\n').slice(Math.max(0, start - 1 - above), Math.max(start, ep.end_line)).join('\n');
|
|
44
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// The declarative check operations. Each takes the op's params and the repository view and returns an outcome.
|
|
2
|
+
// Params per op are documented in src/checks/README.md. No op runs a program or uses the network.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { cmp } from '../util.js';
|
|
6
|
+
import { MAX_EVIDENCE, byFileLine, lineOf } from './context.js';
|
|
7
|
+
const strs = (v) => (Array.isArray(v) ? v.map(String) : typeof v === 'string' ? [v] : []);
|
|
8
|
+
const str = (v, d = '') => (typeof v === 'string' ? v : d);
|
|
9
|
+
const cap = (ev) => ev.sort(byFileLine).slice(0, MAX_EVIDENCE);
|
|
10
|
+
const na = (reason) => ({ value: 'not_applicable', reason, evidence: [], absence: null });
|
|
11
|
+
const cannot = (reason) => ({ value: 'cannot_run', reason, evidence: [], absence: null });
|
|
12
|
+
export function compile(pattern, ignoreCase) {
|
|
13
|
+
return new RegExp(pattern, ignoreCase ? 'gim' : 'gm');
|
|
14
|
+
}
|
|
15
|
+
// Every match of re in text, as evidence.
|
|
16
|
+
export function matchesIn(file, text, re, note) {
|
|
17
|
+
const out = [];
|
|
18
|
+
re.lastIndex = 0;
|
|
19
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
20
|
+
out.push({ file, line: lineOf(text, m.index), note });
|
|
21
|
+
if (m[0] === '')
|
|
22
|
+
re.lastIndex++;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
// Files the op reads: params.files minus params.exclude, kept only when they match params.only_if.
|
|
27
|
+
function selected(ctx, p) {
|
|
28
|
+
const files = ctx.select(strs(p.files), strs(p.exclude));
|
|
29
|
+
if (typeof p.only_if !== 'string')
|
|
30
|
+
return files;
|
|
31
|
+
const re = compile(p.only_if, p.ignore_case === true);
|
|
32
|
+
return files.filter((f) => { re.lastIndex = 0; return re.test(ctx.text(f)); });
|
|
33
|
+
}
|
|
34
|
+
// params.precondition {files, exclude?, pattern?}: when no file meets it the check does not apply. Returns the files that do.
|
|
35
|
+
function precondition(ctx, p) {
|
|
36
|
+
const pre = p.precondition;
|
|
37
|
+
if (!pre)
|
|
38
|
+
return null;
|
|
39
|
+
const files = ctx.select(strs(pre.files), strs(pre.exclude));
|
|
40
|
+
if (typeof pre.pattern !== 'string')
|
|
41
|
+
return files;
|
|
42
|
+
const re = compile(pre.pattern, pre.ignore_case === true);
|
|
43
|
+
return files.filter((f) => { re.lastIndex = 0; return re.test(ctx.text(f)); });
|
|
44
|
+
}
|
|
45
|
+
function preEvidence(ctx, p, files) {
|
|
46
|
+
const pre = p.precondition;
|
|
47
|
+
const note = str(p.precondition_note, 'searched here');
|
|
48
|
+
if (typeof pre.pattern !== 'string')
|
|
49
|
+
return files.map((file) => ({ file, line: 1, note }));
|
|
50
|
+
const re = compile(pre.pattern, pre.ignore_case === true);
|
|
51
|
+
return files.map((file) => ({ file, line: matchesIn(file, ctx.text(file), re, note)[0]?.line ?? 1, note }));
|
|
52
|
+
}
|
|
53
|
+
function regexOp(ctx, p, present) {
|
|
54
|
+
const pre = precondition(ctx, p);
|
|
55
|
+
if (pre && pre.length === 0)
|
|
56
|
+
return na(str(p.not_applicable_reason, 'no file meets the precondition'));
|
|
57
|
+
const files = selected(ctx, p);
|
|
58
|
+
const pattern = str(p.pattern);
|
|
59
|
+
if (!pattern)
|
|
60
|
+
return cannot('params.pattern is missing');
|
|
61
|
+
const absence = (matches) => ({ pattern, files_searched: files.length, file_globs: strs(p.files), matches });
|
|
62
|
+
if (files.length === 0) {
|
|
63
|
+
const w = str(p.when_no_files, 'not_applicable');
|
|
64
|
+
if (w === 'not_applicable')
|
|
65
|
+
return na(str(p.not_applicable_reason, 'no file to search'));
|
|
66
|
+
const value = w === 'true';
|
|
67
|
+
return { value, evidence: !value && pre ? cap(preEvidence(ctx, p, pre)) : [], absence: absence(0) };
|
|
68
|
+
}
|
|
69
|
+
const re = compile(pattern, p.ignore_case === true);
|
|
70
|
+
const note = str(p.note, present ? 'matches the pattern' : 'matches the pattern this check forbids');
|
|
71
|
+
const perFile = files.map((f) => ({ f, ev: matchesIn(f, ctx.text(f), re, note) }));
|
|
72
|
+
const all = perFile.flatMap((x) => x.ev);
|
|
73
|
+
if (!present)
|
|
74
|
+
return { value: all.length === 0, evidence: cap(all), absence: absence(all.length) };
|
|
75
|
+
if (p.each_file === true) {
|
|
76
|
+
const missing = perFile.filter((x) => x.ev.length === 0).map((x) => x.f);
|
|
77
|
+
const missNote = str(p.missing_note, 'the pattern is not in this file');
|
|
78
|
+
return missing.length
|
|
79
|
+
? { value: false, evidence: cap(missing.map((file) => ({ file, line: 1, note: missNote }))), absence: absence(all.length) }
|
|
80
|
+
: { value: true, evidence: cap(all), absence: absence(all.length) };
|
|
81
|
+
}
|
|
82
|
+
if (all.length)
|
|
83
|
+
return { value: true, evidence: cap(all), absence: absence(all.length) };
|
|
84
|
+
// Nothing found anywhere: point at the files where the thing is normally configured, when the check names them.
|
|
85
|
+
return { value: false, evidence: pre ? cap(preEvidence(ctx, p, pre)) : [], absence: absence(0) };
|
|
86
|
+
}
|
|
87
|
+
function fileExists(ctx, p, wantPresent) {
|
|
88
|
+
const found = ctx.select(strs(p.files), strs(p.exclude));
|
|
89
|
+
const note = str(p.note, wantPresent ? 'file present' : 'file present that this check forbids');
|
|
90
|
+
const evidence = cap(found.map((file) => ({ file, key: '(present)', note })));
|
|
91
|
+
const absence = { pattern: strs(p.files).join(' '), files_searched: ctx.files.length, file_globs: strs(p.files), matches: found.length };
|
|
92
|
+
return { value: wantPresent ? found.length > 0 : found.length === 0, evidence, absence };
|
|
93
|
+
}
|
|
94
|
+
// params.manifests: manifest file name to the lockfile names that pin it. A lockfile in the same folder or any parent counts.
|
|
95
|
+
function fileExistsForEach(ctx, p) {
|
|
96
|
+
const table = (p.manifests ?? {});
|
|
97
|
+
const names = Object.keys(table);
|
|
98
|
+
const have = new Set(ctx.files);
|
|
99
|
+
const manifests = ctx.files.filter((f) => names.includes(f.slice(f.lastIndexOf('/') + 1)));
|
|
100
|
+
if (manifests.length === 0)
|
|
101
|
+
return na('no package manifest in the repository');
|
|
102
|
+
const missing = [];
|
|
103
|
+
for (const m of manifests) {
|
|
104
|
+
const locks = table[m.slice(m.lastIndexOf('/') + 1)];
|
|
105
|
+
const parts = m.split('/').slice(0, -1);
|
|
106
|
+
let ok = false;
|
|
107
|
+
for (let i = parts.length; i >= 0 && !ok; i--) {
|
|
108
|
+
const dir = parts.slice(0, i).join('/');
|
|
109
|
+
ok = locks.some((l) => have.has(dir ? `${dir}/${l}` : l));
|
|
110
|
+
}
|
|
111
|
+
if (!ok)
|
|
112
|
+
missing.push({ file: m, line: 1, note: str(p.note, `no lockfile (${locks.join(', ')}) in this folder or above`) });
|
|
113
|
+
}
|
|
114
|
+
const absence = { pattern: [...new Set(Object.values(table).flat())].sort(cmp).join(' '), files_searched: manifests.length, file_globs: names, matches: manifests.length - missing.length };
|
|
115
|
+
return missing.length ? { value: false, evidence: cap(missing), absence } : { value: true, evidence: cap(manifests.map((file) => ({ file, line: 1, note: 'lockfile found' }))), absence };
|
|
116
|
+
}
|
|
117
|
+
// Strips SQL comments, keeping line breaks so line numbers stay right.
|
|
118
|
+
const stripSqlComments = (t) => t.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
119
|
+
const tableName = (raw) => {
|
|
120
|
+
const parts = raw.replace(/"/g, '').toLowerCase().split('.');
|
|
121
|
+
return parts.length > 1 ? { schema: parts[0], name: parts[1] } : { schema: null, name: parts[0] };
|
|
122
|
+
};
|
|
123
|
+
// Every table created in an exposed schema has row level security enabled after it, reading files in name order.
|
|
124
|
+
function sqlEveryTableHas(ctx, p) {
|
|
125
|
+
const files = ctx.select(strs(p.files), strs(p.exclude));
|
|
126
|
+
if (files.length === 0)
|
|
127
|
+
return na('no migration files');
|
|
128
|
+
const schemas = strs(p.schemas).length ? strs(p.schemas) : ['public'];
|
|
129
|
+
const ident = '((?:"[^"]+"|[\\w$]+)(?:\\.(?:"[^"]+"|[\\w$]+))?)';
|
|
130
|
+
const re = new RegExp(`\\b(create\\s+(?:unlogged\\s+)?table\\s+(?:if\\s+not\\s+exists\\s+)?${ident}|alter\\s+table\\s+(?:if\\s+exists\\s+)?(?:only\\s+)?${ident}\\s+(enable|disable|force)\\s+row\\s+level\\s+security|drop\\s+table\\s+(?:if\\s+exists\\s+)?${ident})`, 'gi');
|
|
131
|
+
const tables = new Map();
|
|
132
|
+
const key = (raw) => {
|
|
133
|
+
const t = tableName(raw);
|
|
134
|
+
const schema = t.schema ?? schemas[0];
|
|
135
|
+
return schemas.includes(schema) ? `${schema}.${t.name}` : null;
|
|
136
|
+
};
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
const text = stripSqlComments(ctx.text(f));
|
|
139
|
+
re.lastIndex = 0;
|
|
140
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
141
|
+
if (m[2]) {
|
|
142
|
+
const k = key(m[2]);
|
|
143
|
+
if (k)
|
|
144
|
+
tables.set(k, { file: f, line: lineOf(text, m.index), rls: false });
|
|
145
|
+
}
|
|
146
|
+
else if (m[3]) {
|
|
147
|
+
const k = key(m[3]);
|
|
148
|
+
const t = k ? tables.get(k) : undefined;
|
|
149
|
+
if (t)
|
|
150
|
+
t.rls = m[4].toLowerCase() !== 'disable';
|
|
151
|
+
}
|
|
152
|
+
else if (m[5]) {
|
|
153
|
+
const k = key(m[5]);
|
|
154
|
+
if (k)
|
|
155
|
+
tables.delete(k);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (tables.size === 0)
|
|
160
|
+
return na('no table created in an exposed schema');
|
|
161
|
+
const without = [...tables.entries()].filter(([, t]) => !t.rls);
|
|
162
|
+
const absence = { pattern: 'alter table <name> enable row level security', files_searched: files.length, file_globs: strs(p.files), matches: tables.size - without.length };
|
|
163
|
+
const evidence = without.map(([name, t]) => ({ file: t.file, line: t.line, note: `table ${name} is created and row level security is never enabled on it` }));
|
|
164
|
+
return { value: without.length === 0, evidence: cap(evidence), absence };
|
|
165
|
+
}
|
|
166
|
+
function workflows(ctx, p) {
|
|
167
|
+
return ctx.select(strs(p.files).length ? strs(p.files) : ['.github/workflows/*.yml', '.github/workflows/*.yaml']);
|
|
168
|
+
}
|
|
169
|
+
// Every third party action is referenced by a full 40 character commit SHA.
|
|
170
|
+
function workflowUsesPinnedSha(ctx, p) {
|
|
171
|
+
const files = workflows(ctx, p);
|
|
172
|
+
if (files.length === 0)
|
|
173
|
+
return na('no CI workflow files');
|
|
174
|
+
const re = /^[ \t-]*uses:\s*["']?([^\s"'#]+)/gm;
|
|
175
|
+
const bad = [];
|
|
176
|
+
let total = 0;
|
|
177
|
+
for (const f of files) {
|
|
178
|
+
const text = ctx.text(f);
|
|
179
|
+
re.lastIndex = 0;
|
|
180
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
181
|
+
const ref = m[1];
|
|
182
|
+
if (ref.startsWith('./') || ref.startsWith('docker://'))
|
|
183
|
+
continue;
|
|
184
|
+
total++;
|
|
185
|
+
const at = ref.lastIndexOf('@');
|
|
186
|
+
if (at < 0 || !/^[0-9a-f]{40}$/.test(ref.slice(at + 1)))
|
|
187
|
+
bad.push({ file: f, line: lineOf(text, m.index), note: `${ref} is not pinned to a full commit SHA` });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (total === 0)
|
|
191
|
+
return na('no third party action is used');
|
|
192
|
+
const absence = { pattern: 'uses: <owner>/<repo>@<40 hex>', files_searched: files.length, file_globs: strs(p.files), matches: total - bad.length };
|
|
193
|
+
return { value: bad.length === 0, evidence: cap(bad), absence };
|
|
194
|
+
}
|
|
195
|
+
// Every workflow declares token permissions (at the top or on every job) and none grants write-all.
|
|
196
|
+
function workflowPermissions(ctx, p) {
|
|
197
|
+
const files = workflows(ctx, p);
|
|
198
|
+
if (files.length === 0)
|
|
199
|
+
return na('no CI workflow files');
|
|
200
|
+
const bad = [];
|
|
201
|
+
for (const f of files) {
|
|
202
|
+
const text = ctx.text(f);
|
|
203
|
+
for (const m of text.matchAll(/^[ \t]*permissions:[ \t]*write-all\b/gm))
|
|
204
|
+
bad.push({ file: f, line: lineOf(text, m.index), note: 'the token is granted write access to everything' });
|
|
205
|
+
if (/^permissions:/m.test(text))
|
|
206
|
+
continue;
|
|
207
|
+
const jobsAt = /^jobs:\s*$/m.exec(text);
|
|
208
|
+
if (!jobsAt)
|
|
209
|
+
continue;
|
|
210
|
+
const body = text.slice(jobsAt.index);
|
|
211
|
+
const jobs = [...body.matchAll(/^ {2}([\w-]+):\s*$/gm)];
|
|
212
|
+
const withPerms = jobs.filter((j, i) => {
|
|
213
|
+
const end = jobs[i + 1]?.index ?? body.length;
|
|
214
|
+
return /^ {4}permissions:/m.test(body.slice(j.index, end));
|
|
215
|
+
});
|
|
216
|
+
if (jobs.length === 0 || withPerms.length < jobs.length) {
|
|
217
|
+
bad.push({ file: f, line: lineOf(text, jobsAt.index), note: 'no permissions block for the workflow or for every job, so the token gets the repository default' });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const absence = { pattern: 'permissions: (top level or per job), write-all', files_searched: files.length, file_globs: strs(p.files), matches: bad.length };
|
|
221
|
+
return { value: bad.length === 0, evidence: cap(bad), absence };
|
|
222
|
+
}
|
|
223
|
+
// Reads the kit's framework support table: per heading, release line to status.
|
|
224
|
+
export function supportTable(markdown, heading) {
|
|
225
|
+
const lines = markdown.split(/\r?\n/);
|
|
226
|
+
const at = lines.findIndex((l) => l.trim() === `## ${heading}`);
|
|
227
|
+
if (at < 0)
|
|
228
|
+
return null;
|
|
229
|
+
const rows = new Map();
|
|
230
|
+
for (let i = at + 1; i < lines.length && !lines[i].startsWith('## '); i++) {
|
|
231
|
+
const l = lines[i].trim();
|
|
232
|
+
if (!l.startsWith('|') || /^\|[\s|:-]+\|$/.test(l))
|
|
233
|
+
continue;
|
|
234
|
+
const cells = l.slice(1, -1).split('|').map((c) => c.trim());
|
|
235
|
+
if (cells[0] === 'Release line')
|
|
236
|
+
continue;
|
|
237
|
+
rows.set(cells[0], cells[cells.length - 1]);
|
|
238
|
+
}
|
|
239
|
+
return rows;
|
|
240
|
+
}
|
|
241
|
+
// The release line of a declared version: "13.4.0" -> "13", "^0.85.2" -> "0.85", "~=5.2.1" -> "5.2".
|
|
242
|
+
export function releaseLine(version, digits) {
|
|
243
|
+
const m = /(\d+)(?:\.(\d+))?/.exec(version);
|
|
244
|
+
if (!m)
|
|
245
|
+
return null;
|
|
246
|
+
return digits === 2 ? (m[2] === undefined ? null : `${m[1]}.${m[2]}`) : m[1];
|
|
247
|
+
}
|
|
248
|
+
// params.table: kit relative path. params.frameworks: stack id to {heading, package, digits}.
|
|
249
|
+
function versionInTable(ctx, p) {
|
|
250
|
+
if (!ctx.kitDir)
|
|
251
|
+
return cannot('the kit folder is not known, so the framework support table cannot be read');
|
|
252
|
+
let md;
|
|
253
|
+
try {
|
|
254
|
+
md = readFileSync(join(ctx.kitDir, str(p.table)), 'utf8');
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return cannot(`cannot read ${str(p.table)} in the kit`);
|
|
258
|
+
}
|
|
259
|
+
const table = (p.frameworks ?? {});
|
|
260
|
+
const items = [...ctx.stack.frameworks, ...ctx.stack.mobile].filter((i) => table[i.id]);
|
|
261
|
+
if (items.length === 0)
|
|
262
|
+
return na('no framework from the support table is in stack.json');
|
|
263
|
+
const bad = [];
|
|
264
|
+
const good = [];
|
|
265
|
+
const unknown = [];
|
|
266
|
+
for (const item of items) {
|
|
267
|
+
const row = table[item.id];
|
|
268
|
+
const rows = supportTable(md, row.heading);
|
|
269
|
+
const ev = item.evidence.find((e) => e.rule.includes('.dep.')) ?? item.evidence[0];
|
|
270
|
+
const declared = (item.versions ?? []).filter((v) => v.startsWith(`${row.package}@`)).map((v) => v.slice(row.package.length + 1));
|
|
271
|
+
if (!rows || declared.length === 0) {
|
|
272
|
+
unknown.push(item.id);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
for (const v of declared) {
|
|
276
|
+
const line = releaseLine(v, row.digits ?? 1);
|
|
277
|
+
const status = line ? rows.get(line) : undefined;
|
|
278
|
+
const where = { file: ev?.file ?? 'package.json', ...(ev?.line ? { line: ev.line } : { key: ev?.key ?? `(present)` }) };
|
|
279
|
+
if (/canary|alpha|beta|rc|next|experimental|nightly/i.test(v))
|
|
280
|
+
bad.push({ ...where, note: `${row.package} ${v} is a prerelease or canary version` });
|
|
281
|
+
else if (status === 'Supported')
|
|
282
|
+
good.push({ ...where, note: `${row.package} ${v}: release line ${line} is supported in the kit table` });
|
|
283
|
+
else if (status)
|
|
284
|
+
bad.push({ ...where, note: `${row.package} ${v}: release line ${line} is ${status.toLowerCase()} in the kit's framework support table` });
|
|
285
|
+
else
|
|
286
|
+
unknown.push(`${item.id} ${v}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const absence = { pattern: `release lines in ${str(p.table)}`, files_searched: 1, file_globs: [str(p.table)], matches: good.length };
|
|
290
|
+
if (bad.length)
|
|
291
|
+
return { value: false, evidence: cap(bad), absence };
|
|
292
|
+
if (unknown.length)
|
|
293
|
+
return cannot(`no row in the framework support table for ${unknown.sort(cmp).join(', ')}`);
|
|
294
|
+
return { value: true, evidence: cap(good), absence };
|
|
295
|
+
}
|
|
296
|
+
export const OPS = {
|
|
297
|
+
file_exists: (c, p) => fileExists(c, p, true),
|
|
298
|
+
file_absent: (c, p) => fileExists(c, p, false),
|
|
299
|
+
file_exists_for_each: fileExistsForEach,
|
|
300
|
+
regex_present: (c, p) => regexOp(c, p, true),
|
|
301
|
+
regex_absent: (c, p) => regexOp(c, p, false),
|
|
302
|
+
sql_every_table_has: sqlEveryTableHas,
|
|
303
|
+
workflow_uses_pinned_sha: workflowUsesPinnedSha,
|
|
304
|
+
workflow_permissions: workflowPermissions,
|
|
305
|
+
version_in_table: versionInTable,
|
|
306
|
+
// json_value is in the op list of the design and not built: no check needs it yet.
|
|
307
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PRO_MAP_CHECKS = [];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Loads every check (checks/*.json and the TypeScript checks in src/checks/code/), runs the ones that apply, and returns
|
|
2
|
+
// checks.json. Deterministic: files and checks are sorted, no clock, no network, no program is run.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join, sep } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { cmp } from '../util.js';
|
|
8
|
+
import { OPS } from './ops.js';
|
|
9
|
+
import { check as webhookVerify } from './code/webhook-verify.js';
|
|
10
|
+
import { ownerFilter, toolOwnerFilter } from './code/owner-filter.js';
|
|
11
|
+
import { check as adminRole } from './code/admin-role.js';
|
|
12
|
+
import { ENTRY_CHECKS } from './map/entry-checks.js';
|
|
13
|
+
import { CONFIG_CHECKS } from './map/config-checks.js';
|
|
14
|
+
import { PRO_MAP_CHECKS } from './pro-map.js';
|
|
15
|
+
export const CHECKS_DIR = fileURLToPath(new URL('../../checks/', import.meta.url));
|
|
16
|
+
export const CODE_CHECKS = [webhookVerify, ownerFilter, toolOwnerFilter, adminRole, ...ENTRY_CHECKS, ...PRO_MAP_CHECKS, ...CONFIG_CHECKS];
|
|
17
|
+
export function loadChecks(dir = CHECKS_DIR) {
|
|
18
|
+
const json = readdirSync(dir).filter((n) => n.endsWith('.json')).sort(cmp)
|
|
19
|
+
.map((n) => ({ ...JSON.parse(readFileSync(join(dir, n), 'utf8')), source: join(dir, n) }));
|
|
20
|
+
return [...json, ...CODE_CHECKS].sort((a, b) => cmp(a.id, b.id));
|
|
21
|
+
}
|
|
22
|
+
// sha256 of the check's own file: a JSON definition or the TypeScript module. Goes into machine_evidence.config_sha256.
|
|
23
|
+
const shaCache = new Map();
|
|
24
|
+
// A module running from dist/ is hashed through its TypeScript twin in src/ (shipped with the package), so the same check gives the
|
|
25
|
+
// same hash whether the engine runs from the sources or from the compiled build.
|
|
26
|
+
const sourceFile = (p) => {
|
|
27
|
+
if (!p.endsWith('.js'))
|
|
28
|
+
return p;
|
|
29
|
+
const twin = p.replace(/\.js$/, '.ts').replace(`${sep}dist${sep}`, `${sep}src${sep}`);
|
|
30
|
+
return existsSync(twin) ? twin : p;
|
|
31
|
+
};
|
|
32
|
+
export function checkSha(c) {
|
|
33
|
+
let s = shaCache.get(c.source);
|
|
34
|
+
if (!s) {
|
|
35
|
+
s = createHash('sha256').update(readFileSync(sourceFile(c.source))).digest('hex');
|
|
36
|
+
shaCache.set(c.source, s);
|
|
37
|
+
}
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
// The check applies when every category it names has an item whose id it lists (or any item for "*").
|
|
41
|
+
export function appliesTo(c, stack) {
|
|
42
|
+
for (const [cat, want] of Object.entries(c.applies_when)) {
|
|
43
|
+
const items = stack[cat] ?? [];
|
|
44
|
+
const ok = want === '*' ? items.length > 0 : items.some((i) => want.includes(i.id));
|
|
45
|
+
if (!ok)
|
|
46
|
+
return `not applicable: stack.json has no ${cat} item${want === '*' ? '' : ` of ${want.join(', ')}`}`;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
export function runOne(c, ctx) {
|
|
51
|
+
if (c.op === 'code')
|
|
52
|
+
return c.run(ctx);
|
|
53
|
+
const op = OPS[c.op];
|
|
54
|
+
if (!op)
|
|
55
|
+
return { value: 'cannot_run', reason: `the op ${c.op} is not built in this engine version`, evidence: [], absence: null };
|
|
56
|
+
return op(ctx, c.params);
|
|
57
|
+
}
|
|
58
|
+
export function runChecks(ctx, checks = loadChecks()) {
|
|
59
|
+
const results = [];
|
|
60
|
+
const notRun = [];
|
|
61
|
+
for (const c of checks) {
|
|
62
|
+
const skip = appliesTo(c, ctx.stack);
|
|
63
|
+
if (skip) {
|
|
64
|
+
notRun.push({ check_id: c.id, reason: skip });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
let o;
|
|
68
|
+
try {
|
|
69
|
+
o = runOne(c, ctx);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
o = { value: 'cannot_run', reason: `the check failed: ${e.message}`, evidence: [], absence: null };
|
|
73
|
+
}
|
|
74
|
+
if (o.value === 'not_applicable') {
|
|
75
|
+
notRun.push({ check_id: c.id, reason: `not applicable: ${o.reason ?? ''}`.trim() });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (o.value === 'cannot_run') {
|
|
79
|
+
notRun.push({ check_id: c.id, reason: `cannot run: ${o.reason ?? ''}`.trim() });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
results.push({
|
|
83
|
+
check_id: c.id,
|
|
84
|
+
sec_ids: [...c.sec_ids].sort(cmp),
|
|
85
|
+
result: o.value ? c.on_true : c.on_false,
|
|
86
|
+
evidence: o.evidence,
|
|
87
|
+
absence_search: o.absence,
|
|
88
|
+
message_key: c.id,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return { schema_version: '1.0.0', commit: ctx.stack.commit, results, not_run: notRun };
|
|
92
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// pr-comment.md: the summary posted on a pull request. Same findings, same bytes: no clock, no link to a run.
|
|
2
|
+
// The first line is a marker so the Action can find and update its own comment instead of adding a new one each push.
|
|
3
|
+
import { cmp } from '../util.js';
|
|
4
|
+
import { ENGINE_VERSION } from '../version.js';
|
|
5
|
+
import { SEV_RANK } from '../report/kit.js';
|
|
6
|
+
export const COMMENT_MARKER = '<!-- hullproof-ci -->';
|
|
7
|
+
export const FAIL_ON_LEVELS = ['blocker', 'critical', 'high', 'medium', 'low', 'none'];
|
|
8
|
+
const MAX_ROWS = 15;
|
|
9
|
+
// The CI verdict. A fail record blocks when its severity is at or above the chosen level. Only fail records count: observations,
|
|
10
|
+
// leads and routed items never do, as in the release gate. "none" never blocks.
|
|
11
|
+
export function ciVerdict(f, failOn) {
|
|
12
|
+
const fails = f.records.filter((r) => r.kind === 'fail')
|
|
13
|
+
.sort((a, b) => SEV_RANK[b.severity] - SEV_RANK[a.severity] || cmp(a.fingerprint, b.fingerprint));
|
|
14
|
+
const min = failOn === 'none' ? 99 : SEV_RANK[failOn.toUpperCase()];
|
|
15
|
+
const blocking = fails.filter((r) => SEV_RANK[r.severity] >= min);
|
|
16
|
+
return { blocked: blocking.length > 0, blocking, others: fails.filter((r) => !blocking.includes(r)) };
|
|
17
|
+
}
|
|
18
|
+
const cell = (s) => s.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim();
|
|
19
|
+
const where = (r) => (r.evidence[0] ? `\`${r.evidence[0].file}:${r.evidence[0].line}\`` : 'repository');
|
|
20
|
+
export function prComment(i) {
|
|
21
|
+
const { findings, verdict, failOn } = i;
|
|
22
|
+
const out = [COMMENT_MARKER];
|
|
23
|
+
const p = (...l) => { out.push(...l); };
|
|
24
|
+
const n = verdict.blocking.length;
|
|
25
|
+
p(`## Hullproof: ${verdict.blocked ? `${n} blocking finding${n === 1 ? '' : 's'}` : 'no blocking findings'}`, '');
|
|
26
|
+
p(`Commit \`${findings.report.commit.slice(0, 12)}\`. Hullproof Engine ${ENGINE_VERSION}, ${i.edition} edition, stage ${findings.report.stage}. This check fails on ${failOn === 'none' ? 'nothing (report only)' : `${failOn.toUpperCase()} and above`}. Scanners that ran: ${i.scanners.length ? i.scanners.join(', ') : 'none'}.`, '');
|
|
27
|
+
if (verdict.blocked) {
|
|
28
|
+
p('| Severity | Where | Finding | Requirement |', '|---|---|---|---|');
|
|
29
|
+
for (const r of verdict.blocking.slice(0, MAX_ROWS))
|
|
30
|
+
p(`| ${r.severity} | ${where(r)} | ${cell(r.title)} | ${r.sec_ids.join(', ')} |`);
|
|
31
|
+
if (n > MAX_ROWS)
|
|
32
|
+
p('', `${n - MAX_ROWS} more blocking findings are in report.md.`);
|
|
33
|
+
p('', '### How to fix the first one', '');
|
|
34
|
+
const first = verdict.blocking[0];
|
|
35
|
+
p(cell(first.impact_statement), '', cell(first.remediation.strategy), '', `To check the fix: ${cell(first.remediation.confirm)}`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
p('Nothing at or above the chosen level failed a check or a mapped rule. That does not mean the requirements pass: most are settled by provider settings or a test of the running app, and report.md lists what nothing settled.');
|
|
39
|
+
}
|
|
40
|
+
if (verdict.others.length)
|
|
41
|
+
p('', `Other findings below the blocking level: ${verdict.others.length} (see report.md).`);
|
|
42
|
+
p('', 'This comment comes from code and configuration only, with no model. The full report and results.sarif are in the workflow artifacts.', '');
|
|
43
|
+
return out.join('\n');
|
|
44
|
+
}
|