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.
- attackmap_analyzer_go/__init__.py +3 -0
- attackmap_analyzer_go/analyzer.py +727 -0
- attackmap_analyzer_go/route_auth.py +911 -0
- attackmap_analyzer_go-0.2.0.dist-info/METADATA +103 -0
- attackmap_analyzer_go-0.2.0.dist-info/RECORD +9 -0
- attackmap_analyzer_go-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_go-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_go-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_go-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|