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.
@@ -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"]