From 92eb6c66edbd44a1e5d9eabed75ebbf4f0f5457b Mon Sep 17 00:00:00 2001 From: Kerry McAdams <58492561+klmcadams@users.noreply.github.com> Date: Mon, 6 May 2024 03:44:30 -0400 Subject: [PATCH 1/6] fix: remove unnecessary curly brackets in doc-deploy-changelog docs (#476) --- doc/source/migrations/docs-deploy-changelog-setup.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/source/migrations/docs-deploy-changelog-setup.rst b/doc/source/migrations/docs-deploy-changelog-setup.rst index df443b381..41571b4a2 100644 --- a/doc/source/migrations/docs-deploy-changelog-setup.rst +++ b/doc/source/migrations/docs-deploy-changelog-setup.rst @@ -25,7 +25,7 @@ Once the ``doc-changelog`` action is done being set up, continue with the ``doc- steps: - uses: ansys/actions/doc-deploy-changelog@{{ version }} with: - token: ${{ '{{ secrets.PYANSYS_CI_BOT_TOKEN }}' }} + token: ${{ secrets.PYANSYS_CI_BOT_TOKEN }} release: name: Release project @@ -36,14 +36,14 @@ Once the ``doc-changelog`` action is done being set up, continue with the ``doc- - name: Release to the public PyPI repository uses: ansys/actions/release-pypi-public@{{ version }} with: - library-name: ${{ '{{ env.PACKAGE_NAME }}' }} + library-name: ${{ env.PACKAGE_NAME }} twine-username: "__token__" - twine-token: ${{ '{{ secrets.PYPI_TOKEN }}' }} + twine-token: ${{ secrets.PYPI_TOKEN }} - name: Release to GitHub uses: ansys/actions/release-github@{{ version }} with: - library-name: ${{ '{{ env.PACKAGE_NAME }}' }} + library-name: ${{ env.PACKAGE_NAME }} .. warning:: From 56313a9df459908e61dd2cb93ae3d1e68c036bd5 Mon Sep 17 00:00:00 2001 From: Kerry McAdams <58492561+klmcadams@users.noreply.github.com> Date: Mon, 6 May 2024 14:03:01 -0400 Subject: [PATCH 2/6] feat: vale ignore towncrier directory (#478) --- doc-style/action.yml | 54 ++++++- doc/source/style-actions/index.rst | 224 ++++++++++++++--------------- 2 files changed, 165 insertions(+), 113 deletions(-) diff --git a/doc-style/action.yml b/doc-style/action.yml index c8af8f5b4..5b33192ca 100644 --- a/doc-style/action.yml +++ b/doc-style/action.yml @@ -55,6 +55,20 @@ inputs: required: false type: string + toml-version: + description: > + Toml version used for retrieving the towncrier directory. + default: '0.10.2' + required: false + type: string + + ignore-changelogd: + description: > + Whether or not to ignore markdown files in doc/changelog.d/. + default: true + required: false + type: bool + checkout: description: > Whether to clone the repository in the CI/CD machine. Default value is @@ -108,6 +122,44 @@ runs: fi fi + - name: "Install toml" + shell: bash + run: | + python -m pip install --upgrade pip toml==${{ inputs.toml-version }} + + - name: "Get towncrier directory" + shell: python + run: | + import os + import toml + + # Load pyproject.toml + with open('pyproject.toml', 'r') as f: + config = toml.load(f) + try: + # Get towncrier directory + directory=config["tool"]["towncrier"]["directory"] + except KeyError: + # If towncrier directory isn't specified in pyproject.toml + directory="" + + # Get the GITHUB_ENV variable + github_env = os.getenv('GITHUB_ENV') + + # Append the TOWNCRIER_DIR with its value to GITHUB_ENV + with open(github_env, "a") as f: + f.write(f"TOWNCRIER_DIR={directory}") + + - name: "Check if changelog.d is ignored" + shell: bash + run: | + # If changelog.d is ignored and the TOWNCRIER_DIR exists in pyproject.toml, add the ignore glob + if [[ ${{ inputs.ignore-changelogd }} == 'true' ]] && [[ -n "${{ env.TOWNCRIER_DIR }}" ]]; then + echo VALE_FLAGS="--config=${{ inputs.vale-config }} --glob=!${{ env.TOWNCRIER_DIR }}/*.md" >> $GITHUB_ENV + else + echo VALE_FLAGS="--config=${{ inputs.vale-config }}" >> $GITHUB_ENV + fi + - name: "Run Vale" uses: errata-ai/vale-action@reviewdog env: @@ -118,5 +170,5 @@ runs: level: error filter_mode: nofilter fail_on_error: true - vale_flags: "--config=${{ inputs.vale-config }}" + vale_flags: "${{ ENV.VALE_FLAGS }}" version: ${{ inputs.vale-version }} diff --git a/doc/source/style-actions/index.rst b/doc/source/style-actions/index.rst index 4d0979971..2d8d2cea6 100644 --- a/doc/source/style-actions/index.rst +++ b/doc/source/style-actions/index.rst @@ -1,112 +1,112 @@ -Style actions -============= -Style actions verify code and documentation quality compliance -with PyAnsys guidelines. - - -Code style action ------------------ - -.. jinja:: code-style - - {{ description }} - - {{ inputs_table }} - - Examples - ++++++++ - - {% for filename, title in examples %} - .. dropdown:: {{ title }} - :animate: fade-in - - .. literalinclude:: examples/{{ filename }} - :language: yaml - - {% endfor %} - - -Doc style action ----------------- - -.. jinja:: doc-style - - {{ description }} - - {{ inputs_table }} - - Examples - ++++++++ - - {% for filename, title in examples %} - .. dropdown:: {{ title }} - :animate: fade-in - - .. literalinclude:: examples/{{ filename }} - :language: yaml - - {% endfor %} - - -Docker style action -------------------- - -.. jinja:: docker-style - - {{ description }} - - {{ inputs_table }} - - Examples - ++++++++ - - {% for filename, title in examples %} - .. dropdown:: {{ title }} - :animate: fade-in - - .. literalinclude:: examples/{{ filename }} - :language: yaml - - {% endfor %} - - -Commit style action -------------------- - -.. jinja:: commit-style - - {{ description }} - - {{ inputs_table }} - - Examples - ++++++++ - - {% for filename, title in examples %} - .. dropdown:: {{ title }} - :animate: fade-in - - .. literalinclude:: examples/{{ filename }} - :language: yaml - - {% endfor %} - - -Branch name style action ------------------------- - -.. jinja:: branch-name-style - - {{ description }} - - Examples - ++++++++ - - {% for filename, title in examples %} - .. dropdown:: {{ title }} - :animate: fade-in - - .. literalinclude:: examples/{{ filename }} - :language: yaml - - {% endfor %} +Style actions +============= +Style actions verify code and documentation quality compliance +with PyAnsys guidelines. + + +Code style action +----------------- + +.. jinja:: code-style + + {{ description }} + + {{ inputs_table }} + + Examples + ++++++++ + + {% for filename, title in examples %} + .. dropdown:: {{ title }} + :animate: fade-in + + .. literalinclude:: examples/{{ filename }} + :language: yaml + + {% endfor %} + + +Doc style action +---------------- + +.. jinja:: doc-style + + {{ description }} + + {{ inputs_table }} + + Examples + ++++++++ + + {% for filename, title in examples %} + .. dropdown:: {{ title }} + :animate: fade-in + + .. literalinclude:: examples/{{ filename }} + :language: yaml + + {% endfor %} + + +Docker style action +------------------- + +.. jinja:: docker-style + + {{ description }} + + {{ inputs_table }} + + Examples + ++++++++ + + {% for filename, title in examples %} + .. dropdown:: {{ title }} + :animate: fade-in + + .. literalinclude:: examples/{{ filename }} + :language: yaml + + {% endfor %} + + +Commit style action +------------------- + +.. jinja:: commit-style + + {{ description }} + + {{ inputs_table }} + + Examples + ++++++++ + + {% for filename, title in examples %} + .. dropdown:: {{ title }} + :animate: fade-in + + .. literalinclude:: examples/{{ filename }} + :language: yaml + + {% endfor %} + + +Branch name style action +------------------------ + +.. jinja:: branch-name-style + + {{ description }} + + Examples + ++++++++ + + {% for filename, title in examples %} + .. dropdown:: {{ title }} + :animate: fade-in + + .. literalinclude:: examples/{{ filename }} + :language: yaml + + {% endfor %} From ae38d785700892dcbcff55b77912fd05f71e608b Mon Sep 17 00:00:00 2001 From: Kerry McAdams <58492561+klmcadams@users.noreply.github.com> Date: Mon, 6 May 2024 14:28:30 -0400 Subject: [PATCH 3/6] fix: check pyproject.toml file exists before opening it (#480) --- .github/workflows/ci_cd.yml | 2 +- doc-style/action.yml | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 888c82bee..5940b249b 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -117,7 +117,7 @@ jobs: needs: commit-and-branch-style steps: - name: "Run documentation style checks" - uses: ansys/actions/doc-style@main + uses: ansys/actions/doc-style@fix/file-dne with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/doc-style/action.yml b/doc-style/action.yml index 5b33192ca..7c2b65894 100644 --- a/doc-style/action.yml +++ b/doc-style/action.yml @@ -133,15 +133,18 @@ runs: import os import toml - # Load pyproject.toml - with open('pyproject.toml', 'r') as f: - config = toml.load(f) - try: - # Get towncrier directory - directory=config["tool"]["towncrier"]["directory"] - except KeyError: - # If towncrier directory isn't specified in pyproject.toml - directory="" + if os.path.exists("pyproject.toml"): + # Load pyproject.toml + with open('pyproject.toml', 'r') as f: + config = toml.load(f) + try: + # Get towncrier directory + directory=config["tool"]["towncrier"]["directory"] + except KeyError: + # If towncrier directory isn't specified in pyproject.toml + directory="" + else: + directory="" # Get the GITHUB_ENV variable github_env = os.getenv('GITHUB_ENV') From 46b07e020dcd9d30437c71c51f545d11e665f6f4 Mon Sep 17 00:00:00 2001 From: Roberto Pastor Muela <37798125+RobPasMue@users.noreply.github.com> Date: Mon, 6 May 2024 23:06:41 -0700 Subject: [PATCH 4/6] fix: typo in ci/cd (#482) --- .github/workflows/ci_cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 5940b249b..888c82bee 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -117,7 +117,7 @@ jobs: needs: commit-and-branch-style steps: - name: "Run documentation style checks" - uses: ansys/actions/doc-style@fix/file-dne + uses: ansys/actions/doc-style@main with: token: ${{ secrets.GITHUB_TOKEN }} From 944342f9c208c0f06c4e593d4b33e536a95c6512 Mon Sep 17 00:00:00 2001 From: Kerry McAdams <58492561+klmcadams@users.noreply.github.com> Date: Tue, 7 May 2024 03:27:34 -0400 Subject: [PATCH 5/6] chore: gh-pages does not exist message for doc-deploy-dev (#475) --- doc-deploy-dev/action.yml | 35 ++++++++++++++++++++++++++++++++++- doc-deploy-stable/action.yml | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/doc-deploy-dev/action.yml b/doc-deploy-dev/action.yml index 9fb6f94a4..59788bbeb 100644 --- a/doc-deploy-dev/action.yml +++ b/doc-deploy-dev/action.yml @@ -134,9 +134,42 @@ runs: uses: actions/checkout@v4 with: repository: ${{ env.REPOSITORY }} - ref: ${{ inputs.branch }} token: ${{ inputs.token }} + - name: "Ensure that the desired branch exists" + env: + BRANCH: ${{ inputs.branch }} + shell: bash + run: | + # Check the ${{ env.BRANCH }} branch exists on remote + branch_exists=$(git ls-remote --heads origin refs/heads/${{ env.BRANCH }} 2>&1) + + # If the ${{ env.BRANCH }} doesn't exist, then print error message and exit 1 + if [ -z "$branch_exists" ]; then + echo "The ${{ env.BRANCH }} branch does not exist. Creating ${{ env.BRANCH }}." + + # Create orphan branch + git checkout --orphan ${{ env.BRANCH }} + + # Unstage files to be committed + git rm --cached -r . + + # Remove untracked files + git clean -fd + + # Configure git username & email + git config user.name 'pyansys-ci-bot' + git config user.email 'pyansys.github.bot@ansys.com' + + # Commit ${{ env.BRANCH }} & push to origin + git commit --allow-empty -m "Create ${{ env.BRANCH }} branch" + git push -u origin ${{ env.BRANCH }} + else + # Fetch and switch to ${{ env.BRANCH }} + git fetch origin ${{ env.BRANCH }}:${{ env.BRANCH }} + git switch ${{ env.BRANCH }} + fi + # ------------------------------------------------------------------------ - uses: ansys/actions/_logging@main diff --git a/doc-deploy-stable/action.yml b/doc-deploy-stable/action.yml index 0d13d20b1..2b3577521 100644 --- a/doc-deploy-stable/action.yml +++ b/doc-deploy-stable/action.yml @@ -210,9 +210,42 @@ runs: uses: actions/checkout@v4 with: repository: ${{ env.REPOSITORY }} - ref: ${{ inputs.branch }} token: ${{ inputs.token }} + - name: "Ensure that the desired branch exists" + env: + BRANCH: ${{ inputs.branch }} + shell: bash + run: | + # Check the ${{ env.BRANCH }} branch exists on remote + branch_exists=$(git ls-remote --heads origin refs/heads/${{ env.BRANCH }} 2>&1) + + # If the ${{ env.BRANCH }} doesn't exist, then print error message and exit 1 + if [ -z "$branch_exists" ]; then + echo "The ${{ env.BRANCH }} branch does not exist. Creating ${{ env.BRANCH }}." + + # Create orphan branch + git checkout --orphan ${{ env.BRANCH }} + + # Unstage files to be committed + git rm --cached -r . + + # Remove untracked files + git clean -fd + + # Configure git username & email + git config user.name 'pyansys-ci-bot' + git config user.email 'pyansys.github.bot@ansys.com' + + # Commit ${{ env.BRANCH }} & push to origin + git commit --allow-empty -m "Create ${{ env.BRANCH }} branch" + git push -u origin ${{ env.BRANCH }} + else + # Fetch and switch to ${{ env.BRANCH }} + git fetch origin ${{ env.BRANCH }}:${{ env.BRANCH }} + git switch ${{ env.BRANCH }} + fi + # ------------------------------------------------------------------------ - uses: ansys/actions/_logging@main From cd8e7159c3e68c2b7af7e5a9e2852912494dca0d Mon Sep 17 00:00:00 2001 From: Roberto Pastor Muela <37798125+RobPasMue@users.noreply.github.com> Date: Tue, 7 May 2024 08:57:18 -0700 Subject: [PATCH 6/6] feat: use github context action_path for vulnerabilities action (#483) --- check-vulnerabilities/action.yml | 296 +------------- .../check_vulnerabilities.py | 367 ++++++++++++++++++ check-vulnerabilities/requirements.txt | 4 + doc/source/vulnerability-actions/index.rst | 21 + 4 files changed, 395 insertions(+), 293 deletions(-) create mode 100644 check-vulnerabilities/check_vulnerabilities.py create mode 100644 check-vulnerabilities/requirements.txt diff --git a/check-vulnerabilities/action.yml b/check-vulnerabilities/action.yml index 5d8555971..783554db7 100644 --- a/check-vulnerabilities/action.yml +++ b/check-vulnerabilities/action.yml @@ -193,7 +193,7 @@ runs: shell: bash run: | python -m pip install --upgrade pip - pip install "pygithub>=1.59,<2" "bandit>=1.7,<2" "safety>=2.3,<4" + pip install -r ${{ github.action_path }}/requirements.txt - name: "Install library" shell: bash @@ -219,303 +219,13 @@ runs: safety check -o bare --save-json info_safety.json --continue-on-error $ignored_vulnerabilities bandit -r ${{ inputs.source-directory }} -o info_bandit.json -f json --exit-zero - - name: "Declare Python script" - shell: bash - run: | - cat > dependency-check.py << 'EOF' - - """ - Security vulnerabilities script. - - Notes - ----- - Script for detecting vulnerabilities on a given repo and creating - associated security vulnerability advisories. - """ - - import hashlib - import json - import os - import sys - from typing import Any, Dict - - import github - - TOKEN = os.environ.get("DEPENDENCY_CHECK_TOKEN", None) - PACKAGE = os.environ.get("DEPENDENCY_CHECK_PACKAGE_NAME", None) - REPOSITORY = os.environ.get("DEPENDENCY_CHECK_REPOSITORY", None) - DRY_RUN = True if os.environ.get("DEPENDENCY_CHECK_DRY_RUN", None) else False - ERROR_IF_NEW_ADVISORY = True if os.environ.get("DEPENDENCY_CHECK_ERROR_EXIT", None) else False - CREATE_ISSUES = True if os.environ.get("DEPENDENCY_CHECK_CREATE_ISSUES", None) else False - - - def dict_hash(dictionary: Dict[str, Any]) -> str: - """MD5 hash of a dictionary.""" - dhash = hashlib.md5() - # We need to sort arguments so {'a': 1, 'b': 2} is - # the same as {'b': 2, 'a': 1} - encoded = json.dumps(dictionary, sort_keys=True).encode() - dhash.update(encoded) - return dhash.hexdigest() - - - def check_vulnerabilities(): - """Check library and third-party vulnerabilities.""" - new_advisory_detected = False - # Check that the needed environment variables are provided - if any([v is None for v in [TOKEN, REPOSITORY, PACKAGE]]): - raise RuntimeError( - "Required environment variables are not defined. Enter value for ", - "'DEPENDENCY_CHECK_TOKEN', 'DEPENDENCY_CHECK_PACKAGE_NAME', ", - "'DEPENDENCY_CHECK_REPOSITORY'.", - ) - - # Check if DRY_RUN or not - if DRY_RUN: - print("Dry run... not creating advisories and issues.") - print("Information will be presented on screen.\n") - - # Load the security checks - safety_results = {} - with open("info_safety.json", "r") as json_file: - safety_results = json.loads(json_file.read()) - - # If the security checks have not been loaded... problem ahead! - if not safety_results: - raise RuntimeError( - "Safety results have not been generated... Something went wrong during", - "the execution of 'safety check -o bare --save-json info_safety.json'. ", - "Verify workflow logs.", - ) - - # Connect to the repository - g = github.Github(auth=github.Auth.Token(TOKEN)) - - # Get the repository - repo = g.get_repo(REPOSITORY) - - # Get the available security advisories - existing_advisories = {} - pl_advisories = repo.get_repository_advisories() - for advisory in pl_advisories: - existing_advisories[advisory.summary] = advisory - - ############################################################################### - # THIRD PARTY SECURITY ADVISORIES - ############################################################################### - - # Process the detected advisories by Safety - safety_results_reported = 0 - vulnerability: dict - for vulnerability in safety_results["vulnerabilities"]: - # Retrieve the needed values - v_id = vulnerability.get("vulnerability_id") - v_package = vulnerability.get("package_name") - v_cve = vulnerability.get("CVE") - v_url = vulnerability.get("more_info_url") - v_desc = vulnerability.get("advisory") - v_affected_versions = vulnerability.get("vulnerable_spec") - v_fixed_versions = vulnerability.get("fixed_versions") - - # Advisory info - summary = f"Safety vulnerability {v_id} for package '{v_package}'" - vuln_adv = { - "package": {"name": f"{v_package}", "ecosystem": "pip"}, - "vulnerable_version_range": f"{v_affected_versions}", - "patched_versions": f"{v_fixed_versions}", - "vulnerable_functions": [], - } - desc = f""" - {v_desc} - - #### More information - - Visit {v_url} to find out more information. - """ - # Check if the advisory already exists - if existing_advisories.get(summary): - continue - elif not DRY_RUN: - # New safety advisory detected - safety_results_reported += 1 - new_advisory_detected = True - - # Create the advisory but do not publish it - advisory = repo.create_repository_advisory( - summary=summary, - description=desc, - severity_or_cvss_vector_string="medium", - cve_id=v_cve, - vulnerabilities=[vuln_adv], - ) - - # Create an issue - if CREATE_ISSUES: - issue_body = f""" - A new security advisory was open in this repository. See {advisory.html_url}. - - --- - **NOTE** - - Please update the security advisory status after evaluating. Publish the advisory - once it has been verified (since it has been created in draft mode). - - --- - - #### Description - - {desc} - """ - repo.create_issue(title=summary, body=issue_body, labels=["security"]) - else: - # New safety advisory detected - safety_results_reported += 1 - new_advisory_detected = True - print("===========================================\n") - print(f"{summary}") - print(f"{desc}") - - ############################################################################### - # LIBRARY SECURITY ADVISORIES - ############################################################################### - - # Load the bandit checks - bandit_results = {} - with open("info_bandit.json", "r") as json_file: - bandit_results = json.loads(json_file.read()) - - # If the bandit results have not been loaded... problem ahead! - if not bandit_results: - raise RuntimeError( - "Bandit results have not been generated... Something went wrong during", - "the execution of 'bandit -r -o info_bandit.json -f json'. ", - "Verify workflow logs.", - ) - - # Process the detected advisories by Bandit - bandit_results_reported = 0 - vulnerability: dict - for vulnerability in bandit_results["results"]: - # Retrieve the needed values - v_hash = dict_hash(vulnerability) - v_test_id = vulnerability.get("test_id") - v_test_name = vulnerability.get("test_name") - v_severity_level = vulnerability.get("issue_severity", "medium").lower() - v_filename = vulnerability.get("filename") - v_code = vulnerability.get("code") - v_package = PACKAGE - v_cwe = vulnerability.get("issue_cwe", {"id": "", "link": ""}) - v_url = vulnerability.get("more_info") - v_desc = vulnerability.get("issue_text") - - # Advisory info - summary = f"Bandit [{v_test_id}:{v_test_name}] on {v_filename} - Hash: {v_hash}" - vuln_adv = { - "package": {"name": f"{v_package}", "ecosystem": "pip"}, - "vulnerable_functions": [], - "vulnerable_version_range": None, - "patched_versions": None, - } - desc = f""" - {v_desc} - - #### Code - - On file {v_filename}: - - ``` - {v_code} - ``` - - #### CWE - {v_cwe['id']} - - For more information see {v_cwe['link']} - - #### More information - - Visit {v_url} to find out more information. - """ - # Check if the advisory already exists - if existing_advisories.get(summary): - continue - elif not DRY_RUN: - # New bandit advisory detected - bandit_results_reported += 1 - new_advisory_detected = True - - # Create the advisory but do not publish it - advisory = repo.create_repository_advisory( - summary=summary, - description=desc, - severity_or_cvss_vector_string=v_severity_level, - vulnerabilities=[vuln_adv], - cwe_ids=[f"CWE-{v_cwe['id']}"], - ) - - # Create an issue - if CREATE_ISSUES: - issue_body = f""" - A new security advisory was open in this repository. See {advisory.html_url}. - - --- - **NOTE** - - Please update the security advisory status after evaluating. Publish the advisory - once it has been verified (since it has been created in draft mode). - - --- - - #### Description - {desc} - """ - repo.create_issue(title=summary, body=issue_body, labels=["security"]) - else: - # New bandit advisory detected - bandit_results_reported += 1 - new_advisory_detected = True - print("===========================================\n") - print(f"{summary}") - print(f"{desc}") - - # Print out information - safety_entries = len(safety_results["vulnerabilities"]) - bandit_entries = len(bandit_results["results"]) - print("\n*******************************************") - print(f"Total 'safety' advisories detected: {safety_entries}") - print(f"Total 'safety' advisories reported: {safety_results_reported}") - print(f"Total 'bandit' advisories detected: {bandit_entries}") - print(f"Total 'bandit' advisories reported: {bandit_results_reported}") - print("*******************************************") - print(f"Total advisories detected: {safety_entries + bandit_entries}") - print(f"Total advisories reported: {safety_results_reported + bandit_results_reported}") - print("*******************************************") - - # Return whether new advisories have been created or not - return new_advisory_detected - - - if __name__ == "__main__": - new_advisory_detected = check_vulnerabilities() - - if new_advisory_detected and ERROR_IF_NEW_ADVISORY: - # New advisories detected - exit with error - sys.exit(1) - else: - # No new advisories detected or no failure requested - pass - - - - EOF - cat dependency-check.py - - name: "Run safety advisory checks" shell: bash run: | if [[ ${{ inputs.hide-log }} == 'true' ]]; then - python dependency-check.py > /dev/null 2>&1 + python ${{ github.action_path }}/check_vulnerabilities.py > /dev/null 2>&1 else - python dependency-check.py + python ${{ github.action_path }}/check_vulnerabilities.py fi - name: "Uploading safety and bandit results" diff --git a/check-vulnerabilities/check_vulnerabilities.py b/check-vulnerabilities/check_vulnerabilities.py new file mode 100644 index 000000000..ccb855dd4 --- /dev/null +++ b/check-vulnerabilities/check_vulnerabilities.py @@ -0,0 +1,367 @@ +""" +Security vulnerabilities script. + +Notes +----- +Script for detecting vulnerabilities on a given repo and creating +associated security vulnerability advisories. +""" + +import hashlib +import json +import os +import sys +from typing import Any, Dict + +import click +import github + +TOKEN = os.environ.get("DEPENDENCY_CHECK_TOKEN", None) +PACKAGE = os.environ.get("DEPENDENCY_CHECK_PACKAGE_NAME", None) +REPOSITORY = os.environ.get("DEPENDENCY_CHECK_REPOSITORY", None) +DRY_RUN = True if os.environ.get("DEPENDENCY_CHECK_DRY_RUN", None) else False +ERROR_IF_NEW_ADVISORY = ( + True if os.environ.get("DEPENDENCY_CHECK_ERROR_EXIT", None) else False +) +CREATE_ISSUES = ( + True if os.environ.get("DEPENDENCY_CHECK_CREATE_ISSUES", None) else False +) + + +def dict_hash(dictionary: Dict[str, Any]) -> str: + """MD5 hash of a dictionary. + + Parameters + ---------- + dictionary : Dict[str, Any] + Dictionary to hash. + + Returns + ------- + str + MD5 hash of the dictionary. + """ + dhash = hashlib.md5() + # We need to sort arguments so {'a': 1, 'b': 2} is + # the same as {'b': 2, 'a': 1} + encoded = json.dumps(dictionary, sort_keys=True).encode() + dhash.update(encoded) + return dhash.hexdigest() + + +def check_vulnerabilities(): + """Check library and third-party vulnerabilities.""" + new_advisory_detected = False + # Check that the needed environment variables are provided + if not TOKEN: + raise RuntimeError( + "Required environment variable 'DEPENDENCY_CHECK_TOKEN' is not defined." + ) + + if not REPOSITORY: + raise RuntimeError( + "Required environment variable 'DEPENDENCY_CHECK_REPOSITORY' is not defined." + ) + + if not PACKAGE: + raise RuntimeError( + "Required environment variable 'DEPENDENCY_CHECK_PACKAGE_NAME' is not defined." + ) + + # Check if DRY_RUN or not + if DRY_RUN: + print("Dry run... not creating advisories and issues.") + print("Information will be presented on screen.\n") + + # Load the security checks + safety_results = {} + with open("info_safety.json", "r") as json_file: + safety_results = json.loads(json_file.read()) + + # If the security checks have not been loaded... problem ahead! + if not safety_results: + raise RuntimeError( + "Safety results have not been generated... Something went wrong during", + "the execution of 'safety check -o bare --save-json info_safety.json'. ", + "Verify workflow logs.", + ) + + # Connect to the repository + g = github.Github(auth=github.Auth.Token(TOKEN)) + + # Get the repository + repo = g.get_repo(REPOSITORY) + + # Get the available security advisories + existing_advisories = {} + pl_advisories = repo.get_repository_advisories() + for advisory in pl_advisories: + existing_advisories[advisory.summary] = advisory + + ############################################################################### + # THIRD PARTY SECURITY ADVISORIES + ############################################################################### + + # Process the detected advisories by Safety + safety_results_reported = 0 + vulnerability: dict + for vulnerability in safety_results["vulnerabilities"]: + # Retrieve the needed values + v_id = vulnerability.get("vulnerability_id") + v_package = vulnerability.get("package_name") + v_cve = vulnerability.get("CVE") + v_url = vulnerability.get("more_info_url") + v_desc = vulnerability.get("advisory") + v_affected_versions = vulnerability.get("vulnerable_spec") + v_fixed_versions = vulnerability.get("fixed_versions") + + # Advisory info + summary = f"Safety vulnerability {v_id} for package '{v_package}'" + vuln_adv = { + "package": {"name": f"{v_package}", "ecosystem": "pip"}, + "vulnerable_version_range": f"{v_affected_versions}", + "patched_versions": f"{v_fixed_versions}", + "vulnerable_functions": [], + } + desc = f""" +{v_desc} + +#### More information + +Visit {v_url} to find out more information. +""" + # Check if the advisory already exists + if existing_advisories.get(summary): + continue + elif not DRY_RUN: + # New safety advisory detected + safety_results_reported += 1 + new_advisory_detected = True + + # Create the advisory but do not publish it + advisory = repo.create_repository_advisory( + summary=summary, + description=desc, + severity_or_cvss_vector_string="medium", + cve_id=v_cve, + vulnerabilities=[vuln_adv], + ) + + # Create an issue + if CREATE_ISSUES: + issue_body = f""" +A new security advisory was open in this repository. See {advisory.html_url}. + +--- +**NOTE** + +Please update the security advisory status after evaluating. Publish the advisory +once it has been verified (since it has been created in draft mode). + +--- + +#### Description + +{desc} +""" + repo.create_issue(title=summary, body=issue_body, labels=["security"]) + else: + # New safety advisory detected + safety_results_reported += 1 + new_advisory_detected = True + print("===========================================\n") + print(f"{summary}") + print(f"{desc}") + + ############################################################################### + # LIBRARY SECURITY ADVISORIES + ############################################################################### + + # Load the bandit checks + bandit_results = {} + with open("info_bandit.json", "r") as json_file: + bandit_results = json.loads(json_file.read()) + + # If the bandit results have not been loaded... problem ahead! + if not bandit_results: + raise RuntimeError( + "Bandit results have not been generated... Something went wrong during", + "the execution of 'bandit -r -o info_bandit.json -f json'. ", + "Verify workflow logs.", + ) + + # Process the detected advisories by Bandit + bandit_results_reported = 0 + vulnerability: dict + for vulnerability in bandit_results["results"]: + # Retrieve the needed values + v_hash = dict_hash(vulnerability) + v_test_id = vulnerability.get("test_id") + v_test_name = vulnerability.get("test_name") + v_severity_level = vulnerability.get("issue_severity", "medium").lower() + v_filename = vulnerability.get("filename") + v_code = vulnerability.get("code") + v_package = PACKAGE + v_cwe = vulnerability.get("issue_cwe", {"id": "", "link": ""}) + v_url = vulnerability.get("more_info") + v_desc = vulnerability.get("issue_text") + + # Advisory info + summary = f"Bandit [{v_test_id}:{v_test_name}] on {v_filename} - Hash: {v_hash}" + vuln_adv = { + "package": {"name": f"{v_package}", "ecosystem": "pip"}, + "vulnerable_functions": [], + "vulnerable_version_range": None, + "patched_versions": None, + } + desc = f""" +{v_desc} + +#### Code + +On file {v_filename}: + +``` +{v_code} +``` + +#### CWE - {v_cwe['id']} + +For more information see {v_cwe['link']} + +#### More information + +Visit {v_url} to find out more information. +""" + # Check if the advisory already exists + if existing_advisories.get(summary): + continue + elif not DRY_RUN: + # New bandit advisory detected + bandit_results_reported += 1 + new_advisory_detected = True + + # Create the advisory but do not publish it + advisory = repo.create_repository_advisory( + summary=summary, + description=desc, + severity_or_cvss_vector_string=v_severity_level, + vulnerabilities=[vuln_adv], + cwe_ids=[f"CWE-{v_cwe['id']}"], + ) + + # Create an issue + if CREATE_ISSUES: + issue_body = f""" +A new security advisory was open in this repository. See {advisory.html_url}. + +--- +**NOTE** + +Please update the security advisory status after evaluating. Publish the advisory +once it has been verified (since it has been created in draft mode). + +--- + +#### Description +{desc} +""" + repo.create_issue(title=summary, body=issue_body, labels=["security"]) + else: + # New bandit advisory detected + bandit_results_reported += 1 + new_advisory_detected = True + print("===========================================\n") + print(f"{summary}") + print(f"{desc}") + + # Print out information + safety_entries = len(safety_results["vulnerabilities"]) + bandit_entries = len(bandit_results["results"]) + print("\n*******************************************") + print(f"Total 'safety' advisories detected: {safety_entries}") + print(f"Total 'safety' advisories reported: {safety_results_reported}") + print(f"Total 'bandit' advisories detected: {bandit_entries}") + print(f"Total 'bandit' advisories reported: {bandit_results_reported}") + print("*******************************************") + print(f"Total advisories detected: {safety_entries + bandit_entries}") + print( + f"Total advisories reported: {safety_results_reported + bandit_results_reported}" + ) + print("*******************************************") + + # Return whether new advisories have been created or not + return new_advisory_detected + + +def generate_advisory_files(): + """ + Generate advisory files for local purposes. + + This function runs safety and bandit on the user's behalf at the current location + and generates the necessary advisory files for local testing. + + Notes + ----- + This function should ONLY be used for local purposes. + """ + import bandit.cli.main as bandit + import safety.cli as safety + + # Delete previous advisory files + if os.path.exists("info_safety.json"): + os.remove("info_safety.json") + if os.path.exists("info_bandit.json"): + os.remove("info_bandit.json") + + # Safety check + try: + safety.cli.main( + ["check", "-o", "bare", "--save-json", "info_safety.json"], + standalone_mode=False, + ) + except: # noqa: E722 + print("Safety check performed.") + pass + + # Bandit check + try: + sys.argv.pop() + sys.argv.extend(["-r", "./src", "-o", "info_bandit.json", "-f", "json"]) + bandit.main() + except: # noqa: E722 + pass + finally: + print("Bandit check performed.") + sys.argv = sys.argv[: len(sys.argv) - 5] + sys.argv.append("--run-local") + + print("Advisory files generated successfully.") + + +@click.command(short_help="Perform third-party and in-library vulnerability analysis.") +@click.option( + "--run-local", + is_flag=True, + default=False, + help="Simulate the behavior of the synchronization without performing it.", +) +def main(run_local: bool): + """Main function.""" + if run_local: + generate_advisory_files() + global DRY_RUN + DRY_RUN = True + + new_advisory_detected = check_vulnerabilities() + + if new_advisory_detected and ERROR_IF_NEW_ADVISORY: + # New advisories detected - exit with error + sys.exit(1) + else: + # No new advisories detected or no failure requested + pass + + +if __name__ == "__main__": + main() diff --git a/check-vulnerabilities/requirements.txt b/check-vulnerabilities/requirements.txt new file mode 100644 index 000000000..cdb53f3ee --- /dev/null +++ b/check-vulnerabilities/requirements.txt @@ -0,0 +1,4 @@ +bandit>=1.7,<2 +click>=7.0,<9 +pygithub>=1.59,<2 +safety>=2.3,<4 diff --git a/doc/source/vulnerability-actions/index.rst b/doc/source/vulnerability-actions/index.rst index 84d07f2db..a4c3d4e39 100644 --- a/doc/source/vulnerability-actions/index.rst +++ b/doc/source/vulnerability-actions/index.rst @@ -10,6 +10,27 @@ and third-party libraries (that is, dependencies). Check vulnerabilities action ---------------------------- +.. note:: + + Users can try out the ``ansys/check-vulnerabilities`` action on their local repository + by doing the following: + + #. Download the ``check_vulnerabilities.py`` script and the ``requirements.txt`` file from + the `ansys/check-vulnerabilities action folder `_. + #. Move the downloaded files to the root of the repository. + #. Create a virtual environment by running ``python -m venv .venv``. + #. Activate the virtual environment. + #. Install the required dependencies by running ``pip install -r requirements.txt``. + #. Install your repository with the command ``pip install -e .``. + #. Define the following environment variables: + + - ``DEPENDENCY_CHECK_TOKEN``: A GitHub token with the necessary permissions to access security advisories on the repository you are interested in. + - ``DEPENDENCY_CHECK_PACKAGE_NAME``: The Python package name of your repository. This is the name of the package that you would use in a ``pip install`` command. + - ``DEPENDENCY_CHECK_REPOSITORY``: The full name of the repository you are interested in. This is the name of the repository in the format ``/``. + + #. Run the script by running ``python check_vulnerabilities.py --run-local``. + + .. jinja:: check-vulnerabilities