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,72 @@
|
|
|
1
|
+
// The read only view of a repository that every check runs against: the file list, file text, stack.json and scan hits.
|
|
2
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { LIMITS } from '../discover/discover.ts';
|
|
5
|
+
import { walk } from '../discover/walk.ts';
|
|
6
|
+
import { cmp, globToRegExp } from '../util.ts';
|
|
7
|
+
import { readTracked } from '../scanners/common.ts';
|
|
8
|
+
import type { CheckEvidence, Hit, Stack } from '../types/index.ts';
|
|
9
|
+
|
|
10
|
+
// Folders no check searches: engine output, the kit's own files and the project's security records. The kit folders hold
|
|
11
|
+
// text that names almost every term a check looks for (STANDARD.md, "What the searches leave out").
|
|
12
|
+
export const LEFT_OUT = ['.hullproof/', 'docs/hullproof/', 'tools/hullproof/', '.claude/', 'editors/', 'prompts/', 'docs/security/'];
|
|
13
|
+
|
|
14
|
+
export interface CheckContext {
|
|
15
|
+
repo: string; // absolute path
|
|
16
|
+
files: string[]; // repository relative POSIX paths, sorted, LEFT_OUT removed
|
|
17
|
+
stack: Stack;
|
|
18
|
+
hits: Hit[];
|
|
19
|
+
kitDir: string | null; // absolute path of the kit (docs/hullproof and tools/hullproof under it), when known
|
|
20
|
+
text(file: string): string; // '' when unreadable; at most LIMITS.maxBytesPerFile bytes
|
|
21
|
+
select(globs: string[], exclude?: string[]): string[];
|
|
22
|
+
// Every file in the git index, sorted, including files inside folders discovery skips (.vercel, .netlify, dist ...),
|
|
23
|
+
// so a check can see a committed link or build file. null outside a git repository. Read once, on first use.
|
|
24
|
+
tracked(): string[] | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readHead(abs: string, max: number): string {
|
|
28
|
+
const fd = openSync(abs, 'r');
|
|
29
|
+
try {
|
|
30
|
+
const buf = Buffer.alloc(max);
|
|
31
|
+
const n = readSync(fd, buf, 0, max, 0);
|
|
32
|
+
return buf.subarray(0, n).toString('utf8');
|
|
33
|
+
} finally { closeSync(fd); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function makeContext(repo: string, stack: Stack, hits: Hit[] = [], kitDir: string | null = null): CheckContext {
|
|
37
|
+
const root = resolve(repo);
|
|
38
|
+
const files = walk(root, LIMITS.maxFiles).files.filter((f) => !LEFT_OUT.some((p) => f.startsWith(p)));
|
|
39
|
+
const cache = new Map<string, string>();
|
|
40
|
+
const text = (f: string): string => {
|
|
41
|
+
let t = cache.get(f);
|
|
42
|
+
if (t === undefined) {
|
|
43
|
+
try { t = readHead(join(root, f), LIMITS.maxBytesPerFile); } catch { t = ''; }
|
|
44
|
+
cache.set(f, t);
|
|
45
|
+
}
|
|
46
|
+
return t;
|
|
47
|
+
};
|
|
48
|
+
const select = (globs: string[], exclude: string[] = []): string[] => {
|
|
49
|
+
const inc = globs.map(globToRegExp);
|
|
50
|
+
const exc = exclude.map(globToRegExp);
|
|
51
|
+
return files.filter((f) => inc.some((r) => r.test(f)) && !exc.some((r) => r.test(f)));
|
|
52
|
+
};
|
|
53
|
+
let trackedList: string[] | null | undefined;
|
|
54
|
+
const tracked = (): string[] | null => {
|
|
55
|
+
if (trackedList === undefined) { const t = readTracked(root); trackedList = t ? [...t].sort(cmp) : null; }
|
|
56
|
+
return trackedList;
|
|
57
|
+
};
|
|
58
|
+
return { repo: root, files, stack, hits, kitDir, text, select, tracked };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 1 based line of a character index.
|
|
62
|
+
export function lineOf(text: string, index: number): number {
|
|
63
|
+
let n = 1;
|
|
64
|
+
for (let i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) n++;
|
|
65
|
+
return n;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const byFileLine = (a: CheckEvidence, b: CheckEvidence): number =>
|
|
69
|
+
a.file < b.file ? -1 : a.file > b.file ? 1 : (a.line ?? 0) - (b.line ?? 0) || (a.note < b.note ? -1 : a.note > b.note ? 1 : 0);
|
|
70
|
+
|
|
71
|
+
// The most evidence entries one check result keeps. The count of all matches is kept in absence_search.
|
|
72
|
+
export const MAX_EVIDENCE = 25;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Checks that read the configuration maps: Supabase tables (grants and policies) and Firebase rules. Start as leads.
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import type { CheckEvidence, SupabaseTable } from '../../types/index.ts';
|
|
4
|
+
import { PRIVILEGED } from '../../map/vocab.ts';
|
|
5
|
+
import type { CodeCheck } from '../types.ts';
|
|
6
|
+
import { mapCheck } from './shared.ts';
|
|
7
|
+
|
|
8
|
+
const source = fileURLToPath(import.meta.url);
|
|
9
|
+
const NATIVE_FIXTURES = { pass: 'test/fixtures/map-native/pass', fail: 'test/fixtures/map-native/fail' };
|
|
10
|
+
const CLIENT_ROLES = new Set(['anon', 'authenticated', 'public']);
|
|
11
|
+
const tables = (m: { config: { supabase: { tables: SupabaseTable[] } | null } }): SupabaseTable[] => m.config.supabase?.tables ?? [];
|
|
12
|
+
const alwaysTrue = (e: string | null): boolean => e !== null && /^\(?\s*true\s*\)?$/i.test(e.trim());
|
|
13
|
+
const WRITE_PRIVS = new Set(['insert', 'update', 'delete', 'all', 'truncate']);
|
|
14
|
+
|
|
15
|
+
export const supabaseOpenTable = mapCheck({
|
|
16
|
+
id: 'check.map.supabase-open-table', sec_ids: ['SEC-DB-001', 'SEC-DB-003'], name: 'supabase-open-table', source,
|
|
17
|
+
pattern: 'a table granted to anon without row level security, or with a policy for anon or public that is always true',
|
|
18
|
+
none: 'the code map has no Supabase table',
|
|
19
|
+
candidates: (m) => tables(m).length,
|
|
20
|
+
fails: (m) => tables(m).flatMap((t): CheckEvidence[] => {
|
|
21
|
+
const out: CheckEvidence[] = [];
|
|
22
|
+
const anon = t.grants.filter((g) => g.role === 'anon' || g.role === 'public');
|
|
23
|
+
if (t.rls_enabled !== true && anon.length) out.push({ file: t.file, line: anon[0].line, note: `${t.name} has no row level security and ${anon[0].role} holds ${[...new Set(anon.flatMap((g) => g.privileges))].join(', ')}` });
|
|
24
|
+
for (const p of t.policies) {
|
|
25
|
+
if (p.roles.some((r) => r === 'anon' || r === 'public') && (alwaysTrue(p.using) || (p.using === null && alwaysTrue(p.check)) || alwaysTrue(p.check))) out.push({ file: t.file, line: p.line, note: `${t.name} policy ${p.name} lets ${p.roles.join(', ')} ${p.op} with an expression that is always true` });
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const supabasePrivilegedColumnGrant = mapCheck({
|
|
32
|
+
id: 'check.map.supabase-privileged-column-grant', sec_ids: ['SEC-DB-033'], name: 'supabase-privileged-column-grant', source,
|
|
33
|
+
pattern: 'a client role holding insert or update on a table that has a role, plan, admin or similar column, without a column list that leaves it out',
|
|
34
|
+
none: 'the code map has no Supabase table with a role, plan or admin column',
|
|
35
|
+
candidates: (m) => tables(m).filter((t) => t.columns.some((c) => PRIVILEGED.test(c))).length,
|
|
36
|
+
fails: (m) => tables(m).flatMap((t): CheckEvidence[] => {
|
|
37
|
+
const priv = t.columns.filter((c) => PRIVILEGED.test(c) && !/^(?:owner_?id|org_?id|tenant_?id)$/i.test(c));
|
|
38
|
+
if (!priv.length) return [];
|
|
39
|
+
const lines = new Map<string, { role: string; privileges: Set<string>; columns: string[] | null; line: number }>();
|
|
40
|
+
for (const g of t.grants.filter((x) => CLIENT_ROLES.has(x.role) && x.privileges.some((p) => p === 'update' || p === 'insert' || p === 'all'))) {
|
|
41
|
+
const k = `${g.role}|${g.line}|${(g.columns ?? ['*']).join(',')}`;
|
|
42
|
+
const cur = lines.get(k) ?? { role: g.role, privileges: new Set<string>(), columns: g.columns, line: g.line };
|
|
43
|
+
for (const p of g.privileges) cur.privileges.add(p);
|
|
44
|
+
lines.set(k, cur);
|
|
45
|
+
}
|
|
46
|
+
return [...lines.values()].flatMap((g) => {
|
|
47
|
+
const reach = g.columns === null ? priv : priv.filter((c) => g.columns!.includes(c));
|
|
48
|
+
return reach.length ? [{ file: t.file, line: g.line, note: `${t.name}: ${g.role} can ${[...g.privileges].sort().join(', ')} ${g.columns === null ? 'every column' : 'the listed columns'} including ${reach.join(', ')}` }] : [];
|
|
49
|
+
});
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const supabasePolicyTrustsClaims = mapCheck({
|
|
54
|
+
id: 'check.map.supabase-policy-trusts-claims', sec_ids: ['SEC-DB-006'], name: 'supabase-policy-trusts-claims', source,
|
|
55
|
+
pattern: 'a policy expression that reads user_metadata or raw_user_meta_data',
|
|
56
|
+
none: 'the code map has no Supabase policy',
|
|
57
|
+
candidates: (m) => tables(m).reduce((n, t) => n + t.policies.length, 0),
|
|
58
|
+
fails: (m) => tables(m).flatMap((t): CheckEvidence[] => t.policies.filter((p) => /user_metadata|raw_user_meta_data/i.test(`${p.using ?? ''} ${p.check ?? ''}`)).map((p) => ({ file: t.file, line: p.line, note: `${t.name} policy ${p.name} trusts user_metadata, which the signed in user can edit` }))),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const firebaseAuthOnlyRule = mapCheck({
|
|
62
|
+
id: 'check.map.firebase-auth-only-rule', sec_ids: ['SEC-AUTHZ-010'], name: 'firebase-auth-only-rule', source,
|
|
63
|
+
pattern: 'a rule on a path with an owner wildcard whose only condition is that the caller is signed in',
|
|
64
|
+
none: 'the code map has no Firebase rule on a path with an owner wildcard',
|
|
65
|
+
candidates: (m) => (m.config.firebase?.rules ?? []).filter((r) => r.owner_wildcard).length,
|
|
66
|
+
fails: (m) => (m.config.firebase?.rules ?? []).filter((r) => r.owner_wildcard && r.uses_auth && !r.owner_bound && !r.always_true).map((r) => ({
|
|
67
|
+
file: r.file, line: r.line, note: `${r.service} ${r.path} allows ${r.ops.join(', ')} to any signed in user, not to the owner named in the path`,
|
|
68
|
+
})),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const mobileSecretInConfig = mapCheck({
|
|
72
|
+
id: 'check.map.mobile-secret-in-config', sec_ids: ['SEC-MOBILE-001'], name: 'mobile-secret-in-config', source, fixtures: NATIVE_FIXTURES,
|
|
73
|
+
pattern: 'a value written in AndroidManifest.xml, an Android strings file or an iOS Info.plist under a name that says secret, password or private key',
|
|
74
|
+
none: 'the code map has no Android or iOS configuration file',
|
|
75
|
+
candidates: (m) => (m.config.android?.manifests.length ?? 0) + (m.config.ios?.plists.length ?? 0),
|
|
76
|
+
fails: (m) => m.config.secrets.map((x): CheckEvidence => ({ file: x.file, line: x.line, note: `${x.where}: the name ${x.key} holds a value written into the file, and this file ships inside the app` })),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const CONFIG_CHECKS: CodeCheck[] = [supabaseOpenTable, supabasePrivilegedColumnGrant, supabasePolicyTrustsClaims, firebaseAuthOnlyRule, mobileSecretInConfig];
|
|
@@ -0,0 +1,116 @@
|
|
|
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 type { EntryPoint } from '../../types/index.ts';
|
|
5
|
+
import type { CodeCheck } from '../types.ts';
|
|
6
|
+
import { ev, evAt, has, hasAuth, judged, mapCheck, stateChanging, weakAuth, where } from './shared.ts';
|
|
7
|
+
|
|
8
|
+
const source = fileURLToPath(import.meta.url);
|
|
9
|
+
const exempt = (ep: EntryPoint): boolean => ep.tags.includes('auth_endpoint') || ep.tags.includes('public') || ep.tags.includes('webhook');
|
|
10
|
+
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;
|
|
11
|
+
|
|
12
|
+
export const mutatingRouteWithoutAuth = mapCheck({
|
|
13
|
+
id: 'check.map.mutating-route-without-auth', sec_ids: ['SEC-API-001'], name: 'mutating-route-without-auth', source,
|
|
14
|
+
pattern: 'a state changing entry point with no session read on the handler, a wrapper, the router or a gate',
|
|
15
|
+
none: 'the code map has no state changing entry point',
|
|
16
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && stateChanging(e)).length,
|
|
17
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && stateChanging(e) && !exempt(e) && !hasAuth(e)).map((e) => {
|
|
18
|
+
const weak = weakAuth(e);
|
|
19
|
+
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})` : ''}`);
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const dataReadWithoutAuth = mapCheck({
|
|
24
|
+
id: 'check.map.data-read-without-auth', sec_ids: ['SEC-API-001', 'SEC-DATA-024'], name: 'data-read-without-auth', source,
|
|
25
|
+
pattern: 'a GET entry point that reads a table with a personal data name and has no session read',
|
|
26
|
+
none: 'the code map has no GET entry point that reads a data store',
|
|
27
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.kind === 'http_route' && e.method !== 'POST' && e.data.some((d) => d.op === 'read')).length,
|
|
28
|
+
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) => {
|
|
29
|
+
const d = e.data.find((x) => x.op === 'read' && x.target && SENSITIVE.test(x.target) && x.store !== 'filesystem');
|
|
30
|
+
return d ? [evAt(e, `${where(e)} reads ${d.target} and no session read is reachable on it`, d.line)] : [];
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const writeByIdWithoutOwner = mapCheck({
|
|
35
|
+
id: 'check.map.write-by-id-without-owner', sec_ids: ['SEC-AUTHZ-003'], name: 'write-by-id-without-owner', source,
|
|
36
|
+
pattern: 'an update or delete filtered by an id from the request with no owner, user or tenant value from the session',
|
|
37
|
+
none: 'the code map has no update or delete by id',
|
|
38
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.data.some((d) => (d.op === 'write' || d.op === 'delete') && d.by_id)).length,
|
|
39
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.kind !== 'webhook' && !has(e, 'role_check')).flatMap((e) =>
|
|
40
|
+
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))),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const readByIdWithoutOwner = mapCheck({
|
|
44
|
+
id: 'check.map.read-by-id-without-owner', sec_ids: ['SEC-AUTHZ-003'], name: 'read-by-id-without-owner', source,
|
|
45
|
+
fixtures: { pass: 'test/fixtures/map-idor/pass', fail: 'test/fixtures/map-idor/fail' },
|
|
46
|
+
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',
|
|
47
|
+
none: 'the code map has no signed in entry point that reads a record by an id from the request',
|
|
48
|
+
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,
|
|
49
|
+
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) =>
|
|
50
|
+
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))),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const adminRouteWithoutRole = mapCheck({
|
|
54
|
+
id: 'check.map.admin-route-without-role', sec_ids: ['SEC-AUTHZ-020', 'SEC-AUTHZ-002'], name: 'admin-route-without-role', source,
|
|
55
|
+
pattern: 'an entry point in an admin area with no role check on the handler, a wrapper, the router or a gate',
|
|
56
|
+
none: 'the code map has no entry point in an admin area',
|
|
57
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.tags.includes('admin')).length,
|
|
58
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.tags.includes('admin') && !has(e, 'role_check')).map((e) =>
|
|
59
|
+
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)' : ''}`)),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const webhookVerifyBeforeUse = mapCheck({
|
|
63
|
+
id: 'check.map.webhook-verify-before-use', sec_ids: ['SEC-API-101'], name: 'webhook-verify-before-use', source,
|
|
64
|
+
pattern: 'a webhook entry point with no signature check, a check that does not stop the request, or a body parsed first',
|
|
65
|
+
none: 'the code map has no webhook entry point',
|
|
66
|
+
candidates: (m) => m.entry_points.filter((e) => !e.test_code && e.kind === 'webhook').length,
|
|
67
|
+
fails: (m) => m.entry_points.filter((e) => !e.test_code && e.kind === 'webhook').flatMap((e) => {
|
|
68
|
+
const sigs = e.protections.filter((p) => p.kind === 'signature_verify');
|
|
69
|
+
if (!sigs.length) return [ev(e, `${where(e)} is a webhook and no signature check was found on it`)];
|
|
70
|
+
if (sigs.every((p) => p.enforced === false)) return [{ file: sigs[0].file, line: sigs[0].line, note: `${where(e)} checks the signature but carries on when it fails` }];
|
|
71
|
+
if (sigs.some((p) => p.after_parse) && !sigs.some((p) => !p.after_parse)) return [{ file: sigs[0].file, line: sigs[0].line, note: `${where(e)} parses the request body before it checks the signature` }];
|
|
72
|
+
return [];
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const requestUrlFetched = mapCheck({
|
|
77
|
+
id: 'check.map.request-url-fetched', sec_ids: ['SEC-API-035'], name: 'request-url-fetched', source,
|
|
78
|
+
pattern: 'an outbound HTTP call whose target comes from the request, with no allowlist in the same function',
|
|
79
|
+
none: 'the code map has no outbound HTTP call in an entry point',
|
|
80
|
+
candidates: (m) => m.entry_points.filter((e) => !e.test_code && e.outbound.some((o) => o.kind === 'http')).length,
|
|
81
|
+
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) =>
|
|
82
|
+
evAt(e, `${where(e)} fetches ${o.target}, which comes from the request, and no allowlist of hosts is checked`, o.line))),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export const privilegedFieldFromRequest = mapCheck({
|
|
86
|
+
id: 'check.map.privileged-field-from-request', sec_ids: ['SEC-AUTHZ-004'], name: 'privileged-field-from-request', source,
|
|
87
|
+
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',
|
|
88
|
+
none: 'the code map has no write in an entry point',
|
|
89
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.data.some((d) => d.op === 'write')).length,
|
|
90
|
+
fails: (m) => m.entry_points.filter((e) => judged(e) && e.kind !== 'webhook' && !has(e, 'role_check')).flatMap((e) =>
|
|
91
|
+
e.data.filter((d) => d.op === 'write' && (d.sets.length > 0 || d.mass_assign)).map((d) =>
|
|
92
|
+
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))),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const privilegedActionWithoutAudit = mapCheck({
|
|
96
|
+
id: 'check.map.privileged-action-without-audit', sec_ids: ['SEC-LOG-003'], name: 'privileged-action-without-audit', source,
|
|
97
|
+
pattern: 'a write or delete in an admin area with no audit record call',
|
|
98
|
+
none: 'the code map has no write or delete in an admin area',
|
|
99
|
+
candidates: (m) => m.entry_points.filter((e) => judged(e) && e.tags.includes('admin') && e.data.some((d) => d.op === 'write' || d.op === 'delete')).length,
|
|
100
|
+
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) =>
|
|
101
|
+
ev(e, `${where(e)} changes data in an admin area and writes no audit record`)),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const redirectFromRequest = mapCheck({
|
|
105
|
+
id: 'check.map.redirect-from-request', sec_ids: ['SEC-AUTH-030'], name: 'redirect-from-request', source,
|
|
106
|
+
pattern: 'a redirect target taken from the request with no allowlist comparison in the same function',
|
|
107
|
+
none: 'the code map has no redirect in an entry point',
|
|
108
|
+
candidates: (m) => m.entry_points.filter((e) => !e.test_code && e.outbound.some((o) => o.kind === 'redirect')).length,
|
|
109
|
+
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) =>
|
|
110
|
+
evAt(e, `${where(e)} sends the user to ${o.target}, which comes from the request, and no allowlist is checked`, o.line))),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const ENTRY_CHECKS: CodeCheck[] = [
|
|
114
|
+
mutatingRouteWithoutAuth, dataReadWithoutAuth, writeByIdWithoutOwner, readByIdWithoutOwner, adminRouteWithoutRole, webhookVerifyBeforeUse,
|
|
115
|
+
requestUrlFetched, privilegedFieldFromRequest, privilegedActionWithoutAudit, redirectFromRequest,
|
|
116
|
+
];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Helpers for the checks that read the code map (docs/PHASE-2-DESIGN.md section 4). The map is built once per context.
|
|
2
|
+
import type { CheckEvidence, CodeMap, EntryPoint, Protection, ProtectionKind } from '../../types/index.ts';
|
|
3
|
+
import { buildCodeMap } from '../../map/build.ts';
|
|
4
|
+
import type { CheckContext } from '../context.ts';
|
|
5
|
+
import type { OpOutcome } from '../ops.ts';
|
|
6
|
+
import type { CodeCheck } from '../types.ts';
|
|
7
|
+
|
|
8
|
+
const cache = new WeakMap<CheckContext, CodeMap>();
|
|
9
|
+
export function getMap(ctx: CheckContext): CodeMap {
|
|
10
|
+
let m = cache.get(ctx);
|
|
11
|
+
if (!m) { m = buildCodeMap(ctx); cache.set(ctx, m); }
|
|
12
|
+
return m;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A protection that counts: not a weak guess, and not one the code is known to ignore.
|
|
16
|
+
export const counts = (p: Protection): boolean => p.confidence !== 'low' && p.enforced !== false;
|
|
17
|
+
export const has = (ep: EntryPoint, kind: ProtectionKind): boolean => ep.protections.some((p) => p.kind === kind && counts(p));
|
|
18
|
+
export const hasAuth = (ep: EntryPoint): boolean => has(ep, 'session_read') || has(ep, 'signature_verify');
|
|
19
|
+
export const weakAuth = (ep: EntryPoint): Protection | undefined => ep.protections.find((p) => p.kind === 'session_read' && !counts(p));
|
|
20
|
+
|
|
21
|
+
export const HANDLER_KINDS = new Set(['http_route', 'server_action', 'graphql_resolver', 'rpc']);
|
|
22
|
+
// Entry points the checks judge: shipped code, a kind that handles requests.
|
|
23
|
+
export const judged = (ep: EntryPoint): boolean => !ep.test_code && HANDLER_KINDS.has(ep.kind) && ep.framework !== 'django-admin';
|
|
24
|
+
export const stateChanging = (ep: EntryPoint): boolean => ep.tags.includes('state_changing') || (ep.kind === 'graphql_resolver' && ep.method === 'MUTATION') || (ep.kind === 'rpc' && ep.method === 'MUTATION');
|
|
25
|
+
|
|
26
|
+
// 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
|
|
27
|
+
// call), in the file that holds the handler: a route registered in one file can have its handler in another.
|
|
28
|
+
export const ev = (ep: EntryPoint, note: string): CheckEvidence => ({ file: ep.file, line: ep.line, note });
|
|
29
|
+
export const evAt = (ep: EntryPoint, note: string, line: number): CheckEvidence => ({ file: ep.handler?.file ?? ep.file, line, note });
|
|
30
|
+
|
|
31
|
+
export const where = (ep: EntryPoint): string => `${ep.method && ep.method !== 'ANY' ? ep.method + ' ' : ''}${ep.path ?? ep.name ?? ep.file}`.trim();
|
|
32
|
+
|
|
33
|
+
export interface MapCheckSpec {
|
|
34
|
+
id: string; sec_ids: string[]; name: string; source: string;
|
|
35
|
+
// The entry points or records the check looks at. Zero candidates makes the check not applicable.
|
|
36
|
+
candidates(map: CodeMap): number;
|
|
37
|
+
// Evidence for everything that fails.
|
|
38
|
+
fails(map: CodeMap, ctx: CheckContext): CheckEvidence[];
|
|
39
|
+
none: string; // reason when there is nothing to judge
|
|
40
|
+
pattern: string; // what was searched, for the absence record
|
|
41
|
+
fixtures?: { pass: string; fail: string }; // when the check reads facts the shared map fixtures do not hold
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function mapCheck(s: MapCheckSpec): CodeCheck {
|
|
45
|
+
return {
|
|
46
|
+
id: s.id, sec_ids: s.sec_ids, applies_when: {}, op: 'code', on_true: 'NOT ASSESSED: NEEDS DYNAMIC TEST', on_false: 'FAIL',
|
|
47
|
+
fixtures: s.fixtures ?? { pass: 'test/fixtures/map/pass', fail: 'test/fixtures/map/fail' }, source: s.source,
|
|
48
|
+
run: (ctx): OpOutcome => {
|
|
49
|
+
const map = getMap(ctx);
|
|
50
|
+
const n = s.candidates(map);
|
|
51
|
+
if (n === 0) return { value: 'not_applicable', reason: s.none, evidence: [], absence: null };
|
|
52
|
+
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));
|
|
53
|
+
return { value: bad.length === 0, evidence: bad, absence: { pattern: s.pattern, files_searched: n, file_globs: ['codemap.json'], matches: bad.length } };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The text of an entry point's handler, with `above` lines before it (decorators and route options sit above).
|
|
59
|
+
export function handlerText(ctx: CheckContext, ep: EntryPoint, above = 0): string {
|
|
60
|
+
const file = ep.handler?.file ?? ep.file;
|
|
61
|
+
const start = ep.handler?.line ?? ep.line;
|
|
62
|
+
return ctx.text(file).split('\n').slice(Math.max(0, start - 1 - above), Math.max(start, ep.end_line)).join('\n');
|
|
63
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
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.ts';
|
|
6
|
+
import type { AbsenceSearch, CheckEvidence, CheckOp } from '../types/index.ts';
|
|
7
|
+
import { MAX_EVIDENCE, byFileLine, lineOf, type CheckContext } from './context.ts';
|
|
8
|
+
|
|
9
|
+
// value: true or false picks on_true or on_false. 'not_applicable' and 'cannot_run' put the check in not_run with reason.
|
|
10
|
+
export interface OpOutcome {
|
|
11
|
+
value: boolean | 'not_applicable' | 'cannot_run';
|
|
12
|
+
reason?: string;
|
|
13
|
+
evidence: CheckEvidence[];
|
|
14
|
+
absence: AbsenceSearch | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type P = Record<string, unknown>;
|
|
18
|
+
const strs = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : typeof v === 'string' ? [v] : []);
|
|
19
|
+
const str = (v: unknown, d = ''): string => (typeof v === 'string' ? v : d);
|
|
20
|
+
const cap = (ev: CheckEvidence[]): CheckEvidence[] => ev.sort(byFileLine).slice(0, MAX_EVIDENCE);
|
|
21
|
+
const na = (reason: string): OpOutcome => ({ value: 'not_applicable', reason, evidence: [], absence: null });
|
|
22
|
+
const cannot = (reason: string): OpOutcome => ({ value: 'cannot_run', reason, evidence: [], absence: null });
|
|
23
|
+
|
|
24
|
+
export function compile(pattern: string, ignoreCase: boolean): RegExp {
|
|
25
|
+
return new RegExp(pattern, ignoreCase ? 'gim' : 'gm');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Every match of re in text, as evidence.
|
|
29
|
+
export function matchesIn(file: string, text: string, re: RegExp, note: string): CheckEvidence[] {
|
|
30
|
+
const out: CheckEvidence[] = [];
|
|
31
|
+
re.lastIndex = 0;
|
|
32
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
33
|
+
out.push({ file, line: lineOf(text, m.index), note });
|
|
34
|
+
if (m[0] === '') re.lastIndex++;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Files the op reads: params.files minus params.exclude, kept only when they match params.only_if.
|
|
40
|
+
function selected(ctx: CheckContext, p: P): string[] {
|
|
41
|
+
const files = ctx.select(strs(p.files), strs(p.exclude));
|
|
42
|
+
if (typeof p.only_if !== 'string') return files;
|
|
43
|
+
const re = compile(p.only_if, p.ignore_case === true);
|
|
44
|
+
return files.filter((f) => { re.lastIndex = 0; return re.test(ctx.text(f)); });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// params.precondition {files, exclude?, pattern?}: when no file meets it the check does not apply. Returns the files that do.
|
|
48
|
+
function precondition(ctx: CheckContext, p: P): string[] | null {
|
|
49
|
+
const pre = p.precondition as P | undefined;
|
|
50
|
+
if (!pre) return null;
|
|
51
|
+
const files = ctx.select(strs(pre.files), strs(pre.exclude));
|
|
52
|
+
if (typeof pre.pattern !== 'string') return files;
|
|
53
|
+
const re = compile(pre.pattern, pre.ignore_case === true);
|
|
54
|
+
return files.filter((f) => { re.lastIndex = 0; return re.test(ctx.text(f)); });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function preEvidence(ctx: CheckContext, p: P, files: string[]): CheckEvidence[] {
|
|
58
|
+
const pre = p.precondition as P;
|
|
59
|
+
const note = str(p.precondition_note, 'searched here');
|
|
60
|
+
if (typeof pre.pattern !== 'string') return files.map((file) => ({ file, line: 1, note }));
|
|
61
|
+
const re = compile(pre.pattern, pre.ignore_case === true);
|
|
62
|
+
return files.map((file) => ({ file, line: matchesIn(file, ctx.text(file), re, note)[0]?.line ?? 1, note }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function regexOp(ctx: CheckContext, p: P, present: boolean): OpOutcome {
|
|
66
|
+
const pre = precondition(ctx, p);
|
|
67
|
+
if (pre && pre.length === 0) return na(str(p.not_applicable_reason, 'no file meets the precondition'));
|
|
68
|
+
const files = selected(ctx, p);
|
|
69
|
+
const pattern = str(p.pattern);
|
|
70
|
+
if (!pattern) return cannot('params.pattern is missing');
|
|
71
|
+
const absence = (matches: number): AbsenceSearch => ({ pattern, files_searched: files.length, file_globs: strs(p.files), matches });
|
|
72
|
+
if (files.length === 0) {
|
|
73
|
+
const w = str(p.when_no_files, 'not_applicable');
|
|
74
|
+
if (w === 'not_applicable') return na(str(p.not_applicable_reason, 'no file to search'));
|
|
75
|
+
const value = w === 'true';
|
|
76
|
+
return { value, evidence: !value && pre ? cap(preEvidence(ctx, p, pre)) : [], absence: absence(0) };
|
|
77
|
+
}
|
|
78
|
+
const re = compile(pattern, p.ignore_case === true);
|
|
79
|
+
const note = str(p.note, present ? 'matches the pattern' : 'matches the pattern this check forbids');
|
|
80
|
+
const perFile = files.map((f) => ({ f, ev: matchesIn(f, ctx.text(f), re, note) }));
|
|
81
|
+
const all = perFile.flatMap((x) => x.ev);
|
|
82
|
+
if (!present) return { value: all.length === 0, evidence: cap(all), absence: absence(all.length) };
|
|
83
|
+
if (p.each_file === true) {
|
|
84
|
+
const missing = perFile.filter((x) => x.ev.length === 0).map((x) => x.f);
|
|
85
|
+
const missNote = str(p.missing_note, 'the pattern is not in this file');
|
|
86
|
+
return missing.length
|
|
87
|
+
? { value: false, evidence: cap(missing.map((file) => ({ file, line: 1, note: missNote }))), absence: absence(all.length) }
|
|
88
|
+
: { value: true, evidence: cap(all), absence: absence(all.length) };
|
|
89
|
+
}
|
|
90
|
+
if (all.length) return { value: true, evidence: cap(all), absence: absence(all.length) };
|
|
91
|
+
// Nothing found anywhere: point at the files where the thing is normally configured, when the check names them.
|
|
92
|
+
return { value: false, evidence: pre ? cap(preEvidence(ctx, p, pre)) : [], absence: absence(0) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fileExists(ctx: CheckContext, p: P, wantPresent: boolean): OpOutcome {
|
|
96
|
+
const found = ctx.select(strs(p.files), strs(p.exclude));
|
|
97
|
+
const note = str(p.note, wantPresent ? 'file present' : 'file present that this check forbids');
|
|
98
|
+
const evidence = cap(found.map((file) => ({ file, key: '(present)', note })));
|
|
99
|
+
const absence: AbsenceSearch = { pattern: strs(p.files).join(' '), files_searched: ctx.files.length, file_globs: strs(p.files), matches: found.length };
|
|
100
|
+
return { value: wantPresent ? found.length > 0 : found.length === 0, evidence, absence };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// params.manifests: manifest file name to the lockfile names that pin it. A lockfile in the same folder or any parent counts.
|
|
104
|
+
function fileExistsForEach(ctx: CheckContext, p: P): OpOutcome {
|
|
105
|
+
const table = (p.manifests ?? {}) as Record<string, string[]>;
|
|
106
|
+
const names = Object.keys(table);
|
|
107
|
+
const have = new Set(ctx.files);
|
|
108
|
+
const manifests = ctx.files.filter((f) => names.includes(f.slice(f.lastIndexOf('/') + 1)));
|
|
109
|
+
if (manifests.length === 0) return na('no package manifest in the repository');
|
|
110
|
+
const missing: CheckEvidence[] = [];
|
|
111
|
+
for (const m of manifests) {
|
|
112
|
+
const locks = table[m.slice(m.lastIndexOf('/') + 1)];
|
|
113
|
+
const parts = m.split('/').slice(0, -1);
|
|
114
|
+
let ok = false;
|
|
115
|
+
for (let i = parts.length; i >= 0 && !ok; i--) {
|
|
116
|
+
const dir = parts.slice(0, i).join('/');
|
|
117
|
+
ok = locks.some((l) => have.has(dir ? `${dir}/${l}` : l));
|
|
118
|
+
}
|
|
119
|
+
if (!ok) missing.push({ file: m, line: 1, note: str(p.note, `no lockfile (${locks.join(', ')}) in this folder or above`) });
|
|
120
|
+
}
|
|
121
|
+
const absence: AbsenceSearch = { pattern: [...new Set(Object.values(table).flat())].sort(cmp).join(' '), files_searched: manifests.length, file_globs: names, matches: manifests.length - missing.length };
|
|
122
|
+
return missing.length ? { value: false, evidence: cap(missing), absence } : { value: true, evidence: cap(manifests.map((file) => ({ file, line: 1, note: 'lockfile found' }))), absence };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Strips SQL comments, keeping line breaks so line numbers stay right.
|
|
126
|
+
const stripSqlComments = (t: string): string => t.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
127
|
+
const tableName = (raw: string): { schema: string | null; name: string } => {
|
|
128
|
+
const parts = raw.replace(/"/g, '').toLowerCase().split('.');
|
|
129
|
+
return parts.length > 1 ? { schema: parts[0], name: parts[1] } : { schema: null, name: parts[0] };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Every table created in an exposed schema has row level security enabled after it, reading files in name order.
|
|
133
|
+
function sqlEveryTableHas(ctx: CheckContext, p: P): OpOutcome {
|
|
134
|
+
const files = ctx.select(strs(p.files), strs(p.exclude));
|
|
135
|
+
if (files.length === 0) return na('no migration files');
|
|
136
|
+
const schemas = strs(p.schemas).length ? strs(p.schemas) : ['public'];
|
|
137
|
+
const ident = '((?:"[^"]+"|[\\w$]+)(?:\\.(?:"[^"]+"|[\\w$]+))?)';
|
|
138
|
+
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');
|
|
139
|
+
const tables = new Map<string, { file: string; line: number; rls: boolean }>();
|
|
140
|
+
const key = (raw: string): string | null => {
|
|
141
|
+
const t = tableName(raw);
|
|
142
|
+
const schema = t.schema ?? schemas[0];
|
|
143
|
+
return schemas.includes(schema) ? `${schema}.${t.name}` : null;
|
|
144
|
+
};
|
|
145
|
+
for (const f of files) {
|
|
146
|
+
const text = stripSqlComments(ctx.text(f));
|
|
147
|
+
re.lastIndex = 0;
|
|
148
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
149
|
+
if (m[2]) { const k = key(m[2]); if (k) tables.set(k, { file: f, line: lineOf(text, m.index), rls: false }); }
|
|
150
|
+
else if (m[3]) { const k = key(m[3]); const t = k ? tables.get(k) : undefined; if (t) t.rls = m[4].toLowerCase() !== 'disable'; }
|
|
151
|
+
else if (m[5]) { const k = key(m[5]); if (k) tables.delete(k); }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (tables.size === 0) return na('no table created in an exposed schema');
|
|
155
|
+
const without = [...tables.entries()].filter(([, t]) => !t.rls);
|
|
156
|
+
const absence: AbsenceSearch = { pattern: 'alter table <name> enable row level security', files_searched: files.length, file_globs: strs(p.files), matches: tables.size - without.length };
|
|
157
|
+
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` }));
|
|
158
|
+
return { value: without.length === 0, evidence: cap(evidence), absence };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function workflows(ctx: CheckContext, p: P): string[] {
|
|
162
|
+
return ctx.select(strs(p.files).length ? strs(p.files) : ['.github/workflows/*.yml', '.github/workflows/*.yaml']);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Every third party action is referenced by a full 40 character commit SHA.
|
|
166
|
+
function workflowUsesPinnedSha(ctx: CheckContext, p: P): OpOutcome {
|
|
167
|
+
const files = workflows(ctx, p);
|
|
168
|
+
if (files.length === 0) return na('no CI workflow files');
|
|
169
|
+
const re = /^[ \t-]*uses:\s*["']?([^\s"'#]+)/gm;
|
|
170
|
+
const bad: CheckEvidence[] = [];
|
|
171
|
+
let total = 0;
|
|
172
|
+
for (const f of files) {
|
|
173
|
+
const text = ctx.text(f);
|
|
174
|
+
re.lastIndex = 0;
|
|
175
|
+
for (let m = re.exec(text); m; m = re.exec(text)) {
|
|
176
|
+
const ref = m[1];
|
|
177
|
+
if (ref.startsWith('./') || ref.startsWith('docker://')) continue;
|
|
178
|
+
total++;
|
|
179
|
+
const at = ref.lastIndexOf('@');
|
|
180
|
+
if (at < 0 || !/^[0-9a-f]{40}$/.test(ref.slice(at + 1))) bad.push({ file: f, line: lineOf(text, m.index), note: `${ref} is not pinned to a full commit SHA` });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (total === 0) return na('no third party action is used');
|
|
184
|
+
const absence: AbsenceSearch = { pattern: 'uses: <owner>/<repo>@<40 hex>', files_searched: files.length, file_globs: strs(p.files), matches: total - bad.length };
|
|
185
|
+
return { value: bad.length === 0, evidence: cap(bad), absence };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Every workflow declares token permissions (at the top or on every job) and none grants write-all.
|
|
189
|
+
function workflowPermissions(ctx: CheckContext, p: P): OpOutcome {
|
|
190
|
+
const files = workflows(ctx, p);
|
|
191
|
+
if (files.length === 0) return na('no CI workflow files');
|
|
192
|
+
const bad: CheckEvidence[] = [];
|
|
193
|
+
for (const f of files) {
|
|
194
|
+
const text = ctx.text(f);
|
|
195
|
+
for (const m of text.matchAll(/^[ \t]*permissions:[ \t]*write-all\b/gm)) bad.push({ file: f, line: lineOf(text, m.index), note: 'the token is granted write access to everything' });
|
|
196
|
+
if (/^permissions:/m.test(text)) continue;
|
|
197
|
+
const jobsAt = /^jobs:\s*$/m.exec(text);
|
|
198
|
+
if (!jobsAt) continue;
|
|
199
|
+
const body = text.slice(jobsAt.index);
|
|
200
|
+
const jobs = [...body.matchAll(/^ {2}([\w-]+):\s*$/gm)];
|
|
201
|
+
const withPerms = jobs.filter((j, i) => {
|
|
202
|
+
const end = jobs[i + 1]?.index ?? body.length;
|
|
203
|
+
return /^ {4}permissions:/m.test(body.slice(j.index, end));
|
|
204
|
+
});
|
|
205
|
+
if (jobs.length === 0 || withPerms.length < jobs.length) {
|
|
206
|
+
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' });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const absence: AbsenceSearch = { pattern: 'permissions: (top level or per job), write-all', files_searched: files.length, file_globs: strs(p.files), matches: bad.length };
|
|
210
|
+
return { value: bad.length === 0, evidence: cap(bad), absence };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Reads the kit's framework support table: per heading, release line to status.
|
|
214
|
+
export function supportTable(markdown: string, heading: string): Map<string, string> | null {
|
|
215
|
+
const lines = markdown.split(/\r?\n/);
|
|
216
|
+
const at = lines.findIndex((l) => l.trim() === `## ${heading}`);
|
|
217
|
+
if (at < 0) return null;
|
|
218
|
+
const rows = new Map<string, string>();
|
|
219
|
+
for (let i = at + 1; i < lines.length && !lines[i].startsWith('## '); i++) {
|
|
220
|
+
const l = lines[i].trim();
|
|
221
|
+
if (!l.startsWith('|') || /^\|[\s|:-]+\|$/.test(l)) continue;
|
|
222
|
+
const cells = l.slice(1, -1).split('|').map((c) => c.trim());
|
|
223
|
+
if (cells[0] === 'Release line') continue;
|
|
224
|
+
rows.set(cells[0], cells[cells.length - 1]);
|
|
225
|
+
}
|
|
226
|
+
return rows;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The release line of a declared version: "13.4.0" -> "13", "^0.85.2" -> "0.85", "~=5.2.1" -> "5.2".
|
|
230
|
+
export function releaseLine(version: string, digits: number): string | null {
|
|
231
|
+
const m = /(\d+)(?:\.(\d+))?/.exec(version);
|
|
232
|
+
if (!m) return null;
|
|
233
|
+
return digits === 2 ? (m[2] === undefined ? null : `${m[1]}.${m[2]}`) : m[1];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// params.table: kit relative path. params.frameworks: stack id to {heading, package, digits}.
|
|
237
|
+
function versionInTable(ctx: CheckContext, p: P): OpOutcome {
|
|
238
|
+
if (!ctx.kitDir) return cannot('the kit folder is not known, so the framework support table cannot be read');
|
|
239
|
+
let md: string;
|
|
240
|
+
try { md = readFileSync(join(ctx.kitDir, str(p.table)), 'utf8'); } catch { return cannot(`cannot read ${str(p.table)} in the kit`); }
|
|
241
|
+
const table = (p.frameworks ?? {}) as Record<string, { heading: string; package: string; digits?: number }>;
|
|
242
|
+
const items = [...ctx.stack.frameworks, ...ctx.stack.mobile].filter((i) => table[i.id]);
|
|
243
|
+
if (items.length === 0) return na('no framework from the support table is in stack.json');
|
|
244
|
+
const bad: CheckEvidence[] = [];
|
|
245
|
+
const good: CheckEvidence[] = [];
|
|
246
|
+
const unknown: string[] = [];
|
|
247
|
+
for (const item of items) {
|
|
248
|
+
const row = table[item.id];
|
|
249
|
+
const rows = supportTable(md, row.heading);
|
|
250
|
+
const ev = item.evidence.find((e) => e.rule.includes('.dep.')) ?? item.evidence[0];
|
|
251
|
+
const declared = (item.versions ?? []).filter((v) => v.startsWith(`${row.package}@`)).map((v) => v.slice(row.package.length + 1));
|
|
252
|
+
if (!rows || declared.length === 0) { unknown.push(item.id); continue; }
|
|
253
|
+
for (const v of declared) {
|
|
254
|
+
const line = releaseLine(v, row.digits ?? 1);
|
|
255
|
+
const status = line ? rows.get(line) : undefined;
|
|
256
|
+
const where = { file: ev?.file ?? 'package.json', ...(ev?.line ? { line: ev.line } : { key: ev?.key ?? `(present)` }) };
|
|
257
|
+
if (/canary|alpha|beta|rc|next|experimental|nightly/i.test(v)) bad.push({ ...where, note: `${row.package} ${v} is a prerelease or canary version` });
|
|
258
|
+
else if (status === 'Supported') good.push({ ...where, note: `${row.package} ${v}: release line ${line} is supported in the kit table` });
|
|
259
|
+
else if (status) bad.push({ ...where, note: `${row.package} ${v}: release line ${line} is ${status.toLowerCase()} in the kit's framework support table` });
|
|
260
|
+
else unknown.push(`${item.id} ${v}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const absence: AbsenceSearch = { pattern: `release lines in ${str(p.table)}`, files_searched: 1, file_globs: [str(p.table)], matches: good.length };
|
|
264
|
+
if (bad.length) return { value: false, evidence: cap(bad), absence };
|
|
265
|
+
if (unknown.length) return cannot(`no row in the framework support table for ${unknown.sort(cmp).join(', ')}`);
|
|
266
|
+
return { value: true, evidence: cap(good), absence };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const OPS: Partial<Record<CheckOp, (ctx: CheckContext, p: P) => OpOutcome>> = {
|
|
270
|
+
file_exists: (c, p) => fileExists(c, p, true),
|
|
271
|
+
file_absent: (c, p) => fileExists(c, p, false),
|
|
272
|
+
file_exists_for_each: fileExistsForEach,
|
|
273
|
+
regex_present: (c, p) => regexOp(c, p, true),
|
|
274
|
+
regex_absent: (c, p) => regexOp(c, p, false),
|
|
275
|
+
sql_every_table_has: sqlEveryTableHas,
|
|
276
|
+
workflow_uses_pinned_sha: workflowUsesPinnedSha,
|
|
277
|
+
workflow_permissions: workflowPermissions,
|
|
278
|
+
version_in_table: versionInTable,
|
|
279
|
+
// json_value is in the op list of the design and not built: no check needs it yet.
|
|
280
|
+
};
|