diff --git a/docs/content/en/connecting_your_tools/parsers/file/github_sast.md b/docs/content/en/connecting_your_tools/parsers/file/github_sast.md new file mode 100644 index 00000000000..a551d9ea0ef --- /dev/null +++ b/docs/content/en/connecting_your_tools/parsers/file/github_sast.md @@ -0,0 +1,9 @@ +--- +title: "Github SAST Scan" +toc_hide: true +--- +Import findings in JSON format from Github Code Scanning REST API: + + +### Sample Scan Data +Sample Github SAST scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/github_sast). \ No newline at end of file diff --git a/docs/content/en/connecting_your_tools/parsers/file/github_vulnerability.md b/docs/content/en/connecting_your_tools/parsers/file/github_vulnerability.md index 4d92f546685..5705165913a 100644 --- a/docs/content/en/connecting_your_tools/parsers/file/github_vulnerability.md +++ b/docs/content/en/connecting_your_tools/parsers/file/github_vulnerability.md @@ -1,5 +1,5 @@ --- -title: "Github Vulnerability" +title: "Github Vulnerability Scan" toc_hide: true --- Import findings from Github vulnerability scan (GraphQL Query): @@ -15,6 +15,8 @@ vulnerabilityAlerts (RepositoryVulnerabilityAlert object) + createdAt (optional) + vulnerableManifestPath + state (optional) + + dependabotUpdate (DependabotUpdate object) (optional) + + pullRequest (PullRequest object) (optional) + securityVulnerability (SecurityVulnerability object) + severity (CRITICAL/HIGH/LOW/MODERATE) + package (optional) @@ -27,10 +29,17 @@ vulnerabilityAlerts (RepositoryVulnerabilityAlert object) + value + references (optional) + url (optional) - + cvss (optional) + + cvss (optional - deprecated, use cvssSeverities instead) + score (optional) + vectorString (optional) + + cvssSeverities (optional) + + cvssV3 (CVSS object) (optional) + + score (optional) + + vectorString (optional) + cwes (optional) + + epss (EPSS object) (optional) + + percentage (optional) + + percentile (optional) ``` References: diff --git a/docs/content/en/open_source/upgrading/2.51.md b/docs/content/en/open_source/upgrading/2.51.md index 973234698d6..14ebd729c6a 100644 --- a/docs/content/en/open_source/upgrading/2.51.md +++ b/docs/content/en/open_source/upgrading/2.51.md @@ -36,6 +36,13 @@ The following Helm chart values have been modified in this release: - **Enhanced probe configuration for Celery**: Added support for customizing liveness, readiness, and startup probes in both Celery beat and worker deployments. - **Enhanced environment variable management**: All deployments now include `extraEnv` support for adding custom environment variables. For backwards compatibility, `.Values.extraEnv` can be used to inject common environment variables to all workloads. +## GitHub Scan Type and Parser Updates +The Github Vulnerability scan type and parser has been split into two disctinct scan types: +- [Github Vulnerability](https://github.com/DefectDojo/django-DefectDojo/blob/master/docs/content/en/connecting_your_tools/parsers/file/github_vulnerability.md) (original) +- [Github SAST](https://github.com/DefectDojo/django-DefectDojo/blob/master/docs/content/en/connecting_your_tools/parsers/file/github_sast.md) + +The original Github Vulnerability scan type will continue to accept SCA vulnerabilities uploaded in GitHub's GraphQL format, as it has always done. It will also continue to accept SAST uploads, however we recommend upgrading to the new Github SAST scan type for uploading these types of vulnerabilities going forward. This new scan type will accept the raw JSON response from [GitHub's REST API for code scanning alerts](https://docs.github.com/en/rest/code-scanning/code-scanning). Sample Github SAST scan data can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/github_sast). + ### Other changes - **Celery pod annotations**: Now we can add annotations to Celery beat/worker pods separately. diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index d20465e6301..5ebb9a92dd8 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1306,6 +1306,7 @@ def saml2_attrib_map_format(din): "JFrog Xray On Demand Binary Scan": ["title", "component_name", "component_version"], "Scout Suite Scan": ["file_path", "vuln_id_from_tool"], # for now we use file_path as there is no attribute for "service" "Meterian Scan": ["cwe", "component_name", "component_version", "description", "severity"], + "Github SAST Scan": ["vuln_id_from_tool", "severity", "file_path", "line"], "Github Vulnerability Scan": ["title", "severity", "component_name", "vulnerability_ids", "file_path"], "Solar Appscreener Scan": ["title", "file_path", "line", "severity"], "pip-audit Scan": ["vuln_id_from_tool", "component_name", "component_version"], @@ -1550,6 +1551,7 @@ def saml2_attrib_map_format(din): "Scout Suite Scan": DEDUPE_ALGO_HASH_CODE, "AWS Security Hub Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL, "Meterian Scan": DEDUPE_ALGO_HASH_CODE, + "Github SAST Scan": DEDUPE_ALGO_HASH_CODE, "Github Vulnerability Scan": DEDUPE_ALGO_HASH_CODE, "Cloudsploit Scan": DEDUPE_ALGO_HASH_CODE, "SARIF": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, diff --git a/dojo/tools/github_sast/__init__.py b/dojo/tools/github_sast/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/github_sast/parser.py b/dojo/tools/github_sast/parser.py new file mode 100644 index 00000000000..4d20e71623a --- /dev/null +++ b/dojo/tools/github_sast/parser.py @@ -0,0 +1,84 @@ +import json +from urllib.parse import urlparse + +from dojo.models import Finding + + +class GithubSASTParser: + def get_scan_types(self): + return ["Github SAST Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "GitHub SAST report file can be imported in JSON format." + + def get_findings(self, filename, test): + data = json.load(filename) + if not isinstance(data, list): + error_msg = "Invalid SAST report format, expected a JSON list of alerts." + raise TypeError(error_msg) + + findings = [] + for vuln in data: + rule = vuln.get("rule", {}) + inst = vuln.get("most_recent_instance", {}) + loc = inst.get("location", {}) + html_url = vuln.get("html_url") + rule_id = rule.get("id") + title = f"{rule.get('description')} ({rule_id})" + severity = rule.get("security_severity_level", "Info").title() + active = vuln.get("state") == "open" + + # Build description with context + desc_lines = [] + if html_url: + desc_lines.append(f"GitHub Alert: [{html_url}]({html_url})") + owner = repo = None + commit_sha = inst.get("commit_sha") + if html_url: + parsed = urlparse(html_url) + parts = parsed.path.strip("/").split("/") + # URL is ///security/... so parts[0]=owner, parts[1]=repo + if len(parts) >= 2: + owner, repo = parts[0], parts[1] + if owner and repo and commit_sha and loc.get("path") and loc.get("start_line"): + file_link = ( + f"{parsed.scheme}://{parsed.netloc}/" + f"{owner}/{repo}/blob/{commit_sha}/" + f"{loc['path']}#L{loc['start_line']}" + ) + desc_lines.append(f"Location: [{loc['path']}:{loc['start_line']}]({file_link})") + elif loc.get("path") and loc.get("start_line"): + # fallback if something is missing + desc_lines.append(f"Location: {loc['path']}:{loc['start_line']}") + msg = inst.get("message", {}).get("text") + if msg: + desc_lines.append(f"Message: {msg}") + if severity: + desc_lines.append(f"Rule Severity: {severity}") + if rule.get("full_description"): + desc_lines.append(f"Description: {rule.get('full_description')}") + description = "\n".join(desc_lines) + + finding = Finding( + title=title, + test=test, + description=description, + severity=severity, + active=active, + static_finding=True, + dynamic_finding=False, + vuln_id_from_tool=rule_id, + ) + + # File path & line + finding.file_path = loc.get("path") + finding.line = loc.get("start_line") + + if html_url: + finding.url = html_url + + findings.append(finding) + return findings diff --git a/dojo/tools/github_vulnerability/parser.py b/dojo/tools/github_vulnerability/parser.py index aa958934cc7..5c646086aeb 100644 --- a/dojo/tools/github_vulnerability/parser.py +++ b/dojo/tools/github_vulnerability/parser.py @@ -18,114 +18,128 @@ def get_description_for_scan_types(self, scan_type): def get_findings(self, filename, test): data = json.load(filename) - if "data" in data: - vulnerabilityAlerts = self._search_vulnerability_alerts(data["data"]) - if not vulnerabilityAlerts: - msg = "Invalid report, no 'vulnerabilityAlerts' node found" - raise ValueError(msg) - repository_url = None - if "repository" in data["data"]: - if "nameWithOwner" in data["data"]["repository"]: - repository_url = "https://github.com/{}".format( - data["data"]["repository"]["nameWithOwner"], - ) - if "url" in data["data"]["repository"]: - repository_url = data["data"]["repository"]["url"] + + if isinstance(data, dict): + if "data" not in data: + error_msg = ( + "Invalid report format, expected a GitHub RepositoryVulnerabilityAlert GraphQL query response." + ) + raise ValueError(error_msg) + + alerts = self._search_vulnerability_alerts(data.get("data")) + if not alerts: + error_msg = "Invalid report, no 'vulnerabilityAlerts' node found" + raise ValueError(error_msg) + + repo = data.get("data").get("repository", {}) + repo_url = repo.get("url") + dupes = {} - for alert in vulnerabilityAlerts["nodes"]: - description = alert["securityVulnerability"]["advisory"][ - "description" - ] - if "number" in alert and repository_url is not None: - dependabot_url = ( - repository_url - + "/security/dependabot/{}".format(alert["number"]) - ) - description = ( - f"[{dependabot_url}]({dependabot_url})\n" - + description - ) + for alert in alerts.get("nodes", []): + vuln = alert.get("securityVulnerability", {}) + advisory = vuln.get("advisory", {}) + summary = advisory.get("summary", "") + desc = advisory.get("description", "") + + pr_link = None + dependabot_update = alert.get("dependabotUpdate", {}) + if dependabot_update: + pr = dependabot_update.get("pullRequest", {}) + if pr: + pr_link = pr.get("permalink") + desc = f"Fix PR: [{pr_link}]({pr_link})\n" + desc + + alert_num = alert.get("number") + if alert_num and repo_url: + alert_link = f"{repo_url}/security/dependabot/{alert_num}" + desc = f"Repo Alert: [{alert_link}]({alert_link})\n" + desc + finding = Finding( - title=alert["securityVulnerability"]["advisory"]["summary"], + title=summary, test=test, - description=description, - severity=self._convert_security( - alert["securityVulnerability"].get("severity", "MODERATE"), - ), + description=desc, + severity=self._convert_security(vuln.get("severity", "MODERATE")), static_finding=True, dynamic_finding=False, - unique_id_from_tool=alert["id"], + unique_id_from_tool=alert.get("id"), ) - if "vulnerableManifestPath" in alert: - finding.file_path = alert["vulnerableManifestPath"] - if "vulnerableRequirements" in alert and alert["vulnerableRequirements"].startswith("= "): - finding.component_version = alert["vulnerableRequirements"][2:] - if "createdAt" in alert: - finding.date = dateutil.parser.parse(alert["createdAt"]) - if "state" in alert and ( - alert["state"] == "FIXED" or alert["state"] == "DISMISSED" - ): + + if alert_num and repo_url: + finding.url = alert_link + + cwes = advisory.get("cwes", {}).get("nodes", []) + if cwes: + cwe_id = cwes[0].get("cweId", "")[4:] + if cwe_id.isdigit(): + finding.cwe = int(cwe_id) + + if alert.get("vulnerableManifestPath"): + finding.file_path = alert.get("vulnerableManifestPath") + req = alert.get("vulnerableRequirements", "") + if req.startswith("= "): + finding.component_version = req[2:] + elif req: + finding.component_version = req + pkg = vuln.get("package", {}) + finding.component_name = pkg.get("name") + + if alert.get("createdAt"): + finding.date = dateutil.parser.parse(alert.get("createdAt")) + if alert.get("state") in {"FIXED", "DISMISSED"}: finding.active = False finding.is_mitigated = True - # if the package is present - if "package" in alert["securityVulnerability"]: - finding.component_name = alert["securityVulnerability"][ - "package" - ].get("name") - if "references" in alert["securityVulnerability"]["advisory"]: - finding.references = "" - for ref in alert["securityVulnerability"]["advisory"][ - "references" - ]: - finding.references += ref["url"] + "\r\n" - if "identifiers" in alert["securityVulnerability"]["advisory"]: - unsaved_vulnerability_ids = [identifier.get("value") for identifier in alert["securityVulnerability"]["advisory"][ - "identifiers" - ] if identifier.get("value")] - if unsaved_vulnerability_ids: - finding.unsaved_vulnerability_ids = ( - unsaved_vulnerability_ids - ) - if "cvss" in alert["securityVulnerability"]["advisory"]: - if ( - "score" - in alert["securityVulnerability"]["advisory"]["cvss"] - ): - score = alert["securityVulnerability"]["advisory"]["cvss"][ - "score" - ] + + ref_urls = [r.get("url") for r in advisory.get("references", []) if r.get("url")] + if alert_num and repo_url: + ref_urls.append(alert_link) + if pr_link: + ref_urls.append(pr_link) + if ref_urls: + finding.references = "\r\n".join(ref_urls) + + ids = [i.get("value") for i in advisory.get("identifiers", []) if i.get("value")] + if ids: + for identifier in ids: + if identifier.startswith("CVE-"): + finding.cve = identifier + elif identifier.startswith("GHSA-"): + finding.vuln_id_from_tool = identifier + if not finding.vuln_id_from_tool: + finding.vuln_id_from_tool = ids[0] + finding.unsaved_vulnerability_ids = ids + + # cvss is deprecated, so we favor cvssSeverities if it exists + for key in ("cvssSeverities", "cvss"): + cvss = advisory.get(key, {}) + if key == "cvssSeverities" and cvss: + cvss = cvss.get("cvssV3", {}) + if cvss: + score = cvss.get("score") if score is not None: finding.cvssv3_score = score - if ( - "vectorString" - in alert["securityVulnerability"]["advisory"]["cvss"] - ): - cvss_vector_string = alert["securityVulnerability"][ - "advisory" - ]["cvss"]["vectorString"] - if cvss_vector_string is not None: - cvss_objects = cvss_parser.parse_cvss_from_text( - cvss_vector_string, - ) - if len(cvss_objects) > 0: - finding.cvssv3 = cvss_objects[0].clean_vector() - if ( - "cwes" in alert["securityVulnerability"]["advisory"] - and "nodes" - in alert["securityVulnerability"]["advisory"]["cwes"] - ): - cwe_nodes = alert["securityVulnerability"]["advisory"]["cwes"][ - "nodes" - ] - if cwe_nodes and len(cwe_nodes) > 0: - finding.cwe = int(cwe_nodes[0].get("cweId")[4:]) + vec = cvss.get("vectorString") + if vec: + parsed = cvss_parser.parse_cvss_from_text(vec) + if parsed: + finding.cvssv3 = parsed[0].clean_vector() + break + + epss = advisory.get("epss", {}) + percentage = epss.get("percentage") + percentile = epss.get("percentile") + if percentage is not None: + finding.epss_score = percentage + if percentile is not None: + finding.epss_percentile = percentile + dupe_key = finding.unique_id_from_tool if dupe_key in dupes: - find = dupes[dupe_key] - find.nb_occurences += 1 + dupes[dupe_key].nb_occurences += 1 else: dupes[dupe_key] = finding + return list(dupes.values()) + if isinstance(data, list): findings = [] for vuln in data: @@ -177,24 +191,25 @@ def get_findings(self, filename, test): ) findings.append(finding) return findings - return None + error_msg = ( + "Invalid report format, expected a GitHub RepositoryVulnerabilityAlert GraphQL query response." + ) + raise TypeError(error_msg) def _search_vulnerability_alerts(self, data): - if isinstance(data, list): + if isinstance(data, dict): + if "vulnerabilityAlerts" in data: + return data["vulnerabilityAlerts"] + for v in data.values(): + res = self._search_vulnerability_alerts(v) + if res: + return res + elif isinstance(data, list): for item in data: - result = self._search_vulnerability_alerts(item) - if result: - return result - elif isinstance(data, dict): - for key in data: - if key == "vulnerabilityAlerts": - return data[key] - result = self._search_vulnerability_alerts(data[key]) - if result: - return result + res = self._search_vulnerability_alerts(item) + if res: + return res return None def _convert_security(self, val): - if val.lower() == "moderate": - return "Medium" - return val.title() + return "Medium" if val.lower() == "moderate" else val.title() diff --git a/unittests/scans/github_sast/github_sast_many_vul.json b/unittests/scans/github_sast/github_sast_many_vul.json new file mode 100644 index 00000000000..ca03ebf4094 --- /dev/null +++ b/unittests/scans/github_sast/github_sast_many_vul.json @@ -0,0 +1,107 @@ +[ + { + "number":35, + "created_at":"2024-01-19T14:11:18Z", + "updated_at":"2024-01-19T14:11:20Z", + "url":"https://api.github.com/repos/OWASP/test-repository/code-scanning/alerts/35", + "html_url":"https://github.com/OWASP/test-repository/security/code-scanning/35", + "state":"open", + "fixed_at":"None", + "dismissed_by":"None", + "dismissed_at":"None", + "dismissed_reason":"None", + "dismissed_comment":"None", + "rule":{ + "id":"py/clear-text-storage-sensitive-data", + "severity":"error", + "description":"Clear-text storage of sensitive information", + "name":"py/clear-text-storage-sensitive-data", + "tags":[ + "external/cwe/cwe-312", + "external/cwe/cwe-315", + "external/cwe/cwe-359", + "security" + ], + "security_severity_level":"high" + }, + "tool":{ + "name":"CodeQL", + "guid":"None", + "version":"2.16.2" + }, + "most_recent_instance":{ + "ref":"refs/OWASP/test-repository", + "analysis_key":"dynamic/github-code-scanning/codeql:analyze", + "environment":"{\"language\":\"python\"}", + "category":"/language:python", + "state":"open", + "commit_sha":"XXX", + "message":{ + "text":"This expression stores sensitive data (secret) as clear text." + }, + "location":{ + "path":"src/file.py", + "start_line":42, + "end_line":42, + "start_column":17, + "end_column":23 + }, + "classifications":[] + }, + "instances_url":"https://api.github.com/repos/OWASP/test-repository/code-scanning/alerts/35/instances" + }, + { + "number":34, + "created_at":"2024-01-19T14:11:18Z", + "updated_at":"2024-01-19T14:11:20Z", + "url":"https://api.github.com/repos/OWASP/test-repository/code-scanning/alerts/34", + "html_url":"https://github.com/OWASP/test-repository/security/code-scanning/34", + "state":"open", + "fixed_at":"None", + "dismissed_by":"None", + "dismissed_at":"None", + "dismissed_reason":"None", + "dismissed_comment":"None", + "rule":{ + "id":"py/path-injection", + "severity":"error", + "description":"Uncontrolled data used in path expression", + "name":"py/path-injection", + "tags":[ + "correctness", + "external/cwe/cwe-022", + "external/cwe/cwe-023", + "external/cwe/cwe-036", + "external/cwe/cwe-073", + "external/cwe/cwe-099", + "security" + ], + "security_severity_level":"high" + }, + "tool":{ + "name":"CodeQL", + "guid":"None", + "version":"2.16.2" + }, + "most_recent_instance":{ + "ref":"refs/OWASP/test-repository", + "analysis_key":"dynamic/github-code-scanning/codeql:analyze", + "environment":"{\"language\":\"python\"}", + "category":"/language:python", + "state":"open", + "commit_sha":"XXX", + "message":{ + "text":"This path depends on a user-provided value." + }, + "location":{ + "path":"src/file2.py", + "start_line":78, + "end_line":78, + "start_column":25, + "end_column":63 + }, + "classifications":[] + }, + "instances_url":"https://api.github.com/repos/OWASP/test-repository/code-scanning/alerts/34/instances" + } + ] \ No newline at end of file diff --git a/unittests/scans/github_sast/github_sast_one_vul.json b/unittests/scans/github_sast/github_sast_one_vul.json new file mode 100644 index 00000000000..cd598f7077e --- /dev/null +++ b/unittests/scans/github_sast/github_sast_one_vul.json @@ -0,0 +1,53 @@ +[ + { + "number":35, + "created_at":"2024-01-19T14:11:18Z", + "updated_at":"2024-01-19T14:11:20Z", + "url":"https://api.github.com/repos/OWASP/test-repository/code-scanning/alerts/35", + "html_url":"https://github.com/OWASP/test-repository/security/code-scanning/35", + "state":"open", + "fixed_at":"None", + "dismissed_by":"None", + "dismissed_at":"None", + "dismissed_reason":"None", + "dismissed_comment":"None", + "rule":{ + "id":"py/clear-text-storage-sensitive-data", + "severity":"error", + "description":"Clear-text storage of sensitive information", + "name":"py/clear-text-storage-sensitive-data", + "tags":[ + "external/cwe/cwe-312", + "external/cwe/cwe-315", + "external/cwe/cwe-359", + "security" + ], + "security_severity_level":"high" + }, + "tool":{ + "name":"CodeQL", + "guid":"None", + "version":"2.16.2" + }, + "most_recent_instance":{ + "ref":"refs/OWASP/test-repository", + "analysis_key":"dynamic/github-code-scanning/codeql:analyze", + "environment":"{\"language\":\"python\"}", + "category":"/language:python", + "state":"open", + "commit_sha":"XXX", + "message":{ + "text":"This expression stores sensitive data (secret) as clear text." + }, + "location":{ + "path":"src/file.py", + "start_line":42, + "end_line":42, + "start_column":17, + "end_column":23 + }, + "classifications":[] + }, + "instances_url":"https://api.github.com/repos/OWASP/test-repository/code-scanning/alerts/35/instances" + } + ] \ No newline at end of file diff --git a/unittests/scans/github_sast/github_sast_zero_vul.json b/unittests/scans/github_sast/github_sast_zero_vul.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/unittests/scans/github_sast/github_sast_zero_vul.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/unittests/scans/github_vulnerability/github-1-vuln-repo-dependabot-link.json b/unittests/scans/github_vulnerability/github-1-vuln-repo-dependabot-link.json index 4c493c8360a..bdb216d707a 100644 --- a/unittests/scans/github_vulnerability/github-1-vuln-repo-dependabot-link.json +++ b/unittests/scans/github_vulnerability/github-1-vuln-repo-dependabot-link.json @@ -2,6 +2,7 @@ "data": { "repository": { "nameWithOwner": "OWASP/test-repository", + "url": "https://github.com/OWASP/test-repository", "search": { "nodes": [ { @@ -10,6 +11,11 @@ { "id": "aabbccddeeff1122334401", "number": "1", + "dependabotUpdate": { + "pullRequest": { + "permalink": "https://github.com/OWASP/test-repository/pull/1" + } + }, "securityVulnerability": { "severity": "CRITICAL", "package": { diff --git a/unittests/scans/github_vulnerability/github-vuln-version.json b/unittests/scans/github_vulnerability/github-vuln-version.json index e80afe7e583..7b1ac5e0037 100644 --- a/unittests/scans/github_vulnerability/github-vuln-version.json +++ b/unittests/scans/github_vulnerability/github-vuln-version.json @@ -7,6 +7,11 @@ "id": "RVA_kwDOLJyUo88AAAABQUWapw", "createdAt": "2024-01-26T02:42:32Z", "vulnerableManifestPath": "sompath/pom.xml", + "dependabotUpdate": { + "pullRequest": { + "permalink": "https://github.com/OWASP/test-repository/pull/1" + } + }, "securityVulnerability": { "severity": "CRITICAL", "updatedAt": "2022-12-09T22:02:22Z", @@ -21,6 +26,10 @@ "advisory": { "description": "Pivotal Spring Framework before 6.0.0 suffers from a potential remote code execution (RCE) issue if used for Java deserialization of untrusted data. Depending on how the library is implemented within a product, this issue may or not occur, and authentication may be required.\n\nMaintainers recommend investigating alternative components or a potential mitigating control. Version 4.2.6 and 3.2.17 contain [enhanced documentation](https://github.com/spring-projects/spring-framework/commit/5cbe90b2cd91b866a5a9586e460f311860e11cfa) advising users to take precautions against unsafe Java deserialization, version 5.3.0 [deprecate the impacted classes](https://github.com/spring-projects/spring-framework/issues/25379) and version 6.0.0 [removed it entirely](https://github.com/spring-projects/spring-framework/issues/27422).", "summary": "Pivotal Spring Framework contains unsafe Java deserialization methods", + "epss": { + "percentage": 0.00212, + "percentile": 0.44035 + }, "identifiers": [ { "value": "GHSA-4wrc-f8pq-fpqp", diff --git a/unittests/scans/github_vulnerability/issue_9582.json b/unittests/scans/github_vulnerability/issue_9582.json index 7e297d8f1b2..4500ffcd6e7 100644 --- a/unittests/scans/github_vulnerability/issue_9582.json +++ b/unittests/scans/github_vulnerability/issue_9582.json @@ -1,111 +1,111 @@ -[ - { - "number":35, - "created_at":"2024-01-19T14:11:18Z", - "updated_at":"2024-01-19T14:11:20Z", - "url":"https://api.github.com/repos/XX/YY/code-scanning/alerts/35", - "html_url":"https://github.com/XX/YY/security/code-scanning/35", - "state":"open", - "fixed_at":"None", - "dismissed_by":"None", - "dismissed_at":"None", - "dismissed_reason":"None", - "dismissed_comment":"None", - "rule":{ - "id":"py/clear-text-storage-sensitive-data", - "severity":"error", - "description":"Clear-text storage of sensitive information", - "name":"py/clear-text-storage-sensitive-data", - "tags":[ - "external/cwe/cwe-312", - "external/cwe/cwe-315", - "external/cwe/cwe-359", - "security" - ], - "security_severity_level":"high" - }, - "tool":{ - "name":"CodeQL", - "guid":"None", - "version":"2.16.2" - }, - "most_recent_instance":{ - "ref":"refs/XX/YY", - "analysis_key":"dynamic/github-code-scanning/codeql:analyze", - "environment":"{\"language\":\"python\"}", - "category":"/language:python", - "state":"open", - "commit_sha":"XXX", - "message":{ - "text":"This expression stores sensitive data (secret) as clear text." - }, - "location":{ - "path":"Unsafe Deserialization/file.py", - "start_line":42, - "end_line":42, - "start_column":17, - "end_column":23 - }, - "classifications":[ - - ] - }, - "instances_url":"https://api.github.com/repos/XX/YY/code-scanning/alerts/35/instances" - }, - { - "number":34, - "created_at":"2024-01-19T14:11:18Z", - "updated_at":"2024-01-19T14:11:20Z", - "url":"https://api.github.com/repos/XX/YY/code-scanning/alerts/34", - "html_url":"https://github.com/XX/YY/security/code-scanning/34", - "state":"open", - "fixed_at":"None", - "dismissed_by":"None", - "dismissed_at":"None", - "dismissed_reason":"None", - "dismissed_comment":"None", - "rule":{ - "id":"py/path-injection", - "severity":"error", - "description":"Uncontrolled data used in path expression", - "name":"py/path-injection", - "tags":[ - "correctness", - "external/cwe/cwe-022", - "external/cwe/cwe-023", - "external/cwe/cwe-036", - "external/cwe/cwe-073", - "external/cwe/cwe-099", - "security" - ], - "security_severity_level":"high" - }, - "tool":{ - "name":"CodeQL", - "guid":"None", - "version":"2.16.2" - }, - "most_recent_instance":{ - "ref":"refs/XX/YY", - "analysis_key":"dynamic/github-code-scanning/codeql:analyze", - "environment":"{\"language\":\"python\"}", - "category":"/language:python", - "state":"open", - "commit_sha":"XXX", - "message":{ - "text":"This path depends on a user-provided value." - }, - "location":{ - "path":"Path Traversal/file2.py", - "start_line":78, - "end_line":78, - "start_column":25, - "end_column":63 - }, - "classifications":[ - - ] - }, - "instances_url":"https://api.github.com/repos/XX/YY/code-scanning/alerts/34/instances" - } -] \ No newline at end of file +[ + { + "number":35, + "created_at":"2024-01-19T14:11:18Z", + "updated_at":"2024-01-19T14:11:20Z", + "url":"https://api.github.com/repos/XX/YY/code-scanning/alerts/35", + "html_url":"https://github.com/XX/YY/security/code-scanning/35", + "state":"open", + "fixed_at":"None", + "dismissed_by":"None", + "dismissed_at":"None", + "dismissed_reason":"None", + "dismissed_comment":"None", + "rule":{ + "id":"py/clear-text-storage-sensitive-data", + "severity":"error", + "description":"Clear-text storage of sensitive information", + "name":"py/clear-text-storage-sensitive-data", + "tags":[ + "external/cwe/cwe-312", + "external/cwe/cwe-315", + "external/cwe/cwe-359", + "security" + ], + "security_severity_level":"high" + }, + "tool":{ + "name":"CodeQL", + "guid":"None", + "version":"2.16.2" + }, + "most_recent_instance":{ + "ref":"refs/XX/YY", + "analysis_key":"dynamic/github-code-scanning/codeql:analyze", + "environment":"{\"language\":\"python\"}", + "category":"/language:python", + "state":"open", + "commit_sha":"XXX", + "message":{ + "text":"This expression stores sensitive data (secret) as clear text." + }, + "location":{ + "path":"Unsafe Deserialization/file.py", + "start_line":42, + "end_line":42, + "start_column":17, + "end_column":23 + }, + "classifications":[ + + ] + }, + "instances_url":"https://api.github.com/repos/XX/YY/code-scanning/alerts/35/instances" + }, + { + "number":34, + "created_at":"2024-01-19T14:11:18Z", + "updated_at":"2024-01-19T14:11:20Z", + "url":"https://api.github.com/repos/XX/YY/code-scanning/alerts/34", + "html_url":"https://github.com/XX/YY/security/code-scanning/34", + "state":"open", + "fixed_at":"None", + "dismissed_by":"None", + "dismissed_at":"None", + "dismissed_reason":"None", + "dismissed_comment":"None", + "rule":{ + "id":"py/path-injection", + "severity":"error", + "description":"Uncontrolled data used in path expression", + "name":"py/path-injection", + "tags":[ + "correctness", + "external/cwe/cwe-022", + "external/cwe/cwe-023", + "external/cwe/cwe-036", + "external/cwe/cwe-073", + "external/cwe/cwe-099", + "security" + ], + "security_severity_level":"high" + }, + "tool":{ + "name":"CodeQL", + "guid":"None", + "version":"2.16.2" + }, + "most_recent_instance":{ + "ref":"refs/XX/YY", + "analysis_key":"dynamic/github-code-scanning/codeql:analyze", + "environment":"{\"language\":\"python\"}", + "category":"/language:python", + "state":"open", + "commit_sha":"XXX", + "message":{ + "text":"This path depends on a user-provided value." + }, + "location":{ + "path":"Path Traversal/file2.py", + "start_line":78, + "end_line":78, + "start_column":25, + "end_column":63 + }, + "classifications":[ + + ] + }, + "instances_url":"https://api.github.com/repos/XX/YY/code-scanning/alerts/34/instances" + } + ] \ No newline at end of file diff --git a/unittests/tools/test_github_sast_parser.py b/unittests/tools/test_github_sast_parser.py new file mode 100644 index 00000000000..9b42a795ec5 --- /dev/null +++ b/unittests/tools/test_github_sast_parser.py @@ -0,0 +1,53 @@ +import io + +from dojo.models import Test +from dojo.tools.github_sast.parser import GithubSASTParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestGithubSASTParser(DojoTestCase): + def test_parse_file_with_no_vuln_has_no_findings(self): + """Empty list should yield no findings""" + with (get_unit_tests_scans_path("github_sast") / "github_sast_zero_vul.json").open( + encoding="utf-8", + ) as testfile: + parser = GithubSASTParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(0, len(findings)) + + def test_parse_file_with_one_vuln_parsed_correctly(self): + """Single vulnerability entry parsed correctly""" + with (get_unit_tests_scans_path("github_sast") / "github_sast_one_vul.json").open(encoding="utf-8") as testfile: + parser = GithubSASTParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + finding = findings[0] + for ep in getattr(finding, "unsaved_endpoints", []): + ep.clean() + + expected_title = "Clear-text storage of sensitive information (py/clear-text-storage-sensitive-data)" + self.assertEqual(expected_title, finding.title) + self.assertEqual("src/file.py", finding.file_path) + self.assertEqual(42, finding.line) + self.assertEqual("py/clear-text-storage-sensitive-data", finding.vuln_id_from_tool) + self.assertEqual("High", finding.severity) + self.assertEqual("https://github.com/OWASP/test-repository/security/code-scanning/35", finding.url) + self.assertIn("This expression stores sensitive data", finding.description) + + def test_parse_file_with_multiple_vulns_has_multiple_findings(self): + """Multiple entries produce corresponding findings""" + with (get_unit_tests_scans_path("github_sast") / "github_sast_many_vul.json").open( + encoding="utf-8", + ) as testfile: + parser = GithubSASTParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(2, len(findings)) + lines = sorted(f.line for f in findings) + self.assertListEqual([42, 78], lines) + + def test_parse_file_invalid_format_raises(self): + """Non-list JSON should raise""" + bad_json = io.StringIO('{"not": "a list"}') + parser = GithubSASTParser() + with self.assertRaises(TypeError): + parser.get_findings(bad_json, Test()) diff --git a/unittests/tools/test_github_vulnerability_parser.py b/unittests/tools/test_github_vulnerability_parser.py index 1065a31a2f5..2e5869476bf 100644 --- a/unittests/tools/test_github_vulnerability_parser.py +++ b/unittests/tools/test_github_vulnerability_parser.py @@ -10,14 +10,18 @@ class TestGithubVulnerabilityParser(DojoTestCase): def test_parse_file_with_no_vuln_has_no_findings(self): """Sample with zero vulnerability""" - with (get_unit_tests_scans_path("github_vulnerability") / "github-0-vuln.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github-0-vuln.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(0, len(findings)) def test_parse_file_with_one_vuln_has_one_findings(self): """Sample with one vulnerability""" - with (get_unit_tests_scans_path("github_vulnerability") / "github-1-vuln.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github-1-vuln.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(1, len(findings)) @@ -36,8 +40,10 @@ def test_parse_file_with_one_vuln_has_one_findings(self): self.assertEqual(finding.unique_id_from_tool, "aabbccddeeff1122334401") def test_parse_file_with_one_vuln_has_one_finding_and_dependabot_direct_link(self): - """Sample with one vulnerability""" - with (get_unit_tests_scans_path("github_vulnerability") / "github-1-vuln-repo-dependabot-link.json").open(encoding="utf-8") as testfile: + """Sample with dependabot PR and repository alert link""" + with (get_unit_tests_scans_path("github_vulnerability") / "github-1-vuln-repo-dependabot-link.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(1, len(findings)) @@ -47,23 +53,31 @@ def test_parse_file_with_one_vuln_has_one_finding_and_dependabot_direct_link(sel with self.subTest(i=0): finding = findings[0] self.assertEqual(finding.title, "Critical severity vulnerability that affects package") - self.assertEqual( - finding.description, - "[https://github.com/OWASP/test-repository/security/dependabot/1](https://github.com/OWASP/test-repository/security/dependabot/1)\nThis is a sample description for sample description from Github API.", + expected_desc = ( + "Repo Alert: [https://github.com/OWASP/test-repository/security/dependabot/1]" + "(https://github.com/OWASP/test-repository/security/dependabot/1)\n" + "Fix PR: [https://github.com/OWASP/test-repository/pull/1]" + "(https://github.com/OWASP/test-repository/pull/1)\n" + "This is a sample description for sample description from Github API." ) + self.assertEqual(finding.description, expected_desc) self.assertEqual(finding.severity, "Critical") self.assertEqual(finding.component_name, "package") self.assertEqual(finding.unique_id_from_tool, "aabbccddeeff1122334401") def test_parse_file_with_multiple_vuln_has_multiple_findings(self): """Sample with five vulnerability""" - with (get_unit_tests_scans_path("github_vulnerability") / "github-5-vuln.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github-5-vuln.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(5, len(findings)) def test_parse_file_issue2984(self): - with (get_unit_tests_scans_path("github_vulnerability") / "github_issue2984.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github_issue2984.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(4, len(findings)) @@ -87,7 +101,9 @@ def test_parse_file_issue2984(self): self.assertEqual(finding.unique_id_from_tool, "DASFMMFKLNKDSAKFSDLANJKKFDSNJSAKDFNJKDFS=") def test_parse_file_search(self): - with (get_unit_tests_scans_path("github_vulnerability") / "github_search.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github_search.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(2, len(findings)) @@ -102,7 +118,9 @@ def test_parse_file_search(self): self.assertEqual(finding.unsaved_vulnerability_ids[0], "GHSA-2qrg-x229-3v8q") self.assertEqual(finding.unsaved_vulnerability_ids[1], "CVE-2019-17571") self.assertEqual(finding.component_name, "log4j:log4j") - self.assertEqual(finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQyMDg2Nzc5NzY=") + self.assertEqual( + finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQyMDg2Nzc5NzY=", + ) with self.subTest(i=1): finding = findings[1] self.assertEqual(finding.title, "Deserialization of Untrusted Data in Log4j") @@ -111,11 +129,15 @@ def test_parse_file_search(self): self.assertEqual(finding.unsaved_vulnerability_ids[0], "GHSA-2qrg-x229-3v8q") self.assertEqual(finding.unsaved_vulnerability_ids[1], "CVE-2019-17571") self.assertEqual(finding.component_name, "log4j:log4j") - self.assertEqual(finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQ1NTE5NTI2OTM=") + self.assertEqual( + finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQ1NTE5NTI2OTM=", + ) def test_parse_file_search2(self): """Search result with more data/attributes""" - with (get_unit_tests_scans_path("github_vulnerability") / "github_search2.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github_search2.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(2, len(findings)) @@ -130,7 +152,9 @@ def test_parse_file_search2(self): self.assertEqual(finding.unsaved_vulnerability_ids[0], "GHSA-2qrg-x229-3v8q") self.assertEqual(finding.unsaved_vulnerability_ids[1], "CVE-2019-17571") self.assertEqual(finding.component_name, "log4j:log4j") - self.assertEqual(finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQyMDg2Nzc5NzY=") + self.assertEqual( + finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQyMDg2Nzc5NzY=", + ) with self.subTest(i=1): finding = findings[1] self.assertEqual(finding.title, "Deserialization of Untrusted Data in Log4j") @@ -139,11 +163,15 @@ def test_parse_file_search2(self): self.assertEqual(finding.unsaved_vulnerability_ids[0], "GHSA-2qrg-x229-3v8q") self.assertEqual(finding.unsaved_vulnerability_ids[1], "CVE-2019-17571") self.assertEqual(finding.component_name, "log4j:log4j") - self.assertEqual(finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQ1NTE5NTI2OTM=") + self.assertEqual( + finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQ1NTE5NTI2OTM=", + ) def test_parse_file_search3(self): """Search result with more data/attributes""" - with (get_unit_tests_scans_path("github_vulnerability") / "github_search3.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github_search3.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(2, len(findings)) @@ -160,7 +188,9 @@ def test_parse_file_search3(self): self.assertEqual(finding.component_name, "log4j:log4j") self.assertEqual(finding.cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") self.assertEqual(finding.file_path, "gogoph-crawler/pom.xml") - self.assertEqual(finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQyMDg2Nzc5NzY=") + self.assertEqual( + finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQyMDg2Nzc5NzY=", + ) with self.subTest(i=1): finding = findings[1] self.assertEqual(finding.title, "Deserialization of Untrusted Data in Log4j") @@ -171,11 +201,15 @@ def test_parse_file_search3(self): self.assertEqual(finding.component_name, "log4j:log4j") self.assertEqual(finding.cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") self.assertEqual(finding.file_path, "gogoph/pom.xml") - self.assertEqual(finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQ1NTE5NTI2OTM=") + self.assertEqual( + finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQ1NTE5NTI2OTM=", + ) def test_parse_file_search4_null_cvss_vector(self): """Search result with more data/attributes""" - with (get_unit_tests_scans_path("github_vulnerability") / "github_search4_null_cvss_vector.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github_search4_null_cvss_vector.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(2, len(findings)) @@ -192,7 +226,9 @@ def test_parse_file_search4_null_cvss_vector(self): self.assertEqual(finding.component_name, "log4j:log4j") self.assertEqual(finding.cvssv3, None) self.assertEqual(finding.file_path, "gogoph-crawler/pom.xml") - self.assertEqual(finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQyMDg2Nzc5NzY=") + self.assertEqual( + finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQyMDg2Nzc5NzY=", + ) with self.subTest(i=1): finding = findings[1] self.assertEqual(finding.title, "Deserialization of Untrusted Data in Log4j") @@ -203,7 +239,9 @@ def test_parse_file_search4_null_cvss_vector(self): self.assertEqual(finding.component_name, "log4j:log4j") self.assertEqual(finding.cvssv3, "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H") self.assertEqual(finding.file_path, "gogoph/pom.xml") - self.assertEqual(finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQ1NTE5NTI2OTM=") + self.assertEqual( + finding.unique_id_from_tool, "MDI4OlJlcG9zaXRvcnlWdWxuZXJhYmlsaXR5QWxlcnQ1NTE5NTI2OTM=", + ) def test_parse_cwe_and_date(self): with (get_unit_tests_scans_path("github_vulnerability") / "github_h2.json").open(encoding="utf-8") as testfile: @@ -229,7 +267,9 @@ def test_parse_cwe_and_date(self): self.assertEqual(finding.active, True) def test_parse_state(self): - with (get_unit_tests_scans_path("github_vulnerability") / "github_shiro.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github_shiro.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(1, len(findings)) @@ -238,7 +278,10 @@ def test_parse_state(self): with self.subTest(i=0): finding = findings[0] - self.assertEqual(finding.title, "Apache Shiro vulnerable to a specially crafted HTTP request causing an authentication bypass") + self.assertEqual( + finding.title, + "Apache Shiro vulnerable to a specially crafted HTTP request causing an authentication bypass", + ) self.assertEqual(finding.severity, "Critical") self.assertEqual(len(finding.unsaved_vulnerability_ids), 2) self.assertEqual(finding.unsaved_vulnerability_ids[0], "GHSA-f6jp-j6w3-w9hm") @@ -253,7 +296,9 @@ def test_parse_state(self): self.assertEqual(finding.is_mitigated, True) def test_parser_version(self): - with (get_unit_tests_scans_path("github_vulnerability") / "github-vuln-version.json").open(encoding="utf-8") as testfile: + with (get_unit_tests_scans_path("github_vulnerability") / "github-vuln-version.json").open( + encoding="utf-8", + ) as testfile: parser = GithubVulnerabilityParser() findings = parser.get_findings(testfile, Test()) self.assertEqual(1, len(findings)) @@ -266,6 +311,8 @@ def test_parser_version(self): self.assertEqual(finding.severity, "Critical") self.assertEqual(finding.component_name, "org.springframework:spring-web") self.assertEqual(finding.component_version, "5.3.29") + self.assertAlmostEqual(finding.epss_score, 0.00212, places=5) + self.assertAlmostEqual(finding.epss_percentile, 0.44035, places=5) def test_parse_file_issue_9582(self): with (get_unit_tests_scans_path("github_vulnerability") / "issue_9582.json").open(encoding="utf-8") as testfile: