@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
  3. package/examples/member-portal/backend-dotnet/Program.cs +1 -72
  4. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
  5. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
  6. package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
  7. package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
  8. package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
  9. package/operate/ai/client.py +5 -1
  10. package/operate/cli/__init__.py +1 -1
  11. package/operate/cross_user_tester/runner.py +4 -1
  12. package/operate/devsecops/sbom.py +12 -4
  13. package/operate/simulate/scenarios.py +11 -2
  14. package/operate/tests/test_audit_minimum_versions.py +89 -0
  15. package/operate/tests/test_cli.py +1 -1
  16. package/operate/tests/test_scheme_and_xml_guards.py +67 -0
  17. package/package.json +1 -1
  18. package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
  19. package/remediate/authz/golang/authz_test.go +5 -2
  20. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
  21. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
  22. package/remediate/deps/patcher.py +2 -1
  23. package/remediate/edge/mock-backend/app.py +2 -1
  24. package/remediate/edge/tests/test_edge_security.py +5 -5
  25. package/remediate/ekyc/requirements.txt +1 -1
  26. package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
  27. package/remediate/identity/python/requirements.txt +2 -2
  28. package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
  29. package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
  30. package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
  31. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
  32. 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 System.Security.Claims;
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
- }
@@ -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,
@@ -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
@@ -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
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
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)
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.6.0" # x-release-please-version
5
+ __version__ = "2.6.1" # x-release-please-version
@@ -242,7 +242,10 @@ class CrossUserTestRunner:
242
242
  error_message = None
243
243
 
244
244
  try:
245
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
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
- tree = ET.parse(file_path)
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
- tree = ET.parse(file_path)
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
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
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
- with urllib.request.urlopen(req, timeout=self.timeout) as resp:
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", "/tmp", "--format", "html"])
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.0",
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",