Skip to content

Commit fb3b564

Browse files
jacalataclaude
andcommitted
Redact password column from CSV import logging and error output
`UserItem.CSVImport.validate_file_for_import` and `_validate_import_line_or_throw` wrote the raw CSV line -- including the password column -- to any caller-supplied logger at INFO/DEBUG level, and the whole raw line was pushed into the `invalid_lines` list returned to callers when a row failed validation. Anyone using the sample logger config or forwarding logs to a centralized system would see clear-text passwords in the log stream. Changes: - `validate_file_for_import` logs only the username (column 0) at DEBUG, and calls a new `_redact_password_column` helper before appending an invalid row to the returned list. - `_validate_import_line_or_throw` masks the PASS column value as `***` before logging it. Other column values still logged as-is for debugging. - Both callers changed from INFO to DEBUG for these per-row messages; large imports were spamming operator-visible logs. - Two regression tests capture logs and returned invalid_lines to assert the secret never appears in either place, plus a positive assertion that a `***` masked value IS logged so a future refactor that just removes the log line entirely doesn't pass. Fixes #1829. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa9e3a0 commit fb3b564

3 files changed

Lines changed: 94 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11

22
## Unreleased
33

4+
* Security: `UserItem.CSVImport` no longer logs the password column when
5+
validating a user-import CSV file. The password field was previously written
6+
to any caller-supplied logger at DEBUG level, and the raw row was returned in
7+
`validate_file_for_import`'s `invalid_lines` list unmasked. Fixes #1829.
48
* Added `Projects.get_by_path(path)` to look up a project by its slash-separated
59
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
610
level using the REST API name filter, so a path with *n* components issues *n*

tableauserverclient/models/user_item.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -478,17 +478,29 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l
478478
csv_file.seek(0) # set to start of file in case it has been read earlier
479479
line: str = csv_file.readline()
480480
while line and line != "":
481+
# Log only the username (column 0); the rest of the line contains the password (column 1) and other PII.
482+
username = line.partition(",")[0].strip()
481483
try:
482-
# do not print passwords
483-
logger.info(f"Reading user {line[:4]}")
484+
logger.debug(f"Reading user {username}")
484485
UserItem.CSVImport._validate_import_line_or_throw(line, logger)
485486
num_valid_lines += 1
486487
except Exception as exc:
487-
logger.info(f"Error parsing {line[:4]}: {exc}")
488-
invalid_lines.append(line)
488+
logger.debug(f"Error parsing user {username}: {exc}")
489+
invalid_lines.append(UserItem.CSVImport._redact_password_column(line))
489490
line = csv_file.readline()
490491
return num_valid_lines, invalid_lines
491492

493+
# Return a copy of a raw CSV line with the password column replaced by "***".
494+
# Callers that log or expose invalid rows will not disclose the credential.
495+
@staticmethod
496+
def _redact_password_column(line: str) -> str:
497+
trailing_newline = "\n" if line.endswith("\n") else ""
498+
fields = line.rstrip("\n").split(",")
499+
pass_index = UserItem.CSVImport.ColumnType.PASS.value
500+
if len(fields) > pass_index:
501+
fields[pass_index] = "***"
502+
return ",".join(fields) + trailing_newline
503+
492504
# Some fields in the import file are restricted to specific values
493505
# Iterate through each field and validate the given value against hardcoded constraints
494506
@staticmethod
@@ -511,10 +523,11 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
511523
logger.debug(f"> details - {username}")
512524
UserItem.validate_username_or_throw(username)
513525
for i in range(1, len(line)):
514-
logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}")
515-
UserItem.CSVImport._validate_attribute_value(
516-
line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i)
517-
)
526+
column = UserItem.CSVImport.ColumnType(i)
527+
# Mask the password column so it never reaches log handlers.
528+
safe_value = "***" if column == UserItem.CSVImport.ColumnType.PASS else line[i]
529+
logger.debug(f"column {column.name}: {safe_value}")
530+
UserItem.CSVImport._validate_attribute_value(line[i], _valid_attributes[i], column)
518531

519532
# Given a restricted set of possible values, confirm the item is in that set
520533
@staticmethod

test/test_user_model.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,72 @@ def test_validate_usernames_file() -> None:
136136
test_data = _mock_file_content(usernames)
137137
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
138138
assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + len(invalid)}"
139+
140+
141+
def _mask_present(records: list) -> bool:
142+
combined = "\n".join(record.getMessage() for record in records)
143+
return "PASS" in combined and "***" in combined
144+
145+
146+
def test_password_not_logged_at_debug(caplog: pytest.LogCaptureFixture) -> None:
147+
"""Regression test for #1829: passwords must not appear in DEBUG logs."""
148+
secret = "hunter2SUPERSECRET"
149+
line = f"jsmith,{secret},John Smith,creator,site,yes,jsmith@example.com"
150+
with caplog.at_level(logging.DEBUG, logger=logger.name):
151+
TSC.UserItem.CSVImport._validate_import_line_or_throw(line, logger)
152+
combined = "\n".join(record.getMessage() for record in caplog.records)
153+
assert secret not in combined, f"Password leaked into logs: {combined!r}"
154+
# Positive assertion: something references the PASS column and something is
155+
# masked as ***, so a "fix" that only removed the log line would not pass.
156+
assert _mask_present(caplog.records), f"Expected masked PASS log line; got: {combined!r}"
157+
158+
159+
def test_password_not_logged_when_line_invalid(caplog: pytest.LogCaptureFixture) -> None:
160+
"""Regression test for #1829: passwords must not appear when a row fails to validate."""
161+
secret = "hunter2SUPERSECRET"
162+
line = f"jsmith,{secret},John Smith,not-a-real-license,site,yes,jsmith@example.com"
163+
test_data = _mock_file_content([line])
164+
with caplog.at_level(logging.DEBUG, logger=logger.name):
165+
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
166+
assert valid == 0
167+
assert len(invalid) == 1
168+
assert secret not in invalid[0], f"Password leaked into returned invalid_lines: {invalid[0]!r}"
169+
combined = "\n".join(record.getMessage() for record in caplog.records)
170+
assert secret not in combined, f"Password leaked into logs on invalid row: {combined!r}"
171+
172+
173+
def test_password_with_comma_partially_masks(caplog: pytest.LogCaptureFixture) -> None:
174+
"""A password containing commas is misaligned by the naive split parser: only the
175+
portion that lands in column 1 gets masked. The remaining fragments still leak.
176+
This documents the limitation — fully protecting passwords with embedded commas
177+
requires a proper CSV parser — but confirms that the column-1 mask holds even
178+
when the password value contains a comma."""
179+
line = "jsmith,hunter2,SECRETTAIL,creator,site,yes,jsmith@example.com"
180+
with caplog.at_level(logging.DEBUG, logger=logger.name):
181+
try:
182+
TSC.UserItem.CSVImport._validate_import_line_or_throw(line, logger)
183+
except Exception:
184+
pass # misaligned columns are expected to fail validation
185+
combined = "\n".join(record.getMessage() for record in caplog.records)
186+
# Column 1 ("hunter2") is masked; the fragment that spilled into column 2
187+
# ("SECRETTAIL") is not — this is the documented limitation.
188+
assert "hunter2" not in combined
189+
assert _mask_present(caplog.records)
190+
191+
192+
def test_redact_password_column_helper() -> None:
193+
"""Unit-level coverage for _redact_password_column across newline and edge cases."""
194+
redact = TSC.UserItem.CSVImport._redact_password_column
195+
# LF-terminated
196+
assert redact("jsmith,hunter2,fname\n") == "jsmith,***,fname\n"
197+
# CRLF-terminated (the \r rides with the last field, ending is preserved)
198+
assert redact("jsmith,hunter2,fname\r\n") == "jsmith,***,fname\r\n"
199+
# No trailing newline
200+
assert redact("jsmith,hunter2,fname") == "jsmith,***,fname"
201+
# Empty password field: still replaced (unconditional mask)
202+
assert redact("jsmith,,fname") == "jsmith,***,fname"
203+
# Trailing comma with nothing after: column 1 exists as empty string, gets masked
204+
assert redact("jsmith,") == "jsmith,***"
205+
# Single column: no password to redact; return line unchanged
206+
assert redact("jsmith") == "jsmith"
207+
assert redact("jsmith\n") == "jsmith\n"

0 commit comments

Comments
 (0)