From c922bc24ac49c281427be261f8b84793eacbd4fe Mon Sep 17 00:00:00 2001 From: Jannis-Mittenzwei <145327095+Jannis-Mittenzwei@users.noreply.github.com> Date: Wed, 25 Sep 2024 10:32:52 +0200 Subject: [PATCH] add security linter bandit to nox (#208) --- .github/workflows/checks.yml | 28 ++++++++++++++++++ .github/workflows/report.yml | 1 + .gitignore | 1 + exasol/toolbox/metrics.py | 47 +++++++++++++++++++++++++++-- exasol/toolbox/nox/_lint.py | 34 +++++++++++++++++++++ exasol/toolbox/nox/_metrics.py | 3 +- poetry.lock | 54 ++++++++++++++++++++++++++++++++-- pyproject.toml | 1 + test/unit/report_test.py | 50 +++++++++++++++++++++++++++++++ 9 files changed, 213 insertions(+), 6 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7e1bbd09..754e26f0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -89,6 +89,34 @@ jobs: - name: Run type-check run: poetry run nox -s type-check + security-job: + name: Security Checking (Python-${{ matrix.python-version }}) + needs: [ version-check-job ] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.8", "3.9", "3.10", "3.11" ] + + steps: + - name: SCM Checkout + uses: actions/checkout@v4 + + - name: Setup Python & Poetry Environment + uses: ./.github/actions/python-environment + with: + python-version: ${{ matrix.python-version }} + + - name: Run security + run: poetry run nox -s security + + - name: Upload Artifacts + uses: actions/upload-artifact@v4.4.0 + with: + name: security-python${{ matrix.python-version }} + path: .security.json + include-hidden-files: true + tests-job: name: Tests (Python-${{ matrix.python-version }}, Exasol-${{ matrix.exasol-version}}) needs: [ build-documentation-job, lint-job, type-check-job ] diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index 451ffc3e..483bfbe9 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -33,6 +33,7 @@ jobs: run: | cp coverage-python3.9/.coverage ../ cp lint-python3.9/.lint.txt ../ + cp security-python3.9/.security.json ../ - name: Generate Report run: poetry run nox -s report -- -- --format json | tee metrics.json diff --git a/.gitignore b/.gitignore index e86a9935..0ed5574d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .lint.json .lint.txt +.security.json odbcconfig/odbcinst.ini diff --git a/exasol/toolbox/metrics.py b/exasol/toolbox/metrics.py index 1af908de..cea643a4 100644 --- a/exasol/toolbox/metrics.py +++ b/exasol/toolbox/metrics.py @@ -19,6 +19,7 @@ Any, Callable, Dict, + List, Optional, Union, ) @@ -67,6 +68,26 @@ def from_score(score: float) -> "Rating": "Uncategorized score, score should be in the following interval [0,10]." ) + @staticmethod + def bandit_rating(score: float) -> "Rating": + score = round(score, 3) + if score <= 0.2: + return Rating.F + elif 0.2 < score <= 1.6: + return Rating.E + elif 1.6 < score <= 3: + return Rating.D + elif 3 < score <= 4.4: + return Rating.C + elif 4.4 < score <= 5.8: + return Rating.B + elif 5.8 < score <= 6: + return Rating.A + else: + raise ValueError( + "Uncategorized score, score should be in the following interval [0,6]." + ) + @dataclass(frozen=True) class Report: @@ -124,8 +145,27 @@ def reliability() -> Rating: return Rating.NotAvailable -def security() -> Rating: - return Rating.NotAvailable +def security(file: Union[str, Path]) -> Rating: + with open(file) as json_file: + security_lint = json.load(json_file) + return Rating.bandit_rating(_bandit_scoring(security_lint["results"])) + + +def _bandit_scoring(ratings: List[Dict[str, Any]]) -> float: + def char(value: str, default: str = "H") -> str: + if value in ["HIGH", "MEDIUM", "LOW"]: + return value[0] + return default + + weight = {"LL": 1/18, "LM": 1/15, "LH": 1/12, "ML": 1/9, "MM": 1/6, "MH": 1/3} + exp = 0.0 + for infos in ratings: + severity = infos["issue_severity"] + if severity == "HIGH": + return 0.0 + index = char(severity) + char(infos["issue_confidence"]) + exp += weight[index] + return 6 * (2**-exp) def technical_debt() -> Rating: @@ -137,6 +177,7 @@ def create_report( date: Optional[datetime.datetime] = None, coverage_report: Union[str, Path] = ".coverage", pylint_report: Union[str, Path] = ".lint.txt", + bandit_report: Union[str, Path] = ".security.json", ) -> Report: return Report( commit=commit, @@ -144,7 +185,7 @@ def create_report( coverage=total_coverage(coverage_report), maintainability=maintainability(pylint_report), reliability=reliability(), - security=security(), + security=security(bandit_report), technical_debt=technical_debt(), ) diff --git a/exasol/toolbox/nox/_lint.py b/exasol/toolbox/nox/_lint.py index b476c734..bc474e7d 100644 --- a/exasol/toolbox/nox/_lint.py +++ b/exasol/toolbox/nox/_lint.py @@ -37,6 +37,33 @@ def _type_check(session: Session, files: Iterable[str]) -> None: ) +def _security_lint(session: Session, files: Iterable[str]) -> None: + session.run( + "poetry", + "run", + "bandit", + "--severity-level", + "low", + "--quiet", + "--format", + "json", + "--output", + ".security.json", + "--exit-zero", + *files, + ) + session.run( + "poetry", + "run", + "bandit", + "--severity-level", + "low", + "--quiet", + "--exit-zero", + *files, + ) + + @nox.session(python=False) def lint(session: Session) -> None: """Runs the linter on the project""" @@ -49,3 +76,10 @@ def type_check(session: Session) -> None: """Runs the type checker on the project""" py_files = [f"{file}" for file in python_files(PROJECT_CONFIG.root)] _type_check(session, py_files) + + +@nox.session(name="security", python=False) +def security_lint(session: Session) -> None: + """Runs the security linter on the project""" + py_files = [f"{file}" for file in python_files(PROJECT_CONFIG.root)] + _security_lint(session, list(filter(lambda file: "test" not in file, py_files))) diff --git a/exasol/toolbox/nox/_metrics.py b/exasol/toolbox/nox/_metrics.py index 0d43936a..d00ba06e 100644 --- a/exasol/toolbox/nox/_metrics.py +++ b/exasol/toolbox/nox/_metrics.py @@ -47,10 +47,11 @@ def report(session: Session) -> None: required_files = ( PROJECT_CONFIG.root / ".coverage", PROJECT_CONFIG.root / ".lint.txt", + PROJECT_CONFIG.root / ".security.json", ) if not all(file.exists() for file in required_files): session.error( - "Please make sure you run the `coverage` and the `lint` target first" + "Please make sure you run the `coverage`, `security` and the `lint` target first" ) sha1 = str( session.run("git", "rev-parse", "HEAD", external=True, silent=True) diff --git a/poetry.lock b/poetry.lock index 00522467..e2e444e9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -104,6 +104,31 @@ pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] +[[package]] +name = "bandit" +version = "1.7.10" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bandit-1.7.10-py3-none-any.whl", hash = "sha256:665721d7bebbb4485a339c55161ac0eedde27d51e638000d91c8c2d68343ad02"}, + {file = "bandit-1.7.10.tar.gz", hash = "sha256:59ed5caf5d92b6ada4bf65bc6437feea4a9da1093384445fed4d472acc6cff7b"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" +tomli = {version = ">=1.1.0", optional = true, markers = "python_version < \"3.11\" and extra == \"toml\""} + +[package.extras] +baseline = ["GitPython (>=3.1.30)"] +sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] +toml = ["tomli (>=1.1.0)"] +yaml = ["PyYAML"] + [[package]] name = "beautifulsoup4" version = "4.12.3" @@ -917,6 +942,17 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] +[[package]] +name = "pbr" +version = "6.1.0" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-6.1.0-py2.py3-none-any.whl", hash = "sha256:a776ae228892d8013649c0aeccbb3d5f99ee15e005a4cbb7e61d55a067b28a2a"}, + {file = "pbr-6.1.0.tar.gz", hash = "sha256:788183e382e3d1d7707db08978239965e8b9e4e5ed42669bf4758186734d5f24"}, +] + [[package]] name = "platformdirs" version = "4.3.6" @@ -1598,6 +1634,20 @@ files = [ lint = ["docutils-stubs", "flake8", "mypy"] test = ["pytest"] +[[package]] +name = "stevedore" +version = "5.3.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.3.0-py3-none-any.whl", hash = "sha256:1efd34ca08f474dad08d9b19e934a22c68bb6fe416926479ba29e5013bcc8f78"}, + {file = "stevedore-5.3.0.tar.gz", hash = "sha256:9a64265f4060312828151c204efbe9b7a9852a0d9228756344dbc7e4023e375a"}, +] + +[package.dependencies] +pbr = ">=2.0.0" + [[package]] name = "tokenize-rt" version = "6.0.0" @@ -1740,4 +1790,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "ca885528be7bd58edf02ee1880c86d73109549c8d04f5ab89defb1d5112f13eb" +content-hash = "65e5106028ae791bfde144958f5899da84b0a642308fe4c5a92f538c31e00a5a" diff --git a/pyproject.toml b/pyproject.toml index 0a2911d4..83188439 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ sphinx-design = ">=0.5.0,<1" typer = {extras = ["all"], version = ">=0.7.0"} +bandit = {extras = ["toml"], version = "^1.7.9"} [tool.poetry.group.dev.dependencies] autoimport = "^1.4.0" diff --git a/test/unit/report_test.py b/test/unit/report_test.py index 24f7c9a6..1d9b34ac 100644 --- a/test/unit/report_test.py +++ b/test/unit/report_test.py @@ -1,9 +1,14 @@ from inspect import cleandoc +from typing import ( + Dict, + List, +) import pytest from exasol.toolbox.metrics import ( Rating, + _bandit_scoring, _static_code_analysis, ) @@ -110,3 +115,48 @@ def test_static_code_analysis( coverage_report = named_temp_file(name=".lint.txt", content=content) actual = _static_code_analysis(coverage_report) assert actual == expected + + +def _level(char): + levels = {"H": "HIGH", "M": "MEDIUM", "L": "LOW"} + return levels[char] + + +def _ratings(cases): + output = [] + for rating in cases: + output.append( + { + "issue_severity": _level(rating[0]), + "issue_confidence": _level(rating[1]), + } + ) + return output + + +@pytest.mark.parametrize( + "given,expected", + [ + (["HH", "LL"], 0), + (["HM", "LM", "ML"], 0), + (["HL", "MH"], 0), + ([], 6), + ], +) +def test_bandit_value(given, expected): + assert _bandit_scoring(_ratings(given)) == expected + + +@pytest.mark.parametrize( + "lower,higher", + [ + (["HL"], ["MH"]), + (["MH"], ["MM"]), + (["MM"], ["ML"]), + (["HL"], ["LL"]), + (["LL"], []), + (["MH", "LL"], ["MH"]), + ], +) +def test_bandit_order(lower, higher): + assert _bandit_scoring(_ratings(lower)) < _bandit_scoring(_ratings(higher))