From 8de54809f8b85e87b45a27f93d564775378d4990 Mon Sep 17 00:00:00 2001 From: Kunal Jaura Date: Mon, 14 Sep 2026 07:12:16 -0700 Subject: [PATCH] feat(go): per-route auth detection + gopls auth-flow refinement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings Go auth analysis to parity with the JS/TS/Python/Java path. Mapper (per-route, not file-level): - has_auth_check is now decided per route — a router/group `.Use(authMw)` guards its routes; every other route carries its handler body in handler_source for the analyzer/LSP to judge. One guarded handler no longer vouches for an unguarded neighbour in the same file. - Fixed a real mis-mapping the redesign surfaced: `r.Header.Get("Authorization")` and `c.Get("user")` were mapped as routes. A registration must pass a handler after the path, which separates `r.GET("/p", h)` from `.Get("X")`. Shared auth patterns extended with Go idioms: - identity: Header.Get("Authorization"), RequireAuth/Authenticate/VerifyToken/ session.Get/c.Get("user"), r.Context().Value(...) - enforcement: http.StatusUnauthorized/Forbidden, WriteHeader(401/403), Gin AbortWithStatus, echo.NewHTTPError(401), Fiber status - LSP terminals: the identity idioms above + jwt.Parse Tracer Go strategy (auth_flow_tracer): - New per-route Go path: router/group middleware -> inline identity+refusal -> a cross-file auth helper resolved via gopls go-to-definition, whose body is then checked for an auth terminal. - Uses the RouteEntry's own content (Go does not depend on file_index), fixing the tracer returning early when the index did not resolve the Go path. Verified live end-to-end (real `isitsecure` scan of Go apps): - unprotected route -> flagged missing-auth; inline-guarded route -> verified. - auth enforced ONLY inside a cross-file helper: route_analyzer's regex false-positives it, then the LSP follows the helper with gopls and `LSP validation: 1 findings suppressed` — the JS-level refinement, for Go. - Full suite: 2610 passed, 1 xfailed. README marks Go Auth Detection as Yes; lsp-setup.md documents the three modes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EZ4QoTqRoYWE25CNoV2Wfy --- README.md | 6 +- docs/lsp-setup.md | 26 ++-- .../engine/code_analysis/go_route_mapper.py | 141 +++++++++++++----- .../code_analysis/lsp/auth_flow_tracer.py | 101 ++++++++++++- isitsecure/engine/constants.py | 30 ++++ .../test_go_auth_flow_tracer.py | 121 +++++++++++++++ .../test_go_route_mapper.py | 82 +++++++++- 7 files changed, 447 insertions(+), 60 deletions(-) create mode 100644 tests/engine/test_code_analysis/test_go_auth_flow_tracer.py diff --git a/README.md b/README.md index d587d40..2e5a81d 100644 --- a/README.md +++ b/README.md @@ -311,12 +311,10 @@ record to prove it could is not worth the finding. | **TypeScript/JavaScript** (Next.js, Express, tRPC, GraphQL) | Yes | Yes | Yes (npm) | Yes | | **Python** (Django, FastAPI, Flask) | Yes | Yes | Yes (pip) | Yes | | **Java/Kotlin** (Spring Boot) | Yes | Yes | Yes (Maven, Gradle) | Yes | -| **Go** (net/http, Gin, Echo, chi, gorilla) | Yes | Basic¹ | No | Yes | +| **Go** (net/http, Gin, Echo, chi, gorilla) | Yes | Yes | No | Yes | | **Ruby, Rust, PHP, etc.** | No | No | No | Yes (DAST works against any HTTP API) | -DAST scanners test live HTTP endpoints regardless of backend language. SAST route mapping, auth detection, and dependency scanning are language-specific. - -¹ Go SAST covers the full injection taint floor (SQLi, command injection, SSRF, path traversal) and route mapping, and the Go LSP (gopls) is wired into the scan. Auth detection is currently coarse (file-level) — the LSP auth-flow tracer's per-route verification recognizes JS/TS, Python, and Java idioms but not yet Go's, so it does not refine Go auth findings. Deeper Go auth tracing is a tracked follow-up. +DAST scanners test live HTTP endpoints regardless of backend language. SAST route mapping, auth detection, and dependency scanning are language-specific. Go auth detection is per-route (router/group middleware, in-handler checks) and the gopls LSP refines it — following a cross-file auth helper via go-to-definition to confirm a guard or suppress a false "missing auth". Dependency (go.mod) scanning is the one Go gap. ## Output Formats diff --git a/docs/lsp-setup.md b/docs/lsp-setup.md index 6d64700..8c2f837 100644 --- a/docs/lsp-setup.md +++ b/docs/lsp-setup.md @@ -446,21 +446,29 @@ gopls version ### What Gets Traced -gopls spawns during a Go scan and traces the mapped routes, but the auth-flow -tracer's per-route auth **verification** currently recognizes JS/TS, Python, and -Java idioms — not yet Go's middleware/handler patterns. So on Go projects the -LSP is initialized and runs, but does not yet refine (suppress/boost) auth -findings. The working Go SAST today is the injection taint floor (SQLi, command -injection, SSRF, path traversal) plus route mapping. Deeper Go auth tracing — +The auth-flow tracer verifies Go routes per handler, in three ways: ```go -// (planned) Does the AuthMiddleware actually run before this handler? +// 1. Router/group middleware guards every route on it. api := r.Group("/api/v1") api.Use(AuthMiddleware()) api.GET("/users/:id", getUser) -``` -— is a tracked follow-up. +// 2. The handler identifies a caller AND refuses, inline. +func getUser(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + http.Error(w, "no", http.StatusUnauthorized); return + } +} + +// 3. The handler's auth is a helper defined in ANOTHER file — gopls +// go-to-definition follows it and reads its auth terminal. This is what +// lets the LSP suppress a false "missing auth" that a per-file regex, +// seeing only the call, would raise. +func secret(w http.ResponseWriter, r *http.Request) { + if !mustAuth(w, r) { return } // mustAuth lives in mw.go +} +``` ## How the Server Is Chosen diff --git a/isitsecure/engine/code_analysis/go_route_mapper.py b/isitsecure/engine/code_analysis/go_route_mapper.py index 56bf094..fde8eaa 100644 --- a/isitsecure/engine/code_analysis/go_route_mapper.py +++ b/isitsecure/engine/code_analysis/go_route_mapper.py @@ -15,7 +15,6 @@ from isitsecure.engine.code_analysis.protocols import RouteEntry from isitsecure.engine.code_analysis.shared_utils import ( - has_auth_patterns, normalize_route_pattern, should_skip_path, ) @@ -63,28 +62,18 @@ class GoRouteMapper: re.MULTILINE, ) - # File-level auth-enforcement signals. Kept auth-specific (not a bare - # ``.Use(``, which is also logging/CORS) so a file without auth isn't - # miscredited; the LSP auth-flow tracer refines per-route afterwards. - AUTH_PATTERNS = ( - "AuthMiddleware", - "AuthRequired", - "RequireAuth", - "RequireLogin", - "Authenticate", - "Authorized", - "middleware.JWT", - "jwtMiddleware", - "JWTAuth", - "IsAuthenticated", - "GetUserID", - "c.Get(\"user\")", - "r.Context().Value", - "Authorization", - "VerifyToken", - "ValidateToken", - "session.Get", + # `recv.Use(Middleware())` — router/group-wide middleware application. + USE_PATTERN = re.compile(r"""(\w+)\.Use\s*\(([^)]*)\)""", re.MULTILINE) + + # A middleware/handler name that names authentication. Deliberately + # auth-specific — a bare `.Use(` is also logging/CORS/recovery, which must + # not credit a route with auth. + _AUTH_NAME = re.compile( + r"(?i)(auth|login|jwt|token|session|protected|require[-_]?(?:auth|login))" ) + # A handler is credited with auth only when its body both looks up an + # identity AND refuses — checked by the route analyzer via handler_source; + # the mapper only decides the definite router/group-middleware case itself. def map_routes(self, clone_path: str) -> list[RouteEntry]: """Scan Go source for route definitions.""" @@ -117,37 +106,109 @@ def _has_routes(self, content: str) -> bool: def _extract_routes(self, file_path: str, content: str) -> list[RouteEntry]: routes: list[RouteEntry] = [] - has_auth = has_auth_patterns(content, self.AUTH_PATTERNS) groups = self._group_prefixes(content) + auth_receivers = self._auth_receivers(content) - for match in self.VERB_PATTERN.finditer(content): - recv, verb = match.group(1), match.group(2) - path = match.group(3) or match.group(4) # "..." or `...` + def _entry(recv: str, methods: list[str], path: str, body: str): full = normalize_route_pattern(self._prefix(groups, recv) + path) - routes.append(RouteEntry( + # A router/group with auth middleware guards every route on it — + # that is definite. Otherwise leave the per-route verdict to the + # route analyzer, which re-examines handler_source for an + # identity-check-AND-refusal (and the LSP tracer follows helpers + # across files). None of `content`'s other handlers can vouch here. + group_auth = recv in auth_receivers + return RouteEntry( file_path=file_path, - http_methods=[verb.upper()], + http_methods=methods, route_pattern=full, - has_auth_check=has_auth, + has_auth_check=True if group_auth else False, content=content, - )) + handler_source=body, + ) + + for match in self.VERB_PATTERN.finditer(content): + recv, verb = match.group(1), match.group(2) + path = match.group(3) or match.group(4) + # A real route registration passes a handler after the path — this + # separates `r.GET("/p", h)` from `r.Header.Get("X")` / `c.Get("u")`. + is_route, body = self._resolve_handler(content, match.end()) + if not is_route: + continue + routes.append(_entry(recv, [verb.upper()], path, body)) for match in self.HANDLE_PATTERN.finditer(content): recv = match.group(1) path = match.group(2) or match.group(3) - full = normalize_route_pattern(self._prefix(groups, recv) + path) - routes.append(RouteEntry( - file_path=file_path, - # net/http muxes accept any method — record the wildcard so the - # analyzer treats every verb as reachable. - http_methods=["ANY"], - route_pattern=full, - has_auth_check=has_auth, - content=content, - )) + is_route, body = self._resolve_handler(content, match.end()) + if not is_route: + continue + # net/http muxes accept any method — wildcard so every verb reads + # as reachable. + routes.append(_entry(recv, ["ANY"], path, body)) return routes + def _auth_receivers(self, content: str) -> set[str]: + """Router/group variables that apply an auth-naming middleware via + ``.Use(...)`` — those guard every route registered on them.""" + receivers: set[str] = set() + for match in self.USE_PATTERN.finditer(content): + recv, arg = match.group(1), match.group(2) + if self._AUTH_NAME.search(arg): + receivers.add(recv) + return receivers + + def _resolve_handler(self, content: str, after: int) -> tuple[bool, str]: + """Resolve the handler following a route registration's path arg. + + Returns ``(is_route, handler_source)``: + - named handler ``, getUser)`` → (True, body of ``func getUser``) — or + (True, "") when it is package-qualified/defined elsewhere (the LSP + tracer resolves those cross-file); + - inline ``, func(...) {...}`` → (True, the literal's body); + - no handler (``r.Header.Get("X")``, ``c.Get("user")``) → (False, ""). + """ + tail = content[after:after + 160] + m = re.match(r"\s*,\s*([A-Za-z_]\w*(?:\.\w+)?)\s*[),]", tail) + if m: + return True, self._func_body(content, m.group(1)) + inline = re.match(r"\s*,\s*func\s*\(", tail) + if inline: + brace = content.find("{", after + inline.end()) + return True, self._brace_block(content, brace) if brace != -1 else "" + return False, "" + + @classmethod + def _func_body(cls, content: str, handler: str) -> str: + """The body of ``func (...) { ... }`` in this file, or "". + + Package-qualified handlers (``pkg.Handler``) live in another file and + are left to the LSP tracer's go-to-definition; here they resolve to "". + """ + if not handler or "." in handler: + return "" + m = re.search(rf"\bfunc\s+{re.escape(handler)}\s*\(", content) + if not m: + return "" + brace = content.find("{", m.end()) + if brace == -1: + return "" + block = cls._brace_block(content, brace) + return content[m.start():brace] + block if block else "" + + @staticmethod + def _brace_block(content: str, brace: int) -> str: + """The ``{...}`` block starting at ``brace``, brace-matched, or "".""" + depth = 0 + for i in range(brace, len(content)): + if content[i] == "{": + depth += 1 + elif content[i] == "}": + depth -= 1 + if depth == 0: + return content[brace:i + 1] + return content[brace:] # unbalanced — return what we have + def _group_prefixes(self, content: str) -> dict[str, str]: """Map a group variable to its path prefix (best-effort, one level).""" groups: dict[str, str] = {} diff --git a/isitsecure/engine/code_analysis/lsp/auth_flow_tracer.py b/isitsecure/engine/code_analysis/lsp/auth_flow_tracer.py index d619a2e..5293ef1 100644 --- a/isitsecure/engine/code_analysis/lsp/auth_flow_tracer.py +++ b/isitsecure/engine/code_analysis/lsp/auth_flow_tracer.py @@ -156,12 +156,20 @@ async def _trace_file( 3. Auth decorators: @UseGuards, @login_required, @PreAuthorize 4. Inline auth calls, following project-local helpers one hop. """ + abs_path = self._resolve_path(file_path) + + # Go has its own idioms (router/group .Use middleware, in-handler and + # cross-function auth helpers) that the Express mount model doesn't fit. + # Dispatched before the file_index lookup below: each Go RouteEntry + # already carries its file `content` and `handler_source` from the + # mapper, so Go does not depend on file_index resolving this path. + if file_path.endswith(".go"): + return await self._trace_go_file(routes, abs_path) + content = self._get_file_content(file_path) if not content: return {} - abs_path = self._resolve_path(file_path) - # Middleware applied without a path guards every route on the router, # so it settles the whole file regardless of what the mounts say. router_wide = await self._trace_express_auth(content, abs_path) @@ -188,6 +196,95 @@ async def _trace_file( result = await self._trace_whole_file(content, abs_path) return self._for_every_method(routes, result) + # ------------------------------------------------------------------ + # Go strategy: per-route, from the handler the mapper recorded + # ------------------------------------------------------------------ + + async def _trace_go_file( + self, routes: list[RouteEntry], abs_path: str + ) -> dict[tuple[str, str], AuthFlowResult]: + """Per-route auth verdict for a Go file. + + Each Go route carries its own ``handler_source`` AND its file + ``content`` (from GoRouteMapper), so the verdict is per handler — one + guarded handler never vouches for an unguarded neighbour in the same + file — and Go does not depend on the file_index. Verification, in order: + 1. router/group ``.Use(authMiddleware)`` — the mapper already set + ``has_auth_check`` True for those; + 2. the handler itself identifies a caller AND refuses (inline); + 3. the handler calls an auth-naming helper that go-to-definition + resolves to a body with an auth terminal — the cross-file case + gopls exists for. + """ + results: dict[tuple[str, str], AuthFlowResult] = {} + for route in routes: + result = await self._verify_go_route(route, route.content, abs_path) + for method in route.http_methods: + results[(method, route.route_pattern)] = result + return results + + async def _verify_go_route( + self, route: RouteEntry, content: str, abs_path: str + ) -> AuthFlowResult: + # 1. Router/group middleware guard (definite, set by the mapper). + if route.has_auth_check is True: + return AuthFlowResult( + has_verified_auth=True, + auth_method="router-middleware", + middleware_chain=["Use"], + confidence=LSPConfig.CONFIDENCE_LSP_CONFIRMED, + trace_depth=0, + ) + + body = route.handler_source or "" + + # 2. Handler identifies AND refuses inline. + terminal = self._find_auth_terminal(body) + if terminal and self._has_enforcement(body): + return AuthFlowResult( + has_verified_auth=True, + auth_method=terminal, + middleware_chain=["inline"], + confidence=LSPConfig.CONFIDENCE_LSP_CONFIRMED, + trace_depth=0, + ) + + # 3. Follow an auth-naming helper the handler calls to its definition. + for name in self._go_auth_helper_calls(body): + match = re.search(rf"\b{re.escape(name)}\s*\(", content) + if not match: + continue + line, char = self._offset_to_position(content, match.start()) + definition = await self._trace_definition_body(abs_path, line, char) + if definition and self._find_auth_terminal(definition): + return AuthFlowResult( + has_verified_auth=True, + auth_method=name, + middleware_chain=[name], + confidence=LSPConfig.CONFIDENCE_LSP_CONFIRMED, + trace_depth=1, + ) + + return AuthFlowResult(confidence=0.5) + + @staticmethod + def _go_auth_helper_calls(body: str) -> list[str]: + """Auth-naming functions the handler calls (``requireAuth(r)`` etc.). + + Deliberately auth-specific so an ordinary helper is not chased, and + deduplicated in call order. The resolved body still has to contain an + auth terminal before the route is credited, so a mis-named function + cannot vouch for auth on its own. + """ + names: list[str] = [] + for match in re.finditer( + r"\b(\w*(?:[Aa]uth|[Ll]ogin|[Tt]oken|[Ss]ession)\w*)\s*\(", body + ): + name = match.group(1) + if name not in names: + names.append(name) + return names[: LSPConfig.MAX_MOUNT_MIDDLEWARE] + async def _verify_route_mount( self, mounts: dict[tuple[str, str], str], diff --git a/isitsecure/engine/constants.py b/isitsecure/engine/constants.py index 5a550bf..0f78d6a 100644 --- a/isitsecure/engine/constants.py +++ b/isitsecure/engine/constants.py @@ -1112,6 +1112,19 @@ class RouteAuthAnalyzerConfig: r'requireAuth\s*\(', r'withAuth\s*\(', r'isAuthenticated', + # --- Go idioms --- + r'\.Header\.Get\s*\(\s*["\']Authorization["\']', # r.Header.Get("Authorization") + r'\.GetHeader\s*\(\s*["\']Authorization["\']', # Gin c.GetHeader("Authorization") + r'\bRequireAuth\s*\(', + r'\bRequireLogin\s*\(', + r'\bAuthenticate\s*\(', + r'\bIsAuthenticated\s*\(', + r'\bVerifyToken\s*\(', + r'\bValidateToken\s*\(', + r'\bParseToken\s*\(', + r'\.Context\s*\(\s*\)\s*\.Value\s*\(', # r.Context().Value(userKey) + r'\bsession\.Get\s*\(', + r'\bc\.Get\s*\(\s*["\']user', # Gin/Echo c.Get("user") ) + SharedPatterns.SIGNATURE_VERIFICATION_PATTERNS # Patterns indicating authorization/ownership check @@ -5009,6 +5022,15 @@ class LSPConfig: r'createServerClient\s*\(', # Passport r'passport\.authenticate\s*\(', + # --- Go token/session verification --- + r'\.Header\.Get\s*\(\s*["\']Authorization["\']', + r'\.GetHeader\s*\(\s*["\']Authorization["\']', + r'\bVerifyToken\s*\(', + r'\bValidateToken\s*\(', + r'\bParseToken\s*\(', + r'jwt\.Parse\w*\s*\(', # jwt.Parse / jwt.ParseWithClaims + r'\bsession\.Get\s*\(', + r'\bc\.Get\s*\(\s*["\']user', # Express auth middleware that verifies for you. The terminal is then # inside the package, which tracing deliberately will not enter, so # the middleware itself has to count as the terminal. @@ -5052,6 +5074,14 @@ class LSPConfig: # Generic error text patterns r'["\']UNAUTHORIZED["\']', r'["\']Unauthorized["\']', + # --- Go idioms --- + r'http\.StatusUnauthorized', # net/http 401 constant + r'http\.StatusForbidden', # net/http 403 constant + r'\.WriteHeader\s*\(\s*401', + r'\.WriteHeader\s*\(\s*403', + r'\.AbortWithStatus\w*\s*\(\s*(?:401|403|http\.Status(?:Unauthorized|Forbidden))', # Gin + r'echo\.NewHTTPError\s*\(\s*(?:401|403|http\.Status(?:Unauthorized|Forbidden))', # Echo + r'fiber\.Status(?:Unauthorized|Forbidden)', # Fiber ) # --- Ownership terminal patterns --- diff --git a/tests/engine/test_code_analysis/test_go_auth_flow_tracer.py b/tests/engine/test_code_analysis/test_go_auth_flow_tracer.py new file mode 100644 index 0000000..f6c1efe --- /dev/null +++ b/tests/engine/test_code_analysis/test_go_auth_flow_tracer.py @@ -0,0 +1,121 @@ +"""Go strategy in the LSP auth-flow tracer. + +Verifies per-route auth verdicts for Go: router/group middleware, inline +identity+refusal, and — the reason gopls is here — a handler whose auth is a +cross-file helper resolved by go-to-definition. A fake LSP client stands in for +gopls so the tests are deterministic. +""" + +from __future__ import annotations + +import pytest + +from isitsecure.engine.code_analysis.lsp.auth_flow_tracer import AuthFlowTracer +from isitsecure.engine.code_analysis.protocols import RouteEntry + + +class _FakeRepo: + clone_path = "/repo" + file_index: dict = {} + + +class _FakeLSP: + """Resolves one symbol name to a canned definition body.""" + def __init__(self, defs: dict[str, str]): + self._defs = defs + self.last_error = None + + async def get_definition(self, file_path, line, character): + # The tracer follows _trace_definition_body which calls get_definition; + # we short-circuit by returning a location the tracer then reads. To + # keep it simple we monkeypatch _trace_definition_body instead. + return None + + +def _tracer(defs=None): + t = AuthFlowTracer(_FakeLSP(defs or {}), _FakeRepo()) + return t + + +def _route(pattern, methods, body="", auth=False, content=""): + return RouteEntry( + file_path="h.go", http_methods=methods, route_pattern=pattern, + has_auth_check=auth, content=content or body, handler_source=body, + ) + + +@pytest.mark.asyncio +async def test_group_middleware_route_is_verified(): + t = _tracer() + r = _route("/api/me", ["GET"], body="func me(){}", auth=True) + res = await t._verify_go_route(r, r.content, "/repo/h.go") + assert res.has_verified_auth is True + assert res.middleware_chain == ["Use"] + + +@pytest.mark.asyncio +async def test_inline_identity_and_refusal_is_verified(): + body = ('func h(w http.ResponseWriter, r *http.Request){ ' + 'if r.Header.Get("Authorization")=="" { ' + 'http.Error(w,"no",http.StatusUnauthorized); return }; w.Write(x) }') + t = _tracer() + r = _route("/x", ["GET"], body=body, auth=False) + res = await t._verify_go_route(r, r.content, "/repo/h.go") + assert res.has_verified_auth is True + assert res.trace_depth == 0 + + +@pytest.mark.asyncio +async def test_unprotected_handler_is_not_verified(): + body = 'func h(w http.ResponseWriter, r *http.Request){ w.Write(x) }' + t = _tracer() + r = _route("/open", ["GET"], body=body, auth=False) + res = await t._verify_go_route(r, r.content, "/repo/h.go") + assert res.has_verified_auth is False + + +@pytest.mark.asyncio +async def test_cross_file_helper_resolved_via_lsp(monkeypatch): + """The handler's only auth is a cross-file helper; go-to-definition returns + a body with an auth terminal → verified (the gopls refinement).""" + handler = 'func secret(w, r){ if !mustAuth(w,r) { return }; w.Write(x) }' + content = handler + "\n// mustAuth lives in another file" + helper_body = ('func mustAuth(w, r) bool { ' + 'if r.Header.Get("Authorization")=="" { return false }; return true }') + + t = _tracer() + + async def _fake_def(abs_path, line, char, depth=0, seen=frozenset()): + return helper_body + monkeypatch.setattr(t, "_trace_definition_body", _fake_def) + + r = _route("/secret", ["ANY"], body=handler, content=content, auth=False) + res = await t._verify_go_route(r, r.content, "/repo/h.go") + assert res.has_verified_auth is True + assert res.middleware_chain == ["mustAuth"] + assert res.trace_depth == 1 + + +@pytest.mark.asyncio +async def test_helper_without_terminal_does_not_verify(monkeypatch): + """A helper whose body has no auth terminal must NOT vouch for auth.""" + handler = 'func h(w, r){ doAuthThing(r); w.Write(x) }' + t = _tracer() + + async def _fake_def(abs_path, line, char, depth=0, seen=frozenset()): + return "func doAuthThing(r) { log.Println(\"hi\") }" # no terminal + monkeypatch.setattr(t, "_trace_definition_body", _fake_def) + + r = _route("/x", ["GET"], body=handler, content=handler, auth=False) + res = await t._verify_go_route(r, r.content, "/repo/h.go") + assert res.has_verified_auth is False + + +def test_go_auth_helper_calls_are_auth_named_only(): + t = _tracer() + body = "requireAuth(r); validateSession(x); render(y); computeTotal(z)" + names = t._go_auth_helper_calls(body) + assert "requireAuth" in names + assert "validateSession" in names + assert "render" not in names + assert "computeTotal" not in names diff --git a/tests/engine/test_code_analysis/test_go_route_mapper.py b/tests/engine/test_code_analysis/test_go_route_mapper.py index 319f2c6..0342623 100644 --- a/tests/engine/test_code_analysis/test_go_route_mapper.py +++ b/tests/engine/test_code_analysis/test_go_route_mapper.py @@ -63,16 +63,32 @@ def test_chi_title_case_verbs(tmp_path): assert routes["/items/:id"].http_methods == ["DELETE"] -def test_auth_detected_at_file_level(tmp_path): +def test_group_middleware_marks_routes_authed(tmp_path): + """A router/group with an auth-naming .Use middleware guards its routes.""" _write(tmp_path, "auth.go", """ package main func routes(r *gin.Engine) { - r.Use(AuthMiddleware()) - r.GET("/me", currentUser) + api := r.Group("/api") + api.Use(AuthMiddleware()) + api.GET("/me", currentUser) } """) - routes = GoRouteMapper().map_routes(str(tmp_path)) - assert routes and routes[0].has_auth_check is True + routes = _by_pattern(GoRouteMapper().map_routes(str(tmp_path))) + assert routes["/api/me"].has_auth_check is True + + +def test_non_auth_middleware_does_not_credit_auth(tmp_path): + """A .Use of logging/CORS/recovery must NOT mark routes as authed.""" + _write(tmp_path, "log.go", """ +package main +func routes(r *gin.Engine) { + r.Use(Logger()) + r.Use(gin.Recovery()) + r.GET("/open", openHandler) +} +""") + routes = _by_pattern(GoRouteMapper().map_routes(str(tmp_path))) + assert routes["/open"].has_auth_check is False def test_no_auth_signal_means_false(tmp_path): @@ -86,6 +102,62 @@ def test_no_auth_signal_means_false(tmp_path): assert routes and routes[0].has_auth_check is False +def test_handler_source_captured_for_named_handler(tmp_path): + """The route carries its handler's body so per-route auth can be judged.""" + _write(tmp_path, "h.go", """ +package main +import "net/http" +func getThing(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + http.Error(w, "no", http.StatusUnauthorized) + return + } + w.Write([]byte("thing")) +} +func routes(mux *http.ServeMux) { + mux.HandleFunc("/thing", getThing) +} +""") + routes = _by_pattern(GoRouteMapper().map_routes(str(tmp_path))) + body = routes["/thing"].handler_source + assert "func getThing" in body + assert "StatusUnauthorized" in body # brace-matched to the full body + + +def test_non_route_method_calls_are_not_routes(tmp_path): + """`r.Header.Get("Authorization")` / `c.Get("user")` have no handler arg, + so they must not be mapped as routes (a real regression that produced a + bogus `/Authorization` route).""" + _write(tmp_path, "h.go", """ +package main +import "net/http" +func h(w http.ResponseWriter, r *http.Request) { + _ = r.Header.Get("Authorization") + _ = r.URL.Query().Get("id") +} +func routes(mux *http.ServeMux) { + mux.HandleFunc("/real", h) +} +""") + routes = _by_pattern(GoRouteMapper().map_routes(str(tmp_path))) + assert set(routes) == {"/real"} + + +def test_inline_handler_is_a_route(tmp_path): + """An inline func literal handler still counts as a route.""" + _write(tmp_path, "inline.go", """ +package main +import "net/http" +func routes(mux *http.ServeMux) { + mux.HandleFunc("/inline", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hi")) + }) +} +""") + routes = _by_pattern(GoRouteMapper().map_routes(str(tmp_path))) + assert "/inline" in routes + + def test_test_files_are_skipped(tmp_path): _write(tmp_path, "handlers_test.go", """ package main