Files
presidio/presidio-cli/presidio_cli/config.py
T
Dmitry Voropaev 645dfa1cc6 fix(cli): fix --no-warnings, exit code, github output and threshold config (#2266)
* fix(cli): make --no-warnings, the exit code and -f github work

PIIProblem never got the `level` attribute that show_problems() reads for
--no-warnings, so the flag crashed with AttributeError as soon as a file
had a finding. Findings now have level "error" (score 1.0) or "warning",
the same split the colored output already used.

show_problems() always returned 0 and run() overwrote its result for every
file, so the CLI exited with 0 even when it reported PII. show_problems()
now returns the number of findings it printed and run() adds them up, so
the exit code is 1 when any finding is reported.

-f github printed `::<score> file=...`, which is not a workflow command, so
GitHub Actions never created annotations. It now prints ::warning and
::error commands with escaped property values and message, and drops the
./ prefix that `presidio .` adds to file paths.

The problems test fixture used Mock objects, which create any attribute on
access and so hid the missing `level`; it now builds real PIIProblem
objects.

* fix(cli): validate the threshold read from the config file

PresidioCLIConfig.parse() range-checked the current threshold (the default
0) instead of the configured value, and only converted the configured
value afterwards. `threshold: 5` was accepted and filtered out every
finding, and `threshold: abc` raised an uncaught ValueError. The
configured value is now converted and range-checked before it is stored,
and invalid values raise PresidioCLIConfigError. This also covers YAML
booleans such as `true`, which float() would accept as 1.0, and integers
too large for a float.
2026-09-20 09:15:55 +00:00

174 lines
6.1 KiB
Python

import os
from typing import Optional
import pathspec
import yaml
from presidio_analyzer import AnalyzerEngine
class PresidioCLIConfigError(Exception):
"""Represents an error in the configuration file."""
pass
class PresidioCLIConfig(object):
"""Represents Presidio CLI configuration file. Reads the file and transforms the contents to class fields.""" # noqa: E501
def __init__(
self, content: Optional[str] = None, file: Optional[str] = None
) -> None:
assert (content is None) ^ (file is None)
self.ignore = None
self.locale = None
self.analyzer = AnalyzerEngine()
self.threshold = 0
self.language = "en"
self.allow_list = []
if file is not None:
with open(file) as f:
content = f.read()
self.parse(content)
self.validate()
def is_file_ignored(self, filepath: str) -> bool:
"""
Check if file should be processed by the Analyzer or ignored.
:param filepath: Path of file to be processed.
"""
return self.ignore and filepath and self.ignore.match_file(filepath)
def is_text_file(self, filepath: str) -> bool:
"""Detect if file is a not a binary file.Based on https://stackoverflow.com/a/7392391.
:param filepath: Path of the configuration file.
"""
# Try to read the file as UTF-8.
# In case some invalid UTF-8 characters are found,
# an exception is caught and the file is not going
# to be processed.
try:
with open(filepath, newline="", encoding="utf-8") as f:
_ = f.read()
except UnicodeDecodeError:
return False
textchars = bytearray(
{7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7F}
)
with open(filepath, "rb") as f:
# return true if it's not a binary
return not bool(f.read(1024).translate(None, textchars))
def extend(self, base_config: "PresidioCLIConfig") -> None:
"""
In case the configuration file is based on another file, append detected entities from base config to current config and overwrite language and ignored files with contents of base config.
:param base_config: PresidioCLIConfig object
""" # noqa: E501
assert isinstance(base_config, PresidioCLIConfig)
# Create list with unique entries
if base_config.entities is not None:
self.entities = list(set(base_config.entities + self.entities))
if base_config.ignore is not None:
self.ignore = base_config.ignore
if base_config.language is not None:
self.language = base_config.language
def parse(self, raw_content: str) -> None:
"""
Read the content of YAML file and save the properties in class fields.
:param raw_content: String with the contents of configuration file.
"""
try:
conf = yaml.safe_load(raw_content)
except Exception as e:
raise PresidioCLIConfigError("invalid config: %s" % e)
if not isinstance(conf, dict):
raise PresidioCLIConfigError("invalid config: not a dict")
self.entities = conf.get("entities", {})
if self.entities == {}:
self.entities = self.analyzer.get_supported_entities()
if "threshold" in conf:
try:
# YAML loads true/yes/on as booleans, which float() would accept
if isinstance(conf["threshold"], bool):
raise TypeError("threshold is a boolean")
threshold = float(conf["threshold"])
except (TypeError, ValueError, OverflowError) as e:
raise PresidioCLIConfigError(
f"Invalid threshold value: {conf['threshold']}. "
"Threshold must be a number"
) from e
if not 0 <= threshold <= 1:
raise PresidioCLIConfigError(
f"Invalid threshold value: {conf['threshold']}. "
f"Threshold must be between 0 and 1"
)
self.threshold = threshold
if "allow" in conf:
self.allow_list = conf["allow"]
if "language" in conf:
self.language = conf["language"]
if "extends" in conf:
path = get_extended_config_file(conf["extends"])
base = PresidioCLIConfig(file=path)
try:
self.extend(base)
except Exception as e:
raise PresidioCLIConfigError("invalid config: %s" % e)
if "ignore" in conf:
if not isinstance(conf["ignore"], str):
raise PresidioCLIConfigError(
"invalid config: ignore should contain file patterns"
)
self.ignore = pathspec.PathSpec.from_lines(
"gitwildmatch", conf["ignore"].splitlines()
)
if "locale" in conf:
if not isinstance(conf["locale"], str):
raise PresidioCLIConfigError(
"invalid config: locale should be a string"
)
self.locale = conf["locale"]
def validate(self) -> None:
"""Check if entities requested to be detected in input file are supported by Presidio.""" # noqa: E501
for id in self.entities:
try:
assert id in self.analyzer.get_supported_entities()
except Exception:
raise PresidioCLIConfigError("invalid config: no such entity %s" % id)
def get_extended_config_file(name: str) -> str:
"""
Check if the configuration file is one of sample configs or if it is a file supplied by the user.
:param name: file name
:return: Full path of configuration file
""" # noqa: E501
# Is it a standard conf shipped with yamllint...
if "/" not in name:
std_conf = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "conf", name + ".yaml"
)
if os.path.isfile(std_conf):
return std_conf
# or a custom conf on filesystem?
return name