Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 17 additions & 9 deletions docs/lsp-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
141 changes: 101 additions & 40 deletions isitsecure/engine/code_analysis/go_route_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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 <handler>(...) { ... }`` 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] = {}
Expand Down
101 changes: 99 additions & 2 deletions isitsecure/engine/code_analysis/lsp/auth_flow_tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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],
Expand Down
30 changes: 30 additions & 0 deletions isitsecure/engine/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ---
Expand Down
Loading
Loading