@hoanghainh/komagu 2.1.2 → 2.2.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/assess/lsa/knowledge/bfla-patterns.md +8 -0
  3. package/assess/lsa/knowledge/mass-assignment-patterns.md +7 -0
  4. package/operate/cli/__init__.py +1 -1
  5. package/package.json +1 -1
  6. package/remediate/authz/README.md +122 -1
  7. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/KomaguRoleOptions.cs +20 -0
  8. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RequireRoleAttribute.cs +34 -0
  9. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleAuthorizationFilter.cs +114 -0
  10. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleDenyResponse.cs +17 -0
  11. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/ServiceCollectionExtensions.cs +16 -0
  12. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/MassAssignmentDtoTests.cs +76 -0
  13. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/RoleAuthorizationFilterTests.cs +233 -0
  14. package/remediate/authz/golang/massassign.go +16 -0
  15. package/remediate/authz/golang/massassign_test.go +135 -0
  16. package/remediate/authz/golang/roles.go +168 -0
  17. package/remediate/authz/golang/roles_test.go +158 -0
  18. package/remediate/authz/nodejs/src/express/role-middleware.d.ts +14 -0
  19. package/remediate/authz/nodejs/src/express/role-middleware.js +52 -0
  20. package/remediate/authz/nodejs/src/index.d.ts +2 -0
  21. package/remediate/authz/nodejs/src/index.js +17 -2
  22. package/remediate/authz/nodejs/src/interfaces.d.ts +19 -4
  23. package/remediate/authz/nodejs/src/interfaces.js +10 -0
  24. package/remediate/authz/nodejs/src/mass-assignment.d.ts +8 -0
  25. package/remediate/authz/nodejs/src/mass-assignment.js +40 -0
  26. package/remediate/authz/nodejs/src/nestjs/decorators.d.ts +16 -0
  27. package/remediate/authz/nodejs/src/nestjs/decorators.js +27 -0
  28. package/remediate/authz/nodejs/src/nestjs/guards.d.ts +5 -0
  29. package/remediate/authz/nodejs/src/nestjs/guards.js +69 -2
  30. package/remediate/authz/nodejs/src/roles.js +90 -0
  31. package/remediate/authz/nodejs/src/service.d.ts +6 -0
  32. package/remediate/authz/nodejs/src/service.js +30 -0
  33. package/remediate/authz/nodejs/tests/test-authz.js +4 -0
  34. package/remediate/authz/nodejs/tests/test-roles.js +278 -0
  35. package/remediate/authz/spring/build.gradle.kts +5 -0
  36. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/SecurityAuditEvent.kt +10 -2
  37. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/data/MassAssignment.kt +18 -0
  38. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/AuthenticationRequiredException.kt +14 -0
  39. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/KomaguSecurityAutoConfiguration.kt +20 -0
  40. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RequireRole.kt +34 -0
  41. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleAspect.kt +97 -0
  42. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleDeniedException.kt +14 -0
  43. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/KomaguSecurityAutoConfigurationTest.kt +34 -0
  44. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/MassAssignmentTest.kt +81 -0
  45. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/RoleAspectTest.kt +196 -0
@@ -0,0 +1,81 @@
1
+ package io.komagu.security
2
+
3
+ import com.fasterxml.jackson.databind.DeserializationFeature
4
+ import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
5
+ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
6
+ import com.fasterxml.jackson.module.kotlin.readValue
7
+ import io.komagu.security.data.allowFields
8
+ import org.junit.jupiter.api.Test
9
+ import org.junit.jupiter.api.assertThrows
10
+ import java.math.BigDecimal
11
+ import kotlin.test.assertEquals
12
+
13
+ class MassAssignmentTest {
14
+
15
+ /** Persistent entity: role, isAdmin and balance are privileged and server-controlled. */
16
+ data class UserAccount(
17
+ val id: String,
18
+ val name: String,
19
+ val email: String,
20
+ val role: String,
21
+ val isAdmin: Boolean,
22
+ val balance: BigDecimal
23
+ )
24
+
25
+ /**
26
+ * Request DTO: declares ONLY the fields a user may edit on their own profile.
27
+ *
28
+ * Jackson's own default is FAIL_ON_UNKNOWN_PROPERTIES = true, so `role` / `isAdmin` / `balance` in the body
29
+ * make deserialization fail (map that to 400). Spring Boot's auto-configured ObjectMapper turns the feature
30
+ * OFF, so those fields are silently ignored instead. Either way they can never reach the entity, because the
31
+ * DTO has nowhere to put them and [applyTo] copies the allowed fields explicitly.
32
+ */
33
+ data class ProfileUpdate(val name: String, val email: String) {
34
+ fun applyTo(account: UserAccount): UserAccount = account.copy(name = name, email = email)
35
+ }
36
+
37
+ private val attackBody =
38
+ """{"name":"Mallory","email":"m@example.test","role":"admin","isAdmin":true,"balance":1000000}"""
39
+
40
+ private val stored = UserAccount("u-1", "Alice", "a@example.test", "user", false, BigDecimal("10.00"))
41
+
42
+ @Test
43
+ fun `allowFields keeps only allowed keys and returns a new map`() {
44
+ val source = mapOf("name" to "Mallory", "email" to "m@example.test", "role" to "admin", "isAdmin" to true)
45
+ val snapshot = source.toMap()
46
+
47
+ val filtered = allowFields(source, "name", "email")
48
+
49
+ assertEquals(mapOf("name" to "Mallory", "email" to "m@example.test"), filtered)
50
+ assertEquals(snapshot, source)
51
+ assertEquals(emptyMap(), allowFields(source))
52
+ assertEquals(emptyMap(), allowFields(source, "Name"))
53
+ }
54
+
55
+ @Test
56
+ fun `allowFields keeps explicit null values of allowed keys`() {
57
+ assertEquals(mapOf("email" to null), allowFields(mapOf("email" to null, "role" to null), "email"))
58
+ }
59
+
60
+ @Test
61
+ fun `strict mapper rejects privileged fields in the body`() {
62
+ val strict = jacksonObjectMapper().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
63
+ assertThrows<UnrecognizedPropertyException> { strict.readValue<ProfileUpdate>(attackBody) }
64
+ }
65
+
66
+ @Test
67
+ fun `lenient mapper ignores privileged fields and the explicit copy cannot change them`() {
68
+ // Same setting as Spring Boot's default ObjectMapper.
69
+ val lenient = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
70
+ val update = lenient.readValue<ProfileUpdate>(attackBody)
71
+
72
+ val saved = update.applyTo(stored)
73
+
74
+ assertEquals("Mallory", saved.name)
75
+ assertEquals("m@example.test", saved.email)
76
+ assertEquals("user", saved.role)
77
+ assertEquals(false, saved.isAdmin)
78
+ assertEquals(BigDecimal("10.00"), saved.balance)
79
+ assertEquals("u-1", saved.id)
80
+ }
81
+ }
@@ -0,0 +1,196 @@
1
+ package io.komagu.security
2
+
3
+ import io.komagu.security.authorization.DefaultKomaguSecurityManager
4
+ import io.komagu.security.authorization.SecurityAuditEvent
5
+ import io.komagu.security.authorization.SecurityAuditSink
6
+ import io.komagu.security.spring.AuthenticationRequiredException
7
+ import io.komagu.security.spring.RequireRole
8
+ import io.komagu.security.spring.ResourceNotFoundException
9
+ import io.komagu.security.spring.RoleAspect
10
+ import io.komagu.security.spring.RoleDeniedException
11
+ import org.junit.jupiter.api.AfterEach
12
+ import org.junit.jupiter.api.Test
13
+ import org.junit.jupiter.api.assertThrows
14
+ import org.springframework.aop.aspectj.annotation.AspectJProxyFactory
15
+ import org.springframework.mock.web.MockHttpServletRequest
16
+ import org.springframework.security.authentication.AnonymousAuthenticationToken
17
+ import org.springframework.security.authentication.TestingAuthenticationToken
18
+ import org.springframework.security.core.authority.AuthorityUtils
19
+ import org.springframework.security.core.context.SecurityContextHolder
20
+ import org.springframework.web.context.request.RequestContextHolder
21
+ import org.springframework.web.context.request.ServletRequestAttributes
22
+ import kotlin.test.assertEquals
23
+ import kotlin.test.assertTrue
24
+
25
+ class RoleAspectTest {
26
+
27
+ open class AdminService {
28
+ @RequireRole("admin")
29
+ open fun deleteUser(userId: String): String = "deleted-$userId"
30
+
31
+ @RequireRole("admin", "auditor")
32
+ open fun exportLogs(): String = "logs"
33
+
34
+ @RequireRole("admin", hideAsNotFound = true)
35
+ open fun hiddenEndpoint(): String = "hidden"
36
+
37
+ @RequireRole("ROLE_admin")
38
+ open fun prefixedRequirement(): String = "prefixed"
39
+
40
+ @RequireRole
41
+ open fun misconfigured(): String = "never"
42
+
43
+ /** Simulates a controller that also receives client-controlled role data. */
44
+ @RequireRole("admin")
45
+ open fun promote(role: String, body: Map<String, Any?>): String = "promoted-$role"
46
+
47
+ open fun unprotected(): String = "open"
48
+ }
49
+
50
+ @RequireRole("admin")
51
+ open class AdminOnlyController {
52
+ open fun dashboard(): String = "dashboard"
53
+
54
+ @RequireRole("auditor")
55
+ open fun auditTrail(): String = "audit"
56
+ }
57
+
58
+ private val events = mutableListOf<SecurityAuditEvent>()
59
+ private val sink = SecurityAuditSink { events.add(it) }
60
+
61
+ private fun aspect(hideDeniedAsNotFound: Boolean = false) =
62
+ RoleAspect(DefaultKomaguSecurityManager(sink), sink, hideDeniedAsNotFound)
63
+
64
+ private inline fun <reified T : Any> proxy(target: T, aspect: RoleAspect = aspect()): T {
65
+ val factory = AspectJProxyFactory(target)
66
+ factory.isProxyTargetClass = true
67
+ factory.addAspect(aspect)
68
+ return factory.getProxy()
69
+ }
70
+
71
+ private fun authenticate(user: String, vararg authorities: String) {
72
+ val auth = TestingAuthenticationToken(user, null, *authorities)
73
+ auth.details = mapOf("tenant_id" to "tenant-1")
74
+ SecurityContextHolder.getContext().authentication = auth
75
+ }
76
+
77
+ @AfterEach
78
+ fun clear() {
79
+ SecurityContextHolder.clearContext()
80
+ RequestContextHolder.resetRequestAttributes()
81
+ }
82
+
83
+ @Test
84
+ fun `caller with the required role is allowed`() {
85
+ authenticate("alice", "ROLE_admin")
86
+ assertEquals("deleted-42", proxy(AdminService()).deleteUser("42"))
87
+ assertTrue(events.isEmpty())
88
+ }
89
+
90
+ @Test
91
+ fun `any one of several required roles is enough`() {
92
+ authenticate("bob", "ROLE_auditor")
93
+ assertEquals("logs", proxy(AdminService()).exportLogs())
94
+ }
95
+
96
+ @Test
97
+ fun `caller without the role is denied with 403`() {
98
+ authenticate("mallory", "ROLE_user")
99
+ assertThrows<RoleDeniedException> { proxy(AdminService()).deleteUser("42") }
100
+ }
101
+
102
+ @Test
103
+ fun `annotation attribute hides the endpoint with 404`() {
104
+ authenticate("mallory", "ROLE_user")
105
+ assertThrows<ResourceNotFoundException> { proxy(AdminService()).hiddenEndpoint() }
106
+ }
107
+
108
+ @Test
109
+ fun `configured default hides every denied endpoint with 404`() {
110
+ authenticate("mallory", "ROLE_user")
111
+ assertThrows<ResourceNotFoundException> {
112
+ proxy(AdminService(), aspect(hideDeniedAsNotFound = true)).deleteUser("42")
113
+ }
114
+ }
115
+
116
+ @Test
117
+ fun `unauthenticated caller gets 401`() {
118
+ assertThrows<AuthenticationRequiredException> { proxy(AdminService()).deleteUser("42") }
119
+ }
120
+
121
+ @Test
122
+ fun `anonymous token is treated as unauthenticated`() {
123
+ SecurityContextHolder.getContext().authentication = AnonymousAuthenticationToken(
124
+ "key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS", "ROLE_admin")
125
+ )
126
+ assertThrows<AuthenticationRequiredException> { proxy(AdminService()).deleteUser("42") }
127
+ }
128
+
129
+ @Test
130
+ fun `role claimed in request parameter, header, body or argument is ignored`() {
131
+ authenticate("mallory", "ROLE_user")
132
+ val request = MockHttpServletRequest("POST", "/admin/promote")
133
+ request.addParameter("role", "admin")
134
+ request.addHeader("X-Role", "admin")
135
+ request.contentType = "application/json"
136
+ request.setContent("""{"role":"admin","isAdmin":true}""".toByteArray())
137
+ RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
138
+
139
+ assertThrows<RoleDeniedException> {
140
+ proxy(AdminService()).promote("admin", mapOf("role" to "admin", "isAdmin" to true))
141
+ }
142
+ assertEquals(1, events.size)
143
+ }
144
+
145
+ @Test
146
+ fun `ROLE_ prefix is normalized on both sides, the name stays case-sensitive`() {
147
+ authenticate("alice", "admin")
148
+ assertEquals("deleted-1", proxy(AdminService()).deleteUser("1"))
149
+ assertEquals("prefixed", proxy(AdminService()).prefixedRequirement())
150
+
151
+ authenticate("carol", "ROLE_Admin", "SCOPE_admin")
152
+ assertThrows<RoleDeniedException> { proxy(AdminService()).deleteUser("1") }
153
+ }
154
+
155
+ @Test
156
+ fun `denial emits a RoleDenied audit event with actor and required roles`() {
157
+ authenticate("mallory", "ROLE_user")
158
+ assertThrows<RoleDeniedException> { proxy(AdminService()).exportLogs() }
159
+
160
+ val event = events.single()
161
+ assertEquals(SecurityAuditEvent.ROLE_DENIED, event.eventName)
162
+ assertEquals("Komagu.Authorization.RoleDenied", event.eventName)
163
+ assertEquals("mallory", event.actorUserId)
164
+ assertEquals("tenant-1", event.actorTenantId)
165
+ assertEquals("AdminService.exportLogs", event.resourceId)
166
+ assertTrue(event.reason.contains("admin") && event.reason.contains("auditor"), event.reason)
167
+ }
168
+
169
+ @Test
170
+ fun `failing audit sink does not change the denial`() {
171
+ authenticate("mallory", "ROLE_user")
172
+ val failing = SecurityAuditSink { error("sink down") }
173
+ val aspect = RoleAspect(DefaultKomaguSecurityManager(failing), failing)
174
+ assertThrows<RoleDeniedException> { proxy(AdminService(), aspect).deleteUser("42") }
175
+ }
176
+
177
+ @Test
178
+ fun `empty role list fails fast before the method runs`() {
179
+ authenticate("alice", "ROLE_admin")
180
+ assertThrows<IllegalStateException> { proxy(AdminService()).misconfigured() }
181
+ assertThrows<IllegalStateException> { aspect().requireAnyRole(listOf(" ")) }
182
+ }
183
+
184
+ @Test
185
+ fun `class-level annotation applies and a method-level one overrides it`() {
186
+ authenticate("bob", "ROLE_auditor")
187
+ val controller = proxy(AdminOnlyController())
188
+ assertThrows<RoleDeniedException> { controller.dashboard() }
189
+ assertEquals("audit", controller.auditTrail())
190
+ }
191
+
192
+ @Test
193
+ fun `unannotated method is not intercepted`() {
194
+ assertEquals("open", proxy(AdminService()).unprotected())
195
+ }
196
+ }