Repository: jazzband/pip-tools

Issue:
Clear-text password leaked via index URL
Clear-text username and password from index URL are directly put to the generated `requirements.txt` files, not hidden in any way as `pip` does, and ignoring the `--no-index-url`/`--no-trusted-host` options.

#### Environment Versions

1. OS Type: Linux
1. Python version: 3.6
1. pip version:  20.1
1. pip-tools version: 5.1.1

#### Steps to replicate

1. Set env var `PIP_INDEX_URL` to `https://username:password@hostname/...`
2. Set env var `PIP_TRUSTED_HOST` to `hostname`
2. Run `pip-compile --no-index --no-emit-trusted-host`

#### Expected result

No index URL is added due to `--no-index`.

OR: No credentials included.

OR: Credentials are hidden.

#### Actual result

Index URL with credentials is added to the requirements.txt file:

```
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile --index-url=https://username:password@hostname/... --no-emit-trusted-host --no-index --output-file=requirements.txt --trusted-host=hostname requirements.in
#
………
```

...



Discussion:
Related with a similar issue: #1028
And also, when clear-text username and password are used, `pip-compile` still leaks them on stdout/stderr (not into `requirements.txt` as above); stdout/stderr are often sent to remote logging systems, so they should not contain sensitive data (same is true for user terminals).

```
$ cat ~/.pip/pip.conf
[global]
index-url = http://user1:passw1@localhost:8888/
```

```
$ pip-compile --no-emit-trusted-host --no-index requirements.in --upgrade
Could not find a version that matches kopf (from -r requirements.in (line 1))
No versions found
Was http://user1:passw1@localhost:8888/ reachable?
```

This code is also in pip-tools: https://github.com/jazzband/pip-tools/blob/b37b91d251f7c7e590047f902e6f51e9a49d276c/piptools/exceptions.py#L45-L47

Resolving patch:
diff --git a/piptools/exceptions.py b/piptools/exceptions.py
--- a/piptools/exceptions.py
+++ b/piptools/exceptions.py
@@ -1,3 +1,6 @@
+from pip._internal.utils.misc import redact_auth_from_url
+
+
 class PipToolsError(Exception):
     pass
 
@@ -40,11 +43,14 @@ def __str__(self):
             source_ireqs = getattr(self.ireq, "_source_ireqs", [])
             lines.extend("  {}".format(ireq) for ireq in source_ireqs)
         else:
+            redacted_urls = tuple(
+                redact_auth_from_url(url) for url in self.finder.index_urls
+            )
             lines.append("No versions found")
             lines.append(
                 "{} {} reachable?".format(
-                    "Were" if len(self.finder.index_urls) > 1 else "Was",
-                    " or ".join(self.finder.index_urls),
+                    "Were" if len(redacted_urls) > 1 else "Was",
+                    " or ".join(redacted_urls),
                 )
             )
         return "\n".join(lines)
diff --git a/piptools/scripts/compile.py b/piptools/scripts/compile.py
--- a/piptools/scripts/compile.py
+++ b/piptools/scripts/compile.py
@@ -9,6 +9,7 @@
 from click.utils import safecall
 from pip._internal.commands import create_command
 from pip._internal.req.constructors import install_req_from_line
+from pip._internal.utils.misc import redact_auth_from_url
 
 from .. import click
 from .._compat import parse_requirements
@@ -24,9 +25,15 @@
 DEFAULT_REQUIREMENTS_FILE = "requirements.in"
 DEFAULT_REQUIREMENTS_OUTPUT_FILE = "requirements.txt"
 
-# Get default values of the pip's options (including options from pip.conf).
-install_command = create_command("install")
-pip_defaults = install_command.parser.get_default_values()
+
+def _get_default_option(option_name):
+    """
+    Get default value of the pip's option (including option from pip.conf)
+    by a given option name.
+    """
+    install_command = create_command("install")
+    default_values = install_command.parser.get_default_values()
+    return getattr(default_values, option_name)
 
 
 @click.command()
@@ -63,7 +70,9 @@
 @click.option(
     "-i",
     "--index-url",
-    help="Change index URL (defaults to {})".format(pip_defaults.index_url),
+    help="Change index URL (defaults to {index_url})".format(
+        index_url=redact_auth_from_url(_get_default_option("index_url"))
+    ),
     envvar="PIP_INDEX_URL",
 )
 @click.option(
@@ -371,14 +380,14 @@ def cli(
     log.debug("Using indexes:")
     with log.indentation():
         for index_url in dedup(repository.finder.index_urls):
-            log.debug(index_url)
+            log.debug(redact_auth_from_url(index_url))
 
     if repository.finder.find_links:
         log.debug("")
-        log.debug("Configuration:")
+        log.debug("Using links:")
         with log.indentation():
             for find_link in dedup(repository.finder.find_links):
-                log.debug("-f {}".format(find_link))
+                log.debug(redact_auth_from_url(find_link))
 
     try:
         resolver = Resolver(
diff --git a/piptools/utils.py b/piptools/utils.py
--- a/piptools/utils.py
+++ b/piptools/utils.py
@@ -8,6 +8,8 @@
 import six
 from click.utils import LazyFile
 from pip._internal.req.constructors import install_req_from_line
+from pip._internal.utils.misc import redact_auth_from_url
+from pip._internal.vcs import is_url
 from six.moves import shlex_quote
 
 from ._compat import PIP_VERSION
@@ -365,6 +367,8 @@ def get_compile_command(click_ctx):
                 left_args.append(shlex_quote(arg))
             # Append to args the option with a value
             else:
+                if isinstance(val, six.string_types) and is_url(val):
+                    val = redact_auth_from_url(val)
                 if option.name == "pip_args":
                     # shlex_quote would produce functional but noisily quoted results,
                     # e.g. --pip-args='--cache-dir='"'"'/tmp/with spaces'"'"''


Test patch:
diff --git a/tests/test_cli_compile.py b/tests/test_cli_compile.py
--- a/tests/test_cli_compile.py
+++ b/tests/test_cli_compile.py
@@ -107,7 +107,7 @@ def test_find_links_option(runner):
     out = runner.invoke(cli, ["-v", "-f", "./libs1", "-f", "./libs2"])
 
     # Check that find-links has been passed to pip
-    assert "Configuration:\n  -f ./libs1\n  -f ./libs2\n  -f ./libs3\n" in out.stderr
+    assert "Using links:\n  ./libs1\n  ./libs2\n  ./libs3\n" in out.stderr
 
     # Check that find-links has been written to a requirements.txt
     with open("requirements.txt", "r") as req_txt:
@@ -143,6 +143,30 @@ def test_extra_index_option(pip_with_index_conf, runner):
     )
 
 
+@pytest.mark.parametrize("option", ("--extra-index-url", "--find-links"))
+def test_redacted_urls_in_verbose_output(runner, option):
+    """
+    Test that URLs with sensitive data don't leak to the output.
+    """
+    with open("requirements.in", "w"):
+        pass
+
+    out = runner.invoke(
+        cli,
+        [
+            "--no-header",
+            "--no-index",
+            "--no-emit-find-links",
+            "--verbose",
+            option,
+            "http://username:password@example.com",
+        ],
+    )
+
+    assert "http://username:****@example.com" in out.stderr
+    assert "password" not in out.stderr
+
+
 def test_trusted_host(pip_conf, runner):
     with open("requirements.in", "w"):
         pass
@@ -658,21 +682,37 @@ def test_no_candidates_pre(pip_conf, runner):
     assert "Tried pre-versions:" in out.stderr
 
 
-def test_default_index_url(pip_with_index_conf):
+@pytest.mark.parametrize(
+    ("url", "expected_url"),
+    (
+        pytest.param("https://example.com", "https://example.com", id="regular url"),
+        pytest.param(
+            "https://username:password@example.com",
+            "https://username:****@example.com",
+            id="url with credentials",
+        ),
+    ),
+)
+def test_default_index_url(make_pip_conf, url, expected_url):
+    """
+    Test help's output with default index URL.
+    """
+    make_pip_conf(
+        dedent(
+            """\
+            [global]
+            index-url = {url}
+            """.format(
+                url=url
+            )
+        )
+    )
+
     status, output = invoke([sys.executable, "-m", "piptools", "compile", "--help"])
     output = output.decode("utf-8")
 
-    # Click's subprocess output has \r\r\n line endings on win py27. Fix it.
-    output = output.replace("\r\r", "\r")
-
     assert status == 0
-    expected = (
-        "  -i, --index-url TEXT            Change index URL (defaults to"
-        + os.linesep
-        + "                                  http://example.com)"
-        + os.linesep
-    )
-    assert expected in output
+    assert expected_url in output
 
 
 def test_stdin_without_output_file(runner):
@@ -995,15 +1035,20 @@ def test_options_in_requirements_file(runner, options):
     ("cli_options", "expected_message"),
     (
         pytest.param(
-            ["--index-url", "file:foo"],
-            "Was file:foo reachable?",
+            ["--index-url", "scheme://foo"],
+            "Was scheme://foo reachable?",
             id="single index url",
         ),
         pytest.param(
-            ["--index-url", "file:foo", "--extra-index-url", "file:bar"],
-            "Were file:foo or file:bar reachable?",
+            ["--index-url", "scheme://foo", "--extra-index-url", "scheme://bar"],
+            "Were scheme://foo or scheme://bar reachable?",
             id="multiple index urls",
         ),
+        pytest.param(
+            ["--index-url", "scheme://username:password@host"],
+            "Was scheme://username:****@host reachable?",
+            id="index url with credentials",
+        ),
     ),
 )
 def test_unreachable_index_urls(runner, cli_options, expected_message):
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -274,6 +274,16 @@ def test_force_text(value, expected_text):
             ["--pip-args", "--disable-pip-version-check --isolated"],
             "pip-compile --pip-args='--disable-pip-version-check --isolated'",
         ),
+        pytest.param(
+            ["--extra-index-url", "https://username:password@example.com/"],
+            "pip-compile --extra-index-url='https://username:****@example.com/'",
+            id="redact password in index",
+        ),
+        pytest.param(
+            ["--find-links", "https://username:password@example.com/"],
+            "pip-compile --find-links='https://username:****@example.com/'",
+            id="redact password in link",
+        ),
     ),
 )
 def test_get_compile_command(tmpdir_cwd, cli_args, expected_command):