Skip to content

Commit 332ef6b

Browse files
committed
Mask password columns in invalid import lines, fall back to comma split for malformed rows
Invalid lines returned by validate_file_for_import now have the password column and the one after it masked, so a password containing an unquoted comma does not expose its fragment. include_passwords=True returns the lines as written in the file. Rows that are not well-formed CSV fall back to a plain comma split instead of raising csv.Error, so they keep importing. Written with Claude Code
1 parent aa2f3c9 commit 332ef6b

2 files changed

Lines changed: 89 additions & 71 deletions

File tree

‎tableauserverclient/models/user_item.py‎

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -524,15 +524,25 @@ def create_user_from_line(line: str):
524524

525525
@staticmethod
526526
def _parse_csv_line(line: str) -> list[str]:
527-
values = next(csv.reader([line], strict=True, skipinitialspace=True), [])
527+
try:
528+
values = next(csv.reader([line], strict=True, skipinitialspace=True), [])
529+
except csv.Error:
530+
# Not well-formed CSV (e.g. an unmatched quote in a password). Split on
531+
# commas as unquoted files always have, so these rows keep importing.
532+
values = line.strip().split(",")
528533
if not values:
529534
values = [""]
530535
return list(map(str.strip, values))
531536

532537
# Read through an entire CSV file meant for user import
533-
# Return the number of valid lines and a list of all the invalid lines
538+
# Return the number of valid lines and a list of all the invalid lines.
539+
# Invalid lines have the password column and the column after it masked with "***"
540+
# (a password containing one unquoted comma spills into the next column);
541+
# pass include_passwords=True to get them exactly as they appear in the file.
534542
@staticmethod
535-
def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, list[str]]:
543+
def validate_file_for_import(
544+
csv_file: io.TextIOWrapper, logger, include_passwords: bool = False
545+
) -> tuple[int, list[str]]:
536546
num_valid_lines = 0
537547
invalid_lines = []
538548
csv_file.seek(0) # set to start of file in case it has been read earlier
@@ -546,44 +556,36 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l
546556
num_valid_lines += 1
547557
except Exception as exc:
548558
logger.debug(f"Error parsing user {username}: {exc}")
549-
invalid_lines.append(UserItem.CSVImport._redact_password_column(line))
559+
invalid_lines.append(line if include_passwords else UserItem.CSVImport._mask_password_columns(line))
550560
line = csv_file.readline()
551561
return num_valid_lines, invalid_lines
552562

553-
# Single source of truth for "is this column (or, for a malformed row that
554-
# shifted fields, anything at or after it) sensitive enough to mask before
555-
# it reaches a log handler or an exposed invalid-lines entry." Both
556-
# _redact_password_column (whole-line redaction) and
557-
# _validate_import_line_or_throw (per-column debug logging) key off this
558-
# instead of each re-deriving the PASS boundary independently.
563+
# Number of columns masked in returned invalid lines, starting at the password column.
564+
_MASKED_COLUMN_COUNT = 2
565+
566+
# Return the line with the password column and the one after it replaced by "***".
567+
# Other fields are re-serialized (whitespace trimmed, quoting normalized); the line ending is kept.
568+
@staticmethod
569+
def _mask_password_columns(line: str) -> str:
570+
body = line.rstrip("\r\n")
571+
ending = line[len(body) :]
572+
fields = UserItem.CSVImport._parse_csv_line(body)
573+
first = UserItem.CSVImport.ColumnType.PASS.value
574+
if len(fields) <= first:
575+
return line
576+
for i in range(first, min(first + UserItem.CSVImport._MASKED_COLUMN_COUNT, len(fields))):
577+
fields[i] = "***"
578+
output = io.StringIO()
579+
csv.writer(output, lineterminator="").writerow(fields)
580+
return output.getvalue() + ending
581+
582+
# True for the password column and every column after it. A row whose password
583+
# contains an unquoted comma shifts password fragments into later columns, so
584+
# all of them are kept out of log output.
559585
@staticmethod
560586
def _is_sensitive_column(column_index: int) -> bool:
561587
return column_index >= UserItem.CSVImport.ColumnType.PASS.value
562588

563-
# Return a copy of a raw CSV line with the password column replaced by "***".
564-
# Callers that log or expose invalid rows will not disclose the credential.
565-
# Preserves the original line ending (\r\n or \n) so log output and
566-
# returned rows stay byte-identical apart from the redaction.
567-
@staticmethod
568-
def _redact_password_column(line: str) -> str:
569-
if line.endswith("\r\n"):
570-
body, ending = line[:-2], "\r\n"
571-
elif line.endswith("\n"):
572-
body, ending = line[:-1], "\n"
573-
else:
574-
body, ending = line, ""
575-
try:
576-
fields = UserItem.CSVImport._parse_csv_line(body)
577-
except csv.Error:
578-
return "<invalid CSV row redacted>" + ending
579-
pass_index = UserItem.CSVImport.ColumnType.PASS.value
580-
if len(fields) > pass_index:
581-
fields[pass_index] = "***"
582-
output = io.StringIO()
583-
csv.writer(output, lineterminator="").writerow(fields)
584-
return output.getvalue() + ending
585-
return line
586-
587589
# Some fields in the import file are restricted to specific values
588590
# Iterate through each field and validate the given value against hardcoded constraints
589591
@staticmethod
@@ -641,10 +643,13 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
641643
# Never call _validate_attribute_value for PASS: its ValueError
642644
# embeds the raw value, and a future PR that adds password-format
643645
# checks to PASS's allowlist would leak the password through the
644-
# exception message. Belt-and-braces even though PASS's allowlist
645-
# is currently empty.
646+
# exception message.
646647
if not skip_validation and column != UserItem.CSVImport.ColumnType.PASS:
647-
UserItem.CSVImport._validate_attribute_value(value, valid, column)
648+
try:
649+
UserItem.CSVImport._validate_attribute_value(value, valid, column)
650+
except ValueError:
651+
# The rejected value may be a password fragment from a shifted row.
652+
raise ValueError(f"Invalid value in column {column.name}") from None
648653

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

‎test/test_user_model.py‎

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -197,37 +197,71 @@ def test_password_not_logged_when_line_invalid(caplog: pytest.LogCaptureFixture)
197197
with caplog.at_level(logging.DEBUG, logger=logger.name):
198198
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
199199
assert valid == 0
200-
assert len(invalid) == 1
201-
assert secret not in invalid[0], f"Password leaked into returned invalid_lines: {invalid[0]!r}"
200+
assert invalid == ["jsmith,***,***,not-a-real-license,site,yes,jsmith@example.com"]
202201
combined = "\n".join(record.getMessage() for record in caplog.records)
203202
assert secret not in combined, f"Password leaked into logs on invalid row: {combined!r}"
204203

205204

206-
def test_password_with_comma_is_fully_redacted(caplog: pytest.LogCaptureFixture) -> None:
207-
"""A quoted comma in a password must not leak from invalid row output or logs."""
205+
def test_quoted_comma_password_not_logged(caplog: pytest.LogCaptureFixture) -> None:
206+
"""A quoted comma in a password must not reach the logs; the invalid row is returned as written."""
208207
line = 'jsmith,"hunter2,SECRETTAIL",John Smith,not-a-real-license,site,yes,jsmith@example.com'
209208
test_data = _mock_file_content([line])
210209
with caplog.at_level(logging.DEBUG, logger=logger.name):
211210
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
212211
assert valid == 0
213-
assert len(invalid) == 1
214-
assert "hunter2" not in invalid[0]
215-
assert "SECRETTAIL" not in invalid[0]
212+
assert invalid == ["jsmith,***,***,not-a-real-license,site,yes,jsmith@example.com"]
216213
combined = "\n".join(record.getMessage() for record in caplog.records)
217214
assert "hunter2" not in combined
218215
assert "SECRETTAIL" not in combined
219216
assert _mask_present(caplog.records)
220217

221218

222-
def test_malformed_csv_row_is_not_returned_unredacted(caplog: pytest.LogCaptureFixture) -> None:
223-
secret = "SECRETTAIL"
224-
test_data = _mock_file_content([f'jsmith,"{secret}'])
219+
def test_include_passwords_returns_invalid_lines_verbatim() -> None:
220+
line = "jsmith,hunter2,John Smith,not-a-real-license,site,yes,jsmith@example.com\n"
221+
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(
222+
_mock_file_content([line]), logger, include_passwords=True
223+
)
224+
assert valid == 0
225+
assert invalid == [line]
226+
227+
228+
def test_mask_password_columns_helper() -> None:
229+
mask = TSC.UserItem.CSVImport._mask_password_columns
230+
assert mask("jsmith,hunter2,fname,creator\n") == "jsmith,***,***,creator\n"
231+
assert mask("jsmith,hunter2,fname,creator\r\n") == "jsmith,***,***,creator\r\n"
232+
assert mask("jsmith,hunter2,fname") == "jsmith,***,***"
233+
assert mask("jsmith,hunter2\r\n") == "jsmith,***\r\n"
234+
assert mask("jsmith,,fname") == "jsmith,***,***"
235+
assert mask("jsmith,") == "jsmith,***"
236+
assert mask('jsmith,"pass,word",fname,creator') == "jsmith,***,***,creator"
237+
# No password column: returned unchanged
238+
assert mask("jsmith") == "jsmith"
239+
assert mask("jsmith\n") == "jsmith\n"
240+
241+
242+
def test_unmatched_quote_row_still_parses() -> None:
243+
"""A row that is not well-formed CSV is split on commas instead of aborting the import."""
244+
user = TSC.UserItem.CSVImport.create_user_from_line('username,pa"ss,fname,creator,none,yes,email')
245+
assert user is not None
246+
assert user.fullname == "fname"
247+
user = TSC.UserItem.CSVImport.create_user_from_line('username,"unterminated,fname,creator,none,yes,email')
248+
assert user is not None
249+
assert user.fullname == "fname"
250+
251+
252+
def test_shifted_password_fragment_not_logged(caplog: pytest.LogCaptureFixture) -> None:
253+
"""An unquoted comma shifts password fragments into later columns; they must not reach the logs."""
254+
line = "jsmith,hunter2,SECRETTAIL,TAILFRAGMENT,site,yes,jsmith@example.com"
255+
test_data = _mock_file_content([line])
225256
with caplog.at_level(logging.DEBUG, logger=logger.name):
226257
valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger)
227258
assert valid == 0
228-
assert invalid == ["<invalid CSV row redacted>"]
229-
combined = "\n".join(record.getMessage() for record in caplog.records)
230-
assert secret not in combined
259+
# Only two columns are masked, so a fragment past an unquoted second comma stays in the returned row.
260+
assert invalid == ["jsmith,***,***,TAILFRAGMENT,site,yes,jsmith@example.com"]
261+
combined = " ".join(record.getMessage() for record in caplog.records).lower()
262+
assert "tailfragment" not in combined
263+
assert "secrettail" not in combined
264+
assert "column license" in combined
231265

232266

233267
def test_password_not_in_validate_attribute_value_error(
@@ -269,27 +303,6 @@ def spy(item: str, possible_values: list, column_type) -> None:
269303
assert secret not in combined, f"Password leaked into logs: {combined!r}"
270304

271305

272-
def test_redact_password_column_helper() -> None:
273-
"""Unit-level coverage for _redact_password_column across newline and edge cases."""
274-
redact = TSC.UserItem.CSVImport._redact_password_column
275-
# LF-terminated
276-
assert redact("jsmith,hunter2,fname\n") == "jsmith,***,fname\n"
277-
# CRLF-terminated (the \r rides with the last field, ending is preserved)
278-
assert redact("jsmith,hunter2,fname\r\n") == "jsmith,***,fname\r\n"
279-
# CRLF where password IS the last field: the \r must not be silently
280-
# dropped when the password value is replaced.
281-
assert redact("jsmith,hunter2\r\n") == "jsmith,***\r\n"
282-
# No trailing newline
283-
assert redact("jsmith,hunter2,fname") == "jsmith,***,fname"
284-
# Empty password field: still replaced (unconditional mask)
285-
assert redact("jsmith,,fname") == "jsmith,***,fname"
286-
# Trailing comma with nothing after: column 1 exists as empty string, gets masked
287-
assert redact("jsmith,") == "jsmith,***"
288-
# Single column: no password to redact; return line unchanged
289-
assert redact("jsmith") == "jsmith"
290-
assert redact("jsmith\n") == "jsmith\n"
291-
292-
293306
def test_validate_mixed_case_license() -> None:
294307
# Regression: issue #1809 - 'Viewer' (capital V) was rejected by case-sensitive check
295308
TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, Viewer, None, no, email", logger)

0 commit comments

Comments
 (0)