Repository: pypa/twine

Issue:
If pypirc has an invalid format, twine leaks its contents to stdout
### Is there an existing issue for this?

- [x] I have searched the existing issues (open and closed), and could not find an existing issue

### What keywords did you use to search existing issues?

pypirc
credential
leak
stdout
invalid config

(sorry, I didn't keep a record of all the other combinations that I tried)

### What operating system are you using?

Linux

### If you selected 'Other', describe your Operating System here

_No response_

### What version of Python are you running?

```console
Python 3.12.9
```

### How did you install twine? Did you use your operating system's package manager or pip or something else?

```console
apk add twine
```

### What version of twine do you have installed (include the complete output)

```console
twine version 6.0.1 (keyring: 24.3.1, pkginfo: 1.10.0, requests: 2.32.3, requests-toolbelt: 1.0.0, urllib3: 1.26.20)
```

### Which package repository are you using?

pypi

### Please describe the issue that you are experiencing

Due to a misconfiguration, my pipeline had an invalid `.pypirc` format.

When twine attempted to use it, it printed out the contents of this file (which holds the secret token to upload to PyPI) to stdout, which ended up in public logs:

```
+ twine upload --non-interactive dist/django_renderpdf-5.0.0-py3-none-any.whl dist/django_renderpdf-5.0.0.tar.gz
Traceback (most recent call last):
  File "/usr/bin/twine", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/usr/lib/python3.12/site-packages/twine/__main__.py", line 33, in main
    error = cli.dispatch(sys.argv[1:])
            ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/twine/cli.py", line 129, in dispatch
    return main(args.args)
           ^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/twine/commands/upload.py", line 255, in main
    upload_settings = settings.Settings.from_argparse(parsed_args)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/twine/settings.py", line 288, in from_argparse
    return cls(**settings)
           ^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/twine/settings.py", line 116, in __init__
    self._handle_repository_options(
  File "/usr/lib/python3.12/site-packages/twine/settings.py", line 304, in _handle_repository_options
    self.repository_config = utils.get_repository_from_config(
                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/twine/utils.py", line 150, in get_repository_from_config
    config = get_config(config_file)[repository]
             ^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/site-packages/twine/utils.py", line 62, in get_config
    parser.read_file(f)
  File "/usr/lib/python3.12/configparser.py", line 705, in read_file
    self._read(f, source)
  File "/usr/lib/python3.12/configparser.py", line 1064, in _read
    raise MissingSectionHeaderError(fpname, lineno, line)
configparser.MissingSectionHeaderError: File contains no section headers.
file: '/home/build/.pypirc', line: 1
'pypi-AgEIcHlwaS5vcmcCJDEyNzg5MjUzLWNmYjQtNDc3Yy1hODYxLTJlNTcwM2NhNmQ3ZQACGFsxLFsiZGphbmdvLXJlbmRlcnBkZiJdXQACLFsyLFsiMTdmZTQzNjQtY2VhYS00MjYzLWFjYjItZmVlZTk3ZjdlMDRiIl1dAAAGIFMg3ntKRW4jPRWG7ETRUEZBam1khFyS8Cbtj0h98cwd\n'
```

(the above token was quickly revoked)

### Please list the steps required to reproduce this behaviour

1. Create a typo in `.pypirc`
2. `twine upload --non-interactive path/to/some/package`

### Anything else you'd like to mention?

A file containing sensitive credentials should never be printed to stdout; twine is frequently used in CI pipelines where logs are kept public.


Discussion:
Hi @WhyNotHugo, thanks for opening an issue. I agree this is not ideal behavior and should probably be fixed (either by masking the secret in the exception, or perhaps better by catching the exception and controlling its presentation more completely). I can look into a patch this week.

To take a step back: could you share a bit about your pipeline configuration? In particular, if you're using GitHub Actions or GitLab CI/CD, is there a reason why you're using a `.pypirc` instead of the CI-provided secret management system? The latter is designed to automatically mask these kinds of secrets.
@WhyNotHugo gentle ping on the question above!
I've opened #1240 for this, and confirmed that the fix in that PR masks as expected.
Hi! Sorry, I apparently wrote a reply and never hit submit, here goes:

---

> To take a step back: could you share a bit about your pipeline configuration?

I have the secret file `.pypirc` configured for the builds, but I accidentally wrote the file in the wrong format, so it's syntax was completely invalid.

> In particular, if you're using GitHub Actions or GitLab CI/CD, is there a reason why you're using a .pypirc instead of the CI-provided secret management system?

I'm using the builds.sr.ht. `.pypirc` is configured and created via the secrets management system.

> The latter is designed to automatically mask these kinds of secrets.

This particular setup doesn't use heuristics to try and detect/censor leaked secrets.

Note that the data in the exception details is escaped  (i.e.: is has `\n` instead of an actual new-line), so such heuristics would likely not have matched anyway.
No problem at all, thanks for the additional details! Interesting to know srht does secret management that way.



Patch:
diff --git a/changelog/1240.bugfix.rst b/changelog/1240.bugfix.rst
new file mode 100644
index 00000000..e94ff704
--- /dev/null
+++ b/changelog/1240.bugfix.rst
@@ -0,0 +1,2 @@
+``twine`` now catches ``configparser.Error`` to prevent accidental
+leaks of secret tokens or passwords to the user's console.
diff --git a/twine/utils.py b/twine/utils.py
index 2bc56d0b..2ea9ca9f 100644
--- a/twine/utils.py
+++ b/twine/utils.py
@@ -159,6 +159,13 @@ def get_repository_from_config(
             f"Missing '{repository}' section from {config_file}.\n"
             f"More info: https://packaging.python.org/specifications/pypirc/ "
         )
+    except configparser.Error:
+        # NOTE: We intentionally fully mask the configparser exception here,
+        # since it could leak tokens and other sensitive values.
+        raise exceptions.InvalidConfiguration(
+            f"Malformed configuration in {config_file}.\n"
+            f"More info: https://packaging.python.org/specifications/pypirc/ "
+        )
 
     config["repository"] = normalize_repository_url(cast(str, config["repository"]))
     return config


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 88e324fc..37c8fad7 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -227,6 +227,36 @@ def test_get_repository_config_missing_repository(write_config_file):
         utils.get_repository_from_config(config_file, "missing-repository")
 
 
+@pytest.mark.parametrize(
+    "invalid_config",
+    [
+        # No surrounding [server] section
+        """
+    username = testuser
+    password = testpassword
+    """,
+        # Valid section but bare API token
+        """
+    [pypi]
+    pypi-lolololol
+    """,
+        # No section, bare API token
+        """
+    pypi-lolololol
+    """,
+    ],
+)
+def test_get_repository_config_invalid_syntax(write_config_file, invalid_config):
+    """Raise an exception when the .pypirc has invalid syntax."""
+    config_file = write_config_file(invalid_config)
+
+    with pytest.raises(
+        exceptions.InvalidConfiguration,
+        match="Malformed configuration",
+    ):
+        utils.get_repository_from_config(config_file, "pypi")
+
+
 @pytest.mark.parametrize("repository", ["pypi", "missing-repository"])
 def test_get_repository_config_missing_file(repository):
     """Raise an exception when a custom config file doesn't exist."""