@hoanghainh/komagu 2.6.0 → 2.6.1
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.
- package/CHANGELOG.md +13 -0
- package/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
- package/examples/member-portal/backend-dotnet/Program.cs +1 -72
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
- package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
- package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
- package/operate/ai/client.py +5 -1
- package/operate/cli/__init__.py +1 -1
- package/operate/cross_user_tester/runner.py +4 -1
- package/operate/devsecops/sbom.py +12 -4
- package/operate/simulate/scenarios.py +11 -2
- package/operate/tests/test_audit_minimum_versions.py +89 -0
- package/operate/tests/test_cli.py +1 -1
- package/operate/tests/test_scheme_and_xml_guards.py +67 -0
- package/package.json +1 -1
- package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
- package/remediate/authz/golang/authz_test.go +5 -2
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
- package/remediate/deps/patcher.py +2 -1
- package/remediate/edge/mock-backend/app.py +2 -1
- package/remediate/edge/tests/test_edge_security.py +5 -5
- package/remediate/ekyc/requirements.txt +1 -1
- package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
- package/remediate/identity/python/requirements.txt +2 -2
- package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
- package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
- package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
- package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
- package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/detekt/StringBuiltSqlQueryRule.kt +19 -24
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
|
|
4
4
|
|
|
5
|
+
## [2.6.1](https://github.com/hoanghainh1188/komagu/compare/v2.6.0...v2.6.1) (2026-11-11)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **deps:** raise vulnerable minimum versions and gate dependency audits ([#391](https://github.com/hoanghainh1188/komagu/issues/391)) ([40173a3](https://github.com/hoanghainh1188/komagu/commit/40173a36826b9a69ecb1046159d077381494850c))
|
|
11
|
+
* **security:** clear the 64 open code scanning alerts before gating ([#388](https://github.com/hoanghainh1188/komagu/issues/388)) ([49372da](https://github.com/hoanghainh1188/komagu/commit/49372da959690251db6d0810c60eb15ba3f2591f))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Documentation
|
|
15
|
+
|
|
16
|
+
* **pilot:** pin the runbook to v2.6.0 ([#386](https://github.com/hoanghainh1188/komagu/issues/386)) ([b416ff8](https://github.com/hoanghainh1188/komagu/commit/b416ff8613410d74b71a485a1ac05297abf164c6))
|
|
17
|
+
|
|
5
18
|
## [2.6.0](https://github.com/hoanghainh1188/komagu/compare/v2.5.0...v2.6.0) (2026-11-11)
|
|
6
19
|
|
|
7
20
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
namespace MemberPortal.Backend.Auth;
|
|
2
|
+
|
|
3
|
+
using System.Security.Claims;
|
|
4
|
+
using System.Text.Encodings.Web;
|
|
5
|
+
using Microsoft.AspNetCore.Authentication;
|
|
6
|
+
using Microsoft.Extensions.Options;
|
|
7
|
+
|
|
8
|
+
/// <summary>
|
|
9
|
+
/// Flexible authentication handler supporting mock tokens for personas as well as JWT claims.
|
|
10
|
+
/// </summary>
|
|
11
|
+
public class DemoAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
|
12
|
+
{
|
|
13
|
+
public DemoAuthenticationHandler(
|
|
14
|
+
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
15
|
+
ILoggerFactory logger,
|
|
16
|
+
UrlEncoder encoder)
|
|
17
|
+
: base(options, logger, encoder)
|
|
18
|
+
{
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
22
|
+
{
|
|
23
|
+
if (!Request.Headers.TryGetValue("Authorization", out var authHeaderValues))
|
|
24
|
+
{
|
|
25
|
+
// Anonymous fallback
|
|
26
|
+
return Task.FromResult(AuthenticateResult.NoResult());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var header = authHeaderValues.ToString();
|
|
30
|
+
var token = header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
|
31
|
+
? header.Substring(7).Trim()
|
|
32
|
+
: header.Trim();
|
|
33
|
+
|
|
34
|
+
var claims = token switch
|
|
35
|
+
{
|
|
36
|
+
"token-user-a" => new[]
|
|
37
|
+
{
|
|
38
|
+
new Claim(ClaimTypes.NameIdentifier, "user-a"),
|
|
39
|
+
new Claim("sub", "user-a"),
|
|
40
|
+
new Claim("tenant_id", "tenant-alpha"),
|
|
41
|
+
new Claim("name", "Alice"),
|
|
42
|
+
new Claim(ClaimTypes.Role, "member")
|
|
43
|
+
},
|
|
44
|
+
"token-user-b" => new[]
|
|
45
|
+
{
|
|
46
|
+
new Claim(ClaimTypes.NameIdentifier, "user-b"),
|
|
47
|
+
new Claim("sub", "user-b"),
|
|
48
|
+
new Claim("tenant_id", "tenant-alpha"),
|
|
49
|
+
new Claim("name", "Bob"),
|
|
50
|
+
new Claim(ClaimTypes.Role, "member")
|
|
51
|
+
},
|
|
52
|
+
"token-user-c" => new[]
|
|
53
|
+
{
|
|
54
|
+
new Claim(ClaimTypes.NameIdentifier, "user-c"),
|
|
55
|
+
new Claim("sub", "user-c"),
|
|
56
|
+
new Claim("tenant_id", "tenant-beta"),
|
|
57
|
+
new Claim("name", "Charlie"),
|
|
58
|
+
new Claim(ClaimTypes.Role, "member")
|
|
59
|
+
},
|
|
60
|
+
_ => new[]
|
|
61
|
+
{
|
|
62
|
+
new Claim(ClaimTypes.NameIdentifier, token),
|
|
63
|
+
new Claim("sub", token),
|
|
64
|
+
new Claim("tenant_id", "tenant-alpha"),
|
|
65
|
+
new Claim(ClaimTypes.Role, "member")
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
|
70
|
+
var principal = new ClaimsPrincipal(identity);
|
|
71
|
+
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
|
72
|
+
|
|
73
|
+
return Task.FromResult(AuthenticateResult.Success(ticket));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
using
|
|
2
|
-
using System.Text.Encodings.Web;
|
|
1
|
+
using MemberPortal.Backend.Auth;
|
|
3
2
|
using MemberPortal.Backend.Data;
|
|
4
3
|
using Microsoft.AspNetCore.Authentication;
|
|
5
4
|
using Microsoft.EntityFrameworkCore;
|
|
6
|
-
using Microsoft.Extensions.Options;
|
|
7
5
|
using Komagu.Security.Authorization;
|
|
8
6
|
|
|
9
7
|
var builder = WebApplication.CreateBuilder(args);
|
|
@@ -53,72 +51,3 @@ app.UseAuthorization();
|
|
|
53
51
|
app.MapControllers();
|
|
54
52
|
|
|
55
53
|
app.Run();
|
|
56
|
-
|
|
57
|
-
/// <summary>
|
|
58
|
-
/// Flexible authentication handler supporting mock tokens for personas as well as JWT claims.
|
|
59
|
-
/// </summary>
|
|
60
|
-
public class DemoAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
|
61
|
-
{
|
|
62
|
-
public DemoAuthenticationHandler(
|
|
63
|
-
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
|
64
|
-
ILoggerFactory logger,
|
|
65
|
-
UrlEncoder encoder)
|
|
66
|
-
: base(options, logger, encoder)
|
|
67
|
-
{
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
|
71
|
-
{
|
|
72
|
-
if (!Request.Headers.TryGetValue("Authorization", out var authHeaderValues))
|
|
73
|
-
{
|
|
74
|
-
// Anonymous fallback
|
|
75
|
-
return Task.FromResult(AuthenticateResult.NoResult());
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
var header = authHeaderValues.ToString();
|
|
79
|
-
var token = header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
|
80
|
-
? header.Substring(7).Trim()
|
|
81
|
-
: header.Trim();
|
|
82
|
-
|
|
83
|
-
var claims = token switch
|
|
84
|
-
{
|
|
85
|
-
"token-user-a" => new[]
|
|
86
|
-
{
|
|
87
|
-
new Claim(ClaimTypes.NameIdentifier, "user-a"),
|
|
88
|
-
new Claim("sub", "user-a"),
|
|
89
|
-
new Claim("tenant_id", "tenant-alpha"),
|
|
90
|
-
new Claim("name", "Alice"),
|
|
91
|
-
new Claim(ClaimTypes.Role, "member")
|
|
92
|
-
},
|
|
93
|
-
"token-user-b" => new[]
|
|
94
|
-
{
|
|
95
|
-
new Claim(ClaimTypes.NameIdentifier, "user-b"),
|
|
96
|
-
new Claim("sub", "user-b"),
|
|
97
|
-
new Claim("tenant_id", "tenant-alpha"),
|
|
98
|
-
new Claim("name", "Bob"),
|
|
99
|
-
new Claim(ClaimTypes.Role, "member")
|
|
100
|
-
},
|
|
101
|
-
"token-user-c" => new[]
|
|
102
|
-
{
|
|
103
|
-
new Claim(ClaimTypes.NameIdentifier, "user-c"),
|
|
104
|
-
new Claim("sub", "user-c"),
|
|
105
|
-
new Claim("tenant_id", "tenant-beta"),
|
|
106
|
-
new Claim("name", "Charlie"),
|
|
107
|
-
new Claim(ClaimTypes.Role, "member")
|
|
108
|
-
},
|
|
109
|
-
_ => new[]
|
|
110
|
-
{
|
|
111
|
-
new Claim(ClaimTypes.NameIdentifier, token),
|
|
112
|
-
new Claim("sub", token),
|
|
113
|
-
new Claim("tenant_id", "tenant-alpha"),
|
|
114
|
-
new Claim(ClaimTypes.Role, "member")
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
|
119
|
-
var principal = new ClaimsPrincipal(identity);
|
|
120
|
-
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
|
121
|
-
|
|
122
|
-
return Task.FromResult(AuthenticateResult.Success(ticket));
|
|
123
|
-
}
|
|
124
|
-
}
|
package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt
CHANGED
|
@@ -34,6 +34,8 @@ import java.time.Instant
|
|
|
34
34
|
// so the identity module's JWT decoder, which requires komagu.security.oidc.*, is left out.
|
|
35
35
|
@SpringBootApplication(exclude = [KomaguIdentityAutoConfiguration::class])
|
|
36
36
|
class Application {
|
|
37
|
+
// Demo seed data: one literal per row, read as a table.
|
|
38
|
+
@Suppress("LongMethod")
|
|
37
39
|
@Bean
|
|
38
40
|
fun seedDataRunner(
|
|
39
41
|
orderRepository: OrderRepository,
|
|
@@ -107,6 +109,8 @@ class Application {
|
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
// Standard Spring Boot entry point; the copy of args happens once at startup.
|
|
113
|
+
@Suppress("SpreadOperator")
|
|
110
114
|
fun main(args: Array<String>) {
|
|
111
115
|
runApplication<Application>(*args)
|
|
112
116
|
}
|
|
@@ -162,6 +162,9 @@ class DocumentController(
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
@RestController
|
|
165
|
+
// Each endpoint keeps the planted vulnerable branch and the remediated branch side by side,
|
|
166
|
+
// with early returns, so the demo shows the before and after in one place.
|
|
167
|
+
@Suppress("ReturnCount")
|
|
165
168
|
@RequestMapping("/api/v1/test")
|
|
166
169
|
class LegacyTestController(
|
|
167
170
|
private val orderRepository: OrderRepository,
|
package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt
CHANGED
|
@@ -8,6 +8,8 @@ import java.math.BigDecimal
|
|
|
8
8
|
import java.time.Instant
|
|
9
9
|
import java.util.UUID
|
|
10
10
|
|
|
11
|
+
// JPA entity: one constructor parameter per column.
|
|
12
|
+
@Suppress("LongParameterList")
|
|
11
13
|
@Entity
|
|
12
14
|
@Table(name = "portal_orders")
|
|
13
15
|
class OrderEntity(
|
|
@@ -21,6 +23,8 @@ class OrderEntity(
|
|
|
21
23
|
var createdAt: Instant = Instant.now()
|
|
22
24
|
) : OwnedResource<String>
|
|
23
25
|
|
|
26
|
+
// JPA entity: one constructor parameter per column.
|
|
27
|
+
@Suppress("LongParameterList")
|
|
24
28
|
@Entity
|
|
25
29
|
@Table(name = "portal_documents")
|
|
26
30
|
class DocumentEntity(
|
|
@@ -86,7 +86,7 @@ class TestDemoAppVerification(unittest.TestCase):
|
|
|
86
86
|
health_url = f"{cls.base_url}/api/v1/auth/personas"
|
|
87
87
|
for _ in range(50):
|
|
88
88
|
try:
|
|
89
|
-
with urllib.request.urlopen(health_url, timeout=1.0) as resp:
|
|
89
|
+
with urllib.request.urlopen(health_url, timeout=1.0) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
90
90
|
if resp.status == 200:
|
|
91
91
|
ready = True
|
|
92
92
|
break
|
|
@@ -124,7 +124,7 @@ class TestDemoAppVerification(unittest.TestCase):
|
|
|
124
124
|
url,
|
|
125
125
|
headers={"Authorization": "Bearer token-user-a"},
|
|
126
126
|
)
|
|
127
|
-
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
127
|
+
with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
128
128
|
self.assertEqual(resp.status, 200)
|
|
129
129
|
data = json.loads(resp.read().decode("utf-8"))
|
|
130
130
|
self.assertEqual(data["id"], "ord-001")
|
|
@@ -188,7 +188,7 @@ class TestDemoAppVerification(unittest.TestCase):
|
|
|
188
188
|
headers={"Authorization": "Bearer token-user-c"},
|
|
189
189
|
)
|
|
190
190
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
191
|
-
urllib.request.urlopen(req, timeout=5)
|
|
191
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
192
192
|
self.assertEqual(ctx.exception.code, 404)
|
|
193
193
|
|
|
194
194
|
def test_05_safe_sort_protects_against_sqli(self) -> None:
|
|
@@ -199,7 +199,7 @@ class TestDemoAppVerification(unittest.TestCase):
|
|
|
199
199
|
headers={"Authorization": "Bearer token-user-a"},
|
|
200
200
|
)
|
|
201
201
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
202
|
-
urllib.request.urlopen(req, timeout=5)
|
|
202
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
203
203
|
# Server must reject with 400 or 500 SecurityException, not 200
|
|
204
204
|
self.assertIn(ctx.exception.code, [400, 500])
|
|
205
205
|
|
|
@@ -88,7 +88,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
88
88
|
health_url = f"{cls.base_url}/api/v1/auth/personas"
|
|
89
89
|
for _ in range(50):
|
|
90
90
|
try:
|
|
91
|
-
with urllib.request.urlopen(health_url, timeout=1.0) as resp:
|
|
91
|
+
with urllib.request.urlopen(health_url, timeout=1.0) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
92
92
|
if resp.status == 200:
|
|
93
93
|
ready = True
|
|
94
94
|
break
|
|
@@ -126,7 +126,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
126
126
|
url,
|
|
127
127
|
headers={"Authorization": "Bearer token-user-a"},
|
|
128
128
|
)
|
|
129
|
-
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
129
|
+
with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
130
130
|
self.assertEqual(resp.status, 200)
|
|
131
131
|
data = json.loads(resp.read().decode("utf-8"))
|
|
132
132
|
self.assertEqual(data["id"], "ord-001")
|
|
@@ -169,7 +169,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
169
169
|
headers={"Authorization": "Bearer token-user-b"},
|
|
170
170
|
)
|
|
171
171
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
172
|
-
urllib.request.urlopen(req, timeout=5)
|
|
172
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
173
173
|
self.assertEqual(ctx.exception.code, 404)
|
|
174
174
|
|
|
175
175
|
def test_04_attacker_direct_put_unauthorized_returns_404(self) -> None:
|
|
@@ -186,7 +186,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
186
186
|
method="PUT",
|
|
187
187
|
)
|
|
188
188
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
189
|
-
urllib.request.urlopen(req, timeout=5)
|
|
189
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
190
190
|
self.assertEqual(ctx.exception.code, 404)
|
|
191
191
|
|
|
192
192
|
def test_05_attacker_direct_delete_unauthorized_returns_404(self) -> None:
|
|
@@ -198,7 +198,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
198
198
|
method="DELETE",
|
|
199
199
|
)
|
|
200
200
|
with self.assertRaises(urllib.error.HTTPError) as ctx:
|
|
201
|
-
urllib.request.urlopen(req, timeout=5)
|
|
201
|
+
urllib.request.urlopen(req, timeout=5) # nosec B310 # http://127.0.0.1 test server
|
|
202
202
|
self.assertEqual(ctx.exception.code, 404)
|
|
203
203
|
|
|
204
204
|
def test_06_parameterized_search_neutralizes_sqli(self) -> None:
|
|
@@ -210,7 +210,7 @@ class TestRemediationVerification(unittest.TestCase):
|
|
|
210
210
|
url,
|
|
211
211
|
headers={"Authorization": "Bearer token-user-a"},
|
|
212
212
|
)
|
|
213
|
-
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
213
|
+
with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # http://127.0.0.1 test server
|
|
214
214
|
self.assertEqual(resp.status, 200)
|
|
215
215
|
data = json.loads(resp.read().decode("utf-8"))
|
|
216
216
|
# When safely parameterized as LIKE '%...%', it searches for the literal string and matches nothing
|
package/operate/ai/client.py
CHANGED
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
import re
|
|
10
10
|
import time
|
|
11
11
|
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
12
13
|
import urllib.request
|
|
13
14
|
from typing import Any, Dict, List, Optional, Tuple
|
|
14
15
|
|
|
@@ -29,6 +30,8 @@ class LLMClient:
|
|
|
29
30
|
) -> None:
|
|
30
31
|
if reasoning_effort is not None and reasoning_effort not in REASONING_EFFORTS:
|
|
31
32
|
raise ValueError(f"reasoning_effort must be one of {', '.join(REASONING_EFFORTS)}")
|
|
33
|
+
if urllib.parse.urlparse(endpoint).scheme.lower() not in ("http", "https"):
|
|
34
|
+
raise ValueError("LLM endpoint must be an http:// or https:// URL")
|
|
32
35
|
raw_endpoint = endpoint.rstrip("/")
|
|
33
36
|
if not raw_endpoint.endswith("/chat/completions"):
|
|
34
37
|
self.endpoint = f"{raw_endpoint}/chat/completions"
|
|
@@ -58,7 +61,8 @@ class LLMClient:
|
|
|
58
61
|
req = urllib.request.Request(self.endpoint, data=data, headers=headers, method="POST")
|
|
59
62
|
started = time.monotonic()
|
|
60
63
|
try:
|
|
61
|
-
|
|
64
|
+
# The endpoint scheme is checked to be http(s) in __init__.
|
|
65
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
|
|
62
66
|
body = resp.read().decode("utf-8")
|
|
63
67
|
res_json = json.loads(body)
|
|
64
68
|
self._record_usage(res_json.get("usage") or {}, time.monotonic() - started)
|
package/operate/cli/__init__.py
CHANGED
|
@@ -242,7 +242,10 @@ class CrossUserTestRunner:
|
|
|
242
242
|
error_message = None
|
|
243
243
|
|
|
244
244
|
try:
|
|
245
|
-
|
|
245
|
+
if urllib.parse.urlparse(url).scheme.lower() not in ("http", "https"):
|
|
246
|
+
raise ValueError("base URL must be an http:// or https:// URL")
|
|
247
|
+
# The scheme is checked just above.
|
|
248
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
|
|
246
249
|
status_code = resp.status
|
|
247
250
|
raw = resp.read()
|
|
248
251
|
response_body = raw.decode("utf-8", errors="replace")[:1000]
|
|
@@ -24,6 +24,16 @@ import xml.etree.ElementTree as ET
|
|
|
24
24
|
from typing import Any, Dict, List, Optional
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _parse_xml_without_dtd(file_path: str) -> ET.Element:
|
|
28
|
+
"""Parse a manifest from the scanned project, refusing DTDs (entity expansion)."""
|
|
29
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
|
|
30
|
+
text = handle.read()
|
|
31
|
+
if "<!DOCTYPE" in text.upper() or "<!ENTITY" in text.upper():
|
|
32
|
+
raise ValueError(f"{file_path}: XML with a DTD is not parsed")
|
|
33
|
+
# DTDs are refused above, so no entity can be declared or expanded.
|
|
34
|
+
return ET.fromstring(text) # nosec B314
|
|
35
|
+
|
|
36
|
+
|
|
27
37
|
class Component:
|
|
28
38
|
"""Represents a software package or component in an SBOM."""
|
|
29
39
|
|
|
@@ -195,8 +205,7 @@ class SBOMGenerator:
|
|
|
195
205
|
def _parse_csproj(self, file_path: str) -> List[Component]:
|
|
196
206
|
components = []
|
|
197
207
|
try:
|
|
198
|
-
|
|
199
|
-
root = tree.getroot()
|
|
208
|
+
root = _parse_xml_without_dtd(file_path)
|
|
200
209
|
for pkg in root.findall(".//PackageReference"):
|
|
201
210
|
name = pkg.get("Include") or pkg.get("Update")
|
|
202
211
|
version = pkg.get("Version")
|
|
@@ -213,8 +222,7 @@ class SBOMGenerator:
|
|
|
213
222
|
def _parse_pom_xml(self, file_path: str) -> List[Component]:
|
|
214
223
|
components = []
|
|
215
224
|
try:
|
|
216
|
-
|
|
217
|
-
root = tree.getroot()
|
|
225
|
+
root = _parse_xml_without_dtd(file_path)
|
|
218
226
|
ns = {"m": "http://maven.apache.org/POM/4.0.0"}
|
|
219
227
|
# Search both with namespace and without
|
|
220
228
|
deps = root.findall(".//m:dependency", ns) or root.findall(".//dependency")
|
|
@@ -66,6 +66,11 @@ class ScenarioResult:
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def _require_http(target: str) -> None:
|
|
70
|
+
if urllib.parse.urlparse(target).scheme.lower() not in ("http", "https"):
|
|
71
|
+
raise ValueError("target must be an http:// or https:// URL")
|
|
72
|
+
|
|
73
|
+
|
|
69
74
|
class BaseScenario:
|
|
70
75
|
"""Base class for BAS attack scenarios."""
|
|
71
76
|
|
|
@@ -101,6 +106,7 @@ class TimesCarBreachScenario(BaseScenario):
|
|
|
101
106
|
sample_ids: Optional[List[str]] = None,
|
|
102
107
|
timeout: float = 10.0,
|
|
103
108
|
) -> None:
|
|
109
|
+
_require_http(target)
|
|
104
110
|
super().__init__(target, timeout)
|
|
105
111
|
self.endpoint_pattern = endpoint_pattern
|
|
106
112
|
self.attacker_token = attacker_token
|
|
@@ -145,7 +151,8 @@ class TimesCarBreachScenario(BaseScenario):
|
|
|
145
151
|
response_sample = ""
|
|
146
152
|
|
|
147
153
|
try:
|
|
148
|
-
|
|
154
|
+
# self.target is checked to be http(s) in __init__.
|
|
155
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
|
|
149
156
|
status_code = resp.status
|
|
150
157
|
body = resp.read().decode("utf-8", errors="replace")[:200]
|
|
151
158
|
response_sample = body
|
|
@@ -218,6 +225,7 @@ class MetabaseZeroDayScenario(BaseScenario):
|
|
|
218
225
|
target_param: str = "q",
|
|
219
226
|
timeout: float = 10.0,
|
|
220
227
|
) -> None:
|
|
228
|
+
_require_http(target)
|
|
221
229
|
super().__init__(target, timeout)
|
|
222
230
|
self.query_path = query_path
|
|
223
231
|
self.target_param = target_param
|
|
@@ -269,7 +277,8 @@ class MetabaseZeroDayScenario(BaseScenario):
|
|
|
269
277
|
response_sample = ""
|
|
270
278
|
|
|
271
279
|
try:
|
|
272
|
-
|
|
280
|
+
# self.target is checked to be http(s) in __init__.
|
|
281
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
|
|
273
282
|
status_code = resp.status
|
|
274
283
|
body = resp.read().decode("utf-8", errors="replace")[:300]
|
|
275
284
|
response_sample = body
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""`.github/scripts/audit_minimum_versions.py`: audits the lowest version each requirement allows (#390)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest import mock
|
|
12
|
+
|
|
13
|
+
SCRIPT = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "audit_minimum_versions.py"
|
|
14
|
+
_spec = importlib.util.spec_from_file_location("audit_minimum_versions", SCRIPT)
|
|
15
|
+
amv = importlib.util.module_from_spec(_spec)
|
|
16
|
+
_spec.loader.exec_module(amv)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _report(*vulns: tuple) -> dict:
|
|
20
|
+
deps = {}
|
|
21
|
+
for name, version, vuln_id, fixes in vulns:
|
|
22
|
+
deps.setdefault((name, version), []).append({"id": vuln_id, "fix_versions": fixes})
|
|
23
|
+
return {"dependencies": [{"name": n, "version": v, "vulns": vs} for (n, v), vs in deps.items()]}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestMinimumPins(unittest.TestCase):
|
|
27
|
+
def test_lower_bounds_and_exact_pins_become_pins(self) -> None:
|
|
28
|
+
text = "# comment\npyjwt>=2.15.0\ncryptography[ssh] >= 50.0.0 # note\nrequests==2.32.3\n"
|
|
29
|
+
self.assertEqual(amv.minimum_pins(text), ["pyjwt==2.15.0", "cryptography==50.0.0", "requests==2.32.3"])
|
|
30
|
+
|
|
31
|
+
def test_unversioned_and_upper_bounds_are_ignored(self) -> None:
|
|
32
|
+
self.assertEqual(amv.minimum_pins("flask\nurllib3<3\n-r other.txt\n"), [])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestClassify(unittest.TestCase):
|
|
36
|
+
def test_fixable_and_unfixable_are_split_and_deduplicated(self) -> None:
|
|
37
|
+
report = _report(
|
|
38
|
+
("pyjwt", "2.8.0", "PYSEC-1", ["2.12.0"]),
|
|
39
|
+
("pyjwt", "2.8.0", "PYSEC-1", ["2.12.0"]),
|
|
40
|
+
("pyjwt", "2.8.0", "PYSEC-2", []),
|
|
41
|
+
)
|
|
42
|
+
fixable, unfixable = amv.classify(report)
|
|
43
|
+
self.assertEqual(fixable, ["pyjwt 2.8.0: PYSEC-1 (fixed in 2.12.0)"])
|
|
44
|
+
self.assertEqual(unfixable, ["pyjwt 2.8.0: PYSEC-2 (no fixed version)"])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestMain(unittest.TestCase):
|
|
48
|
+
def setUp(self) -> None:
|
|
49
|
+
self.tmp = tempfile.mkdtemp(prefix="komagu_amv_")
|
|
50
|
+
self.req = os.path.join(self.tmp, "requirements.txt")
|
|
51
|
+
self.summary = os.path.join(self.tmp, "summary.md")
|
|
52
|
+
with open(self.req, "w", encoding="utf-8") as handle:
|
|
53
|
+
handle.write("pyjwt>=2.8.0\n")
|
|
54
|
+
|
|
55
|
+
def _run(self, report: dict) -> int:
|
|
56
|
+
with mock.patch.object(amv, "audit", return_value=report) as audit, \
|
|
57
|
+
mock.patch("builtins.print"):
|
|
58
|
+
code = amv.main(["--summary", self.summary, self.req])
|
|
59
|
+
audit.assert_called_once_with(["pyjwt==2.8.0"])
|
|
60
|
+
return code
|
|
61
|
+
|
|
62
|
+
def test_fixable_vulnerability_at_the_minimum_fails(self) -> None:
|
|
63
|
+
self.assertEqual(self._run(_report(("pyjwt", "2.8.0", "PYSEC-1", ["2.12.0"]))), 1)
|
|
64
|
+
with open(self.summary, encoding="utf-8") as handle:
|
|
65
|
+
self.assertIn("FAIL pyjwt 2.8.0: PYSEC-1", handle.read())
|
|
66
|
+
|
|
67
|
+
def test_vulnerability_without_a_fix_is_only_reported(self) -> None:
|
|
68
|
+
self.assertEqual(self._run(_report(("pyjwt", "2.8.0", "PYSEC-2", []))), 0)
|
|
69
|
+
with open(self.summary, encoding="utf-8") as handle:
|
|
70
|
+
self.assertIn("note pyjwt 2.8.0: PYSEC-2", handle.read())
|
|
71
|
+
|
|
72
|
+
def test_clean_minimum_passes(self) -> None:
|
|
73
|
+
self.assertEqual(self._run({"dependencies": [{"name": "pyjwt", "version": "2.8.0", "vulns": []}]}), 0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestRepositoryMinimums(unittest.TestCase):
|
|
77
|
+
"""Minimums raised in #390: these versions had fixable advisories."""
|
|
78
|
+
|
|
79
|
+
def test_raised_minimums(self) -> None:
|
|
80
|
+
root = SCRIPT.parents[2]
|
|
81
|
+
ekyc = amv.minimum_pins((root / "remediate/ekyc/requirements.txt").read_text(encoding="utf-8"))
|
|
82
|
+
identity = amv.minimum_pins((root / "remediate/identity/python/requirements.txt").read_text(encoding="utf-8"))
|
|
83
|
+
self.assertIn("cryptography==50.0.0", ekyc)
|
|
84
|
+
self.assertIn("cryptography==50.0.0", identity)
|
|
85
|
+
self.assertIn("pyjwt==2.15.0", identity)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -61,7 +61,7 @@ class TestKomaguCLI(unittest.TestCase):
|
|
|
61
61
|
def test_subcommand_parser_builds_cleanly(self) -> None:
|
|
62
62
|
"""In-process parser must parse subcommands without throwing."""
|
|
63
63
|
parser = build_parser()
|
|
64
|
-
args_scan = parser.parse_args(["scan", "
|
|
64
|
+
args_scan = parser.parse_args(["scan", "./src", "--format", "html"])
|
|
65
65
|
self.assertEqual(args_scan.subcommand, "scan")
|
|
66
66
|
self.assertEqual(args_scan.format, "html")
|
|
67
67
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Guards behind the Bandit B310 / B314 suppressions (#385).
|
|
3
|
+
|
|
4
|
+
Each `# nosec` added for #385 relies on a check: outbound requests only use
|
|
5
|
+
http(s) URLs, and SBOM manifests with a DTD are not parsed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
from operate.ai.client import LLMClient
|
|
16
|
+
from operate.cross_user_tester.runner import CrossUserTestRunner, TestCase
|
|
17
|
+
from operate.devsecops.sbom import SBOMGenerator
|
|
18
|
+
from operate.simulate.scenarios import MetabaseZeroDayScenario, TimesCarBreachScenario
|
|
19
|
+
|
|
20
|
+
CSPROJ = '<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="Dapper" Version="2.1.28" /></ItemGroup></Project>'
|
|
21
|
+
CSPROJ_WITH_DTD = (
|
|
22
|
+
'<?xml version="1.0"?>\n<!DOCTYPE Project [<!ENTITY a "aaaaaaaaaa">]>\n'
|
|
23
|
+
'<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><PackageReference Include="&a;" Version="1.0.0" /></ItemGroup></Project>'
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestHttpOnlyUrls(unittest.TestCase):
|
|
28
|
+
def test_llm_client_accepts_http_and_https(self) -> None:
|
|
29
|
+
self.assertEqual(LLMClient("http://127.0.0.1:11434/v1").endpoint, "http://127.0.0.1:11434/v1/chat/completions")
|
|
30
|
+
self.assertEqual(LLMClient("https://llm.example/v1/chat/completions").endpoint, "https://llm.example/v1/chat/completions")
|
|
31
|
+
|
|
32
|
+
def test_llm_client_rejects_other_schemes(self) -> None:
|
|
33
|
+
for endpoint in ("file:///etc/passwd", "ftp://host/v1", "127.0.0.1:11434/v1"):
|
|
34
|
+
with self.subTest(endpoint=endpoint), self.assertRaises(ValueError):
|
|
35
|
+
LLMClient(endpoint)
|
|
36
|
+
|
|
37
|
+
def test_scenarios_reject_other_schemes(self) -> None:
|
|
38
|
+
for scenario in (TimesCarBreachScenario, MetabaseZeroDayScenario):
|
|
39
|
+
with self.subTest(scenario=scenario.__name__), self.assertRaises(ValueError):
|
|
40
|
+
scenario("file:///etc")
|
|
41
|
+
|
|
42
|
+
def test_bearer_runner_does_not_open_other_schemes(self) -> None:
|
|
43
|
+
res = CrossUserTestRunner("file://").execute_test(TestCase("t", "/etc/passwd", "GET"))
|
|
44
|
+
self.assertFalse(res.is_secure or res.is_breach)
|
|
45
|
+
self.assertEqual(res.status_code, 0)
|
|
46
|
+
self.assertIn("http", res.error_message or "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestSbomRefusesDtd(unittest.TestCase):
|
|
50
|
+
def setUp(self) -> None:
|
|
51
|
+
self.root = tempfile.mkdtemp(prefix="komagu_sbom_dtd_")
|
|
52
|
+
self.addCleanup(shutil.rmtree, self.root, True)
|
|
53
|
+
|
|
54
|
+
def _scan(self, content: str) -> list:
|
|
55
|
+
with open(os.path.join(self.root, "Service.csproj"), "w", encoding="utf-8") as handle:
|
|
56
|
+
handle.write(content)
|
|
57
|
+
return [c.name for c in SBOMGenerator(root_dir=self.root).scan()]
|
|
58
|
+
|
|
59
|
+
def test_plain_manifest_is_parsed(self) -> None:
|
|
60
|
+
self.assertIn("Dapper", self._scan(CSPROJ))
|
|
61
|
+
|
|
62
|
+
def test_manifest_with_dtd_is_skipped(self) -> None:
|
|
63
|
+
self.assertEqual(self._scan(CSPROJ_WITH_DTD), [])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
unittest.main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoanghainh/komagu",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"description": "Security-First Legacy Modernization Platform CLI (LSA Kit, IAM Perimeter, Cross-User Testing)",
|
|
5
5
|
"main": "remediate/authz/nodejs/src/index.js",
|
|
6
6
|
"types": "remediate/authz/nodejs/src/index.d.ts",
|