attackmap-analyzer-java-spring 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_java_spring/__init__.py +3 -0
- attackmap_analyzer_java_spring/analyzer.py +1187 -0
- attackmap_analyzer_java_spring/annotations.py +464 -0
- attackmap_analyzer_java_spring/constants.py +278 -0
- attackmap_analyzer_java_spring/functional.py +171 -0
- attackmap_analyzer_java_spring/security_dsl.py +1119 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/METADATA +118 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/RECORD +12 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_java_spring-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
"""Java/Kotlin Spring Boot ecosystem analyzer for AttackMap.
|
|
2
|
+
|
|
3
|
+
Coverage (v0.1):
|
|
4
|
+
- Web frameworks: Spring MVC / Spring Boot (annotation routing, with class-level
|
|
5
|
+
@RequestMapping prefix joining), JAX-RS / Jersey / Quarkus (@Path + @GET/@POST),
|
|
6
|
+
Ktor (Kotlin DSL routing), Javalin, Micronaut
|
|
7
|
+
- Databases: Spring Data JPA (@Entity, JpaRepository), Spring Data MongoDB
|
|
8
|
+
(@Document, MongoRepository), Spring Data Redis (RedisTemplate), JDBC
|
|
9
|
+
(DriverManager.getConnection), Hibernate, jOOQ
|
|
10
|
+
- Auth: Spring Security (@PreAuthorize, @Secured, SecurityFilterChain),
|
|
11
|
+
jjwt / nimbus-jose-jwt, BCryptPasswordEncoder / Argon2PasswordEncoder,
|
|
12
|
+
OAuth2 (oauth2Login, OAuth2AuthenticationToken)
|
|
13
|
+
- HTTP clients (external calls): RestTemplate, WebClient, java.net.http.HttpClient,
|
|
14
|
+
OkHttpClient, @FeignClient, Apache HttpClient
|
|
15
|
+
- Secrets: System.getenv("X"), @Value("${X}"), Environment.getProperty("X")
|
|
16
|
+
- Entrypoints: @SpringBootApplication, SpringApplication.run, embedded servlet
|
|
17
|
+
containers
|
|
18
|
+
- Service hints: artifactId/groupId from pom.xml, rootProject.name from
|
|
19
|
+
build.gradle, spring.application.name from application.properties/yml
|
|
20
|
+
|
|
21
|
+
Class-level @RequestMapping (Spring), @Path (JAX-RS) and @Controller
|
|
22
|
+
(Micronaut) prefixes are joined with member mappings to produce the full route
|
|
23
|
+
path. Annotation arguments are parsed (bare, positional, `value=`/`path=`/
|
|
24
|
+
`uri=`, Java `{...}` and Kotlin `[...]`/`arrayOf(...)` arrays, `method =`
|
|
25
|
+
lists) by `annotations.py`, and a class prefix applies only inside that class's
|
|
26
|
+
body (found by brace matching).
|
|
27
|
+
|
|
28
|
+
Functional endpoints (`RouterFunctions`, the `route()` builder, Kotlin
|
|
29
|
+
`router {}` / `coRouter {}`) are read by `functional.py`. Route auth (#3):
|
|
30
|
+
Spring Security `authorizeHttpRequests` / `authorizeRequests` /
|
|
31
|
+
`authorizeExchange` rules (`security_dsl.py`) are applied to Spring, JAX-RS and
|
|
32
|
+
functional routes in first-match order, together with method security
|
|
33
|
+
(`@PreAuthorize`, `@Secured`, `@RolesAllowed`, `@DenyAll`, `@PermitAll`), and
|
|
34
|
+
the result is recorded on `Route.auth` / `guards` / `guard_evidence`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from attackmap.sdk import (
|
|
44
|
+
DEFAULT_SKIP_DIRS,
|
|
45
|
+
AnalyzerMetadata,
|
|
46
|
+
AuthHint,
|
|
47
|
+
DatabaseHint,
|
|
48
|
+
EntrypointHint,
|
|
49
|
+
ExternalCall,
|
|
50
|
+
FrameworkHint,
|
|
51
|
+
iter_repo_files,
|
|
52
|
+
line_of,
|
|
53
|
+
read_source,
|
|
54
|
+
rel,
|
|
55
|
+
Route,
|
|
56
|
+
ScanResult,
|
|
57
|
+
SecretHint,
|
|
58
|
+
ServiceHint,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
from .annotations import (
|
|
62
|
+
Annotation,
|
|
63
|
+
AnnotationBlock,
|
|
64
|
+
annotation_paths,
|
|
65
|
+
bracket_end,
|
|
66
|
+
class_scopes,
|
|
67
|
+
find_annotations,
|
|
68
|
+
group_blocks,
|
|
69
|
+
innermost_scope,
|
|
70
|
+
mask_comments,
|
|
71
|
+
split_top_level,
|
|
72
|
+
string_values,
|
|
73
|
+
)
|
|
74
|
+
from .constants import ConstantTable, resolve_expression
|
|
75
|
+
from .functional import FUNCTIONAL_GATE_RE, functional_routes
|
|
76
|
+
from .security_dsl import (
|
|
77
|
+
ANONYMOUS,
|
|
78
|
+
ANONYMOUS_AUTHORITY_RE,
|
|
79
|
+
REQUIRED,
|
|
80
|
+
SECURITY_GATE_RE,
|
|
81
|
+
UNKNOWN,
|
|
82
|
+
Budget,
|
|
83
|
+
BudgetExceeded,
|
|
84
|
+
FileSecurity,
|
|
85
|
+
Resolution,
|
|
86
|
+
Rule,
|
|
87
|
+
SecurityChain,
|
|
88
|
+
parse_security,
|
|
89
|
+
resolve_route,
|
|
90
|
+
spel_decision,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
CODE_SUFFIXES = {".java", ".kt", ".kts"}
|
|
95
|
+
# JVM-specific directories on top of the SDK defaults (which already cover
|
|
96
|
+
# Maven target/, Gradle build/, out/, .git and node_modules). Matched against
|
|
97
|
+
# directory names inside the repo only (mlaify/AttackMap#253).
|
|
98
|
+
SKIP_DIRS = DEFAULT_SKIP_DIRS | {".gradle", ".mvn", ".idea", "bin"}
|
|
99
|
+
BUILD_FILES = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
100
|
+
_SNIPPET_MAX_CHARS = 160
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------- Patterns ----------
|
|
104
|
+
|
|
105
|
+
# Spring MVC, JAX-RS and Micronaut routes are read with the annotation reader
|
|
106
|
+
# in `annotations.py` (#2): bare mappings, `value=`/`path=`/`uri=`, arrays
|
|
107
|
+
# (`{"/a", "/b"}`, Kotlin `["/a"]` / `arrayOf("/a")`) and
|
|
108
|
+
# `method = {RequestMethod.X, ...}`.
|
|
109
|
+
SPRING_VERB_MAPPINGS = {
|
|
110
|
+
"GetMapping": "GET",
|
|
111
|
+
"PostMapping": "POST",
|
|
112
|
+
"PutMapping": "PUT",
|
|
113
|
+
"DeleteMapping": "DELETE",
|
|
114
|
+
"PatchMapping": "PATCH",
|
|
115
|
+
}
|
|
116
|
+
SPRING_PATH_KEYS = ("value", "path")
|
|
117
|
+
JAXRS_VERBS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"})
|
|
118
|
+
MICRONAUT_VERBS = {
|
|
119
|
+
"Get": "GET",
|
|
120
|
+
"Post": "POST",
|
|
121
|
+
"Put": "PUT",
|
|
122
|
+
"Delete": "DELETE",
|
|
123
|
+
"Patch": "PATCH",
|
|
124
|
+
"Head": "HEAD",
|
|
125
|
+
"Options": "OPTIONS",
|
|
126
|
+
}
|
|
127
|
+
MICRONAUT_PATH_KEYS = ("value", "uri", "uris")
|
|
128
|
+
ROUTE_ANNOTATIONS = frozenset(
|
|
129
|
+
{*SPRING_VERB_MAPPINGS, "RequestMapping", "Path", *JAXRS_VERBS, *MICRONAUT_VERBS, "Controller"}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Ktor (Kotlin): get("/x") { ... } inside `routing { ... }`.
|
|
133
|
+
KTOR_ROUTE_PATTERN = re.compile(
|
|
134
|
+
r'\b(get|post|put|delete|patch|head|options)\s*\(\s*"([^"]+)"\s*\)\s*\{',
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Javalin: app.get("/x", ...), app.post("/x", ...)
|
|
138
|
+
JAVALIN_ROUTE_PATTERN = re.compile(
|
|
139
|
+
r'\bapp\.(get|post|put|delete|patch|head|options)\s*\(\s*"([^"]+)"',
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Method security annotations (Spring and JSR-250) on a handler or its class,
|
|
143
|
+
# and the switch family that turns each on. `@PostAuthorize` / `@PostFilter` run
|
|
144
|
+
# after the handler, so they never guard against its side effects.
|
|
145
|
+
PREPOST, SECURED, JSR250 = "prePost", "secured", "jsr250"
|
|
146
|
+
METHOD_SECURITY_FAMILIES = {
|
|
147
|
+
"PreAuthorize": PREPOST,
|
|
148
|
+
"Secured": SECURED,
|
|
149
|
+
"RolesAllowed": JSR250,
|
|
150
|
+
"DenyAll": JSR250,
|
|
151
|
+
"PermitAll": JSR250,
|
|
152
|
+
}
|
|
153
|
+
ENABLE_METHOD_SECURITY = frozenset({"EnableMethodSecurity", "EnableGlobalMethodSecurity", "EnableReactiveMethodSecurity"})
|
|
154
|
+
# JAX-RS runtimes that enforce @RolesAllowed themselves: Jersey's feature, Quarkus security.
|
|
155
|
+
JAXRS_ROLES_RE = re.compile(r"\bRolesAllowedDynamicFeature\b|\bio\.quarkus\.security\b")
|
|
156
|
+
JAXRS_JSR250 = "jaxrs-jsr250"
|
|
157
|
+
# Spring MVC trailing-slash matching switched on explicitly (it is off by default in Spring 6).
|
|
158
|
+
# Spring 6.2's `UrlHandlerFilter.trailingSlashHandler(..)` does the same by redirect or wrap.
|
|
159
|
+
TRAILING_SLASH_ON_RE = re.compile(
|
|
160
|
+
r"\b(?:setUseTrailingSlashMatch|useTrailingSlashMatch)\s*\(\s*true\b|\bisUseTrailingSlashMatch\s*=\s*true\b"
|
|
161
|
+
r"|\bUrlHandlerFilter\b"
|
|
162
|
+
)
|
|
163
|
+
# A real `import jakarta.servlet...` line (Spring 6 / Boot 3), not a mention.
|
|
164
|
+
JAKARTA_IMPORT_RE = re.compile(r"^[ \t]*import[ \t]+jakarta\.servlet\.", re.MULTILINE)
|
|
165
|
+
# The DispatcherServlet or Jersey mounted below `/`: Ant matchers then see the
|
|
166
|
+
# servlet path in front of the route, so the route's own path isn't what they match.
|
|
167
|
+
SERVLET_PATH_PROPERTY_RE = re.compile(
|
|
168
|
+
r"^[ \t]*spring\.(mvc\.servlet\.path|jersey\.application-path)[ \t]*[=:][ \t]*[\"']?([^\s#\"']*)", re.MULTILINE
|
|
169
|
+
)
|
|
170
|
+
# One `key: value` YAML line (block mappings only; flow style and lists are skipped).
|
|
171
|
+
YAML_LINE_RE = re.compile(r"([ ]*)([A-Za-z0-9_.\-]+)[ \t]*:(?:[ \t]+[\"']?([^\s#\"']*))?[ \t]*(?:#.*)?")
|
|
172
|
+
SERVLET_PATH_KEYS = {"spring.mvc.servlet.path": "mvc", "spring.jersey.application-path": "jersey"}
|
|
173
|
+
MAX_YAML_LINES = 20_000
|
|
174
|
+
APPLICATION_PATH_RE = re.compile(r"@ApplicationPath\s*\(")
|
|
175
|
+
# The same set in code: `PathMatchConfigurer.addPathPrefix("/api", predicate)`, or
|
|
176
|
+
# the DispatcherServlet registered on a sub-path (`new DispatcherServletRegistrationBean(
|
|
177
|
+
# servlet, "/api/*")`, `new ServletRegistrationBean<>(dispatcherServlet, "/api/*")`).
|
|
178
|
+
SERVLET_PREFIX_CALL_RE = re.compile(
|
|
179
|
+
r"\baddPathPrefix\s*\(|\b(DispatcherServletRegistrationBean|ServletRegistrationBean)\s*(?:<[^<>()]{0,100}>)?\s*\("
|
|
180
|
+
)
|
|
181
|
+
_ROOT_PATHS = frozenset({"", "/", "/*", "/**"})
|
|
182
|
+
CONFIG_SUFFIXES = {".properties", ".yml", ".yaml"}
|
|
183
|
+
# Spring Boot major version in a Maven or Gradle build file.
|
|
184
|
+
BOOT_VERSION_RE = re.compile(
|
|
185
|
+
r"spring-boot-starter-parent</artifactId>\s*<version>\s*(\d+)\."
|
|
186
|
+
r"|spring-boot-dependencies</artifactId>\s*<version>\s*(\d+)\."
|
|
187
|
+
r"|<spring-boot\.version>\s*(\d+)\."
|
|
188
|
+
r"|org\.springframework\.boot[\"']?\)?\s+version\s+[\"'](\d+)\."
|
|
189
|
+
r"|spring-boot-gradle-plugin:(\d+)\."
|
|
190
|
+
r"|springBootVersion\s*=\s*[\"'](\d+)\."
|
|
191
|
+
)
|
|
192
|
+
# Route kinds Spring Security's filter chain sits in front of. Micronaut, Ktor
|
|
193
|
+
# and Javalin routes have their own security and are left alone.
|
|
194
|
+
FILTER_CHAIN_KINDS = frozenset({"spring", "jaxrs", "functional"})
|
|
195
|
+
MODULE_BUILD_FILES = ("pom.xml", "build.gradle", "build.gradle.kts")
|
|
196
|
+
# Directory names whose security configs are test-only and never guard a route.
|
|
197
|
+
_TEST_DIR_NAMES = frozenset({"test", "tests", "testfixtures", "integrationtest", "it", "e2e"})
|
|
198
|
+
_ANNOTATION_TEXT_CHARS = 120
|
|
199
|
+
_DECISION_RANK = {None: 0, ANONYMOUS: 0, UNKNOWN: 1, REQUIRED: 2}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass
|
|
203
|
+
class MethodSecurity:
|
|
204
|
+
family: str # PREPOST / SECURED / JSR250
|
|
205
|
+
name: str # the annotation, e.g. "PreAuthorize"
|
|
206
|
+
# Argument expressions; read once the repo's constants are known (#11).
|
|
207
|
+
sources: list[str]
|
|
208
|
+
guard: str
|
|
209
|
+
evidence: str
|
|
210
|
+
decision: str = UNKNOWN # REQUIRED / ANONYMOUS / UNKNOWN, set by resolve()
|
|
211
|
+
resolved: bool = False
|
|
212
|
+
|
|
213
|
+
def resolve(self, constants: ConstantTable) -> None:
|
|
214
|
+
if not self.resolved:
|
|
215
|
+
self.decision = _method_decision(self.name, self.sources, constants)
|
|
216
|
+
self.resolved = True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class RouteMeta:
|
|
221
|
+
kind: str # spring / jaxrs / functional / micronaut / ktor / javalin
|
|
222
|
+
# One (method-level, class-level) annotation pair per handler mapped to this
|
|
223
|
+
# route key; several when `params=` / `headers=` variants share a path+method.
|
|
224
|
+
method_security: list[tuple[list[MethodSecurity], list[MethodSecurity]]] = field(default_factory=list)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass
|
|
228
|
+
class RepoSecurity:
|
|
229
|
+
"""Security facts gathered while walking the repo, applied at the end."""
|
|
230
|
+
|
|
231
|
+
files: dict[str, FileSecurity] = field(default_factory=dict)
|
|
232
|
+
# Comment-masked text of files with security rules, parsed once every
|
|
233
|
+
# constant in the repo is known.
|
|
234
|
+
pending: dict[str, str] = field(default_factory=dict)
|
|
235
|
+
constants: ConstantTable = field(default_factory=ConstantTable)
|
|
236
|
+
# File -> method-security switches it turns on (PREPOST / SECURED / JSR250 / JAXRS_JSR250).
|
|
237
|
+
switches: dict[str, set[str]] = field(default_factory=dict)
|
|
238
|
+
# Main-source files importing `jakarta.servlet` (Spring 6: no trailing-slash
|
|
239
|
+
# matching by default), used when no build file declares a Boot version.
|
|
240
|
+
jakarta_files: set[str] = field(default_factory=set)
|
|
241
|
+
trailing_slash_on: bool = False
|
|
242
|
+
# Files that mount the DispatcherServlet / Jersey below `/` (or set it to
|
|
243
|
+
# something unreadable); applied to routes in the same build module.
|
|
244
|
+
mvc_path_files: set[str] = field(default_factory=set)
|
|
245
|
+
jersey_path_files: set[str] = field(default_factory=set)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _is_test_path(relative: str) -> bool:
|
|
249
|
+
return any(part.lower() in _TEST_DIR_NAMES for part in relative.split("/")[:-1])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _annotation_text(annotation: Annotation) -> str:
|
|
253
|
+
text = f"@{annotation.name}" if annotation.args is None else f"@{annotation.name}({' '.join(annotation.args.split())})"
|
|
254
|
+
return text if len(text) <= _ANNOTATION_TEXT_CHARS else text[: _ANNOTATION_TEXT_CHARS - 1] + "…"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _method_security(block: AnnotationBlock | None, relative: str, content: str) -> list[MethodSecurity]:
|
|
258
|
+
"""Method-security annotations in ``block``; their access is decided later
|
|
259
|
+
by `_method_decision`, once the repo's constants are known."""
|
|
260
|
+
found: list[MethodSecurity] = []
|
|
261
|
+
if block is None:
|
|
262
|
+
return found
|
|
263
|
+
for annotation in block.annotations:
|
|
264
|
+
family = METHOD_SECURITY_FAMILIES.get(annotation.name)
|
|
265
|
+
if family is None:
|
|
266
|
+
continue
|
|
267
|
+
sources = [*annotation.positional, *([annotation.named["value"]] if "value" in annotation.named else [])]
|
|
268
|
+
text = _annotation_text(annotation)
|
|
269
|
+
evidence = f"{relative}:{line_of(content, annotation.start)} {text}"
|
|
270
|
+
found.append(MethodSecurity(family, annotation.name, sources, text, evidence))
|
|
271
|
+
return found
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _array_elements(source: str) -> list[str]:
|
|
275
|
+
"""Elements of `{a, b}`, Kotlin `[a, b]` / `arrayOf(a, b)`, or the value itself."""
|
|
276
|
+
v = source.strip()
|
|
277
|
+
if (v.startswith("{") and v.endswith("}")) or (v.startswith("[") and v.endswith("]")):
|
|
278
|
+
return split_top_level(v[1:-1])
|
|
279
|
+
if v.startswith("arrayOf(") and v.endswith(")"):
|
|
280
|
+
return split_top_level(v[len("arrayOf(") : -1])
|
|
281
|
+
return [v]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _method_decision(name: str, sources: list[str], constants: ConstantTable) -> str:
|
|
285
|
+
"""The access one method-security annotation declares.
|
|
286
|
+
|
|
287
|
+
`@PreAuthorize` SpEL is read like `access(...)`, from one string literal or
|
|
288
|
+
literals and same-repo constants joined by `+`. `@Secured` / `@RolesAllowed`
|
|
289
|
+
naming an ANONYMOUS authority, and `@PermitAll`, open the method; `@DenyAll`
|
|
290
|
+
and other roles require an authenticated caller. Any argument that can't be
|
|
291
|
+
resolved makes the annotation UNKNOWN.
|
|
292
|
+
"""
|
|
293
|
+
if name == "PreAuthorize":
|
|
294
|
+
expression = resolve_expression(sources[0], constants) if len(sources) == 1 else None
|
|
295
|
+
return spel_decision(expression) if expression is not None else UNKNOWN
|
|
296
|
+
if name in ("Secured", "RolesAllowed"):
|
|
297
|
+
roles: list[str] = []
|
|
298
|
+
for source in sources:
|
|
299
|
+
for element in _array_elements(source):
|
|
300
|
+
value = resolve_expression(element, constants)
|
|
301
|
+
if value is None:
|
|
302
|
+
return UNKNOWN
|
|
303
|
+
roles.append(value)
|
|
304
|
+
if not roles:
|
|
305
|
+
return UNKNOWN
|
|
306
|
+
return ANONYMOUS if any(ANONYMOUS_AUTHORITY_RE.search(r) for r in roles) else REQUIRED
|
|
307
|
+
if name == "DenyAll":
|
|
308
|
+
return REQUIRED
|
|
309
|
+
return ANONYMOUS
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _method_security_switches(content: str) -> set[str]:
|
|
313
|
+
"""Method-security families a file switches on (`@EnableMethodSecurity` & co.)."""
|
|
314
|
+
switches: set[str] = set()
|
|
315
|
+
if "MethodSecurity" not in content and "RolesAllowedDynamicFeature" not in content and "io.quarkus" not in content:
|
|
316
|
+
return switches
|
|
317
|
+
masked = mask_comments(content)
|
|
318
|
+
for annotation in find_annotations(masked, ENABLE_METHOD_SECURITY):
|
|
319
|
+
named = {k: v.strip() for k, v in annotation.named.items()}
|
|
320
|
+
if annotation.name == "EnableMethodSecurity":
|
|
321
|
+
if named.get("prePostEnabled") != "false":
|
|
322
|
+
switches.add(PREPOST)
|
|
323
|
+
elif annotation.name == "EnableReactiveMethodSecurity":
|
|
324
|
+
switches.add(PREPOST)
|
|
325
|
+
elif named.get("prePostEnabled") == "true":
|
|
326
|
+
switches.add(PREPOST)
|
|
327
|
+
if named.get("securedEnabled") == "true":
|
|
328
|
+
switches.add(SECURED)
|
|
329
|
+
if named.get("jsr250Enabled") == "true":
|
|
330
|
+
switches.add(JSR250)
|
|
331
|
+
if JAXRS_ROLES_RE.search(masked):
|
|
332
|
+
switches.add(JAXRS_JSR250)
|
|
333
|
+
return switches
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _effective_method_security(
|
|
337
|
+
levels: tuple[list[MethodSecurity], list[MethodSecurity]], switches: set[str], kind: str
|
|
338
|
+
) -> MethodSecurity | None:
|
|
339
|
+
"""What the method security that is switched on does for one handler.
|
|
340
|
+
|
|
341
|
+
Each enabled family looks at the method first, then the class. Every
|
|
342
|
+
enabled interceptor runs, so one that requires authentication guards the
|
|
343
|
+
handler; annotations whose family isn't switched on do nothing.
|
|
344
|
+
"""
|
|
345
|
+
method_level, class_level = levels
|
|
346
|
+
picks: list[MethodSecurity] = []
|
|
347
|
+
for family in (PREPOST, SECURED, JSR250):
|
|
348
|
+
enabled = family in switches or (family == JSR250 and kind == "jaxrs" and JAXRS_JSR250 in switches)
|
|
349
|
+
if not enabled:
|
|
350
|
+
continue
|
|
351
|
+
pick = next((m for m in method_level if m.family == family), None) or next(
|
|
352
|
+
(m for m in class_level if m.family == family), None
|
|
353
|
+
)
|
|
354
|
+
if pick is not None:
|
|
355
|
+
picks.append(pick)
|
|
356
|
+
for decision in (REQUIRED, UNKNOWN, ANONYMOUS):
|
|
357
|
+
for pick in picks:
|
|
358
|
+
if pick.decision == decision:
|
|
359
|
+
return pick
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# `RequestMethod.POST`, or a statically imported `POST`.
|
|
364
|
+
REQUEST_METHOD_RE = re.compile(r"(?<![\w$])(?:RequestMethod\.)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _spring_methods(annotation: Annotation) -> list[str]:
|
|
368
|
+
"""HTTP methods of a `@RequestMapping` (`method = RequestMethod.X`, a
|
|
369
|
+
statically imported `method = POST`, or an array)."""
|
|
370
|
+
value = annotation.named.get("method", "")
|
|
371
|
+
methods = REQUEST_METHOD_RE.findall(value)
|
|
372
|
+
return list(dict.fromkeys(methods)) or ["ANY"]
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# External HTTP calls
|
|
376
|
+
OUTBOUND_PATTERNS: list[re.Pattern[str]] = [
|
|
377
|
+
re.compile(r'\brestTemplate\.\w+(?:For\w+)?\s*\(\s*"(https?://[^"]+)"', re.IGNORECASE),
|
|
378
|
+
re.compile(r'\bWebClient\.\w+\(\).*?\.uri\s*\(\s*"(https?://[^"]+)"', re.DOTALL),
|
|
379
|
+
re.compile(r'\bHttpRequest\.newBuilder\(\s*URI\.create\s*\(\s*"(https?://[^"]+)"'),
|
|
380
|
+
re.compile(r'\bnew\s+Request\.Builder\(\)[^.]*\.url\s*\(\s*"(https?://[^"]+)"', re.DOTALL),
|
|
381
|
+
re.compile(r'\b(?:OkHttpClient|HttpClient)\(\s*\)[^.]*?\.url\s*\(\s*"(https?://[^"]+)"', re.DOTALL),
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
# Database libs
|
|
385
|
+
DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
386
|
+
(re.compile(r'\borg\.springframework\.data\.jpa\b|\bJpaRepository\b|\b@Entity\b'), "sql"),
|
|
387
|
+
(re.compile(r'\borg\.springframework\.data\.mongodb\b|\bMongoRepository\b|\b@Document\b'), "mongodb"),
|
|
388
|
+
(re.compile(r'\borg\.springframework\.data\.redis\b|\bRedisTemplate\b'), "redis"),
|
|
389
|
+
(re.compile(r'\borg\.springframework\.data\.cassandra\b|\bCassandraRepository\b'), "cassandra"),
|
|
390
|
+
(re.compile(r'\borg\.hibernate\b|\bSessionFactory\b'), "sql"),
|
|
391
|
+
(re.compile(r'\borg\.jooq\b|\bDSLContext\b'), "sql"),
|
|
392
|
+
(re.compile(r'\bDriverManager\.getConnection\s*\(\s*"jdbc:postgresql:'), "postgresql"),
|
|
393
|
+
(re.compile(r'\bDriverManager\.getConnection\s*\(\s*"jdbc:mysql:'), "mysql"),
|
|
394
|
+
(re.compile(r'\bDriverManager\.getConnection\s*\(\s*"jdbc:sqlite:'), "sqlite"),
|
|
395
|
+
(re.compile(r'\bDriverManager\.getConnection\s*\(\s*"jdbc:oracle:'), "oracle"),
|
|
396
|
+
(re.compile(r'\bDriverManager\.getConnection\s*\('), "sql"),
|
|
397
|
+
(re.compile(r'\bMongoClients\.create\s*\(|\bcom\.mongodb\.client\b'), "mongodb"),
|
|
398
|
+
(re.compile(r'\bJedis\b|\blettuce\b', re.IGNORECASE), "redis"),
|
|
399
|
+
(re.compile(r'\bsoftware\.amazon\.awssdk\.services\.s3\b|\bAmazonS3\b'), "object_storage"),
|
|
400
|
+
(re.compile(r'\bsoftware\.amazon\.awssdk\.services\.dynamodb\b|\bAmazonDynamoDB\b'), "dynamodb"),
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
# Auth-related signals
|
|
404
|
+
AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
|
|
405
|
+
(re.compile(r'\borg\.springframework\.security\.config\.\w+\.SecurityFilterChain\b|\bSecurityFilterChain\s+\w+\s*\('), "spring_security_filter_chain", 0.9),
|
|
406
|
+
(re.compile(r'@PreAuthorize\s*\(|@PostAuthorize\s*\('), "spring_method_security", 0.9),
|
|
407
|
+
(re.compile(r'@Secured\s*\(|@RolesAllowed\s*\('), "role_check", 0.85),
|
|
408
|
+
(re.compile(r'\bWebSecurityConfigurerAdapter\b'), "spring_security_legacy", 0.85),
|
|
409
|
+
# JWT verification only (#9, AttackMap#325): core credits any auth hint near
|
|
410
|
+
# a route as that route's auth, and imports, `Jwts.builder()`, auth0/Hutool
|
|
411
|
+
# `JWT.create()` or a `JwtEncoder` sign tokens rather than check them.
|
|
412
|
+
(re.compile(
|
|
413
|
+
r'\.parse(?:ClaimsJws|SignedClaims|SignedContent|ContentJws)\s*\(' # jjwt (not parseClaimsJwt: unsigned)
|
|
414
|
+
r'|\bJWT\.require\s*\(' # auth0 java-jwt verifier
|
|
415
|
+
r'|\bJWTUtil\.verify\s*\(|\bJWTValidator\.of\s*\(' # Hutool
|
|
416
|
+
r'|\bnew\s+(?:MAC|RSASSA|ECDSA|Ed25519)Verifier\s*\(' # Nimbus JOSE
|
|
417
|
+
r'|\bNimbusJwtDecoder\.with\w+\s*\(|\bJwtDecoders\.from\w+\s*\(' # Spring decoders
|
|
418
|
+
r'|\boauth2ResourceServer\s*[({][^;]{0,200}?\bjwt\b' # Spring resource server
|
|
419
|
+
), "jwt", 0.85),
|
|
420
|
+
(re.compile(r'\bBCryptPasswordEncoder\b'), "bcrypt", 0.9),
|
|
421
|
+
(re.compile(r'\bArgon2PasswordEncoder\b|\bargon2\b', re.IGNORECASE), "argon2", 0.9),
|
|
422
|
+
(re.compile(r'\bSCryptPasswordEncoder\b'), "scrypt", 0.9),
|
|
423
|
+
(re.compile(r'\boauth2Login\s*\(|\bOAuth2AuthenticationToken\b'), "oauth", 0.85),
|
|
424
|
+
(re.compile(r'@EnableWebSecurity\b'), "spring_security_enabled", 0.85),
|
|
425
|
+
(re.compile(r'@EnableGlobalMethodSecurity\b|@EnableMethodSecurity\b'), "spring_method_security_enabled", 0.85),
|
|
426
|
+
(re.compile(r'\bAuthorization\b'), "authorization_header", 0.6),
|
|
427
|
+
(re.compile(r'\bBearer\b'), "bearer_token", 0.6),
|
|
428
|
+
(re.compile(r'\bapi[_-]?key\b', re.IGNORECASE), "api_key", 0.6),
|
|
429
|
+
]
|
|
430
|
+
|
|
431
|
+
FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
432
|
+
(re.compile(r'\borg\.springframework\.boot\b|@SpringBootApplication\b'), "spring-boot"),
|
|
433
|
+
(re.compile(r'\borg\.springframework\.web\b|@RestController\b|@Controller\b'), "spring-mvc"),
|
|
434
|
+
(re.compile(r'\borg\.springframework\.webflux\b|\bWebFlux\b'), "spring-webflux"),
|
|
435
|
+
(re.compile(r'\bjavax\.ws\.rs\b|\bjakarta\.ws\.rs\b'), "jax-rs"),
|
|
436
|
+
(re.compile(r'\borg\.eclipse\.jersey\b|\bJersey\b'), "jersey"),
|
|
437
|
+
(re.compile(r'\bio\.quarkus\b'), "quarkus"),
|
|
438
|
+
(re.compile(r'\bio\.ktor\b|\bktor\.server\b'), "ktor"),
|
|
439
|
+
(re.compile(r'\bio\.micronaut\b'), "micronaut"),
|
|
440
|
+
(re.compile(r'\bio\.javalin\b'), "javalin"),
|
|
441
|
+
(re.compile(r'\borg\.springframework\.security\b'), "spring-security"),
|
|
442
|
+
]
|
|
443
|
+
|
|
444
|
+
ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
445
|
+
(re.compile(r'@SpringBootApplication\b'), "spring_boot_application"),
|
|
446
|
+
(re.compile(r'\bSpringApplication\.run\s*\('), "spring_application_run"),
|
|
447
|
+
(re.compile(r'\bembeddedServletContainerFactory\b'), "embedded_servlet_container"),
|
|
448
|
+
(re.compile(r'\bquarkusMain\b|\bQuarkusApplication\b'), "quarkus_main"),
|
|
449
|
+
(re.compile(r'\bembeddedServer\s*\(\s*Netty\b|\bembeddedServer\s*\(\s*Tomcat\b|\bembeddedServer\s*\(\s*Jetty\b'), "ktor_embedded_server"),
|
|
450
|
+
(re.compile(r'\bJavalin\.create\s*\(\s*\)?'), "javalin_create"),
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
# Secrets in Java are commonly two shapes:
|
|
454
|
+
# - System.getenv("X")
|
|
455
|
+
# - @Value("${X}") (where X looks secret-like)
|
|
456
|
+
# - Environment.getProperty("X")
|
|
457
|
+
SECRET_PATTERNS: list[re.Pattern[str]] = [
|
|
458
|
+
re.compile(
|
|
459
|
+
r'\bSystem\.getenv\s*\(\s*"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
|
|
460
|
+
),
|
|
461
|
+
re.compile(
|
|
462
|
+
r'@Value\s*\(\s*"\$\{([A-Za-z0-9_.]*(?:secret|token|key|password|pass|pwd)[A-Za-z0-9_.]*)\}?"',
|
|
463
|
+
re.IGNORECASE,
|
|
464
|
+
),
|
|
465
|
+
re.compile(
|
|
466
|
+
r'\bgetProperty\s*\(\s*"([A-Za-z0-9_.]*(?:secret|token|key|password|pass|pwd)[A-Za-z0-9_.]*)"',
|
|
467
|
+
re.IGNORECASE,
|
|
468
|
+
),
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# Kept rather than attackmap.sdk.line_snippet: this takes a match offset and
|
|
473
|
+
# splits on "\n" only, so it stays consistent with line_of() on files that
|
|
474
|
+
# contain form feeds or other str.splitlines() separators, and it costs
|
|
475
|
+
# O(line) per match instead of O(file).
|
|
476
|
+
def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
|
|
477
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
478
|
+
line_end = content.find("\n", offset)
|
|
479
|
+
if line_end == -1:
|
|
480
|
+
line_end = len(content)
|
|
481
|
+
line = content[line_start:line_end].strip()
|
|
482
|
+
if len(line) > max_chars:
|
|
483
|
+
line = line[: max_chars - 1] + "…"
|
|
484
|
+
return line
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _join_paths(prefix: str, suffix: str) -> str:
|
|
488
|
+
"""Join a class-level prefix and a method-level path Spring-style."""
|
|
489
|
+
p = prefix.strip()
|
|
490
|
+
s = suffix.strip()
|
|
491
|
+
if not p:
|
|
492
|
+
return s or "/"
|
|
493
|
+
if not s:
|
|
494
|
+
return p or "/"
|
|
495
|
+
if p == "/":
|
|
496
|
+
return s if s.startswith("/") else "/" + s
|
|
497
|
+
if s == "/":
|
|
498
|
+
return p
|
|
499
|
+
return p.rstrip("/") + "/" + s.lstrip("/")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _route_path(prefix: str, suffix: str) -> str:
|
|
503
|
+
"""Join a class path and a member path, normalized to a leading `/`."""
|
|
504
|
+
path = _join_paths(prefix, suffix)
|
|
505
|
+
return path if path.startswith("/") else "/" + path
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _module_name_from_pom(pom_path: Path, root: Path | None = None) -> str | None:
|
|
509
|
+
text = read_source(pom_path, root=root)
|
|
510
|
+
if text is None:
|
|
511
|
+
return None
|
|
512
|
+
artifact = re.search(r"<artifactId>([^<]+)</artifactId>", text)
|
|
513
|
+
if artifact:
|
|
514
|
+
return artifact.group(1).strip()
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _module_name_from_gradle(root: Path) -> str | None:
|
|
519
|
+
for candidate in ("settings.gradle", "settings.gradle.kts"):
|
|
520
|
+
text = read_source(root / candidate, root=root)
|
|
521
|
+
if text is None:
|
|
522
|
+
continue
|
|
523
|
+
match = re.search(r'rootProject\.name\s*=\s*[\'"]([^\'"]+)[\'"]', text)
|
|
524
|
+
if match:
|
|
525
|
+
return match.group(1).strip()
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _spring_app_name_from_properties(root: Path) -> str | None:
|
|
530
|
+
for candidate in (
|
|
531
|
+
"src/main/resources/application.properties",
|
|
532
|
+
"src/main/resources/application.yml",
|
|
533
|
+
"application.properties",
|
|
534
|
+
"application.yml",
|
|
535
|
+
):
|
|
536
|
+
text = read_source(root / candidate, root=root)
|
|
537
|
+
if text is None:
|
|
538
|
+
continue
|
|
539
|
+
match = re.search(r'^\s*spring\.application\.name\s*[:=]\s*([^\s#]+)', text, re.MULTILINE)
|
|
540
|
+
if match:
|
|
541
|
+
return match.group(1).strip().strip("'\"")
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _class_block_paths(block: AnnotationBlock, is_jaxrs: bool, is_micronaut: bool) -> list[str] | None:
|
|
546
|
+
"""Path prefixes a class-level annotation block declares, or None for none."""
|
|
547
|
+
for annotation in block.get("RequestMapping"):
|
|
548
|
+
return annotation_paths(annotation, SPRING_PATH_KEYS)
|
|
549
|
+
if is_jaxrs:
|
|
550
|
+
for annotation in block.get("Path"):
|
|
551
|
+
return annotation_paths(annotation, ("value",))
|
|
552
|
+
if is_micronaut:
|
|
553
|
+
for annotation in block.get("Controller"):
|
|
554
|
+
paths = annotation_paths(annotation, MICRONAUT_PATH_KEYS)
|
|
555
|
+
# A bare Micronaut @Controller is mounted at "/".
|
|
556
|
+
return paths if paths != [""] else ["/"]
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _member_routes(
|
|
561
|
+
block: AnnotationBlock, is_jaxrs: bool, is_micronaut: bool
|
|
562
|
+
) -> list[tuple[Annotation, str, list[str], str]]:
|
|
563
|
+
"""(anchor annotation, HTTP method, member paths, kind) for a member's block.
|
|
564
|
+
|
|
565
|
+
A JAX-RS verb pairs only with the `@Path` in its own block, i.e. the
|
|
566
|
+
annotations between the previous member and this method's signature, so a
|
|
567
|
+
neighbouring method's path is never borrowed (#3).
|
|
568
|
+
"""
|
|
569
|
+
routes: list[tuple[Annotation, str, list[str], str]] = []
|
|
570
|
+
for annotation in block.annotations:
|
|
571
|
+
if annotation.name in SPRING_VERB_MAPPINGS:
|
|
572
|
+
paths = annotation_paths(annotation, SPRING_PATH_KEYS)
|
|
573
|
+
if paths is not None:
|
|
574
|
+
routes.append((annotation, SPRING_VERB_MAPPINGS[annotation.name], paths, "spring"))
|
|
575
|
+
elif annotation.name == "RequestMapping":
|
|
576
|
+
paths = annotation_paths(annotation, SPRING_PATH_KEYS)
|
|
577
|
+
if paths is not None:
|
|
578
|
+
routes.extend((annotation, method, paths, "spring") for method in _spring_methods(annotation))
|
|
579
|
+
elif is_micronaut and annotation.name in MICRONAUT_VERBS:
|
|
580
|
+
paths = annotation_paths(annotation, MICRONAUT_PATH_KEYS)
|
|
581
|
+
if paths is not None:
|
|
582
|
+
routes.append((annotation, MICRONAUT_VERBS[annotation.name], paths, "micronaut"))
|
|
583
|
+
if is_jaxrs:
|
|
584
|
+
verbs = [a for a in block.annotations if a.name in JAXRS_VERBS and a.args is None]
|
|
585
|
+
if verbs:
|
|
586
|
+
path_annotation = next(iter(block.get("Path")), None)
|
|
587
|
+
paths = annotation_paths(path_annotation, ("value",)) if path_annotation else [""]
|
|
588
|
+
if paths is not None:
|
|
589
|
+
anchor = path_annotation or verbs[0]
|
|
590
|
+
routes.extend((anchor, verb.name, paths, "jaxrs") for verb in verbs)
|
|
591
|
+
return routes
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class JavaSpringAnalyzer:
|
|
595
|
+
metadata = AnalyzerMetadata(
|
|
596
|
+
name="java-spring",
|
|
597
|
+
display_name="Java/Kotlin Spring Boot Analyzer",
|
|
598
|
+
version="0.2.0",
|
|
599
|
+
description="Java and Kotlin analyzer with Spring Boot, JAX-RS, Ktor, and Spring Security awareness.",
|
|
600
|
+
scope="Maven, Gradle, and Kotlin Spring Boot projects. Detects Spring MVC, JAX-RS, Ktor, Javalin, and Micronaut routing.",
|
|
601
|
+
targets=["java", "kotlin", "spring", "spring-boot"],
|
|
602
|
+
languages=["java", "kotlin"],
|
|
603
|
+
# Framework band (50-149): core runs analyzers in (priority, name) order
|
|
604
|
+
# across built-ins and plugins, and merge is first-seen-wins.
|
|
605
|
+
priority=60,
|
|
606
|
+
experimental=False,
|
|
607
|
+
enabled_by_default=True,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def name(self) -> str:
|
|
612
|
+
return self.metadata.name
|
|
613
|
+
|
|
614
|
+
# ---------- Public entry points ----------
|
|
615
|
+
|
|
616
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
617
|
+
root = Path(repo_path).resolve()
|
|
618
|
+
if not root.exists() or not root.is_dir():
|
|
619
|
+
return False
|
|
620
|
+
for marker in ("pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"):
|
|
621
|
+
if (root / marker).exists():
|
|
622
|
+
return True
|
|
623
|
+
# Any nested source or build file; stop at the first one.
|
|
624
|
+
return next(iter_repo_files(root, suffixes=CODE_SUFFIXES, names=BUILD_FILES, skip_dirs=SKIP_DIRS), None) is not None
|
|
625
|
+
|
|
626
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
627
|
+
root = Path(repo_path).resolve()
|
|
628
|
+
result = ScanResult(root=str(root))
|
|
629
|
+
if not root.exists() or not root.is_dir():
|
|
630
|
+
return result
|
|
631
|
+
|
|
632
|
+
# Service-name hints from build/application metadata
|
|
633
|
+
artifact = _module_name_from_pom(root / "pom.xml", root)
|
|
634
|
+
gradle_name = _module_name_from_gradle(root)
|
|
635
|
+
spring_app = _spring_app_name_from_properties(root)
|
|
636
|
+
if artifact:
|
|
637
|
+
self._append_unique_service(result, f"artifact:{artifact}", "pom.xml")
|
|
638
|
+
if gradle_name:
|
|
639
|
+
self._append_unique_service(result, f"gradle_root:{gradle_name}", "settings.gradle")
|
|
640
|
+
if spring_app:
|
|
641
|
+
self._append_unique_service(result, f"spring_app:{spring_app}", "application.properties")
|
|
642
|
+
|
|
643
|
+
route_meta: dict[tuple[str, str, str], RouteMeta] = {}
|
|
644
|
+
security = RepoSecurity()
|
|
645
|
+
for file_path in iter_repo_files(root, suffixes=CODE_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
646
|
+
content = read_source(file_path, root=root)
|
|
647
|
+
if content is None:
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
result.files_scanned += 1
|
|
651
|
+
language = "kotlin" if file_path.suffix.lower() in {".kt", ".kts"} else "java"
|
|
652
|
+
if language not in result.languages:
|
|
653
|
+
result.languages.append(language)
|
|
654
|
+
|
|
655
|
+
relative = rel(file_path, root)
|
|
656
|
+
self._extract_routes(content, relative, result, route_meta)
|
|
657
|
+
self._extract_security_rules(content, relative, security)
|
|
658
|
+
self._extract_databases(content, relative, result)
|
|
659
|
+
self._extract_auth(content, relative, result)
|
|
660
|
+
self._extract_secrets(content, relative, result)
|
|
661
|
+
self._extract_external_calls(content, relative, result)
|
|
662
|
+
self._extract_frameworks(content, relative, result)
|
|
663
|
+
self._extract_entrypoints(content, relative, result)
|
|
664
|
+
self._infer_service_role(content, relative, result)
|
|
665
|
+
|
|
666
|
+
_read_servlet_paths(root, security)
|
|
667
|
+
self._apply_route_auth(root, result, route_meta, security)
|
|
668
|
+
result.languages.sort()
|
|
669
|
+
return result
|
|
670
|
+
|
|
671
|
+
# ---------- Extractors ----------
|
|
672
|
+
|
|
673
|
+
def _extract_routes(
|
|
674
|
+
self,
|
|
675
|
+
content: str,
|
|
676
|
+
relative: str,
|
|
677
|
+
result: ScanResult,
|
|
678
|
+
route_meta: dict[tuple[str, str, str], RouteMeta] | None = None,
|
|
679
|
+
) -> None:
|
|
680
|
+
meta = route_meta if route_meta is not None else {}
|
|
681
|
+
self._extract_annotation_routes(content, relative, result, meta)
|
|
682
|
+
|
|
683
|
+
# Ktor: routing { get("/x") { ... } }
|
|
684
|
+
if "io.ktor" in content or "ktor.server.routing" in content:
|
|
685
|
+
for match in KTOR_ROUTE_PATTERN.finditer(content):
|
|
686
|
+
method, path = match.group(1).upper(), match.group(2)
|
|
687
|
+
self._append_unique_route(result, path, method, relative, line_of(content, match.start()), meta, RouteMeta("ktor"))
|
|
688
|
+
|
|
689
|
+
# Javalin: app.get("/x", ...)
|
|
690
|
+
if "io.javalin" in content or "Javalin.create" in content:
|
|
691
|
+
for match in JAVALIN_ROUTE_PATTERN.finditer(content):
|
|
692
|
+
method, path = match.group(1).upper(), match.group(2)
|
|
693
|
+
self._append_unique_route(result, path, method, relative, line_of(content, match.start()), meta, RouteMeta("javalin"))
|
|
694
|
+
|
|
695
|
+
# Spring functional endpoints: RouterFunctions / route() builder /
|
|
696
|
+
# Kotlin router {} and coRouter {} (#3).
|
|
697
|
+
if FUNCTIONAL_GATE_RE.search(content):
|
|
698
|
+
for route in functional_routes(mask_comments(content)):
|
|
699
|
+
self._append_unique_route(
|
|
700
|
+
result, route.path, route.method, relative, line_of(content, route.offset), meta, RouteMeta("functional")
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
def _extract_security_rules(self, content: str, relative: str, security: RepoSecurity) -> None:
|
|
704
|
+
"""Spring Security authorization rules and switches in ``content`` (#3).
|
|
705
|
+
|
|
706
|
+
Test configurations (`src/test/...`) never guard a deployed route.
|
|
707
|
+
"""
|
|
708
|
+
if _is_test_path(relative):
|
|
709
|
+
return
|
|
710
|
+
if JAKARTA_IMPORT_RE.search(content) and JAKARTA_IMPORT_RE.search(mask_comments(content)):
|
|
711
|
+
security.jakarta_files.add(relative)
|
|
712
|
+
if APPLICATION_PATH_RE.search(content) and _application_path_not_root(content):
|
|
713
|
+
security.jersey_path_files.add(relative)
|
|
714
|
+
if SERVLET_PREFIX_CALL_RE.search(content) and _code_mounts_mvc_below_root(content):
|
|
715
|
+
security.mvc_path_files.add(relative)
|
|
716
|
+
if not security.trailing_slash_on and TRAILING_SLASH_ON_RE.search(content):
|
|
717
|
+
security.trailing_slash_on = True
|
|
718
|
+
switches = _method_security_switches(content)
|
|
719
|
+
if switches:
|
|
720
|
+
security.switches[relative] = switches
|
|
721
|
+
gated = SECURITY_GATE_RE.search(content) is not None
|
|
722
|
+
declares_constants = ("final" in content and "String" in content) or "const val" in content
|
|
723
|
+
if not gated and not declares_constants:
|
|
724
|
+
return
|
|
725
|
+
masked = mask_comments(content)
|
|
726
|
+
if declares_constants:
|
|
727
|
+
security.constants.collect(masked, kotlin=relative.endswith((".kt", ".kts")))
|
|
728
|
+
if gated:
|
|
729
|
+
security.pending[relative] = masked
|
|
730
|
+
|
|
731
|
+
def _apply_route_auth(
|
|
732
|
+
self,
|
|
733
|
+
root: Path,
|
|
734
|
+
result: ScanResult,
|
|
735
|
+
route_meta: dict[tuple[str, str, str], RouteMeta],
|
|
736
|
+
security: RepoSecurity,
|
|
737
|
+
) -> None:
|
|
738
|
+
"""Set `Route.auth` from filter-chain rules and method security.
|
|
739
|
+
|
|
740
|
+
A chain applies to routes in its own build module (nearest `pom.xml` /
|
|
741
|
+
`build.gradle`); a module with routes but no chain of its own uses the
|
|
742
|
+
chains of route-less modules (a shared security library). The same goes
|
|
743
|
+
for `@EnableMethodSecurity`-style switches: an annotation whose family
|
|
744
|
+
isn't switched on is ignored. The filter chain runs first, so method
|
|
745
|
+
security can turn a chain's `anonymous` into `required` but never opens
|
|
746
|
+
a route the chain protects. Several handlers on one route key (`params=`
|
|
747
|
+
variants) count only as much as the least restrictive of them.
|
|
748
|
+
|
|
749
|
+
Applying rules costs routes x chains x rules x entries, so it shares one
|
|
750
|
+
`Budget`; once that runs out the remaining routes keep the chain's answer
|
|
751
|
+
unknown.
|
|
752
|
+
"""
|
|
753
|
+
for relative, masked in security.pending.items():
|
|
754
|
+
parsed = parse_security(masked, relative, security.constants)
|
|
755
|
+
if parsed.chains or parsed.ignored:
|
|
756
|
+
security.files[relative] = parsed
|
|
757
|
+
security.pending.clear()
|
|
758
|
+
module_cache: dict[str, str] = {}
|
|
759
|
+
chains_by_module: dict[str, list[SecurityChain]] = {}
|
|
760
|
+
ignored_by_module: dict[str, list[Rule]] = {}
|
|
761
|
+
for relative, parsed in security.files.items():
|
|
762
|
+
module = _module_of(root, relative, module_cache)
|
|
763
|
+
chains_by_module.setdefault(module, []).extend(parsed.chains)
|
|
764
|
+
ignored_by_module.setdefault(module, []).extend(parsed.ignored)
|
|
765
|
+
switches_by_module: dict[str, set[str]] = {}
|
|
766
|
+
for relative, switches in security.switches.items():
|
|
767
|
+
switches_by_module.setdefault(_module_of(root, relative, module_cache), set()).update(switches)
|
|
768
|
+
jakarta_modules = {_module_of(root, relative, module_cache) for relative in security.jakarta_files}
|
|
769
|
+
mvc_path_modules = {_module_of(root, relative, module_cache) for relative in security.mvc_path_files}
|
|
770
|
+
jersey_path_modules = {_module_of(root, relative, module_cache) for relative in security.jersey_path_files}
|
|
771
|
+
route_modules = {_module_of(root, r.file, module_cache) for r in result.routes}
|
|
772
|
+
shared = [m for m in chains_by_module if m not in route_modules]
|
|
773
|
+
shared_switches: set[str] = set()
|
|
774
|
+
for module, switches in switches_by_module.items():
|
|
775
|
+
if module not in route_modules:
|
|
776
|
+
shared_switches |= switches
|
|
777
|
+
spring6_cache: dict[str, bool] = {}
|
|
778
|
+
budget = Budget()
|
|
779
|
+
|
|
780
|
+
for route in result.routes:
|
|
781
|
+
meta = route_meta.get((route.path, route.method, route.file))
|
|
782
|
+
if meta is None:
|
|
783
|
+
continue
|
|
784
|
+
module = _module_of(root, route.file, module_cache)
|
|
785
|
+
chain_result: Resolution | None = None
|
|
786
|
+
if meta.kind in FILTER_CHAIN_KINDS and chains_by_module and not budget.exhausted:
|
|
787
|
+
modules = [module] if module in chains_by_module else shared
|
|
788
|
+
chains = [c for m in modules for c in chains_by_module[m]]
|
|
789
|
+
ignored = [r for m in modules for r in ignored_by_module.get(m, [])]
|
|
790
|
+
route_dir = route.file.rsplit("/", 1)[0] if "/" in route.file else ""
|
|
791
|
+
# Spring 6 / Boot 3 no longer routes `/x/` to `/x`; JAX-RS runtimes do.
|
|
792
|
+
trailing = (
|
|
793
|
+
meta.kind == "jaxrs"
|
|
794
|
+
or security.trailing_slash_on
|
|
795
|
+
or not _is_spring6_module(root, module, module in jakarta_modules, spring6_cache)
|
|
796
|
+
)
|
|
797
|
+
# Under a servlet path the matchers see a different path than the route.
|
|
798
|
+
mounted_elsewhere = module in (jersey_path_modules if meta.kind == "jaxrs" else mvc_path_modules)
|
|
799
|
+
if not mounted_elsewhere:
|
|
800
|
+
try:
|
|
801
|
+
chain_result = resolve_route(
|
|
802
|
+
chains, ignored, route.path, route.method, route_dir,
|
|
803
|
+
trailing_slash_reaches_handler=trailing, budget=budget,
|
|
804
|
+
)
|
|
805
|
+
except BudgetExceeded:
|
|
806
|
+
chain_result = None
|
|
807
|
+
switches = switches_by_module.get(module, set()) | shared_switches
|
|
808
|
+
method_security: MethodSecurity | None = None
|
|
809
|
+
if meta.kind in FILTER_CHAIN_KINDS and meta.method_security:
|
|
810
|
+
for method_level, class_level in meta.method_security:
|
|
811
|
+
for annotation in (*method_level, *class_level):
|
|
812
|
+
annotation.resolve(security.constants)
|
|
813
|
+
effective = [_effective_method_security(levels, switches, meta.kind) for levels in meta.method_security]
|
|
814
|
+
# The least restrictive handler decides.
|
|
815
|
+
method_security = min(effective, key=lambda m: _DECISION_RANK[m.decision if m else None])
|
|
816
|
+
method_decision = method_security.decision if method_security is not None else None
|
|
817
|
+
if chain_result is not None and chain_result.decision == REQUIRED:
|
|
818
|
+
route.auth = "required"
|
|
819
|
+
extra = [method_security.guard] if method_security is not None and method_decision == REQUIRED else []
|
|
820
|
+
route.guards = [*chain_result.guards, *extra]
|
|
821
|
+
route.guard_evidence = chain_result.evidence
|
|
822
|
+
elif method_security is not None and method_decision == REQUIRED:
|
|
823
|
+
route.auth = "required"
|
|
824
|
+
route.guards = [method_security.guard]
|
|
825
|
+
route.guard_evidence = method_security.evidence
|
|
826
|
+
elif chain_result is not None and chain_result.decision == ANONYMOUS and method_decision in (None, ANONYMOUS):
|
|
827
|
+
route.auth = "anonymous"
|
|
828
|
+
route.guards = []
|
|
829
|
+
route.guard_evidence = chain_result.evidence
|
|
830
|
+
|
|
831
|
+
def _extract_annotation_routes(
|
|
832
|
+
self, content: str, relative: str, result: ScanResult, meta: dict[tuple[str, str, str], RouteMeta]
|
|
833
|
+
) -> None:
|
|
834
|
+
"""Spring MVC, JAX-RS and Micronaut annotation routes (#2).
|
|
835
|
+
|
|
836
|
+
Each annotation block is attributed to a class (its paths become the
|
|
837
|
+
prefix for that class body, found by brace matching) or to a member
|
|
838
|
+
(a route under the innermost enclosing class's prefixes).
|
|
839
|
+
"""
|
|
840
|
+
if "@" not in content:
|
|
841
|
+
return
|
|
842
|
+
is_jaxrs = "javax.ws.rs" in content or "jakarta.ws.rs" in content
|
|
843
|
+
is_micronaut = "io.micronaut" in content
|
|
844
|
+
masked = mask_comments(content)
|
|
845
|
+
# Group every annotation (not just route ones) so `@Validated` between
|
|
846
|
+
# `@RequestMapping` and `class` keeps the block on the class.
|
|
847
|
+
annotations = find_annotations(masked)
|
|
848
|
+
if not any(a.name in ROUTE_ANNOTATIONS for a in annotations):
|
|
849
|
+
return
|
|
850
|
+
blocks = group_blocks(masked, annotations)
|
|
851
|
+
scopes = class_scopes(masked)
|
|
852
|
+
|
|
853
|
+
# Class keyword offset -> that class's path prefixes / annotation block.
|
|
854
|
+
class_paths: dict[int, list[str]] = {}
|
|
855
|
+
class_blocks: dict[int, AnnotationBlock] = {}
|
|
856
|
+
for block in blocks:
|
|
857
|
+
if block.class_keyword_at is None:
|
|
858
|
+
continue
|
|
859
|
+
class_blocks[block.class_keyword_at] = block
|
|
860
|
+
prefixes = _class_block_paths(block, is_jaxrs, is_micronaut)
|
|
861
|
+
if prefixes is not None:
|
|
862
|
+
class_paths[block.class_keyword_at] = prefixes
|
|
863
|
+
|
|
864
|
+
for block in blocks:
|
|
865
|
+
if block.class_keyword_at is not None:
|
|
866
|
+
continue
|
|
867
|
+
members = _member_routes(block, is_jaxrs, is_micronaut)
|
|
868
|
+
if not members:
|
|
869
|
+
continue
|
|
870
|
+
scope = innermost_scope(scopes, block.annotations[0].start)
|
|
871
|
+
prefixes = class_paths.get(scope.keyword_at, [""]) if scope is not None else [""]
|
|
872
|
+
# Method-level annotations, then the class's (each family looks at both).
|
|
873
|
+
levels = (
|
|
874
|
+
_method_security(block, relative, content),
|
|
875
|
+
_method_security(class_blocks.get(scope.keyword_at), relative, content) if scope is not None else [],
|
|
876
|
+
)
|
|
877
|
+
for annotation, method, paths, kind in members:
|
|
878
|
+
line = line_of(content, annotation.start)
|
|
879
|
+
route_meta = RouteMeta(kind, [levels])
|
|
880
|
+
for prefix in prefixes:
|
|
881
|
+
for suffix in paths:
|
|
882
|
+
self._append_unique_route(
|
|
883
|
+
result, _route_path(prefix, suffix), method, relative, line, meta, route_meta
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
|
|
887
|
+
for pattern, kind in DB_PATTERNS:
|
|
888
|
+
match = pattern.search(content)
|
|
889
|
+
if match is None:
|
|
890
|
+
continue
|
|
891
|
+
self._append_unique_database(
|
|
892
|
+
result, kind, relative,
|
|
893
|
+
line_of(content, match.start()),
|
|
894
|
+
_line_snippet(content, match.start()),
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
|
|
898
|
+
for pattern, hint, confidence in AUTH_PATTERNS:
|
|
899
|
+
match = pattern.search(content)
|
|
900
|
+
if match is None:
|
|
901
|
+
continue
|
|
902
|
+
self._append_unique_auth(
|
|
903
|
+
result, hint, relative,
|
|
904
|
+
line_of(content, match.start()),
|
|
905
|
+
_line_snippet(content, match.start()),
|
|
906
|
+
confidence,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
|
|
910
|
+
for pattern in SECRET_PATTERNS:
|
|
911
|
+
for match in pattern.finditer(content):
|
|
912
|
+
groups = match.groups()
|
|
913
|
+
name = groups[0] if groups and groups[0] else "unknown"
|
|
914
|
+
self._append_unique_secret(
|
|
915
|
+
result, name, relative,
|
|
916
|
+
line_of(content, match.start()),
|
|
917
|
+
_line_snippet(content, match.start()),
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
|
|
921
|
+
for pattern in OUTBOUND_PATTERNS:
|
|
922
|
+
for match in pattern.finditer(content):
|
|
923
|
+
target = match.group(1)
|
|
924
|
+
if not (target.startswith("http://") or target.startswith("https://")):
|
|
925
|
+
continue
|
|
926
|
+
self._append_unique_external(
|
|
927
|
+
result, target, relative,
|
|
928
|
+
line_of(content, match.start()),
|
|
929
|
+
_line_snippet(content, match.start()),
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
|
|
933
|
+
for pattern, name in FRAMEWORK_PATTERNS:
|
|
934
|
+
match = pattern.search(content)
|
|
935
|
+
if match is None:
|
|
936
|
+
continue
|
|
937
|
+
self._append_unique_framework(
|
|
938
|
+
result, name, relative,
|
|
939
|
+
line_of(content, match.start()),
|
|
940
|
+
_line_snippet(content, match.start()),
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
944
|
+
for pattern, hint in ENTRYPOINT_PATTERNS:
|
|
945
|
+
match = pattern.search(content)
|
|
946
|
+
if match is None:
|
|
947
|
+
continue
|
|
948
|
+
self._append_unique_entrypoint(
|
|
949
|
+
result, hint, relative,
|
|
950
|
+
line_of(content, match.start()),
|
|
951
|
+
_line_snippet(content, match.start()),
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
def _infer_service_role(self, content: str, relative: str, result: ScanResult) -> None:
|
|
955
|
+
haystack = (relative + " " + content[:500]).lower()
|
|
956
|
+
role: str | None = None
|
|
957
|
+
if any(token in haystack for token in ("worker", "consumer", "queue", "scheduler", "background")):
|
|
958
|
+
role = "worker"
|
|
959
|
+
elif any(token in haystack for token in ("controller", "rest", "resource", "api", "router")):
|
|
960
|
+
role = "api"
|
|
961
|
+
elif any(token in haystack for token in ("client", "sdk")):
|
|
962
|
+
role = "client"
|
|
963
|
+
if role:
|
|
964
|
+
self._append_unique_service(result, f"service_role:{role}", relative)
|
|
965
|
+
|
|
966
|
+
# ---------- Append helpers ----------
|
|
967
|
+
|
|
968
|
+
@staticmethod
|
|
969
|
+
def _append_unique_route(
|
|
970
|
+
result: ScanResult,
|
|
971
|
+
path: str,
|
|
972
|
+
method: str,
|
|
973
|
+
file: str,
|
|
974
|
+
line: int | None,
|
|
975
|
+
route_meta: dict[tuple[str, str, str], RouteMeta] | None = None,
|
|
976
|
+
meta: RouteMeta | None = None,
|
|
977
|
+
) -> None:
|
|
978
|
+
key = (path, method, file)
|
|
979
|
+
if route_meta is not None and key in route_meta:
|
|
980
|
+
# Another handler on the same path+method (`params=` / `headers=`
|
|
981
|
+
# variants): keep its method security too, so the least restrictive wins.
|
|
982
|
+
if meta is not None:
|
|
983
|
+
route_meta[key].method_security.extend(meta.method_security)
|
|
984
|
+
return
|
|
985
|
+
if any((item.path, item.method, item.file) == key for item in result.routes):
|
|
986
|
+
return
|
|
987
|
+
result.routes.append(Route(path=path, method=method, file=file, line=line))
|
|
988
|
+
if route_meta is not None and meta is not None:
|
|
989
|
+
route_meta[key] = meta
|
|
990
|
+
|
|
991
|
+
@staticmethod
|
|
992
|
+
def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
993
|
+
key = (kind, file)
|
|
994
|
+
if any((item.kind, item.file) == key for item in result.databases):
|
|
995
|
+
return
|
|
996
|
+
result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
|
|
997
|
+
|
|
998
|
+
@staticmethod
|
|
999
|
+
def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
|
|
1000
|
+
key = (hint, file)
|
|
1001
|
+
if any((item.hint, item.file) == key for item in result.auth_hints):
|
|
1002
|
+
return
|
|
1003
|
+
result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
|
|
1004
|
+
|
|
1005
|
+
@staticmethod
|
|
1006
|
+
def _append_unique_secret(result: ScanResult, name: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
1007
|
+
key = (name, file)
|
|
1008
|
+
if any((item.name, item.file) == key for item in result.secret_hints):
|
|
1009
|
+
return
|
|
1010
|
+
result.secret_hints.append(SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85))
|
|
1011
|
+
|
|
1012
|
+
@staticmethod
|
|
1013
|
+
def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
1014
|
+
key = (target, file)
|
|
1015
|
+
if any((item.target, item.file) == key for item in result.external_calls):
|
|
1016
|
+
return
|
|
1017
|
+
result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
|
|
1018
|
+
|
|
1019
|
+
@staticmethod
|
|
1020
|
+
def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
1021
|
+
key = (hint, file)
|
|
1022
|
+
if any((item.hint, item.file) == key for item in result.framework_hints):
|
|
1023
|
+
return
|
|
1024
|
+
result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
1025
|
+
|
|
1026
|
+
@staticmethod
|
|
1027
|
+
def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
1028
|
+
key = (hint, file)
|
|
1029
|
+
if any((item.hint, item.file) == key for item in result.entrypoint_hints):
|
|
1030
|
+
return
|
|
1031
|
+
result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
1032
|
+
|
|
1033
|
+
@staticmethod
|
|
1034
|
+
def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
|
|
1035
|
+
key = (hint, file)
|
|
1036
|
+
if any((item.hint, item.file) == key for item in result.service_hints):
|
|
1037
|
+
return
|
|
1038
|
+
result.service_hints.append(ServiceHint(hint=hint, file=file))
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _declared_boot_major(text: str) -> int | None:
|
|
1042
|
+
for match in BOOT_VERSION_RE.finditer(text):
|
|
1043
|
+
version = next((g for g in match.groups() if g), None)
|
|
1044
|
+
if version is not None:
|
|
1045
|
+
return int(version)
|
|
1046
|
+
return None
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _is_spring6_module(root: Path, module: str, imports_jakarta: bool, cache: dict[str, bool]) -> bool:
|
|
1050
|
+
"""True when the module runs Spring Boot 3+ (Spring 6).
|
|
1051
|
+
|
|
1052
|
+
The nearest build file that declares a Boot version decides (a module's own
|
|
1053
|
+
Boot 2 wins over the root's Boot 3). Only when none does, a main-source
|
|
1054
|
+
`import jakarta.servlet...` in the module stands in for it.
|
|
1055
|
+
"""
|
|
1056
|
+
if module in cache:
|
|
1057
|
+
return cache[module]
|
|
1058
|
+
major: int | None = None
|
|
1059
|
+
directory = module
|
|
1060
|
+
for _ in range(64):
|
|
1061
|
+
base = root / directory if directory else root
|
|
1062
|
+
for name in MODULE_BUILD_FILES:
|
|
1063
|
+
text = read_source(base / name, root=root) if (base / name).is_file() else None
|
|
1064
|
+
if text is not None:
|
|
1065
|
+
major = _declared_boot_major(text)
|
|
1066
|
+
if major is not None:
|
|
1067
|
+
break
|
|
1068
|
+
if major is not None or not directory:
|
|
1069
|
+
break
|
|
1070
|
+
directory = directory.rsplit("/", 1)[0] if "/" in directory else ""
|
|
1071
|
+
found = major >= 3 if major is not None else imports_jakarta
|
|
1072
|
+
cache[module] = found
|
|
1073
|
+
return found
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _application_path_not_root(content: str) -> bool:
|
|
1077
|
+
"""A JAX-RS `@ApplicationPath` that isn't `/` (or isn't a literal)."""
|
|
1078
|
+
masked = mask_comments(content)
|
|
1079
|
+
for annotation in find_annotations(masked, frozenset({"ApplicationPath"})):
|
|
1080
|
+
paths = annotation_paths(annotation, ("value",))
|
|
1081
|
+
if paths is None or any(p not in _ROOT_PATHS for p in paths):
|
|
1082
|
+
return True
|
|
1083
|
+
return False
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _code_mounts_mvc_below_root(content: str) -> bool:
|
|
1087
|
+
"""`addPathPrefix(..)` or a DispatcherServlet registration on a non-root (or
|
|
1088
|
+
unreadable) path in Java/Kotlin code. A prefix applies to the controllers its
|
|
1089
|
+
predicate selects; we don't read predicates, so the whole module counts."""
|
|
1090
|
+
masked = mask_comments(content)
|
|
1091
|
+
for count, m in enumerate(SERVLET_PREFIX_CALL_RE.finditer(masked)):
|
|
1092
|
+
if count >= 64:
|
|
1093
|
+
return True # too many to read: assume the worst
|
|
1094
|
+
close = bracket_end(masked, m.end() - 1, 5_000)
|
|
1095
|
+
if close is None:
|
|
1096
|
+
return True
|
|
1097
|
+
parts = split_top_level(masked[m.end() : close - 1])
|
|
1098
|
+
bean = m.group(1)
|
|
1099
|
+
if bean is None: # addPathPrefix(prefix, predicate)
|
|
1100
|
+
mappings = parts[:1]
|
|
1101
|
+
elif bean == "ServletRegistrationBean" and not (parts and "ispatcher" in parts[0]):
|
|
1102
|
+
continue # some other servlet (an H2 console, ...): MVC routes don't move
|
|
1103
|
+
else:
|
|
1104
|
+
mappings = parts[1:]
|
|
1105
|
+
if not mappings:
|
|
1106
|
+
if bean is None:
|
|
1107
|
+
return True
|
|
1108
|
+
continue # a registration without mappings keeps the default "/"
|
|
1109
|
+
for mapping in mappings:
|
|
1110
|
+
values = string_values(mapping)
|
|
1111
|
+
if values is None or any(v not in _ROOT_PATHS for v in values):
|
|
1112
|
+
return True
|
|
1113
|
+
return False
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _read_servlet_paths(root: Path, security: RepoSecurity) -> None:
|
|
1117
|
+
"""`spring.mvc.servlet.path` / `spring.jersey.application-path` below `/`
|
|
1118
|
+
(or an unreadable value such as `${PATH}`) in application properties or YAML."""
|
|
1119
|
+
for path in iter_repo_files(root, suffixes=CONFIG_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
1120
|
+
name = path.name.lower()
|
|
1121
|
+
if not (name.startswith("application") or name.startswith("bootstrap")):
|
|
1122
|
+
continue
|
|
1123
|
+
relative = rel(path, root)
|
|
1124
|
+
if _is_test_path(relative):
|
|
1125
|
+
continue
|
|
1126
|
+
text = read_source(path, root=root)
|
|
1127
|
+
if text is None:
|
|
1128
|
+
continue
|
|
1129
|
+
found = [(m.group(1), m.group(2)) for m in SERVLET_PATH_PROPERTY_RE.finditer(text)]
|
|
1130
|
+
if path.suffix.lower() in (".yml", ".yaml"):
|
|
1131
|
+
found += _yaml_servlet_paths(text)
|
|
1132
|
+
for key, value in found:
|
|
1133
|
+
if value not in _ROOT_PATHS:
|
|
1134
|
+
(security.mvc_path_files if key.startswith("mvc") else security.jersey_path_files).add(relative)
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _yaml_servlet_paths(text: str) -> list[tuple[str, str]]:
|
|
1138
|
+
"""`(kind, value)` for `spring.mvc.servlet.path` / `spring.jersey.application-path`
|
|
1139
|
+
in a YAML file, following block-mapping indentation (`spring:` / ` mvc:` /
|
|
1140
|
+
` servlet:` / ` path: /app`) and dotted keys (`spring.mvc.servlet.path: /app`)."""
|
|
1141
|
+
found: list[tuple[str, str]] = []
|
|
1142
|
+
stack: list[tuple[int, str]] = []
|
|
1143
|
+
for count, line in enumerate(text.splitlines()):
|
|
1144
|
+
if count >= MAX_YAML_LINES:
|
|
1145
|
+
break
|
|
1146
|
+
if line.startswith("---"):
|
|
1147
|
+
stack = []
|
|
1148
|
+
continue
|
|
1149
|
+
m = YAML_LINE_RE.fullmatch(line)
|
|
1150
|
+
if m is None:
|
|
1151
|
+
continue
|
|
1152
|
+
indent = len(m.group(1))
|
|
1153
|
+
while stack and stack[-1][0] >= indent:
|
|
1154
|
+
stack.pop()
|
|
1155
|
+
key = ".".join([k for _, k in stack] + [m.group(2)])
|
|
1156
|
+
if m.group(3) is None or m.group(3) == "":
|
|
1157
|
+
stack.append((indent, m.group(2)))
|
|
1158
|
+
kind = SERVLET_PATH_KEYS.get(key)
|
|
1159
|
+
if kind is not None:
|
|
1160
|
+
found.append((kind, m.group(3) or ""))
|
|
1161
|
+
return found
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _module_of(root: Path, relative: str, cache: dict[str, str]) -> str:
|
|
1165
|
+
"""Directory (relative to ``root``) of the nearest build file above ``relative``.
|
|
1166
|
+
|
|
1167
|
+
``cache`` maps every directory already looked at to its module.
|
|
1168
|
+
"""
|
|
1169
|
+
directory = relative.rsplit("/", 1)[0] if "/" in relative else ""
|
|
1170
|
+
walked: list[str] = []
|
|
1171
|
+
found = ""
|
|
1172
|
+
while True:
|
|
1173
|
+
if directory in cache:
|
|
1174
|
+
found = cache[directory]
|
|
1175
|
+
break
|
|
1176
|
+
walked.append(directory)
|
|
1177
|
+
base = root / directory if directory else root
|
|
1178
|
+
if not directory or any((base / name).is_file() for name in MODULE_BUILD_FILES):
|
|
1179
|
+
found = directory
|
|
1180
|
+
break
|
|
1181
|
+
directory = directory.rsplit("/", 1)[0] if "/" in directory else ""
|
|
1182
|
+
for seen in walked:
|
|
1183
|
+
cache[seen] = found
|
|
1184
|
+
return found
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
__all__ = ["JavaSpringAnalyzer"]
|