Skip to content

Commit c922bc2

Browse files
add security linter bandit to nox (#208)
1 parent fc35075 commit c922bc2

File tree

9 files changed

+213
-6
lines changed

9 files changed

+213
-6
lines changed

.github/workflows/checks.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,34 @@ jobs:
8989
- name: Run type-check
9090
run: poetry run nox -s type-check
9191

92+
security-job:
93+
name: Security Checking (Python-${{ matrix.python-version }})
94+
needs: [ version-check-job ]
95+
runs-on: ubuntu-latest
96+
strategy:
97+
fail-fast: false
98+
matrix:
99+
python-version: [ "3.8", "3.9", "3.10", "3.11" ]
100+
101+
steps:
102+
- name: SCM Checkout
103+
uses: actions/checkout@v4
104+
105+
- name: Setup Python & Poetry Environment
106+
uses: ./.github/actions/python-environment
107+
with:
108+
python-version: ${{ matrix.python-version }}
109+
110+
- name: Run security
111+
run: poetry run nox -s security
112+
113+
- name: Upload Artifacts
114+
uses: actions/upload-artifact@v4.4.0
115+
with:
116+
name: security-python${{ matrix.python-version }}
117+
path: .security.json
118+
include-hidden-files: true
119+
92120
tests-job:
93121
name: Tests (Python-${{ matrix.python-version }}, Exasol-${{ matrix.exasol-version}})
94122
needs: [ build-documentation-job, lint-job, type-check-job ]

.github/workflows/report.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ jobs:
3333
run: |
3434
cp coverage-python3.9/.coverage ../
3535
cp lint-python3.9/.lint.txt ../
36+
cp security-python3.9/.security.json ../
3637
3738
- name: Generate Report
3839
run: poetry run nox -s report -- -- --format json | tee metrics.json

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.lint.json
22
.lint.txt
3+
.security.json
34

45
odbcconfig/odbcinst.ini
56

exasol/toolbox/metrics.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Any,
2020
Callable,
2121
Dict,
22+
List,
2223
Optional,
2324
Union,
2425
)
@@ -67,6 +68,26 @@ def from_score(score: float) -> "Rating":
6768
"Uncategorized score, score should be in the following interval [0,10]."
6869
)
6970

71+
@staticmethod
72+
def bandit_rating(score: float) -> "Rating":
73+
score = round(score, 3)
74+
if score <= 0.2:
75+
return Rating.F
76+
elif 0.2 < score <= 1.6:
77+
return Rating.E
78+
elif 1.6 < score <= 3:
79+
return Rating.D
80+
elif 3 < score <= 4.4:
81+
return Rating.C
82+
elif 4.4 < score <= 5.8:
83+
return Rating.B
84+
elif 5.8 < score <= 6:
85+
return Rating.A
86+
else:
87+
raise ValueError(
88+
"Uncategorized score, score should be in the following interval [0,6]."
89+
)
90+
7091

7192
@dataclass(frozen=True)
7293
class Report:
@@ -124,8 +145,27 @@ def reliability() -> Rating:
124145
return Rating.NotAvailable
125146

126147

127-
def security() -> Rating:
128-
return Rating.NotAvailable
148+
def security(file: Union[str, Path]) -> Rating:
149+
with open(file) as json_file:
150+
security_lint = json.load(json_file)
151+
return Rating.bandit_rating(_bandit_scoring(security_lint["results"]))
152+
153+
154+
def _bandit_scoring(ratings: List[Dict[str, Any]]) -> float:
155+
def char(value: str, default: str = "H") -> str:
156+
if value in ["HIGH", "MEDIUM", "LOW"]:
157+
return value[0]
158+
return default
159+
160+
weight = {"LL": 1/18, "LM": 1/15, "LH": 1/12, "ML": 1/9, "MM": 1/6, "MH": 1/3}
161+
exp = 0.0
162+
for infos in ratings:
163+
severity = infos["issue_severity"]
164+
if severity == "HIGH":
165+
return 0.0
166+
index = char(severity) + char(infos["issue_confidence"])
167+
exp += weight[index]
168+
return 6 * (2**-exp)
129169

130170

131171
def technical_debt() -> Rating:
@@ -137,14 +177,15 @@ def create_report(
137177
date: Optional[datetime.datetime] = None,
138178
coverage_report: Union[str, Path] = ".coverage",
139179
pylint_report: Union[str, Path] = ".lint.txt",
180+
bandit_report: Union[str, Path] = ".security.json",
140181
) -> Report:
141182
return Report(
142183
commit=commit,
143184
date=date if date is not None else datetime.datetime.now(),
144185
coverage=total_coverage(coverage_report),
145186
maintainability=maintainability(pylint_report),
146187
reliability=reliability(),
147-
security=security(),
188+
security=security(bandit_report),
148189
technical_debt=technical_debt(),
149190
)
150191

exasol/toolbox/nox/_lint.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,33 @@ def _type_check(session: Session, files: Iterable[str]) -> None:
3737
)
3838

3939

40+
def _security_lint(session: Session, files: Iterable[str]) -> None:
41+
session.run(
42+
"poetry",
43+
"run",
44+
"bandit",
45+
"--severity-level",
46+
"low",
47+
"--quiet",
48+
"--format",
49+
"json",
50+
"--output",
51+
".security.json",
52+
"--exit-zero",
53+
*files,
54+
)
55+
session.run(
56+
"poetry",
57+
"run",
58+
"bandit",
59+
"--severity-level",
60+
"low",
61+
"--quiet",
62+
"--exit-zero",
63+
*files,
64+
)
65+
66+
4067
@nox.session(python=False)
4168
def lint(session: Session) -> None:
4269
"""Runs the linter on the project"""
@@ -49,3 +76,10 @@ def type_check(session: Session) -> None:
4976
"""Runs the type checker on the project"""
5077
py_files = [f"{file}" for file in python_files(PROJECT_CONFIG.root)]
5178
_type_check(session, py_files)
79+
80+
81+
@nox.session(name="security", python=False)
82+
def security_lint(session: Session) -> None:
83+
"""Runs the security linter on the project"""
84+
py_files = [f"{file}" for file in python_files(PROJECT_CONFIG.root)]
85+
_security_lint(session, list(filter(lambda file: "test" not in file, py_files)))

exasol/toolbox/nox/_metrics.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ def report(session: Session) -> None:
4747
required_files = (
4848
PROJECT_CONFIG.root / ".coverage",
4949
PROJECT_CONFIG.root / ".lint.txt",
50+
PROJECT_CONFIG.root / ".security.json",
5051
)
5152
if not all(file.exists() for file in required_files):
5253
session.error(
53-
"Please make sure you run the `coverage` and the `lint` target first"
54+
"Please make sure you run the `coverage`, `security` and the `lint` target first"
5455
)
5556
sha1 = str(
5657
session.run("git", "rev-parse", "HEAD", external=True, silent=True)

poetry.lock

Lines changed: 52 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ sphinx-design = ">=0.5.0,<1"
5757
typer = {extras = ["all"], version = ">=0.7.0"}
5858

5959

60+
bandit = {extras = ["toml"], version = "^1.7.9"}
6061
[tool.poetry.group.dev.dependencies]
6162
autoimport = "^1.4.0"
6263

test/unit/report_test.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
from inspect import cleandoc
2+
from typing import (
3+
Dict,
4+
List,
5+
)
26

37
import pytest
48

59
from exasol.toolbox.metrics import (
610
Rating,
11+
_bandit_scoring,
712
_static_code_analysis,
813
)
914

@@ -110,3 +115,48 @@ def test_static_code_analysis(
110115
coverage_report = named_temp_file(name=".lint.txt", content=content)
111116
actual = _static_code_analysis(coverage_report)
112117
assert actual == expected
118+
119+
120+
def _level(char):
121+
levels = {"H": "HIGH", "M": "MEDIUM", "L": "LOW"}
122+
return levels[char]
123+
124+
125+
def _ratings(cases):
126+
output = []
127+
for rating in cases:
128+
output.append(
129+
{
130+
"issue_severity": _level(rating[0]),
131+
"issue_confidence": _level(rating[1]),
132+
}
133+
)
134+
return output
135+
136+
137+
@pytest.mark.parametrize(
138+
"given,expected",
139+
[
140+
(["HH", "LL"], 0),
141+
(["HM", "LM", "ML"], 0),
142+
(["HL", "MH"], 0),
143+
([], 6),
144+
],
145+
)
146+
def test_bandit_value(given, expected):
147+
assert _bandit_scoring(_ratings(given)) == expected
148+
149+
150+
@pytest.mark.parametrize(
151+
"lower,higher",
152+
[
153+
(["HL"], ["MH"]),
154+
(["MH"], ["MM"]),
155+
(["MM"], ["ML"]),
156+
(["HL"], ["LL"]),
157+
(["LL"], []),
158+
(["MH", "LL"], ["MH"]),
159+
],
160+
)
161+
def test_bandit_order(lower, higher):
162+
assert _bandit_scoring(_ratings(lower)) < _bandit_scoring(_ratings(higher))

0 commit comments

Comments
 (0)