diff --git a/CHANGELOG.md b/CHANGELOG.md index 670dae0..f50bbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All major and minor version changes will be documented in this file. Details of patch-level version changes can be found in [commit messages](../../commits/master). +## 2023.0.2 - 2023/06/27 + +- Add `Level.CRIT` +- Misc fixes to make plugins more resilient +- Test plugin utility functions + ## 2023.0.1 - 2023/06/27 - Fix `csv` writer diff --git a/documentation/reference/simplesecurity/level.md b/documentation/reference/simplesecurity/level.md index c564c83..73301bb 100644 --- a/documentation/reference/simplesecurity/level.md +++ b/documentation/reference/simplesecurity/level.md @@ -18,11 +18,6 @@ Level Levels for confidence and severity. -UNKNOWN = 0 -LOW = 1 -MED = 2 -HIGH = 3 - #### Signature ```python @@ -32,7 +27,7 @@ class Level(IntEnum): ### Level().__repr__ -[Show source in level.py:27](../../../simplesecurity/level.py#L27) +[Show source in level.py:22](../../../simplesecurity/level.py#L22) __repr__ method. @@ -49,7 +44,7 @@ def __repr__(self) -> str: ### Level().__str__ -[Show source in level.py:35](../../../simplesecurity/level.py#L35) +[Show source in level.py:30](../../../simplesecurity/level.py#L30) __str__ method (tostring). @@ -66,7 +61,7 @@ def __str__(self) -> str: ### Level().toSarif -[Show source in level.py:49](../../../simplesecurity/level.py#L49) +[Show source in level.py:45](../../../simplesecurity/level.py#L45) Convert to sarif representation. diff --git a/documentation/reference/simplesecurity/plugins.md b/documentation/reference/simplesecurity/plugins.md index b437755..e87a5ae 100644 --- a/documentation/reference/simplesecurity/plugins.md +++ b/documentation/reference/simplesecurity/plugins.md @@ -16,7 +16,7 @@ Plugins ## bandit -[Show source in plugins.py:90](../../../simplesecurity/plugins.py#L90) +[Show source in plugins.py:92](../../../simplesecurity/plugins.py#L92) Generate list of findings using bandit. requires bandit on the system path. @@ -47,7 +47,7 @@ def bandit(scanDir=".") -> list[Finding]: ## dlint -[Show source in plugins.py:247](../../../simplesecurity/plugins.py#L247) +[Show source in plugins.py:274](../../../simplesecurity/plugins.py#L274) Generate list of findings using _tool_. requires _tool_ on the system path. @@ -78,7 +78,7 @@ def dlint(scanDir=".") -> list[Finding]: ## dodgy -[Show source in plugins.py:211](../../../simplesecurity/plugins.py#L211) +[Show source in plugins.py:237](../../../simplesecurity/plugins.py#L237) Generate list of findings using _tool_. requires _tool_ on the system path. @@ -137,7 +137,7 @@ def extractEvidence(desiredLine: int, file: str) -> list[Line]: ## safety -[Show source in plugins.py:172](../../../simplesecurity/plugins.py#L172) +[Show source in plugins.py:198](../../../simplesecurity/plugins.py#L198) Generate list of findings using _tool_. requires _tool_ on the system path. @@ -168,7 +168,7 @@ def safety(scanDir=".") -> list[Finding]: ## semgrep -[Show source in plugins.py:302](../../../simplesecurity/plugins.py#L302) +[Show source in plugins.py:331](../../../simplesecurity/plugins.py#L331) Generate list of findings using for semgrep. Requires semgrep on the system path (wsl in windows). diff --git a/pyproject.toml b/pyproject.toml index 6643ae5..7b26b6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "simplesecurity" -version = "2023.0.1" +version = "2023.0.2" license = "mit" description = "Combine multiple popular python security tools and generate reports or output into different formats" authors = ["FredHappyface"] diff --git a/simplesecurity/__init__.py b/simplesecurity/__init__.py index 72ac446..e055154 100644 --- a/simplesecurity/__init__.py +++ b/simplesecurity/__init__.py @@ -79,7 +79,7 @@ def _processPlugin(args) -> list[Callable]: }, "safety": { "func": plugins.safety, - "max_severity": 2, + "max_severity": 4, "max_confidence": 3, "fast": True, }, @@ -91,7 +91,7 @@ def _processPlugin(args) -> list[Callable]: }, "dlint": { "func": plugins.dlint, - "max_severity": 3, + "max_severity": 4, "max_confidence": 2, "fast": True, }, diff --git a/simplesecurity/level.py b/simplesecurity/level.py index 516ade1..ab04b3e 100644 --- a/simplesecurity/level.py +++ b/simplesecurity/level.py @@ -11,18 +11,13 @@ class Level(IntEnum): - """Levels for confidence and severity. - - UNKNOWN = 0 - LOW = 1 - MED = 2 - HIGH = 3 - """ + """Levels for confidence and severity.""" UNKNOWN = 0 LOW = 1 MED = 2 HIGH = 3 + CRIT = 4 def __repr__(self) -> str: """__repr__ method. @@ -43,6 +38,7 @@ def __str__(self) -> str: Level.LOW: "Low", Level.MED: "Medium", Level.HIGH: "High", + Level.CRIT: "Critical", } return reprMap[self] @@ -50,8 +46,9 @@ def toSarif(self) -> str: """Convert to sarif representation.""" reprMap = { Level.UNKNOWN: "note", - Level.LOW: "warning", + Level.LOW: "note", Level.MED: "warning", Level.HIGH: "error", + Level.CRIT: "error", } return reprMap[self] diff --git a/simplesecurity/plugins.py b/simplesecurity/plugins.py index e8b39f0..e9ccb07 100644 --- a/simplesecurity/plugins.py +++ b/simplesecurity/plugins.py @@ -75,15 +75,17 @@ def extractEvidence(desiredLine: int, file: str) -> list[Line]: """ with open(file, encoding="utf-8", errors="ignore") as fileContents: start = max(desiredLine - 3, 0) - for line in range(start): - next(fileContents) content = [] - for line in range(start + 1, desiredLine + 3): - try: + try: + for line in range(start): + next(fileContents) + for line in range(start + 1, desiredLine + 3): lineContent = next(fileContents).rstrip().replace("\t", " ") - except StopIteration: - break - content.append({"selected": line == desiredLine, "line": line, "content": lineContent}) + content.append( + {"selected": line == desiredLine, "line": line, "content": lineContent} + ) + except StopIteration: + pass return content @@ -115,20 +117,22 @@ def bandit(scanDir=".") -> list[Finding]: )[1] )["results"] for result in results: - file = result["filename"].replace("\\", "/") + file = result.get("filename").replace("\\", "/") + resultId = result.get("test_id") + line = result.get("line_number") findings.append( { - "id": result["test_id"], - "title": f"{result['test_id']}: {result['test_name']}", - "description": result["issue_text"], + "id": resultId, + "title": f"{resultId}: {result.get('test_name')}", + "description": result.get("issue_text"), "file": file, - "evidence": extractEvidence(result["line_number"], file), - "severity": levelMap[result["issue_severity"]], - "confidence": levelMap[result["issue_confidence"]], - "line": result["line_number"], + "evidence": extractEvidence(line, file), + "severity": levelMap[result.get("issue_severity")], + "confidence": levelMap[result.get("issue_confidence")], + "line": line, "_other": { - "more_info": result["more_info"], - "line_range": result["line_range"], + "more_info": result.get("more_info"), + "line_range": result.get("line_range"), }, } ) @@ -138,23 +142,45 @@ def bandit(scanDir=".") -> list[Finding]: def _doSafetyProcessing(results: dict[str, Any]) -> list[Finding]: findings = [] for result in results["vulnerabilities"]: + vulnerabilityId = result.get("vulnerability_id") + packageName = result.get("package_name") + advisory = result.get("advisory") + + moreInfo = result.get("more_info_url") + affectedVersions = "; ".join(result.get("affected_versions")) + + content = f"{packageName}, version(s)={affectedVersions}" + description = ( + f"Vulnerability found in package {packageName}," + f"version(s)={affectedVersions}. {advisory}. More info available at {moreInfo}" + ) + + cvssv3Score = result.get("severity").get("cvssv3", {}).get("base_score", 0) + severity = Level.LOW + if cvssv3Score > 3.9: + severity = Level.MED + if cvssv3Score > 6.9: + severity = Level.HIGH + if cvssv3Score > 8.9: + severity = Level.CRIT + findings.append( { - "id": result[4], - "title": f"{result[4]}: {result[0]}", - "description": result[3], + "id": vulnerabilityId, + "title": f"{vulnerabilityId}: {packageName}", + "description": description, "file": "Project Requirements", "evidence": [ { "selected": True, "line": 0, - "content": f"{result[0]} version={result[2]} affects{result[1]}", + "content": content, } ], - "severity": Level.MED, + "severity": severity, "confidence": Level.HIGH, "line": "Unknown", - "_other": {"id": result[4], "affected": result[1]}, + "_other": {"id": vulnerabilityId, "affectedVersions": affectedVersions}, } ) return findings @@ -227,17 +253,18 @@ def dodgy(scanDir=".") -> list[Finding]: rawResults = _doSysExec(f"dodgy {scanDir} -i {' '.join(EXCLUDED)}")[1] results = loads(rawResults)["warnings"] for result in results: - file = "./" + result["path"].replace("\\", "/") + file = "./" + result.get("path").replace("\\", "/") + message = result.get("message") findings.append( { - "id": result["code"], - "title": result["message"], - "description": result["message"], + "id": result.get("code"), + "title": message, + "description": message, "file": file, - "evidence": extractEvidence(result["line"], file), + "evidence": extractEvidence(result.get("line"), file), "severity": Level.MED, "confidence": Level.MED, - "line": result["line"], + "line": result.get("line"), "_other": {}, } ) @@ -269,29 +296,31 @@ def dlint(scanDir=".") -> list[Finding]: "info": Level.LOW, "minor": Level.MED, "major": Level.MED, - "critical": Level.HIGH, - "blocker": Level.HIGH, + "critical": Level.CRIT, + "blocker": Level.CRIT, } for filePath, scanResults in jsonResults.items(): - for scanResult in scanResults: + for result in scanResults: + message = f"{result.get('check_name')}: " f"{result.get('description')}" + positions = result.get("location", {}).get("positions", {}) + line = positions.get("begin", {}).get("line", 0) findings.append( { - "id": scanResult["check_name"], - "title": f"{scanResult['check_name']}: " f"{scanResult['description']}", - "description": f"{scanResult['check_name']}: " f"{scanResult['description']}", + "id": result.get("check_name"), + "title": message, + "description": message, "file": filePath, "evidence": extractEvidence( - scanResult["location"]["positions"]["begin"]["line"], + line, filePath, ), - "severity": levelMap[scanResult["severity"]], + "severity": levelMap[result.get("severity")], "confidence": Level.MED, - "line": scanResult["location"]["positions"]["begin"]["line"], + "line": line, "_other": { - "col": scanResult["location"]["positions"]["begin"]["column"], - "start": scanResult["location"]["positions"]["begin"]["line"], - "end": scanResult["location"]["positions"]["end"]["line"], - "fingerprint": scanResult["fingerprint"], + "start": line, + "end": positions.get("end", {}).get("line", 0), + "fingerprint": result.get("fingerprint"), }, } ) @@ -324,23 +353,24 @@ def semgrep(scanDir=".") -> list[Finding]: )["results"] levelMap = {"INFO": Level.LOW, "WARNING": Level.MED, "ERROR": Level.HIGH} for result in results: - filePath = result["Target"].replace("\\", "/") + filePath = result.get("Target").replace("\\", "/") file = f"{scanDir}/{filePath}" + resultId = result.get("check_id", "") + extras = result.get("extra", {}) + line = result.get("start", {}).get("line", 0) findings.append( { - "id": result["check_id"], - "title": result["check_id"].split(".")[-1], - "description": result["extra"]["message"].strip(), + "id": resultId, + "title": resultId.split(".")[-1], + "description": extras("message").strip(), "file": file, - "evidence": extractEvidence(result["start"]["line"], file), - "severity": levelMap[result["extra"]["severity"]], + "evidence": extractEvidence(line, file), + "severity": levelMap[extras("severity")], "confidence": Level.HIGH, - "line": result["start"]["line"], + "line": line, "_other": { - "col": result["start"]["col"], - "start": result["start"], - "end": result["end"], - "extra": result["extra"], + "end": result.get("end"), + "extra": extras, }, } ) diff --git a/tests/__init__.py b/tests/__init__.py index 8b13789..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ - diff --git a/tests/data/advanced.sarif b/tests/data/advanced.sarif index b055cf0..237465e 100644 --- a/tests/data/advanced.sarif +++ b/tests/data/advanced.sarif @@ -42,7 +42,7 @@ }, { "ruleId": "TEST_ID2", - "level": "warning", + "level": "note", "message": { "text": "TEST2: This is a test2" }, diff --git a/tests/data/evidence.txt b/tests/data/evidence.txt new file mode 100644 index 0000000..b42d10c --- /dev/null +++ b/tests/data/evidence.txt @@ -0,0 +1,20 @@ +Lorem +ipsum +dolor +sit +amet, +consectetur +adipiscing +elit. +Aenean +ullamcorper +dolor +odio, +ut +rutrum +nibh +tempus +eu. +Nulla +ut +lectus. diff --git a/tests/data/evidence_big.txt b/tests/data/evidence_big.txt new file mode 100644 index 0000000..aa8b26c --- /dev/null +++ b/tests/data/evidence_big.txt @@ -0,0 +1,100 @@ +Lorem +ipsum +dolor +sit +amet, +consectetur +adipiscing +elit. +Nulla +eget +velit +vel +turpis +fermentum +sollicitudin. +Aliquam +metus +sem, +tempor +vitae +sapien +vitae, +vehicula +placerat +ante. +Mauris +et +eros +a +risus +condimentum +fermentum. +Nam +hendrerit +sit +amet +velit +in +pellentesque. +Phasellus +imperdiet +pulvinar +luctus. +Nunc +nulla +dolor, +rutrum +nec +dolor +id, +aliquam +porta +augue. +Donec +velit +enim, +varius +sed +facilisis +quis, +pulvinar +at +metus. +Nam +lacinia +fringilla +nibh, +et +tincidunt +dolor +tincidunt +non. +Fusce +et +lacus +malesuada, +auctor +sapien +ut, +viverra +est. +Nullam +placerat +at +augue +nec +tempus. +Lorem +ipsum +dolor +sit +amet, +consectetur +adipiscing +elit. +Praesent +sed +viverra +ex. +Aenean. diff --git a/tests/test_formatter.py b/tests/test_formatter.py index a394059..717d8e3 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -2,63 +2,69 @@ from pathlib import Path from jsonschema import validate + from simplesecurity import formatter, level, types THISDIR = str(Path(__file__).resolve().parent) sarifSchema = json.loads(Path(f"{THISDIR}/data/sarif-schema-2.1.0.json").read_text("utf-8")) -simpleFindings: list[types.Finding] = [{ - "id": "TEST_ID", - "title": "TEST", - "description": "This is a test", - "file": "this_file_does_not_exist", - "evidence": [{"selected": True, "line": 0, "content": "lineContent"}], - "severity": level.Level.MED, - "confidence": level.Level.MED, - "line": 0, - "_other": {}, - -}] - -advancedFindings: list[types.Finding] = [{ - "id": "TEST_ID", - "title": "TEST", - "description": "This is a test", - "file": "this_file_does_not_exist", - "evidence": [{"selected": True, "line": 0, "content": "lineContent"}], - "severity": level.Level.MED, - "confidence": level.Level.MED, - "line": 0, - "_other": {}, -}, { - "id": "TEST_ID2", - "title": "TEST2", - "description": "This is a test2", - "file": "this_file_does_not_exist2", - "evidence": [ - {"selected": False, "line": 3, "content": "3"}, - {"selected": True, "line": 5, "content": "5"}, - {"selected": True, "line": 9, "content": "9"}, - {"selected": True, "line": 99, "content": "999999999999999999999999999999999"}, - ], - "severity": level.Level.LOW, - "confidence": level.Level.HIGH, - "line": 700, - "_other": {}, -} +simpleFindings: list[types.Finding] = [ + { + "id": "TEST_ID", + "title": "TEST", + "description": "This is a test", + "file": "this_file_does_not_exist", + "evidence": [{"selected": True, "line": 0, "content": "lineContent"}], + "severity": level.Level.MED, + "confidence": level.Level.MED, + "line": 0, + "_other": {}, + } +] +advancedFindings: list[types.Finding] = [ + { + "id": "TEST_ID", + "title": "TEST", + "description": "This is a test", + "file": "this_file_does_not_exist", + "evidence": [{"selected": True, "line": 0, "content": "lineContent"}], + "severity": level.Level.MED, + "confidence": level.Level.MED, + "line": 0, + "_other": {}, + }, + { + "id": "TEST_ID2", + "title": "TEST2", + "description": "This is a test2", + "file": "this_file_does_not_exist2", + "evidence": [ + {"selected": False, "line": 3, "content": "3"}, + {"selected": True, "line": 5, "content": "5"}, + {"selected": True, "line": 9, "content": "9"}, + {"selected": True, "line": 99, "content": "999999999999999999999999999999999"}, + ], + "severity": level.Level.LOW, + "confidence": level.Level.HIGH, + "line": 700, + "_other": {}, + }, ] + def test_simpleMarkdown(): fmt = formatter.markdown(simpleFindings) # Path(f"{THISDIR}/data/simple.md").write_text(fmt, "utf-8") assert fmt == Path(f"{THISDIR}/data/simple.md").read_text("utf-8") + def test_simpleAnsi(): fmt = formatter.ansi(simpleFindings) # Path(f"{THISDIR}/data/simple.ansi").write_text(fmt, "utf-8") assert fmt == Path(f"{THISDIR}/data/simple.ansi").read_text("utf-8") + def test_simpleJson(): fmt = formatter.json(simpleFindings) # Path(f"{THISDIR}/data/simple.json").write_text(fmt, "utf-8") @@ -70,9 +76,10 @@ def test_simpleCsv(): # Path(f"{THISDIR}/data/simple.csv").write_text(fmt, "utf-8") assert fmt == Path(f"{THISDIR}/data/simple.csv").read_text("utf-8") + def test_simpleSarif(): fmt = formatter.sarif(simpleFindings) - Path(f"{THISDIR}/data/simple.sarif").write_text(fmt, "utf-8") + # Path(f"{THISDIR}/data/simple.sarif").write_text(fmt, "utf-8") assert fmt == Path(f"{THISDIR}/data/simple.sarif").read_text("utf-8") assert validate(json.loads(fmt), sarifSchema) is None @@ -82,11 +89,13 @@ def test_advancedMarkdown(): # Path(f"{THISDIR}/data/advanced.md").write_text(fmt, "utf-8") assert fmt == Path(f"{THISDIR}/data/advanced.md").read_text("utf-8") + def test_advancedAnsi(): fmt = formatter.ansi(advancedFindings) # Path(f"{THISDIR}/data/advanced.ansi").write_text(fmt, "utf-8") assert fmt == Path(f"{THISDIR}/data/advanced.ansi").read_text("utf-8") + def test_advancedJson(): fmt = formatter.json(advancedFindings) # Path(f"{THISDIR}/data/advanced.json").write_text(fmt, "utf-8") @@ -98,8 +107,9 @@ def test_advancedCsv(): # Path(f"{THISDIR}/data/advanced.csv").write_text(fmt, "utf-8") assert fmt == Path(f"{THISDIR}/data/advanced.csv").read_text("utf-8") + def test_advancedSarif(): fmt = formatter.sarif(advancedFindings) - Path(f"{THISDIR}/data/advanced.sarif").write_text(fmt, "utf-8") + # Path(f"{THISDIR}/data/advanced.sarif").write_text(fmt, "utf-8") assert fmt == Path(f"{THISDIR}/data/advanced.sarif").read_text("utf-8") assert validate(json.loads(fmt), sarifSchema) is None diff --git a/tests/test_plugin_utils.py b/tests/test_plugin_utils.py new file mode 100644 index 0000000..9990237 --- /dev/null +++ b/tests/test_plugin_utils.py @@ -0,0 +1,131 @@ +import sys +from pathlib import Path + +from simplesecurity import level, plugins + +THISDIR = str(Path(__file__).resolve().parent) + +evidence = f"{THISDIR}/data/evidence.txt" +evidenceBig = f"{THISDIR}/data/evidence_big.txt" + + +def test_doSysExec_ls(): + ls = "ls" + if "win" in sys.platform.lower(): + ls = "dir" + assert plugins._doSysExec(f"{ls} {THISDIR}")[0] == 0 + + +def test_doSysExec_commandnotexists(): + assert plugins._doSysExec("commandnotexists")[0] != 0 + + +def test_extractEvidence_1line(): + compare = [ + {"content": "Lorem", "line": 1, "selected": True}, + {"content": "ipsum", "line": 2, "selected": False}, + {"content": "dolor", "line": 3, "selected": False}, + ] + assert plugins.extractEvidence(1, evidence) == compare + + +def test_extractEvidence_5line(): + compare = [ + {"content": "Lorem", "line": 1, "selected": False}, + {"content": "ipsum", "line": 2, "selected": False}, + {"content": "dolor", "line": 3, "selected": True}, + {"content": "sit", "line": 4, "selected": False}, + {"content": "amet,", "line": 5, "selected": False}, + ] + assert plugins.extractEvidence(3, evidence) == compare + + +def test_extractEvidence_line_notinfile_lower(): + compare = [ + {"content": "Lorem", "line": 1, "selected": False}, + {"content": "ipsum", "line": 2, "selected": False}, + ] + assert plugins.extractEvidence(0, evidence) == compare + + +def test_extractEvidence_line_notinfile_upper(): + compare = [ + {"content": "Nulla", "line": 18, "selected": False}, + {"content": "ut", "line": 19, "selected": False}, + {"content": "lectus.", "line": 20, "selected": True}, + ] + assert plugins.extractEvidence(20, evidence) == compare + + +def test__doSafetyProcessing(): + safety = { + "vulnerabilities": [ + { + "vulnerability_id": "44742", + "package_name": "django", + "ignored": False, + "ignored_reason": None, + "ignored_expires": None, + "vulnerable_spec": ">=4.0a1,<4.0.2", + "all_vulnerable_specs": [">=4.0a1,<4.0.2"], + "analyzed_version": "4.0.1", + "analyzed_requirement": { + "raw": "django==4.0.1", + "extras": [], + "marker": None, + "name": "django", + "specifier": "==4.0.1", + "url": None, + "found": None, + }, + "advisory": "The {% debug %} template tag in Django", + "is_transitive": False, + "published_date": "2022-Feb-03", + "fixed_versions": ["2.2.27", "3.2.12", "4.0.2"], + "closest_versions_without_known_vulnerabilities": [], + "resources": ["https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-22818"], + "CVE": "CVE-2022-22818", + "severity": { + "source": "CVE-2022-22818", + "cvssv2": { + "base_score": 4.3, + "impact_score": 2.9, + "vector_string": "AV:N/AC:M/Au:N/C:N/I:P/A:N", + }, + "cvssv3": { + "base_score": 6.1, + "impact_score": 2.7, + "base_severity": "MEDIUM", + "vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + }, + }, + "affected_versions": [ + "4.0.1", + ], + "more_info_url": "https://pyup.io/vulnerabilities/CVE-2022-22818/44742/", + }, + ], + } + findings = [ + { + "id": "44742", + "title": "44742: django", + "description": ( + "Vulnerability found in package django," + "version(s)=4.0.1. The {% debug %} template tag in Django. More info available at https://pyup.io/vulnerabilities/CVE-2022-22818/44742/" + ), + "file": "Project Requirements", + "evidence": [ + { + "selected": True, + "line": 0, + "content": "django, version(s)=4.0.1", + } + ], + "severity": level.Level.MED, + "confidence": level.Level.HIGH, + "line": "Unknown", + "_other": {"id": "44742", "affectedVersions": "4.0.1"}, + } + ] + assert plugins._doSafetyProcessing(safety) == findings