@hoanghainh/komagu 2.4.0 → 2.5.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 (55) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +202 -0
  3. package/README.md +1 -0
  4. package/assess/lsa/scripts/detect_stack.py +13 -5
  5. package/assess/lsa/scripts/find_injection.py +35 -2
  6. package/assess/lsa/scripts/java_routes.py +323 -0
  7. package/assess/lsa/scripts/lsa_common.py +72 -2
  8. package/assess/lsa/scripts/map_routes.py +36 -160
  9. package/assess/lsa/scripts/merge_findings.py +2 -0
  10. package/assess/lsa/tests/test_java_legacy_realistic.py +453 -0
  11. package/contracts/schemas/persona-suite.schema.json +38 -2
  12. package/examples/member-portal/backend-dotnet/Dockerfile +2 -1
  13. package/examples/member-portal/backend-spring/Dockerfile +2 -0
  14. package/examples/member-portal/docker-compose.yml +1 -1
  15. package/examples/member-portal/frontend/Dockerfile +3 -2
  16. package/examples/member-portal/frontend/package-lock.json +24 -656
  17. package/examples/member-portal/frontend/package.json +1 -1
  18. package/operate/ai/runner.py +20 -2
  19. package/operate/cli/__init__.py +1 -1
  20. package/operate/cli/commands/fix.py +4 -0
  21. package/operate/cli/commands/leak_check.py +75 -0
  22. package/operate/cli/commands/protect.py +32 -3
  23. package/operate/cli/commands/test.py +131 -15
  24. package/operate/cli/main.py +5 -3
  25. package/operate/cross_user_tester/persona_matrix.py +118 -25
  26. package/operate/cross_user_tester/runner.py +190 -7
  27. package/operate/cross_user_tester/session_config.py +180 -0
  28. package/operate/cross_user_tester/session_http.py +288 -0
  29. package/operate/devsecops/leak_check.py +314 -0
  30. package/operate/remediator/runner.py +18 -1
  31. package/operate/remediator/strategies.py +2 -2
  32. package/operate/simulate/scenarios.py +2 -2
  33. package/operate/tests/mock_legacy_session_app.py +160 -0
  34. package/operate/tests/test_cross_user_session.py +565 -0
  35. package/operate/tests/test_docker_packaging.py +3 -3
  36. package/operate/tests/test_headless_ai.py +6 -0
  37. package/operate/tests/test_leak_check.py +250 -0
  38. package/operate/tests/test_legacy_session_edge.py +192 -0
  39. package/operate/tests/test_pilot_safeguards.py +245 -0
  40. package/operate/tests/test_remediator_java_legacy.py +330 -0
  41. package/package.json +2 -1
  42. package/remediate/authz/patcher.py +120 -11
  43. package/remediate/authz/tests/test_bola_patcher.py +4 -1
  44. package/remediate/edge/coraza/Caddyfile +1 -1
  45. package/remediate/edge/coraza/Dockerfile +9 -0
  46. package/remediate/edge/coraza/rules/coraza-crs-custom.conf +12 -0
  47. package/remediate/edge/coraza/virtual_patcher.py +24 -0
  48. package/remediate/edge/docker-compose.yml +7 -2
  49. package/remediate/edge/kong/kong-legacy-session.yaml +51 -0
  50. package/remediate/edge/mock-backend/Dockerfile +2 -0
  51. package/remediate/edge/tests/test_edge_security.py +21 -1
  52. package/remediate/resilience/terraform/backup_worm.tf +39 -1
  53. package/remediate/resilience/terraform/main.tf +15 -3
  54. package/remediate/resilience/terraform/variables.tf +11 -0
  55. package/remediate/resilience/tests/test_resilience.py +16 -0
@@ -9,8 +9,9 @@ to call it. Every other persona is sent the request and must be refused:
9
9
  normal write plus privileged fields; reading the object back shows that a
10
10
  privileged field was stored.
11
11
 
12
- Tokens are read only from environment variables (``token_env``). They never
13
- appear in a suite file, a result or a report.
12
+ Tokens are read only from environment variables (``token_env``). Session
13
+ cookies come from an environment variable or a file (``cookie_env`` /
14
+ ``cookie_file``). Neither appears in a suite file, a result or a report.
14
15
 
15
16
  Python standard library only.
16
17
  """
@@ -24,14 +25,31 @@ import time
24
25
  import urllib.error
25
26
  import urllib.parse
26
27
  import urllib.request
27
- from dataclasses import dataclass, field
28
+ from dataclasses import dataclass, field, replace
28
29
  from typing import Any, Dict, List, Mapping, Optional, Tuple
29
30
 
31
+ from operate.cross_user_tester.session_config import read_cookie
32
+ from operate.cross_user_tester.session_http import (
33
+ ALLOWED,
34
+ DEFAULT_DENY_STATUS,
35
+ DENIED,
36
+ INCONCLUSIVE,
37
+ STRICT_DENY_STATUS,
38
+ CsrfConfig,
39
+ DenialSignal,
40
+ HttpReply,
41
+ SessionConfigError,
42
+ SessionRequest,
43
+ classify,
44
+ deliver,
45
+ is_form_action,
46
+ parse_body_format,
47
+ parse_csrf,
48
+ )
30
49
  from operate.lsa_sarif.sarif_findings import findings_from_sarif, is_sarif
31
50
 
32
51
  SUITE_VERSION = 2
33
- DENY_STATUS = (401, 403, 404)
34
- STRICT_DENY_STATUS = (404,)
52
+ DENY_STATUS = DEFAULT_DENY_STATUS
35
53
  ALLOW_STATUS = (200, 201, 202, 204)
36
54
  WRITE_METHODS = ("POST", "PUT", "PATCH")
37
55
  HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
@@ -40,8 +58,8 @@ MAX_BODY_BYTES = 1_000_000
40
58
 
41
59
  _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
42
60
  _PLACEHOLDER = re.compile(r"\{([A-Za-z0-9_-]+)\}|:([A-Za-z_][A-Za-z0-9_]*)")
43
- _PERSONA_KEYS = {"token_env", "description"}
44
- _ROUTE_KEYS = {"name", "method", "path", "params", "payload", "allow", "mass_assignment_probe"}
61
+ _PERSONA_KEYS = {"token_env", "cookie_env", "cookie_file", "description"}
62
+ _ROUTE_KEYS = {"name", "method", "path", "params", "payload", "allow", "mass_assignment_probe", "body_format", "csrf"}
45
63
  _PROBE_KEYS = {"fields", "persona", "readback"}
46
64
 
47
65
 
@@ -53,6 +71,8 @@ class SuiteError(ValueError):
53
71
  class Persona:
54
72
  name: str
55
73
  token_env: Optional[str] = None
74
+ cookie_env: Optional[str] = None
75
+ cookie_file: Optional[str] = None
56
76
 
57
77
 
58
78
  @dataclass(frozen=True)
@@ -68,6 +88,8 @@ class MatrixCase:
68
88
  probe_fields: Optional[Dict[str, Any]] = None
69
89
  readback: Optional[Tuple[str, str]] = None
70
90
  finding_id: Optional[str] = None
91
+ body_format: str = "json"
92
+ csrf: Optional[CsrfConfig] = None
71
93
 
72
94
 
73
95
  @dataclass(frozen=True)
@@ -121,7 +143,14 @@ def _parse_personas(raw: Any) -> Dict[str, Persona]:
121
143
  env = spec.get("token_env")
122
144
  if env is not None and (not isinstance(env, str) or not _ENV_NAME.match(env)):
123
145
  raise SuiteError(f"persona '{name}': token_env must be an environment variable name")
124
- personas[name] = Persona(name=name, token_env=env)
146
+ cookie_env, cookie_file = spec.get("cookie_env"), spec.get("cookie_file")
147
+ if cookie_env is not None and (not isinstance(cookie_env, str) or not _ENV_NAME.match(cookie_env)):
148
+ raise SuiteError(f"persona '{name}': cookie_env must be an environment variable name")
149
+ if cookie_file is not None and not isinstance(cookie_file, str):
150
+ raise SuiteError(f"persona '{name}': cookie_file must be a path")
151
+ if sum(v is not None for v in (env, cookie_env, cookie_file)) > 1:
152
+ raise SuiteError(f"persona '{name}': use only one of token_env, cookie_env, cookie_file")
153
+ personas[name] = Persona(name=name, token_env=env, cookie_env=cookie_env, cookie_file=cookie_file)
125
154
  return personas
126
155
 
127
156
 
@@ -154,6 +183,8 @@ def _route_cases(
154
183
  route: Any,
155
184
  personas: Dict[str, Persona],
156
185
  default_id: str,
186
+ default_body_format: str = "json",
187
+ default_csrf: Optional[CsrfConfig] = None,
157
188
  ) -> List[MatrixCase]:
158
189
  if not isinstance(route, dict):
159
190
  raise SuiteError(f"routes[{index}] must be an object")
@@ -179,6 +210,9 @@ def _route_cases(
179
210
  if payload is not None and not isinstance(payload, dict):
180
211
  raise SuiteError(f"routes[{index}]: 'payload' must be an object")
181
212
 
213
+ body_format = parse_body_format(route.get("body_format"), f"routes[{index}]", default_body_format)
214
+ csrf = parse_csrf(route.get("csrf"), f"routes[{index}]", default_csrf)
215
+
182
216
  path = fill_path(raw_path, params, default_id)
183
217
  label = route.get("name") or f"{method} {raw_path}"
184
218
  cases = [
@@ -189,6 +223,8 @@ def _route_cases(
189
223
  method=method,
190
224
  path=path,
191
225
  payload=payload if method in WRITE_METHODS else None,
226
+ body_format=body_format,
227
+ csrf=csrf,
192
228
  )
193
229
  for name, p in personas.items()
194
230
  if name not in allow
@@ -196,7 +232,11 @@ def _route_cases(
196
232
 
197
233
  probe = route.get("mass_assignment_probe")
198
234
  if probe is not None:
199
- cases.append(_probe_case(index, label, method, path, payload, probe, allow, personas, params, default_id))
235
+ cases.append(replace(
236
+ _probe_case(index, label, method, path, payload, probe, allow, personas, params, default_id),
237
+ body_format=body_format,
238
+ csrf=csrf,
239
+ ))
200
240
  return cases
201
241
 
202
242
 
@@ -237,7 +277,12 @@ def _probe_case(
237
277
  )
238
278
 
239
279
 
240
- def load_persona_suite(data: Dict[str, Any], default_id: str = "1") -> List[MatrixCase]:
280
+ def load_persona_suite(
281
+ data: Dict[str, Any],
282
+ default_id: str = "1",
283
+ default_body_format: str = "json",
284
+ default_csrf: Optional[CsrfConfig] = None,
285
+ ) -> List[MatrixCase]:
241
286
  """Build matrix cases from a parsed v2 suite."""
242
287
  if not is_v2_suite(data):
243
288
  raise SuiteError(f"suite 'version' must be {SUITE_VERSION}")
@@ -247,7 +292,7 @@ def load_persona_suite(data: Dict[str, Any], default_id: str = "1") -> List[Matr
247
292
  raise SuiteError("'routes' must be a non-empty list")
248
293
  cases: List[MatrixCase] = []
249
294
  for i, route in enumerate(routes):
250
- cases.extend(_route_cases(i, route, personas, default_id))
295
+ cases.extend(_route_cases(i, route, personas, default_id, default_body_format, default_csrf))
251
296
  return cases
252
297
 
253
298
 
@@ -279,6 +324,9 @@ def generate_matrix_cases_from_findings(
279
324
  token_env: Optional[str],
280
325
  default_id: str = "12345",
281
326
  min_consensus: float = 0.0,
327
+ cookie_env: Optional[str] = None,
328
+ body_format: str = "auto",
329
+ csrf: Optional[CsrfConfig] = None,
282
330
  ) -> List[MatrixCase]:
283
331
  """Turn BFLA and mass assignment findings (findings.json or SARIF) into matrix cases.
284
332
 
@@ -289,7 +337,7 @@ def generate_matrix_cases_from_findings(
289
337
  if is_sarif(data):
290
338
  data = findings_from_sarif(data)
291
339
  items = data if isinstance(data, list) else (data.get("findings", []) if isinstance(data, dict) else [])
292
- persona = Persona(name="user", token_env=token_env)
340
+ persona = Persona(name="user", token_env=None if cookie_env else token_env, cookie_env=cookie_env)
293
341
  cases: List[MatrixCase] = []
294
342
  for item in items:
295
343
  kind = _finding_kind(item)
@@ -304,11 +352,14 @@ def generate_matrix_cases_from_findings(
304
352
  methods = [m.strip().upper() for m in str(av.get("method") or ep.get("method") or "GET").split("|") if m.strip()]
305
353
  fid = str(item.get("finding_id", ""))
306
354
  title = item.get("title", path)
355
+ is_form = body_format == "form" or (body_format == "auto" and is_form_action(raw_path))
356
+ request_shape: Dict[str, Any] = {"body_format": "form" if is_form else "json", "csrf": csrf}
307
357
  if kind == "bfla":
308
358
  for m in methods:
309
359
  cases.append(MatrixCase(
310
360
  kind="bfla", name=f"[{fid}] {title} ({m})", persona=persona,
311
361
  method=m, path=path, payload={} if m in WRITE_METHODS else None, finding_id=fid,
362
+ **request_shape,
312
363
  ))
313
364
  continue
314
365
  write = next((m for m in methods if m in WRITE_METHODS), None)
@@ -320,6 +371,7 @@ def generate_matrix_cases_from_findings(
320
371
  kind="mass_assignment", name=f"[{fid}] {title} ({write})", persona=persona,
321
372
  method=write, path=path, payload={}, probe_fields=dict(fields),
322
373
  readback=(rb_method, fill_path(rb_path, {}, default_id)), finding_id=fid,
374
+ **request_shape,
323
375
  ))
324
376
  return cases
325
377
 
@@ -351,6 +403,7 @@ class PersonaMatrixRunner:
351
403
  auth_header: str = "Authorization",
352
404
  token_prefix: str = "Bearer ",
353
405
  timeout: float = 10.0,
406
+ denial: Optional[DenialSignal] = None,
354
407
  ) -> None:
355
408
  parsed = urllib.parse.urlsplit(base_url)
356
409
  if parsed.scheme not in ("http", "https") or not parsed.netloc:
@@ -358,6 +411,7 @@ class PersonaMatrixRunner:
358
411
  self.base_url = base_url.rstrip("/")
359
412
  self.env = os.environ if env is None else env
360
413
  self.deny_status = STRICT_DENY_STATUS if strict else DENY_STATUS
414
+ self.denial = denial
361
415
  self.auth_header = auth_header
362
416
  self.token_prefix = token_prefix
363
417
  self.timeout = timeout
@@ -371,7 +425,30 @@ class PersonaMatrixRunner:
371
425
  return None, f"environment variable {persona.token_env} is not set"
372
426
  return token, None
373
427
 
374
- def _send(self, method: str, path: str, token: Optional[str], body: Optional[Dict[str, Any]]) -> Tuple[int, bytes, Optional[str]]:
428
+ def _cookie(self, persona: Persona) -> Tuple[Optional[str], Optional[str]]:
429
+ """Return (cookie, error). Personas without a cookie source have neither."""
430
+ try:
431
+ return read_cookie(persona.cookie_env, persona.cookie_file, self.env, persona.name), None
432
+ except SessionConfigError as err:
433
+ return None, str(err)
434
+
435
+ def _session_mode(self, cookie: Optional[str], csrf: Optional[CsrfConfig], body_format: str) -> bool:
436
+ return cookie is not None or self.denial is not None or csrf is not None or body_format == "form"
437
+
438
+ def _deliver(
439
+ self, method: str, path: str, token: Optional[str], cookie: Optional[str], body: Optional[Dict[str, Any]],
440
+ body_format: str, csrf: Optional[CsrfConfig],
441
+ ) -> HttpReply:
442
+ auth = (self.auth_header, f"{self.token_prefix}{token}".strip()) if token else None
443
+ return deliver(self.base_url, SessionRequest(method, path, cookie, auth, body, body_format, csrf), self.timeout)
444
+
445
+ def _send(
446
+ self, method: str, path: str, token: Optional[str], body: Optional[Dict[str, Any]],
447
+ cookie: Optional[str] = None, body_format: str = "json", csrf: Optional[CsrfConfig] = None,
448
+ ) -> Tuple[int, bytes, Optional[str]]:
449
+ if self._session_mode(cookie, csrf, body_format):
450
+ reply = self._deliver(method, path, token, cookie, body, body_format, csrf)
451
+ return reply.status, reply.body.encode("utf-8"), reply.error
375
452
  headers: Dict[str, str] = {}
376
453
  if token:
377
454
  headers[self.auth_header] = f"{self.token_prefix}{token}".strip()
@@ -389,9 +466,9 @@ class PersonaMatrixRunner:
389
466
  except Exception as ex: # network errors are reported, not raised
390
467
  return 0, b"", f"{type(ex).__name__}: {ex}"
391
468
 
392
- def _readback(self, case: MatrixCase, token: Optional[str]) -> Tuple[Optional[Any], Optional[str]]:
469
+ def _readback(self, case: MatrixCase, token: Optional[str], cookie: Optional[str]) -> Tuple[Optional[Any], Optional[str]]:
393
470
  assert case.readback is not None
394
- status, raw, err = self._send(case.readback[0], case.readback[1], token, None)
471
+ status, raw, err = self._send(case.readback[0], case.readback[1], token, None, cookie)
395
472
  if err or status not in ALLOW_STATUS:
396
473
  return None, err or f"readback {case.readback[0]} {case.readback[1]} returned HTTP {status}"
397
474
  try:
@@ -399,7 +476,20 @@ class PersonaMatrixRunner:
399
476
  except ValueError:
400
477
  return None, "readback response is not JSON"
401
478
 
402
- def _run_bfla(self, case: MatrixCase, token: Optional[str], start: float) -> MatrixResult:
479
+ def _run_session_bfla(self, case: MatrixCase, token: Optional[str], cookie: Optional[str], start: float) -> MatrixResult:
480
+ signal = self.denial or DenialSignal(deny_status=self.deny_status)
481
+ reply = self._deliver(case.method, case.path, token, cookie, case.payload, case.body_format, case.csrf)
482
+ verdict, reason = classify(signal, reply.status, reply.location, reply.body) if not reply.error else (INCONCLUSIVE, reply.error)
483
+ return MatrixResult(
484
+ case=case, status_code=reply.status,
485
+ is_breach=verdict == ALLOWED, is_secure=verdict == DENIED,
486
+ duration_ms=(time.time() - start) * 1000.0,
487
+ error_message=reason if verdict == INCONCLUSIVE else None,
488
+ )
489
+
490
+ def _run_bfla(self, case: MatrixCase, token: Optional[str], cookie: Optional[str], start: float) -> MatrixResult:
491
+ if self._session_mode(cookie, case.csrf, case.body_format):
492
+ return self._run_session_bfla(case, token, cookie, start)
403
493
  status, _, err = self._send(case.method, case.path, token, case.payload)
404
494
  return MatrixResult(
405
495
  case=case, status_code=status,
@@ -407,26 +497,28 @@ class PersonaMatrixRunner:
407
497
  duration_ms=(time.time() - start) * 1000.0, error_message=err,
408
498
  )
409
499
 
410
- def _run_probe(self, case: MatrixCase, token: Optional[str], start: float) -> MatrixResult:
500
+ def _run_probe(self, case: MatrixCase, token: Optional[str], cookie: Optional[str], start: float) -> MatrixResult:
411
501
  fields = case.probe_fields or {}
412
502
 
413
503
  def done(status: int, breach: bool, secure: bool, err: Optional[str] = None, persisted: Tuple[str, ...] = ()) -> MatrixResult:
414
504
  return MatrixResult(case, status, breach, secure, (time.time() - start) * 1000.0, err, persisted)
415
505
 
416
- before, err = self._readback(case, token)
506
+ before, err = self._readback(case, token, cookie)
417
507
  if err:
418
508
  return done(0, False, False, err)
419
509
  already = [k for k, v in fields.items() if _contains_value(before, k, v)]
420
510
  if already:
421
511
  return done(0, False, False, f"inconclusive: {', '.join(already)} already has the probe value")
422
512
  body = {**(case.payload or {}), **fields}
423
- status, _, err = self._send(case.method, case.path, token, body)
513
+ status, _, err = self._send(case.method, case.path, token, body, cookie, case.body_format, case.csrf)
424
514
  if err:
425
515
  return done(status, False, False, err)
426
- if status not in ALLOW_STATUS:
516
+ # With redirects not followed, a 3xx may be post/redirect/get after a stored write: let the readback decide.
517
+ accepted = status in ALLOW_STATUS or (self._session_mode(cookie, case.csrf, case.body_format) and 300 <= status < 400)
518
+ if not accepted:
427
519
  # The write was refused (e.g. 400/422 for unknown fields): nothing stored.
428
520
  return done(status, False, True)
429
- after, err = self._readback(case, token)
521
+ after, err = self._readback(case, token, cookie)
430
522
  if err:
431
523
  return done(status, False, False, err)
432
524
  persisted = tuple(k for k, v in fields.items() if _contains_value(after, k, v))
@@ -435,11 +527,12 @@ class PersonaMatrixRunner:
435
527
  def execute(self, case: MatrixCase) -> MatrixResult:
436
528
  start = time.time()
437
529
  token, err = self._token(case.persona)
438
- if err:
439
- return MatrixResult(case, 0, False, False, 0.0, err)
530
+ cookie, cookie_err = self._cookie(case.persona)
531
+ if err or cookie_err:
532
+ return MatrixResult(case, 0, False, False, 0.0, err or cookie_err)
440
533
  if case.kind == "mass_assignment":
441
- return self._run_probe(case, token, start)
442
- return self._run_bfla(case, token, start)
534
+ return self._run_probe(case, token, cookie, start)
535
+ return self._run_bfla(case, token, cookie, start)
443
536
 
444
537
  def run(self, cases: List[MatrixCase]) -> List[MatrixResult]:
445
538
  return [self.execute(c) for c in cases]