@hoanghainh/komagu 2.3.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 (60) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +202 -0
  3. package/README.md +1 -0
  4. package/assess/lsa/CHANGELOG.md +15 -0
  5. package/assess/lsa/scripts/detect_stack.py +15 -6
  6. package/assess/lsa/scripts/find_injection.py +35 -2
  7. package/assess/lsa/scripts/java_routes.py +323 -0
  8. package/assess/lsa/scripts/lsa_common.py +73 -7
  9. package/assess/lsa/scripts/map_routes.py +36 -160
  10. package/assess/lsa/scripts/merge_findings.py +2 -0
  11. package/assess/lsa/scripts/redaction.py +118 -0
  12. package/assess/lsa/tests/test_java_legacy_realistic.py +453 -0
  13. package/assess/lsa/tests/test_modern_stacks.py +13 -0
  14. package/contracts/schemas/persona-suite.schema.json +38 -2
  15. package/examples/member-portal/backend-dotnet/Dockerfile +2 -1
  16. package/examples/member-portal/backend-spring/Dockerfile +2 -0
  17. package/examples/member-portal/docker-compose.yml +1 -1
  18. package/examples/member-portal/frontend/Dockerfile +3 -2
  19. package/examples/member-portal/frontend/package-lock.json +24 -656
  20. package/examples/member-portal/frontend/package.json +1 -1
  21. package/operate/ai/context.py +22 -2
  22. package/operate/ai/runner.py +20 -2
  23. package/operate/cli/__init__.py +1 -1
  24. package/operate/cli/commands/fix.py +4 -0
  25. package/operate/cli/commands/leak_check.py +75 -0
  26. package/operate/cli/commands/protect.py +32 -3
  27. package/operate/cli/commands/test.py +131 -15
  28. package/operate/cli/main.py +5 -3
  29. package/operate/cross_user_tester/persona_matrix.py +118 -25
  30. package/operate/cross_user_tester/runner.py +190 -7
  31. package/operate/cross_user_tester/session_config.py +180 -0
  32. package/operate/cross_user_tester/session_http.py +288 -0
  33. package/operate/devsecops/leak_check.py +314 -0
  34. package/operate/remediator/runner.py +18 -1
  35. package/operate/remediator/strategies.py +2 -2
  36. package/operate/simulate/scenarios.py +2 -2
  37. package/operate/tests/mock_legacy_session_app.py +160 -0
  38. package/operate/tests/test_ai_redaction.py +181 -0
  39. package/operate/tests/test_cross_user_session.py +565 -0
  40. package/operate/tests/test_docker_packaging.py +3 -3
  41. package/operate/tests/test_headless_ai.py +22 -1
  42. package/operate/tests/test_leak_check.py +250 -0
  43. package/operate/tests/test_legacy_session_edge.py +192 -0
  44. package/operate/tests/test_pilot_safeguards.py +245 -0
  45. package/operate/tests/test_remediator_java_legacy.py +330 -0
  46. package/package.json +2 -1
  47. package/remediate/authz/patcher.py +120 -11
  48. package/remediate/authz/tests/test_bola_patcher.py +4 -1
  49. package/remediate/edge/coraza/Caddyfile +1 -1
  50. package/remediate/edge/coraza/Dockerfile +9 -0
  51. package/remediate/edge/coraza/rules/coraza-crs-custom.conf +12 -0
  52. package/remediate/edge/coraza/virtual_patcher.py +24 -0
  53. package/remediate/edge/docker-compose.yml +7 -2
  54. package/remediate/edge/kong/kong-legacy-session.yaml +51 -0
  55. package/remediate/edge/mock-backend/Dockerfile +2 -0
  56. package/remediate/edge/tests/test_edge_security.py +21 -1
  57. package/remediate/resilience/terraform/backup_worm.tf +39 -1
  58. package/remediate/resilience/terraform/main.tf +15 -3
  59. package/remediate/resilience/terraform/variables.tf +11 -0
  60. package/remediate/resilience/tests/test_resilience.py +16 -0
@@ -379,7 +379,7 @@ class IDCFrontierRansomwareScenario(BaseScenario):
379
379
  self.target_dir.mkdir(parents=True, exist_ok=True)
380
380
  test_file.write_text("Komagu Immutable WORM Backup - Read Only", encoding="utf-8")
381
381
  # Mark file read-only (simulating local WORM compliance mode)
382
- os.chmod(str(test_file), 0o444)
382
+ os.chmod(str(test_file), 0o400)
383
383
  except Exception:
384
384
  pass
385
385
 
@@ -416,7 +416,7 @@ class IDCFrontierRansomwareScenario(BaseScenario):
416
416
 
417
417
  # Clean up
418
418
  try:
419
- os.chmod(str(test_file), 0o666)
419
+ os.chmod(str(test_file), 0o600)
420
420
  test_file.unlink(missing_ok=True)
421
421
  except Exception:
422
422
  pass
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Small session-based app that behaves like a JSP / Struts application.
3
+
4
+ Used to rehearse ``komagu test`` on legacy behaviour:
5
+
6
+ - login state lives in a ``JSESSIONID`` cookie;
7
+ - writes are ``application/x-www-form-urlencoded`` and need a single-use Struts token;
8
+ - a successful write answers 302 (post/redirect/get);
9
+ - a denied request is a 200 Shift_JIS error page, a 302 to login, or a 404,
10
+ depending on ``server.denial``;
11
+ - ``server.fixed`` is the ownership check being deployed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import secrets
17
+ import threading
18
+ from http.server import BaseHTTPRequestHandler, HTTPServer
19
+ from typing import Dict, List, NamedTuple, Optional, Tuple
20
+ from urllib.parse import parse_qs, urlsplit
21
+
22
+ ALICE_COOKIE = "JSESSIONID=sess-alice-0001"
23
+ BOB_COOKIE = "JSESSIONID=sess-bob-0002"
24
+ EXPIRED_COOKIE = "JSESSIONID=sess-expired-0003"
25
+ TOKEN_FIELD = "org.apache.struts.taglib.html.TOKEN"
26
+ DENIED_TEXT = "アクセス権限がありません"
27
+ LOGIN_REDIRECT = "/login.do;jsessionid=sess-rewritten-url-9999"
28
+
29
+
30
+ class Seen(NamedTuple):
31
+ method: str
32
+ path: str
33
+ cookie: Optional[str]
34
+ authorization: Optional[str]
35
+ content_type: Optional[str]
36
+ body: str
37
+
38
+
39
+ class LegacyHandler(BaseHTTPRequestHandler):
40
+ server: "LegacyServer"
41
+
42
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
43
+ pass
44
+
45
+ def _user(self) -> Optional[str]:
46
+ cookie = self.headers.get("Cookie", "")
47
+ pairs = dict(p.strip().split("=", 1) for p in cookie.split(";") if "=" in p)
48
+ return self.server.sessions.get(pairs.get("JSESSIONID", ""))
49
+
50
+ def _send(self, status: int, body: bytes = b"", charset: str = "UTF-8", location: Optional[str] = None) -> None:
51
+ self.send_response(status)
52
+ self.send_header("Content-Type", f"text/html; charset={charset}")
53
+ self.send_header("Content-Length", str(len(body)))
54
+ if location:
55
+ self.send_header("Location", location)
56
+ self.end_headers()
57
+ self.wfile.write(body)
58
+
59
+ def _to_login(self) -> None:
60
+ self._send(302, location=LOGIN_REDIRECT)
61
+
62
+ def _deny(self) -> None:
63
+ if self.server.denial == "login_redirect":
64
+ self._to_login()
65
+ elif self.server.denial == "not_found":
66
+ self._send(404, b"<html>Not Found</html>")
67
+ else:
68
+ page = f"<html><body><p>{DENIED_TEXT}</p></body></html>"
69
+ self._send(200, page.encode("cp932"), charset="Windows-31J")
70
+
71
+ def _may_access(self, user: str, order_id: str) -> bool:
72
+ owner = self.server.orders.get(order_id)
73
+ if owner is None:
74
+ return False
75
+ return owner == user or not self.server.fixed
76
+
77
+ def _record(self, method: str, body: str = "") -> None:
78
+ self.server.seen.append(Seen(
79
+ method, self.path, self.headers.get("Cookie"), self.headers.get("Authorization"),
80
+ self.headers.get("Content-Type"), body,
81
+ ))
82
+
83
+ def do_GET(self) -> None: # noqa: N802
84
+ self._record("GET")
85
+ url = urlsplit(self.path)
86
+ query = parse_qs(url.query)
87
+ if url.path == "/login.do":
88
+ self._send(200, b"<html>Please log in</html>")
89
+ return
90
+ user = self._user()
91
+ if user is None:
92
+ self._to_login()
93
+ elif url.path == "/home.do":
94
+ self._send(200, f"<html>Welcome {user}</html>".encode())
95
+ elif url.path == "/order/view.do":
96
+ self._view(user, query.get("id", [""])[0])
97
+ elif url.path == "/order/edit.do":
98
+ self._edit_form(user)
99
+ else:
100
+ self._send(404, b"<html>No such action</html>")
101
+
102
+ def _view(self, user: str, order_id: str) -> None:
103
+ if not self._may_access(user, order_id):
104
+ self._deny()
105
+ return
106
+ self._send(200, f"<html>Order detail {order_id}: secret-note</html>".encode())
107
+
108
+ def _edit_form(self, user: str) -> None:
109
+ if self.server.hide_token:
110
+ self._send(200, b"<html><form></form></html>")
111
+ return
112
+ token = secrets.token_hex(8)
113
+ self.server.tokens[user] = token
114
+ # value before name, as some tag libraries render it.
115
+ page = f'<html><form><input value="{token}" type="hidden" name="{TOKEN_FIELD}"></form></html>'
116
+ self._send(200, page.encode())
117
+
118
+ def do_POST(self) -> None: # noqa: N802
119
+ length = int(self.headers.get("Content-Length") or 0)
120
+ raw = self.rfile.read(length).decode("utf-8", "replace")
121
+ self._record("POST", raw)
122
+ user = self._user()
123
+ if user is None:
124
+ self._to_login()
125
+ return
126
+ if self.headers.get("Content-Type", "").split(";")[0] != "application/x-www-form-urlencoded":
127
+ self._send(415, b"<html>Form post expected</html>")
128
+ return
129
+ form = parse_qs(raw)
130
+ if urlsplit(self.path).path != "/order/update.do":
131
+ self._send(404, b"<html>No such action</html>")
132
+ return
133
+ if form.get(TOKEN_FIELD, [""])[0] != self.server.tokens.pop(user, None):
134
+ self._send(400, b"<html>Invalid token</html>")
135
+ return
136
+ order_id = form.get("id", [""])[0]
137
+ if not self._may_access(user, order_id):
138
+ self._deny()
139
+ return
140
+ self.server.updates.append((user, order_id))
141
+ self._send(302, location="/order/list.do")
142
+
143
+
144
+ class LegacyServer(HTTPServer):
145
+ def __init__(self) -> None:
146
+ super().__init__(("127.0.0.1", 0), LegacyHandler)
147
+ self.fixed = False
148
+ self.denial = "error_page"
149
+ self.hide_token = False
150
+ self.orders: Dict[str, str] = {"100": "alice", "200": "bob"}
151
+ self.sessions: Dict[str, str] = {"sess-alice-0001": "alice", "sess-bob-0002": "bob"}
152
+ self.tokens: Dict[str, str] = {}
153
+ self.updates: List[Tuple[str, str]] = []
154
+ self.seen: List[Seen] = []
155
+
156
+
157
+ def start_legacy_app() -> Tuple[LegacyServer, str]:
158
+ server = LegacyServer()
159
+ threading.Thread(target=server.serve_forever, daemon=True).start()
160
+ return server, f"http://127.0.0.1:{server.server_port}"
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """Secrets are masked in every code snippet the headless agents send to the model (#350)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import shutil
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ import time
12
+
13
+ from operate.ai.context import _model_source, _read_snippet, _redact_lines, _verification_context
14
+
15
+ JAVA_DAO = '''package com.acme.dao;
16
+ public class AccountDao {
17
+ private static final String URL = "jdbc:sqlserver://db01;databaseName=ams;user=sa;password=Ams#2019!";
18
+ private static final String MONGO = "mongodb://svc:Mongo2020pw@mongo01:27017/ams";
19
+ private static final String ORA = "jdbc:oracle:thin:scott/Tiger2018@db02:1521/AMS";
20
+ // db.password=CommentPw7 (old config, kept for reference)
21
+ private String apiKey = "sk-live-abcdef123456";
22
+ public Account find(HttpServletRequest req) {
23
+ String password = req.getParameter("password");
24
+ String sql = "SELECT * FROM account WHERE id = " + req.getParameter("id");
25
+ return jdbc.query(sql);
26
+ }
27
+ }
28
+ '''
29
+
30
+ PROPERTIES = '''spring.datasource.url=jdbc:oracle:thin:@db02:1521/AMS
31
+ spring.datasource.username=ams_app
32
+ spring.datasource.password=Oracle#Pw2021
33
+ mail.smtp.password: smtpPw99
34
+ app.jwt.secret=c2VjcmV0LWtleS1mb3ItYW1z
35
+ app.token.placeholder=${APP_TOKEN}
36
+ '''
37
+
38
+ PRIVATE_KEY = '''-----BEGIN RSA PRIVATE KEY-----
39
+ MIIEowIBAAKCAQEAwJ1rdTw6kLqA
40
+ Zm9vYmFyYmF6cXV4cXV1eHF1dXg=
41
+ -----END RSA PRIVATE KEY-----
42
+ class Signer {}
43
+ '''
44
+
45
+ JSP = '''<%-- legacy: ldap.bind.password=LdapPw55 --%>
46
+ <%@ page import="java.sql.*" %>
47
+ <% String id = request.getParameter("id"); %>
48
+ '''
49
+
50
+ SECRETS = ("Ams#2019!", "Mongo2020pw", "Tiger2018", "CommentPw7", "sk-live-abcdef123456", "Oracle#Pw2021", "smtpPw99",
51
+ "c2VjcmV0LWtleS1mb3ItYW1z", "MIIEowIBAAKCAQEAwJ1rdTw6kLqA", "Zm9vYmFyYmF6cXV4cXV1eHF1dXg=", "LdapPw55")
52
+
53
+
54
+ class TestSnippetRedaction(unittest.TestCase):
55
+ def setUp(self) -> None:
56
+ self.target = Path(tempfile.mkdtemp(prefix="komagu_redact_"))
57
+ files = {"src/com/acme/dao/AccountDao.java": JAVA_DAO, "src/main/resources/application.properties": PROPERTIES,
58
+ "src/com/acme/Signer.java": PRIVATE_KEY, "WebContent/user/detail.jsp": JSP}
59
+ for rel, text in files.items():
60
+ (self.target / rel).parent.mkdir(parents=True, exist_ok=True)
61
+ (self.target / rel).write_text(text, encoding="utf-8")
62
+
63
+ def tearDown(self) -> None:
64
+ shutil.rmtree(self.target, ignore_errors=True)
65
+
66
+ def assert_no_secret(self, text: str) -> None:
67
+ for secret in SECRETS:
68
+ self.assertNotIn(secret, text)
69
+
70
+ def test_java_source_snippet_masks_connection_strings_and_keys(self) -> None:
71
+ snip = _read_snippet(self.target, "src/com/acme/dao/AccountDao.java", 5)
72
+ self.assert_no_secret(snip)
73
+ self.assertIn("[REDACTED]", snip)
74
+
75
+ def test_code_the_auditor_needs_is_kept(self) -> None:
76
+ snip = _read_snippet(self.target, "src/com/acme/dao/AccountDao.java", 5)
77
+ self.assertIn('String password = req.getParameter("password");', snip)
78
+ self.assertIn('"SELECT * FROM account WHERE id = " + req.getParameter("id")', snip)
79
+ self.assertIn("jdbc:sqlserver://db01;databaseName=ams;user=sa;password=[REDACTED]", snip)
80
+ self.assertIn("mongodb://svc:[REDACTED]@mongo01:27017/ams", snip)
81
+ self.assertIn("jdbc:oracle:thin:scott/[REDACTED]@db02:1521/AMS", snip)
82
+
83
+ def test_config_line_inside_a_comment_is_masked(self) -> None:
84
+ snip = _read_snippet(self.target, "WebContent/user/detail.jsp", 2)
85
+ self.assert_no_secret(snip)
86
+ self.assertIn("ldap.bind.password=[REDACTED] --%>", snip)
87
+ self.assertIn('String id = request.getParameter("id");', snip)
88
+
89
+ def test_assignments_in_code_are_kept(self) -> None:
90
+ lines = ["user.password = req.body.password;", "if (token == null) return;", "token: req.headers.authorization,"]
91
+ (self.target / "app.js").write_text("\n".join(lines) + "\n", encoding="utf-8")
92
+ snip = _read_snippet(self.target, "app.js", 2)
93
+ for line in lines:
94
+ self.assertIn(line, snip)
95
+
96
+ def test_unquoted_properties_values_are_masked(self) -> None:
97
+ snip = _read_snippet(self.target, "src/main/resources/application.properties", 3)
98
+ self.assert_no_secret(snip)
99
+ self.assertIn("spring.datasource.username=ams_app", snip)
100
+ self.assertIn("app.token.placeholder=${APP_TOKEN}", snip)
101
+
102
+ def test_private_key_block_is_masked_with_line_numbers_kept(self) -> None:
103
+ snip = _read_snippet(self.target, "src/com/acme/Signer.java", 3)
104
+ self.assert_no_secret(snip)
105
+ self.assertIn(" 5 | class Signer {}", snip)
106
+
107
+ def test_model_and_verifier_context_are_masked(self) -> None:
108
+ model = _model_source(self.target, "AccountDao", "src/com/acme/dao/AccountDao.java", 1)
109
+ self.assert_no_secret(model["code"])
110
+ ctx = _verification_context(self.target, {"location": {"file": "src/com/acme/dao/AccountDao.java", "start_line": 8},
111
+ "evidence": [{"file": "src/main/resources/application.properties", "line": 3}]})
112
+ self.assert_no_secret(str(ctx))
113
+
114
+
115
+ class TestJavaLegacySecretForms(unittest.TestCase):
116
+ """Forms found in a security review of the first version (#350); each must hide Hunter2x."""
117
+
118
+ LEAKS = (
119
+ '<property name="password" value="Hunter2x"/>',
120
+ '<property name="jdbc.password"\n value="Hunter2x"/>',
121
+ "<param-name>db.password</param-name><param-value>Hunter2x</param-value>",
122
+ "<init-param>\n <param-name>password</param-name>\n <param-value>Hunter2x</param-value>",
123
+ "<password>Hunter2x</password>",
124
+ 'conn.setPassword("Hunter2x");',
125
+ 'props.setProperty("mail.smtp.password", "Hunter2x");',
126
+ 'Connection c = DriverManager.getConnection(url, "sa", "Hunter2x");',
127
+ 'return new PasswordAuthentication("svc", "Hunter2x".toCharArray());',
128
+ 'String pw = "Hunter2x";',
129
+ 'static final String DB_PASS = "Hunter2x";',
130
+ '<Connector port="8443" keystorePass="Hunter2x" truststorePass="Hunter2x"/>',
131
+ '{"password":"Hunter2x", "apikey" : "Hunter2x"}',
132
+ "$conf = array('password' => 'Hunter2x');",
133
+ "db_password: Hunter2x # prod",
134
+ "mail.smtp.pass=Hunter2x",
135
+ "ssl.key.passphrase=Hunter2x",
136
+ "jdbc:sqlserver://db;user=sa;password={Hunter2x;x}",
137
+ 'conn.setRequestProperty("Authorization", "Basic SHVudGVyMng6eHl6");',
138
+ 'h.put("Authorization", "Bearer Hunter2xHunter2x.eyJzdWIiOiIxIn0.sig");',
139
+ "-----BEGIN PRIVATE KEY----- Hunter2xMIIEvQIBADANBgkq -----END PRIVATE KEY-----",
140
+ )
141
+ TOKENS = ("AKIAIOSFODNN7EXAMPLE", "ghp_" + "a" * 36, "sk-ant-api03-" + "b" * 40, "AIza" + "c" * 35)
142
+ KEPT = (
143
+ 'String password = req.getParameter("password");',
144
+ "String sql = \"SELECT * FROM users WHERE login='\" + u + \"' AND pwd='\" + p + \"'\";",
145
+ "<select id=\"login\">SELECT * FROM users WHERE pwd = #{password}</select>",
146
+ "spring.datasource.password=${DB_PASS}",
147
+ "user.password = req.body.password;",
148
+ "if (token == null) return;",
149
+ 'String header = "Bearer " + token;',
150
+ '<property name="username" value="ams_app"/>',
151
+ "Passenger p = passengerDao.find(id);",
152
+ )
153
+
154
+ def redact(self, text: str) -> str:
155
+ return "\n".join(_redact_lines(text.split("\n")))
156
+
157
+ def test_java_legacy_forms_are_masked(self) -> None:
158
+ for sample in self.LEAKS:
159
+ with self.subTest(sample=sample):
160
+ self.assertNotIn("Hunter2x", self.redact(sample))
161
+
162
+ def test_cloud_and_vcs_tokens_are_masked(self) -> None:
163
+ for token in self.TOKENS:
164
+ with self.subTest(token=token[:8]):
165
+ self.assertNotIn(token, self.redact(f'String key = cfg.get("{token}");'))
166
+
167
+ def test_code_the_auditor_needs_is_kept(self) -> None:
168
+ for line in self.KEPT:
169
+ with self.subTest(line=line):
170
+ self.assertEqual(self.redact(line), line)
171
+
172
+ def test_long_lines_are_fast_and_not_sent(self) -> None:
173
+ lines = ["a.b" * 300000 + "password", "a-" * 500000 + "://", "a-b." * 250000, "x" * 1000000]
174
+ start = time.monotonic()
175
+ out = _redact_lines(lines)
176
+ self.assertLess(time.monotonic() - start, 2.0)
177
+ self.assertTrue(all(o.startswith("[line of ") for o in out))
178
+
179
+
180
+ if __name__ == "__main__":
181
+ unittest.main()