CVE-2024-41129 (Python, published 2024-07-22T15:15Z)
Description: value: The ops library is a Python framework for developing and testing Kubernetes and machine charms. The issue here is that ops passes the secret content as one of the args via CLI. This issue may affect any of the charms that are using: Juju (>=3.0), Juju secrets and not correctly capturing and processing `subprocess.CalledProcessError`. This vulnerability is fixed in 2.15.0.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/canonical/operator
Files changed:
ops/model.py
test/test_helpers.py
test/test_model.py
Commit message: fix: use temp dir for secret data (#1290)

Change the mechanism used to pass secret values from ops to hook tools
from command line to files in temporary directory.

Removes the 2MB total secret size limit imposed by command line length

Hook tool calls juju agent which in turn reads these files, I've
confirmed with the Juju team that juju agent and charm code are run as
the same user.

Vulnerable code:
        if content is not None:
            # The content has already been validated with Secret._validate_content
            for k, v in content.items():
                args.append(f'{k}={v}')
        self._run_for_secret('secret-set', *args)
        # The content has already been validated with Secret._validate_content
        for k, v in content.items():
            args.append(f'{k}={v}')
        result = self._run('secret-add', *args, return_output=True)
from unittest.mock import MagicMock, patch
        assert fake_script.calls(clear=True) == [['secret-add', '--owner', 'application', 'foo=x']]
                'foo=x',
                'bar=y',
        assert fake_script.calls(clear=True) == [['secret-add', '--owner', 'unit', 'foo=x']]
                'foo=w',
                'bar=z',
            ['secret-set', 'secret:x', 'foo=bar'],
            ['secret-set', 'secret:z', 'bar=foo'],

Fixed code:
        with tempfile.TemporaryDirectory() as tmp:
            # The content is None or has already been validated with Secret._validate_content
            for k, v in (content or {}).items():
                with open(f'{tmp}/{k}', mode='w', encoding='utf-8') as f:
                    f.write(v)
                args.append(f'{k}#file={tmp}/{k}')
            self._run_for_secret('secret-set', *args)
        with tempfile.TemporaryDirectory() as tmp:
            # The content has already been validated with Secret._validate_content
            for k, v in content.items():
                with open(f'{tmp}/{k}', mode='w', encoding='utf-8') as f:
                    f.write(v)
                args.append(f'{k}#file={tmp}/{k}')
            result = self._run('secret-add', *args, return_output=True)

# Capture key and data from key#file=/some/path arguments
for word in "$@"; do
  echo "$word" | grep -q "#file=" || continue
  key=$(echo "$word" | cut -d'#' -f1)
  path=$(echo "$word" | cut -d'=' -f2)
  cp "$path" "{path}/$key.secret"
done

    def secrets(self) -> typing.Dict[str, str]:
        return {p.stem: p.read_text() for p in self.path.iterdir() if p.suffix == '.secret'}

from unittest.mock import ANY, MagicMock, patch
        assert fake_script.calls(clear=True) == [['secret-add', '--owner', 'application', ANY]]
        assert fake_script.secrets() == {'foo': 'x'}
                ANY,
                ANY,
        assert fake_script.secrets() == {'foo': 'x', 'bar': 'y'}
        assert fake_script.calls(clear=True) == [['secret-add', '--owner', 'unit', ANY]]
        assert fake_script.secrets() == {'foo': 'x'}
                ANY,
                ANY,
        assert fake_script.secrets() == {'foo': 'w', 'bar': 'z'}
            ['secret-set', 'secret:x', ANY],
            ['secret-set', 'secret:z', ANY],
        assert fake_script.secrets() == {'foo': 'bar', 'bar': 'foo'}