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,205 @@
|
|
|
1
|
+
// ASP.NET Core entry points (C#): controllers with [Route] and the [HttpGet] family, and minimal APIs (MapGet, MapPost and the others,
|
|
2
|
+
// with MapGroup prefixes). What protects each one: [Authorize] (with Roles or Policy), [AllowAnonymous], RequireAuthorization() on an
|
|
3
|
+
// endpoint or a group, a fallback policy that requires a sign in, and what the handler body does. [AllowAnonymous] anywhere on an
|
|
4
|
+
// endpoint turns the authorization attributes off, as it does in ASP.NET Core. Pattern based, like the other extractors.
|
|
5
|
+
import type { Protection } from '../types/index.ts';
|
|
6
|
+
import type { FileView } from './analyze.ts';
|
|
7
|
+
import { splitTop, strings } from './cblocks.ts';
|
|
8
|
+
import { empty, joinPath, makeEntry, normPath, type Extracted } from './entry.ts';
|
|
9
|
+
import { cMethod, type FnIndex } from './light-index.ts';
|
|
10
|
+
import { analyzeLight, type LightDeco } from './light.ts';
|
|
11
|
+
import { closeOf, splitArgs } from './lex.ts';
|
|
12
|
+
import type { LocalVocab } from './local-vocab.ts';
|
|
13
|
+
import type { Repo } from './repo.ts';
|
|
14
|
+
|
|
15
|
+
const HTTP: Record<string, string> = { HttpGet: 'GET', HttpPost: 'POST', HttpPut: 'PUT', HttpDelete: 'DELETE', HttpPatch: 'PATCH', HttpHead: 'HEAD', HttpOptions: 'OPTIONS' };
|
|
16
|
+
const SERVICE_TYPE = /^(?:I[A-Z]\w+|\w*(?:Db|DbContext|Context|Service|Repository|Logger|Options|Factory|Manager|Client|Provider|Handler)|CancellationToken|HttpContext|HttpRequest|HttpResponse|ClaimsPrincipal|IConfiguration|LinkGenerator|TokenService)\b/;
|
|
17
|
+
const AUTH_KINDS = new Set(['session_read', 'role_check']);
|
|
18
|
+
|
|
19
|
+
export function dotnetFiles(repo: Repo): string[] {
|
|
20
|
+
return repo.filesOf('cs').filter((f) => { const v = repo.view(f); return !!v && (/Controller\b|\[ApiController\]/.test(v.masked) || /\.\s*Map(?:Get|Post|Put|Delete|Patch|Methods|Group)\s*\(/.test(v.masked)); });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Attr { name: string; start: number; end: number; args: string }
|
|
24
|
+
interface ABlock { attrs: Attr[]; start: number; end: number; declStart: number }
|
|
25
|
+
|
|
26
|
+
// Attribute lists ([A], [A, B(1)], [A][B]) that stand alone at brace depth 0 of [from, to): the first thing on their line, or right after another list.
|
|
27
|
+
function attributeBlocks(v: FileView, from: number, to: number): ABlock[] {
|
|
28
|
+
const m = v.masked;
|
|
29
|
+
const out: ABlock[] = [];
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let cur: ABlock | null = null;
|
|
32
|
+
for (let i = from; i < to; i++) {
|
|
33
|
+
const c = m[i];
|
|
34
|
+
if (c === '{' || c === '(') { depth++; continue; }
|
|
35
|
+
if (c === '}' || c === ')') { depth--; continue; }
|
|
36
|
+
if (c === '[' && depth === 0) {
|
|
37
|
+
const lineStart = m.lastIndexOf('\n', i) + 1;
|
|
38
|
+
const first = /^[ \t]*$/.test(m.slice(Math.max(lineStart, from), i));
|
|
39
|
+
const chained = cur !== null && /^\s*$/.test(m.slice(cur.end, i));
|
|
40
|
+
if (!first && !chained) { const e = closeOf(m, i); if (e > 0) i = e - 1; continue; }
|
|
41
|
+
const close = closeOf(m, i);
|
|
42
|
+
if (close < 0) continue;
|
|
43
|
+
const attrs: Attr[] = splitTop(v.text.slice(i + 1, close - 1)).map((t) => {
|
|
44
|
+
const nm = /^(?:\w+\s*:\s*)?([\w.]+)\s*(?:\(([\s\S]*)\))?$/.exec(t);
|
|
45
|
+
const at = i + 1 + v.text.slice(i + 1, close - 1).indexOf(t);
|
|
46
|
+
return { name: (nm?.[1] ?? t).replace(/^.*\./, '').replace(/Attribute$/, ''), start: at, end: at + t.length, args: nm?.[2] ?? '' };
|
|
47
|
+
});
|
|
48
|
+
if (chained && cur) { cur.attrs.push(...attrs); cur.end = close; }
|
|
49
|
+
else { cur = { attrs, start: i, end: close, declStart: 0 }; out.push(cur); }
|
|
50
|
+
i = close - 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const b of out) { let d = b.end; while (/\s/.test(m[d] ?? '')) d++; b.declStart = d; }
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const tokens = (route: string, controller: string, action: string): string => route.replace(/\[controller\]/gi, controller.toLowerCase()).replace(/\[action\]/gi, action.toLowerCase()).replace(/\[area\]/gi, '');
|
|
58
|
+
const cleanPath = (p: string): string => normPath(p.replace(/\{(\w+)\?\}/g, '{$1}').replace(/\{\*{1,2}(\w+)\}/g, '{$1}'));
|
|
59
|
+
|
|
60
|
+
function seedsOf(paramsText: string): string[] {
|
|
61
|
+
const seeds: string[] = [];
|
|
62
|
+
for (const part of splitTop(paramsText)) {
|
|
63
|
+
if (/\[FromServices\]/.test(part)) continue;
|
|
64
|
+
const bare = part.replace(/\[[^\]]*\]\s*/g, '').replace(/\s*=\s*.*$/, '').trim();
|
|
65
|
+
const mm = /^(?:this\s+)?(?:ref\s+|out\s+|in\s+)?([\w.<>?,\[\]]+)\s+(\w+)$/.exec(bare);
|
|
66
|
+
if (!mm || SERVICE_TYPE.test(mm[1])) continue;
|
|
67
|
+
seeds.push(mm[2]);
|
|
68
|
+
}
|
|
69
|
+
return seeds;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Group { prefix: string; chain: { start: number; end: number; view: FileView } | null; anonymous: boolean }
|
|
73
|
+
|
|
74
|
+
export function extractDotnet(repo: Repo, fn: FnIndex, local: LocalVocab | null): Extracted {
|
|
75
|
+
const out = empty();
|
|
76
|
+
// A fallback policy that needs a sign in, set anywhere in the project, covers every endpoint that is not [AllowAnonymous].
|
|
77
|
+
let fallback: Protection | null = null;
|
|
78
|
+
let controllersAuthorized: Protection | null = null;
|
|
79
|
+
for (const file of repo.filesOf('cs')) {
|
|
80
|
+
const v = repo.view(file);
|
|
81
|
+
if (!v) continue;
|
|
82
|
+
const fb = /\bFallbackPolicy\s*=/.exec(v.masked);
|
|
83
|
+
if (fb && !fallback) fallback = { kind: 'session_read', scope: 'global', file, line: v.lines.at(fb.index), via: 'FallbackPolicy requires an authenticated user', enforced: true, confidence: 'high' };
|
|
84
|
+
const mc = /\.\s*MapControllers\s*\(\s*\)\s*\.\s*RequireAuthorization\b/.exec(v.masked);
|
|
85
|
+
if (mc && !controllersAuthorized) controllersAuthorized = { kind: 'session_read', scope: 'router', file, line: v.lines.at(mc.index), via: 'MapControllers().RequireAuthorization()', enforced: true, confidence: 'high' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const analyse = (v: FileView, start: number, end: number, seeds: string[], decos: LightDeco[], extra: Protection[], anonymous: boolean) => {
|
|
89
|
+
const a = analyzeLight(v, { start, end, seeds, decorators: decos, extra, lookup: fn.find, local });
|
|
90
|
+
if (anonymous) a.protections = a.protections.filter((p) => !(AUTH_KINDS.has(p.kind) && (p.scope === 'router' || p.scope === 'wrapper' || p.scope === 'global' || p.scope === 'middleware')));
|
|
91
|
+
return a;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const file of dotnetFiles(repo)) {
|
|
95
|
+
const v = repo.view(file);
|
|
96
|
+
if (!v) continue;
|
|
97
|
+
const m = v.masked;
|
|
98
|
+
|
|
99
|
+
// ---- controllers (a class is at the top of the file, or inside a block scoped namespace)
|
|
100
|
+
const ranges: [number, number][] = [[0, m.length]];
|
|
101
|
+
for (const ns of m.matchAll(/\bnamespace\s+[\w.]+\s*\{/g)) { const open = ns.index! + ns[0].length - 1; const close = closeOf(m, open); if (close > 0) ranges.push([open + 1, close - 1]); }
|
|
102
|
+
for (const cls of ranges.flatMap(([a, b]) => attributeBlocks(v, a, b))) {
|
|
103
|
+
const decl = /^(?:(?:public|internal|abstract|sealed|partial)\s+)*class\s+(\w+)\s*(?:\((?:[^()]|\([^()]*\))*\))?\s*(?::\s*([^{]+?))?\s*\{/.exec(m.slice(cls.declStart, cls.declStart + 500));
|
|
104
|
+
if (!decl) continue;
|
|
105
|
+
const names = cls.attrs.map((a) => a.name);
|
|
106
|
+
if (!(names.includes('ApiController') || names.includes('Controller') || /\b(?:ControllerBase|Controller)\b/.test(decl[2] ?? '') || /\wController$/.test(decl[1]))) continue;
|
|
107
|
+
const className = decl[1];
|
|
108
|
+
const controller = className.replace(/Controller$/, '');
|
|
109
|
+
const bodyOpen = cls.declStart + decl[0].length - 1;
|
|
110
|
+
const bodyClose = closeOf(m, bodyOpen);
|
|
111
|
+
if (bodyClose < 0) continue;
|
|
112
|
+
const classRoute = cls.attrs.find((a) => a.name === 'Route');
|
|
113
|
+
const classTemplate = classRoute ? strings(classRoute.args)[0] ?? '' : '';
|
|
114
|
+
const classAnon = names.includes('AllowAnonymous');
|
|
115
|
+
const classDeco: LightDeco = { view: v, start: cls.start, end: cls.end, scope: 'router' };
|
|
116
|
+
for (const blk of attributeBlocks(v, bodyOpen + 1, bodyClose - 1)) {
|
|
117
|
+
const verbs = blk.attrs.filter((a) => HTTP[a.name] || a.name === 'AcceptVerbs');
|
|
118
|
+
const routeAttr = blk.attrs.find((a) => a.name === 'Route');
|
|
119
|
+
if (!verbs.length && !routeAttr) continue;
|
|
120
|
+
const nameAt = /(\w+)\s*(?:<[^>]*>)?\s*\(/.exec(m.slice(blk.declStart, blk.declStart + 400));
|
|
121
|
+
if (!nameAt) continue;
|
|
122
|
+
const f = cMethod(m, blk.declStart + nameAt.index);
|
|
123
|
+
if (!f) continue;
|
|
124
|
+
const action = nameAt[1];
|
|
125
|
+
const anonymous = classAnon || blk.attrs.some((a) => a.name === 'AllowAnonymous');
|
|
126
|
+
const decos: LightDeco[] = [{ view: v, start: blk.start, end: blk.end, scope: 'wrapper' }];
|
|
127
|
+
if (cls.attrs.length) decos.push(classDeco);
|
|
128
|
+
const extra: Protection[] = anonymous ? [] : [fallback, controllersAuthorized].filter((x): x is Protection => !!x);
|
|
129
|
+
const seeds = seedsOf(f.params);
|
|
130
|
+
const methodList = verbs.length ? verbs.flatMap((a) => (HTTP[a.name] ? [{ verb: HTTP[a.name], attr: a }] : [...a.args.matchAll(/"([A-Za-z]+)"/g)].map((x) => ({ verb: x[1].toUpperCase(), attr: a })))) : [{ verb: 'ANY', attr: routeAttr! }];
|
|
131
|
+
for (const { verb, attr } of methodList) {
|
|
132
|
+
const own = strings(attr.args)[0];
|
|
133
|
+
const template = own === undefined && routeAttr && attr !== routeAttr ? strings(routeAttr.args)[0] : own;
|
|
134
|
+
let full: string;
|
|
135
|
+
const tpl = template === undefined ? undefined : tokens(template, controller, action);
|
|
136
|
+
const ctpl = tokens(classTemplate, controller, action);
|
|
137
|
+
if (tpl !== undefined && /^~?\//.test(tpl)) full = tpl.replace(/^~/, '');
|
|
138
|
+
else full = joinPath(ctpl ? '/' + ctpl.replace(/^\//, '') : '', tpl ?? '') || '/';
|
|
139
|
+
const path = cleanPath(full);
|
|
140
|
+
const an = analyse(v, blk.declStart, f.end, seeds, decos, extra, anonymous);
|
|
141
|
+
out.entries.push(makeEntry(repo, {
|
|
142
|
+
kind: 'http_route', framework: 'aspnet', file, line: v.lines.at(attr.start), method: verb, path: path.startsWith('/') ? path : '/' + path, name: `${className}.${action}`,
|
|
143
|
+
confidence: classTemplate || template !== undefined ? 'high' : 'medium', handlerView: v, spec: { start: blk.declStart, end: f.end, seeds }, analysis: an,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- minimal APIs
|
|
150
|
+
const groups = new Map<string, Group>();
|
|
151
|
+
for (const g of m.matchAll(/\b(?:var|RouteGroupBuilder)\s+(\w+)\s*=\s*(\w+)\s*\.\s*MapGroup\s*\(/g)) {
|
|
152
|
+
const open = g.index! + g[0].length - 1;
|
|
153
|
+
const close = closeOf(m, open);
|
|
154
|
+
if (close < 0) continue;
|
|
155
|
+
const semi = m.indexOf(';', close);
|
|
156
|
+
const parent = groups.get(g[2]);
|
|
157
|
+
const lit = strings(v.text.slice(open, close))[0] ?? '';
|
|
158
|
+
const chainText = m.slice(close, semi < 0 ? close : semi);
|
|
159
|
+
groups.set(g[1], { prefix: joinPath(parent?.prefix ?? '', lit), chain: { start: close, end: semi < 0 ? close : semi, view: v }, anonymous: (parent?.anonymous ?? false) || /\.\s*AllowAnonymous\s*\(/.test(chainText) });
|
|
160
|
+
}
|
|
161
|
+
for (const mp of m.matchAll(/\b(\w+)\s*\.\s*Map(Get|Post|Put|Delete|Patch|Methods)\s*\(/g)) {
|
|
162
|
+
const recv = mp[1];
|
|
163
|
+
const grp = groups.get(recv);
|
|
164
|
+
if (!grp && recv !== 'app' && recv !== 'endpoints' && recv !== 'builder') continue;
|
|
165
|
+
const open = mp.index! + mp[0].length - 1;
|
|
166
|
+
const close = closeOf(m, open);
|
|
167
|
+
if (close < 0) continue;
|
|
168
|
+
const args = splitArgs(m, open);
|
|
169
|
+
if (!args.length) continue;
|
|
170
|
+
const pathLit = strings(v.text.slice(args[0].start, args[0].end))[0];
|
|
171
|
+
if (pathLit === undefined) continue;
|
|
172
|
+
const semi = m.indexOf(';', close);
|
|
173
|
+
const chain = { start: close, end: semi < 0 ? close : semi };
|
|
174
|
+
const chainText = m.slice(chain.start, chain.end);
|
|
175
|
+
const anonymous = /\.\s*AllowAnonymous\s*\(/.test(chainText) || !!grp?.anonymous;
|
|
176
|
+
const verbs = mp[2] === 'Methods' ? [...v.text.slice(args[1]?.start ?? 0, args[1]?.end ?? 0).matchAll(/"([A-Za-z]+)"/g)].map((x) => x[1].toUpperCase()) : [mp[2].toUpperCase()];
|
|
177
|
+
const handler = args[args.length - 1];
|
|
178
|
+
if (!handler || args.length < 2) continue;
|
|
179
|
+
const htext = m.slice(handler.start, handler.end);
|
|
180
|
+
let seeds: string[] = [];
|
|
181
|
+
let start = handler.start;
|
|
182
|
+
let end = handler.end;
|
|
183
|
+
let hv: FileView = v;
|
|
184
|
+
const lam = /^(?:static\s+)?(?:async\s+)?\(([^)]*)\)\s*=>/.exec(htext) ?? /^(?:static\s+)?(?:async\s+)?(\w+)\s*=>/.exec(htext);
|
|
185
|
+
if (lam) seeds = seedsOf(v.text.slice(handler.start + htext.indexOf(lam[1]), handler.start + htext.indexOf(lam[1]) + lam[1].length));
|
|
186
|
+
else {
|
|
187
|
+
const nm = /(\w+)\s*$/.exec(htext)?.[1];
|
|
188
|
+
const ref = nm ? fn.find(nm, file) : null;
|
|
189
|
+
if (ref) { hv = ref.view; start = ref.start; end = ref.end; const op = ref.view.masked.indexOf('(', ref.start); const pc = op >= 0 ? closeOf(ref.view.masked, op) : -1; if (pc > 0) seeds = seedsOf(ref.view.text.slice(op + 1, pc - 1)); }
|
|
190
|
+
}
|
|
191
|
+
const decos: LightDeco[] = [{ view: v, start: chain.start, end: chain.end, scope: 'wrapper' }];
|
|
192
|
+
if (grp?.chain) decos.push({ view: grp.chain.view, start: grp.chain.start, end: grp.chain.end, scope: 'router' });
|
|
193
|
+
const extra: Protection[] = anonymous ? [] : [fallback].filter((x): x is Protection => !!x);
|
|
194
|
+
for (const verb of verbs) {
|
|
195
|
+
const path = cleanPath(joinPath(grp?.prefix ?? '', pathLit));
|
|
196
|
+
const an = analyse(hv, start, end, seeds, decos, extra, anonymous);
|
|
197
|
+
out.entries.push(makeEntry(repo, {
|
|
198
|
+
kind: 'http_route', framework: 'aspnet', file, line: v.lines.at(mp.index!), method: verb, path, name: null,
|
|
199
|
+
confidence: 'high', handlerView: hv, spec: { start, end, seeds }, analysis: an,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
package/src/map/entry.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Builds one EntryPoint from a registration: runs the body analysis and adds the tags and the stable id.
|
|
2
|
+
import type { Confidence, EntryKind, EntryPoint } from '../types/index.ts';
|
|
3
|
+
import { testCodeReason } from '../report/test-code.ts';
|
|
4
|
+
import { analyze, type Analysis, type FileView, type Spec } from './analyze.ts';
|
|
5
|
+
import type { Repo } from './repo.ts';
|
|
6
|
+
import { ADMIN_PATH, AUTH_ENDPOINT, PUBLIC_PATH } from './vocab.ts';
|
|
7
|
+
|
|
8
|
+
export interface Seed {
|
|
9
|
+
kind: EntryKind; framework: string;
|
|
10
|
+
file: string; line: number; // where the entry point is declared (the registration)
|
|
11
|
+
method: string | null; path: string | null; name: string | null;
|
|
12
|
+
confidence: Confidence;
|
|
13
|
+
handlerView: FileView; spec: Spec; // the handler body to read, and what to read it with
|
|
14
|
+
tags?: string[];
|
|
15
|
+
analysis?: Analysis; // the body already read (Go, Ruby and PHP use light.ts)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STATE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
19
|
+
const WEBHOOK_HEADER = /[xX]-[\w-]*[sS]ignature|stripe-signature|paystack-signature|svix-signature|webhook-signature|HTTP_X_\w*SIGNATURE|x-hub-signature/i;
|
|
20
|
+
|
|
21
|
+
// Path text with every parameter style written :name, and catch-alls written *name.
|
|
22
|
+
export function normPath(p: string): string {
|
|
23
|
+
return p
|
|
24
|
+
.replace(/\[\[?\.\.\.(\w+)\]?\]/g, '*$1').replace(/\[(\w+)\]/g, ':$1')
|
|
25
|
+
.replace(/<(?:[\w.]+:)?(\w+)>/g, ':$1').replace(/\{(\w+)(?::[^}]*)?\}/g, ':$1')
|
|
26
|
+
.replace(/\(\?P<(\w+)>[^)]*\)/g, ':$1')
|
|
27
|
+
.replace(/\/{2,}/g, '/');
|
|
28
|
+
}
|
|
29
|
+
export function joinPath(prefix: string, path: string): string {
|
|
30
|
+
const j = `${prefix.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
31
|
+
return normPath(j === '/' ? '/' : j.replace(/\/+$/, '') || '/');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function makeEntry(repo: Repo, s: Seed): EntryPoint {
|
|
35
|
+
const hv = s.handlerView;
|
|
36
|
+
const a = s.analysis ?? analyze(hv, s.spec, repo.helpers(hv.lang));
|
|
37
|
+
const pathText = `${s.path ?? ''} ${s.name ?? ''}`;
|
|
38
|
+
const handlerText = hv.text.slice(s.spec.start, s.spec.end);
|
|
39
|
+
const hasSig = a.protections.some((p) => p.kind === 'signature_verify' && p.confidence === 'high' && /signature verify|hmac|svix/.test(p.via));
|
|
40
|
+
const webhook = s.kind === 'webhook' || (s.kind === 'http_route' && (/webhook/i.test(pathText) || /(^|\/)webhooks?(\/|\.|$)/i.test(s.file) || WEBHOOK_HEADER.test(handlerText) || hasSig && /hook/i.test(`${s.file} ${pathText}`)));
|
|
41
|
+
const kind: EntryKind = webhook ? 'webhook' : s.kind;
|
|
42
|
+
const method = s.method;
|
|
43
|
+
const tags = new Set<string>(s.tags ?? []);
|
|
44
|
+
if (webhook) tags.add('webhook');
|
|
45
|
+
const nameIsHandler = s.path === null || ['flask', 'fastapi', 'django', 'nest', 'quart', 'sanic', 'next-app', 'next-pages'].includes(s.framework);
|
|
46
|
+
if (AUTH_ENDPOINT.test(s.path ?? '') || (nameIsHandler && s.framework !== 'next-app' && AUTH_ENDPOINT.test((s.name ?? '').replace(/([a-z])([A-Z])/g, '$1_$2')))) tags.add('auth_endpoint');
|
|
47
|
+
if (ADMIN_PATH.test(s.path ?? '') || ADMIN_PATH.test(s.file)) tags.add('admin');
|
|
48
|
+
if (PUBLIC_PATH.test(s.path ?? '')) tags.add('public');
|
|
49
|
+
if (a.reads.some((r) => r.source === 'file')) tags.add('upload');
|
|
50
|
+
const writes = a.data.some((d) => d.op === 'write' || d.op === 'delete');
|
|
51
|
+
if (kind === 'server_action' || (method && STATE_METHODS.has(method)) || ((method === 'ANY' || method === null) && (writes || a.reads.some((r) => r.source === 'body' || r.source === 'form')) && (kind === 'http_route' || kind === 'webhook'))) tags.add('state_changing');
|
|
52
|
+
const clientPage = s.kind === 'page' && /^\s*(?:\/\/[^\n]*\n\s*)*['"]use client['"]/.test(hv.text.slice(0, 400));
|
|
53
|
+
if (clientPage) tags.add('client_component');
|
|
54
|
+
const handler = hv.file === s.file && s.spec.start >= 0 && hv.lines.at(s.spec.start) === s.line ? null : { file: hv.file, line: hv.lines.at(s.spec.start) };
|
|
55
|
+
return {
|
|
56
|
+
id: `${s.file}:${s.line}:${kind}:${method ?? '-'}:${s.path ?? '-'}:${s.name ?? '-'}`,
|
|
57
|
+
kind, framework: s.framework, file: s.file, line: s.line,
|
|
58
|
+
end_line: hv.lines.at(Math.max(s.spec.start, s.spec.end - 1)),
|
|
59
|
+
method, path: s.path, name: s.name, handler, confidence: s.confidence,
|
|
60
|
+
test_code: testCodeReason(s.file) !== null, tags: [...tags].sort(),
|
|
61
|
+
protections: clientPage ? [] : a.protections, reads: clientPage ? [] : a.reads, data: clientPage ? [] : a.data, outbound: clientPage ? [] : a.outbound,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Parameter names of the function whose "(" is at `open` in masked text: plain names and the names inside destructuring.
|
|
66
|
+
export function paramNames(masked: string, open: number): string[] {
|
|
67
|
+
let depth = 0;
|
|
68
|
+
let end = open;
|
|
69
|
+
for (let i = open; i < masked.length; i++) { const c = masked[i]; if (c === '(') depth++; else if (c === ')') { depth--; if (depth === 0) { end = i; break; } } }
|
|
70
|
+
const inner = masked.slice(open + 1, end).replace(/:[^,={}()]*(?:<[^>]*>)?/g, ' ').replace(/=[^,}]*/g, ' ');
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const m of inner.matchAll(/(?<![.\w$@])([A-Za-z_$][\w$]*)/g)) if (!['async', 'this', 'self', 'cls'].includes(m[1])) out.push(m[1]);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// What one extractor returns for one file. A mount says "routes registered in `target` live under `prefix`".
|
|
77
|
+
import type { Gate, Protection } from '../types/index.ts';
|
|
78
|
+
export interface Mount { from: string; line: number; prefix: string; target: string; protections: Protection[] }
|
|
79
|
+
export interface Extracted { entries: EntryPoint[]; gates: Gate[]; mounts: Mount[] }
|
|
80
|
+
export const empty = (): Extracted => ({ entries: [], gates: [], mounts: [] });
|
package/src/map/expo.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Expo and React Native entry points: Expo Router screens (the app/ folder), API routes (+api files), deep links declared
|
|
2
|
+
// in app.json or app.config (scheme, universal links, Android intent filters) and deep links handled in code (Linking
|
|
3
|
+
// listeners, useURL, a navigation linking config). The protections of a screen are the sign in guards in the layouts above
|
|
4
|
+
// it. A layout guard is a client side redirect: it keeps a signed out user from seeing a screen, it does not stop a request
|
|
5
|
+
// to your server, so a screen's own data calls still need a server side check (the report says "client side" in `via`).
|
|
6
|
+
import type { EntryPoint, Protection } from '../types/index.ts';
|
|
7
|
+
import { testCodeReason } from '../report/test-code.ts';
|
|
8
|
+
import type { FileView } from './analyze.ts';
|
|
9
|
+
import { empty, makeEntry, normPath, type Extracted } from './entry.ts';
|
|
10
|
+
import { splitArgs } from './lex.ts';
|
|
11
|
+
import { exportedFunctions, resolveFunction } from './js-next.ts';
|
|
12
|
+
import type { Repo } from './repo.ts';
|
|
13
|
+
|
|
14
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
15
|
+
const SOURCE_EXT = /\.[cm]?[jt]sx?$/;
|
|
16
|
+
// Session reads in mobile code: auth hooks and contexts, provider SDK calls, secure storage of a token.
|
|
17
|
+
const EXPO_SESSION: { re: RegExp; via: string; conf: 'high' | 'medium' }[] = [
|
|
18
|
+
{ re: /\b(?:useAuth|isSignedIn|useSession|useUser|useAuthContext|useAuthState|useSupabaseSession|useConvexAuth|useKindeAuth|useAuth0)\b/, via: 'auth hook', conf: 'medium' },
|
|
19
|
+
{ re: /\bsupabase\.auth\.(?:getSession|getUser|onAuthStateChange)\b|\bonAuthStateChanged\s*\(|\bgetAuth\s*\(|\bfirebase\.auth\(\)|\bauth\(\)\.currentUser|\bClerkProvider\b|\bSignedIn\b|\bSignedOut\b/, via: 'auth provider', conf: 'high' },
|
|
20
|
+
{ re: /\b(?:SecureStore|AsyncStorage|storage)\.(?:getItem|getItemAsync|get|getString)\s*\(\s*['"][\w.-]*(?:token|session|jwt|auth)[\w.-]*['"]/i, via: 'stored token', conf: 'medium' },
|
|
21
|
+
{ re: /\b(?:isAuthenticated|isLoggedIn|isAuthed|signedIn|loggedIn)\b/, via: 'signed in flag', conf: 'medium' },
|
|
22
|
+
];
|
|
23
|
+
const GUARD_REDIRECT = /<Redirect\b[^>]*\bhref\b|\brouter\.(?:replace|push|navigate)\s*\(|\bnavigation\.(?:replace|navigate|reset)\s*\(|\bProtected\b[^>]*\bguard\s*=|\bredirect\s*\(/;
|
|
24
|
+
const GUARD_CONDITION = /\bif\s*\(\s*!\s*\(?\s*\w*(?:session|user|token|signedIn|isSignedIn|isAuthenticated|isLoggedIn|authenticated|auth)\w*|\bguard\s*=\s*\{|[?&]{2}\s*<Redirect|:\s*<Redirect/i;
|
|
25
|
+
// The status compared with a string, as in if (status === 'signOut'): the string is blank in the masked copy, so this reads the text.
|
|
26
|
+
const GUARD_STATUS = /\bif\s*\([^)]*['"](?:signOut|signedOut|unauthenticated|loggedOut|logged_out|anonymous|guest)['"]/i;
|
|
27
|
+
const PUBLIC_GROUP = /\((?:auth|public|login|sign-?in|sign-?up|onboarding|welcome|landing|guest|unauth\w*)\)/i;
|
|
28
|
+
|
|
29
|
+
function roots(repo: Repo): string[] {
|
|
30
|
+
const out: string[] = [];
|
|
31
|
+
for (const f of repo.ctx.files) {
|
|
32
|
+
if (!/(?:^|\/)package\.json$/.test(f) || /node_modules\//.test(f)) continue;
|
|
33
|
+
if (/"expo-router"|"expo"\s*:/.test(repo.ctx.text(f))) out.push(f.replace(/package\.json$/, ''));
|
|
34
|
+
}
|
|
35
|
+
return out.sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const routePath = (rel: string): string => {
|
|
39
|
+
const segs = rel.replace(SOURCE_EXT, '').split('/').filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith('@'));
|
|
40
|
+
if (segs[segs.length - 1] === 'index') segs.pop();
|
|
41
|
+
return normPath('/' + segs.join('/').replace(/\[\.\.\.(\w+)\]/g, '*$1').replace(/\[(\w+)\]/g, ':$1'));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function layoutGuard(v: FileView): { line: number; enforced: boolean | null } | null {
|
|
45
|
+
const session = EXPO_SESSION.some((s) => s.re.test(v.masked));
|
|
46
|
+
const redirect = GUARD_REDIRECT.exec(v.masked);
|
|
47
|
+
if (!session || !redirect) return null;
|
|
48
|
+
const cond = GUARD_CONDITION.test(v.masked) || GUARD_STATUS.test(v.text);
|
|
49
|
+
if (!cond && !/\bProtected\b/.test(v.masked)) return null;
|
|
50
|
+
return { line: v.lines.at(redirect.index), enforced: cond ? true : null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractExpo(repo: Repo): Extracted {
|
|
54
|
+
const out = empty();
|
|
55
|
+
for (const root of roots(repo)) {
|
|
56
|
+
const appDir = [`${root}app/`, `${root}src/app/`].find((d) => repo.ctx.files.some((f) => f.startsWith(d)));
|
|
57
|
+
const files = repo.ctx.files.filter((f) => f.startsWith(root) && !/node_modules\//.test(f) && SOURCE_EXT.test(f) && !/\.d\.ts$/.test(f));
|
|
58
|
+
if (appDir) {
|
|
59
|
+
// Layout guards, by directory.
|
|
60
|
+
const guards: { dir: string; file: string; fact: { line: number; enforced: boolean | null }; text: string }[] = [];
|
|
61
|
+
for (const f of files) {
|
|
62
|
+
if (!f.startsWith(appDir) || !/(?:^|\/)_layout\.[cm]?[jt]sx?$/.test(f)) continue;
|
|
63
|
+
const v = repo.view(f);
|
|
64
|
+
const g = v ? layoutGuard(v) : null;
|
|
65
|
+
if (v && g) guards.push({ dir: f.slice(0, f.lastIndexOf('/') + 1), file: f, fact: g, text: v.masked });
|
|
66
|
+
}
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
if (!f.startsWith(appDir)) continue;
|
|
69
|
+
const rel = f.slice(appDir.length);
|
|
70
|
+
const base = rel.split('/').pop()!;
|
|
71
|
+
if (/^_layout\./.test(base) || /^\+(?:html|not-found|native-intent)\./.test(base)) continue;
|
|
72
|
+
const v = repo.view(f);
|
|
73
|
+
if (!v) continue;
|
|
74
|
+
if (/\+api\.[cm]?[jt]sx?$/.test(f)) {
|
|
75
|
+
const path = routePath(rel.replace(/\+api(\.[cm]?[jt]sx?)$/, '$1'));
|
|
76
|
+
for (const e of exportedFunctions(repo, v)) {
|
|
77
|
+
if (!METHODS.includes(e.exportName)) continue;
|
|
78
|
+
const r = e.r;
|
|
79
|
+
const seeds = [...(r.params.length ? r.params : ['request', 'req']), 'params'];
|
|
80
|
+
out.entries.push(makeEntry(repo, { kind: 'http_route', framework: 'expo-api', file: f, line: e.line, method: e.exportName, path, name: e.exportName, confidence: 'high', handlerView: r.view, spec: { start: r.start, end: r.end, seeds, decorators: r.wrapper ? [{ view: v, start: r.wrapper.from, end: r.wrapper.to, scope: 'wrapper' }] : [], extraCalls: r.wrapper ? [r.wrapper.name] : [], returnsJson: true } }));
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// A screen: the file under app/ with a default export that is not a layout.
|
|
85
|
+
const DEFAULT_EXPORT = /\bexport\s+default\b|\bexport\s*\{[^}]*(?:\bas\s+default|\bdefault\b)[^}]*\}/;
|
|
86
|
+
if (!DEFAULT_EXPORT.test(v.masked)) continue;
|
|
87
|
+
const path = routePath(rel);
|
|
88
|
+
const seeds = ['params', 'searchParams'];
|
|
89
|
+
for (const m of v.masked.matchAll(/\b(?:const|let)\s+(\{[^}]*\}|\w+)\s*=\s*use(?:Local|Global)?SearchParams\b/g)) seeds.push(...[...m[1].matchAll(/\w+/g)].map((x) => x[0]));
|
|
90
|
+
const dm = /\bexport\s+default\s+(?:async\s+)?function\s+(\w+)/.exec(v.masked);
|
|
91
|
+
const ep = makeEntry(repo, { kind: 'page', framework: 'expo-router', file: f, line: v.lines.at(dm?.index ?? Math.max(0, v.masked.search(DEFAULT_EXPORT))), method: null, path, name: dm?.[1] ?? null, confidence: 'high', handlerView: v, spec: { start: 0, end: v.text.length, seeds, returnsJson: false }, tags: ['mobile_screen'] });
|
|
92
|
+
const extra: Protection[] = [];
|
|
93
|
+
for (const s of EXPO_SESSION) {
|
|
94
|
+
const m = s.re.exec(v.masked);
|
|
95
|
+
if (m) { extra.push({ kind: 'session_read', scope: 'handler', file: f, line: v.lines.at(m.index), via: s.via, enforced: null, confidence: s.conf === 'high' ? 'medium' : 'medium' }); break; }
|
|
96
|
+
}
|
|
97
|
+
const publicGroup = PUBLIC_GROUP.test(f.slice(appDir.length));
|
|
98
|
+
for (const g of guards) {
|
|
99
|
+
if (!f.startsWith(g.dir) || publicGroup) continue;
|
|
100
|
+
extra.push({ kind: 'session_read', scope: 'router', file: g.file, line: g.fact.line, via: 'expo layout guard (client side)', enforced: g.fact.enforced, confidence: 'medium' });
|
|
101
|
+
}
|
|
102
|
+
if (extra.length) {
|
|
103
|
+
ep.protections = [...ep.protections, ...extra].sort((a, b) => a.line - b.line || (a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0) || (a.via < b.via ? -1 : 1));
|
|
104
|
+
if (guards.some((g) => f.startsWith(g.dir)) && !publicGroup) ep.tags = [...new Set([...ep.tags, 'guarded'])].sort();
|
|
105
|
+
}
|
|
106
|
+
if (publicGroup) ep.tags = [...new Set([...ep.tags, 'public'])].sort();
|
|
107
|
+
out.entries.push(ep);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Deep links declared in the app config.
|
|
112
|
+
for (const cfg of [`${root}app.json`, `${root}app.config.json`]) {
|
|
113
|
+
const text = repo.ctx.text(cfg);
|
|
114
|
+
if (!text) continue;
|
|
115
|
+
let json: { expo?: Record<string, unknown> } | null = null;
|
|
116
|
+
try { json = JSON.parse(text) as { expo?: Record<string, unknown> }; } catch { json = null; }
|
|
117
|
+
const expo = (json?.expo ?? json ?? {}) as Record<string, unknown>;
|
|
118
|
+
const lineOf = (needle: string): number => { const i = text.indexOf(needle); return i < 0 ? 1 : text.slice(0, i).split('\n').length; };
|
|
119
|
+
const add = (path: string, name: string, needle: string, tags: string[]): void => {
|
|
120
|
+
const line = lineOf(needle);
|
|
121
|
+
out.entries.push(deepLink(cfg, line, path, name, 'high', tags));
|
|
122
|
+
};
|
|
123
|
+
const scheme = expo.scheme;
|
|
124
|
+
for (const s of Array.isArray(scheme) ? scheme : scheme ? [scheme] : []) if (typeof s === 'string') add(`${s}://`, 'custom scheme', `"${s}"`, ['deep_link', 'custom_scheme']);
|
|
125
|
+
const ios = (expo.ios ?? {}) as { associatedDomains?: unknown };
|
|
126
|
+
for (const d of Array.isArray(ios.associatedDomains) ? ios.associatedDomains : []) if (typeof d === 'string' && d.startsWith('applinks:')) add(`https://${d.slice(9)}/`, 'universal link', `"${d}"`, ['deep_link', 'universal_link']);
|
|
127
|
+
const android = (expo.android ?? {}) as { intentFilters?: { data?: { scheme?: string; host?: string; pathPrefix?: string }[]; autoVerify?: boolean }[] };
|
|
128
|
+
for (const f of android.intentFilters ?? []) for (const d of f.data ?? []) if (d.scheme) add(`${d.scheme}://${d.host ?? ''}${d.pathPrefix ?? ''}`, f.autoVerify ? 'android app link' : 'android intent filter', `"${d.host ?? d.scheme}"`, ['deep_link', ...(f.autoVerify ? ['universal_link'] : ['custom_scheme'])]);
|
|
129
|
+
}
|
|
130
|
+
for (const cfg of [`${root}app.config.js`, `${root}app.config.ts`]) {
|
|
131
|
+
const text = repo.ctx.text(cfg);
|
|
132
|
+
if (!text) continue;
|
|
133
|
+
for (const m of text.matchAll(/\bscheme\s*:\s*['"]([\w+.-]+)['"]/g)) out.entries.push(deepLink(cfg, text.slice(0, m.index).split('\n').length, `${m[1]}://`, 'custom scheme', 'high', ['deep_link', 'custom_scheme']));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Deep links handled in code.
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
const v = repo.view(f);
|
|
139
|
+
if (!v || !/\bLinking\b|\buseURL\b|\bprefixes\s*:/.test(v.masked)) continue;
|
|
140
|
+
for (const x of v.masked.matchAll(/\bLinking\.addEventListener\s*\(/g)) {
|
|
141
|
+
const open = x.index! + x[0].length - 1;
|
|
142
|
+
const args = splitArgs(v.masked, open);
|
|
143
|
+
if (args.length < 2 || !/^['"]url['"]$/.test(v.text.slice(args[0].start, args[0].end).trim())) continue;
|
|
144
|
+
const r = resolveFunction(repo, v, args[1].start, args[1].end);
|
|
145
|
+
const spec = r ? { start: r.start, end: r.end, seeds: r.params.length ? r.params : ['event', 'url'], returnsJson: false } : { start: x.index!, end: args[args.length - 1].end, seeds: ['event', 'url'], returnsJson: false };
|
|
146
|
+
out.entries.push(makeEntry(repo, { kind: 'deep_link', framework: 'react-native', file: f, line: v.lines.at(x.index!), method: null, path: null, name: 'Linking url listener', confidence: r ? 'high' : 'medium', handlerView: r?.view ?? v, spec, tags: ['deep_link'] }));
|
|
147
|
+
}
|
|
148
|
+
for (const x of v.masked.matchAll(/\b(?:Linking\.getInitialURL|useURL)\s*\(/g)) {
|
|
149
|
+
const at = x.index!;
|
|
150
|
+
const line = v.lines.at(at);
|
|
151
|
+
const end = Math.min(v.text.length, v.lines.start(line + 12));
|
|
152
|
+
out.entries.push(makeEntry(repo, { kind: 'deep_link', framework: 'react-native', file: f, line, method: null, path: null, name: x[0].replace(/\s*\($/, ''), confidence: 'medium', handlerView: v, spec: { start: at, end, seeds: ['url', 'initialUrl', 'event'], returnsJson: false }, tags: ['deep_link'] }));
|
|
153
|
+
}
|
|
154
|
+
for (const x of v.masked.matchAll(/\bprefixes\s*:\s*\[/g)) {
|
|
155
|
+
const line = v.lines.at(x.index!);
|
|
156
|
+
const prefixes = [...v.text.slice(x.index!, x.index! + 300).matchAll(/['"]([^'"]+:\/\/[^'"]*)['"]/g)].map((y) => y[1]);
|
|
157
|
+
const end = Math.min(v.text.length, v.lines.start(line + 25));
|
|
158
|
+
out.entries.push(makeEntry(repo, { kind: 'deep_link', framework: 'react-navigation', file: f, line, method: null, path: prefixes[0] ?? null, name: 'navigation linking config', confidence: 'medium', handlerView: v, spec: { start: x.index!, end, seeds: [], returnsJson: false }, tags: ['deep_link'] }));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function deepLink(file: string, line: number, path: string, name: string, confidence: EntryPoint['confidence'], tags: string[]): EntryPoint {
|
|
166
|
+
return {
|
|
167
|
+
id: `${file}:${line}:deep_link:-:${path}:${name}`, kind: 'deep_link', framework: 'expo', file, line, end_line: line, method: null, path, name, handler: null, confidence,
|
|
168
|
+
test_code: testCodeReason(file) !== null, tags: [...tags].sort(), protections: [], reads: [{ source: 'params', line }], data: [], outbound: [],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|