@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +202 -0
- package/README.md +1 -0
- package/assess/lsa/CHANGELOG.md +15 -0
- package/assess/lsa/scripts/detect_stack.py +15 -6
- package/assess/lsa/scripts/find_injection.py +35 -2
- package/assess/lsa/scripts/java_routes.py +323 -0
- package/assess/lsa/scripts/lsa_common.py +73 -7
- package/assess/lsa/scripts/map_routes.py +36 -160
- package/assess/lsa/scripts/merge_findings.py +2 -0
- package/assess/lsa/scripts/redaction.py +118 -0
- package/assess/lsa/tests/test_java_legacy_realistic.py +453 -0
- package/assess/lsa/tests/test_modern_stacks.py +13 -0
- package/contracts/schemas/persona-suite.schema.json +38 -2
- package/examples/member-portal/backend-dotnet/Dockerfile +2 -1
- package/examples/member-portal/backend-spring/Dockerfile +2 -0
- package/examples/member-portal/docker-compose.yml +1 -1
- package/examples/member-portal/frontend/Dockerfile +3 -2
- package/examples/member-portal/frontend/package-lock.json +24 -656
- package/examples/member-portal/frontend/package.json +1 -1
- package/operate/ai/context.py +22 -2
- package/operate/ai/runner.py +20 -2
- package/operate/cli/__init__.py +1 -1
- package/operate/cli/commands/fix.py +4 -0
- package/operate/cli/commands/leak_check.py +75 -0
- package/operate/cli/commands/protect.py +32 -3
- package/operate/cli/commands/test.py +131 -15
- package/operate/cli/main.py +5 -3
- package/operate/cross_user_tester/persona_matrix.py +118 -25
- package/operate/cross_user_tester/runner.py +190 -7
- package/operate/cross_user_tester/session_config.py +180 -0
- package/operate/cross_user_tester/session_http.py +288 -0
- package/operate/devsecops/leak_check.py +314 -0
- package/operate/remediator/runner.py +18 -1
- package/operate/remediator/strategies.py +2 -2
- package/operate/simulate/scenarios.py +2 -2
- package/operate/tests/mock_legacy_session_app.py +160 -0
- package/operate/tests/test_ai_redaction.py +181 -0
- package/operate/tests/test_cross_user_session.py +565 -0
- package/operate/tests/test_docker_packaging.py +3 -3
- package/operate/tests/test_headless_ai.py +22 -1
- package/operate/tests/test_leak_check.py +250 -0
- package/operate/tests/test_legacy_session_edge.py +192 -0
- package/operate/tests/test_pilot_safeguards.py +245 -0
- package/operate/tests/test_remediator_java_legacy.py +330 -0
- package/package.json +2 -1
- package/remediate/authz/patcher.py +120 -11
- package/remediate/authz/tests/test_bola_patcher.py +4 -1
- package/remediate/edge/coraza/Caddyfile +1 -1
- package/remediate/edge/coraza/Dockerfile +9 -0
- package/remediate/edge/coraza/rules/coraza-crs-custom.conf +12 -0
- package/remediate/edge/coraza/virtual_patcher.py +24 -0
- package/remediate/edge/docker-compose.yml +7 -2
- package/remediate/edge/kong/kong-legacy-session.yaml +51 -0
- package/remediate/edge/mock-backend/Dockerfile +2 -0
- package/remediate/edge/tests/test_edge_security.py +21 -1
- package/remediate/resilience/terraform/backup_worm.tf +39 -1
- package/remediate/resilience/terraform/main.tf +15 -3
- package/remediate/resilience/terraform/variables.tf +11 -0
- package/remediate/resilience/tests/test_resilience.py +16 -0
|
@@ -12,6 +12,76 @@ from dataclasses import dataclass
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, Dict, List, Optional, Tuple
|
|
14
14
|
|
|
15
|
+
JAVA_LEGACY_SHIM = "remediate/legacy_shims/java/OwnershipEnforcer.java"
|
|
16
|
+
|
|
17
|
+
_SPRING_BUILD_FILES = ("pom.xml", "build.gradle", "build.gradle.kts", "gradle.properties")
|
|
18
|
+
_SPRING_BOOT_VERSION_PATTERNS = (
|
|
19
|
+
re.compile(r"spring-boot-(?:starter-parent|dependencies)</artifactId>\s*<version>\s*(\d+)\."),
|
|
20
|
+
re.compile(r"<spring-boot\.version>\s*(\d+)\."),
|
|
21
|
+
re.compile(r"org\.springframework\.boot['\"]\)?\s+version\s+['\"](\d+)\."),
|
|
22
|
+
re.compile(r"org\.springframework\.boot:spring-boot[\w-]*:(\d+)\."),
|
|
23
|
+
re.compile(r"springBootVersion\s*=\s*['\"]?(\d+)\."),
|
|
24
|
+
)
|
|
25
|
+
_XML_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
26
|
+
_SPRING_CONTROLLER = re.compile(r"^\s*@(?:Rest)?Controller\b", re.MULTILINE)
|
|
27
|
+
_SPRING_MAPPING_LINE = re.compile(r"^\s*@(?:Get|Post|Put|Patch|Delete|Request)Mapping\b")
|
|
28
|
+
_JAVA_METHOD_LINE = re.compile(r"^\s*(?:public|protected)\b[^;=]*\(")
|
|
29
|
+
_JAVA_DECLARATION_LINE = re.compile(r"^\s*(?:public|protected|private|abstract|final|class)\b")
|
|
30
|
+
_JAVA_TYPE_NAME = re.compile(r"[A-Z][A-Za-z0-9_]*")
|
|
31
|
+
_JAVA_LITERALS_AND_COMMENTS = re.compile(
|
|
32
|
+
r""""(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])'|//[^\n]*|/\*.*?\*/""", re.DOTALL
|
|
33
|
+
)
|
|
34
|
+
_JAVA_PACKAGE_STATEMENT = re.compile(r"^[ \t]*package\s+[\w.]+\s*;[ \t]*(?:\r\n|\r|\n)", re.MULTILINE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _newline_of(content: str) -> str:
|
|
38
|
+
"""Return the line ending a generated line must use to match the surrounding file."""
|
|
39
|
+
if "\r\n" in content:
|
|
40
|
+
return "\r\n"
|
|
41
|
+
return "\r" if "\r" in content else "\n"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _declared_spring_boot_major(build_file: Path) -> Optional[int]:
|
|
45
|
+
"""Return the Spring Boot major version a build file declares, if it declares one."""
|
|
46
|
+
try:
|
|
47
|
+
text = _XML_COMMENT.sub("", build_file.read_text(encoding="utf-8", errors="replace"))
|
|
48
|
+
except OSError:
|
|
49
|
+
return None
|
|
50
|
+
for pattern in _SPRING_BOOT_VERSION_PATTERNS:
|
|
51
|
+
match = pattern.search(text)
|
|
52
|
+
if match:
|
|
53
|
+
return int(match.group(1))
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _spring_boot_major(source_file: Path) -> Optional[int]:
|
|
58
|
+
"""Find the Spring Boot major version from the nearest build file that declares one."""
|
|
59
|
+
for directory in Path(source_file).resolve().parents:
|
|
60
|
+
for name in _SPRING_BUILD_FILES:
|
|
61
|
+
major = _declared_spring_boot_major(directory / name)
|
|
62
|
+
if major is not None:
|
|
63
|
+
return major
|
|
64
|
+
if (directory / ".git").exists():
|
|
65
|
+
break
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_java_handler_line(lines: List[str], idx: int) -> bool:
|
|
70
|
+
"""True when an annotation inserted above lines[idx] would land on a controller method."""
|
|
71
|
+
if not (_SPRING_MAPPING_LINE.match(lines[idx]) or _JAVA_METHOD_LINE.match(lines[idx])):
|
|
72
|
+
return False
|
|
73
|
+
declaration = next((line for line in lines[idx:] if _JAVA_DECLARATION_LINE.match(line)), "")
|
|
74
|
+
return "(" in declaration and not re.search(r"\bclass\b", declaration)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _java_uses_type(content: str, type_name: str) -> bool:
|
|
78
|
+
"""True when code outside strings and comments references type_name, other than declaring it."""
|
|
79
|
+
if not _JAVA_TYPE_NAME.fullmatch(type_name):
|
|
80
|
+
return False
|
|
81
|
+
code = _JAVA_LITERALS_AND_COMMENTS.sub(" ", content)
|
|
82
|
+
code = re.sub(rf"\b(?:class|interface|enum)\s+{type_name}\b", " ", code)
|
|
83
|
+
return re.search(rf"\b{type_name}\b", code) is not None
|
|
84
|
+
|
|
15
85
|
|
|
16
86
|
@dataclass
|
|
17
87
|
class BOLARewriteResult:
|
|
@@ -84,43 +154,73 @@ class BOLAOwnershipPatcher:
|
|
|
84
154
|
|
|
85
155
|
def ensure_imports(self, content: str, lang: str) -> Tuple[str, bool]:
|
|
86
156
|
"""Ensure necessary Komagu authorization import is present at the top of the file."""
|
|
157
|
+
newline = _newline_of(content)
|
|
87
158
|
if lang in ("typescript", "javascript"):
|
|
88
159
|
if "from '@hoanghainh/komagu'" in content or 'from "@hoanghainh/komagu"' in content:
|
|
89
160
|
return content, False
|
|
90
|
-
import_stmt = "import { RequireOwnership, OwnershipGuard, ResourceOperation } from '@hoanghainh/komagu'
|
|
161
|
+
import_stmt = "import { RequireOwnership, OwnershipGuard, ResourceOperation } from '@hoanghainh/komagu';" + newline
|
|
91
162
|
return import_stmt + content, True
|
|
92
163
|
|
|
93
164
|
elif lang == "csharp":
|
|
94
165
|
if "using Komagu.Security.Authorization;" in content:
|
|
95
166
|
return content, False
|
|
96
|
-
import_stmt = "using Komagu.Security.Authorization
|
|
167
|
+
import_stmt = "using Komagu.Security.Authorization;" + newline
|
|
97
168
|
return import_stmt + content, True
|
|
98
169
|
|
|
99
170
|
elif lang == "java":
|
|
100
|
-
if "import io.komagu.security.
|
|
171
|
+
if "import io.komagu.security.spring.RequireOwnership;" in content:
|
|
101
172
|
return content, False
|
|
102
|
-
import_stmt =
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
173
|
+
import_stmt = (
|
|
174
|
+
"import io.komagu.security.spring.RequireOwnership;" + newline
|
|
175
|
+
+ "import io.komagu.security.authorization.ResourceOperation;" + newline
|
|
176
|
+
)
|
|
177
|
+
package = _JAVA_PACKAGE_STATEMENT.search(content)
|
|
178
|
+
if package:
|
|
179
|
+
return content[: package.end()] + newline + import_stmt + content[package.end():], True
|
|
106
180
|
return import_stmt + content, True
|
|
107
181
|
|
|
108
182
|
elif lang == "python":
|
|
109
183
|
if "from remediate.authz.python.komagu_authz" in content:
|
|
110
184
|
return content, False
|
|
111
|
-
import_stmt = "from remediate.authz.python.komagu_authz import RequireOwnership, ResourceOperation
|
|
185
|
+
import_stmt = "from remediate.authz.python.komagu_authz import RequireOwnership, ResourceOperation" + newline
|
|
112
186
|
return import_stmt + content, True
|
|
113
187
|
|
|
114
188
|
return content, False
|
|
115
189
|
|
|
190
|
+
def _java_manual_reason(
|
|
191
|
+
self, target_file: Path, content: str, lines: List[str], idx: int, resource: str
|
|
192
|
+
) -> Optional[str]:
|
|
193
|
+
"""Say why @RequireOwnership cannot be inserted here, or None when it can."""
|
|
194
|
+
if not _SPRING_CONTROLLER.search(content):
|
|
195
|
+
problem = "this file is not a Spring controller"
|
|
196
|
+
elif _spring_boot_major(target_file) != 3:
|
|
197
|
+
problem = "no Spring Boot 3 version was found in the build files"
|
|
198
|
+
elif not _is_java_handler_line(lines, idx):
|
|
199
|
+
problem = "the finding does not point at a controller method"
|
|
200
|
+
elif not _java_uses_type(content, resource):
|
|
201
|
+
problem = f"the entity class could not be confirmed from the route (inferred {resource!r})"
|
|
202
|
+
else:
|
|
203
|
+
return None
|
|
204
|
+
return (
|
|
205
|
+
f"@RequireOwnership needs a Spring Boot 3 controller method, but {problem}; requires manual "
|
|
206
|
+
f"remediation: call OwnershipEnforcer.requireOwnership(...) before the object is read or "
|
|
207
|
+
f"written (see {JAVA_LEGACY_SHIM})."
|
|
208
|
+
)
|
|
209
|
+
|
|
116
210
|
def patch_file(
|
|
117
211
|
self,
|
|
118
212
|
target_file: Path,
|
|
119
213
|
line_number: int,
|
|
120
214
|
finding: Dict[str, Any],
|
|
215
|
+
content: Optional[str] = None,
|
|
121
216
|
) -> Tuple[str, BOLARewriteResult]:
|
|
122
|
-
"""Inject appropriate ownership guard above the method or route at line_number.
|
|
123
|
-
|
|
217
|
+
"""Inject appropriate ownership guard above the method or route at line_number.
|
|
218
|
+
|
|
219
|
+
Pass the already-decoded `content` to keep the caller's encoding and pending edits.
|
|
220
|
+
Without it the file must be valid UTF-8: replacing bytes would corrupt the source.
|
|
221
|
+
"""
|
|
222
|
+
if content is None:
|
|
223
|
+
content = Path(target_file).read_bytes().decode("utf-8")
|
|
124
224
|
lines = content.splitlines(keepends=True)
|
|
125
225
|
idx = line_number - 1
|
|
126
226
|
|
|
@@ -150,6 +250,7 @@ class BOLAOwnershipPatcher:
|
|
|
150
250
|
explanation="Ownership guard already present at target line.",
|
|
151
251
|
)
|
|
152
252
|
|
|
253
|
+
newline = _newline_of(content)
|
|
153
254
|
guard_lines: List[str] = []
|
|
154
255
|
|
|
155
256
|
if lang in ("typescript", "javascript"):
|
|
@@ -173,6 +274,14 @@ class BOLAOwnershipPatcher:
|
|
|
173
274
|
guard_lines.append(f"{indent}[RequireOwnership(typeof({resource}), idRouteParameter: \"id\", operation: ResourceOperation.{cs_op})]\n")
|
|
174
275
|
|
|
175
276
|
elif lang == "java":
|
|
277
|
+
manual_reason = self._java_manual_reason(target_file, content, lines, idx, resource)
|
|
278
|
+
if manual_reason:
|
|
279
|
+
return content, BOLARewriteResult(
|
|
280
|
+
original_line=target_line,
|
|
281
|
+
patched_line=target_line,
|
|
282
|
+
changed=False,
|
|
283
|
+
explanation=manual_reason,
|
|
284
|
+
)
|
|
176
285
|
guard_lines.append(f"{indent}@RequireOwnership(entityClass = {resource}.class, operation = ResourceOperation.{operation})\n")
|
|
177
286
|
|
|
178
287
|
elif lang == "python":
|
|
@@ -186,7 +295,7 @@ class BOLAOwnershipPatcher:
|
|
|
186
295
|
explanation=f"Automated BOLA ownership guard not supported for language '{lang}'; requires manual remediation.",
|
|
187
296
|
)
|
|
188
297
|
|
|
189
|
-
guard_snippet = "".join(guard_lines)
|
|
298
|
+
guard_snippet = "".join(guard_lines).replace("\n", newline)
|
|
190
299
|
lines.insert(idx, guard_snippet)
|
|
191
300
|
patched_body = "".join(lines)
|
|
192
301
|
|
|
@@ -106,6 +106,9 @@ class TestBOLAOwnershipPatcher(unittest.TestCase):
|
|
|
106
106
|
|
|
107
107
|
def test_patch_spring_boot_controller(self) -> None:
|
|
108
108
|
"""Spring Boot controller method must receive @RequireOwnership annotation and import."""
|
|
109
|
+
(self.test_dir / "build.gradle").write_text(
|
|
110
|
+
"plugins { id 'org.springframework.boot' version '3.2.1' }\n", encoding="utf-8"
|
|
111
|
+
)
|
|
109
112
|
controller_file = self.test_dir / "OrderController.java"
|
|
110
113
|
controller_file.write_text(
|
|
111
114
|
"package com.example.demo;\n\n"
|
|
@@ -127,7 +130,7 @@ class TestBOLAOwnershipPatcher(unittest.TestCase):
|
|
|
127
130
|
|
|
128
131
|
patched_content, res = self.patcher.patch_file(controller_file, 5, finding)
|
|
129
132
|
self.assertTrue(res.changed)
|
|
130
|
-
self.assertIn("import io.komagu.security.
|
|
133
|
+
self.assertIn("import io.komagu.security.spring.RequireOwnership;", patched_content)
|
|
131
134
|
self.assertIn("@RequireOwnership(entityClass = Order.class, operation = ResourceOperation.READ)", patched_content)
|
|
132
135
|
|
|
133
136
|
def test_patch_python_fastapi(self) -> None:
|
|
@@ -7,3 +7,12 @@ RUN xcaddy build \
|
|
|
7
7
|
FROM caddy:alpine
|
|
8
8
|
|
|
9
9
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
|
10
|
+
|
|
11
|
+
# Caddy stores runtime state under /data and /config; it listens on :8080, so no
|
|
12
|
+
# bind capability is needed
|
|
13
|
+
RUN addgroup -S caddy \
|
|
14
|
+
&& adduser -S -G caddy caddy \
|
|
15
|
+
&& mkdir -p /data/caddy /config/caddy \
|
|
16
|
+
&& chown -R caddy:caddy /data /config
|
|
17
|
+
|
|
18
|
+
USER caddy
|
|
@@ -20,6 +20,18 @@ SecAction \
|
|
|
20
20
|
t:none,\
|
|
21
21
|
setvar:'tx.allowed_request_content_type=|application/x-www-form-urlencoded| |multipart/form-data| |text/xml| |application/xml| |application/soap+xml| |application/x-amf| |application/json| |application/cloudevents+json| |application/problem+json|'"
|
|
22
22
|
|
|
23
|
+
# Struts 1 token forms post a field named org.apache.struts.taglib.html.TOKEN, which CRS 944130 reads as a Java class.
|
|
24
|
+
# Only that field name is excluded, and the raw body of such a request; ARGS still carries every parsed field,
|
|
25
|
+
# so Java classes in any value or other field name still score.
|
|
26
|
+
SecRule ARGS_NAMES "@streq org.apache.struts.taglib.html.TOKEN" \
|
|
27
|
+
"id:200002,\
|
|
28
|
+
phase:2,\
|
|
29
|
+
pass,\
|
|
30
|
+
nolog,\
|
|
31
|
+
t:none,\
|
|
32
|
+
ctl:ruleRemoveTargetById=944130;ARGS_NAMES:org.apache.struts.taglib.html.TOKEN,\
|
|
33
|
+
ctl:ruleRemoveTargetById=944130;REQUEST_BODY"
|
|
34
|
+
|
|
23
35
|
# Custom rule: Ensure SQL injection indicators in query string, URI, JSON body, or headers trigger HTTP 403
|
|
24
36
|
# Tuned to prevent false positives on dashes, hash symbols, or benign words (e.g. ?q=a--b, ?name=C#)
|
|
25
37
|
SecRule ARGS|REQUEST_URI|REQUEST_HEADERS "@rx (?i)(?:(?:\bunion\b[\s\S]*?\bselect\b)|(?:\b(?:or|and)\b\s+(?:(?:\d+\s*=\s*\d+)|(?:['\"][^'\"]+['\"]\s*=\s*['\"]?[^'\"\s]+)|true\b|false\b))|(?:;\s*(?:drop|alter|truncate|delete)\s+table\b)|(?:\-\-(?:[\s+\r\n\t]|$))|(?:\/\*[\s\S]*?\*\/)|(?:['\"`]\s*#|#\s+))" \
|
|
@@ -13,11 +13,15 @@ from pathlib import Path
|
|
|
13
13
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
SESSION_ROUTE_SUFFIXES = (".do", ".action", ".jsp", ".jspx")
|
|
17
|
+
|
|
18
|
+
|
|
16
19
|
class VirtualPatcher:
|
|
17
20
|
"""Generates OWASP Coraza / ModSecurity SecRule virtual patches from LSA findings."""
|
|
18
21
|
|
|
19
22
|
def __init__(self, start_rule_id: int = 1100001) -> None:
|
|
20
23
|
self.current_rule_id = start_rule_id
|
|
24
|
+
self.skipped: List[str] = []
|
|
21
25
|
|
|
22
26
|
def _sanitize_regex_path(self, path: str) -> str:
|
|
23
27
|
"""Convert a parameterized endpoint path like /api/orders/{id} into a regex."""
|
|
@@ -121,6 +125,20 @@ SecRule REQUEST_URI "@rx {path_regex}" \\
|
|
|
121
125
|
"""
|
|
122
126
|
return rule
|
|
123
127
|
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _finding_path(finding: Dict[str, Any]) -> Optional[str]:
|
|
130
|
+
return (finding.get("attack_vector") or {}).get("path") or (finding.get("endpoint") or {}).get("path")
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _is_session_route(path: Optional[str]) -> bool:
|
|
134
|
+
"""Legacy form actions sign users in with a session cookie, so no Authorization header is expected."""
|
|
135
|
+
return bool(path) and path.split("?", 1)[0].lower().endswith(SESSION_ROUTE_SUFFIXES)
|
|
136
|
+
|
|
137
|
+
def _skip(self, rules: List[str], fid: str, reason: str) -> None:
|
|
138
|
+
note = f"{fid}: no virtual patch, {reason}"
|
|
139
|
+
self.skipped.append(note)
|
|
140
|
+
rules.append(f"# {note}\n")
|
|
141
|
+
|
|
124
142
|
def generate_patches_from_findings(
|
|
125
143
|
self,
|
|
126
144
|
findings_data: Union[Dict[str, Any], List[Dict[str, Any]]],
|
|
@@ -146,6 +164,9 @@ SecRule REQUEST_URI "@rx {path_regex}" \\
|
|
|
146
164
|
continue
|
|
147
165
|
|
|
148
166
|
if cat == "bola" or fid.startswith("BOLA"):
|
|
167
|
+
if self._is_session_route(self._finding_path(item)):
|
|
168
|
+
self._skip(rules, fid, "a WAF cannot check object ownership on a session-cookie route; fix it in code")
|
|
169
|
+
continue
|
|
149
170
|
rule_text = self.generate_bola_rule(item)
|
|
150
171
|
rules.append(rule_text)
|
|
151
172
|
manifest.append({
|
|
@@ -155,6 +176,9 @@ SecRule REQUEST_URI "@rx {path_regex}" \\
|
|
|
155
176
|
"endpoint": item.get("endpoint", {}).get("path", ""),
|
|
156
177
|
})
|
|
157
178
|
elif cat in ("sql_injection", "nosql_injection") or fid.startswith("INJ"):
|
|
179
|
+
if not self._finding_path(item):
|
|
180
|
+
self._skip(rules, fid, "the finding names no route; the general CRS and Komagu SQLi rules still apply")
|
|
181
|
+
continue
|
|
158
182
|
rule_text = self.generate_sqli_rule(item)
|
|
159
183
|
rules.append(rule_text)
|
|
160
184
|
manifest.append({
|
|
@@ -13,9 +13,9 @@ services:
|
|
|
13
13
|
KONG_ADMIN_LISTEN: 0.0.0.0:8001
|
|
14
14
|
KOMAGU_JWT_SECRET: ${KOMAGU_JWT_SECRET:-komagu-default-test-jwt-secret-key-32chars}
|
|
15
15
|
volumes:
|
|
16
|
-
-
|
|
16
|
+
- ${KOMAGU_KONG_CONFIG:-./kong/kong.yaml}:/kong/declarative/kong.yaml:ro
|
|
17
17
|
ports:
|
|
18
|
-
- "8000:8000" # Public API Gateway proxy port
|
|
18
|
+
- "${KOMAGU_EDGE_BIND:-0.0.0.0}:8000:8000" # Public API Gateway proxy port
|
|
19
19
|
- "127.0.0.1:8001:8001" # Kong Admin API bound strictly to loopback interface on host
|
|
20
20
|
depends_on:
|
|
21
21
|
- coraza-waf
|
|
@@ -34,10 +34,15 @@ services:
|
|
|
34
34
|
context: ./coraza
|
|
35
35
|
image: komagu-coraza-caddy:local
|
|
36
36
|
container_name: komagu-coraza-waf
|
|
37
|
+
environment:
|
|
38
|
+
KOMAGU_UPSTREAM: ${KOMAGU_UPSTREAM:-http://mock-backend:5000}
|
|
39
|
+
extra_hosts:
|
|
40
|
+
- "host.docker.internal:host-gateway"
|
|
37
41
|
volumes:
|
|
38
42
|
- ./coraza/Caddyfile:/etc/caddy/Caddyfile:ro
|
|
39
43
|
- ./coraza/coraza.conf:/etc/coraza/coraza.conf:ro
|
|
40
44
|
- ./coraza/rules:/etc/coraza/rules:ro
|
|
45
|
+
- ${KOMAGU_VIRTUAL_PATCH_FILE:-./coraza/rules/virtual-patches.conf}:/etc/coraza/rules/virtual-patches.conf:ro
|
|
41
46
|
expose:
|
|
42
47
|
- "8080"
|
|
43
48
|
depends_on:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Kong profile for a legacy application that signs users in with a session cookie (JSESSIONID) and form actions.
|
|
2
|
+
# Every path goes to Coraza, and rate limiting is per client IP because these requests carry no JWT.
|
|
3
|
+
# Use it with: komagu protect --target <url> --config remediate/edge/kong/kong-legacy-session.yaml --up
|
|
4
|
+
_format_version: "3.0"
|
|
5
|
+
|
|
6
|
+
services:
|
|
7
|
+
- name: komagu-legacy-app
|
|
8
|
+
url: http://coraza-waf:8080
|
|
9
|
+
connect_timeout: 5000
|
|
10
|
+
read_timeout: 30000
|
|
11
|
+
write_timeout: 30000
|
|
12
|
+
routes:
|
|
13
|
+
- name: legacy-all-paths
|
|
14
|
+
paths:
|
|
15
|
+
- /
|
|
16
|
+
strip_path: false
|
|
17
|
+
preserve_host: false
|
|
18
|
+
protocols:
|
|
19
|
+
- http
|
|
20
|
+
- https
|
|
21
|
+
plugins:
|
|
22
|
+
- name: rate-limiting
|
|
23
|
+
config:
|
|
24
|
+
minute: 120
|
|
25
|
+
second: 10
|
|
26
|
+
limit_by: ip
|
|
27
|
+
policy: redis
|
|
28
|
+
fault_tolerant: false
|
|
29
|
+
redis:
|
|
30
|
+
host: redis
|
|
31
|
+
port: 6379
|
|
32
|
+
timeout: 2000
|
|
33
|
+
database: 0
|
|
34
|
+
hide_client_headers: false
|
|
35
|
+
error_code: 429
|
|
36
|
+
error_message: "Rate limit exceeded. Please back off and retry."
|
|
37
|
+
|
|
38
|
+
plugins:
|
|
39
|
+
- name: correlation-id
|
|
40
|
+
config:
|
|
41
|
+
header_name: X-Correlation-ID
|
|
42
|
+
generator: uuid#counter
|
|
43
|
+
echo_downstream: true
|
|
44
|
+
|
|
45
|
+
- name: response-transformer
|
|
46
|
+
config:
|
|
47
|
+
remove:
|
|
48
|
+
headers:
|
|
49
|
+
- X-Kong-Upstream-Latency
|
|
50
|
+
- X-Kong-Proxy-Latency
|
|
51
|
+
- Server
|
|
@@ -380,9 +380,12 @@ class TestCorazaWafContainer(unittest.TestCase):
|
|
|
380
380
|
_docker("rm", "-f", cls.container)
|
|
381
381
|
|
|
382
382
|
@classmethod
|
|
383
|
-
def _status(cls, path, body=None):
|
|
383
|
+
def _status(cls, path, body=None, form=None):
|
|
384
384
|
headers = {"Content-Type": "application/json"} if body is not None else {}
|
|
385
385
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
386
|
+
if form is not None:
|
|
387
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
388
|
+
data = form.encode("ascii")
|
|
386
389
|
req = urllib.request.Request(f"http://127.0.0.1:{cls.port}{path}", data=data, headers=headers)
|
|
387
390
|
try:
|
|
388
391
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
@@ -423,6 +426,23 @@ class TestCorazaWafContainer(unittest.TestCase):
|
|
|
423
426
|
self.assertNotEqual(self._status("/api/v1/orders?q=salt+and+pepper=yes"), 403)
|
|
424
427
|
self.assertNotEqual(self._status("/api/v1/orders?name=C%23"), 403)
|
|
425
428
|
|
|
429
|
+
def test_struts_token_field_name_not_blocked(self):
|
|
430
|
+
"""CRS 944130 reads the Struts 1 token field name as a Java class, which blocked every token form (#374)."""
|
|
431
|
+
self._require_running()
|
|
432
|
+
self.assertNotEqual(self._status("/order/update.do?org.apache.struts.taglib.html.TOKEN=abc123"), 403)
|
|
433
|
+
|
|
434
|
+
def _post_form(self, body):
|
|
435
|
+
return self._status("/order/update.do", form=body)
|
|
436
|
+
|
|
437
|
+
def test_struts_token_form_post_not_blocked(self):
|
|
438
|
+
self._require_running()
|
|
439
|
+
self.assertNotEqual(self._post_form("org.apache.struts.taglib.html.TOKEN=abc123&id=1¬e=hello"), 403)
|
|
440
|
+
|
|
441
|
+
def test_java_class_in_a_value_still_blocked(self):
|
|
442
|
+
self._require_running()
|
|
443
|
+
self.assertEqual(self._status("/order/update.do?note=java.lang.Runtime"), 403)
|
|
444
|
+
self.assertEqual(self._post_form("org.apache.struts.taglib.html.TOKEN=abc123¬e=java.lang.Runtime"), 403)
|
|
445
|
+
|
|
426
446
|
|
|
427
447
|
class TestCorazaWafSkipLogic(unittest.TestCase):
|
|
428
448
|
"""
|
|
@@ -148,6 +148,8 @@ resource "aws_s3_bucket_versioning" "logs_versioning" {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
# S3 server access logging requires SSE-S3 on the destination bucket: AWS does
|
|
152
|
+
# not support SSE-KMS here, so a customer managed key cannot be used.
|
|
151
153
|
resource "aws_s3_bucket_server_side_encryption_configuration" "logs_encryption" {
|
|
152
154
|
provider = aws.secondary
|
|
153
155
|
bucket = aws_s3_bucket.worm_access_logs.id
|
|
@@ -344,6 +346,19 @@ resource "aws_s3_bucket_policy" "deny_destructive_actions" {
|
|
|
344
346
|
}
|
|
345
347
|
|
|
346
348
|
# 10. Cross-Region Replication Infrastructure (Primary Tokyo -> Secondary Osaka)
|
|
349
|
+
resource "aws_kms_key" "primary_state_key" {
|
|
350
|
+
count = var.enable_cross_region_replication ? 1 : 0
|
|
351
|
+
provider = aws.primary
|
|
352
|
+
description = "KMS Key for Komagu primary state backups in Tokyo"
|
|
353
|
+
deletion_window_in_days = 30
|
|
354
|
+
enable_key_rotation = true
|
|
355
|
+
|
|
356
|
+
tags = {
|
|
357
|
+
Name = "${var.project_name}-primary-state-key"
|
|
358
|
+
Environment = var.environment
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
347
362
|
resource "aws_s3_bucket" "primary_backup" {
|
|
348
363
|
count = var.enable_cross_region_replication ? 1 : 0
|
|
349
364
|
provider = aws.primary
|
|
@@ -382,8 +397,11 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "primary_encryptio
|
|
|
382
397
|
bucket = aws_s3_bucket.primary_backup[0].id
|
|
383
398
|
|
|
384
399
|
rule {
|
|
400
|
+
bucket_key_enabled = true
|
|
401
|
+
|
|
385
402
|
apply_server_side_encryption_by_default {
|
|
386
|
-
|
|
403
|
+
kms_master_key_id = aws_kms_key.primary_state_key[0].arn
|
|
404
|
+
sse_algorithm = "aws:kms"
|
|
387
405
|
}
|
|
388
406
|
}
|
|
389
407
|
}
|
|
@@ -465,6 +483,16 @@ resource "aws_iam_policy" "replication_policy" {
|
|
|
465
483
|
]
|
|
466
484
|
Effect = "Allow"
|
|
467
485
|
Resource = "${aws_s3_bucket.immutable_worm_backup.arn}/*"
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
Action = ["kms:Decrypt"]
|
|
489
|
+
Effect = "Allow"
|
|
490
|
+
Resource = aws_kms_key.primary_state_key[0].arn
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
Action = ["kms:Encrypt", "kms:GenerateDataKey*"]
|
|
494
|
+
Effect = "Allow"
|
|
495
|
+
Resource = aws_kms_key.worm_backup_key.arn
|
|
468
496
|
}
|
|
469
497
|
]
|
|
470
498
|
})
|
|
@@ -486,9 +514,19 @@ resource "aws_s3_bucket_replication_configuration" "crr" {
|
|
|
486
514
|
id = "ReplicateToOsakaWORM"
|
|
487
515
|
status = "Enabled"
|
|
488
516
|
|
|
517
|
+
source_selection_criteria {
|
|
518
|
+
sse_kms_encrypted_objects {
|
|
519
|
+
status = "Enabled"
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
489
523
|
destination {
|
|
490
524
|
bucket = aws_s3_bucket.immutable_worm_backup.arn
|
|
491
525
|
storage_class = "STANDARD"
|
|
526
|
+
|
|
527
|
+
encryption_configuration {
|
|
528
|
+
replica_kms_key_id = aws_kms_key.worm_backup_key.arn
|
|
529
|
+
}
|
|
492
530
|
}
|
|
493
531
|
}
|
|
494
532
|
|
|
@@ -64,7 +64,7 @@ resource "aws_subnet" "primary_public_1a" {
|
|
|
64
64
|
vpc_id = aws_vpc.primary.id
|
|
65
65
|
cidr_block = "10.100.1.0/24"
|
|
66
66
|
availability_zone = "${var.primary_region}a"
|
|
67
|
-
map_public_ip_on_launch =
|
|
67
|
+
map_public_ip_on_launch = false # Internet-facing resources must request a public IP explicitly
|
|
68
68
|
|
|
69
69
|
tags = {
|
|
70
70
|
Name = "${var.project_name}-primary-public-1a"
|
|
@@ -76,7 +76,7 @@ resource "aws_subnet" "primary_public_1c" {
|
|
|
76
76
|
vpc_id = aws_vpc.primary.id
|
|
77
77
|
cidr_block = "10.100.2.0/24"
|
|
78
78
|
availability_zone = "${var.primary_region}c"
|
|
79
|
-
map_public_ip_on_launch =
|
|
79
|
+
map_public_ip_on_launch = false # Internet-facing resources must request a public IP explicitly
|
|
80
80
|
|
|
81
81
|
tags = {
|
|
82
82
|
Name = "${var.project_name}-primary-public-1c"
|
|
@@ -102,10 +102,22 @@ resource "aws_security_group" "primary_default_sg" {
|
|
|
102
102
|
description = "Default egress-only security group for primary workloads"
|
|
103
103
|
|
|
104
104
|
egress {
|
|
105
|
+
description = "Workload traffic inside the primary VPC"
|
|
105
106
|
from_port = 0
|
|
106
107
|
to_port = 0
|
|
107
108
|
protocol = "-1"
|
|
108
|
-
cidr_blocks = [
|
|
109
|
+
cidr_blocks = [aws_vpc.primary.cidr_block]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dynamic "egress" {
|
|
113
|
+
for_each = length(var.workload_egress_cidrs) > 0 ? [1] : []
|
|
114
|
+
content {
|
|
115
|
+
description = "HTTPS to operator-approved external destinations"
|
|
116
|
+
from_port = 443
|
|
117
|
+
to_port = 443
|
|
118
|
+
protocol = "tcp"
|
|
119
|
+
cidr_blocks = var.workload_egress_cidrs
|
|
120
|
+
}
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
tags = {
|
|
@@ -35,3 +35,14 @@ variable "enable_cross_region_replication" {
|
|
|
35
35
|
type = bool
|
|
36
36
|
default = true
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
variable "workload_egress_cidrs" {
|
|
40
|
+
description = "External CIDR ranges primary workloads may reach over HTTPS. Empty keeps egress inside the VPC."
|
|
41
|
+
type = list(string)
|
|
42
|
+
default = []
|
|
43
|
+
|
|
44
|
+
validation {
|
|
45
|
+
condition = !contains(var.workload_egress_cidrs, "0.0.0.0/0") && !contains(var.workload_egress_cidrs, "::/0")
|
|
46
|
+
error_message = "workload_egress_cidrs must list specific ranges, not 0.0.0.0/0 or ::/0."
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -66,6 +66,22 @@ class TestMultiRegionResilience(unittest.TestCase):
|
|
|
66
66
|
self.assertIn("aws_route_table_association", content)
|
|
67
67
|
self.assertIn("aws_security_group", content)
|
|
68
68
|
|
|
69
|
+
def test_03b_terraform_network_and_encryption_hardening(self) -> None:
|
|
70
|
+
"""Verifies no automatic public IPs, no open egress and CMK-encrypted primary backups."""
|
|
71
|
+
tf_dir = os.path.join(MODULE_ROOT, "terraform")
|
|
72
|
+
with open(os.path.join(tf_dir, "main.tf"), "r", encoding="utf-8") as f:
|
|
73
|
+
main_tf = f.read()
|
|
74
|
+
with open(os.path.join(tf_dir, "backup_worm.tf"), "r", encoding="utf-8") as f:
|
|
75
|
+
worm_tf = f.read()
|
|
76
|
+
|
|
77
|
+
self.assertNotIn("map_public_ip_on_launch = true", main_tf)
|
|
78
|
+
sg_start = main_tf.index('resource "aws_security_group" "primary_default_sg"')
|
|
79
|
+
sg_block = main_tf[sg_start:main_tf.index("# 2. Secondary Standby DR VPC")]
|
|
80
|
+
self.assertNotIn("0.0.0.0/0", sg_block)
|
|
81
|
+
|
|
82
|
+
self.assertIn("aws_kms_key.primary_state_key[0].arn", worm_tf)
|
|
83
|
+
self.assertIn("replica_kms_key_id = aws_kms_key.worm_backup_key.arn", worm_tf)
|
|
84
|
+
|
|
69
85
|
def test_04_dr_snapshot_creation_and_integrity(self) -> None:
|
|
70
86
|
"""Verifies immutable snapshot creation and cryptographic integrity check."""
|
|
71
87
|
mgr = DRManager(secondary_region="ap-northeast-3")
|