attackmap-analyzer-go 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,727 @@
1
+ """Go ecosystem analyzer for AttackMap.
2
+
3
+ Coverage (v0.1):
4
+ - Web frameworks: net/http (stdlib), chi, gin, echo, fiber, gorilla/mux
5
+ - Databases: database/sql, gorm, sqlx, pgx, mongo-go-driver, go-redis, bbolt
6
+ - Auth packages: golang-jwt/jwt, golang.org/x/oauth2, gorilla/sessions, casbin,
7
+ golang.org/x/crypto/bcrypt and friends, x/crypto/scrypt, x/crypto/argon2
8
+ - HTTP clients (external calls): net/http (http.Get/Post), go-resty
9
+ - Secrets: os.Getenv, godotenv (joho/godotenv), viper.GetString
10
+ - Entrypoints: http.ListenAndServe, gin.Run, echo.Start, fiber.Listen
11
+ - Service hints: module name from go.mod
12
+
13
+ Emits Signal v2 fields (line numbers + evidence snippets + confidence) so
14
+ downstream insights can cite `path/to/file.go:NN`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ from pathlib import Path
22
+
23
+ from attackmap.sdk import (
24
+ DEFAULT_SKIP_DIRS,
25
+ AnalyzerMetadata,
26
+ AuthHint,
27
+ DatabaseHint,
28
+ EntrypointHint,
29
+ ExternalCall,
30
+ FrameworkHint,
31
+ iter_repo_files,
32
+ line_of,
33
+ read_source,
34
+ rel,
35
+ Route,
36
+ ScanResult,
37
+ SecretHint,
38
+ ServiceHint,
39
+ )
40
+
41
+ from . import route_auth as ra
42
+
43
+ CODE_SUFFIXES = {".go"}
44
+ # vendor/, .git, node_modules, dist and build are all in the SDK defaults, which
45
+ # are matched against directory names inside the repo only (mlaify/AttackMap#253).
46
+ SKIP_DIRS = DEFAULT_SKIP_DIRS
47
+ # The go tool ignores `testdata/` directories: they hold fixtures, never code
48
+ # that ships. analyze() skips them along with `*_test.go` files unless test
49
+ # code is opted in with ATTACKMAP_INCLUDE_TESTS.
50
+ TEST_DATA_DIRS = frozenset({"testdata"})
51
+ INCLUDE_TESTS_ENV = "ATTACKMAP_INCLUDE_TESTS"
52
+ _SNIPPET_MAX_CHARS = 160
53
+
54
+
55
+ # ---------- Patterns ----------
56
+
57
+ # Path literals: interpreted ("/x", no escapes) or raw (`/x`), on one line and
58
+ # bounded, so an unterminated quote can't make a match scan the rest of the file.
59
+ _LIT = ra.LIT
60
+ _SLASH_LIT = r'(?:"([/*][^"\\\n]{0,511})"|`([/*][^`\n]{0,511})`)'
61
+
62
+ # Gin: r.GET("/path", h), r.POST("/path", h), r.Group("/api")
63
+ # Method must be uppercase verb (Gin convention). An empty path is the group's
64
+ # own path (`users.GET("", list)`).
65
+ GIN_ROUTE_PATTERN = re.compile(
66
+ r'\b(\w+)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\(\s*' + _LIT,
67
+ )
68
+
69
+ # Chi: r.Get("/path", h), r.Post("/path", h)
70
+ # Title-case method (Chi convention). Same shape as Echo.
71
+ #
72
+ # Title-case `.Get(...)` is also the shape of every key/value lookup in Go
73
+ # (`req.Header.Get("Authorization")`, `q.Get("id")`, `viper.Get("key")`, fiber's
74
+ # `c.Get("X-Api-Key")`), so a registration must look like one: the path is a
75
+ # string literal starting with `/` (chi panics on anything else; fiber's
76
+ # catch-all `*` is the one exception) followed by a handler argument.
77
+ CHI_ECHO_ROUTE_PATTERN = re.compile(
78
+ r'\b(\w+)\.(Get|Post|Put|Delete|Patch|Head|Options)\(\s*' + _SLASH_LIT + r'\s*,',
79
+ )
80
+ # Chi: r.Method("GET", "/path", h) / r.MethodFunc(...).
81
+ CHI_METHOD_PATTERN = re.compile(
82
+ r'\b(\w+)\s*\.\s*Method(?:Func)?\(\s*"([A-Z]+)"\s*,\s*' + _SLASH_LIT + r'\s*,',
83
+ )
84
+
85
+ # Chi inline middleware: r.With(mw...).Post("/path", h). The receiver's
86
+ # `.With(` is matched here; the chained verb call is read after its `)`.
87
+ CHI_WITH_PATTERN = re.compile(r"\b(\w+)\s*\.\s*With\s*\(")
88
+ CHI_WITH_CHAIN_PATTERN = re.compile(r"\s*\.\s*With\s*\(")
89
+ CHI_WITH_VERB_PATTERN = re.compile(
90
+ r'\s*\.\s*(Get|Post|Put|Delete|Patch|Head|Options)\s*\(\s*' + _SLASH_LIT + r'\s*,'
91
+ )
92
+
93
+ # Receivers that are never routers: request accessors (`r.Header.Get`,
94
+ # `r.URL.Query().Get`, `r.Form.Get`), config/env lookups, and the per-request
95
+ # context (`c`/`ctx`) whose `.Get` reads a header or a context value.
96
+ NON_ROUTER_RECEIVERS = frozenset(
97
+ {
98
+ "Header", "Query", "URL", "Form", "PostForm", "Trailer", "Values",
99
+ "Cookies", "Params", "viper", "os", "c", "ctx",
100
+ }
101
+ )
102
+
103
+ # Fiber: app.Get("/path", h)
104
+ # Same shape as chi/echo title-case but framework is detected by app.* + fiber import.
105
+
106
+ # Gorilla/mux: a route is a chain from the router, read call by call:
107
+ # r.HandleFunc("/path", h).Methods("GET", "POST"), r.Handle("/path", authMW(h)),
108
+ # r.Path("/path").Methods("GET").HandlerFunc(h), r.PathPrefix("/static/").Handler(fs).
109
+ # A chain that ends in `.Subrouter()` is a router, not a route (see route_auth).
110
+ GORILLA_CHAIN_START = re.compile(
111
+ r"\b(\w+)\s*\.\s*(?:HandleFunc|Handle|Path|PathPrefix|Methods|Host|Schemes|Headers|Queries|Name|NewRoute)\s*\(",
112
+ )
113
+ _GORILLA_PATH_CALLS = frozenset({"HandleFunc", "Handle", "Path", "PathPrefix"})
114
+ _GORILLA_TERMINALS = {"HandleFunc": 1, "Handle": 1, "Handler": 0, "HandlerFunc": 0} # -> handler arg
115
+ _HTTP_METHOD_CONST = re.compile(r"\s*http\s*\.\s*Method([A-Z][a-z]+)\s*$")
116
+
117
+ # net/http stdlib: http.HandleFunc("/path", h), mux.Handle("/path", h), and
118
+ # Go 1.22 patterns `"[METHOD ][HOST]/[PATH]"`: mux.HandleFunc("GET /items/{id}", h).
119
+ NET_HTTP_HANDLE_PATTERN = re.compile(
120
+ r'\b(\w+)\s*\.\s*(?:HandleFunc|Handle)\s*\(\s*' + _LIT + r'\s*,',
121
+ )
122
+ MUX_PATTERN_SYNTAX = re.compile(r"(?:([A-Z]+)[ \t]+)?([^/ \t]*)(/[^ \t]*)")
123
+ MUX_PATTERN_HOST = re.compile(
124
+ r"(?:[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+|localhost|\[[0-9A-Fa-f:.]+\])(?::[0-9]+)?"
125
+ )
126
+
127
+ # External HTTP calls
128
+ OUTBOUND_PATTERNS: list[re.Pattern[str]] = [
129
+ re.compile(r'\bhttp\.(?:Get|Post|Head)\(\s*"(https?://[^"]+)"'),
130
+ re.compile(r'\bhttp\.NewRequest\(\s*"[A-Z]+"\s*,\s*"(https?://[^"]+)"'),
131
+ re.compile(r'\bresty\.New\(\)\.\w+\(\)\.\w+\(\s*"(https?://[^"]+)"'),
132
+ re.compile(r'\bgrequests\.\w+\(\s*"(https?://[^"]+)"'),
133
+ re.compile(r'\bclient\.\w+\(\s*ctx\s*,\s*"(https?://[^"]+)"', re.IGNORECASE),
134
+ ]
135
+
136
+ # Database libs
137
+ DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
138
+ (re.compile(r'\bsql\.Open\(\s*"postgres"'), "postgresql"),
139
+ (re.compile(r'\bsql\.Open\(\s*"pgx"'), "postgresql"),
140
+ (re.compile(r'\bsql\.Open\(\s*"mysql"'), "mysql"),
141
+ (re.compile(r'\bsql\.Open\(\s*"sqlite3?"'), "sqlite"),
142
+ (re.compile(r'\bsql\.Open\('), "sql"),
143
+ (re.compile(r'\bgorm\.Open\(\s*postgres\.'), "postgresql"),
144
+ (re.compile(r'\bgorm\.Open\(\s*mysql\.'), "mysql"),
145
+ (re.compile(r'\bgorm\.Open\(\s*sqlite\.'), "sqlite"),
146
+ (re.compile(r'\bgorm\.Open\('), "sql"),
147
+ (re.compile(r'\bsqlx\.(?:Connect|Open|MustConnect)\('), "sql"),
148
+ (re.compile(r'\bpgx\.(?:Connect|ConnectConfig)\(|\bpgxpool\.(?:New|Connect)\('), "postgresql"),
149
+ (re.compile(r'\bmongo\.Connect\('), "mongodb"),
150
+ (re.compile(r'\bredis\.NewClient\(|\bgo-redis\b'), "redis"),
151
+ (re.compile(r'\bbolt\.Open\(|\bbbolt\.Open\('), "bolt"),
152
+ (re.compile(r'\bs3\.New\w*\(|\baws-sdk-go(?:-v2)?/service/s3\b'), "object_storage"),
153
+ (re.compile(r'\bdynamodb\.New\w*\('), "dynamodb"),
154
+ ]
155
+
156
+ # Auth-related signals. Core credits any auth hint near a route as that route's
157
+ # auth, so JWT hints come from calls that verify a token (AttackMap#325): an
158
+ # import path, signing (`NewWithClaims`), a claims type or a config constructor
159
+ # authenticates nobody.
160
+ AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
161
+ # golang-jwt / jwx `jwt.Parse` / `ParseWithClaims` verify; `ParseUnverified`
162
+ # and `ParseInsecure` don't, and the `\s*\(` keeps them out.
163
+ (re.compile(r'\bjwt\.Parse(?:WithClaims)?\s*\(|\.ParseWithClaims\s*\('), "jwt", 0.85),
164
+ (re.compile(r'\bx/crypto/bcrypt\b|\bbcrypt\.GenerateFromPassword\b'), "bcrypt", 0.9),
165
+ (re.compile(r'\bx/crypto/argon2\b|\bargon2\.IDKey\b'), "argon2", 0.9),
166
+ (re.compile(r'\bx/crypto/scrypt\b'), "scrypt", 0.9),
167
+ (re.compile(r'\bx/oauth2\b|\boauth2\.Config\b'), "oauth", 0.85),
168
+ (re.compile(r'\bgorilla/sessions\b|\bsessions\.NewCookieStore\('), "gorilla_sessions", 0.85),
169
+ (re.compile(r'\bcasbin\.NewEnforcer\(|\bcasbin/v\d+\b'), "casbin_authz", 0.9),
170
+ # jwtauth.New only builds the config; Verifier/Verify*/Authenticator check.
171
+ (re.compile(r'\bjwtauth\.(?:Verifier|Verify|VerifyToken|VerifyRequest|Authenticator)\s*\('), "chi_jwtauth", 0.85),
172
+ (re.compile(r'\bechojwt\.(?:JWT|WithConfig)\s*\(|\bmiddleware\.JWT(?:WithConfig)?\s*\('), "echo_jwt", 0.85),
173
+ (re.compile(r'\bAuthorization\b'), "authorization_header", 0.6),
174
+ (re.compile(r'\bBearer\b'), "bearer_token", 0.6),
175
+ (re.compile(r'\bapi[_-]?key\b', re.IGNORECASE), "api_key", 0.6),
176
+ ]
177
+
178
+ # Web framework presence
179
+ FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
180
+ (re.compile(r'\bgithub\.com/gin-gonic/gin\b|\bgin\.(?:Default|New|Engine)\('), "gin"),
181
+ (re.compile(r'\bgithub\.com/go-chi/chi\b|\bchi\.(?:NewRouter|NewMux)\('), "chi"),
182
+ (re.compile(r'\bgithub\.com/labstack/echo\b|\becho\.New\('), "echo"),
183
+ (re.compile(r'\bgithub\.com/gofiber/fiber\b|\bfiber\.New\('), "fiber"),
184
+ (re.compile(r'\bgithub\.com/gorilla/mux\b|\bmux\.NewRouter\('), "gorilla-mux"),
185
+ (re.compile(r'\bgithub\.com/gorilla/sessions\b'), "gorilla-sessions"),
186
+ (re.compile(r'\bnet/http\b|\bhttp\.HandleFunc\b'), "net-http"),
187
+ (re.compile(r'\bgrpc\.NewServer\(|\bgoogle\.golang\.org/grpc\b'), "grpc"),
188
+ ]
189
+
190
+ # Entrypoint markers
191
+ ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
192
+ (re.compile(r'\bhttp\.ListenAndServe(?:TLS)?\('), "net_http_listen"),
193
+ (re.compile(r'\b\w+\.ListenAndServe\(\)'), "server_listen"),
194
+ (re.compile(r'\b\w+\.Run\(\s*"[^"]*"\s*\)'), "gin_run"),
195
+ (re.compile(r'\b\w+\.Start\(\s*"[^"]*"\s*\)'), "echo_start"),
196
+ (re.compile(r'\b\w+\.Listen\(\s*"[^"]*"\s*\)'), "fiber_listen"),
197
+ (re.compile(r'\bgrpc\.NewServer\('), "grpc_server"),
198
+ ]
199
+
200
+ # Secrets — Go env access patterns
201
+ SECRET_PATTERNS: list[re.Pattern[str]] = [
202
+ re.compile(
203
+ r'\bos\.Getenv\(\s*"([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
204
+ ),
205
+ re.compile(
206
+ r'\bos\.LookupEnv\(\s*"([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
207
+ ),
208
+ re.compile(
209
+ r'\bgodotenv\.\w+\(.*?"([A-Z0-9_]*(SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
210
+ re.DOTALL,
211
+ ),
212
+ re.compile(
213
+ r'\bviper\.(?:GetString|Get)\(\s*"([A-Za-z0-9_.]*(?:secret|token|key|password|pass|pwd)[A-Za-z0-9_.]*)"',
214
+ re.IGNORECASE,
215
+ ),
216
+ ]
217
+
218
+
219
+ # Kept rather than attackmap.sdk.line_snippet: this takes a match offset and
220
+ # splits on "\n" only, so it stays consistent with line_of() on files that
221
+ # contain form feeds or other str.splitlines() separators, and it costs
222
+ # O(line) per match instead of O(file).
223
+ def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
224
+ line_start = content.rfind("\n", 0, offset) + 1
225
+ line_end = content.find("\n", offset)
226
+ if line_end == -1:
227
+ line_end = len(content)
228
+ line = content[line_start:line_end].strip()
229
+ if len(line) > max_chars:
230
+ line = line[: max_chars - 1] + "…"
231
+ return line
232
+
233
+
234
+ def _literal(match: re.Match, group: int) -> str:
235
+ """The path of a ``LIT`` alternation starting at ``group`` (the
236
+ interpreted-string group, then the raw-string one)."""
237
+ value = match.group(group)
238
+ return value if value is not None else match.group(group + 1)
239
+
240
+
241
+ def _split_mux_pattern(pattern: str) -> tuple[str | None, str, str] | None:
242
+ """``(method, host, path)`` of a net/http ServeMux pattern
243
+ ``[METHOD ][HOST]/[PATH]`` (Go 1.22; older patterns are a path, or a host
244
+ and a path). None when it isn't one."""
245
+ match = MUX_PATTERN_SYNTAX.fullmatch(pattern)
246
+ if not match:
247
+ return None
248
+ method, host, path = match.groups()
249
+ if host and not MUX_PATTERN_HOST.fullmatch(host):
250
+ return None
251
+ return method, host, path
252
+
253
+
254
+ def _module_name_from_gomod(gomod_path: Path, root: Path | None = None) -> str | None:
255
+ text = read_source(gomod_path, root=root)
256
+ if text is None:
257
+ return None
258
+ match = re.search(r"^\s*module\s+([^\s]+)\s*$", text, re.MULTILINE)
259
+ if match:
260
+ return match.group(1).strip()
261
+ return None
262
+
263
+
264
+ # Frameworks whose route extractor uses chi/echo title-case shape; we only call
265
+ # the relevant extractor when the file looks like it's using that framework, to
266
+ # avoid double-counting (chi.Get, echo.Get, fiber.Get all match the same regex).
267
+ def _file_is_chi(content: str) -> bool:
268
+ return "go-chi/chi" in content or "chi.NewRouter" in content or "chi.NewMux" in content
269
+
270
+
271
+ def _file_is_echo(content: str) -> bool:
272
+ return "labstack/echo" in content or "echo.New(" in content
273
+
274
+
275
+ def _file_is_fiber(content: str) -> bool:
276
+ return "gofiber/fiber" in content or "fiber.New(" in content
277
+
278
+
279
+ def _file_is_gin(content: str) -> bool:
280
+ return "gin-gonic/gin" in content or "gin.Default(" in content or "gin.New(" in content or "gin.Engine" in content
281
+
282
+
283
+ def _file_is_gorilla_mux(content: str) -> bool:
284
+ return "gorilla/mux" in content or "mux.NewRouter(" in content
285
+
286
+
287
+ def _file_is_net_http(content: str) -> bool:
288
+ return "net/http" in content or "http.HandleFunc" in content
289
+
290
+
291
+ class GoAnalyzer:
292
+ metadata = AnalyzerMetadata(
293
+ name="go",
294
+ display_name="Go Analyzer",
295
+ version="0.2.0",
296
+ description="Go ecosystem analyzer covering net/http, chi, gin, echo, fiber, gorilla/mux, and common DB/auth packages.",
297
+ scope="Go modules and workspaces. go.mod-bearing repos are auto-detected; pure .go trees also work.",
298
+ targets=["go", "golang", "gin", "chi", "echo", "fiber"],
299
+ languages=["go"],
300
+ priority=20,
301
+ experimental=False,
302
+ enabled_by_default=True,
303
+ )
304
+
305
+ @property
306
+ def name(self) -> str:
307
+ return self.metadata.name
308
+
309
+ # ---------- Public entry points ----------
310
+
311
+ def detect(self, repo_path: str | Path) -> bool:
312
+ root = Path(repo_path).resolve()
313
+ if not root.exists() or not root.is_dir():
314
+ return False
315
+ if (root / "go.mod").exists() or (root / "go.sum").exists():
316
+ return True
317
+ # Any nested go.mod or .go file; stop at the first one.
318
+ return next(iter_repo_files(root, suffixes=CODE_SUFFIXES, names={"go.mod"}, skip_dirs=SKIP_DIRS), None) is not None
319
+
320
+ def analyze(self, repo_path: str | Path) -> ScanResult:
321
+ root = Path(repo_path).resolve()
322
+ result = ScanResult(root=str(root))
323
+ if not root.exists() or not root.is_dir():
324
+ return result
325
+
326
+ module_name = _module_name_from_gomod(root / "go.mod", root)
327
+ if module_name:
328
+ self._append_unique_service(result, f"module:{module_name}", "go.mod")
329
+
330
+ # `*_test.go` and `testdata/` are test code: httptest registrations in
331
+ # them are not production routes (#2). ATTACKMAP_INCLUDE_TESTS opts in.
332
+ include_tests = bool(os.environ.get(INCLUDE_TESTS_ENV))
333
+ skip_dirs = SKIP_DIRS if include_tests else SKIP_DIRS | TEST_DATA_DIRS
334
+ for file_path in iter_repo_files(
335
+ root, suffixes=CODE_SUFFIXES, skip_dirs=skip_dirs, include_tests=include_tests
336
+ ):
337
+ content = read_source(file_path, root=root)
338
+ if content is None:
339
+ continue
340
+
341
+ result.files_scanned += 1
342
+ if "go" not in result.languages:
343
+ result.languages.append("go")
344
+
345
+ relative = rel(file_path, root)
346
+ self._extract_routes(content, relative, result)
347
+ self._extract_databases(content, relative, result)
348
+ self._extract_auth(content, relative, result)
349
+ self._extract_secrets(content, relative, result)
350
+ self._extract_external_calls(content, relative, result)
351
+ self._extract_frameworks(content, relative, result)
352
+ self._extract_entrypoints(content, relative, result)
353
+ self._infer_service_role(content, relative, result)
354
+
355
+ result.languages.sort()
356
+ return result
357
+
358
+ # ---------- Extractors ----------
359
+
360
+ def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
361
+ is_gin = _file_is_gin(content)
362
+ is_chi = _file_is_chi(content)
363
+ is_echo = _file_is_echo(content)
364
+ is_fiber = _file_is_fiber(content)
365
+ is_gorilla = _file_is_gorilla_mux(content)
366
+ is_net_http = _file_is_net_http(content)
367
+ routers: list[ra.FileRouters] = [] # built on first use
368
+ # Route keys include the file and each file is read once, so a fresh
369
+ # index per file is complete: key -> the route's index in result.routes.
370
+ seen: dict[tuple[str, str, str], int] = {}
371
+
372
+ def file_routers() -> ra.FileRouters:
373
+ if not routers:
374
+ routers.append(ra.FileRouters(content))
375
+ return routers[0]
376
+
377
+ def emit(
378
+ receiver: str, start: int, path: str, methods: list[str], local: list[ra.Guard] | None,
379
+ *, ordered: bool = True,
380
+ ) -> None:
381
+ """Record a route registered on ``receiver`` at ``start``, its path
382
+ joined with the prefixes of the groups it is registered in."""
383
+ resolution = file_routers().resolve_route(receiver, start, path, local, ordered=ordered)
384
+ full = resolution.path if resolution.path is not None else path
385
+ if not full:
386
+ return # an empty path under a prefix we couldn't read
387
+ line = line_of(content, start)
388
+ for method in methods:
389
+ self._append_unique_route(
390
+ result, full, method, relative, line,
391
+ auth=resolution.auth, guards=resolution.guards, guard_evidence=resolution.evidence,
392
+ seen=seen,
393
+ )
394
+
395
+ def local_guards(start: int, paren: int, first: int, last: int | None) -> list[ra.Guard] | None:
396
+ """Auth middleware among a registration's arguments [first:last];
397
+ the evidence quotes the registration from ``start``."""
398
+ close = ra.matching_close(content, paren)
399
+ if close < 0:
400
+ return None
401
+ context = content[start : min(close + 1, start + 400)]
402
+ args = ra.split_args(content, paren, close)[first:last]
403
+ return [g for s, e in args if (g := ra.guard_from_arg(content, s, e, context))]
404
+
405
+ def handler_guards(start: int, paren: int, index: int) -> list[ra.Guard] | None:
406
+ """The handler argument ``index`` when it is wrapped in auth
407
+ middleware (``requireAuth(h)``)."""
408
+ close = ra.matching_close(content, paren)
409
+ if close < 0:
410
+ return None
411
+ args = ra.split_args(content, paren, close)
412
+ if not -len(args) <= index < len(args):
413
+ return []
414
+ s, e = args[index]
415
+ guard = ra.wrapper_guard(content, s, e, content[start : min(close + 1, start + 400)])
416
+ return [guard] if guard else []
417
+
418
+ def gorilla_route(match: re.Match) -> None:
419
+ """One gorilla route chain: its path (``HandleFunc``/``Handle``/``Path``/
420
+ ``PathPrefix``), ``.Methods(...)`` and handler argument."""
421
+ segments = ra.walk_chain(content, match.start(1) + len(match.group(1)))
422
+ path = ""
423
+ methods: list[str] = []
424
+ handler: tuple[int, int, int] | None = None # (paren, close, arg index)
425
+ for name, open_idx, close in segments:
426
+ if name == "Subrouter":
427
+ return # a router (route_auth binds it), not a route
428
+ args = ra.split_args(content, open_idx, close)
429
+ if name in _GORILLA_PATH_CALLS:
430
+ literal = ra.string_literal(content[args[0][0] : args[0][1]]) if args else None
431
+ if literal is None:
432
+ return # a path we can't read
433
+ if name in ("HandleFunc", "Handle") and (" " in literal or "\t" in literal):
434
+ pattern = _split_mux_pattern(literal) # net/http's Go 1.22 syntax
435
+ if pattern is None:
436
+ return
437
+ if pattern[0]:
438
+ methods.append(pattern[0])
439
+ literal = pattern[2]
440
+ path = ra.join_paths(path, literal)
441
+ elif name == "Methods":
442
+ for s, e in args:
443
+ text = content[s:e]
444
+ value = ra.string_literal(text)
445
+ const = _HTTP_METHOD_CONST.match(text)
446
+ if value is not None:
447
+ methods += [m.strip().upper() for m in value.split(",") if m.strip()]
448
+ elif const:
449
+ methods.append(const.group(1).upper())
450
+ if name in _GORILLA_TERMINALS and handler is None:
451
+ handler = (open_idx, close, _GORILLA_TERMINALS[name])
452
+ if handler is None or not path:
453
+ return
454
+ open_idx, close, index = handler
455
+ args = ra.split_args(content, open_idx, close)
456
+ if index < len(args) and file_routers().is_router(content[args[index][0] : args[index][1]], match.start()):
457
+ return # http.Handle("/", r): serves the router itself
458
+ local: list[ra.Guard] = []
459
+ if index < len(args):
460
+ s, e = args[index]
461
+ context = content[match.start() : min(close + 1, match.start() + 400)]
462
+ guard = ra.wrapper_guard(content, s, e, context)
463
+ local = [guard] if guard else []
464
+ emit(match.group(1), match.start(), path, list(dict.fromkeys(methods)) or ["ANY"], local, ordered=False)
465
+
466
+ # Gin / echo: uppercase verb method (r.GET, e.POST, ...). Echo's runtime API
467
+ # is the same uppercase shape as gin's despite different surface labels.
468
+ # Route middleware: gin `r.POST(path, mw..., h)`, echo `e.POST(path, h, mw...)`.
469
+ if is_gin or is_echo:
470
+ echo_args = is_echo and not is_gin
471
+ for match in GIN_ROUTE_PATTERN.finditer(content):
472
+ paren = match.end(2)
473
+ local = local_guards(match.start(), paren, 2, None) if echo_args else local_guards(match.start(), paren, 1, -1)
474
+ emit(match.group(1), match.start(), _literal(match, 3), [match.group(2)], local)
475
+
476
+ # Chi / fiber: title-case verb method (r.Get, app.Post, ...). Only run when
477
+ # we know which framework is in this file; otherwise we'd mis-attribute
478
+ # generic `.Get(...)` calls on non-router types.
479
+ # Route middleware: fiber `app.Post(path, mw..., h)`; chi uses `.With(mw)`
480
+ # or wraps the handler (`r.Post("/x", requireAuth(h))`).
481
+ if is_chi or is_fiber:
482
+ for match in CHI_ECHO_ROUTE_PATTERN.finditer(content):
483
+ if match.group(1) in NON_ROUTER_RECEIVERS:
484
+ continue
485
+ paren = match.end(2)
486
+ if is_fiber and not is_chi:
487
+ local = local_guards(match.start(), paren, 1, -1)
488
+ else:
489
+ local = handler_guards(match.start(), paren, -1)
490
+ emit(match.group(1), match.start(), _literal(match, 3), [match.group(2).upper()], local)
491
+
492
+ if is_chi:
493
+ # r.Method("GET", "/path", h)
494
+ for match in CHI_METHOD_PATTERN.finditer(content):
495
+ if match.group(1) in NON_ROUTER_RECEIVERS:
496
+ continue
497
+ paren = content.find("(", match.start(1) + len(match.group(1)))
498
+ local = handler_guards(match.start(), paren, -1)
499
+ emit(match.group(1), match.start(), _literal(match, 3), [match.group(2)], local)
500
+
501
+ # Chi inline middleware: r.With(mw...)[.With(mw...)].Post("/path", h).
502
+ for match in CHI_WITH_PATTERN.finditer(content):
503
+ if match.group(1) in NON_ROUTER_RECEIVERS:
504
+ continue
505
+ local: list[ra.Guard] = []
506
+ paren = match.end() - 1
507
+ while True:
508
+ guards = local_guards(match.start(), paren, 0, None)
509
+ close = ra.matching_close(content, paren)
510
+ if guards is None:
511
+ break
512
+ local += guards
513
+ chained = CHI_WITH_CHAIN_PATTERN.match(content, close + 1)
514
+ if chained:
515
+ paren = chained.end() - 1
516
+ continue
517
+ verb = CHI_WITH_VERB_PATTERN.match(content, close + 1)
518
+ if verb:
519
+ emit(match.group(1), match.start(), _literal(verb, 2), [verb.group(1).upper()], local)
520
+ break
521
+
522
+ # Gorilla/mux: a chain of route-builder calls ending in a handler.
523
+ # Middleware is `r.Use(...)` on the router or a subrouter, which gorilla
524
+ # applies to all of its routes whenever they were registered.
525
+ if is_gorilla:
526
+ for match in GORILLA_CHAIN_START.finditer(content):
527
+ gorilla_route(match)
528
+
529
+ # net/http stdlib: http.HandleFunc / mux.Handle, Go 1.22 method and host
530
+ # patterns — but only when the file isn't already covered by gorilla/mux
531
+ # (whose chain extractor reads the same calls).
532
+ if is_net_http and not is_gorilla:
533
+ for match in NET_HTTP_HANDLE_PATTERN.finditer(content):
534
+ pattern = _split_mux_pattern(_literal(match, 2))
535
+ if pattern is None:
536
+ continue
537
+ method, host, path = pattern
538
+ paren = content.find("(", match.start(1) + len(match.group(1)))
539
+ close = ra.matching_close(content, paren)
540
+ args = ra.split_args(content, paren, close) if close >= 0 else []
541
+ if len(args) >= 2 and file_routers().is_router(content[args[1][0] : args[1][1]], match.start()):
542
+ continue # http.Handle("/", r): serves a router, whose routes are extracted
543
+ local = handler_guards(match.start(), paren, 1)
544
+ emit(match.group(1), match.start(), path, [method or "ANY"], local)
545
+ if host:
546
+ self._append_unique_service(
547
+ result, f"route_host:{host}", relative,
548
+ line=line_of(content, match.start()), evidence=_line_snippet(content, match.start()),
549
+ )
550
+
551
+ def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
552
+ for pattern, kind in DB_PATTERNS:
553
+ match = pattern.search(content)
554
+ if match is None:
555
+ continue
556
+ self._append_unique_database(
557
+ result, kind, relative,
558
+ line_of(content, match.start()),
559
+ _line_snippet(content, match.start()),
560
+ )
561
+
562
+ def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
563
+ for pattern, hint, confidence in AUTH_PATTERNS:
564
+ match = pattern.search(content)
565
+ if match is None:
566
+ continue
567
+ self._append_unique_auth(
568
+ result, hint, relative,
569
+ line_of(content, match.start()),
570
+ _line_snippet(content, match.start()),
571
+ confidence,
572
+ )
573
+
574
+ def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
575
+ for pattern in SECRET_PATTERNS:
576
+ for match in pattern.finditer(content):
577
+ groups = match.groups()
578
+ name = groups[0] if groups and groups[0] else "unknown"
579
+ self._append_unique_secret(
580
+ result, name, relative,
581
+ line_of(content, match.start()),
582
+ _line_snippet(content, match.start()),
583
+ )
584
+
585
+ def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
586
+ for pattern in OUTBOUND_PATTERNS:
587
+ for match in pattern.finditer(content):
588
+ target = match.group(1)
589
+ if not (target.startswith("http://") or target.startswith("https://")):
590
+ continue
591
+ self._append_unique_external(
592
+ result, target, relative,
593
+ line_of(content, match.start()),
594
+ _line_snippet(content, match.start()),
595
+ )
596
+
597
+ def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
598
+ for pattern, name in FRAMEWORK_PATTERNS:
599
+ match = pattern.search(content)
600
+ if match is None:
601
+ continue
602
+ self._append_unique_framework(
603
+ result, name, relative,
604
+ line_of(content, match.start()),
605
+ _line_snippet(content, match.start()),
606
+ )
607
+
608
+ def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
609
+ for pattern, hint in ENTRYPOINT_PATTERNS:
610
+ match = pattern.search(content)
611
+ if match is None:
612
+ continue
613
+ self._append_unique_entrypoint(
614
+ result, hint, relative,
615
+ line_of(content, match.start()),
616
+ _line_snippet(content, match.start()),
617
+ )
618
+
619
+ def _infer_service_role(self, content: str, relative: str, result: ScanResult) -> None:
620
+ haystack = (relative + " " + content[:500]).lower()
621
+ role: str | None = None
622
+ if any(token in haystack for token in ("worker", "consumer", "queue", "background", "cronjob")):
623
+ role = "worker"
624
+ elif any(token in haystack for token in ("api", "server", "handler", "router", "controller")):
625
+ role = "api"
626
+ elif any(token in haystack for token in ("client", "sdk")):
627
+ role = "client"
628
+ if role:
629
+ self._append_unique_service(result, f"service_role:{role}", relative)
630
+
631
+ # ---------- Append helpers (dedup-aware) ----------
632
+
633
+ @staticmethod
634
+ def _append_unique_route(
635
+ result: ScanResult,
636
+ path: str,
637
+ method: str,
638
+ file: str,
639
+ line: int | None,
640
+ *,
641
+ auth: str = ra.UNKNOWN,
642
+ guards: list[str] | None = None,
643
+ guard_evidence: str | None = None,
644
+ seen: dict[tuple[str, str, str], int] | None = None,
645
+ ) -> int | None:
646
+ """Append a route once per (path, method, file); return its index, or
647
+ None when it was already recorded. ``seen`` maps the keys recorded so
648
+ far (the caller's, for one file) to their index, so a file with many
649
+ routes stays linear; without it the result's routes are scanned.
650
+
651
+ A key registered again with a different auth becomes ``unknown``:
652
+ which registration serves the request depends on the framework (echo
653
+ replaces the handler, chi prefers a static route over a mount), so
654
+ neither guard can be trusted."""
655
+ key = (path, method, file)
656
+ if seen is not None:
657
+ index = seen.get(key)
658
+ else:
659
+ index = next((i for i, r in enumerate(result.routes) if (r.path, r.method, r.file) == key), None)
660
+ if index is not None:
661
+ existing = result.routes[index]
662
+ if (existing.auth, existing.guards) != (auth, list(guards or [])) and existing.auth != ra.UNKNOWN:
663
+ result.routes[index] = Route(path=existing.path, method=existing.method, file=existing.file, line=existing.line)
664
+ return None
665
+ result.routes.append(
666
+ Route(
667
+ path=path, method=method, file=file, line=line,
668
+ auth=auth, guards=list(guards or []), guard_evidence=guard_evidence,
669
+ )
670
+ )
671
+ if seen is not None:
672
+ seen[key] = len(result.routes) - 1
673
+ return len(result.routes) - 1
674
+
675
+ @staticmethod
676
+ def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
677
+ key = (kind, file)
678
+ if any((item.kind, item.file) == key for item in result.databases):
679
+ return
680
+ result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
681
+
682
+ @staticmethod
683
+ def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
684
+ key = (hint, file)
685
+ if any((item.hint, item.file) == key for item in result.auth_hints):
686
+ return
687
+ result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
688
+
689
+ @staticmethod
690
+ def _append_unique_secret(result: ScanResult, name: str, file: str, line: int | None, evidence: str | None) -> None:
691
+ key = (name, file)
692
+ if any((item.name, item.file) == key for item in result.secret_hints):
693
+ return
694
+ result.secret_hints.append(SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85))
695
+
696
+ @staticmethod
697
+ def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
698
+ key = (target, file)
699
+ if any((item.target, item.file) == key for item in result.external_calls):
700
+ return
701
+ result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
702
+
703
+ @staticmethod
704
+ def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
705
+ key = (hint, file)
706
+ if any((item.hint, item.file) == key for item in result.framework_hints):
707
+ return
708
+ result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
709
+
710
+ @staticmethod
711
+ def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
712
+ key = (hint, file)
713
+ if any((item.hint, item.file) == key for item in result.entrypoint_hints):
714
+ return
715
+ result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
716
+
717
+ @staticmethod
718
+ def _append_unique_service(
719
+ result: ScanResult, hint: str, file: str, *, line: int | None = None, evidence: str | None = None
720
+ ) -> None:
721
+ key = (hint, file)
722
+ if any((item.hint, item.file) == key for item in result.service_hints):
723
+ return
724
+ result.service_hints.append(ServiceHint(hint=hint, file=file, line=line, evidence_text=evidence))
725
+
726
+
727
+ __all__ = ["GoAnalyzer"]