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
package/src/map/repo.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// A read only view of the repository for the extractors: file views (text, masked text, line index) and an index of the
|
|
2
|
+
// repository's own functions by name, used to follow a handler one hop into the helpers it calls.
|
|
3
|
+
import type { CheckContext } from '../checks/context.ts';
|
|
4
|
+
import { cmp } from '../util.ts';
|
|
5
|
+
import type { FileView, HelperRef, Helpers } from './analyze.ts';
|
|
6
|
+
import { jsFunction, Lines, mask, pyBlock, type Lang } from './lex.ts';
|
|
7
|
+
import { loadLocalVocab, type LocalVocab } from './local-vocab.ts';
|
|
8
|
+
|
|
9
|
+
export const JS_EXT = /\.(?:[cm]?[jt]sx?)$/;
|
|
10
|
+
export const PY_EXT = /\.py$/;
|
|
11
|
+
export const langOf = (file: string): Lang | null => (JS_EXT.test(file) && !/\.d\.ts$/.test(file) ? 'js' : PY_EXT.test(file) ? 'py' : /\.go$/.test(file) ? 'go' : /\.rb$/.test(file) ? 'rb' : /\.php$/.test(file) ? 'php' : /\.java$/.test(file) ? 'java' : /\.cs$/.test(file) ? 'cs' : /\.kts?$/.test(file) ? 'kt' : /\.swift$/.test(file) ? 'swift' : /\.rs$/.test(file) ? 'rs' : null);
|
|
12
|
+
|
|
13
|
+
// The lines of a Go, Ruby or PHP file that name what it uses: import blocks, require lines, use statements.
|
|
14
|
+
const IMPORTS_MORE: Record<Exclude<Lang, 'js' | 'py'>, RegExp> = {
|
|
15
|
+
go: /^[ \t]*import\s*(?:\([\s\S]*?\)|(?:\w+\s+)?"[^"]*")/gm,
|
|
16
|
+
rb: /^[ \t]*(?:require(?:_relative)?|gem|include|extend)\s[^\n]*/gm,
|
|
17
|
+
php: /^[ \t]*(?:use|namespace)\s+[^;\n]*;/gm,
|
|
18
|
+
java: /^[ \t]*(?:import|package)\s+[^;\n]*;/gm,
|
|
19
|
+
cs: /^[ \t]*(?:global\s+)?(?:using|namespace)\s+[^;\n{]*[;{]?/gm,
|
|
20
|
+
kt: /^[ \t]*(?:import|package)\s+[^\n]*/gm,
|
|
21
|
+
swift: /^[ \t]*(?:@testable\s+)?import\s+[^\n]*/gm,
|
|
22
|
+
rs: /^[ \t]*(?:use|extern\s+crate|mod)\s+[^;\n]*;/gm,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// A file that is browser code or generated: no server entry points are looked for in it.
|
|
26
|
+
const CLIENT_FILE = /^\s*["']use client["']|@Component\s*\(|@NgModule\s*\(|from\s+["']@angular\/|\.min\.[cm]?js$/;
|
|
27
|
+
|
|
28
|
+
export class Repo {
|
|
29
|
+
private views = new Map<string, FileView | null>();
|
|
30
|
+
private indexes = new Map<Lang, Map<string, HelperRef[]>>();
|
|
31
|
+
readonly fileSet: Set<string>;
|
|
32
|
+
readonly ctx: CheckContext;
|
|
33
|
+
readonly local: LocalVocab;
|
|
34
|
+
constructor(ctx: CheckContext) { this.ctx = ctx; this.fileSet = new Set(ctx.files); this.local = loadLocalVocab(ctx.repo); }
|
|
35
|
+
|
|
36
|
+
view(file: string): FileView | null {
|
|
37
|
+
if (this.views.has(file)) return this.views.get(file)!;
|
|
38
|
+
const lang = langOf(file);
|
|
39
|
+
let v: FileView | null = null;
|
|
40
|
+
if (lang) {
|
|
41
|
+
const text = this.ctx.text(file);
|
|
42
|
+
if (text) {
|
|
43
|
+
const masked = mask(text, lang);
|
|
44
|
+
const imports = lang !== 'js' && lang !== 'py' ? (text.match(IMPORTS_MORE[lang]) ?? []).join('\n') : (text.match(/\bimport\s[^;]*?from\s+['"][^'"]+['"]|\bimport\s+['"][^'"]+['"]|\b(?:const|let|var)\s+[^=;\n]+=\s*require\(\s*['"][^'"]+['"]\s*\)|^[ \t]*(?:from\s+\S+\s+import\b[^\n]*|import\s+\S+[^\n]*)/gm) ?? []).join('\n');
|
|
45
|
+
v = { file, text, masked, lang, lines: new Lines(text), imports };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.views.set(file, v);
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Local name -> import specifier, for the default, named and namespace imports and require calls of a JavaScript file.
|
|
53
|
+
importMap(v: FileView): Map<string, string> {
|
|
54
|
+
const out = new Map<string, string>();
|
|
55
|
+
for (const m of v.imports.matchAll(/\bimport\s+(?:type\s+)?([^'"]*?)\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
56
|
+
const clause = m[1];
|
|
57
|
+
const named = /\{([^}]*)\}/.exec(clause)?.[1] ?? '';
|
|
58
|
+
for (const part of named.split(',')) { const n = part.trim().split(/\s+as\s+/).pop()?.trim(); if (n) out.set(n, m[2]); }
|
|
59
|
+
const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+/, '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
60
|
+
for (const d of def) out.set(d, m[2]);
|
|
61
|
+
}
|
|
62
|
+
for (const m of v.imports.matchAll(/\b(?:const|let|var)\s+(\{[^}]*\}|[\w$]+)\s*=\s*require\(\s*['"]([^'"]+)['"]/g)) {
|
|
63
|
+
if (m[1].startsWith('{')) { for (const part of m[1].slice(1, -1).split(',')) { const n = part.trim().split(/\s*:\s*/).pop()?.trim(); if (n) out.set(n, m[2]); } } else out.set(m[1], m[2]);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isClient(v: FileView): boolean { return CLIENT_FILE.test(v.text.slice(0, 4000)) || (v.lang === 'js' && /\.[jt]sx$/.test(v.file) && /<\w+[^>]*>/.test(v.masked.slice(0, 20000)) && /\buseState\b|\buseEffect\b/.test(v.masked)); }
|
|
69
|
+
|
|
70
|
+
filesOf(lang: Lang): string[] { return this.ctx.files.filter((f) => langOf(f) === lang); }
|
|
71
|
+
|
|
72
|
+
// Resolve a relative (or "@/" aliased) import specifier from a file to a repository file.
|
|
73
|
+
resolveImport(from: string, spec: string): string | null {
|
|
74
|
+
let base: string;
|
|
75
|
+
if (spec.startsWith('.')) {
|
|
76
|
+
const parts = from.split('/').slice(0, -1);
|
|
77
|
+
for (const seg of spec.split('/')) { if (seg === '..') parts.pop(); else if (seg !== '.') parts.push(seg); }
|
|
78
|
+
base = parts.join('/');
|
|
79
|
+
} else if (spec.startsWith('@/') || spec.startsWith('~/')) {
|
|
80
|
+
const rest = spec.slice(2);
|
|
81
|
+
for (const root of ['src/', '']) { const r = this.tryFile(root + rest); if (r) return r; }
|
|
82
|
+
return null;
|
|
83
|
+
} else return null;
|
|
84
|
+
return this.tryFile(base);
|
|
85
|
+
}
|
|
86
|
+
private tryFile(base: string): string | null {
|
|
87
|
+
for (const ext of ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '/index.ts', '/index.js', '/index.mjs', '/__init__.py']) if (this.fileSet.has(base + ext)) return base + ext;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private index(lang: Lang): Map<string, HelperRef[]> {
|
|
92
|
+
let idx = this.indexes.get(lang);
|
|
93
|
+
if (idx) return idx;
|
|
94
|
+
idx = new Map();
|
|
95
|
+
if (lang !== 'js' && lang !== 'py') { this.indexes.set(lang, idx); return idx; } // the other languages keep their own indexes (light-index.ts)
|
|
96
|
+
const put = (name: string, ref: HelperRef): void => { const l = idx!.get(name); if (l) l.push(ref); else idx!.set(name, [ref]); };
|
|
97
|
+
for (const file of this.filesOf(lang)) {
|
|
98
|
+
const v = this.view(file);
|
|
99
|
+
if (!v || this.isClient(v)) continue;
|
|
100
|
+
if (lang === 'js') {
|
|
101
|
+
const m = v.masked;
|
|
102
|
+
for (const x of m.matchAll(/(?<![.\w$])function\s*\*?\s*([A-Za-z_$][\w$]*)\s*[(<]/g)) {
|
|
103
|
+
const at = /async\s+$/.test(m.slice(Math.max(0, x.index! - 7), x.index!)) ? x.index! - 6 : x.index!;
|
|
104
|
+
const f = jsFunction(m, at);
|
|
105
|
+
if (f) put(x[1], { view: v, start: f.start, end: f.end });
|
|
106
|
+
}
|
|
107
|
+
for (const x of m.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?=\s*/g)) {
|
|
108
|
+
const f = jsFunction(m, x.index! + x[0].length);
|
|
109
|
+
if (f) put(x[1], { view: v, start: f.start, end: f.end });
|
|
110
|
+
}
|
|
111
|
+
for (const x of m.matchAll(/\bexports\.([A-Za-z_$][\w$]*)\s*=\s*/g)) {
|
|
112
|
+
const f = jsFunction(m, x.index! + x[0].length);
|
|
113
|
+
if (f) put(x[1], { view: v, start: f.start, end: f.end });
|
|
114
|
+
}
|
|
115
|
+
for (const x of m.matchAll(/(?:^|[{,\n])\s*([A-Za-z_$][\w$]*)\s*:\s*(?=async\b|function\b|\(|[A-Za-z_$][\w$]*\s*=>)/g)) {
|
|
116
|
+
const f = jsFunction(m, x.index! + x[0].length);
|
|
117
|
+
if (f) put(x[1], { view: v, start: f.start, end: f.end });
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
for (const x of v.masked.matchAll(/^([ \t]*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/gm)) {
|
|
121
|
+
const b = pyBlock(v.masked, v.lines, x.index!);
|
|
122
|
+
put(x[2], { view: v, start: x.index!, end: b.end });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
this.indexes.set(lang, idx);
|
|
127
|
+
return idx;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Which repository file defines a function of this name: unique across the repository, else the one in the caller's file.
|
|
131
|
+
helpers(lang: Lang): Helpers {
|
|
132
|
+
return {
|
|
133
|
+
local: this.local,
|
|
134
|
+
find: (name, fromFile) => {
|
|
135
|
+
const refs = this.index(lang).get(name);
|
|
136
|
+
if (!refs) return null;
|
|
137
|
+
if (refs.length === 1) return refs[0];
|
|
138
|
+
const same = refs.filter((r) => r.view.file === fromFile);
|
|
139
|
+
if (same.length === 1) return same[0];
|
|
140
|
+
const sorted = refs.slice().sort((a, b) => cmp(a.view.file, b.view.file) || a.start - b.start);
|
|
141
|
+
return fromFile === undefined ? sorted[0] : null;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Function definitions of a name, all of them (for resolving a handler reference).
|
|
147
|
+
defs(lang: Lang, name: string): HelperRef[] {
|
|
148
|
+
return (this.index(lang).get(name) ?? []).slice().sort((a, b) => cmp(a.view.file, b.view.file) || a.start - b.start);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/map/rust.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Rust (Phase 6f). Level L1 reads Cargo.toml into the code map: dependencies by source (registry, git, path), the release profile, the
|
|
2
|
+
// unsafe code lint, the edition, and whether a Cargo.lock is committed. Level L2 reads the routes of axum (Router::new() chains with
|
|
3
|
+
// route, nest, merge, layer and route_layer, followed in order) and actix web (the #[get] family of attribute macros), with what protects
|
|
4
|
+
// each one: layers whose function names a sign in or a role check, and extractor types in the handler signature. HANDLER BODIES ARE NOT
|
|
5
|
+
// READ: no data store, outbound call or input is recorded for a Rust handler, so the checks that need those facts stay silent for Rust.
|
|
6
|
+
// A signature check is looked for in the handler text only.
|
|
7
|
+
import type { CargoConfig, CargoDependency, CargoManifest, Protection, Scope } from '../types/index.ts';
|
|
8
|
+
import type { FileView } from './analyze.ts';
|
|
9
|
+
import { empty, joinPath, makeEntry, type Extracted } from './entry.ts';
|
|
10
|
+
import { closeOf, splitArgs } from './lex.ts';
|
|
11
|
+
import type { Repo } from './repo.ts';
|
|
12
|
+
|
|
13
|
+
// ---- Cargo.toml ----------------------------------------------------------------------------------------------------------------------
|
|
14
|
+
export function readCargo(file: string, text: string, hasLock: boolean, binary: boolean): CargoManifest {
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
const m: CargoManifest = { file, line: 1, name: null, edition: null, dependencies: [], release_debug: null, release_overflow_checks: null, unsafe_code_lint: null, has_lock: hasLock, binary };
|
|
17
|
+
let section = '';
|
|
18
|
+
let pending: { name: string; line: number; text: string; depth: number } | null = null;
|
|
19
|
+
const addDep = (name: string, body: string, line: number): void => {
|
|
20
|
+
const t = body.trim();
|
|
21
|
+
let source: CargoDependency['source'] = 'registry';
|
|
22
|
+
let version: string | null = null;
|
|
23
|
+
if (t.startsWith('{')) {
|
|
24
|
+
if (/\bgit\s*=/.test(t)) source = 'git'; else if (/\bpath\s*=/.test(t)) source = 'path';
|
|
25
|
+
version = /\bversion\s*=\s*"([^"]*)"/.exec(t)?.[1] ?? null;
|
|
26
|
+
} else version = /^"([^"]*)"/.exec(t)?.[1] ?? null;
|
|
27
|
+
m.dependencies.push({ name, source, version, line });
|
|
28
|
+
};
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const raw = lines[i].replace(/(^|\s)#.*$/, '').trimEnd();
|
|
31
|
+
const line = i + 1;
|
|
32
|
+
if (pending) {
|
|
33
|
+
pending.text += ' ' + raw.trim();
|
|
34
|
+
pending.depth += (raw.match(/\{/g) ?? []).length - (raw.match(/\}/g) ?? []).length;
|
|
35
|
+
if (pending.depth <= 0) { addDep(pending.name, pending.text, pending.line); pending = null; }
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const sec = /^\s*\[\[?([^\]]+)\]\]?\s*$/.exec(raw);
|
|
39
|
+
if (sec) {
|
|
40
|
+
section = sec[1].trim();
|
|
41
|
+
const named = /^(?:workspace\.|target\.[^.]+\.)?(?:dependencies|build-dependencies)\.(.+)$/.exec(section);
|
|
42
|
+
if (named) m.dependencies.push({ name: named[1], source: 'registry', version: null, line });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const kv = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*)$/.exec(raw);
|
|
46
|
+
if (!kv) continue;
|
|
47
|
+
if (section === 'package') { if (kv[1] === 'name') m.name = /"([^"]*)"/.exec(kv[2])?.[1] ?? null; if (kv[1] === 'edition') m.edition = /"([^"]*)"/.exec(kv[2])?.[1] ?? null; if (kv[1] === 'name') m.line = line; }
|
|
48
|
+
else if (/^(?:workspace\.|target\.[^.]+\.)?(?:dependencies|build-dependencies)$/.test(section)) {
|
|
49
|
+
const depth = (kv[2].match(/\{/g) ?? []).length - (kv[2].match(/\}/g) ?? []).length;
|
|
50
|
+
if (depth > 0) pending = { name: kv[1], line, text: kv[2], depth }; else addDep(kv[1], kv[2], line);
|
|
51
|
+
} else if (/^dependencies\./.test(section) || /^(?:workspace\.|target\.[^.]+\.)?(?:dependencies|build-dependencies)\.[^.]+$/.test(section)) {
|
|
52
|
+
const dep = m.dependencies[m.dependencies.length - 1];
|
|
53
|
+
if (dep && kv[1] === 'git') dep.source = 'git';
|
|
54
|
+
else if (dep && kv[1] === 'path' && dep.source === 'registry') dep.source = 'path';
|
|
55
|
+
else if (dep && kv[1] === 'version') dep.version = /"([^"]*)"/.exec(kv[2])?.[1] ?? null;
|
|
56
|
+
} else if (section === 'profile.release') {
|
|
57
|
+
if (kv[1] === 'debug') m.release_debug = /^(true|2|1|"full"|"limited")/.test(kv[2].trim()) ? true : /^(false|0)/.test(kv[2].trim()) ? false : null;
|
|
58
|
+
if (kv[1] === 'overflow-checks') m.release_overflow_checks = /^true/.test(kv[2].trim()) ? true : /^false/.test(kv[2].trim()) ? false : null;
|
|
59
|
+
} else if (section === 'lints.rust' || section === 'workspace.lints.rust') {
|
|
60
|
+
if (kv[1] === 'unsafe_code') m.unsafe_code_lint = /"([^"]*)"/.exec(kv[2])?.[1] ?? /level\s*=\s*"([^"]*)"/.exec(kv[2])?.[1] ?? null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
m.dependencies.sort((a, b) => a.line - b.line || (a.name < b.name ? -1 : 1));
|
|
64
|
+
return m;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cargoConfig(repo: Repo): CargoConfig | null {
|
|
68
|
+
const manifests = repo.ctx.files.filter((f) => /(?:^|\/)Cargo\.toml$/.test(f) && !/(?:^|\/)(?:target|node_modules|vendor)\//.test(f));
|
|
69
|
+
if (!manifests.length) return null;
|
|
70
|
+
const set = new Set(repo.ctx.files);
|
|
71
|
+
return {
|
|
72
|
+
manifests: manifests.map((f) => {
|
|
73
|
+
const dir = f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '';
|
|
74
|
+
const up = (name: string): boolean => { for (let d = dir; ; d = d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '') { if (set.has(d ? `${d}/${name}` : name)) return true; if (!d) return false; } };
|
|
75
|
+
const text = repo.ctx.text(f);
|
|
76
|
+
const binary = set.has(dir ? `${dir}/src/main.rs` : 'src/main.rs') || /^\s*\[\[bin\]\]/m.test(text);
|
|
77
|
+
return readCargo(f, text, up('Cargo.lock'), binary);
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- routes ---------------------------------------------------------------------------------------------------------------------------
|
|
83
|
+
interface FnDef { view: FileView; name: string; start: number; end: number; paramsText: string; bodyStart: number }
|
|
84
|
+
const AUTH_TYPE = /\b(?:AuthUser|CurrentUser|AuthenticatedUser|Authenticated|SessionUser|LoggedIn|UserClaims|Claims|Identity|BearerAuth|AuthBearer|Jwt|JwtClaims|Principal)\b|TypedHeader\s*<\s*Authorization|\bBearerAuth\b|\bUser\s*\(/;
|
|
85
|
+
const ADMIN_TYPE = /\b(?:AdminUser|Admin|RequireAdmin|StaffUser|SuperUser|OwnerUser)\b/;
|
|
86
|
+
const AUTH_NAME = /auth|login|session|jwt|token|bearer|identity|credential/i;
|
|
87
|
+
const ROLE_NAME = /admin|role|permission|staff|owner|authorize|superuser/i;
|
|
88
|
+
const SIGNATURE = /\b(?:verify_signature|verify_webhook|verify_slice|constant_time_eq|Webhook::construct_event|construct_event|validate_signature|verify_hmac|hmac_sha256_verify)\b|\bHmac\s*::\s*<|\bMac\s*::\s*verify/;
|
|
89
|
+
const RATE = /\bGovernorLayer\b|\bRateLimitLayer\b|\btower_governor\b|\bRateLimiter\b/;
|
|
90
|
+
|
|
91
|
+
function fnDefs(repo: Repo): Map<string, FnDef[]> {
|
|
92
|
+
const out = new Map<string, FnDef[]>();
|
|
93
|
+
for (const f of repo.filesOf('rs')) {
|
|
94
|
+
const v = repo.view(f);
|
|
95
|
+
if (!v) continue;
|
|
96
|
+
const m = v.masked;
|
|
97
|
+
for (const x of m.matchAll(/(?:^|\n)[ \t]*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)\s*(?:<[^>\n]*>)?\s*\(/g)) {
|
|
98
|
+
const open = x.index! + x[0].length - 1;
|
|
99
|
+
const pc = closeOf(m, open);
|
|
100
|
+
if (pc < 0) continue;
|
|
101
|
+
let j = pc;
|
|
102
|
+
while (j < m.length && m[j] !== '{' && m[j] !== ';') j++;
|
|
103
|
+
if (m[j] !== '{') continue;
|
|
104
|
+
const e = closeOf(m, j);
|
|
105
|
+
if (e < 0) continue;
|
|
106
|
+
const def: FnDef = { view: v, name: x[1], start: x.index! + x[0].indexOf('fn'), end: e, paramsText: v.text.slice(open + 1, pc - 1), bodyStart: j };
|
|
107
|
+
out.set(x[1], [...(out.get(x[1]) ?? []), def]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// What a layer or middleware function says, from its name and, when the function is in the project, what its body answers.
|
|
114
|
+
function layerProtections(defs: Map<string, FnDef[]>, view: FileView, at: number, expr: string): Protection[] {
|
|
115
|
+
const out: Protection[] = [];
|
|
116
|
+
const names = [...expr.matchAll(/\b(?:from_fn(?:_with_state)?|map_request|from_extractor|from_extractor_with_state)\s*(?:::\s*<[^>]*>)?\s*\(\s*(?:[^,()]*,\s*)?([A-Za-z_][\w:]*)/g)].map((x) => x[1].split('::').pop()!);
|
|
117
|
+
const consts = [...expr.matchAll(/\b(\w*(?:Auth|Bearer|Jwt|Session)\w*Layer)\b/g)].map((x) => x[1]);
|
|
118
|
+
const line = view.lines.at(at);
|
|
119
|
+
const push = (kind: Protection['kind'], via: string, enforced: boolean | null, confidence: Protection['confidence']): void => { out.push({ kind, scope: 'middleware' as Scope, file: view.file, line, via, enforced, confidence }); };
|
|
120
|
+
for (const n of [...names, ...consts]) {
|
|
121
|
+
const d = defs.get(n)?.[0];
|
|
122
|
+
const body = d ? d.view.text.slice(d.bodyStart, d.end) : '';
|
|
123
|
+
const rejects = /StatusCode::(?:UNAUTHORIZED|FORBIDDEN)|Err\s*\(/.test(body);
|
|
124
|
+
if (ROLE_NAME.test(n)) { push('role_check', `layer ${n}`, d ? (rejects ? true : null) : null, d && rejects ? 'high' : 'medium'); if (AUTH_NAME.test(n)) push('session_read', `layer ${n}`, d ? (rejects ? true : null) : null, d && rejects ? 'high' : 'medium'); }
|
|
125
|
+
else if (AUTH_NAME.test(n)) push('session_read', `layer ${n}`, d ? (rejects ? true : null) : null, d && rejects ? 'high' : 'medium');
|
|
126
|
+
}
|
|
127
|
+
if (RATE.test(expr)) push('rate_limit', 'rate limit layer', null, 'medium');
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const handlerProtections = (d: FnDef | undefined, name: string): Protection[] => {
|
|
132
|
+
if (!d) return [];
|
|
133
|
+
const out: Protection[] = [];
|
|
134
|
+
const line = d.view.lines.at(d.start);
|
|
135
|
+
const p = d.paramsText;
|
|
136
|
+
if (ADMIN_TYPE.test(p)) { out.push({ kind: 'role_check', scope: 'handler', file: d.view.file, line, via: `extractor ${ADMIN_TYPE.exec(p)![0]}`, enforced: true, confidence: 'medium' }); out.push({ kind: 'session_read', scope: 'handler', file: d.view.file, line, via: `extractor ${ADMIN_TYPE.exec(p)![0]}`, enforced: true, confidence: 'medium' }); }
|
|
137
|
+
else if (AUTH_TYPE.test(p)) out.push({ kind: 'session_read', scope: 'handler', file: d.view.file, line, via: `extractor ${AUTH_TYPE.exec(p)![0].trim()}`, enforced: true, confidence: 'medium' });
|
|
138
|
+
const body = d.view.text.slice(d.bodyStart, d.end);
|
|
139
|
+
if (SIGNATURE.test(body)) out.push({ kind: 'signature_verify', scope: 'handler', file: d.view.file, line: d.view.lines.at(d.bodyStart + (SIGNATURE.exec(body)?.index ?? 0)), via: SIGNATURE.exec(body)![0], enforced: /if\s+!|return\s+(?:Err|StatusCode::UNAUTHORIZED)|\?;/.test(body) ? true : null, confidence: 'high' });
|
|
140
|
+
void name;
|
|
141
|
+
return out;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
interface Route { method: string; path: string; handler: string; view: FileView; at: number; prot: Protection[] }
|
|
145
|
+
interface Chain { view: FileView; calls: { name: string; open: number; close: number }[] }
|
|
146
|
+
|
|
147
|
+
function chainAt(v: FileView, from: number): Chain {
|
|
148
|
+
const m = v.masked;
|
|
149
|
+
const calls: Chain['calls'] = [];
|
|
150
|
+
let i = from + 'Router::new()'.length;
|
|
151
|
+
for (;;) {
|
|
152
|
+
const mm = /^\s*\.\s*(\w+)\s*(?:::\s*<[^>]*>)?\s*\(/.exec(m.slice(i, i + 400));
|
|
153
|
+
if (!mm) break;
|
|
154
|
+
const open = i + mm[0].length - 1;
|
|
155
|
+
const close = closeOf(m, open);
|
|
156
|
+
if (close < 0) break;
|
|
157
|
+
calls.push({ name: mm[1], open, close });
|
|
158
|
+
i = close;
|
|
159
|
+
}
|
|
160
|
+
return { view: v, calls };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function rustFiles(repo: Repo): string[] {
|
|
164
|
+
return repo.filesOf('rs').filter((f) => { const v = repo.view(f); return !!v && (/\bRouter\s*::\s*new\s*\(/.test(v.masked) || /#\s*\[\s*(?:get|post|put|delete|patch)\s*\(/.test(v.text)); });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function extractRust(repo: Repo): Extracted {
|
|
168
|
+
const out = empty();
|
|
169
|
+
const defs = fnDefs(repo);
|
|
170
|
+
const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'any']);
|
|
171
|
+
|
|
172
|
+
const handlerOf = (expr: string): { method: string; name: string; layers: string }[] => {
|
|
173
|
+
// get(h).post(h2).layer(...) : the handler of each method, and the text of layers placed on the method router
|
|
174
|
+
const res: { method: string; name: string; layers: string }[] = [];
|
|
175
|
+
let layers = '';
|
|
176
|
+
for (const x of expr.matchAll(/(?:^|\.|\b)(get|post|put|delete|patch|head|options|any|on|layer|route_layer)\s*\(((?:[^()]|\((?:[^()]|\([^()]*\))*\))*)\)/g)) {
|
|
177
|
+
if (x[1] === 'layer' || x[1] === 'route_layer') { layers += ' ' + x[2]; continue; }
|
|
178
|
+
if (!METHODS.has(x[1])) continue;
|
|
179
|
+
const nm = /([A-Za-z_][\w:]*)\s*$/.exec(x[2].trim().replace(/\)\s*$/, ''))?.[1]?.split('::').pop();
|
|
180
|
+
if (nm) res.push({ method: x[1].toUpperCase(), name: nm, layers: '' });
|
|
181
|
+
}
|
|
182
|
+
return res.map((r) => ({ ...r, layers }));
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const build = (ch: Chain, vars: Map<string, Chain>, depth: number): Route[] => {
|
|
186
|
+
let routes: Route[] = [];
|
|
187
|
+
const v = ch.view;
|
|
188
|
+
for (const c of ch.calls) {
|
|
189
|
+
const args = splitArgs(v.masked, c.open);
|
|
190
|
+
const txt = (k: number): string => (args[k] ? v.text.slice(args[k].start, args[k].end) : '');
|
|
191
|
+
if (c.name === 'route' && args.length >= 2) {
|
|
192
|
+
const path = /"([^"]*)"/.exec(txt(0))?.[1];
|
|
193
|
+
if (path === undefined) continue;
|
|
194
|
+
for (const h of handlerOf(txt(1))) routes.push({ method: h.method, path, handler: h.name, view: v, at: c.open, prot: h.layers ? layerProtections(defs, v, c.open, h.layers) : [] });
|
|
195
|
+
} else if ((c.name === 'nest' || c.name === 'merge') && depth < 6) {
|
|
196
|
+
const prefix = c.name === 'nest' ? /"([^"]*)"/.exec(txt(0))?.[1] ?? '' : '';
|
|
197
|
+
const target = c.name === 'nest' ? txt(1).trim() : txt(0).trim();
|
|
198
|
+
let sub: Chain | null = null;
|
|
199
|
+
const inl = /Router\s*::\s*new\s*\(\s*\)/.exec(target);
|
|
200
|
+
if (inl) { const start = args[c.name === 'nest' ? 1 : 0].start + inl.index; sub = chainAt(v, start); }
|
|
201
|
+
else if (/^\w+$/.test(target)) sub = vars.get(target) ?? null;
|
|
202
|
+
if (!sub) continue;
|
|
203
|
+
for (const r of build(sub, vars, depth + 1)) routes.push({ ...r, path: prefix ? joinPath(prefix, r.path) : r.path });
|
|
204
|
+
} else if (c.name === 'layer' || c.name === 'route_layer') {
|
|
205
|
+
const ps = layerProtections(defs, v, c.open, txt(0));
|
|
206
|
+
if (ps.length) routes = routes.map((r) => ({ ...r, prot: [...r.prot, ...ps] }));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return routes;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
for (const file of rustFiles(repo)) {
|
|
213
|
+
const v = repo.view(file);
|
|
214
|
+
if (!v) continue;
|
|
215
|
+
const m = v.masked;
|
|
216
|
+
// axum: chains, with `let name = Router::new()...;` variables resolved by name within the file
|
|
217
|
+
const vars = new Map<string, Chain>();
|
|
218
|
+
const chains: { name: string | null; chain: Chain }[] = [];
|
|
219
|
+
for (const x of m.matchAll(/\bRouter\s*::\s*new\s*\(\s*\)/g)) {
|
|
220
|
+
const before = m.slice(Math.max(0, x.index! - 120), x.index!);
|
|
221
|
+
const prevCall = /\.\s*(?:nest|merge)\s*\(\s*(?:"[^"]*"\s*,\s*)?$/.test(before);
|
|
222
|
+
if (prevCall) continue;
|
|
223
|
+
const nm = /\blet\s+(?:mut\s+)?(\w+)(?:\s*:\s*[\w<>:, ]+)?\s*=\s*$/.exec(before)?.[1] ?? null;
|
|
224
|
+
const ch = chainAt(v, x.index!);
|
|
225
|
+
if (nm) vars.set(nm, ch);
|
|
226
|
+
chains.push({ name: nm, chain: ch });
|
|
227
|
+
}
|
|
228
|
+
// Roots: chains that no other chain nests or merges by name.
|
|
229
|
+
const used = new Set<string>();
|
|
230
|
+
for (const { chain } of chains) for (const c of chain.calls) if (c.name === 'nest' || c.name === 'merge') { const t = splitArgs(v.masked, c.open).map((a) => v.text.slice(a.start, a.end).trim()); for (const a of t) if (/^\w+$/.test(a)) used.add(a); }
|
|
231
|
+
const seen = new Set<string>();
|
|
232
|
+
for (const { name, chain } of chains) {
|
|
233
|
+
if (name && used.has(name)) continue;
|
|
234
|
+
for (const r of build(chain, vars, 0)) {
|
|
235
|
+
const key = `${r.method} ${r.path} ${r.handler} ${r.at}`;
|
|
236
|
+
if (seen.has(key)) continue;
|
|
237
|
+
seen.add(key);
|
|
238
|
+
const d = (defs.get(r.handler) ?? []).find((x) => x.view.file === file) ?? defs.get(r.handler)?.[0];
|
|
239
|
+
const prot = [...r.prot, ...handlerProtections(d, r.handler)];
|
|
240
|
+
const analysis = { protections: prot, reads: [], data: [], outbound: [], taint: { names: new Set<string>(), validated: new Set<string>(), trusted: new Set<string>() } };
|
|
241
|
+
const hv = d?.view ?? v;
|
|
242
|
+
out.entries.push(makeEntry(repo, {
|
|
243
|
+
kind: 'http_route', framework: 'axum', file, line: v.lines.at(r.at), method: r.method, path: r.path === '' ? '/' : joinPath('', r.path), name: r.handler,
|
|
244
|
+
confidence: d ? 'high' : 'medium', handlerView: hv, spec: { start: d?.start ?? r.at, end: d?.end ?? r.at, seeds: [] }, analysis,
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// actix web: #[get("/x")] above an async fn
|
|
249
|
+
for (const x of v.text.matchAll(/#\s*\[\s*(get|post|put|delete|patch|head)\s*\(\s*"([^"]*)"[^\]]*\]\s*(?:\n\s*#\s*\[[^\]]*\]\s*)*\n?\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/g)) {
|
|
250
|
+
const d = (defs.get(x[3]) ?? []).find((y) => y.view.file === file);
|
|
251
|
+
const analysis = { protections: handlerProtections(d, x[3]), reads: [], data: [], outbound: [], taint: { names: new Set<string>(), validated: new Set<string>(), trusted: new Set<string>() } };
|
|
252
|
+
out.entries.push(makeEntry(repo, { kind: 'http_route', framework: 'actix-web', file, line: v.lines.at(x.index!), method: x[1].toUpperCase(), path: joinPath('', x[2]), name: x[3], confidence: 'medium', handlerView: v, spec: { start: d?.start ?? x.index!, end: d?.end ?? x.index!, seeds: [] }, analysis }));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Spring MVC entry points (Java): @RestController and @Controller classes with @RequestMapping, @GetMapping, @PostMapping, @PutMapping,
|
|
2
|
+
// @DeleteMapping and @PatchMapping, joined to what protects them: method security annotations (@PreAuthorize, @Secured, @RolesAllowed),
|
|
3
|
+
// the Spring Security filter chain (authorizeHttpRequests and authorizeRequests rules, first matching rule wins), the default of
|
|
4
|
+
// requiring a sign in when the security starter is on the class path, and what the handler body does. A method security annotation
|
|
5
|
+
// counts as enforced only when @EnableMethodSecurity or @EnableGlobalMethodSecurity is somewhere in the project. Pattern based, like the
|
|
6
|
+
// other extractors; every fact carries a confidence.
|
|
7
|
+
import type { Gate, Protection } from '../types/index.ts';
|
|
8
|
+
import type { FileView } from './analyze.ts';
|
|
9
|
+
import { annotationBlocks, splitTop, strings, type Anno } from './cblocks.ts';
|
|
10
|
+
import { empty, joinPath, makeEntry, normPath, type Extracted } from './entry.ts';
|
|
11
|
+
import { cMethod, type FnIndex } from './light-index.ts';
|
|
12
|
+
import { analyzeLight, type LightDeco } from './light.ts';
|
|
13
|
+
import { closeOf } from './lex.ts';
|
|
14
|
+
import type { LocalVocab } from './local-vocab.ts';
|
|
15
|
+
import type { Repo } from './repo.ts';
|
|
16
|
+
|
|
17
|
+
const MAPPING_VERB: Record<string, string> = { GetMapping: 'GET', PostMapping: 'POST', PutMapping: 'PUT', DeleteMapping: 'DELETE', PatchMapping: 'PATCH' };
|
|
18
|
+
const REQUEST_SEEDS = /@(?:PathVariable|RequestParam|RequestBody|RequestHeader|CookieValue|ModelAttribute|MatrixVariable|RequestPart)\b/;
|
|
19
|
+
const SIMPLE_TYPES = /^(?:String|Long|Integer|int|long|UUID|Boolean|boolean|Double|double|LocalDate|BigDecimal)\b/;
|
|
20
|
+
const METHOD_SECURITY = /@(?:PreAuthorize|PostAuthorize|Secured|RolesAllowed)\b/;
|
|
21
|
+
|
|
22
|
+
export function springFiles(repo: Repo): string[] {
|
|
23
|
+
return repo.filesOf('java').filter((f) => { const v = repo.view(f); return !!v && /@(?:Rest)?Controller\b/.test(v.masked); });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SecRule { methods: string[]; patterns: string[]; any: boolean; effect: 'permit' | 'authenticated' | 'role' | 'deny'; roles: string[]; file: string; line: number; readable: boolean }
|
|
27
|
+
interface SecurityInfo { rules: SecRule[]; chains: number; methodSecurity: boolean; starter: boolean; securityMatchers: boolean }
|
|
28
|
+
|
|
29
|
+
const EFFECT: [RegExp, SecRule['effect']][] = [
|
|
30
|
+
[/^permitAll$|^anonymous$/, 'permit'], [/^authenticated$|^fullyAuthenticated$|^rememberMe$/, 'authenticated'],
|
|
31
|
+
[/^hasRole$|^hasAnyRole$|^hasAuthority$|^hasAnyAuthority$|^access$/, 'role'], [/^denyAll$/, 'deny'],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function securityInfo(repo: Repo): SecurityInfo {
|
|
35
|
+
const info: SecurityInfo = { rules: [], chains: 0, methodSecurity: false, starter: false, securityMatchers: false };
|
|
36
|
+
for (const f of ['pom.xml', 'build.gradle', 'build.gradle.kts']) if (/spring-boot-starter-security|spring-security-web|spring-security-config/.test(repo.ctx.text(f))) info.starter = true;
|
|
37
|
+
for (const file of repo.filesOf('java')) {
|
|
38
|
+
const v = repo.view(file);
|
|
39
|
+
if (!v) continue;
|
|
40
|
+
const m = v.masked;
|
|
41
|
+
if (/@EnableMethodSecurity\b|@EnableGlobalMethodSecurity\b/.test(m)) info.methodSecurity = true;
|
|
42
|
+
if (!/\bSecurityFilterChain\b|\bWebSecurityConfigurerAdapter\b|\bauthorizeHttpRequests\b|\bauthorizeRequests\b/.test(m)) continue;
|
|
43
|
+
if (/\bsecurityMatchers?\s*\(/.test(m)) info.securityMatchers = true;
|
|
44
|
+
for (const start of [...m.matchAll(/\.\s*(?:authorizeHttpRequests|authorizeRequests)\s*\(/g)]) {
|
|
45
|
+
info.chains++;
|
|
46
|
+
const open = start.index! + start[0].length - 1;
|
|
47
|
+
const close = closeOf(m, open);
|
|
48
|
+
let end = close > 0 ? close : open;
|
|
49
|
+
// authorizeRequests() with no argument is followed by a chain that runs to the end of the statement
|
|
50
|
+
if (close > 0 && close === open + 2) { const semi = m.indexOf(';', close); end = semi < 0 ? m.length : semi; }
|
|
51
|
+
const region = m.slice(open, end);
|
|
52
|
+
const text = v.text.slice(open, end);
|
|
53
|
+
for (const tok of region.matchAll(/\.\s*(requestMatchers|antMatchers|mvcMatchers|regexMatchers|anyRequest)\s*\(/g)) {
|
|
54
|
+
const any = tok[1] === 'anyRequest';
|
|
55
|
+
let a = tok.index! + tok[0].length - 1;
|
|
56
|
+
let argsText = '';
|
|
57
|
+
if (!any) { const c = closeOf(region, a); if (c < 0) continue; argsText = text.slice(a + 1, c - 1); a = c; }
|
|
58
|
+
else { const c = closeOf(region, a); a = c > 0 ? c : a + 1; }
|
|
59
|
+
const eff = /^\s*\.\s*(permitAll|anonymous|authenticated|fullyAuthenticated|rememberMe|hasRole|hasAnyRole|hasAuthority|hasAnyAuthority|access|denyAll)\s*\(([^)]*)\)/.exec(region.slice(a));
|
|
60
|
+
if (!eff) continue;
|
|
61
|
+
const effect = EFFECT.find(([re]) => re.test(eff[1]))![1];
|
|
62
|
+
const lits = strings(argsText);
|
|
63
|
+
const methods = [...argsText.matchAll(/\bHttpMethod\s*\.\s*([A-Z]+)/g)].map((x) => x[1]);
|
|
64
|
+
const roles = strings(text.slice(a, a + eff[0].length)).map((r) => r.replace(/^ROLE_/, ''));
|
|
65
|
+
const line = v.lines.at(open + tok.index!);
|
|
66
|
+
info.rules.push({ methods, patterns: lits, any, effect, roles, file, line, readable: any || lits.length > 0 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return info;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// An Ant style pattern from the filter chain against a path of the map (parameters written :name).
|
|
74
|
+
function antMatch(pattern: string, path: string): boolean {
|
|
75
|
+
const re = pattern.replace(/[.+^$|\\()[\]]/g, '\\$&').replace(/\{[^/}]+\}/g, '[^/]+').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\u0000/g, '.*');
|
|
76
|
+
const p = path.replace(/:\w+/g, 'x');
|
|
77
|
+
return new RegExp(`^${re}$`).test(p) || (pattern.endsWith('/**') && p === pattern.slice(0, -3));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ParamInfo { seeds: string[]; principal: boolean }
|
|
81
|
+
function params(text: string): ParamInfo {
|
|
82
|
+
const seeds: string[] = [];
|
|
83
|
+
let principal = false;
|
|
84
|
+
for (const part of splitTop(text)) {
|
|
85
|
+
const name = /(\w+)\s*$/.exec(part.replace(/\s*=\s*[^,]*$/, ''))?.[1];
|
|
86
|
+
if (!name) continue;
|
|
87
|
+
if (/@AuthenticationPrincipal\b|\b(?:Principal|Authentication)\s+\w+$/.test(part)) { principal = true; continue; }
|
|
88
|
+
const bare = part.replace(/@\w+(?:\([^)]*\))?\s*/g, '').trim();
|
|
89
|
+
if (REQUEST_SEEDS.test(part) || (!/@/.test(part) && SIMPLE_TYPES.test(bare))) seeds.push(name);
|
|
90
|
+
}
|
|
91
|
+
return { seeds, principal };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pathsOf = (a: Anno | undefined, text: string): string[] => {
|
|
95
|
+
if (!a || !a.args) return [''];
|
|
96
|
+
const args = text.slice(a.args.start, a.args.end);
|
|
97
|
+
const named = /\b(?:value|path)\s*=\s*(\{[^}]*\}|"(?:[^"\\]|\\.)*")/.exec(args);
|
|
98
|
+
const lits = named ? strings(named[1]) : /^\s*(\{[^}]*\}|"(?:[^"\\]|\\.)*")/.exec(args) ? strings(/^\s*(\{[^}]*\}|"(?:[^"\\]|\\.)*")/.exec(args)![1]) : [];
|
|
99
|
+
return lits.length ? lits : [''];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export function extractSpring(repo: Repo, fn: FnIndex, local: LocalVocab | null): Extracted {
|
|
103
|
+
const out = empty();
|
|
104
|
+
const sec = securityInfo(repo);
|
|
105
|
+
const gates: Gate[] = sec.rules.map((r, i) => ({
|
|
106
|
+
id: `spring:${r.file}:${r.line}:${i}`, kind: 'guard', framework: 'spring', file: r.file, line: r.line,
|
|
107
|
+
paths: r.any ? ['*'] : r.patterns.map((p) => p.replace(/\/?\*\*?.*$/, '') || '/'), methods: r.methods,
|
|
108
|
+
protections: ruleProtections(r), confidence: sec.securityMatchers ? 'medium' : 'high',
|
|
109
|
+
}));
|
|
110
|
+
out.gates.push(...gates);
|
|
111
|
+
|
|
112
|
+
const fileOf = (v: FileView): void => {
|
|
113
|
+
const m = v.masked;
|
|
114
|
+
for (const cls of annotationBlocks(m, 0, m.length)) {
|
|
115
|
+
const names = cls.annos.map((a) => a.name);
|
|
116
|
+
if (!names.some((n) => n === 'RestController' || n === 'Controller')) continue;
|
|
117
|
+
const decl = /^(?:(?:public|abstract|final|static)\s+)*class\s+(\w+)[^{]*\{/.exec(m.slice(cls.declStart, cls.declStart + 400));
|
|
118
|
+
if (!decl) continue;
|
|
119
|
+
const className = decl[1];
|
|
120
|
+
const bodyOpen = cls.declStart + decl[0].length - 1;
|
|
121
|
+
const bodyClose = closeOf(m, bodyOpen);
|
|
122
|
+
if (bodyClose < 0) continue;
|
|
123
|
+
const classMap = cls.annos.find((a) => a.name === 'RequestMapping');
|
|
124
|
+
const prefix = pathsOf(classMap, v.text)[0];
|
|
125
|
+
const classDeco: LightDeco = { view: v, start: cls.start, end: cls.end, scope: 'router' };
|
|
126
|
+
for (const blk of annotationBlocks(m, bodyOpen + 1, bodyClose - 1)) {
|
|
127
|
+
const mappings = blk.annos.filter((a) => a.name === 'RequestMapping' || MAPPING_VERB[a.name]);
|
|
128
|
+
if (!mappings.length) continue;
|
|
129
|
+
const nameAt = /(\w+)\s*\(/.exec(m.slice(blk.declStart, blk.declStart + 300));
|
|
130
|
+
if (!nameAt) continue;
|
|
131
|
+
const at = blk.declStart + nameAt.index;
|
|
132
|
+
const f = cMethod(m, at);
|
|
133
|
+
if (!f) continue;
|
|
134
|
+
const pr = params(f.params);
|
|
135
|
+
const decos: LightDeco[] = [{ view: v, start: blk.start, end: blk.end, scope: 'wrapper' }];
|
|
136
|
+
if (cls.annos.some((a) => a !== classMap)) decos.push(classDeco);
|
|
137
|
+
const methodAt = blk.declStart;
|
|
138
|
+
for (const a of mappings) {
|
|
139
|
+
const verbs = MAPPING_VERB[a.name]
|
|
140
|
+
? [MAPPING_VERB[a.name]]
|
|
141
|
+
: (() => { const mm = a.args ? /\bmethod\s*=\s*(\{[^}]*\}|[\w.]+)/.exec(v.text.slice(a.args.start, a.args.end)) : null; const vs = mm ? [...mm[1].matchAll(/\b([A-Z]{3,7})\b/g)].map((x) => x[1]).filter((x) => x !== 'RequestMethod') : []; return vs.length ? vs : ['ANY']; })();
|
|
142
|
+
for (const p of pathsOf(a, v.text)) {
|
|
143
|
+
const joined = joinPath(prefix, p) || '/';
|
|
144
|
+
const full = joined.startsWith('/') ? joined : `/${joined}`;
|
|
145
|
+
for (const verb of verbs) {
|
|
146
|
+
const extra = globalProtections(sec, verb, full);
|
|
147
|
+
const an = analyzeLight(v, { start: methodAt, end: f.end, seeds: pr.seeds, decorators: decos, extra, lookup: fn.find, local });
|
|
148
|
+
if (!sec.methodSecurity) for (const q of an.protections) if (METHOD_SECURITY.test(lineText(v, q.line))) { q.enforced = false; q.via += ' (method security is not enabled)'; }
|
|
149
|
+
out.entries.push(makeEntry(repo, {
|
|
150
|
+
kind: 'http_route', framework: 'spring', file: v.file, line: v.lines.at(mappings[0].start), method: verb, path: normPath(full), name: `${className}.${nameAt[1]}`,
|
|
151
|
+
confidence: 'high', handlerView: v, spec: { start: methodAt, end: f.end, seeds: pr.seeds }, analysis: an,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
for (const file of springFiles(repo)) { const v = repo.view(file); if (v) fileOf(v); }
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const lineText = (v: FileView, line: number): string => v.text.slice(v.lines.start(line), v.lines.start(line + 1));
|
|
164
|
+
|
|
165
|
+
function ruleProtections(r: SecRule): Protection[] {
|
|
166
|
+
const base = { scope: 'global' as const, file: r.file, line: r.line, enforced: true, confidence: 'high' as const };
|
|
167
|
+
if (r.effect === 'authenticated') return [{ ...base, kind: 'session_read', via: 'authorizeHttpRequests authenticated' }];
|
|
168
|
+
if (r.effect === 'role') return [{ ...base, kind: 'session_read', via: 'authorizeHttpRequests role rule' }, { ...base, kind: 'role_check', via: `authorizeHttpRequests ${r.roles.length ? 'role ' + r.roles.join(', ') : 'role rule'}` }];
|
|
169
|
+
if (r.effect === 'deny') return [{ ...base, kind: 'session_read', via: 'authorizeHttpRequests denyAll' }, { ...base, kind: 'role_check', via: 'authorizeHttpRequests denyAll' }];
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// What the filter chain says about one route: the first rule that matches decides. Without a chain, the security starter's default needs a
|
|
174
|
+
// sign in for every request.
|
|
175
|
+
function globalProtections(sec: SecurityInfo, method: string, path: string): Protection[] {
|
|
176
|
+
const conf = sec.securityMatchers ? 'medium' : 'high';
|
|
177
|
+
for (const r of sec.rules) {
|
|
178
|
+
if (!r.readable) continue;
|
|
179
|
+
if (r.methods.length && method !== 'ANY' && !r.methods.includes(method)) continue;
|
|
180
|
+
if (!r.any && !r.patterns.some((p) => antMatch(p, path))) continue;
|
|
181
|
+
return ruleProtections(r).map((q) => ({ ...q, confidence: conf }));
|
|
182
|
+
}
|
|
183
|
+
if (!sec.chains && sec.starter) return [{ kind: 'session_read', scope: 'global', file: 'pom.xml', line: 1, via: 'Spring Security default: every request needs a sign in', enforced: true, confidence: 'medium' }];
|
|
184
|
+
return [];
|
|
185
|
+
}
|