Repository: dbt-labs/dbt-core

Issue:
[CT-2342] [Feature] Support secret `--vars`, by scrubbing their values from logs (like env vars)
### Is this a new bug in dbt-core?

- [X] I believe this is a new bug in dbt-core
- [X] I have searched the existing issues, and I could not find an existing issue for this bug

### Current Behavior

When the `RequiredVarNotFoundError` exception is raised anywhere within the dbt execution it leaks secrets to stdout / logs since it does not mask `DBT_ENV_SECRET_*` vars in the exception message.

### Expected Behavior

`DBT_ENV_SECRET_*` vars should be masked in the exception message for `RequiredVarNotFoundError`

### Steps To Reproduce

1. Trigger a `RequiredVarNotFoundError` with `DBT_ENV_SECRET_*` vars
2. See plain text secrets in stdout

### Relevant log output

_No response_

### Environment

```markdown
- OS: any
- Python: any
- dbt: 1.4.3+
```


### Which database adapter are you using with dbt?

_No response_

### Additional Context

_No response_


Discussion:
@nielspardon Thanks for flagging! 

https://github.com/dbt-labs/dbt-core/blob/050161c78f6d9acc36d0b76bd54ffcd778b7a467/core/dbt/exceptions.py#L800-L817

I think we need that last line to be:
```python
return scrub_secrets(msg, env_secrets())
```
I think our expectation of the behavior was different than documented. What we are actually looking for is secret scrubbing for vars (not only env_vars). The expectation was that if you provide a var with the `DBT_ENV_SECRET_` prefix that it would be scrubbed just like env vars would be scrubbed.
I've been playing around with this code snippet for `RequiredVarNotFoundError.get_message()`:
```
        return scrub_secrets(msg, self.var_secrets())

    def var_secrets(self) -> List[str]:
        return [v for k, v in self.merged.items() if k.startswith(SECRET_ENV_PREFIX) and v.strip()]
```
@nielspardon Ah, ok! That's different from the current behavior, and from what we have documented here: https://docs.getdbt.com/reference/dbt-jinja-functions/env_var#secrets

I just tried this out locally, and found that if I set the same value as _both_ a "secret" env var, and pass it into `--vars`, it is appropriately scrubbed from the logs:
```
-- models/my_model.sql
select {{ var('another_var') }}
```
```
$ DBT_ENV_SECRET_ENV_MYSECRET=abc123 dbt run --vars 'nowasvar: abc123'
...
09:50:00  Compilation Error in model my_model (models/my_model.sql)
09:50:00    Required var 'another_var' not found in config:
09:50:00    Vars supplied to my_model = {
09:50:00        "nowasvar": "*****"
09:50:00    }
09:50:00
09:50:00  Done. PASS=1 WARN=0 ERROR=1 SKIP=0 TOTAL=2
```
> @nielspardon Ah, ok! That's different from the current behavior, and from what we have documented here: https://docs.getdbt.com/reference/dbt-jinja-functions/env_var#secrets

Yes, I also just realized that. It would probably also explain why scrub_secrets was not implemented in the first place for this specific exception.

I think it would be beneficial to also be able to scrub secret vars and this is more of a feature request then.
@nielspardon Could you say more about the intended use case? Since `vars` need to be specified either in `dbt_project.yml` (version control!), or in the `--vars` CLI parameter, it doesn't make as much sense to me as a place to store sensitive values.
I can also save the variables in a file let's say `secrets.json` and issue this CLI command e.g. on a CI/CD system where users have access to the logs but should not see the secret values:

```
dbt run --vars "$(cat secrets.json)"
```
This issue has been marked as Stale because it has been open for 180 days with no activity. If you would like the issue to remain open, please comment on the issue or else it will be closed in 7 days.
This is still an open issue and should be fixed.

Resolving patch:
diff --git a/core/dbt/artifacts/schemas/run/v5/run.py b/core/dbt/artifacts/schemas/run/v5/run.py
--- a/core/dbt/artifacts/schemas/run/v5/run.py
+++ b/core/dbt/artifacts/schemas/run/v5/run.py
@@ -1,9 +1,11 @@
 import threading
 from typing import Any, Optional, Iterable, Tuple, Sequence, Dict, TYPE_CHECKING
+import copy
 from dataclasses import dataclass, field
 from datetime import datetime
 
 
+from dbt.constants import SECRET_ENV_PREFIX
 from dbt.artifacts.resources import CompiledResource
 from dbt.artifacts.schemas.base import (
     BaseArtifactMetadata,
@@ -19,6 +21,7 @@
     ExecutionResult,
 )
 from dbt_common.clients.system import write_json
+from dbt.exceptions import scrub_secrets
 
 
 if TYPE_CHECKING:
@@ -123,7 +126,26 @@ def from_execution_results(
             dbt_schema_version=str(cls.dbt_schema_version),
             generated_at=generated_at,
         )
-        return cls(metadata=meta, results=processed_results, elapsed_time=elapsed_time, args=args)
+
+        secret_vars = [
+            v for k, v in args["vars"].items() if k.startswith(SECRET_ENV_PREFIX) and v.strip()
+        ]
+
+        scrubbed_args = copy.deepcopy(args)
+
+        # scrub secrets in invocation command
+        scrubbed_args["invocation_command"] = scrub_secrets(
+            scrubbed_args["invocation_command"], secret_vars
+        )
+
+        # scrub secrets in vars dict
+        scrubbed_args["vars"] = {
+            k: scrub_secrets(v, secret_vars) for k, v in scrubbed_args["vars"].items()
+        }
+
+        return cls(
+            metadata=meta, results=processed_results, elapsed_time=elapsed_time, args=scrubbed_args
+        )
 
     @classmethod
     def compatible_previous_versions(cls) -> Iterable[Tuple[str, int]]:
diff --git a/core/dbt/exceptions.py b/core/dbt/exceptions.py
--- a/core/dbt/exceptions.py
+++ b/core/dbt/exceptions.py
@@ -17,6 +17,8 @@
 
 from dbt_common.dataclass_schema import ValidationError
 
+from dbt.constants import SECRET_ENV_PREFIX
+
 
 if TYPE_CHECKING:
     import agate
@@ -333,7 +335,10 @@ def get_message(self) -> str:
         pretty_vars = json.dumps(dct, sort_keys=True, indent=4)
 
         msg = f"Required var '{self.var_name}' not found in config:\nVars supplied to {node_name} = {pretty_vars}"
-        return msg
+        return scrub_secrets(msg, self.var_secrets())
+
+    def var_secrets(self) -> List[str]:
+        return [v for k, v in self.merged.items() if k.startswith(SECRET_ENV_PREFIX) and v.strip()]
 
 
 class PackageNotFoundForMacroError(CompilationError):
diff --git a/core/dbt/parser/manifest.py b/core/dbt/parser/manifest.py
--- a/core/dbt/parser/manifest.py
+++ b/core/dbt/parser/manifest.py
@@ -44,6 +44,7 @@
     MANIFEST_FILE_NAME,
     PARTIAL_PARSE_FILE_NAME,
     SEMANTIC_MANIFEST_FILE_NAME,
+    SECRET_ENV_PREFIX,
 )
 from dbt_common.helper_types import PathSet
 from dbt_common.events.functions import fire_event, get_invocation_id, warn_or_error
@@ -116,6 +117,7 @@
     TargetNotFoundError,
     AmbiguousAliasError,
     InvalidAccessTypeError,
+    scrub_secrets,
 )
 from dbt.parser.base import Parser
 from dbt.parser.analysis import AnalysisParser
@@ -989,6 +991,9 @@ def build_manifest_state_check(self):
         # of env_vars, that would need to change.
         # We are using the parsed cli_vars instead of config.args.vars, in order
         # to sort them and avoid reparsing because of ordering issues.
+        secret_vars = [
+            v for k, v in config.cli_vars.items() if k.startswith(SECRET_ENV_PREFIX) and v.strip()
+        ]
         stringified_cli_vars = pprint.pformat(config.cli_vars)
         vars_hash = FileHash.from_contents(
             "\x00".join(
@@ -1003,7 +1008,7 @@ def build_manifest_state_check(self):
         fire_event(
             StateCheckVarsHash(
                 checksum=vars_hash.checksum,
-                vars=stringified_cli_vars,
+                vars=scrub_secrets(stringified_cli_vars, secret_vars),
                 profile=config.args.profile,
                 target=config.args.target,
                 version=__version__,


Test patch:
diff --git a/tests/functional/context_methods/test_cli_vars.py b/tests/functional/context_methods/test_cli_vars.py
--- a/tests/functional/context_methods/test_cli_vars.py
+++ b/tests/functional/context_methods/test_cli_vars.py
@@ -3,7 +3,13 @@
 
 from tests.fixtures.dbt_integration_project import dbt_integration_project  # noqa: F401
 
-from dbt.tests.util import run_dbt, get_artifact, write_config_file
+from dbt.tests.util import (
+    run_dbt,
+    run_dbt_and_capture,
+    get_logging_events,
+    get_artifact,
+    write_config_file,
+)
 from dbt.tests.fixtures.project import write_project_files
 from dbt.exceptions import DbtRuntimeError, CompilationError
 
@@ -206,3 +212,76 @@ def test_vars_in_selectors(self, project):
         # Var in cli_vars works
         results = run_dbt(["run", "--vars", "snapshot_target: dev"])
         assert len(results) == 1
+
+
+models_scrubbing__schema_yml = """
+version: 2
+models:
+- name: simple_model
+  columns:
+  - name: simple
+    data_tests:
+    - accepted_values:
+        values:
+        - abc
+"""
+
+models_scrubbing__simple_model_sql = """
+select
+    '{{ var("DBT_ENV_SECRET_simple") }}'::varchar as simple
+"""
+
+
+class TestCLIVarsScrubbing:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "schema.yml": models_scrubbing__schema_yml,
+            "simple_model.sql": models_scrubbing__simple_model_sql,
+        }
+
+    def test__run_results_scrubbing(self, project):
+        results, output = run_dbt_and_capture(
+            [
+                "--debug",
+                "--log-format",
+                "json",
+                "run",
+                "--vars",
+                "{DBT_ENV_SECRET_simple: abc, unused: def}",
+            ]
+        )
+        assert len(results) == 1
+
+        run_results = get_artifact(project.project_root, "target", "run_results.json")
+        assert run_results["args"]["vars"] == {
+            "DBT_ENV_SECRET_simple": "*****",
+            "unused": "def",
+        }
+
+        log_events = get_logging_events(log_output=output, event_name="StateCheckVarsHash")
+        assert len(log_events) == 1
+        assert (
+            log_events[0]["data"]["vars"] == "{'DBT_ENV_SECRET_simple': '*****', 'unused': 'def'}"
+        )
+
+    def test__exception_scrubbing(self, project):
+        results, output = run_dbt_and_capture(
+            [
+                "--debug",
+                "--log-format",
+                "json",
+                "run",
+                "--vars",
+                "{DBT_ENV_SECRET_unused: abc, unused: def}",
+            ],
+            False,
+        )
+        assert len(results) == 1
+
+        log_events = get_logging_events(log_output=output, event_name="CatchableExceptionOnRun")
+        assert len(log_events) == 1
+        assert (
+            '{\n      "DBT_ENV_SECRET_unused": "*****",\n      "unused": "def"\n  }'
+            in log_events[0]["info"]["msg"]
+        )
diff --git a/tests/unit/test_parse_manifest.py b/tests/unit/test_parse_manifest.py
--- a/tests/unit/test_parse_manifest.py
+++ b/tests/unit/test_parse_manifest.py
@@ -108,7 +108,7 @@ def _new_file(self, searched, name, match):
 class TestPartialParse(unittest.TestCase):
     def setUp(self) -> None:
         mock_project = MagicMock(RuntimeConfig)
-        mock_project.cli_vars = ""
+        mock_project.cli_vars = {}
         mock_project.args = MagicMock()
         mock_project.args.profile = "test"
         mock_project.args.target = "test"