CVE-2024-5206 (published 2024-06-06T19:16Z)

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.


Weakness information:
cwe_id: CWE-921
cwe_name: Storage of Sensitive Data in a Mechanism without Access Control
description: The product stores sensitive information in a file system or device that does not have built-in access control.
extended_description: 
            While many modern file systems or devices utilize some form of access control in order to restrict access to data, not all storage mechanisms have this capability. For example, memory cards, floppy disks, CDs, and USB devices are typically made accessible to any user within the system. This can become a problem when sensitive data is stored in these mechanisms in a multi-user environment, because anybody on the system can read or write this data.
            On Android devices, external storage is typically globally readable and writable by other applications on the device. External storage may also be easily accessible through the mobile device's USB connection or physically accessible through the device's memory card port.
         
         

Fix information:
repo_url: https://github.com/scikit-learn/scikit-learn
commit_details: repo_url: https://github.com/scikit-learn/scikit-learn
author: Olivier Grisel
author_date: 2024-04-22 15:10:46+02:00
msg: FIX remove the computed stop_words_ attribute of text vectorizer (#28823)
repository: repo_url: https://github.com/scikit-learn/scikit-learn
repo_name: scikit-learn/scikit-learn
description: scikit-learn: machine learning in Python
repo_language: Python
owner: scikit-learn
file_changes: filename: v1.5.rst
change_type: ModificationType.MODIFY
diff: @@ -22,6 +22,24 @@ 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
 --------------
 

programming_language: Markdown
filename: test_text.py
change_type: ModificationType.MODIFY
diff: @@ -756,21 +756,11 @@ 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():
@@ -805,21 +795,16 @@ def test_vectorizer_max_df():
     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():
@@ -828,21 +813,16 @@ def test_vectorizer_min_df():
     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():
@@ -1155,28 +1135,6 @@ def test_countvectorizer_vocab_dicts_when_pickling():
         )
 
 
-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)

programming_language: Python
filename: text.py
change_type: ModificationType.MODIFY
diff: @@ -1079,15 +1079,6 @@ class CountVectorizer(_VectorizerMixin, BaseEstimator):
         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
@@ -1096,12 +1087,6 @@ class CountVectorizer(_VectorizerMixin, BaseEstimator):
     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
@@ -1240,19 +1225,17 @@ class CountVectorizer(_VectorizerMixin, BaseEstimator):
             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"""
@@ -1397,7 +1380,7 @@ class CountVectorizer(_VectorizerMixin, BaseEstimator):
                 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:
@@ -1911,15 +1894,6 @@ class TfidfVectorizer(CountVectorizer):
         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.
@@ -1927,12 +1901,6 @@ class TfidfVectorizer(CountVectorizer):
     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

programming_language: Python