Skip to content

Commit 200f430

Browse files
fix(otdf-local): address coderabbit review feedback
- cli_scenario: set OTDF_LOCAL_INSTANCE_NAME + clear settings cache before get_settings() so scenario-driven instance name is picked up - cli_instance: add _validate_instance_name() to guard against path traversal in init/rm; add --force flag to init to prevent silent overwrite - kas: add get_instance_names() public method; replace _instances access in cli - keys: generate_ca_jks() now imports cert only (keytool -importcert) so ca.jks is a proper truststore; ensure_keys_exist() guards include cert files alongside private keys to catch partial-init broken state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1d05dbe commit 200f430

5 files changed

Lines changed: 57 additions & 41 deletions

File tree

otdf-local/src/otdf_local/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def up(
220220
raise typer.Exit(1)
221221

222222
with status_spinner("Waiting for KAS instances..."):
223-
for kas_name in kas_manager._instances:
223+
for kas_name in kas_manager.get_instance_names():
224224
port = settings.get_kas_port(kas_name)
225225
try:
226226
wait_for_health(

otdf-local/src/otdf_local/cli_instance.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
instance_app = typer.Typer(help="Manage named test environment instances.")
2323

2424

25+
def _validate_instance_name(name: str) -> None:
26+
"""Reject names that could escape the instances root via path traversal."""
27+
from pathlib import PurePosixPath
28+
29+
p = PurePosixPath(name)
30+
if not name or p.is_absolute() or len(p.parts) != 1 or name in {".", ".."}:
31+
raise typer.BadParameter(
32+
f"instance name must be a single directory name, got {name!r}"
33+
)
34+
35+
2536
@instance_app.command("init")
2637
def init(
2738
name: Annotated[str, typer.Argument(help="Instance name (used as directory name)")],
@@ -41,11 +52,24 @@ def init(
4152
Optional[str],
4253
typer.Option("--platform", help="Platform dist version (e.g., v0.9.0)"),
4354
] = None,
55+
force: Annotated[
56+
bool,
57+
typer.Option("--force", help="Overwrite existing instance directory"),
58+
] = False,
4459
) -> None:
4560
"""Scaffold a new instance directory at tests/instances/<name>/."""
61+
_validate_instance_name(name)
4662
settings = get_settings()
4763
instance_dir = settings.instances_root / name
4864

65+
if instance_dir.exists() and not force:
66+
typer.echo(
67+
f"Error: instance '{name}' already exists at {instance_dir}. "
68+
"Pass --force to overwrite.",
69+
err=True,
70+
)
71+
raise typer.Exit(2)
72+
4973
if from_scenario is not None:
5074
_init_from_scenario(name, from_scenario, instance_dir)
5175
else:
@@ -263,6 +287,7 @@ def rm(
263287
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
264288
) -> None:
265289
"""Remove an instance directory."""
290+
_validate_instance_name(name)
266291
settings = get_settings()
267292
instance_dir = settings.instances_root / name
268293
if not instance_dir.exists():

otdf-local/src/otdf_local/cli_scenario.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,10 @@ def run(
8080
)
8181
raise typer.Exit(2)
8282

83-
settings = get_settings()
8483
# Force the chosen instance via env so child pytest invocations agree.
8584
os.environ["OTDF_LOCAL_INSTANCE_NAME"] = instance_name
85+
get_settings.cache_clear()
86+
settings = get_settings()
8687

8788
xtest_root = settings.xtest_root
8889
if not xtest_root.exists():

otdf-local/src/otdf_local/services/kas.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ def get_all_info(self) -> list[ServiceInfo]:
249249
"""Get info for all KAS instances."""
250250
return [instance.get_info() for instance in self._instances.values()]
251251

252+
def get_instance_names(self) -> list[str]:
253+
"""Return names of all managed KAS instances."""
254+
return list(self._instances.keys())
255+
252256
def get_running(self) -> list[str]:
253257
"""Get names of running KAS instances."""
254258
return [name for name, inst in self._instances.items() if inst.is_running()]

otdf-local/src/otdf_local/utils/keys.py

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -234,41 +234,23 @@ def generate_localhost_cert(key_dir: Path) -> tuple[Path, Path]:
234234

235235

236236
def generate_ca_jks(key_dir: Path, password: str = "password") -> Path:
237-
"""Convert the keycloak CA into the JKS truststore Keycloak mounts.
237+
"""Convert the keycloak CA certificate into a JKS truststore Keycloak mounts.
238238
239239
Uses keytool inside the keycloak/keycloak:25.0 image so we don't need a
240240
local JDK — docker is already a hard dependency for the test env.
241241
Requires generate_localhost_cert() to have run first.
242+
243+
Only the CA certificate (public) is imported — not the private key — so the
244+
JKS is a proper truststore, not a keystore.
242245
"""
243-
ca_key = key_dir / "keycloak-ca-private.pem"
244246
ca_cert = key_dir / "keycloak-ca.pem"
245-
if not ca_key.exists() or not ca_cert.exists():
247+
if not ca_cert.exists():
246248
raise FileNotFoundError(
247-
f"CA files missing in {key_dir}; call generate_localhost_cert() first"
249+
f"CA certificate missing in {key_dir}; call generate_localhost_cert() first"
248250
)
249-
p12 = key_dir / "ca.p12"
250251
jks = key_dir / "ca.jks"
251252

252-
subprocess.run(
253-
[
254-
"openssl",
255-
"pkcs12",
256-
"-export",
257-
"-in",
258-
str(ca_cert),
259-
"-inkey",
260-
str(ca_key),
261-
"-out",
262-
str(p12),
263-
"-nodes",
264-
"-passout",
265-
f"pass:{password}",
266-
],
267-
check=True,
268-
capture_output=True,
269-
)
270-
271-
# keytool -importkeystore via the keycloak image (matches init-temp-keys.sh)
253+
# keytool -importcert via the keycloak image: cert-only truststore entry
272254
result = subprocess.run(
273255
[
274256
"docker",
@@ -281,18 +263,16 @@ def generate_ca_jks(key_dir: Path, password: str = "password") -> Path:
281263
"--user",
282264
f"{os.getuid()}:{os.getgid()}",
283265
"keycloak/keycloak:25.0",
284-
"-importkeystore",
285-
"-srckeystore",
286-
"/keys/ca.p12",
287-
"-srcstoretype",
288-
"PKCS12",
289-
"-destkeystore",
266+
"-importcert",
267+
"-file",
268+
"/keys/keycloak-ca.pem",
269+
"-alias",
270+
"ca",
271+
"-keystore",
290272
"/keys/ca.jks",
291-
"-deststoretype",
273+
"-storetype",
292274
"JKS",
293-
"-srcstorepass",
294-
password,
295-
"-deststorepass",
275+
"-storepass",
296276
password,
297277
"-noprompt",
298278
],
@@ -301,7 +281,7 @@ def generate_ca_jks(key_dir: Path, password: str = "password") -> Path:
301281
)
302282
if result.returncode != 0:
303283
raise RuntimeError(
304-
f"keytool failed converting PKCS12 → JKS:\n{result.stderr}\n"
284+
f"keytool failed importing CA cert into JKS truststore:\n{result.stderr}\n"
305285
"Ensure Docker is running and `keycloak/keycloak:25.0` is pullable."
306286
)
307287
return jks
@@ -323,24 +303,30 @@ def ensure_keys_exist(key_dir: Path, force: bool = False) -> bool:
323303
True if any keys were generated, False if everything already existed
324304
"""
325305
rsa_private = key_dir / "kas-private.pem"
306+
rsa_cert = key_dir / "kas-cert.pem"
326307
ec_private = key_dir / "kas-ec-private.pem"
308+
ec_cert = key_dir / "kas-ec-cert.pem"
327309
localhost_key = key_dir / "localhost.key"
310+
localhost_cert = key_dir / "localhost.crt"
328311
ca_jks = key_dir / "ca.jks"
329312

330313
if (
331314
not force
332315
and rsa_private.exists()
316+
and rsa_cert.exists()
333317
and ec_private.exists()
318+
and ec_cert.exists()
334319
and localhost_key.exists()
320+
and localhost_cert.exists()
335321
and ca_jks.exists()
336322
):
337323
return False
338324

339-
if force or not rsa_private.exists():
325+
if force or not rsa_private.exists() or not rsa_cert.exists():
340326
generate_rsa_keypair(key_dir, "kas")
341-
if force or not ec_private.exists():
327+
if force or not ec_private.exists() or not ec_cert.exists():
342328
generate_ec_keypair(key_dir, "kas-ec")
343-
if force or not localhost_key.exists():
329+
if force or not localhost_key.exists() or not localhost_cert.exists():
344330
generate_localhost_cert(key_dir)
345331
if force or not ca_jks.exists():
346332
generate_ca_jks(key_dir)

0 commit comments

Comments
 (0)