CVE-2024-5206: Storage of Sensitive Data in a Mechanism without Access Control (CWE-921)
value: A sensitive data leakage vulnerability was identified in scikit-learn's TfidfVectorizer, specifically in versions up to and including 1.4.1.post1, which was fixed in version 1.5.0. The vulnerability arises from the unexpected storage of all tokens present in the training data within the `stop_words_` attribute, rather than only storing the subset of tokens required for the TF-IDF technique to function. This behavior leads to the potential leakage of sensitive information, as the `stop_words_` attribute could contain tokens that were meant to be discarded and not stored, such as passwords or keys. The impact of this vulnerability varies based on the nature of the data being processed by the vectorizer.
CWE description: The product stores sensitive information in a file system or device that does not have built-in access control.
Published: 2024-06-06T19:16Z
Repository: https://github.com/scikit-learn/scikit-learn
Language: Python
Files changed: doc/whats_new/v1.5.rst, sklearn/feature_extraction/tests/test_text.py, sklearn/feature_extraction/text.py
Commit date: 2024-04-22 15:10:46 +0200
Commit message: FIX remove the computed stop_words_ attribute of text vectorizer (#28823)


Diff: diff --git a/doc/whats_new/v1.5.rst b/doc/whats_new/v1.5.rst
index 24893cdebc..974ecc4bf3 100644
--- a/doc/whats_new/v1.5.rst
+++ b/doc/whats_new/v1.5.rst
@@ -20,10 +20,28 @@ Version 1.5
 Version 1.5.0
 =============
 
 **In Development**
 
+Security
+--------
+
+- |Fix| :class:`feature_extraction.text.CountVectorizer` and
+  :class:`feature_extraction.text.TfidfVectorizer` no longer store discarded
+  tokens from the training set in their `stop_words_` attribute. This attribute
+  would hold too frequent (above `max_df`) but also too rare tokens (below
+  `min_df`). This fixes a potential security issue (data leak) if the discarded
+  rare tokens hold sensitive information from the training set without the
+  model developer's knowledge.
+
+  Note: users of those classes are encouraged to either retrain their pipelines
+  with the new scikit-learn version or to manually clear the `stop_words_`
+  attribute from previously trained instances of those transformers. This
+  attribute was designed only for model inspection purposes and has no impact
+  on the behavior of the transformers.
+  :pr:`28823` by :user:`Olivier Grisel <ogrisel>`.
+
 Changed models
 --------------
 
 - |Efficiency| The subsampling in :class:`preprocessing.QuantileTransformer` is now
   more efficient for dense arrays but the fitted quantiles and the results of
diff --git a/sklearn/feature_extraction/tests/test_text.py b/sklearn/feature_extraction/tests/test_text.py
index a513b94436..6b14d0dd8f 100644
--- a/sklearn/feature_extraction/tests/test_text.py
+++ b/sklearn/feature_extraction/tests/test_text.py
@@ -754,25 +754,15 @@ def test_feature_names():
 
 
 @pytest.mark.parametrize("Vectorizer", (CountVectorizer, TfidfVectorizer))
 def test_vectorizer_max_features(Vectorizer):
     expected_vocabulary = {"burger", "beer", "salad", "pizza"}
-    expected_stop_words = {
-        "celeri",
-        "tomato",
-        "copyright",
-        "coke",
-        "sparkling",
-        "water",
-        "the",
-    }
 
     # test bounded number of extracted features
     vectorizer = Vectorizer(max_df=0.6, max_features=4)
     vectorizer.fit(ALL_FOOD_DOCS)
     assert set(vectorizer.vocabulary_) == expected_vocabulary
-    assert vectorizer.stop_words_ == expected_stop_words
 
 
 def test_count_vectorizer_max_features():
     # Regression test: max_features didn't work correctly in 0.14.
 
@@ -803,48 +793,38 @@ def test_vectorizer_max_df():
     test_data = ["abc", "dea", "eat"]
     vect = CountVectorizer(analyzer="char", max_df=1.0)
     vect.fit(test_data)
     assert "a" in vect.vocabulary_.keys()
     assert len(vect.vocabulary_.keys()) == 6
-    assert len(vect.stop_words_) == 0
 
     vect.max_df = 0.5  # 0.5 * 3 documents -> max_doc_count == 1.5
     vect.fit(test_data)
     assert "a" not in vect.vocabulary_.keys()  # {ae} ignored
     assert len(vect.vocabulary_.keys()) == 4  # {bcdt} remain
-    assert "a" in vect.stop_words_
-    assert len(vect.stop_words_) == 2
 
     vect.max_df = 1
     vect.fit(test_data)
     assert "a" not in vect.vocabulary_.keys()  # {ae} ignored
     assert len(vect.vocabulary_.keys()) == 4  # {bcdt} remain
-    assert "a" in vect.stop_words_
-    assert len(vect.stop_words_) == 2
 
 
 def test_vectorizer_min_df():
     test_data = ["abc", "dea", "eat"]
     vect = CountVectorizer(analyzer="char", min_df=1)
     vect.fit(test_data)
     assert "a" in vect.vocabulary_.keys()
     assert len(vect.vocabulary_.keys()) == 6
-    assert len(vect.stop_words_) == 0
 
     vect.min_df = 2
     vect.fit(test_data)
     assert "c" not in vect.vocabulary_.keys()  # {bcdt} ignored
     assert len(vect.vocabulary_.keys()) == 2  # {ae} remain
-    assert "c" in vect.stop_words_
-    assert len(vect.stop_words_) == 4
 
     vect.min_df = 0.8  # 0.8 * 3 documents -> min_doc_count == 2.4
     vect.fit(test_data)
     assert "c" not in vect.vocabulary_.keys()  # {bcdet} ignored
     assert len(vect.vocabulary_.keys()) == 1  # {a} remains
-    assert "c" in vect.stop_words_
-    assert len(vect.stop_words_) == 5
 
 
 def test_count_binary_occurrences():
     # by default multiple occurrences are counted as longs
     test_data = ["aaabc", "abbde"]
@@ -1153,32 +1133,10 @@ def test_countvectorizer_vocab_dicts_when_pickling():
         assert_array_equal(
             cv.get_feature_names_out(), unpickled_cv.get_feature_names_out()
         )
 
 
-def test_stop_words_removal():
-    # Ensure that deleting the stop_words_ attribute doesn't affect transform
-
-    fitted_vectorizers = (
-        TfidfVectorizer().fit(JUNK_FOOD_DOCS),
-        CountVectorizer(preprocessor=strip_tags).fit(JUNK_FOOD_DOCS),
-        CountVectorizer(strip_accents=strip_eacute).fit(JUNK_FOOD_DOCS),
-    )
-
-    for vect in fitted_vectorizers:
-        vect_transform = vect.transform(JUNK_FOOD_DOCS).toarray()
-
-        vect.stop_words_ = None
-        stop_None_transform = vect.transform(JUNK_FOOD_DOCS).toarray()
-
-        delattr(vect, "stop_words_")
-        stop_del_transform = vect.transform(JUNK_FOOD_DOCS).toarray()
-
-        assert_array_equal(stop_None_transform, vect_transform)
-        assert_array_equal(stop_del_transform, vect_transform)
-
-
 def test_pickling_transformer():
     X = CountVectorizer().fit_transform(JUNK_FOOD_DOCS)
     orig = TfidfTransformer().fit(X)
     s = pickle.dumps(orig)
     copy = pickle.loads(s)
diff --git a/sklearn/feature_extraction/text.py b/sklearn/feature_extraction/text.py
index c8cf37ac32..756a5222c4 100644
--- a/sklearn/feature_extraction/text.py
+++ b/sklearn/feature_extraction/text.py
@@ -1077,33 +1077,18 @@ class CountVectorizer(_VectorizerMixin, BaseEstimator):
 
     fixed_vocabulary_ : bool
         True if a fixed vocabulary of term to indices mapping
         is provided by the user.
 
-    stop_words_ : set
-        Terms that were ignored because they either:
-
-          - occurred in too many documents (`max_df`)
-          - occurred in too few documents (`min_df`)
-          - were cut off by feature selection (`max_features`).
-
-        This is only available if no vocabulary was given.
-
     See Also
     --------
     HashingVectorizer : Convert a collection of text documents to a
         matrix of token counts.
 
     TfidfVectorizer : Convert a collection of raw documents to a matrix
         of TF-IDF features.
 
-    Notes
-    -----
-    The ``stop_words_`` attribute can get large and increase the model size
-    when pickling. This attribute is provided only for introspection and can
-    be safely removed using delattr or set to None before pickling.
-
     Examples
     --------
     >>> from sklearn.feature_extraction.text import CountVectorizer
     >>> corpus = [
     ...     'This is the first document.',
@@ -1238,23 +1223,21 @@ class CountVectorizer(_VectorizerMixin, BaseEstimator):
             new_mask = np.zeros(len(dfs), dtype=bool)
             new_mask[np.where(mask)[0][mask_inds]] = True
             mask = new_mask
 
         new_indices = np.cumsum(mask) - 1  # maps old indices to new
-        removed_terms = set()
         for term, old_index in list(vocabulary.items()):
             if mask[old_index]:
                 vocabulary[term] = new_indices[old_index]
             else:
                 del vocabulary[term]
-                removed_terms.add(term)
         kept_indices = np.where(mask)[0]
         if len(kept_indices) == 0:
             raise ValueError(
                 "After pruning, no terms remain. Try a lower min_df or a higher max_df."
             )
-        return X[:, kept_indices], removed_terms
+        return X[:, kept_indices]
 
     def _count_vocab(self, raw_documents, fixed_vocab):
         """Create sparse feature matrix, and vocabulary where fixed_vocab=False"""
         if fixed_vocab:
             vocabulary = self.vocabulary_
@@ -1395,11 +1378,11 @@ class CountVectorizer(_VectorizerMixin, BaseEstimator):
             min_doc_count = min_df if isinstance(min_df, Integral) else min_df * n_doc
             if max_doc_count < min_doc_count:
                 raise ValueError("max_df corresponds to < documents than min_df")
             if max_features is not None:
                 X = self._sort_features(X, vocabulary)
-            X, self.stop_words_ = self._limit_features(
+            X = self._limit_features(
                 X, vocabulary, max_doc_count, min_doc_count, max_features
             )
             if max_features is None:
                 X = self._sort_features(X, vocabulary)
             self.vocabulary_ = vocabulary
@@ -1909,32 +1892,17 @@ class TfidfVectorizer(CountVectorizer):
 
     idf_ : array of shape (n_features,)
         The inverse document frequency (IDF) vector; only defined
         if ``use_idf`` is True.
 
-    stop_words_ : set
-        Terms that were ignored because they either:
-
-          - occurred in too many documents (`max_df`)
-          - occurred in too few documents (`min_df`)
-          - were cut off by feature selection (`max_features`).
-
-        This is only available if no vocabulary was given.
-
     See Also
     --------
     CountVectorizer : Transforms text into a sparse matrix of n-gram counts.
 
     TfidfTransformer : Performs the TF-IDF transformation from a provided
         matrix of counts.
 
-    Notes
-    -----
-    The ``stop_words_`` attribute can get large and increase the model size
-    when pickling. This attribute is provided only for introspection and can
-    be safely removed using delattr or set to None before pickling.
-
     Examples
     --------
     >>> from sklearn.feature_extraction.text import TfidfVectorizer
     >>> corpus = [
     ...     'This is the first document.',

Vulnerable code:     expected_stop_words = {
        "celeri",
        "tomato",
        "copyright",
        "coke",
        "sparkling",
        "water",
        "the",
    }
    assert vectorizer.stop_words_ == expected_stop_words
    assert len(vect.stop_words_) == 0
    assert "a" in vect.stop_words_
    assert len(vect.stop_words_) == 2
    assert "a" in vect.stop_words_
    assert len(vect.stop_words_) == 2
    assert len(vect.stop_words_) == 0
    assert "c" in vect.stop_words_
    assert len(vect.stop_words_) == 4
    assert "c" in vect.stop_words_
    assert len(vect.stop_words_) == 5
def test_stop_words_removal():
    # Ensure that deleting the stop_words_ attribute doesn't affect transform

    fitted_vectorizers = (
        TfidfVectorizer().fit(JUNK_FOOD_DOCS),
        CountVectorizer(preprocessor=strip_tags).fit(JUNK_FOOD_DOCS),
        CountVectorizer(strip_accents=strip_eacute).fit(JUNK_FOOD_DOCS),
    )

    for vect in fitted_vectorizers:
        vect_transform = vect.transform(JUNK_FOOD_DOCS).toarray()

        vect.stop_words_ = None
        stop_None_transform = vect.transform(JUNK_FOOD_DOCS).toarray()

        delattr(vect, "stop_words_")
        stop_del_transform = vect.transform(JUNK_FOOD_DOCS).toarray()

        assert_array_equal(stop_None_transform, vect_transform)
        assert_array_equal(stop_del_transform, vect_transform)


    stop_words_ : set
        Terms that were ignored because they either:

          - occurred in too many documents (`max_df`)
          - occurred in too few documents (`min_df`)
          - were cut off by feature selection (`max_features`).

        This is only available if no vocabulary was given.

    Notes
    -----
    The ``stop_words_`` attribute can get large and increase the model size
    when pickling. This attribute is provided only for introspection and can
    be safely removed using delattr or set to None before pickling.

        removed_terms = set()
                removed_terms.add(term)
        return X[:, kept_indices], removed_terms
            X, self.stop_words_ = self._limit_features(
    stop_words_ : set
        Terms that were ignored because they either:

          - occurred in too many documents (`max_df`)
          - occurred in too few documents (`min_df`)
          - were cut off by feature selection (`max_features`).

        This is only available if no vocabulary was given.

    Notes
    -----
    The ``stop_words_`` attribute can get large and increase the model size
    when pickling. This attribute is provided only for introspection and can
    be safely removed using delattr or set to None before pickling.

Fixed code: Security
--------

- |Fix| :class:`feature_extraction.text.CountVectorizer` and
  :class:`feature_extraction.text.TfidfVectorizer` no longer store discarded
  tokens from the training set in their `stop_words_` attribute. This attribute
  would hold too frequent (above `max_df`) but also too rare tokens (below
  `min_df`). This fixes a potential security issue (data leak) if the discarded
  rare tokens hold sensitive information from the training set without the
  model developer's knowledge.

  Note: users of those classes are encouraged to either retrain their pipelines
  with the new scikit-learn version or to manually clear the `stop_words_`
  attribute from previously trained instances of those transformers. This
  attribute was designed only for model inspection purposes and has no impact
  on the behavior of the transformers.
  :pr:`28823` by :user:`Olivier Grisel <ogrisel>`.

        return X[:, kept_indices]
            X = self._limit_features(