mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
83a1dd1409
commit
d31157cba3
3 changed files with 91 additions and 16 deletions
|
|
@ -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` 패키지가 필요하다. 없으면 해당 커맨드 실행 시 설치 안내가 출력된다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue