Skip to content

Commit

Permalink
feat: STFT-57 - Prompt for update on linter version mismatch (#3)
Browse files Browse the repository at this point in the history
* Adds pre-commit config validation and removes pycache files

* Adds diff output for mismatched configs

* Finishes implementing update check during scan

Also updates the update command to re-generate the config so it
matches expectations.

* Adds unit tests

* Fixes secureli test in build pipeline

* Makes declining update a non-halting outcome for scan

Also corrects 2 bugs:
- Incorrectly processing case with missing/added linter
and version mismatch at same time
- Moved config validation after upgrade check

* Updates tests for adjusted functionality

* Switches to update instead of upgrade command for config mismatch fix

* Renames hash variable and removes unused variables
  • Loading branch information
AldosAC authored Mar 28, 2023
1 parent 4db6e5d commit 78b353b
Show file tree
Hide file tree
Showing 46 changed files with 601 additions and 48 deletions.
13 changes: 13 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[run]
branch = True
source = secureli

[report]
fail_under = 90
omit =
tests/*
*/__init__.py
exclude_lines:
pragma: no cover
@abstractmethod
if __name__ == "__main__":
Binary file removed secureli/__pycache__/__init__.cpython-39.pyc
Binary file not shown.
Binary file removed secureli/__pycache__/container.cpython-39.pyc
Binary file not shown.
Binary file removed secureli/__pycache__/main.cpython-39.pyc
Binary file not shown.
Binary file removed secureli/__pycache__/patterns.cpython-39.pyc
Binary file not shown.
Binary file removed secureli/__pycache__/settings.cpython-39.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
234 changes: 229 additions & 5 deletions secureli/abstractions/pre_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ class LanguagePreCommitConfig(pydantic.BaseModel):
version: str


class UnexpectedReposResult(pydantic.BaseModel):
"""
The result of checking for unexpected repos in config
"""

missing_repos: Optional[list[str]] = []
unexpected_repos: Optional[list[str]] = []


class ExecuteResult(pydantic.BaseModel):
"""
The results of calling execute_hooks
Expand All @@ -59,6 +68,15 @@ class InstallResult(pydantic.BaseModel):
version_installed: str


class ValidateConfigResult(pydantic.BaseModel):
"""
The results of calling validate_config
"""

successful: bool
output: str


class Repo(pydantic.BaseModel):
"""A repository containing pre-commit hooks"""

Expand Down Expand Up @@ -96,7 +114,7 @@ def __init__(

def version_for_language(self, language: str) -> str:
"""
Calculates a hash of the pre-commit file for the given language to be used as part
Calculates a hash of the generated pre-commit file for the given language to be used as part
of the overall installed configuration.
:param language: The language specified
:raises LanguageNotSupportedError if the associated pre-commit file for the language is not found
Expand Down Expand Up @@ -191,6 +209,60 @@ def create_repo(raw_repo: dict) -> Repo:
repos = [create_repo(raw_repo) for raw_repo in config.get("repos", [])]
return HookConfiguration(repos=repos)

def get_current_configuration(self):
"""
Returns the contents of the .pre-commit-config.yaml file. Note that this should be used to
see the current state and not be used for any desired state actions.
:return: Dictionary containing the contents of the .pre-commit-config.yaml file
"""
path_to_pre_commit_file = Path(".pre-commit-config.yaml")

with open(path_to_pre_commit_file, "r") as f:
data = yaml.safe_load(f)
return data

def validate_config(self, language: str) -> bool:
"""
Validates that the current configuration matches the expected configuration generated
by secureli.
:param language: The language to validate against
:return: Returns a boolean indicating whether the configs match
"""
current_config = yaml.dump(self.get_current_configuration())
generated_config = self._calculate_combined_configuration_data(
language=language
)
current_hash = self.get_current_config_hash()
expected_hash = self._hash_config(generated_config)
output = ""

config_matches = current_hash == expected_hash

if not config_matches:
output += "SeCureLI has detected that the .pre-commit-config.yaml file does not match the expected configuration.\n"
output += "This often occurs when the .pre-commit-config.yaml file has been modified directly.\n"
output += "All changes to SeCureLI's configuration should be performed through the .secureli.yaml file.\n"
output += "\n"
output += self._compare_repo_versions(
current_config=yaml.safe_load(current_config),
expected_config=yaml.safe_load(generated_config),
)

return ValidateConfigResult(successful=config_matches, output=output)

def get_current_config_hash(self) -> str:
"""
Returns a hash of the current .pre-commit-config.yaml file. This hash is generated in the
same way that we generate the version hash for the secureli config file so should be valid
for comparison. Note this is the hash of the config file as it currently exists and not
the hash of the combined config.
:return: Returns a hash derived from the
"""
config_data = yaml.dump(self.get_current_configuration())
config_hash = self._hash_config(config_data)

return config_hash

def execute_hooks(
self, all_files: bool = False, hook_id: Optional[str] = None
) -> ExecuteResult:
Expand Down Expand Up @@ -276,7 +348,7 @@ def autoupdate_hooks(
else:
return ExecuteResult(successful=True, output=output)

def install_hooks(self) -> ExecuteResult:
def update(self) -> ExecuteResult:
"""
Installs the hooks defined in pre-commit-config.yml.
:return: ExecuteResult, indicating success or failure.
Expand Down Expand Up @@ -322,9 +394,7 @@ def _get_language_config(self, language: str) -> LanguagePreCommitConfig:
try:
config_data = self._calculate_combined_configuration_data(language)

version = hashlib.md5(
config_data.encode("utf8"), usedforsecurity=False
).hexdigest()
version = self._hash_config(config_data)
return LanguagePreCommitConfig(
language=language, config_data=config_data, version=version
)
Expand Down Expand Up @@ -539,3 +609,157 @@ def _apply_file_exclusions(
if pathspec_pattern.include
]
matching_hook["exclude"] = combine_patterns(raw_patterns)

def _hash_config(self, config: str) -> str:
"""
Creates an MD5 hash from a config string
:return: A hash string
"""
config_hash = hashlib.md5(
config.encode("utf8"), usedforsecurity=False
).hexdigest()

return config_hash

def _get_list_of_repo_urls(self, repo_list: list[dict]) -> list[str]:
"""
Parses a list containing repo dictionaries and returns a list of repo urls
:param repo_list: List of dictionaries containing repo configurations
:return: A list of repo urls.
"""
urls = []

for repo in repo_list:
urls.append(repo["repo"])

return urls

def _get_dict_with_repo_revs(self, repo_list: list[dict]) -> dict:
"""
Parses a list containing repo dictionaries and returns a dictionary which
contains the repo name as the key and rev as the value.
:param repo_list: List of dictionaries containing repo configurations
:return: A dict with the repo urls as the key and the repo rev as the value.
"""
repos_dict = {}

for repo in repo_list:
url = repo["repo"]
rev = repo["rev"]
repos_dict[url] = rev

return repos_dict

def _process_mismatched_repo_versions(
self, current_repos: list[dict], expected_repos: list[dict]
):
"""
Processes the list of repos from the .pre-commit-config.yaml and the expected (generated) config
and returns a output as a string which lists any version mismatches detected.
:param current_repos: List of dictionaries containing repo configurations from the .pre-commit-config.yaml
file
:param expected_repos: List of dictionaries containing repo configurations from the expected (generated)
config
:return: Returns a string of output representing the version mismatches that were detected
"""
current_repos_dict = self._get_dict_with_repo_revs(repo_list=current_repos)
expected_repos_dict = self._get_dict_with_repo_revs(repo_list=expected_repos)
output = ""

for repo in expected_repos_dict:
expected_rev = expected_repos_dict.get(repo)
current_rev = current_repos_dict.get(repo)
if expected_rev != current_rev:
output += (
"Expected {} to be rev {} but it is configured to rev {}\n".format(
repo, expected_rev, current_rev
)
)

return output

def _get_mismatched_repos(self, current_repos: list, expected_repos: list):
"""
Compares the list of repos in the current config against the list of repos
in the expected (generated) config and returns an object with a list of missing
repos and a list of unexpected repos.
"""
current_repos_set = set(current_repos)
expected_repos_set = set(expected_repos)
unexpected_repos = [
repo for repo in current_repos if repo not in expected_repos_set
]
missing_repos = [
repo for repo in expected_repos if repo not in current_repos_set
]

return UnexpectedReposResult(
missing_repos=missing_repos, unexpected_repos=unexpected_repos
)

def _process_repo_list_length_mismatch(
self, current_repos: list[str], expected_repos: list[str]
):
"""
Processes the repo lists for the current config (.pre-commit-config.yaml) and the expected
(generated) config and generates text output indicating which repos are unexpected and
which repos are missing.
:param current_repos: List of repo names that are in the .pre-commit-config.yaml file
:param expected_repos: List of repo names from the expected (generated) config
:return: Returns output in string format with the results of the comparison
"""
output = ""

mismatch_results = self._get_mismatched_repos(
current_repos=current_repos,
expected_repos=expected_repos,
)
unexpected_repos = mismatch_results.unexpected_repos
missing_repos = mismatch_results.missing_repos

if len(unexpected_repos) > 0:
output += "Found unexpected repos in .pre-commit-config.yaml:\n"
for repo in unexpected_repos:
output += "- {}\n".format(repo)

output += "\n"

if len(missing_repos) > 0:
output += (
"Some expected repos were misssing from .pre-commit-config.yaml:\n"
)
for repo in missing_repos:
output += "- {}\n".format(repo)

output += "\n"

return output

def _compare_repo_versions(self, current_config: dict, expected_config: dict):
"""
Compares the current config and expected (generated) config and detemines if there
are version mismatches for the hooks.
:param current_config: The current configuration as a dict
:param expected_config: The expected (generated) configuration as a dict
:return: Returns a string containing the differences between the two configs.
"""
current_config_repos = current_config.get("repos", [])
expected_config_repos = expected_config.get("repos", [])
output = "Comparing current .pre-commit-config.yaml to expected configuration\n"

length_of_repos_lists_match = len(current_config_repos) == len(
expected_config_repos
)

if not length_of_repos_lists_match:
output += self._process_repo_list_length_mismatch(
current_repos=self._get_list_of_repo_urls(current_config_repos),
expected_repos=self._get_list_of_repo_urls(expected_config_repos),
)

output += self._process_mismatched_repo_versions(
current_repos=current_config_repos,
expected_repos=expected_config_repos,
)

return output
Binary file removed secureli/actions/__pycache__/__init__.cpython-39.pyc
Binary file not shown.
Binary file removed secureli/actions/__pycache__/action.cpython-39.pyc
Binary file not shown.
Binary file not shown.
Binary file removed secureli/actions/__pycache__/scan.cpython-39.pyc
Binary file not shown.
Binary file removed secureli/actions/__pycache__/setup.cpython-39.pyc
Binary file not shown.
Binary file removed secureli/actions/__pycache__/yeti.cpython-39.pyc
Binary file not shown.
46 changes: 45 additions & 1 deletion secureli/actions/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from secureli.services.language_support import LanguageSupportService
from secureli.services.scanner import ScannerService, ScanMode
from secureli.services.updater import UpdaterService
from secureli.abstractions.pre_commit import PreCommitAbstraction


class VerifyOutcome(str, Enum):
Expand All @@ -27,6 +28,9 @@ class VerifyOutcome(str, Enum):
UPGRADE_CANCELED = "upgrade-canceled"
UPGRADE_SUCCEEDED = "upgrade-succeeded"
UPGRADE_FAILED = "upgrade-failed"
UPDATE_CANCELED = "update-canceled"
UPDATE_SUCCEEDED = "update-succeeded"
UPDATE_FAILED = "update-failed"
UP_TO_DATE = "up-to-date"


Expand Down Expand Up @@ -56,13 +60,15 @@ def __init__(
scanner: ScannerService,
secureli_config: SecureliConfigRepository,
updater: UpdaterService,
pre_commit: PreCommitAbstraction,
):
self.echo = echo
self.language_analyzer = language_analyzer
self.language_support = language_support
self.scanner = scanner
self.secureli_config = secureli_config
self.updater = updater
self.pre_commit = pre_commit


class Action(ABC):
Expand All @@ -89,9 +95,21 @@ def verify_install(
available_version = self.action_deps.language_support.version_for_language(
config.overall_language
)

# Check for a new version and prompt for upgrade if available
if available_version != config.version_installed:
return self._upgrade_secureli(config, available_version, always_yes)

# Validates the current .pre-commit-config.yaml against the generated config
config_validation_result = self.action_deps.pre_commit.validate_config(
language=config.overall_language
)

# If config mismatch between available version and current version prompt for upgrade
if not config_validation_result.successful:
self.action_deps.echo.print(config_validation_result.output)
return self._update_secureli(always_yes)

self.action_deps.echo.print(
f"SeCureLI is installed and up-to-date (language = {config.overall_language})"
)
Expand All @@ -111,7 +129,7 @@ def _upgrade_secureli(
:return: The new SecureliConfig after upgrade or None if upgrading did not complete
"""
self.action_deps.echo.print(
f"The version installed is {config.version_installed}, but the latest is {available_version}"
f"The config version installed is {config.version_installed}, but the latest is {available_version}"
)
response = always_yes or self.action_deps.echo.confirm(
"Upgrade now?",
Expand Down Expand Up @@ -224,3 +242,29 @@ def _install_secureli(self, folder_path: Path, always_yes: bool) -> VerifyResult
config=config,
analyze_result=analyze_result,
)

def _update_secureli(self, always_yes: bool):
"""
Prompts the user to update to the latest secureli install.
:param always_yes: Assume "Yes" to all prompts
:return: Outcome of update
"""
update_prompt = "Would you like to update your pre-commit configuration to the latest secureli config?\n"
update_prompt += "This will reset any manual changes that may have been made to the .pre-commit-config.yaml file.\n"
update_prompt += "Proceed?"
update_confirmed = always_yes or self.action_deps.echo.confirm(
update_prompt, default_response=True
)

if not update_confirmed:
self.action_deps.echo.print("\nUpdate declined.\n")
return VerifyResult(outcome=VerifyOutcome.UPDATE_CANCELED)

update_result = self.action_deps.updater.update()
details = update_result.output
self.action_deps.echo.print(details)

if update_result.successful:
return VerifyResult(outcome=VerifyOutcome.UPDATE_SUCCEEDED)
else:
return VerifyResult(outcome=VerifyOutcome.UPDATE_FAILED)
Loading

0 comments on commit 78b353b

Please sign in to comment.