Reduce N-card number exposure in KTX booking

Prefer N-card selection by list index so full card numbers are not echoed through JSON output or required in shell history. Keep direct card-number input as a compatibility escape hatch with an explicit warning.\n\nConstraint: PR #231 scope is limited to ktx-booking docs, helper, and tests.\nRejected: Require users to copy full card numbers from ncard-list | exposes sensitive identifiers in logs and shell history.\nConfidence: high\nScope-risk: narrow\nDirective: Keep N-card list outputs masked; prefer index-based selection for future reservation flows.\nTested: python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=scripts python3 -m unittest scripts.test_ktx_booking; npm run lint; npm run typecheck; npm test\nNot-tested: Live Korail N-card reservation; requires real user credentials and owned N-card.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-12 19:06:27 +09:00
commit d31157cba3
3 changed files with 91 additions and 16 deletions

View file

@ -68,7 +68,7 @@ metadata:
`python3 -c 'import korail2, Crypto'` 가 실패하면 다른 구현으로 우회하지 말고 전역 Python 패키지 설치를 먼저 시도한다.
```bash
python3 -m pip install korail2 pycryptodome
python3 -m pip install korail2-ncard pycryptodome
```
### 1. Ensure credentials are available
@ -133,21 +133,21 @@ N카드 할인을 적용하려면 먼저 보유 N카드 목록을 조회해 카
python3 scripts/ktx_booking.py ncard-list
```
N카드로 할인 열차를 조회한다 (`--ncard-index``ncard-list` 결과의 순번).
N카드로 할인 열차를 조회한다 (`--ncard-index``ncard-list` 결과의 순번). `ncard-list` 는 로그/셸 노출을 줄이기 위해 카드 번호를 마스킹해 출력한다.
```bash
python3 scripts/ktx_booking.py ncard-search 대전 서울 20260512 100000 --ncard-index 1 --train-type ktx
```
응답의 `train_id` 를 복사해 `reserve``--ncard-no` 를 붙여 예약한다.
응답의 `train_id` 를 복사해 `reserve`같은 `--ncard-index` 를 붙여 예약한다.
```bash
python3 scripts/ktx_booking.py reserve 대전 서울 20260512 100000 \
--train-id <train_id> \
--ncard-no <card_no>
--ncard-index 1
```
`--ncard-no` 를 지정하면 `--adults` 등 승객 옵션은 무시되고 N카드 승객 1명으로 처리된다. **결제는 자동화하지 않는다.**
`--ncard-index` 를 지정하면 `--adults` 등 승객 옵션은 무시되고 N카드 승객 1명으로 처리된다. `--ncard-no` 직접 입력도 지원하지만 셸 히스토리에 남을 수 있어 권장하지 않는다. **결제는 자동화하지 않는다.**
N카드 기능은 `korail2-ncard` 패키지가 필요하다. 없으면 해당 커맨드 실행 시 설치 안내가 출력된다.

View file

@ -118,6 +118,10 @@ except ImportError:
class NCardPassenger(AdultPassenger):
def __init__(self, count=1, card_no='', card='', card_pw='', discount_type='153'):
AdultPassenger.__init__(self, count)
self.card_no = card_no
self.card = card
self.card_pw = card_pw
self.discount_type = discount_type
DEFAULT_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 13; SM-S928N Build/UP1A.231005.007)"
DYNAPATH_PATHS = [
@ -624,6 +628,15 @@ def normalize_train(train, index: int) -> dict[str, object]:
}
def mask_identifier(value: object, visible: int = 4) -> str:
text = str(value or "")
if not text:
return ""
if len(text) <= visible:
return "*" * len(text)
return f"{'*' * (len(text) - visible)}{text[-visible:]}"
def normalize_reservation(reservation) -> dict[str, object]:
return {
"reservation_id": reservation.rsv_id,
@ -686,12 +699,40 @@ def command_search(args: argparse.Namespace) -> None:
})
def ensure_ncard_available() -> None:
if not _NCARD_AVAILABLE:
raise SystemExit(
"N카드 기능을 사용하려면 korail2-ncard 패키지가 필요합니다: "
"pip install korail2-ncard pycryptodome"
)
def resolve_ncard_no(client: PatchedKorail, ncard_index: int | None, ncard_no: str | None) -> str | None:
if ncard_index is None and not ncard_no:
return None
ensure_ncard_available()
if ncard_index is None:
return ncard_no
ncards = client.owned_ncards()
if not ncards:
raise SystemExit("보유한 N카드가 없습니다.")
if ncard_index < 1 or ncard_index > len(ncards):
raise SystemExit(f"ncard-index는 1~{len(ncards)} 사이여야 합니다.")
selected = ncards[ncard_index - 1]
selected_no = getattr(selected, "discount_card_no", None)
if not selected_no:
raise SystemExit("선택한 N카드에서 카드 번호를 확인할 수 없습니다.")
return selected_no
def command_reserve(args: argparse.Namespace) -> None:
client = build_client()
ncard_no = getattr(args, "ncard_no", None)
ncard_no = resolve_ncard_no(
client,
getattr(args, "ncard_index", None),
getattr(args, "ncard_no", None),
)
if ncard_no:
if not _NCARD_AVAILABLE:
raise SystemExit("N카드 기능을 사용하려면 korail2-ncard 패키지가 필요합니다: pip install korail2-ncard pycryptodome")
passengers = [NCardPassenger(card_no=ncard_no)]
else:
passengers = parse_passengers(args)
@ -730,7 +771,8 @@ def command_reservations(_: argparse.Namespace) -> None:
def normalize_ncard(ncard, index: int) -> dict[str, object]:
return {
"index": index,
"card_no": ncard.discount_card_no or "",
"card_no": mask_identifier(getattr(ncard, "discount_card_no", "")),
"card_no_masked": True,
"ticket_kind": ncard.ticket_kind_name or "",
"dep_name": ncard.dep_name or "",
"arr_name": ncard.arr_name or "",
@ -749,8 +791,7 @@ def normalize_ncard_train(train, index: int) -> dict[str, object]:
def command_ncard_list(args: argparse.Namespace) -> None:
if not _NCARD_AVAILABLE:
raise SystemExit("N카드 기능을 사용하려면 korail2-ncard 패키지가 필요합니다: pip install korail2-ncard pycryptodome")
ensure_ncard_available()
client = build_client()
ncards = client.owned_ncards()
print_json({
@ -760,8 +801,7 @@ def command_ncard_list(args: argparse.Namespace) -> None:
def command_ncard_search(args: argparse.Namespace) -> None:
if not _NCARD_AVAILABLE:
raise SystemExit("N카드 기능을 사용하려면 korail2-ncard 패키지가 필요합니다: pip install korail2-ncard pycryptodome")
ensure_ncard_available()
client = build_client()
ncards = client.owned_ncards()
if not ncards:
@ -840,11 +880,18 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="좌석이 없으면 예약대기를 시도 (reserve 재조회 시 예약대기 열차 자동 포함)",
)
reserve_parser.add_argument(
"--ncard-index",
type=int,
metavar="N",
default=None,
help="ncard-list 결과의 N카드 순번 (권장). 지정하면 N카드 할인 승객으로 예약",
)
reserve_parser.add_argument(
"--ncard-no",
metavar="CARD_NO",
default=None,
help="N카드 번호 (ncard-list의 card_no). 지정하면 N카드 할인 승객으로 예약",
help="N카드 번호 직접 입력 (비권장: 셸 히스토리에 남을 수 있음)",
)
reserve_parser.set_defaults(func=command_reserve)

View file

@ -347,7 +347,8 @@ class KtxBookingTests(unittest.TestCase):
result = json.loads(output.getvalue())
self.assertEqual(result["count"], 1)
self.assertEqual(result["ncards"][0]["index"], 1)
self.assertEqual(result["ncards"][0]["card_no"], ncard.discount_card_no)
self.assertEqual(result["ncards"][0]["card_no"], "************3456")
self.assertTrue(result["ncards"][0]["card_no_masked"])
self.assertEqual(result["ncards"][0]["dep_name"], ncard.dep_name)
def test_command_ncard_search_returns_trains_with_discount_info(self):
@ -374,7 +375,7 @@ class KtxBookingTests(unittest.TestCase):
self.assertEqual(result["count"], 1)
self.assertEqual(result["trains"][0]["discount_name"], "15%할인")
self.assertEqual(result["trains"][0]["price"], "9900")
self.assertEqual(result["ncard"]["card_no"], ncard.discount_card_no)
self.assertEqual(result["ncard"]["card_no"], "************3456")
def test_command_ncard_search_fails_on_invalid_index(self):
ncard = FakeNCard()
@ -388,12 +389,20 @@ class KtxBookingTests(unittest.TestCase):
with self.assertRaises(SystemExit):
ktx_booking.command_ncard_search(args)
def test_reserve_parser_accepts_ncard_index(self):
args = ktx_booking.build_parser().parse_args([
"reserve", "대전", "서울", "20260512", "100000",
"--train-id", "ktx:v1:test", "--ncard-index", "1",
])
self.assertEqual(args.ncard_index, 1)
def test_command_reserve_with_ncard_no_uses_ncard_passenger(self):
selected = FakeTrain(train_no="009", dep_time="100000", arr_time="105700",
dep_name="대전", arr_name="서울")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient([selected])
args = self.make_args(train_id, ncard_no="1234567890123456")
args.ncard_index = None
with patch.object(ktx_booking, "_NCARD_AVAILABLE", True):
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(io.StringIO()):
@ -402,6 +411,25 @@ class KtxBookingTests(unittest.TestCase):
self.assertEqual(len(client.reserved_passengers), 1)
self.assertIsInstance(client.reserved_passengers[0], ktx_booking.NCardPassenger)
def test_command_reserve_with_ncard_index_uses_owned_card_without_exposing_full_number(self):
selected = FakeTrain(train_no="009", dep_time="100000", arr_time="105700",
dep_name="대전", arr_name="서울")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient([selected], ncards=[FakeNCard()])
args = self.make_args(train_id)
args.ncard_index = 1
with patch.object(ktx_booking, "_NCARD_AVAILABLE", True):
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(io.StringIO()):
ktx_booking.command_reserve(args)
self.assertEqual(client.reserved_passengers[0].card_no, "1234567890123456")
def test_ncard_unavailable_error_message_is_shared(self):
with patch.object(ktx_booking, "_NCARD_AVAILABLE", False):
with self.assertRaises(SystemExit) as exc:
ktx_booking.ensure_ncard_available()
self.assertIn("korail2-ncard", str(exc.exception))
class FallbackImportTests(unittest.TestCase):
def test_module_imports_when_korail2_is_missing(self):