From cc09eba22e68c212d5be96d2e081675bc84c42c7 Mon Sep 17 00:00:00 2001 From: DefectDojo release bot Date: Mon, 3 Aug 2026 16:09:06 +0000 Subject: [PATCH 001/110] Update versions in application files --- components/package.json | 2 +- docs/content/releases/os_upgrading/3.3.md | 7 +++++++ dojo/__init__.py | 2 +- helm/defectdojo/Chart.yaml | 8 ++++---- helm/defectdojo/README.md | 2 +- 5 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 docs/content/releases/os_upgrading/3.3.md diff --git a/components/package.json b/components/package.json index c6c93db992d..c2b83f57d86 100644 --- a/components/package.json +++ b/components/package.json @@ -1,6 +1,6 @@ { "name": "defectdojo", - "version": "3.2.0", + "version": "3.3.0-dev", "license": "BSD-3-Clause", "private": true, "dependencies": { diff --git a/docs/content/releases/os_upgrading/3.3.md b/docs/content/releases/os_upgrading/3.3.md new file mode 100644 index 00000000000..c857f839097 --- /dev/null +++ b/docs/content/releases/os_upgrading/3.3.md @@ -0,0 +1,7 @@ +--- +title: 'Upgrading to DefectDojo Version 3.3.x' +toc_hide: true +weight: -20260803 +description: No special instructions. +--- +There are no special instructions for upgrading to 3.3.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.3.0) for the contents of the release. diff --git a/dojo/__init__.py b/dojo/__init__.py index 811d1368e74..6cf8a333c13 100644 --- a/dojo/__init__.py +++ b/dojo/__init__.py @@ -4,6 +4,6 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app # noqa: F401 -__version__ = "3.2.0" +__version__ = "3.3.0-dev" __url__ = "https://github.com/DefectDojo/django-DefectDojo" __docs__ = "https://documentation.defectdojo.com" diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index 5a121f5e29d..970ed57945a 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v2 -appVersion: "3.2.0" +appVersion: "3.3.0-dev" description: A Helm chart for Kubernetes to install DefectDojo name: defectdojo -version: 1.9.44 +version: 1.9.45-dev icon: https://defectdojo.com/hubfs/DefectDojo_favicon.png maintainers: - name: madchap @@ -33,5 +33,5 @@ dependencies: # - kind: security # description: Critical bug annotations: - artifacthub.io/prerelease: "false" - artifacthub.io/changes: "- kind: changed\n description: chore(deps)_ update valkey _ tag from 0.24.1 to v0.24.4 (_/defect_/chart.yaml)\n- kind: changed\n description: chore(deps)_ update gcr.io/cloudsql__/gce_proxy _ tag from 1.38.1 to v1.38.2 (_/defect_/values.yaml)\n- kind: changed\n description: Bump DefectDojo to 3.2.0\n" + artifacthub.io/prerelease: "true" + artifacthub.io/changes: "" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 49ba3f0100a..9b28c829f4f 100644 --- a/helm/defectdojo/README.md +++ b/helm/defectdojo/README.md @@ -511,7 +511,7 @@ The HELM schema will be generated for you. # General information about chart values -![Version: 1.9.44](https://img.shields.io/badge/Version-1.9.44-informational?style=flat-square) ![AppVersion: 3.2.0](https://img.shields.io/badge/AppVersion-3.2.0-informational?style=flat-square) +![Version: 1.9.45-dev](https://img.shields.io/badge/Version-1.9.45--dev-informational?style=flat-square) ![AppVersion: 3.3.0-dev](https://img.shields.io/badge/AppVersion-3.3.0--dev-informational?style=flat-square) A Helm chart for Kubernetes to install DefectDojo From 54dff173d5bd64e060557cda448607cb7d6f51fd Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Mon, 3 Aug 2026 21:57:00 -0600 Subject: [PATCH 002/110] feat(parsers): add fifty-six file parsers for vendors with no importer (#15482) * feat(parsers): add Socket and Lacework parsers Both mirror the mapping the corresponding DefectDojo Pro connector already implements, so that a file import and an API sync produce findings that deduplicate against each other rather than two copies of everything. The scan type strings are byte-identical to what each connector reports, and the deduplication settings are copied from the connector's own configuration. Socket: one finding per alert, identity is the Socket alert key. Note Socket grades alerts low/middle/high/critical - "middle", not "medium"; guessing "medium" would silently drop every middle-severity alert to Info. Lacework: two shapes with different mappings. Container/image rows are static findings keyed by image; host rows are dynamic findings keyed by hostname, falling back to the machine id. Container rows report fix_available as an integer and host rows as a string, so testing one shape's type against the other would drop every host mitigation. A row Lacework reports as fixed or resolved is imported but not active. Fixtures are constructed from each vendor's documented schema with generic package scopes, registries and hostnames. * feat(parsers): add CrowdStrike Falcon Spotlight parser Mirrors pkg/tools/crowdstrike/connector/vuln_converter.go field for field, and uses the connector's ScanTypeSpotlight string verbatim, so a file import and an API sync deduplicate against each other instead of producing two copies of every finding. Dedup settings are copied from the connector's own config - including the fact that it lists unique_id_from_tool among the hash fields as well as pairing with unique_id_from_tool_or_hash_code. Deliberately does NOT claim the connector's separate "CrowdStrike:Detections - Connectors Import" scan type, which is a different shape; a test asserts that. Points worth noting, all mirrored rather than corrected: - The connector marks these findings NEITHER static NOR dynamic. Spotlight reads the Falcon agent's software inventory: it neither analyses source nor probes a running service. - Severity comes from the CVE, not the vulnerability. - CrowdStrike returns no discrete version field, so component_version is what remains of product_name_version once the normalized product name is stripped. - firstCWE takes the first entry parseable as CWE-; a list with none parseable leaves cwe at 0, which is the IntegerField default rather than None. The host is recorded via the nmap locations idiom (LocationData under V3_FEATURE_LOCATIONS, Endpoint otherwise) rather than the connector's protocol-relative "//" string, which exists only to survive DefectDojo's URI parsing. Tested through get_unsaved_locations so it passes in both modes. Fixtures are constructed from the documented combined-vulnerabilities schema with generic hostnames, private-range addresses and placeholder CVE ids. * feat(parsers): add FOSSA parser Mirrors pkg/tools/fossa/converter field for field, uses the connector's ScanType string verbatim, and copies its dedup settings, so a file import and an API sync deduplicate against each other. Covers both of FOSSA's issue categories - security vulnerabilities and licensing or quality issues - because the connector does. Three points that needed care: - An issue counts as a vulnerability when its type says so OR when it carries vulnerability-only fields (cve, vulnId, cvssVector). That belt-and-braces check is the converter's: a missing or renamed type must not silently downgrade a CVE to a licensing finding, which would also change its grading. - Severity for a vulnerability is FOSSA's own, but FOSSA reports "unknown" often enough that the CVSS v3 band fallback matters. Licensing and quality issues carry no severity at all, so the connector's type table is mirrored - including BOTH spellings of the risk_* types, since FOSSA's docs hyphenate (risk_empty-package) while fossa-cli's wire format uses underscores. - unique_id_from_tool is ":". One FOSSA issue can affect several projects and becomes one finding per DefectDojo product; those must not share a tool id. The parser reproduces this from the issue's own projects list, and falls back to the issue id alone when an export carries no project context - documented, since that case cannot reproduce the suffix. FOSSA is SCA, so a finding has no file or line; the dependency coordinates are its only location and are written into the description. References are accepted as either a bare string or a {url, title} object, which is what the connector's Reference.Link() handles. * feat(parsers): add Endor Labs parser Mirrors pkg/tools/endorlabs/connector/converter.go field for field, uses the connector's ScanType string verbatim, and copies its dedup settings, so a file import and an API sync deduplicate against each other. Endor Labs is reachability-aware SCA, and its reachability verdict is the one thing that distinguishes it from any other SCA tool. The connector promotes that verdict to the finding's IMPACT rather than leaving it in tags, and this does the same. The precedence is the connector's: a function-level verdict outranks a dependency-level one, and a definite verdict outranks a "potentially". Endor advisory text arrives as HTML from upstream advisories, and the connector never renders it. Its InertText is reproduced here: script and style content is dropped, block tags become newlines, runs of blank lines collapse, and the result is escaped. Go's html.EscapeString is matched byte for byte rather than using Python's html.escape, which spells the apostrophe entity differently. Other points mirrored rather than corrected: - CVSS prefers the v3 score but falls back to the v4 base score; without that a v4-only advisory would import with no score at all. - Vulnerability IDs are the primary identifier followed by Endor's aliases, deduplicated, so a GHSA and its CVE both land on the finding. - Component name falls back from the resolved dependency name to the package name. - Tags drop any value ending _UNSPECIFIED: those only record that Endor did not determine something. - Identity is the finding UUID, falling back to a vulnerability/component composite when Endor sends none. - Endor also reports secrets and other non-CVE findings with no vulnerability block at all; those import without CVE, score or reachability. Fixtures are constructed from Endor's documented findings schema with generic package names and a generic tenant namespace. * feat(parsers): add GitGuardian parser Mirrors pkg/tools/gitguardian/connector/converter.go field for field, uses the connector's ScanTypeName verbatim, and copies its dedup settings, so a file import and an API sync deduplicate against each other. Note GitGuardian pairs the PLAIN hash_code algorithm with a unique-id-only hash field set, unlike the other connector scan types added here - incident ids are stable, so nothing else is needed. One finding per incident, not per occurrence: an incident is one distinct exposed credential however many times it appears, and the occurrence count goes in the description. No secret value is imported. GitGuardian's incidents endpoint does not return the matched secret, and a test asserts that, so a future change that starts pulling occurrences cannot quietly begin copying credentials into the database. GitGuardian actively checks whether a credential still authenticates, which is the most actionable thing it reports. The connector spells that verdict out rather than leaving a bare enum, and marks the finding verified ONLY for "valid" - an unchecked credential is not evidence either way, and marking it verified would overstate what GitGuardian knows. The three unverified states (no_checker, not_checked, failed_to_check) share one narrative. Fixtures are constructed from the documented secret-incident schema with generic incident names and a generic dashboard host; none contains a credential-shaped value. * feat(parsers): add Codacy parser Mirrors pkg/tools/codacy/converter field for field, uses the connector's ScanType string verbatim, and copies its dedup settings, so a file import and an API sync deduplicate against each other. Codacy surfaces several underlying scanners through one security-items endpoint, so the parser flags a finding static or dynamic from the item's own scanType - only DAST looked at something running. Flagging all of them one way would misreport most. Points that needed care, all mirrored from the converter: - The vulnerable package is the LAST entry of the first non-empty dependency chain. The first entry is the project itself, so taking it would name the application as the vulnerable component on every SCA finding. A leading empty chain is skipped rather than treated as "no package". - Codacy's "cve" is a typed string documented as possibly holding several identifiers, so it is scanned for all of them and deduplicated; vuln_id_from_tool takes the first and falls back to Codacy's itemSourceId. - An item Codacy ignored as a FALSE POSITIVE imports with false_p set, so triaged noise does not go back in front of the team. Any other ignore reason ("acceptable risk") is a real finding somebody accepted and is NOT flagged. The reason comparison strips spaces so "False Positive" matches. - The converter dates a finding today when openedAt will not parse, so a finding always has a date. Mirrored; the test asserts a range so it cannot flake on a date rollover. The scanned application (or, for a container item, its image) is recorded via the locations idiom so it survives under either value of V3_FEATURE_LOCATIONS. * feat(parsers): add DeepSource parser Mirrors pkg/tools/deepsource/converter field for field, uses the connector's ScanType string verbatim, and copies its dedup settings, so a file import and an API sync deduplicate against each other. DeepSource reports two different things and the connector converts them differently, so both shapes are handled: static analysis issue occurrences, and dependency vulnerabilities from advisories. A bare array is classified per entry rather than per file, so a mixed export is not mis-mapped. The severity work is the interesting part. DeepSource grades EVERY issue CRITICAL/MAJOR/MINOR whatever the issue actually is - a missing docstring can be MAJOR - so the category decides which ladder applies. A security issue keeps its grade; a bug-risk, performance, typecheck or anti-pattern issue drops a step, because it describes a defect rather than a weakness. Applying one ladder to both would either inflate every lint finding or bury the real ones. A hit from the secrets analyzer is Critical regardless, since a committed credential is a committed credential. Advisories use a separate ladder: the CVSS v3 band when scored (bottoming out at Low, never Info), otherwise the severity word, accepting GitHub's MODERATE spelling of medium. Identifiers are the advisory id plus aliases, upper-cased and deduplicated. An advisory with no published fix says so explicitly rather than leaving the mitigation blank - "no fix published" is useful triage information. Note the connector grades STYLE, DOCUMENTATION and COVERAGE issues as Info and imports them. That is mirrored here rather than filtered, because parity with the connector is what stops findings duplicating; it is raised in the PR as a follow-up worth discussing against the connector instead. One bug caught by the clean-run fixture: presence of the occurrences or vulnerabilities KEY identifies the shape, not whether it has entries - a clean run legitimately reports both lists empty. * feat(parsers): add Probely parser Mirrors pkg/tools/probely/connector/finding_converter.go field for field and uses the connector's ScanType verbatim. That string is "Probely API Import" - it does NOT follow the " - Connectors Import" pattern the other connector scan types use, so it cannot be derived from the vendor name and a test asserts it. Probely reports severity as an INTEGER with only three values (10/20/30 -> Low/Medium/High, no Critical). Treating the number as a score or an index would misgrade every finding. Findings Probely records as fixed, invalid or accepted are skipped, matching the converter's IsIgnored. "retesting" is deliberately NOT skipped: a re-test means somebody is actively working the issue, so it is assumed still open. Skipping it would drop live findings whenever a re-test was queued. This scan type's dedup config pairs the plain hash_code algorithm with a wide field set that includes ENDPOINTS, so the parser always records the scanned origin - reduced to scheme, host and port as the converter does. An unpopulated endpoint would leave the hash computed over nothing and every rescan would reimport. Recorded via the locations idiom so it works in both V3_FEATURE_LOCATIONS modes, and a test pairs the assertion with the dedup field list so the two cannot drift apart. One parity bug caught by the field-mapping test: Probely names the definition's prose field "desc", not "description", so reading the latter silently produced an empty description. The insertion point is rendered as a readable label with the converter's acronym fixes (URL/JSON/GraphQL); plain title casing gives "Url" and "Json". * feat(parsers): add Detectify parser Mirrors pkg/tools/detectify/connector/finding_converter.go field for field and uses the connector's ScanType verbatim. Like Probely, that string is "Detectify Scan" - it does NOT follow the " - Connectors Import" pattern, so it cannot be derived; a test asserts it. Findings Detectify records as patched or false_positive are skipped, but an ACCEPTED RISK is deliberately kept and flagged risk_accepted rather than discarded - dropping it would lose the record that somebody accepted it. Detectify reports separate CVSS 2.0, 3.0 and 3.1 blocks. The converter prefers 3.1, falls back to 3.0, and ignores 2.0 because cvssv3 is a v3 field. A block counts as present when it carries a score OR a vector, so a vector-only entry is not discarded; the fixture gives all three blocks different values to prove the preference. Detectify has no dedicated CVE field, so identifiers are extracted from the finding title, the definition's title, description and risk text, and every reference name and link, then deduplicated in order. Endpoint preference is the converter's: the request URL, then the host with the location appended ONLY when it starts with "/" (otherwise it is not a path and concatenating would produce a nonsense host), then the location alone. Recorded via the locations idiom so it works in both V3_FEATURE_LOCATIONS modes. Detectify supplies no remediation prose, only reference links, so the mitigation points at them rather than being left empty. CWE arrives as a plain integer, not a CWE- string. * feat(parsers): add HackerOne and YesWeHack parsers Two more bug-bounty platforms, same rule as the rest of this PR: the scan-type string is byte-identical to the connector's and the dedup config is copied from the connector's own block. Both use the plain hash_code algorithm over unique_id_from_tool alone, because report ids are globally unique on each platform. HackerOne's API is JSON:API, and severity, weakness and reporter are RELATIONSHIPS rather than attributes - each nested under relationships..data.attributes. Reading them off the top level would leave every finding at Info with no CWE and no reporter, silently and with no error, so a test asserts the raw fixture really does not carry them anywhere else. An already-flattened export is accepted too. YesWeHack's workflow state carries real triage information and the converter translates each one rather than importing everything active: accepted -> active+verified, resolved/auto_close -> mitigated, wont_fix -> risk accepted, invalid/rejected -> false positive, duplicate -> duplicate, out_of_scope/informative -> inactive. An unrecognised state stays ACTIVE so a state YesWeHack adds later cannot silently close a finding. Its severity resolves through three sources in order - CVSS criticity, priority name, priority slug - because the criticity is often empty while a priority is set. * feat(parsers): add Intigriti parser Mirrors pkg/tools/intigriti/connector/finding_converter.go field for field, uses the connector's ScanType verbatim, and copies its dedup settings (plain hash_code over unique_id_from_tool alone - submission codes are globally unique). Three things needed care: - Intigriti's API lists submissions and serves each report separately, so the converter builds a finding from TWO objects and prefers the overview wherever both carry a field. An export may nest the detail under "detail" or carry the report on the entry itself; both are recognised, because missing the merged form would lose the CWE, impact, solution and the whole description body. - Intigriti grades its top tier "Exceptional", not "Critical". Mapping only "critical" would silently drop every top-tier submission to Info. - For a closed or archived submission the CLOSE REASON distinguishes a fix from a rejection: accepted risk -> risk accepted, duplicate -> duplicate, out of scope -> out of scope, and the rejection reasons (including Intigriti's terse "no") -> false positive, with anything else treated as fixed. Treating every closed submission the same way would mark rejected and duplicate submissions as mitigated, which reads as work completed. Researcher-submitted prose - proof of concept, impact, solution, asset and the intake question answers - is flattened to escaped plain text rather than rendered, matching the connector's InertText including Go's html.EscapeString entities. The connectors repo duplicates that sanitizer per tool rather than sharing it, so it is reproduced here rather than imported from another parser. * feat(parsers): add Bugcrowd connector-parity parser Mirrors pkg/tools/bugcrowd/connector/converter.go field for field, uses the connector's ScanTypeName verbatim, and copies its dedup settings (plain hash_code over unique_id_from_tool alone). Deliberately a SEPARATE directory from the shipped `bugcrowd` parser, which handles Bugcrowd's CSV export under the scan type "BugCrowd Scan" (capital C). Two formats, two scan types; a test asserts both so neither can start shadowing the other, and the CSV parser is untouched. Behaviour mirrored rather than chosen: - "triaging" is NOT importable. A submission mid-triage has no confirmed verdict, so importing it would put unvetted researcher claims into the queue. - not_applicable overrides the priority as well as closing the finding: a P1 that Bugcrowd then judged not applicable must not sit in the queue as Critical. - informational is imported but inactive, so a courtesy report is recorded without occupying the open queue. - P5 has no mapping and lands at Info, like anything unrecognised. - Titles are researcher-written, so colons and quotes become spaces and "@" becomes "at" - but only when the title needs it; whitespace collapses and an over-long title is cut to DefectDojo's 511-character column with an ellipsis. - States are normalised, so Bugcrowd's hyphenated and underscored spellings both match the importable set instead of being silently dropped. - A schemeless bug_url is prefixed with "//" so the host survives URI parsing. One faithfulness note: with no programme code in the export the tracker link contains a double slash, because the connector concatenates base + code + self link. Reproduced rather than tidied, and the test says why - the connector always has a code, so tidying here would be the only divergence for an export that carries one. * feat(parsers): add Cobalt.io connector-parity parser Mirrors pkg/tools/cobalt/connector/converter.go field for field, uses the connector's ScanTypeName verbatim, and copies its dedup settings (plain hash_code over unique_id_from_tool alone). A SEPARATE directory from the shipped `cobalt` parser, which handles Cobalt's CSV export under "Cobalt.io Scan". Two formats, two scan types; a test asserts both and the CSV parser is untouched. Three details that would each be wrong if guessed: - Cobalt nests each finding under "resource" but puts the human-facing deep link OUTSIDE it at links.ui.url. Reading the entry directly finds no fields; reading only the resource loses the link, which is the only route back to the pentest report. A test asserts the raw fixture really is shaped that way. - The date comes from the "created" entry in the finding's LOG, not created_at. Cobalt can carry a finding over from an earlier pentest, and then created_at is the carry-over date - the fixture has one created in January and carried over in July, so taking created_at would date it six months late. - CVSS takes the first entry whose version starts with 3, because Cobalt reports v2 and v3 side by side and cvssv3 is a v3 field. The fixture lists the v2 entry FIRST so a naive first-match would be caught. State handling is the connector's: only valid_fix, invalid and out_of_scope close a finding, so a DUPLICATE or an ACCEPTED RISK stays active and is merely flagged. new and triaging are the only unverified states. A state Cobalt adds later is not imported at all - every documented state is already in the importable set, so an unknown one means the API changed and skipping beats guessing. Impact and likelihood are numeric and ZERO IS A REAL SCORE, so presence is tested against None and the empty string rather than truthiness; ruff suggested the falsy shortcut here and it would have silently dropped the zero. * feat(parsers): add Harbor connector-parity parser Mirrors pkg/tools/harbor/converter field for field, uses the connector's ScanType verbatim, and copies its dedup settings (plain hash_code over unique_id_from_tool alone - the composed id already carries repository, artifact, vulnerability and package). Own directory, distinct from the shipped `harbor_vulnerability` parser ("Harbor Vulnerability Scan"); a test asserts both scan types differ. Two things a reader would not guess: - Harbor's scan endpoint keys the report by the SCANNER'S MIME TYPE, so a saved export is normally an object whose single key is "application/vnd.security.vulnerability.report; version=1.1". Expecting the bare report would reject every real export, so the envelope is unwrapped. - The artifact's identity is NOT in the report body at all. The connector supplies repository, tag and digest from the artifact it fetched, and they feed both the finding identity and the image context in the description. An export should carry them; without them the finding still imports with empty segments, which is the connector's own behaviour when the fields are blank. The digest is preferred over the tag because a tag can be moved to a different image, which would silently merge findings from two artifacts. Also mirrored: only a CVE id becomes a vulnerability id (Harbor reports GHSA and distro ids too, and a GHSA in the CVE field would have DefectDojo try to resolve it as one), and a missing description becomes "No description found" rather than an empty field that reads as a parser bug. * fix(parsers): drop duplicate-vendor parsers, fix DeepSource input shape Two corrections, both from review. 1. A parser is scoped to ONE uploaded run; a connector syncs an entire account's ongoing state. So a second parser for a vendor that already has one is redundant. Dropped bugcrowd_connectors, cobalt_connectors and harbor_connectors - DefectDojo already ships bugcrowd (CSV), cobalt (CSV) and harbor_vulnerability, and those cover the file-import case. Their settings entries are reverted too. That also removes the export conventions I had invented purely to fake whole-account context a single-run file does not have: Harbor's repository/tag/digest and Bugcrowd's program_code. Quay keeps its parser - DefectDojo has none for Quay - and takes the plain `quay` directory rather than a _connectors suffix. 2. DeepSource has NO REST API; everything goes through POST /graphql/. The envelope the parser accepted ({"run":..., "occurrences": [...]}) mirrored the connector's internal Go model and was NOT something a DeepSource user could produce, so the parser could not do its job. It now reads the real saved GraphQL response - data.repository with each collection behind a GraphQL connection (edges[].node) - and the fixtures are that shape. A test pins the envelope so it cannot regress to the invented one. Only the wrapper was wrong: every field mapping already used DeepSource's camelCase names (cvssV3BaseScore, epssScore, beginLine), and 26 of 27 existing tests passed unchanged against the real envelope. 14 parsers remain, one per vendor, none duplicating a shipped parser. * feat(parsers): add Google Cloud SCC parser Mirrors pkg/tools/googlescc/connector/converter.go field for field, uses the connector's ScanTypeName verbatim, and copies its dedup settings (plain hash_code over unique_id_from_tool alone - the finding's full resource name is globally unique across the organisation). The shape detail worth knowing: SCC's ListFindings pairs each finding with the resource it was found on as SIBLINGS, not nested. Both halves matter - the category and severity are on the finding, while the display name and type that make it readable are on the resource. Reading the result as if it were the finding would import nothing; reading only the finding half would lose the resource context. A test asserts the raw fixture really is shaped that way. Also mirrored: the title falls back from " - " to the category alone and then to a constant, because SCC does not always set a category and an empty title is useless in the finding list; SEVERITY_UNSPECIFIED falls through to Info; and CVE/CVSS live two objects deep under vulnerability.cve and exist only on vulnerability-class findings, so a misconfiguration or observation finding legitimately has neither. The score is recorded only when above zero. First of the vendors that have no DefectDojo parser of any kind. * feat(parsers): add Fairwinds Insights parser Mirrors pkg/tools/fairwinds/converter field for field, uses the connector's ScanType verbatim, and copies its dedup settings. Two things that would be wrong if guessed: - Fairwinds normalises severity to a 0.0-1.0 FLOAT, not a word and not a CVSS score. The breakpoints are its own (0.9/0.7/0.4/0.1); reading the number as CVSS would put every finding at Info. A malformed value falls to Info rather than erroring. - Fairwinds aggregates Polaris, Trivy, OPA, kube-bench and Goldilocks into ONE action-item stream, so an item may be about a container image or a Kubernetes manifest. The component is the image and tag when there is one, otherwise the resource name - a single rule would mislabel half the findings. The originating tool becomes a tool: tag. Also mirrored: a Fixed item imports closed rather than active; the resource line is namespace/kind/name with an optional container qualifier, skipping missing segments; CVEs come from the prose since Fairwinds has no CVE field; and the cluster tag is added unconditionally, so an item with no cluster gets a bare "cluster:" tag - reproduced rather than tidied, since tidying would be a divergence between a file import and an API sync. JSON keys are PascalCase (Title, Severity, ResourceKind). * feat(parsers): add AccuKnox parser Mirrors pkg/tools/accuknox/converter field for field, uses the connector's ScanType verbatim, and copies its dedup settings. The defining problem: AccuKnox returns container, IaC, cloud-posture and runtime findings through ONE endpoint with different column names per type, and does not publish that part of its schema. The converter therefore probes a list of candidate keys per field, and so does this parser - assuming one set of names would silently import empty Info findings for every type but one. Every candidate is also tried with AccuKnox's "vulnerability__" column prefix, which some rows use. The fixture deliberately mixes all three conventions so a regression shows up as a failing test rather than blank findings. Status handling: only fixed, accepted-risk and duplicate close a finding; the working states (in progress, waiting for 3rd party, exception requested, waiting for verification) stay OPEN so work in progress is not hidden. A finding is verified unless the status is empty or "potential" - note a blank status counts as verified, the opposite of a plain truthiness check. A row AccuKnox has ignored is marked out of scope rather than dropped, and that flag arrives as a boolean or a string, so both are handled. CVEs come from the CVE column, which may be an array, falling back to the title since AccuKnox often carries the identifier only there. * style(accuknox): clear three ruff errors that were left outstanding I committed the parser with these unfixed because I checked lint with 'tail -1', which showed only the trailing help line and hid 'Found 3 errors'. Check the error count, not the last line. All three are rewrites with no behaviour change; the 24 tests still pass. The verified rule is now spelled 'status not in {"", "potential"}', which reads closer to the connector's intent than the chained comparison did. * feat(parsers): add Halo Security parser Mirrors pkg/tools/halosecurity/converter field for field, uses the connector's ScanType verbatim, and copies its dedup settings. Three things that matter: - Halo splits an issue across TWO calls. The list row carries the issue, target and status; the description, category, CVEs and PCI flag exist ONLY on a per-issue detail. A row-only import would produce findings with no prose at all, so the parser merges a detail supplied as a top-level map keyed by issue id, a top-level array, or nested on the row. - Severity is an INTEGER level with 5 highest - the inverse of a priority number. Reading it as a score, or assuming 1 is worst, would invert the ladder. The row's level wins, falling back to the detail's, because the list response sometimes omits it. - Identity is ":", because Halo reports the same issue once per affected host. Keying on the issue alone would collapse two hosts into one finding - and their statuses often differ, which the fixture covers. This scan type's dedup config includes ENDPOINTS, so the scanned host is always recorded; an unpopulated endpoint would leave the hash computed over nothing. A test pairs that assertion with the dedup field list so the two cannot drift. Also mirrored: only confirmed/fixing/fixed count as verified, since a new or investigating issue has not been confirmed by anyone; Halo's literal "Nobody" assignee is not reported; and the finding is dated today because Halo's list response carries no discovery date. * feat(beagle): add Beagle Security file parser Adds a file parser for Beagle Security DAST reports, matching the scan type "Beagle Security - Connectors Import" so a file import and an API sync deduplicate against each other instead of producing two copies of every finding. Beagle documents the report-level keys and the occurrence block but not the names of the per-finding fields, and their sample report omits the finding array, so the parser reads every field from the same alias set the API path uses and locates the finding array by name or, failing that, by shape. - one finding per occurrence; "Fixed" (the only documented status) imports as mitigated, every other status as open - severity label first, falling back to grading a bare CVSS score against the 9.0 / 7.0 / 4.0 floors; an unrecognised label becomes Info and is kept as a tag - accepts both the API envelope, which carries the report as a JSON string, and the report body itself - records the tested URL, since this scan type's hash includes endpoints 37 tests, four sample reports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(nightfall): add Nightfall AI file parser Adds a file parser for Nightfall AI DLP violations, matching the scan type "Nightfall AI - Connectors Import" so a file import and an API sync deduplicate against each other instead of producing two copies of every violation. Nightfall needs two calls per violation - the violation, then the detections that make it up - and a detection carries no violation id, so the parser accepts the detections keyed by violation id or nested on each violation. Without them a finding loses its evidence, its credential verdict and its severity. - a credential Nightfall verified as live is Critical whatever the policy risk says; otherwise the risk label is used and an unrecognised one becomes Info - PENDING imports as active but unverified, EXPIRED as out of scope - location, permalink and exposure note are per-integration mappings, mirroring the connector; GitHub violations are the only ones with a code location - only redacted detection text is read, so no secret value is imported 39 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(fleet): add Fleet vulnerabilities and policies file parsers Adds two file parsers for Fleet host exports, matching the scan types "Fleet:Vulnerabilities - Connectors Import" and "Fleet:Policies - Connectors Import" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. They are two parsers because Fleet's API models software CVEs and policy results as different things: the connector imports them under two scan types with different deduplication keys, one hashing the component and the other the policy. Each parser reads the same host export and ignores the other's half. Vulnerabilities: - a finding per host per software per CVE; identity ":::" - an unscored CVE is Medium, not Info - Fleet enriches from the NVD, so no score means "not scored yet"; an explicit zero is Info - CISA KEV membership is flagged and tagged but does not change the severity - scores may arrive as numbers or numeric strings Policies: - only a failing policy is a finding; passing, result-less and unnamed ones are skipped - High when Fleet marks the policy critical, Medium otherwise - Fleet has no other severity signal for policies - the policy query is included so a reviewer can see what was checked 43 tests, six sample exports, two docs pages, and four settings entries. Co-Authored-By: Claude Opus 5 * feat(elastic-security): add CNVM, posture and detections file parsers Adds three file parsers for Elastic Security exports, matching the scan types "Elastic Security:CNVM - Connectors Import", "Elastic Security:Posture - Connectors Import" and "Elastic Security:Detections - Connectors Import" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. Elastic returns all three kinds of document from the same _search API with the same ECS asset objects, and each is imported under its own scan type with its own deduplication key - the component for CNVM, the rule for the other two. So there are three parsers, each claiming only its own documents: one export can be uploaded three times without a document landing under the wrong key. The shared document walk and asset rendering live on the CNVM module, the way the shipped Invicti parser extends the Netsparker one. CNVM: - Elastic's severity label wins over the CVSS score; an unrecognised label falls back to grading the score, and only a v3 base reaches the cvssv3_score field - identity is the Elasticsearch document id, which is stable across syncs Posture: - only a failing, named benchmark rule is a finding - an unrecognised label is Medium, not Info - there is no score to fall back on - a description that merely repeats the rationale is not printed twice Detections: - alerts are read from kibana.alert or the older top-level signal object - imported as neither static nor dynamic, with a triage instruction rather than a fix: a detection is observed activity, not a defect with a patch - Elastic's 0-100 risk score is reported, not converted into a severity 62 tests, nine sample exports, three docs pages, and six settings entries. Co-Authored-By: Claude Opus 5 * fix(parsers): guard every endpoint host against DefectDojo's validator Endpoint.clean() accepts a host of letters, digits, dot, hyphen, underscore or plus - or an IP address - and raises ValidationError otherwise. Because it raises, one unusable host fails the WHOLE import rather than the one finding. Codacy hit exactly that in CI: a container item's affectedTargets is an image reference, so "registry.example.com/generic-app" went into the host field and took the import down with it. It is now parsed, giving host + path, which is what DefectDojo does with the endpoint string the connector sends. Two more parsers were hand-splitting a URL in a way that left "host:port" in the host field - Halo Security and YesWeHack - and both now parse instead. Every parser that records a host also checks it first and drops the endpoint rather than raising, because the values are free text: a Fleet display name is often "Someone's MacBook", and an Elastic cloud resource name can be a path. The value still appears in the description, so nothing is lost. Nine parsers touched: beagle, codacy, crowdstrike_spotlight, elastic_security_cnvm (shared by the three Elastic scan types), fleet_policies, fleet_vulnerabilities, halosecurity, probely, yeswehack. Nine new tests pin the behaviour, including IPv6 hosts, which the pattern rejects but the IP check accepts. Co-Authored-By: Claude Opus 5 * fix(detectify): assert an unset location path as falsey, not None An unset path is "" on the URL location model (CharField(blank=True)) and None on Endpoint, so asserting either one specifically passes under one value of V3_FEATURE_LOCATIONS and fails under the other. This test asserted None and so failed the locations leg in CI while passing the endpoint leg. The local harness had the same coercion, which is why it did not reproduce; the harness now keeps "" as the real model does, and reproduced this failure exactly and no others. Co-Authored-By: Claude Opus 5 * feat(action1): add Action1 file parser Adds a file parser for Action1 vulnerability exports, matching the scan type "Action1 Scan" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. Note the scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived, and a test asserts the derived form is not claimed. Action1 describes a finding across two calls - the vulnerability catalogue and the machines each CVE affects - so the parser accepts the affected endpoints keyed by CVE or nested on the vulnerability. A catalogue entry nothing is running produces no finding, mirroring the connector. - identity is "action1--": one CVE on three machines is three findings, and the endpoint's own copy of the software wins because the installed version differs per machine - Action1's "score" is a word, not a number; it is the fallback when base_severity is absent, and an unrecognised bucket is Info - mitigation lists Action1's available updates and is left empty when it knows of no patch, rather than filled with generic advice - the machine name is recorded as an endpoint only when it can be a host; Action1 names are free text 28 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(datadog): add Datadog Cloud Security file parser Adds a file parser for Datadog Cloud Security findings, matching the scan type "Datadog Cloud Security" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived. Datadog returns misconfigurations, library and code vulnerabilities, attack paths, identity risks and API-security findings through one endpoint, told apart only by finding_type - so this parser decides static versus dynamic per row rather than per file. Its attributes are also nested twice, and reading the outer object as the finding yields nothing. - three independent signals mean "already dealt with" and all three are honoured: a muted/resolved/auto_closed status, an explicit workflow mute, and a compliance evaluation that passed - base_severity is deliberately ignored; the adjusted severity is the graded one - dates are unix milliseconds, and CVSS comes from the base block with the adjusted block as fallback, both values taken together - vulnerability ids come from the advisory and from the prose, since Datadog puts them in different places per finding type - tags are deduplicated but not sorted, matching the connector 26 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(escape): add Escape file parser Adds a file parser for Escape API-security scans, matching the scan type "Escape - Connectors Import" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. Escape nests issues under the scan that produced them and the connector reads an application's latest scan, so the parser accepts a scan, an application carrying one, an applications response, or the issue list itself. - this scan type's hash includes endpoints, so the tested URL is always recorded with its scheme, port, path and query - the endpoint line carries the method, because the same URL behaves differently per verb; the method tag is uppercased while the description keeps Escape's casing, matching the connector - CWE is read from "CWE-89" or a bare number, and an unparseable value still appears in the description - mitigation is left unset when Escape has no remediation, rather than filled with generic advice 22 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(insightappsec): add Rapid7 InsightAppSec file parser Adds a file parser for Rapid7 InsightAppSec vulnerabilities, matching the scan type "Rapid7 InsightAppSec - Connectors Import" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. InsightAppSec names a vulnerability only by the id of the attack module that found it, so the readable title, the rule identity and the description prose all come from a separate module-metadata call; the parser accepts it as a list, a map, or the module endpoint's own response. - only UNREVIEWED and VERIFIED rows are imported, so a reimport closes the remediated, duplicate, ignored and false-positive ones - status and severity are matched case-sensitively against Rapid7's own uppercase enums, and the raw label is kept as the severity justification - this scan type hashes the unique id ALONE - no title, no severity - because the vulnerability id is stable and a volatile field would split a regraded finding - evidence is flattened to inert text, matching Go's EscapeString byte for byte, and only the first three entries are printed - CVSS is recorded only when the vector really is v3 28 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(intruder): add Intruder file parser Adds a file parser for Intruder issues, matching the scan type "Intruder API Import" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived. Intruder separates an issue from its occurrences - the targets it was found on - and the occurrence is the finding, so an issue with none produces nothing. Its own issue object carries "occurrences" as a URL string rather than a list, so the parser accepts that second call keyed by issue id or nested on the issue, and a test asserts the unexpanded URL is not mistaken for data. - this is the one connector scan type using the PLAIN hash_code algorithm, with the occurrence id inside the hash fields rather than paired with them - snoozing is how Intruder records triage: FALSE_POSITIVE and the two risk-acceptance reasons map to flags, and an unrecognised reason leaves the finding inactive with neither rather than guessing - the occurrence's CVSS score wins over the issue's, since the same weakness scores differently per target - a port of "0" and a target that cannot be a host are both left unrecorded 27 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(nowsecure): add NowSecure file parser Adds a file parser for NowSecure mobile-app assessments, matching the scan type "NowSecure" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type is the bare vendor name rather than the " - Connectors Import" pattern, so it is copied. NowSecure reports every check it ran, so only rows it marks as affecting the app and not hidden are imported. One assessment runs both a static and a dynamic analysis of the same app, so static-versus-dynamic is decided per finding, and an unrecognised analysis type leaves both flags at their default rather than guessing. - identity is "nowsecure-[-]", falling back to a slug of the title when there is no check id - vulnerability identifiers are SORTED and deduplicated case-insensitively, which is this connector's extractor behaviour and differs from the order-preserving path the others use - the assessment supplies the date and platform, which the finding rows lack - the CVSS score is set unconditionally, so an unscored finding lands as 0.0 22 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(vanta): add Vanta file parser Adds a file parser for Vanta compliance exports, matching the scan type "Vanta Compliance" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived. A Vanta finding is a (test, failing entity) pair: the test is the control and the entity is the resource failing it, so the parser accepts the failing entities keyed by test id or nested on the test, in either the paged results.data wrapper or a bare list. A test with no failing entity is the control working and produces nothing. - severity is always Medium: Vanta has no scale, and Info would read as non-actionable when a failing control is actionable by definition - that makes component_name load-bearing - it is the failing entity, and the hash needs it to keep two resources failing one control apart - only FAILING entities are imported; an entity with no status is taken at its word, since the connector only ever receives failing ones - the date is when the entity started failing, falling back to the test's flip date 21 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(wallarm): add Wallarm file parser Adds a file parser for Wallarm API-security vulnerabilities, matching the scan type "Wallarm API Security" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived. - the threat level arrives in ONE field as either a number or a word, depending on which API answered, so both ladders are needed; 5 is the most severe, which is the one part of the mapping the vendor does not document and is worth confirming live - closed and false-positive rows are skipped - identity is "wallarm-", falling back to the wid and then the location, which is last because it is the only fallback that is not an id - only an absolute path is appended to the endpoint: Wallarm reuses the path field for a parameter location on some vulnerability types - mitigation is Wallarm's exploit example rather than advice - the only remediation-shaped field it has - and identifiers are sorted and deduplicated case-insensitively, matching the connector's extractor 25 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * fix(nowsecure): an unset analysis type lands as dynamic, not neutral DefectDojo defaults static_finding to False and dynamic_finding to TRUE, so a finding that leaves both alone is recorded as DYNAMIC rather than as neither. The NowSecure parser leaves them alone for an analysis type the connector does not recognise - which is correct, because the connector's own findings behave the same way when they reach DefectDojo - but the test asserted two Falses and the docs page called it "neither flag set". Both now state what actually happens. Forcing two Falses instead would have been the wrong fix: it would make a file import and an API sync disagree about the same finding. The local harness stubbed every boolean as None, which is why this passed locally and failed on all four CI legs. It now mirrors the real defaults - active and dynamic_finding True, the rest False - and reproduced this failure exactly while surfacing no others across all 33 parsers. Co-Authored-By: Claude Opus 5 * feat(bright): add Bright Security file parser Adds a file parser for Bright Security DAST scans, matching the scan type "Bright - Connectors Import" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. - this scan type's hash includes endpoints, so one is always recorded: the entry point Bright attacked, falling back to EVERY affected resource, because Bright reports one issue against several when the weakness is reachable from more than one URL - the request and response go in fenced code blocks - raw HTTP captured from the target, which must not be read as markup and which a reviewer needs verbatim - CWE reads "CWE-79" or a bare number, and an unparseable value still shows in the description; the CVSS score may arrive as a number or a numeric string - an entry point that cannot be a host is left out of the endpoints rather than failing the whole import 24 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(defender-for-cloud): add Microsoft Defender for Cloud file parser Adds a file parser for Defender for Cloud sub-assessments, matching the scan type "Microsoft Defender for Cloud - Connectors Import" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. This is a different product from Defender for Endpoint, which DefectDojo already parses as ms_defender. Defender returns every sort of sub-assessment through one endpoint, and the same field means different things in each: a container finding puts the vulnerable package under softwareDetails while a server finding flattens it into softwareName. Reading only one shape would leave every finding of the other kind with no component, which is the field a reviewer patches. - only Unhealthy sub-assessments are imported, so a reimport closes resolved ones; SQL baselines and posture checks are excluded by resource type, and an UNFAMILIAR type is decided by whether it carries a CVE - so a new Defender scanner is not dropped silently - the highest CVSS base score wins, and only a v3 base reaches the v3 field; the justification records which version it was - CVE ids are matched anchored, so "supersedes CVE-2000-0009" in a reference title does not contribute another finding's identifier - the TVM cve field is accepted as a list, an object or a bare string - the title appends the package only when the display name is a bare CVE - deduplication is the ARM id alone: it already encodes subscription, resource and finding 25 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(akto): add Akto file parser Adds a file parser for Akto API-security issues, matching the scan type "Akto Scan" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived. Akto runs every test against every endpoint it knows, so neither alone identifies a finding: the identity is "akto----" and both the endpoint and the test are in this scan type's hash fields. Akto has no package, so component_name is " " - the tested endpoint is what the component slot means here. - IGNORED is how a reviewer marks a false positive; FIXED is inactive but NOT flagged, because "fixed" is not a judgement about whether it was real - a relative apiUrl is not recorded as an endpoint - the connector does not invent a host - but it is still the component and in the description - CVE ids come from Akto's free-text field, sorted and deduplicated case-insensitively 22 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(holm-security): add Holm Security file parser Adds a file parser for Holm Security vulnerabilities, matching the scan type "Holm Security Scan" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived. Holm scans two ways and only the web class exercises a running application, but the class is a property of the scan rather than the row - so an export states it as a top-level "class", and an absent one means static, matching the connector's default. - the severity NAME wins and the numeric level is only the fallback, so an unfamiliar name does not become Info while a usable level sits beside it; 4 is the most severe, the inverse of a priority number - the identity carries the asset and port because Holm reports one weakness per host and per listening port; a port of zero is left out rather than recorded as zero - the endpoint is Holm's URL alone - the separately-reported detected_port is in the identity and description instead, so no endpoint is invented - the CVSS base wins over the score, and the date prefers the last detection 25 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(klocwork): add Klocwork file parser Adds a file parser for Klocwork issues, matching the scan type "Klocwork Scan" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived. Klocwork's search endpoint answers with NDJSON - one issue per line, not an array - so that is the shape read first; a JSON array, an object with an issues list, and a single issue object are accepted for an export somebody reshaped. The trailing summary line is skipped by testing for its key rather than parsing it, and a search that matched nothing answers with that line ALONE, which is an empty result rather than a malformed file. - the severity CODE is the inverse of a score: 1 is Critical, and codes 5-10 are Klocwork's informational tiers - numbers may arrive quoted; both forms are read, because the connector's decoder silently skips a line it cannot parse and would report a clean empty sync - Ignore, Not a problem and Filter are false positives; the deferred states the connector's query selects stay ACTIVE, since a deferred finding is still a finding - file_path and the checker are both in the hash, so one checker in two files is two findings - dates are unix milliseconds 24 tests, four sample exports (three NDJSON, one reshaped array), docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * feat(qwiet): add Qwiet AI file parser Adds a file parser for Qwiet AI findings, matching the scan type "Qwiet Scan" so a file import and an API sync deduplicate against each other instead of producing two copies of everything. The scan type does not follow the " - Connectors Import" pattern, so it is copied rather than derived. Qwiet carries the interesting metadata as a LIST of key/value tag objects rather than as fields - the CVE, package URL, CVSS score, CWE category and reachability verdict all live there - so each is read by key. Looking for fields of those names would find nothing at all. - reachability is recorded as the severity justification rather than changing the grade, so a reviewer can see why two findings of equal severity differ in urgency - a dependency finding with related_findings is reachable even with no reachability tag: those related findings ARE the path Qwiet traced through the application - the package URL is reduced to its last segment, because the namespace before it is the group rather than the artefact a component matches on - only the first file location becomes file_path and line, since a data-flow finding spans several files and DefectDojo has one path; the rest stay in the description, and an unparseable line keeps the path - the hash spans file_path, cwe AND component_name because Qwiet reports both code and dependency findings 23 tests, three sample exports, docs page, and the two settings entries. Co-Authored-By: Claude Opus 5 * fix(holm_security): stop asserting a port the locations model infers The locations model fills an unset port in from the protocol (URL.clean_port reads DEFAULT_PORTS), so an https URL naming no port is port 443 there while Endpoint leaves it None. The test asserted the port was falsy against a fixture whose detected_port is 443 on an https URL - a number both sources produce - so it passed with V3_FEATURE_LOCATIONS off and failed with it on. Assert against the fixture row whose detected_port is 8443 on a URL naming no port instead: a value neither the URL nor the scheme default can produce, so it distinguishes the two sources in both modes. The scheme-default case keeps its own test, covering the description and identity where the port does belong. The parser is unchanged - the connector puts only the URL in the endpoint. * feat(automox,bigid): add Automox and BigID file parsers Both mirror their connector's finding converter field for field, and both report the connector's own scan type verbatim so a file import and an API sync deduplicate against each other rather than producing two copies of everything. Automox reports a missing patch and the device it is missing on from two different endpoints, so an export carries both lists and they are joined on the package's server_id. A package whose device is absent is still a finding: the connector's device lookup is a map read that can miss, and it converts anyway. A row with no usable id is dropped, because the id is the whole identity and every row without one would collapse onto "automox-0". BigID reads sensitive data at rest, so only the case identity, its policy and data-source context and the COUNT of affected objects are carried. No sample, preview or value of the data itself is read, even when the export contains one - covered by a fixture that includes those fields and a test that asserts none of them reach the finding. * feat(calicocloud,dragos): add Calico Cloud and Dragos file parsers Both mirror their connector's finding converter field for field and report the connector's own scan type verbatim, so a file import and an API sync deduplicate against each other rather than producing two copies of everything. Calico Cloud serves the image list and each image's vulnerabilities from two endpoints, so an export carries both - nested in the image or keyed by image id. An image whose scan result is still "Unknown" is skipped entirely, as the connector does: its results are unfinished, and importing them would record a partial scan as a complete one. Severity comes from CVSS rather than Calico's Pass/Warn/Fail verdict, whose thresholds are per-tenant configuration. Dragos grades on its own 0-5 scale where 5 is the most severe - the inverse of a score - so CVSS wins where both exist and the scale is read as a scale, not as a score. Its OT exploitability intel is recorded as the severity justification rather than moving the severity. pera_level is absent rather than zero when unknown, and Purdue level 0 is a real level (the physical process layer), so the two do not render alike. * feat(finitestate): add Finite State file parser Mirrors the connector's finding converter field for field and reports its scan type verbatim, so a file import and an API sync deduplicate against each other. The VEX status carries the real semantics. NOT_AFFECTED is a product team asserting the vulnerability does not apply to this build, so it lands inactive and out of scope rather than active - leaving it active would put an answered question back in the queue on every import. It is additionally a false positive when the justification says the vulnerable code is not there to be reached; a justification like inline mitigations means the flaw is real but handled, which is out of scope and NOT a false positive. Anything unrecognised stays active, the safe direction to be wrong in. "unknown" is a value the platform actually uses and means Info, so it does not fall through to the CVSS-derived severity - treating it as missing would silently upgrade every unscored finding. EPSS is per-CVE, so the highest is taken with its own percentile rather than mixing one CVE's score with another's percentile. This scan type has no hashcode field list in the connector settings, so none is registered here: it deduplicates with DefectDojo's default algorithm, which is what the connector's own findings already do. * feat(fortytwocrunch): add 42Crunch file parser Mirrors the connector's converter field for field and reports its scan type verbatim, so a file import and an API sync deduplicate against each other. 42Crunch produces two reports for the same API and the connector converts both under one scan type: a Security Audit of the OpenAPI definition (static, one finding per issue occurrence) and a Conformance Scan of the running API (dynamic, one per scan issue). A file is one or the other, so the shape decides. Neither report stores its text inline - an audit occurrence's pointer and a scan issue's description and location are integer indexes into the report's own lookup tables, so each is resolved. An out-of-range index resolves to nothing rather than failing the import, and an audit identity then keeps the raw index, without which two occurrences of one issue at no resolvable location would collapse into a single finding. Scan descriptions are templates filled from a separate parameter list, one substitution per parameter. A scan issue's own id is a per-scan UUID, so the identity uses the operation plus the check index - stable for the same issue across scans. The API id prefix that every connector identity carries is not in a downloaded report, so a wrapper may supply it; the docs explain that findings will not deduplicate against synced ones without it. * test: compare default dates against the UTC date, not the local one Finding.date defaults to get_current_date(), which is timezone.now().date() - the UTC date, since USE_TZ is on and TIME_ZONE is "UTC". Four of the new tests compared it against date.today(), the LOCAL date, which agrees only while both happen to fall on the same day. The other fourteen parser tests on this branch already use the UTC form; this makes these four match. * feat(hiddenlayer): add HiddenLayer model-scan file parser Mirrors the connector's finding converter field for field and reports its scan type verbatim, so a file import and an API sync deduplicate against each other. HiddenLayer reports SARIF and DefectDojo already parses SARIF - but importing through the generic parser records the findings under the "SARIF" scan type, where they would not deduplicate against the connector's. The mapping here is the connector's, which itself mirrors dojo/tools/sarif/parser.py. Three SARIF behaviours carry the semantics. A result whose kind is anything other than "fail" is skipped: a pass or informational result is not a finding. An absent kind means failure, per the specification. A result carrying suppressions is inactive AND a false positive, because SARIF suppression is a reviewer saying it does not count - inactive alone would leave it in the open count. And a result with no level is Medium, not Info: level is optional, and a tool that omits it is not saying the result is harmless. The scan id prefix that every connector identity carries is not in a downloaded log, so a wrapper may supply it; the docs explain that findings will not deduplicate against synced ones without it. * feat(netrise): add NetRise firmware-analysis file parser Mirrors the connector's finding converter field for field and reports its scan type verbatim, so a file import and an API sync deduplicate against each other. NetRise answers GraphQL in Relay shape, so every row arrives wrapped in a "node"; the edges are unwrapped and an already-flattened row is used as-is. The identity is scoped to the firmware artifact, so the same CVE in two builds stays two findings - merging them would hide that one of the two is still shipping. An unrecognised severity word falls through to the CVSS score rather than to Info, so a finding graded with an unfamiliar word still lands where its score says. Reachability and CISA KEV listing are recorded as the severity justification and as tags rather than moving the severity, which would make the finding disagree with an API sync of the same data. * feat(nozomi,ostorlab): add Nozomi Vantage and Ostorlab file parsers Both mirror their connector's finding converter field for field and report the connector's own scan type verbatim, so a file import and an API sync deduplicate against each other. Nozomi mirrors the connector's QUERY as well as its converter: the connector asks Vantage for "node_cves | where resolved != true", so a resolved record is something an API sync can never produce. A hand-run query can return them, and importing one would open a finding Nozomi has already closed. Nothing is recorded as a dynamic finding, because Vantage builds its inventory passively - marking a finding dynamic would imply the OT device had been probed. Ostorlab reports checks that PASSED with a SECURE rating; those are skipped, since importing one would file a passing check as a finding. It scans mobile binaries and web targets from one platform, so static versus dynamic is decided per scan by asset type rather than for the tool. It exposes no CVE field at all, so identifiers are extracted from the prose and the references. Two mappings are mirrored rather than corrected, and raised in the PR as connector-side follow-ups: Ostorlab grades an IMPORTANT rating as Info, and its copied hashcode list names a component_name that Ostorlab never reports, so that field hashes as empty. * feat(parasoft,uptycs): add Parasoft DTP and Uptycs file parsers Both mirror their connector's finding converter field for field and report the connector's own scan type verbatim, so a file import and an API sync deduplicate against each other. Parasoft DTP grades with a numeric severity that runs the opposite way from a score - 1 is critical, 5 is informational - so reading it as a score would invert the entire ladder. The identity prefers DTP's violation hash, the value that stays stable as a file is edited around the violation; the rule-plus-file fallback would merge two violations of one rule in one file, so it comes last. Uptycs reports one row per vulnerable package listing every CVE against it, so one row fans out to one finding per CVE - each is separately fixable. A row naming no CVE still becomes a single package finding. The list arrives as an array or as a comma-separated string, which is split: reading it whole would produce one finding titled after every CVE at once. Every finding from one row shares the row's score, because the row carries one score for the package rather than one per CVE. * feat(venafi): add CyberArk Certificate Manager file parser Mirrors the connector's finding converter field for field and reports its scan type verbatim, so a file import and an API sync deduplicate against each other. Neither edition returns a compliance verdict, so certificate posture is COMPUTED from each certificate's own attributes: expired, expiring within thirty days, a weak RSA key, a weak signature hash, and self-signed. One certificate therefore produces no findings when it is healthy and several when it breaks several rules. A rule is skipped when the attribute it needs is absent rather than guessed - a certificate with no recorded expiry is not reported either way, because guessing would raise a false alarm or hide a real lapse. Only RSA keys are measured against the 2048-bit floor, since an elliptic-curve key is much shorter by design. Both editions are read. They name their fields entirely differently - the self-hosted one capitalises everything and sends no self-signed flag, which is inferred from the subject matching the issuer - so reading only one edition's names would silently produce no findings at all against the other. Subject alternative names are sorted here, unlike the connector, which iterates a Go map whose order is randomised; the line is not in the deduplication hash, so the two still match. * feat(vmanplus,zimperium): add ManageEngine VMP and Zimperium zScan parsers Both mirror their connector field for field and report the connector's own scan type verbatim, so a file import and an API sync deduplicate against each other. ManageEngine VMP grades on Microsoft's MSRC scale, whose names do not match DefectDojo's: "Important" is High and "Moderate" is Medium, and reading them literally would fall through to Info and drop each a whole tier. The HOST is the component rather than a package, so the same vulnerability on two machines stays two findings. Its ids are strings its own decoder also accepts as numbers, so an integral float renders without the decimal point - read as "50124.0" it would never match the API's "50124". Zimperium reports SARIF through the same shared utility the other SARIF-reporting connectors use, parameterised by an identity prefix and a static/dynamic flag. This extracts that mapping as a mixin in the HiddenLayer module and extends it, the way the shipped invicti parser extends netsparker, so the mapping stays in one place exactly as it does upstream. A test asserts the mixin is not itself registered as a parser. What is Zimperium's own is the decoration: a SARIF document says nothing about which app or build it came from, and two builds of one app land in the same product, so the app name, build version, upload date and platform come from the export's context afterwards - each only where the SARIF mapping left the field empty. * feat(groupib): add Group-IB ASM file parser Mirrors the connector's issue converter field for field and reports its scan type verbatim, so a file import and an API sync deduplicate against each other. Group-IB has two fields called "status" that mean entirely different things: the issue's own status is its lifecycle state, while the status inside its body is the severity label. Reading one for the other would grade every finding Info AND leave every solved issue open. The severity is matched by containment because Group-IB writes it as a phrase ("Critical severity"), worst keyword first. The three closing lifecycle states stay distinct - solved was fixed, ignored was accepted, a false positive was never real - and anything unrecognised stays active. The affected asset is reported in one field that holds hosts, addresses and URLs alongside software names and SSL descriptors, so a host-shaped value becomes an endpoint and anything else becomes the component: recording a software name as an endpoint would make Endpoint.clean() raise and fail the whole import. The connector prefixes bare hosts with "//" so DefectDojo reads them as an authority rather than a path; this builds the endpoint from its parts instead, which reaches the same result - including for an asset that already arrives scheme-relative. * feat(soos): add SOOS file parser Mirrors the connector's finding converter field for field and reports its scan type verbatim, so a file import and an API sync deduplicate against each other. SOOS runs SCA, SAST, container, SBOM and DAST scans behind one API and stamps each issue with its scan type, so static versus dynamic is decided per ISSUE rather than for the file - only DAST exercises anything. An unrecognised scan type arrives as dynamic, which is the connector's own behaviour: it reads its lookup table with a Go map access, yielding false for a missing key just as for the dast entry. Mirrored so the two paths agree, and raised in the PR because a new SOOS scan type would land as dynamic. A SOOS-side dismissal is carried across so it does not resurface as active on every sync, and the three kinds stay distinct: a false positive was never real, an accepted risk is real and signed off, and a resolved issue is dealt with. "Unknown" is a real SOOS severity rather than a gap, so it grades as Info instead of being dropped. This scan type has no hashcode field list in the connector settings, so none is registered here. --------- Co-authored-by: Claude Opus 5 --- .../supported_tools/parsers/file/accuknox.md | 88 +++ .../supported_tools/parsers/file/action1.md | 80 +++ .../supported_tools/parsers/file/akto.md | 81 +++ .../supported_tools/parsers/file/automox.md | 78 +++ .../supported_tools/parsers/file/beagle.md | 102 ++++ .../supported_tools/parsers/file/bigid.md | 82 +++ .../supported_tools/parsers/file/bright.md | 69 +++ .../parsers/file/calicocloud.md | 93 +++ .../supported_tools/parsers/file/codacy.md | 76 +++ .../parsers/file/crowdstrike_spotlight.md | 73 +++ .../supported_tools/parsers/file/datadog.md | 91 +++ .../parsers/file/deepsource.md | 99 ++++ .../parsers/file/defender_for_cloud.md | 101 ++++ .../supported_tools/parsers/file/detectify.md | 81 +++ .../supported_tools/parsers/file/dragos.md | 92 +++ .../parsers/file/elastic_security_cnvm.md | 81 +++ .../file/elastic_security_detections.md | 75 +++ .../parsers/file/elastic_security_posture.md | 68 +++ .../supported_tools/parsers/file/endorlabs.md | 83 +++ .../supported_tools/parsers/file/escape.md | 71 +++ .../supported_tools/parsers/file/fairwinds.md | 75 +++ .../parsers/file/finitestate.md | 100 ++++ .../parsers/file/fleet_policies.md | 71 +++ .../parsers/file/fleet_vulnerabilities.md | 68 +++ .../parsers/file/fortytwocrunch.md | 96 +++ .../supported_tools/parsers/file/fossa.md | 93 +++ .../parsers/file/gitguardian.md | 85 +++ .../supported_tools/parsers/file/googlescc.md | 75 +++ .../supported_tools/parsers/file/groupib.md | 91 +++ .../supported_tools/parsers/file/hackerone.md | 67 +++ .../parsers/file/halosecurity.md | 87 +++ .../parsers/file/hiddenlayer.md | 97 +++ .../parsers/file/holm_security.md | 85 +++ .../parsers/file/insightappsec.md | 93 +++ .../supported_tools/parsers/file/intigriti.md | 85 +++ .../supported_tools/parsers/file/intruder.md | 84 +++ .../supported_tools/parsers/file/klocwork.md | 83 +++ .../supported_tools/parsers/file/lacework.md | 63 ++ .../supported_tools/parsers/file/netrise.md | 80 +++ .../supported_tools/parsers/file/nightfall.md | 95 +++ .../supported_tools/parsers/file/nowsecure.md | 86 +++ .../supported_tools/parsers/file/nozomi.md | 73 +++ .../supported_tools/parsers/file/ostorlab.md | 95 +++ .../supported_tools/parsers/file/parasoft.md | 62 ++ .../supported_tools/parsers/file/probely.md | 89 +++ .../supported_tools/parsers/file/quay.md | 78 +++ .../supported_tools/parsers/file/qwiet.md | 88 +++ .../supported_tools/parsers/file/socket.md | 53 ++ .../supported_tools/parsers/file/soos.md | 89 +++ .../supported_tools/parsers/file/uptycs.md | 59 ++ .../supported_tools/parsers/file/vanta.md | 68 +++ .../supported_tools/parsers/file/venafi.md | 85 +++ .../supported_tools/parsers/file/vmanplus.md | 75 +++ .../supported_tools/parsers/file/wallarm.md | 83 +++ .../supported_tools/parsers/file/yeswehack.md | 84 +++ .../supported_tools/parsers/file/zimperium.md | 75 +++ dojo/settings/settings.dist.py | 149 +++++ dojo/tools/accuknox/__init__.py | 0 dojo/tools/accuknox/parser.py | 304 ++++++++++ dojo/tools/action1/__init__.py | 0 dojo/tools/action1/parser.py | 341 +++++++++++ dojo/tools/akto/__init__.py | 0 dojo/tools/akto/parser.py | 326 +++++++++++ dojo/tools/automox/__init__.py | 0 dojo/tools/automox/parser.py | 268 +++++++++ dojo/tools/beagle/__init__.py | 0 dojo/tools/beagle/parser.py | 553 ++++++++++++++++++ dojo/tools/bigid/__init__.py | 0 dojo/tools/bigid/parser.py | 226 +++++++ dojo/tools/bright/__init__.py | 0 dojo/tools/bright/parser.py | 252 ++++++++ dojo/tools/calicocloud/__init__.py | 0 dojo/tools/calicocloud/parser.py | 310 ++++++++++ dojo/tools/codacy/__init__.py | 0 dojo/tools/codacy/parser.py | 354 +++++++++++ dojo/tools/crowdstrike_spotlight/__init__.py | 0 dojo/tools/crowdstrike_spotlight/parser.py | 324 ++++++++++ dojo/tools/datadog/__init__.py | 0 dojo/tools/datadog/parser.py | 363 ++++++++++++ dojo/tools/deepsource/__init__.py | 0 dojo/tools/deepsource/parser.py | 457 +++++++++++++++ dojo/tools/defender_for_cloud/__init__.py | 0 dojo/tools/defender_for_cloud/parser.py | 404 +++++++++++++ dojo/tools/detectify/__init__.py | 0 dojo/tools/detectify/parser.py | 324 ++++++++++ dojo/tools/dragos/__init__.py | 0 dojo/tools/dragos/parser.py | 342 +++++++++++ dojo/tools/elastic_security_cnvm/__init__.py | 0 dojo/tools/elastic_security_cnvm/parser.py | 459 +++++++++++++++ .../elastic_security_detections/__init__.py | 0 .../elastic_security_detections/parser.py | 194 ++++++ .../elastic_security_posture/__init__.py | 0 dojo/tools/elastic_security_posture/parser.py | 195 ++++++ dojo/tools/endorlabs/__init__.py | 0 dojo/tools/endorlabs/parser.py | 413 +++++++++++++ dojo/tools/escape/__init__.py | 0 dojo/tools/escape/parser.py | 247 ++++++++ dojo/tools/fairwinds/__init__.py | 0 dojo/tools/fairwinds/parser.py | 332 +++++++++++ dojo/tools/finitestate/__init__.py | 0 dojo/tools/finitestate/parser.py | 402 +++++++++++++ dojo/tools/fleet_policies/__init__.py | 0 dojo/tools/fleet_policies/parser.py | 242 ++++++++ dojo/tools/fleet_vulnerabilities/__init__.py | 0 dojo/tools/fleet_vulnerabilities/parser.py | 356 +++++++++++ dojo/tools/fortytwocrunch/__init__.py | 0 dojo/tools/fortytwocrunch/parser.py | 388 ++++++++++++ dojo/tools/fossa/__init__.py | 0 dojo/tools/fossa/parser.py | 411 +++++++++++++ dojo/tools/gitguardian/__init__.py | 0 dojo/tools/gitguardian/parser.py | 175 ++++++ dojo/tools/googlescc/__init__.py | 0 dojo/tools/googlescc/parser.py | 192 ++++++ dojo/tools/groupib/__init__.py | 0 dojo/tools/groupib/parser.py | 333 +++++++++++ dojo/tools/hackerone/__init__.py | 0 dojo/tools/hackerone/parser.py | 176 ++++++ dojo/tools/halosecurity/__init__.py | 0 dojo/tools/halosecurity/parser.py | 350 +++++++++++ dojo/tools/hiddenlayer/__init__.py | 0 dojo/tools/hiddenlayer/parser.py | 418 +++++++++++++ dojo/tools/holm_security/__init__.py | 0 dojo/tools/holm_security/parser.py | 326 +++++++++++ dojo/tools/insightappsec/__init__.py | 0 dojo/tools/insightappsec/parser.py | 457 +++++++++++++++ dojo/tools/intigriti/__init__.py | 0 dojo/tools/intigriti/parser.py | 385 ++++++++++++ dojo/tools/intruder/__init__.py | 0 dojo/tools/intruder/parser.py | 350 +++++++++++ dojo/tools/klocwork/__init__.py | 0 dojo/tools/klocwork/parser.py | 245 ++++++++ dojo/tools/lacework/__init__.py | 0 dojo/tools/lacework/parser.py | 232 ++++++++ dojo/tools/netrise/__init__.py | 0 dojo/tools/netrise/parser.py | 279 +++++++++ dojo/tools/nightfall/__init__.py | 0 dojo/tools/nightfall/parser.py | 524 +++++++++++++++++ dojo/tools/nowsecure/__init__.py | 0 dojo/tools/nowsecure/parser.py | 275 +++++++++ dojo/tools/nozomi/__init__.py | 0 dojo/tools/nozomi/parser.py | 252 ++++++++ dojo/tools/ostorlab/__init__.py | 0 dojo/tools/ostorlab/parser.py | 362 ++++++++++++ dojo/tools/parasoft/__init__.py | 0 dojo/tools/parasoft/parser.py | 209 +++++++ dojo/tools/probely/__init__.py | 0 dojo/tools/probely/parser.py | 260 ++++++++ dojo/tools/quay/__init__.py | 0 dojo/tools/quay/parser.py | 257 ++++++++ dojo/tools/qwiet/__init__.py | 0 dojo/tools/qwiet/parser.py | 309 ++++++++++ dojo/tools/socket/__init__.py | 0 dojo/tools/socket/parser.py | 190 ++++++ dojo/tools/soos/__init__.py | 0 dojo/tools/soos/parser.py | 310 ++++++++++ dojo/tools/uptycs/__init__.py | 0 dojo/tools/uptycs/parser.py | 219 +++++++ dojo/tools/vanta/__init__.py | 0 dojo/tools/vanta/parser.py | 263 +++++++++ dojo/tools/venafi/__init__.py | 0 dojo/tools/venafi/parser.py | 415 +++++++++++++ dojo/tools/vmanplus/__init__.py | 0 dojo/tools/vmanplus/parser.py | 263 +++++++++ dojo/tools/wallarm/__init__.py | 0 dojo/tools/wallarm/parser.py | 301 ++++++++++ dojo/tools/yeswehack/__init__.py | 0 dojo/tools/yeswehack/parser.py | 334 +++++++++++ dojo/tools/zimperium/__init__.py | 0 dojo/tools/zimperium/parser.py | 166 ++++++ .../scans/accuknox/accuknox_many_vuln.json | 83 +++ .../scans/accuknox/accuknox_no_vuln.json | 5 + .../scans/accuknox/accuknox_one_vuln.json | 23 + .../scans/action1/action1_many_vuln.json | 85 +++ unittests/scans/action1/action1_no_vuln.json | 6 + unittests/scans/action1/action1_one_vuln.json | 35 ++ unittests/scans/akto/akto_many_vuln.json | 71 +++ unittests/scans/akto/akto_no_vuln.json | 3 + unittests/scans/akto/akto_one_vuln.json | 30 + .../scans/automox/automox_many_vuln.json | 99 ++++ unittests/scans/automox/automox_no_vuln.json | 16 + unittests/scans/automox/automox_one_vuln.json | 35 ++ .../scans/automox/automox_packages_only.json | 16 + unittests/scans/beagle/beagle_alias_keys.json | 28 + unittests/scans/beagle/beagle_many_vuln.json | 67 +++ unittests/scans/beagle/beagle_no_vuln.json | 8 + unittests/scans/beagle/beagle_one_vuln.json | 6 + unittests/scans/bigid/bigid_many_vuln.json | 61 ++ unittests/scans/bigid/bigid_no_vuln.json | 6 + unittests/scans/bigid/bigid_one_vuln.json | 23 + .../scans/bigid/bigid_with_sample_values.json | 22 + unittests/scans/bright/bright_many_vuln.json | 48 ++ unittests/scans/bright/bright_no_vuln.json | 1 + unittests/scans/bright/bright_one_vuln.json | 26 + .../calicocloud_keyed_vulnerabilities.json | 45 ++ .../calicocloud/calicocloud_many_vuln.json | 99 ++++ .../calicocloud/calicocloud_no_vuln.json | 16 + .../calicocloud/calicocloud_one_vuln.json | 30 + unittests/scans/codacy/codacy_many_vuln.json | 122 ++++ unittests/scans/codacy/codacy_no_vuln.json | 8 + unittests/scans/codacy/codacy_one_vuln.json | 48 ++ .../crowdstrike_spotlight_many_vuln.json | 358 ++++++++++++ .../crowdstrike_spotlight_no_vuln.json | 3 + .../crowdstrike_spotlight_one_vuln.json | 72 +++ .../scans/datadog/datadog_many_vuln.json | 126 ++++ unittests/scans/datadog/datadog_no_vuln.json | 79 +++ unittests/scans/datadog/datadog_one_vuln.json | 77 +++ .../deepsource/deepsource_many_vuln.json | 240 ++++++++ .../scans/deepsource/deepsource_no_vuln.json | 36 ++ .../scans/deepsource/deepsource_one_vuln.json | 60 ++ .../defender_for_cloud_many_vuln.json | 140 +++++ .../defender_for_cloud_no_vuln.json | 64 ++ .../defender_for_cloud_one_vuln.json | 70 +++ .../scans/detectify/detectify_many_vuln.json | 124 ++++ .../scans/detectify/detectify_no_vuln.json | 5 + .../scans/detectify/detectify_one_vuln.json | 81 +++ unittests/scans/dragos/dragos_many_vuln.json | 94 +++ unittests/scans/dragos/dragos_no_vuln.json | 7 + unittests/scans/dragos/dragos_one_vuln.json | 53 ++ .../elastic_security_cnvm_many_vuln.json | 379 ++++++++++++ .../elastic_security_cnvm_no_vuln.json | 226 +++++++ .../elastic_security_cnvm_one_vuln.json | 60 ++ ...elastic_security_detections_many_vuln.json | 379 ++++++++++++ .../elastic_security_detections_no_vuln.json | 282 +++++++++ .../elastic_security_detections_one_vuln.json | 65 ++ .../elastic_security_posture_many_vuln.json | 379 ++++++++++++ .../elastic_security_posture_no_vuln.json | 261 +++++++++ .../elastic_security_posture_one_vuln.json | 59 ++ .../scans/endorlabs/endorlabs_many_vuln.json | 109 ++++ .../scans/endorlabs/endorlabs_no_vuln.json | 8 + .../scans/endorlabs/endorlabs_one_vuln.json | 76 +++ unittests/scans/escape/escape_many_vuln.json | 48 ++ unittests/scans/escape/escape_no_vuln.json | 5 + unittests/scans/escape/escape_one_vuln.json | 21 + .../scans/fairwinds/fairwinds_many_vuln.json | 106 ++++ .../scans/fairwinds/fairwinds_no_vuln.json | 1 + .../scans/fairwinds/fairwinds_one_vuln.json | 29 + .../finitestate_findings_only.json | 14 + .../finitestate/finitestate_many_vuln.json | 89 +++ .../finitestate/finitestate_no_vuln.json | 7 + .../finitestate/finitestate_one_vuln.json | 56 ++ .../fleet_policies_many_vuln.json | 75 +++ .../fleet_policies_no_vuln.json | 18 + .../fleet_policies_one_vuln.json | 23 + .../fleet_vulnerabilities_many_vuln.json | 89 +++ .../fleet_vulnerabilities_no_vuln.json | 17 + .../fleet_vulnerabilities_one_vuln.json | 32 + .../fortytwocrunch_audit_many_vuln.json | 67 +++ .../fortytwocrunch_audit_one_vuln.json | 29 + .../fortytwocrunch_no_vuln.json | 12 + .../fortytwocrunch_scan_many_vuln.json | 75 +++ unittests/scans/fossa/fossa_many_vuln.json | 110 ++++ unittests/scans/fossa/fossa_no_vuln.json | 3 + unittests/scans/fossa/fossa_one_vuln.json | 66 +++ .../gitguardian/gitguardian_many_vuln.json | 61 ++ .../gitguardian/gitguardian_no_vuln.json | 1 + .../gitguardian/gitguardian_one_vuln.json | 20 + .../scans/googlescc/googlescc_many_vuln.json | 84 +++ .../scans/googlescc/googlescc_no_vuln.json | 5 + .../scans/googlescc/googlescc_one_vuln.json | 32 + .../scans/groupib/groupib_many_vuln.json | 86 +++ unittests/scans/groupib/groupib_no_vuln.json | 4 + unittests/scans/groupib/groupib_one_vuln.json | 29 + .../scans/hackerone/hackerone_many_vuln.json | 100 ++++ .../scans/hackerone/hackerone_no_vuln.json | 6 + .../scans/hackerone/hackerone_one_vuln.json | 49 ++ .../halosecurity/halosecurity_many_vuln.json | 90 +++ .../halosecurity/halosecurity_no_vuln.json | 9 + .../halosecurity/halosecurity_one_vuln.json | 43 ++ .../hiddenlayer/hiddenlayer_many_vuln.json | 96 +++ .../hiddenlayer/hiddenlayer_no_vuln.json | 12 + .../hiddenlayer/hiddenlayer_one_vuln.json | 51 ++ .../holm_security_many_vuln.json | 74 +++ .../holm_security/holm_security_no_vuln.json | 5 + .../holm_security/holm_security_one_vuln.json | 28 + .../insightappsec_many_vuln.json | 95 +++ .../insightappsec/insightappsec_no_vuln.json | 65 ++ .../insightappsec/insightappsec_one_vuln.json | 87 +++ .../scans/intigriti/intigriti_many_vuln.json | 106 ++++ .../scans/intigriti/intigriti_no_vuln.json | 3 + .../scans/intigriti/intigriti_one_vuln.json | 82 +++ .../scans/intruder/intruder_many_vuln.json | 104 ++++ .../scans/intruder/intruder_no_vuln.json | 5 + .../scans/intruder/intruder_one_vuln.json | 31 + .../scans/klocwork/klocwork_many_vuln.json | 75 +++ .../scans/klocwork/klocwork_many_vuln.ndjson | 7 + .../scans/klocwork/klocwork_no_vuln.ndjson | 1 + .../scans/klocwork/klocwork_one_vuln.ndjson | 1 + .../scans/lacework/lacework_many_vuln.json | 81 +++ .../scans/lacework/lacework_no_vuln.json | 3 + .../scans/lacework/lacework_one_vuln.json | 26 + .../scans/netrise/netrise_many_vuln.json | 62 ++ unittests/scans/netrise/netrise_no_vuln.json | 13 + unittests/scans/netrise/netrise_one_vuln.json | 25 + .../scans/nightfall/nightfall_many_vuln.json | 162 +++++ .../scans/nightfall/nightfall_no_vuln.json | 4 + .../scans/nightfall/nightfall_one_vuln.json | 52 ++ .../scans/nowsecure/nowsecure_many_vuln.json | 80 +++ .../scans/nowsecure/nowsecure_no_vuln.json | 41 ++ .../scans/nowsecure/nowsecure_one_vuln.json | 38 ++ unittests/scans/nozomi/nozomi_many_vuln.json | 70 +++ unittests/scans/nozomi/nozomi_no_vuln.json | 3 + unittests/scans/nozomi/nozomi_one_vuln.json | 28 + .../scans/ostorlab/ostorlab_many_vuln.json | 79 +++ .../scans/ostorlab/ostorlab_no_vuln.json | 17 + .../scans/ostorlab/ostorlab_one_vuln.json | 41 ++ .../scans/parasoft/parasoft_many_vuln.json | 57 ++ .../scans/parasoft/parasoft_no_vuln.json | 3 + .../scans/parasoft/parasoft_one_vuln.json | 17 + .../scans/probely/probely_many_vuln.json | 149 +++++ unittests/scans/probely/probely_no_vuln.json | 4 + unittests/scans/probely/probely_one_vuln.json | 32 + unittests/scans/quay/quay_many_vuln.json | 66 +++ unittests/scans/quay/quay_no_vuln.json | 31 + unittests/scans/quay/quay_one_vuln.json | 25 + unittests/scans/qwiet/qwiet_many_vuln.json | 81 +++ unittests/scans/qwiet/qwiet_no_vuln.json | 4 + unittests/scans/qwiet/qwiet_one_vuln.json | 39 ++ unittests/scans/socket/socket_many_vuln.json | 69 +++ unittests/scans/socket/socket_no_vuln.json | 3 + unittests/scans/socket/socket_one_vuln.json | 24 + unittests/scans/soos/soos_many_vuln.json | 88 +++ unittests/scans/soos/soos_no_vuln.json | 4 + unittests/scans/soos/soos_one_vuln.json | 26 + unittests/scans/uptycs/uptycs_many_vuln.json | 40 ++ unittests/scans/uptycs/uptycs_no_vuln.json | 3 + unittests/scans/uptycs/uptycs_one_vuln.json | 15 + unittests/scans/vanta/vanta_many_vuln.json | 86 +++ unittests/scans/vanta/vanta_no_vuln.json | 16 + unittests/scans/vanta/vanta_one_vuln.json | 42 ++ unittests/scans/venafi/venafi_many_vuln.json | 60 ++ unittests/scans/venafi/venafi_no_vuln.json | 22 + unittests/scans/venafi/venafi_one_vuln.json | 19 + .../scans/venafi/venafi_self_hosted.json | 32 + .../scans/vmanplus/vmanplus_many_vuln.json | 55 ++ .../scans/vmanplus/vmanplus_no_vuln.json | 4 + .../scans/vmanplus/vmanplus_one_vuln.json | 23 + .../scans/wallarm/wallarm_many_vuln.json | 89 +++ unittests/scans/wallarm/wallarm_no_vuln.json | 23 + unittests/scans/wallarm/wallarm_one_vuln.json | 34 ++ .../scans/yeswehack/yeswehack_many_vuln.json | 94 +++ .../scans/yeswehack/yeswehack_no_vuln.json | 3 + .../scans/yeswehack/yeswehack_one_vuln.json | 37 ++ .../scans/zimperium/zimperium_many_vuln.json | 80 +++ .../scans/zimperium/zimperium_no_vuln.json | 21 + .../scans/zimperium/zimperium_one_vuln.json | 61 ++ unittests/tools/test_accuknox_parser.py | 244 ++++++++ unittests/tools/test_action1_parser.py | 271 +++++++++ unittests/tools/test_akto_parser.py | 221 +++++++ unittests/tools/test_automox_parser.py | 270 +++++++++ unittests/tools/test_beagle_parser.py | 428 ++++++++++++++ unittests/tools/test_bigid_parser.py | 244 ++++++++ unittests/tools/test_bright_parser.py | 211 +++++++ unittests/tools/test_calicocloud_parser.py | 304 ++++++++++ unittests/tools/test_codacy_parser.py | 266 +++++++++ .../test_crowdstrike_spotlight_parser.py | 208 +++++++ unittests/tools/test_datadog_parser.py | 291 +++++++++ unittests/tools/test_deepsource_parser.py | 313 ++++++++++ .../tools/test_defender_for_cloud_parser.py | 298 ++++++++++ unittests/tools/test_detectify_parser.py | 257 ++++++++ unittests/tools/test_dragos_parser.py | 306 ++++++++++ .../test_elastic_security_cnvm_parser.py | 260 ++++++++ ...test_elastic_security_detections_parser.py | 212 +++++++ .../test_elastic_security_posture_parser.py | 240 ++++++++ unittests/tools/test_endorlabs_parser.py | 230 ++++++++ unittests/tools/test_escape_parser.py | 209 +++++++ unittests/tools/test_fairwinds_parser.py | 199 +++++++ unittests/tools/test_finitestate_parser.py | 379 ++++++++++++ unittests/tools/test_fleet_policies_parser.py | 200 +++++++ .../test_fleet_vulnerabilities_parser.py | 287 +++++++++ unittests/tools/test_fortytwocrunch_parser.py | 355 +++++++++++ unittests/tools/test_fossa_parser.py | 258 ++++++++ unittests/tools/test_gitguardian_parser.py | 164 ++++++ unittests/tools/test_googlescc_parser.py | 205 +++++++ unittests/tools/test_groupib_parser.py | 280 +++++++++ unittests/tools/test_hackerone_parser.py | 170 ++++++ unittests/tools/test_halosecurity_parser.py | 300 ++++++++++ unittests/tools/test_hiddenlayer_parser.py | 371 ++++++++++++ unittests/tools/test_holm_security_parser.py | 264 +++++++++ unittests/tools/test_insightappsec_parser.py | 282 +++++++++ unittests/tools/test_intigriti_parser.py | 243 ++++++++ unittests/tools/test_intruder_parser.py | 273 +++++++++ unittests/tools/test_klocwork_parser.py | 230 ++++++++ unittests/tools/test_lacework_parser.py | 149 +++++ unittests/tools/test_netrise_parser.py | 254 ++++++++ unittests/tools/test_nightfall_parser.py | 422 +++++++++++++ unittests/tools/test_nowsecure_parser.py | 230 ++++++++ unittests/tools/test_nozomi_parser.py | 237 ++++++++ unittests/tools/test_ostorlab_parser.py | 328 +++++++++++ unittests/tools/test_parasoft_parser.py | 193 ++++++ unittests/tools/test_probely_parser.py | 209 +++++++ unittests/tools/test_quay_parser.py | 210 +++++++ unittests/tools/test_qwiet_parser.py | 231 ++++++++ unittests/tools/test_socket_parser.py | 147 +++++ unittests/tools/test_soos_parser.py | 282 +++++++++ unittests/tools/test_uptycs_parser.py | 225 +++++++ unittests/tools/test_vanta_parser.py | 213 +++++++ unittests/tools/test_venafi_parser.py | 327 +++++++++++ unittests/tools/test_vmanplus_parser.py | 226 +++++++ unittests/tools/test_wallarm_parser.py | 246 ++++++++ unittests/tools/test_yeswehack_parser.py | 258 ++++++++ unittests/tools/test_zimperium_parser.py | 241 ++++++++ 401 files changed, 46801 insertions(+) create mode 100644 docs/content/supported_tools/parsers/file/accuknox.md create mode 100644 docs/content/supported_tools/parsers/file/action1.md create mode 100644 docs/content/supported_tools/parsers/file/akto.md create mode 100644 docs/content/supported_tools/parsers/file/automox.md create mode 100644 docs/content/supported_tools/parsers/file/beagle.md create mode 100644 docs/content/supported_tools/parsers/file/bigid.md create mode 100644 docs/content/supported_tools/parsers/file/bright.md create mode 100644 docs/content/supported_tools/parsers/file/calicocloud.md create mode 100644 docs/content/supported_tools/parsers/file/codacy.md create mode 100644 docs/content/supported_tools/parsers/file/crowdstrike_spotlight.md create mode 100644 docs/content/supported_tools/parsers/file/datadog.md create mode 100644 docs/content/supported_tools/parsers/file/deepsource.md create mode 100644 docs/content/supported_tools/parsers/file/defender_for_cloud.md create mode 100644 docs/content/supported_tools/parsers/file/detectify.md create mode 100644 docs/content/supported_tools/parsers/file/dragos.md create mode 100644 docs/content/supported_tools/parsers/file/elastic_security_cnvm.md create mode 100644 docs/content/supported_tools/parsers/file/elastic_security_detections.md create mode 100644 docs/content/supported_tools/parsers/file/elastic_security_posture.md create mode 100644 docs/content/supported_tools/parsers/file/endorlabs.md create mode 100644 docs/content/supported_tools/parsers/file/escape.md create mode 100644 docs/content/supported_tools/parsers/file/fairwinds.md create mode 100644 docs/content/supported_tools/parsers/file/finitestate.md create mode 100644 docs/content/supported_tools/parsers/file/fleet_policies.md create mode 100644 docs/content/supported_tools/parsers/file/fleet_vulnerabilities.md create mode 100644 docs/content/supported_tools/parsers/file/fortytwocrunch.md create mode 100644 docs/content/supported_tools/parsers/file/fossa.md create mode 100644 docs/content/supported_tools/parsers/file/gitguardian.md create mode 100644 docs/content/supported_tools/parsers/file/googlescc.md create mode 100644 docs/content/supported_tools/parsers/file/groupib.md create mode 100644 docs/content/supported_tools/parsers/file/hackerone.md create mode 100644 docs/content/supported_tools/parsers/file/halosecurity.md create mode 100644 docs/content/supported_tools/parsers/file/hiddenlayer.md create mode 100644 docs/content/supported_tools/parsers/file/holm_security.md create mode 100644 docs/content/supported_tools/parsers/file/insightappsec.md create mode 100644 docs/content/supported_tools/parsers/file/intigriti.md create mode 100644 docs/content/supported_tools/parsers/file/intruder.md create mode 100644 docs/content/supported_tools/parsers/file/klocwork.md create mode 100644 docs/content/supported_tools/parsers/file/lacework.md create mode 100644 docs/content/supported_tools/parsers/file/netrise.md create mode 100644 docs/content/supported_tools/parsers/file/nightfall.md create mode 100644 docs/content/supported_tools/parsers/file/nowsecure.md create mode 100644 docs/content/supported_tools/parsers/file/nozomi.md create mode 100644 docs/content/supported_tools/parsers/file/ostorlab.md create mode 100644 docs/content/supported_tools/parsers/file/parasoft.md create mode 100644 docs/content/supported_tools/parsers/file/probely.md create mode 100644 docs/content/supported_tools/parsers/file/quay.md create mode 100644 docs/content/supported_tools/parsers/file/qwiet.md create mode 100644 docs/content/supported_tools/parsers/file/socket.md create mode 100644 docs/content/supported_tools/parsers/file/soos.md create mode 100644 docs/content/supported_tools/parsers/file/uptycs.md create mode 100644 docs/content/supported_tools/parsers/file/vanta.md create mode 100644 docs/content/supported_tools/parsers/file/venafi.md create mode 100644 docs/content/supported_tools/parsers/file/vmanplus.md create mode 100644 docs/content/supported_tools/parsers/file/wallarm.md create mode 100644 docs/content/supported_tools/parsers/file/yeswehack.md create mode 100644 docs/content/supported_tools/parsers/file/zimperium.md create mode 100644 dojo/tools/accuknox/__init__.py create mode 100644 dojo/tools/accuknox/parser.py create mode 100644 dojo/tools/action1/__init__.py create mode 100644 dojo/tools/action1/parser.py create mode 100644 dojo/tools/akto/__init__.py create mode 100644 dojo/tools/akto/parser.py create mode 100644 dojo/tools/automox/__init__.py create mode 100644 dojo/tools/automox/parser.py create mode 100644 dojo/tools/beagle/__init__.py create mode 100644 dojo/tools/beagle/parser.py create mode 100644 dojo/tools/bigid/__init__.py create mode 100644 dojo/tools/bigid/parser.py create mode 100644 dojo/tools/bright/__init__.py create mode 100644 dojo/tools/bright/parser.py create mode 100644 dojo/tools/calicocloud/__init__.py create mode 100644 dojo/tools/calicocloud/parser.py create mode 100644 dojo/tools/codacy/__init__.py create mode 100644 dojo/tools/codacy/parser.py create mode 100644 dojo/tools/crowdstrike_spotlight/__init__.py create mode 100644 dojo/tools/crowdstrike_spotlight/parser.py create mode 100644 dojo/tools/datadog/__init__.py create mode 100644 dojo/tools/datadog/parser.py create mode 100644 dojo/tools/deepsource/__init__.py create mode 100644 dojo/tools/deepsource/parser.py create mode 100644 dojo/tools/defender_for_cloud/__init__.py create mode 100644 dojo/tools/defender_for_cloud/parser.py create mode 100644 dojo/tools/detectify/__init__.py create mode 100644 dojo/tools/detectify/parser.py create mode 100644 dojo/tools/dragos/__init__.py create mode 100644 dojo/tools/dragos/parser.py create mode 100644 dojo/tools/elastic_security_cnvm/__init__.py create mode 100644 dojo/tools/elastic_security_cnvm/parser.py create mode 100644 dojo/tools/elastic_security_detections/__init__.py create mode 100644 dojo/tools/elastic_security_detections/parser.py create mode 100644 dojo/tools/elastic_security_posture/__init__.py create mode 100644 dojo/tools/elastic_security_posture/parser.py create mode 100644 dojo/tools/endorlabs/__init__.py create mode 100644 dojo/tools/endorlabs/parser.py create mode 100644 dojo/tools/escape/__init__.py create mode 100644 dojo/tools/escape/parser.py create mode 100644 dojo/tools/fairwinds/__init__.py create mode 100644 dojo/tools/fairwinds/parser.py create mode 100644 dojo/tools/finitestate/__init__.py create mode 100644 dojo/tools/finitestate/parser.py create mode 100644 dojo/tools/fleet_policies/__init__.py create mode 100644 dojo/tools/fleet_policies/parser.py create mode 100644 dojo/tools/fleet_vulnerabilities/__init__.py create mode 100644 dojo/tools/fleet_vulnerabilities/parser.py create mode 100644 dojo/tools/fortytwocrunch/__init__.py create mode 100644 dojo/tools/fortytwocrunch/parser.py create mode 100644 dojo/tools/fossa/__init__.py create mode 100644 dojo/tools/fossa/parser.py create mode 100644 dojo/tools/gitguardian/__init__.py create mode 100644 dojo/tools/gitguardian/parser.py create mode 100644 dojo/tools/googlescc/__init__.py create mode 100644 dojo/tools/googlescc/parser.py create mode 100644 dojo/tools/groupib/__init__.py create mode 100644 dojo/tools/groupib/parser.py create mode 100644 dojo/tools/hackerone/__init__.py create mode 100644 dojo/tools/hackerone/parser.py create mode 100644 dojo/tools/halosecurity/__init__.py create mode 100644 dojo/tools/halosecurity/parser.py create mode 100644 dojo/tools/hiddenlayer/__init__.py create mode 100644 dojo/tools/hiddenlayer/parser.py create mode 100644 dojo/tools/holm_security/__init__.py create mode 100644 dojo/tools/holm_security/parser.py create mode 100644 dojo/tools/insightappsec/__init__.py create mode 100644 dojo/tools/insightappsec/parser.py create mode 100644 dojo/tools/intigriti/__init__.py create mode 100644 dojo/tools/intigriti/parser.py create mode 100644 dojo/tools/intruder/__init__.py create mode 100644 dojo/tools/intruder/parser.py create mode 100644 dojo/tools/klocwork/__init__.py create mode 100644 dojo/tools/klocwork/parser.py create mode 100644 dojo/tools/lacework/__init__.py create mode 100644 dojo/tools/lacework/parser.py create mode 100644 dojo/tools/netrise/__init__.py create mode 100644 dojo/tools/netrise/parser.py create mode 100644 dojo/tools/nightfall/__init__.py create mode 100644 dojo/tools/nightfall/parser.py create mode 100644 dojo/tools/nowsecure/__init__.py create mode 100644 dojo/tools/nowsecure/parser.py create mode 100644 dojo/tools/nozomi/__init__.py create mode 100644 dojo/tools/nozomi/parser.py create mode 100644 dojo/tools/ostorlab/__init__.py create mode 100644 dojo/tools/ostorlab/parser.py create mode 100644 dojo/tools/parasoft/__init__.py create mode 100644 dojo/tools/parasoft/parser.py create mode 100644 dojo/tools/probely/__init__.py create mode 100644 dojo/tools/probely/parser.py create mode 100644 dojo/tools/quay/__init__.py create mode 100644 dojo/tools/quay/parser.py create mode 100644 dojo/tools/qwiet/__init__.py create mode 100644 dojo/tools/qwiet/parser.py create mode 100644 dojo/tools/socket/__init__.py create mode 100644 dojo/tools/socket/parser.py create mode 100644 dojo/tools/soos/__init__.py create mode 100644 dojo/tools/soos/parser.py create mode 100644 dojo/tools/uptycs/__init__.py create mode 100644 dojo/tools/uptycs/parser.py create mode 100644 dojo/tools/vanta/__init__.py create mode 100644 dojo/tools/vanta/parser.py create mode 100644 dojo/tools/venafi/__init__.py create mode 100644 dojo/tools/venafi/parser.py create mode 100644 dojo/tools/vmanplus/__init__.py create mode 100644 dojo/tools/vmanplus/parser.py create mode 100644 dojo/tools/wallarm/__init__.py create mode 100644 dojo/tools/wallarm/parser.py create mode 100644 dojo/tools/yeswehack/__init__.py create mode 100644 dojo/tools/yeswehack/parser.py create mode 100644 dojo/tools/zimperium/__init__.py create mode 100644 dojo/tools/zimperium/parser.py create mode 100644 unittests/scans/accuknox/accuknox_many_vuln.json create mode 100644 unittests/scans/accuknox/accuknox_no_vuln.json create mode 100644 unittests/scans/accuknox/accuknox_one_vuln.json create mode 100644 unittests/scans/action1/action1_many_vuln.json create mode 100644 unittests/scans/action1/action1_no_vuln.json create mode 100644 unittests/scans/action1/action1_one_vuln.json create mode 100644 unittests/scans/akto/akto_many_vuln.json create mode 100644 unittests/scans/akto/akto_no_vuln.json create mode 100644 unittests/scans/akto/akto_one_vuln.json create mode 100644 unittests/scans/automox/automox_many_vuln.json create mode 100644 unittests/scans/automox/automox_no_vuln.json create mode 100644 unittests/scans/automox/automox_one_vuln.json create mode 100644 unittests/scans/automox/automox_packages_only.json create mode 100644 unittests/scans/beagle/beagle_alias_keys.json create mode 100644 unittests/scans/beagle/beagle_many_vuln.json create mode 100644 unittests/scans/beagle/beagle_no_vuln.json create mode 100644 unittests/scans/beagle/beagle_one_vuln.json create mode 100644 unittests/scans/bigid/bigid_many_vuln.json create mode 100644 unittests/scans/bigid/bigid_no_vuln.json create mode 100644 unittests/scans/bigid/bigid_one_vuln.json create mode 100644 unittests/scans/bigid/bigid_with_sample_values.json create mode 100644 unittests/scans/bright/bright_many_vuln.json create mode 100644 unittests/scans/bright/bright_no_vuln.json create mode 100644 unittests/scans/bright/bright_one_vuln.json create mode 100644 unittests/scans/calicocloud/calicocloud_keyed_vulnerabilities.json create mode 100644 unittests/scans/calicocloud/calicocloud_many_vuln.json create mode 100644 unittests/scans/calicocloud/calicocloud_no_vuln.json create mode 100644 unittests/scans/calicocloud/calicocloud_one_vuln.json create mode 100644 unittests/scans/codacy/codacy_many_vuln.json create mode 100644 unittests/scans/codacy/codacy_no_vuln.json create mode 100644 unittests/scans/codacy/codacy_one_vuln.json create mode 100644 unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_many_vuln.json create mode 100644 unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_no_vuln.json create mode 100644 unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_one_vuln.json create mode 100644 unittests/scans/datadog/datadog_many_vuln.json create mode 100644 unittests/scans/datadog/datadog_no_vuln.json create mode 100644 unittests/scans/datadog/datadog_one_vuln.json create mode 100644 unittests/scans/deepsource/deepsource_many_vuln.json create mode 100644 unittests/scans/deepsource/deepsource_no_vuln.json create mode 100644 unittests/scans/deepsource/deepsource_one_vuln.json create mode 100644 unittests/scans/defender_for_cloud/defender_for_cloud_many_vuln.json create mode 100644 unittests/scans/defender_for_cloud/defender_for_cloud_no_vuln.json create mode 100644 unittests/scans/defender_for_cloud/defender_for_cloud_one_vuln.json create mode 100644 unittests/scans/detectify/detectify_many_vuln.json create mode 100644 unittests/scans/detectify/detectify_no_vuln.json create mode 100644 unittests/scans/detectify/detectify_one_vuln.json create mode 100644 unittests/scans/dragos/dragos_many_vuln.json create mode 100644 unittests/scans/dragos/dragos_no_vuln.json create mode 100644 unittests/scans/dragos/dragos_one_vuln.json create mode 100644 unittests/scans/elastic_security_cnvm/elastic_security_cnvm_many_vuln.json create mode 100644 unittests/scans/elastic_security_cnvm/elastic_security_cnvm_no_vuln.json create mode 100644 unittests/scans/elastic_security_cnvm/elastic_security_cnvm_one_vuln.json create mode 100644 unittests/scans/elastic_security_detections/elastic_security_detections_many_vuln.json create mode 100644 unittests/scans/elastic_security_detections/elastic_security_detections_no_vuln.json create mode 100644 unittests/scans/elastic_security_detections/elastic_security_detections_one_vuln.json create mode 100644 unittests/scans/elastic_security_posture/elastic_security_posture_many_vuln.json create mode 100644 unittests/scans/elastic_security_posture/elastic_security_posture_no_vuln.json create mode 100644 unittests/scans/elastic_security_posture/elastic_security_posture_one_vuln.json create mode 100644 unittests/scans/endorlabs/endorlabs_many_vuln.json create mode 100644 unittests/scans/endorlabs/endorlabs_no_vuln.json create mode 100644 unittests/scans/endorlabs/endorlabs_one_vuln.json create mode 100644 unittests/scans/escape/escape_many_vuln.json create mode 100644 unittests/scans/escape/escape_no_vuln.json create mode 100644 unittests/scans/escape/escape_one_vuln.json create mode 100644 unittests/scans/fairwinds/fairwinds_many_vuln.json create mode 100644 unittests/scans/fairwinds/fairwinds_no_vuln.json create mode 100644 unittests/scans/fairwinds/fairwinds_one_vuln.json create mode 100644 unittests/scans/finitestate/finitestate_findings_only.json create mode 100644 unittests/scans/finitestate/finitestate_many_vuln.json create mode 100644 unittests/scans/finitestate/finitestate_no_vuln.json create mode 100644 unittests/scans/finitestate/finitestate_one_vuln.json create mode 100644 unittests/scans/fleet_policies/fleet_policies_many_vuln.json create mode 100644 unittests/scans/fleet_policies/fleet_policies_no_vuln.json create mode 100644 unittests/scans/fleet_policies/fleet_policies_one_vuln.json create mode 100644 unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_many_vuln.json create mode 100644 unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_no_vuln.json create mode 100644 unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_one_vuln.json create mode 100644 unittests/scans/fortytwocrunch/fortytwocrunch_audit_many_vuln.json create mode 100644 unittests/scans/fortytwocrunch/fortytwocrunch_audit_one_vuln.json create mode 100644 unittests/scans/fortytwocrunch/fortytwocrunch_no_vuln.json create mode 100644 unittests/scans/fortytwocrunch/fortytwocrunch_scan_many_vuln.json create mode 100644 unittests/scans/fossa/fossa_many_vuln.json create mode 100644 unittests/scans/fossa/fossa_no_vuln.json create mode 100644 unittests/scans/fossa/fossa_one_vuln.json create mode 100644 unittests/scans/gitguardian/gitguardian_many_vuln.json create mode 100644 unittests/scans/gitguardian/gitguardian_no_vuln.json create mode 100644 unittests/scans/gitguardian/gitguardian_one_vuln.json create mode 100644 unittests/scans/googlescc/googlescc_many_vuln.json create mode 100644 unittests/scans/googlescc/googlescc_no_vuln.json create mode 100644 unittests/scans/googlescc/googlescc_one_vuln.json create mode 100644 unittests/scans/groupib/groupib_many_vuln.json create mode 100644 unittests/scans/groupib/groupib_no_vuln.json create mode 100644 unittests/scans/groupib/groupib_one_vuln.json create mode 100644 unittests/scans/hackerone/hackerone_many_vuln.json create mode 100644 unittests/scans/hackerone/hackerone_no_vuln.json create mode 100644 unittests/scans/hackerone/hackerone_one_vuln.json create mode 100644 unittests/scans/halosecurity/halosecurity_many_vuln.json create mode 100644 unittests/scans/halosecurity/halosecurity_no_vuln.json create mode 100644 unittests/scans/halosecurity/halosecurity_one_vuln.json create mode 100644 unittests/scans/hiddenlayer/hiddenlayer_many_vuln.json create mode 100644 unittests/scans/hiddenlayer/hiddenlayer_no_vuln.json create mode 100644 unittests/scans/hiddenlayer/hiddenlayer_one_vuln.json create mode 100644 unittests/scans/holm_security/holm_security_many_vuln.json create mode 100644 unittests/scans/holm_security/holm_security_no_vuln.json create mode 100644 unittests/scans/holm_security/holm_security_one_vuln.json create mode 100644 unittests/scans/insightappsec/insightappsec_many_vuln.json create mode 100644 unittests/scans/insightappsec/insightappsec_no_vuln.json create mode 100644 unittests/scans/insightappsec/insightappsec_one_vuln.json create mode 100644 unittests/scans/intigriti/intigriti_many_vuln.json create mode 100644 unittests/scans/intigriti/intigriti_no_vuln.json create mode 100644 unittests/scans/intigriti/intigriti_one_vuln.json create mode 100644 unittests/scans/intruder/intruder_many_vuln.json create mode 100644 unittests/scans/intruder/intruder_no_vuln.json create mode 100644 unittests/scans/intruder/intruder_one_vuln.json create mode 100644 unittests/scans/klocwork/klocwork_many_vuln.json create mode 100644 unittests/scans/klocwork/klocwork_many_vuln.ndjson create mode 100644 unittests/scans/klocwork/klocwork_no_vuln.ndjson create mode 100644 unittests/scans/klocwork/klocwork_one_vuln.ndjson create mode 100644 unittests/scans/lacework/lacework_many_vuln.json create mode 100644 unittests/scans/lacework/lacework_no_vuln.json create mode 100644 unittests/scans/lacework/lacework_one_vuln.json create mode 100644 unittests/scans/netrise/netrise_many_vuln.json create mode 100644 unittests/scans/netrise/netrise_no_vuln.json create mode 100644 unittests/scans/netrise/netrise_one_vuln.json create mode 100644 unittests/scans/nightfall/nightfall_many_vuln.json create mode 100644 unittests/scans/nightfall/nightfall_no_vuln.json create mode 100644 unittests/scans/nightfall/nightfall_one_vuln.json create mode 100644 unittests/scans/nowsecure/nowsecure_many_vuln.json create mode 100644 unittests/scans/nowsecure/nowsecure_no_vuln.json create mode 100644 unittests/scans/nowsecure/nowsecure_one_vuln.json create mode 100644 unittests/scans/nozomi/nozomi_many_vuln.json create mode 100644 unittests/scans/nozomi/nozomi_no_vuln.json create mode 100644 unittests/scans/nozomi/nozomi_one_vuln.json create mode 100644 unittests/scans/ostorlab/ostorlab_many_vuln.json create mode 100644 unittests/scans/ostorlab/ostorlab_no_vuln.json create mode 100644 unittests/scans/ostorlab/ostorlab_one_vuln.json create mode 100644 unittests/scans/parasoft/parasoft_many_vuln.json create mode 100644 unittests/scans/parasoft/parasoft_no_vuln.json create mode 100644 unittests/scans/parasoft/parasoft_one_vuln.json create mode 100644 unittests/scans/probely/probely_many_vuln.json create mode 100644 unittests/scans/probely/probely_no_vuln.json create mode 100644 unittests/scans/probely/probely_one_vuln.json create mode 100644 unittests/scans/quay/quay_many_vuln.json create mode 100644 unittests/scans/quay/quay_no_vuln.json create mode 100644 unittests/scans/quay/quay_one_vuln.json create mode 100644 unittests/scans/qwiet/qwiet_many_vuln.json create mode 100644 unittests/scans/qwiet/qwiet_no_vuln.json create mode 100644 unittests/scans/qwiet/qwiet_one_vuln.json create mode 100644 unittests/scans/socket/socket_many_vuln.json create mode 100644 unittests/scans/socket/socket_no_vuln.json create mode 100644 unittests/scans/socket/socket_one_vuln.json create mode 100644 unittests/scans/soos/soos_many_vuln.json create mode 100644 unittests/scans/soos/soos_no_vuln.json create mode 100644 unittests/scans/soos/soos_one_vuln.json create mode 100644 unittests/scans/uptycs/uptycs_many_vuln.json create mode 100644 unittests/scans/uptycs/uptycs_no_vuln.json create mode 100644 unittests/scans/uptycs/uptycs_one_vuln.json create mode 100644 unittests/scans/vanta/vanta_many_vuln.json create mode 100644 unittests/scans/vanta/vanta_no_vuln.json create mode 100644 unittests/scans/vanta/vanta_one_vuln.json create mode 100644 unittests/scans/venafi/venafi_many_vuln.json create mode 100644 unittests/scans/venafi/venafi_no_vuln.json create mode 100644 unittests/scans/venafi/venafi_one_vuln.json create mode 100644 unittests/scans/venafi/venafi_self_hosted.json create mode 100644 unittests/scans/vmanplus/vmanplus_many_vuln.json create mode 100644 unittests/scans/vmanplus/vmanplus_no_vuln.json create mode 100644 unittests/scans/vmanplus/vmanplus_one_vuln.json create mode 100644 unittests/scans/wallarm/wallarm_many_vuln.json create mode 100644 unittests/scans/wallarm/wallarm_no_vuln.json create mode 100644 unittests/scans/wallarm/wallarm_one_vuln.json create mode 100644 unittests/scans/yeswehack/yeswehack_many_vuln.json create mode 100644 unittests/scans/yeswehack/yeswehack_no_vuln.json create mode 100644 unittests/scans/yeswehack/yeswehack_one_vuln.json create mode 100644 unittests/scans/zimperium/zimperium_many_vuln.json create mode 100644 unittests/scans/zimperium/zimperium_no_vuln.json create mode 100644 unittests/scans/zimperium/zimperium_one_vuln.json create mode 100644 unittests/tools/test_accuknox_parser.py create mode 100644 unittests/tools/test_action1_parser.py create mode 100644 unittests/tools/test_akto_parser.py create mode 100644 unittests/tools/test_automox_parser.py create mode 100644 unittests/tools/test_beagle_parser.py create mode 100644 unittests/tools/test_bigid_parser.py create mode 100644 unittests/tools/test_bright_parser.py create mode 100644 unittests/tools/test_calicocloud_parser.py create mode 100644 unittests/tools/test_codacy_parser.py create mode 100644 unittests/tools/test_crowdstrike_spotlight_parser.py create mode 100644 unittests/tools/test_datadog_parser.py create mode 100644 unittests/tools/test_deepsource_parser.py create mode 100644 unittests/tools/test_defender_for_cloud_parser.py create mode 100644 unittests/tools/test_detectify_parser.py create mode 100644 unittests/tools/test_dragos_parser.py create mode 100644 unittests/tools/test_elastic_security_cnvm_parser.py create mode 100644 unittests/tools/test_elastic_security_detections_parser.py create mode 100644 unittests/tools/test_elastic_security_posture_parser.py create mode 100644 unittests/tools/test_endorlabs_parser.py create mode 100644 unittests/tools/test_escape_parser.py create mode 100644 unittests/tools/test_fairwinds_parser.py create mode 100644 unittests/tools/test_finitestate_parser.py create mode 100644 unittests/tools/test_fleet_policies_parser.py create mode 100644 unittests/tools/test_fleet_vulnerabilities_parser.py create mode 100644 unittests/tools/test_fortytwocrunch_parser.py create mode 100644 unittests/tools/test_fossa_parser.py create mode 100644 unittests/tools/test_gitguardian_parser.py create mode 100644 unittests/tools/test_googlescc_parser.py create mode 100644 unittests/tools/test_groupib_parser.py create mode 100644 unittests/tools/test_hackerone_parser.py create mode 100644 unittests/tools/test_halosecurity_parser.py create mode 100644 unittests/tools/test_hiddenlayer_parser.py create mode 100644 unittests/tools/test_holm_security_parser.py create mode 100644 unittests/tools/test_insightappsec_parser.py create mode 100644 unittests/tools/test_intigriti_parser.py create mode 100644 unittests/tools/test_intruder_parser.py create mode 100644 unittests/tools/test_klocwork_parser.py create mode 100644 unittests/tools/test_lacework_parser.py create mode 100644 unittests/tools/test_netrise_parser.py create mode 100644 unittests/tools/test_nightfall_parser.py create mode 100644 unittests/tools/test_nowsecure_parser.py create mode 100644 unittests/tools/test_nozomi_parser.py create mode 100644 unittests/tools/test_ostorlab_parser.py create mode 100644 unittests/tools/test_parasoft_parser.py create mode 100644 unittests/tools/test_probely_parser.py create mode 100644 unittests/tools/test_quay_parser.py create mode 100644 unittests/tools/test_qwiet_parser.py create mode 100644 unittests/tools/test_socket_parser.py create mode 100644 unittests/tools/test_soos_parser.py create mode 100644 unittests/tools/test_uptycs_parser.py create mode 100644 unittests/tools/test_vanta_parser.py create mode 100644 unittests/tools/test_venafi_parser.py create mode 100644 unittests/tools/test_vmanplus_parser.py create mode 100644 unittests/tools/test_wallarm_parser.py create mode 100644 unittests/tools/test_yeswehack_parser.py create mode 100644 unittests/tools/test_zimperium_parser.py diff --git a/docs/content/supported_tools/parsers/file/accuknox.md b/docs/content/supported_tools/parsers/file/accuknox.md new file mode 100644 index 00000000000..fefc24f89fa --- /dev/null +++ b/docs/content/supported_tools/parsers/file/accuknox.md @@ -0,0 +1,88 @@ +--- +title: "AccuKnox" +toc_hide: true +--- + +Import an [AccuKnox](https://accuknox.com/) findings export. + +This exists for organisations that cannot grant AccuKnox API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro AccuKnox connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from AccuKnox's findings endpoint. Rows live under `results` (also accepted: `findings`, `data`, +`rows`, or a bare array). + +### The column names vary by data type + +AccuKnox returns **container, IaC, cloud-posture and runtime** findings through one endpoint, and the +column names differ per type — AccuKnox does not publish this part of its schema. So each field is +resolved by probing a list of candidate keys, which is what the connector does. Assuming one set of +names would silently import empty findings for every type but one. + +| Field | Candidate columns | +| --- | --- | +| identity | `finding_id`, `id`, `uuid` | +| title | `name`, `title`, `finding_name`, `vulnerability_name` | +| severity | `risk_factor`, `severity` | +| status | `status`, `finding_status` | +| description | `description`, `details`, `summary`, `message` | +| remediation | `solution`, `remediation`, `recommendation`, `fix` | +| asset | `asset_name`, `resource_name`, `asset`, `resource` | +| component | `package_name`, `component_name`, `package`, `component` | +| version | `package_version`, `component_version`, `installed_version`, `version` | +| CVE | `cve`, `cve_id`, `cve_ids` | + +Every candidate is also tried with AccuKnox's **`vulnerability__` column prefix**, because some rows +nest their vulnerability columns that way. + +### Severity + +AccuKnox's risk factor: `critical`→Critical, `high`→High, `medium`→Medium, `low`→Low. Anything +unrecognised, including an empty value, is Info. + +### Status + +Only three statuses close a finding — `fixed` (mitigated), `accepted risk`, and `duplicate`. The +working states (`active`, `in progress`, `waiting for 3rd party`, `exception requested`, +`waiting for verification`) all stay **open**, so something being actively worked is not hidden. + +A finding is **verified unless** its status is empty or `potential` — note a blank status counts as +verified, which is the opposite of what a plain truthiness check would give. + +A row AccuKnox has **ignored** is imported and marked out of scope rather than dropped, so the +suppression is recorded. That flag arrives as a boolean *or* a string, and both are handled. + +### Fields worth noting + +- **`vuln_id_from_tool` is the `data_type`**, which is AccuKnox's finding class (`container_image`, + `iac`, `cloud_posture`, `runtime`); it is also imported as a tag alongside the asset type. +- **CVEs** come from the CVE column — which may be an array — falling back to the finding title, since + AccuKnox often carries the identifier only there. Values are scanned and deduplicated. +- **Dates** accept several formats, so a non-RFC3339 timestamp still dates the finding. +- A row with no recognisable title imports as `AccuKnox finding `, or just `AccuKnox finding`. + +### Scan type and deduplication + +The scan type is **`AccuKnox - Connectors Import`** — identical to the string the AccuKnox connector +reports, so a customer who uploads an export *and* later enables the connector gets one set of findings +that deduplicate rather than two copies of everything. + +### Sample Scan Data + +Sample AccuKnox scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/accuknox). + +The samples are constructed from AccuKnox's observed row shapes and deliberately mix column +conventions: one row uses `finding_id`/`name`/`risk_factor`/`status`, another `id`/`title`/`severity`/ +`finding_status`, and a third the `vulnerability__` prefix. They also cover every closing status, a +`potential` row, a blank status, a string-valued `ignored` flag and a `cve_ids` array. Asset and +package names are generic placeholders. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- description diff --git a/docs/content/supported_tools/parsers/file/action1.md b/docs/content/supported_tools/parsers/file/action1.md new file mode 100644 index 00000000000..b2fbaddd117 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/action1.md @@ -0,0 +1,80 @@ +--- +title: "Action1" +toc_hide: true +--- + +Import an [Action1](https://www.action1.com/) vulnerability export. + +This exists for organisations that cannot grant Action1 API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Action1 connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the vulnerability-list response, with rows under `items`. A bare array of rows is accepted too. + +**Include the endpoints affected by each CVE.** Action1 lists the vulnerability catalogue and the +machines actually affected through two different calls, and the affected endpoint is what makes a +finding: a catalogue entry nothing is running produces nothing at all. Because those rows carry no CVE +of their own, supply them as: + +- a top-level `endpoints` object keyed by CVE id, or +- an `endpoints` array nested on each vulnerability. + +Optionally include the managed-endpoint list as `managed_endpoints` (a bare array or a paged +`{"items": [...]}` response). It supplies each machine's operating system, which Action1's own +vulnerability response does not carry. It is best-effort: a machine missing from it simply has no OS +line. + +### Severity + +Action1 reports a `base_severity` and, separately, a `score` — and **`score` is a word, not a +number**: `Critical`, `High`, `Medium`, `Low`. The base severity is used when present, the score is the +fallback, and anything unrecognised is Info. Treating `score` as numeric would drop every finding +whose base severity is missing. + +| Bucket | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| anything else, or neither field set | Info | + +### One finding per machine + +The identity is `action1--`, so one CVE affecting three machines is three findings. +The installed version differs per machine, which is why the endpoint's own copy of the software is +preferred over the vulnerability's, and why **`component_version` is part of this scan type's +deduplication hash** — a machine that has been patched does not merge with one that has not. + +### Fields worth noting + +- **Mitigation** lists the updates Action1 already has available for the affected software + (`Apply: , …`). When Action1 knows of no patch the mitigation is left **empty** + rather than filled with generic advice — that would be this parser's opinion, not Action1's. +- **The endpoint** is recorded only when the machine name is something DefectDojo accepts as a host. + Action1 names are free text (`Reception Desk PC` is a normal value), and an unusable host fails the + whole import rather than the one finding. The name always appears in the description. +- **Every finding is active.** Action1 reports only what is still present on a machine, so the + connector marks them all active and so does this parser. +- **Description lines are separated by a single newline**, not a blank line, matching the connector. + +### Sample Scan Data + +Sample Action1 scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/action1). + +The samples are constructed from Action1's documented vulnerability, affected-endpoint and +managed-endpoint responses and cover one CVE on two machines with different installed versions, a +score-only severity, an unrecognised bucket, a vulnerability nothing is affected by, an +affected-endpoint row with no id, a machine name that cannot be a host, and both the nested and +CVE-keyed export shapes. Machine names and identifiers are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name +- component_version diff --git a/docs/content/supported_tools/parsers/file/akto.md b/docs/content/supported_tools/parsers/file/akto.md new file mode 100644 index 00000000000..80161e04672 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/akto.md @@ -0,0 +1,81 @@ +--- +title: "Akto" +toc_hide: true +--- + +Import an [Akto](https://www.akto.io/) API-security export. + +This exists for organisations that cannot grant Akto API credentials — air-gapped networks, procurement +restrictions, a pending security review. The DefectDojo Pro Akto connector pulls the same data over the +API; this parser accepts the same data as a file. + +### File Types + +JSON — the `fetchIssuesFromCollections` response, with rows under `issueDetails`. A bare array of issues +is accepted too. + +### One finding per endpoint per test + +Akto runs every test against every endpoint it knows, so neither alone identifies a finding. The +identity is `akto----`, and **both** the endpoint and the +test are in this scan type's deduplication hash: the same test against two paths is two findings, and +two different tests against one path are as well. + +Akto has no package to report, so `component_name` is **` `** — the tested endpoint. That +is what the component slot of the hash means here. + +### Severity + +| Akto `severity` | Severity | +| --- | --- | +| `CRITICAL` | Critical | +| `HIGH` | High | +| `MEDIUM` | Medium | +| `LOW` | Low | +| `INFO`, or anything unrecognised | Info | + +Read case-insensitively. + +### Status + +| Akto `status` | Imported as | +| --- | --- | +| `IGNORED` | inactive, **false positive** | +| `FIXED` | inactive | +| anything else | active | + +`IGNORED` is how a reviewer marks a false positive in Akto. `FIXED` is inactive but **not** flagged as a +false positive — "fixed" is not a judgement about whether the finding was real. + +### A relative path is not an endpoint + +Akto's `apiUrl` is often just a path (`/v1/reports`). The connector records an endpoint only for an +absolute URL, rather than inventing a host, and this parser does the same. The path is still the +component and appears in the description, so nothing is lost. + +### Fields worth noting + +- **CWE** is read from `CWE-639` or a bare `639`; anything else leaves it unset. +- **Vulnerability identifiers** come from Akto's free-text `testCve`, sorted and deduplicated + case-insensitively — an API-security test usually has none, but a dependency-related one may name + several. +- **References** are Akto's own issue link, then the test's reference links. +- **Dates** are unix seconds; a zero keeps DefectDojo's default of today. + +### Sample Scan Data + +Sample Akto scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/akto). + +The samples are constructed from Akto's documented issue shape and cover a full BOLA finding, a relative +path, a collection id sent as a string, duplicate CVEs in mixed case, an ignored issue, a fixed one, an +unrecognised severity, an unparseable CWE, and an issue with no name, method or path at all. Hostnames +are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- endpoints +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/automox.md b/docs/content/supported_tools/parsers/file/automox.md new file mode 100644 index 00000000000..1ce0ea9ecea --- /dev/null +++ b/docs/content/supported_tools/parsers/file/automox.md @@ -0,0 +1,78 @@ +--- +title: "Automox" +toc_hide: true +--- + +Import an [Automox](https://www.automox.com/) export of awaiting (missing) patches. + +This exists for organisations that cannot grant Automox API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Automox connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. Each missing patch is one finding. + +Automox reports the **patch and its device from two different endpoints**, so an export carries both +lists and they are joined on the package's `server_id`: + +```json +{ + "devices": [ { "id": 1001, "name": "generic-host-01", "os_family": "Windows" } ], + "packages": [ { "id": 900001, "server_id": 1001, "name": "example-runtime" } ] +} +``` + +The devices list may be named `servers` instead, matching the endpoint it comes from. The packages +list may be named `packages`, `data` or `results` — or the file may be a **bare array of packages**, +which is exactly what the packages endpoint returns. + +A package whose device is not in the export is **still a finding**; the device-derived lines are +simply absent. The connector's device lookup is a map read that can miss, and it converts anyway — +dropping the finding would lose a real missing patch because the device list did not travel with it. + +A row with **no usable `id` is dropped**: the id is the whole identity, and every row without one +would collapse onto `automox-0`. Automox's own decoder rejects the entire page when an id is not +numeric; dropping the single row keeps the rest of the export importable. + +Numbers may arrive **quoted** (`"id": "900002"`), for ids and scores alike, because Automox's own +decoder accepts either. A `cve_score` that is not a number becomes `0.0` rather than failing the +import, matching the connector's decoder. + +### Severity + +| Automox `severity` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| `none`, `unknown`, `no_known_cves`, absent | Info | + +### Fields worth noting + +- **Title** is `Missing patch: `, falling back to the package name and then the id. +- **The component is the package**, not the device, so the same missing patch on two devices hashes + the same — the package id in the identity is what keeps those two findings apart. +- **The status line** only appears when the patch is available and *not* installed. +- **Timestamps** are Automox's `2006-01-02T15:04:05-0700` form first, then RFC 3339. An unparseable + one leaves the finding date at the import default rather than dropping the finding. +- **Tags** are the device's OS family and `requires-reboot`. The connector's own comment says the + severity is tagged too; its code does not, and the code is what is mirrored here. + +### Sample Scan Data + +Sample Automox scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/automox). + +The samples are constructed from Automox's documented device and package responses and cover a +patch with two CVEs, a package whose device is absent, quoted numerics, a non-numeric score, an +already-installed package, a package with no name, an unparseable timestamp, two rows with no usable +id, and a bare packages array with no devices at all. Host and package names are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/beagle.md b/docs/content/supported_tools/parsers/file/beagle.md new file mode 100644 index 00000000000..a566aaf817b --- /dev/null +++ b/docs/content/supported_tools/parsers/file/beagle.md @@ -0,0 +1,102 @@ +--- +title: "Beagle Security" +toc_hide: true +--- + +Import a [Beagle Security](https://beaglesecurity.com/) test report. + +This exists for organisations that cannot grant Beagle Security API credentials — air-gapped +networks, procurement restrictions, a pending security review. The DefectDojo Pro Beagle Security +connector pulls the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. Beagle returns a report as a JSON **string** inside an envelope — `{"result": "{...}"}` — so +both the envelope and the report body it carries are accepted. + +### The report schema is only partly published + +Beagle documents the report-level keys (`project_name`, `application_name`, `url`, +`generated_date`, `approved_date`) and the occurrence block, but **not the names of the per-finding +fields**, and their only sample report cuts the finding array out. The connector handles this by +reading each field from a set of plausible aliases, and this parser accepts exactly the same set: + +| Field | Accepted keys, in preference order | +| --- | --- | +| name | `name`, `title`, `vulnerability_name`, `vulnerabilityname`, `signature`, `signature_name`, `vulnerability` | +| severity | `severity`, `risk`, `risk_level`, `risklevel`, `severity_level`, `severitylevel`, `priority` | +| score | `cvss_score`, `cvssscore`, `score`, `cvss`, `risk_score`, `riskscore` | +| vector | `cvss_vector`, `cvssvector`, `vector`, `cvss` | +| CWE | `cwe`, `cwe_id`, `cweid`, `cwes` | +| description | `description`, `details`, `detail`, `summary`, `impact`, `vulnerability_description` | +| remediation | `remediation`, `solution`, `recommendation`, `recommendations`, `fix`, `mitigation` | +| occurrences | `occurences`, `occurrences`, `instances` | + +Keys are matched case-insensitively. `occurences` — one `r` — leads the list because that is the +vendor's own spelling. Inside an occurrence, `Method` and `Url` really are capitalised. + +The finding array itself is located by name (`vulnerabilities`, `signatures`, `vulnerability_list`, +`issues`, `findings`, `results`) and, failing that, by shape: the first key, in sorted order, whose +value is an array of objects. A documented report key is never mistaken for the finding list. + +### Severity + +The severity label is used when there is one: + +| Label | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| `info`, `informational`, `information` | Info | +| anything else | Info | + +An unrecognised label becomes Info rather than a guess, and the original label is kept as a tag so +the real value is not lost. A tenant that scores reports with CVSS instead of OWASP sends a bare +number under the same key, which is graded against the connector's floors — 9.0 Critical, 7.0 High, +4.0 Medium, anything lower Low. + +### One finding per occurrence + +An occurrence is one place a finding was observed — a method and a URL — so a finding reported on +three URLs becomes three findings. Their statuses often differ, which is exactly why they are kept +apart: an occurrence Beagle marks `Fixed` is imported as inactive and mitigated, and every other +status counts as open. `Fixed` is the only status value Beagle documents, so the rest of the enum is +treated as open rather than guessed at. A finding with no occurrences still produces one finding, +aimed at the application's own URL. + +### Deduplication hashes the endpoint + +This scan type's configuration pairs `unique_id_from_tool_or_hash_code` with a field set that +**includes `endpoints`**, so the parser always records the tested URL. That matters more here than +usual: the connector's unique id is a SHA-256 over the **application token**, which is a parameter of +every Beagle API call and is *not* part of a report. An export that happens to carry the token gets +connector-identical unique ids; otherwise no unique id is set at all, and the hash over title, +severity and endpoints is what matches a file import to an API sync. Inventing a token-less id would +produce something that deduplicates against nothing. + +### Fields worth noting + +- **Date** — every finding in a report is stamped with the report's `generated_date`, falling back to + `approved_date` and then to today, matching the connector. +- **Param** — the HTTP method the occurrence was found with. +- **Test session** — the connector adds the Beagle test-session id to each description. A report body + does not carry it, so that line is absent from a file import. + +### Sample Scan Data + +Sample Beagle Security scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/beagle). + +The samples are constructed from Beagle's documented report shape and cover a label severity, a +numeric CVSS severity, both occurrence spellings, a `Fixed` occurrence, a finding with no +occurrences, an unrecognised severity label, a finding array found by shape rather than by name, and +an unreadable report date. Hostnames are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- endpoints diff --git a/docs/content/supported_tools/parsers/file/bigid.md b/docs/content/supported_tools/parsers/file/bigid.md new file mode 100644 index 00000000000..91fde209fed --- /dev/null +++ b/docs/content/supported_tools/parsers/file/bigid.md @@ -0,0 +1,82 @@ +--- +title: "BigID" +toc_hide: true +--- + +Import a [BigID](https://bigid.com/) DSPM case export. + +This exists for organisations that cannot grant BigID API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro BigID connector pulls the +same data over the API; this parser accepts the same data as a file. + +### Only the count of affected objects is read — never the data + +A BigID case is a report about **sensitive data that was found**. Only the case identity, its policy +and data-source context, and the **count** of affected objects are read. No sample, preview or value +of the data itself is read out of the export or written into a finding, even when the file contains +one — so importing a case file does not copy regulated data into DefectDojo. + +### File Types + +JSON. Each case is one finding. BigID's own samples disagree about the envelope, so all three shapes +its client accepts work here: + +- a bare array of cases +- `{"data": {"cases": [...], "totalCount": n}}` +- `{"cases": [...], "totalCount": n}` + +The wrapped `data` form wins whenever it carries anything, matching the client's precedence. + +A case with **no `caseId` is dropped**: the id is the whole identity, and every row without one would +collapse onto the same finding. + +### Severity + +BigID has no informational tier of its own, so anything unrecognised lands in Info. + +| BigID `severityLevel` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| unrecognised or absent | Info | + +### Status + +| BigID `caseStatus` | Imported as | +| --- | --- | +| `resolved`, `remediated`, `closed` | inactive, **mitigated** | +| everything else | active | + +Only those three close a case. An unfamiliar status stays **active** — treating it as closed would +silently hide a live exposure. + +### Fields worth noting + +- **Title** is the case label, falling back to the policy name and then the case id. +- **The data source is the component**, so the same policy failing on two data sources stays two + findings. +- **Mitigation** is BigID's own remediation steps, when the case carries them. +- **Timestamps** are `updated_at` then `created_at` — **snake_case**, while every other field on a + case is camelCase. A value that is not a date falls through to the import default rather than + failing the file. +- **Tags** are the data-source type and the sensitivity classification. + +### Sample Scan Data + +Sample BigID scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/bigid). + +The samples are constructed from BigID's documented cases response and cover all three envelope +shapes, a remediated case, a resolved one, a case in an unfamiliar state, a quoted count, a zero and +a negative count, a case with no id, a timestamp that is not a date, and a case carrying sample +values that must not be read. Data-source and policy names are generic, and the sample-value fields +hold obvious placeholders. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/bright.md b/docs/content/supported_tools/parsers/file/bright.md new file mode 100644 index 00000000000..f4f3d709198 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/bright.md @@ -0,0 +1,69 @@ +--- +title: "Bright Security" +toc_hide: true +--- + +Import a [Bright Security](https://brightsec.com/) scan export. + +This exists for organisations that cannot grant Bright API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Bright connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. Bright's issues endpoint answers with a **bare array**, so an export is either that array or an +object carrying it — `{"issues": [...]}`, `{"items": [...]}`, or a scan object with the issues nested +under `scan`/`data`. + +### Severity + +| Bright `severity` | Severity | +| --- | --- | +| `Critical` | Critical | +| `High` | High | +| `Medium` | Medium | +| `Low` | Low | +| anything else | Info | + +Read case-insensitively. The CVSS score is recorded when Bright reports a non-zero one, and may arrive +as a number or a numeric string. + +### Deduplication hashes the endpoint + +This scan type's configuration pairs `unique_id_from_tool_or_hash_code` with a field set that +**includes `endpoints`**, so an endpoint is always recorded: the entry point Bright attacked, falling +back to **every** affected resource. The fallback is a list because Bright reports one issue against +several resources when the same weakness is reachable from more than one URL. + +An entry point that DefectDojo will not accept as a host is left out of the endpoints — an unusable host +fails the whole import rather than the one finding — but it still appears in the description. + +### The request and response are fenced, not rendered + +Both are raw HTTP captured from the target, so they go in fenced code blocks: they must not be read as +markup, and a reviewer needs them verbatim to reproduce the issue. + +### Fields worth noting + +- **CWE** is read from `CWE-79` or a bare `79`. An unparseable value leaves the CWE unset but still + appears in the description. +- **References** are the affected resources, one per line. +- **Mitigation** is Bright's own remediation text, left unset when it has none. +- **Every finding is active, dynamic and not static** — Bright attacks a running application. + +### Sample Scan Data + +Sample Bright Security scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/bright). + +The samples are constructed from Bright's documented scan and issue shape and cover a full issue with +request and response, a score sent as a string, a bare CWE number, an unparseable CWE, an unrecognised +severity, an issue with no entry point (so the resources are used), an issue with several resources, and +an entry point that cannot be a host. Hostnames are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- endpoints diff --git a/docs/content/supported_tools/parsers/file/calicocloud.md b/docs/content/supported_tools/parsers/file/calicocloud.md new file mode 100644 index 00000000000..4e9e3be55be --- /dev/null +++ b/docs/content/supported_tools/parsers/file/calicocloud.md @@ -0,0 +1,93 @@ +--- +title: "Calico Cloud Image Assurance" +toc_hide: true +--- + +Import a [Calico Cloud](https://www.tigera.io/calico-cloud/) Image Assurance export. + +This exists for organisations that cannot grant Calico Cloud API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Calico Cloud connector pulls +the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. Each vulnerability on each scanned image is one finding. + +Calico serves the image list and a given image's vulnerabilities from **two endpoints**, so an export +carries both. The vulnerabilities may be nested in their image: + +```json +{"images": [{"imageID": "img-0001", "repository": "generic-app", + "vulnerabilities": [{"id": "CVE-2000-0001"}]}]} +``` + +…or held in a map keyed by image id: + +```json +{"images": [{"imageID": "img-0001"}], + "vulnerabilities": {"img-0001": [{"id": "CVE-2000-0001"}]}} +``` + +A bare array of images works, as does an object naming the list `images`, `data` or `results`. A file +holding **only** the vulnerability call is accepted too — the identity then carries an empty image +id, which is what the connector would build for an image it knows nothing about, so the findings are +not silently dropped. + +### An image still being scanned contributes nothing + +Calico reports `Unknown` in `scan_result` or `result` while a registry scan is still being processed. +Those images are **skipped entirely**, matching the connector: their results are not finished, so +importing them would record a partial scan as a complete one. + +### Severity comes from CVSS, not from Calico's verdict + +| Source | Used when | +| --- | --- | +| `cvss3Score` | present and greater than zero | +| `cvss.base_score` | no `cvss3Score` | +| Calico's `severity` word | no score at all | + +| CVSS v3 base score | Severity | +| --- | --- | +| ≥ 9.0 | Critical | +| ≥ 7.0 | High | +| ≥ 4.0 | Medium | +| > 0 | Low | + +Calico's Pass/Warn/Fail verdict is **deliberately ignored**, and its severity word is only a +fallback: those thresholds are per-tenant configuration, not a severity, so using them would make the +same CVE a different severity in two tenants. `negligible` and `unknown` are Info. + +### Fields worth noting + +- **Title** is `: `, or whichever of the two is present. A name that merely repeats the id + is not doubled. +- **Identity** is `calico-cloud--`, so the same CVE in two images stays + two findings — two images to rebuild. +- **Only a CVE becomes a vulnerability id.** Calico issues its own advisory ids too, and those go in + `vuln_id_from_tool` alone. +- **The package name** comes from `package_name`, falling back to `package`. +- **The fix** is `fixVersions` joined, falling back to the single `fix` string; with no fix there is + no mitigation. +- **The image reference** is `/:`, falling back to the digest and then the + image id. The registry is only prefixed when there is a repository to prefix. +- **Numbers may arrive quoted**, matching the connector's own decoder. + +### Sample Scan Data + +Sample Calico Cloud scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/calicocloud). + +The samples are constructed from Calico's documented images and vulnerabilities responses and cover +both export shapes, a score that disagrees with the severity word, a nested and quoted base score, an +unscored Calico advisory, a negligible finding, a single fix string, an image still being scanned, an +image known only by its digest, one known only by its id, and a registry with a trailing slash. +Registry, repository and package names are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name +- component_version diff --git a/docs/content/supported_tools/parsers/file/codacy.md b/docs/content/supported_tools/parsers/file/codacy.md new file mode 100644 index 00000000000..08a6524837c --- /dev/null +++ b/docs/content/supported_tools/parsers/file/codacy.md @@ -0,0 +1,76 @@ +--- +title: "Codacy" +toc_hide: true +--- + +Import a [Codacy](https://www.codacy.com/) security-items export. + +This exists for organisations that cannot grant Codacy API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Codacy connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from Codacy's security-items search endpoint. The API's `data` envelope is accepted, as is a +bare array of items. + +### One endpoint, several scan types + +Codacy surfaces the output of several underlying scanners through a single security-items endpoint — +SCA, container scanning and DAST among them — and reports which in each item's `scanType`. This +parser flags a finding from that field rather than assuming: + +- **`DAST`** → `dynamic_finding`, since it looked at something running +- everything else (SCA, container, …) → `static_finding` + +The scan type, security category and the underlying detector (`Trivy`, `ZAP`, …) are imported as tags +so findings can be filtered by which scanner actually produced them. + +### Severity + +Codacy's own item priority: `Critical`→Critical, `High`→High, `Medium`→Medium, `Low`→Low, and anything +unrecognised→Info. + +### Findings Codacy has already dismissed + +An item can be ignored in Codacy with a reason. When that reason is *false positive*, the finding is +imported with `false_p` set, so triaged noise does not go back in front of the team. Any other ignore +reason — "acceptable risk", for instance — is **not** treated as a false positive: that is a real +finding somebody accepted. + +### Fields worth noting + +- **Component** — the vulnerable package is the **last** entry of the first non-empty dependency + chain, not the first. The first entry is the project itself, so taking it would name the application + as the vulnerable component on every SCA finding. The full path is written into the description. +- **CVE** — Codacy's `cve` is a typed string documented as possibly holding several identifiers, so it + is scanned for all of them and deduplicated. `vuln_id_from_tool` takes the first, falling back to + Codacy's own `itemSourceId` when there is no CVE at all. +- **CWE** — parsed from the first `CWE-` in the field; anything unparseable leaves it at 0. +- **Target** — a DAST item's scanned application, falling back to `affectedTargets` (which is where a + container item names its image). +- **Date** — Codacy's `openedAt`. The connector falls back to today when the timestamp will not parse, + so a finding always carries a date; that is mirrored here rather than corrected. + +### Scan type and deduplication + +The scan type is **`Codacy - Connectors Import`** — identical to the string the Codacy connector +reports, so a customer who uploads an export *and* later enables the connector gets one set of findings +that deduplicate rather than two copies of everything. + +Identity is Codacy's internal item id, carried as `unique_id_from_tool`. + +### Sample Scan Data + +Sample Codacy scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/codacy). + +The samples are constructed from Codacy's documented security-item schema, covering an SCA, a DAST and +a container item, with generic package names, a generic host and placeholder CVE identifiers. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/crowdstrike_spotlight.md b/docs/content/supported_tools/parsers/file/crowdstrike_spotlight.md new file mode 100644 index 00000000000..8c7d3dff0b2 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/crowdstrike_spotlight.md @@ -0,0 +1,73 @@ +--- +title: "CrowdStrike Falcon Spotlight" +toc_hide: true +--- + +Import a [CrowdStrike Falcon Spotlight](https://www.crowdstrike.com/) vulnerability export. + +This exists for organisations that cannot grant Falcon API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro CrowdStrike Spotlight +connector pulls the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. Export combined Spotlight vulnerability entities from the Falcon API, for example: + +``` +curl -H "Authorization: Bearer $FALCON_TOKEN" "https://api.crowdstrike.com/spotlight/combined/vulnerabilities/v1?filter=status:'open'&facet=cve&facet=host_info&facet=remediation" > spotlight.json +``` + +A bare JSON array of vulnerabilities is accepted, as is the API's `resources` envelope. + +Note this is Spotlight **vulnerabilities** only. Falcon **Detections** are a different shape and are +imported under their own scan type by the connector; this parser does not claim that scan type. + +### Scan type and deduplication + +The scan type is **`CrowdStrike:Spotlight - Connectors Import`** — identical to the string the +Spotlight connector reports. That is deliberate: a customer who uploads an export *and* later enables +the connector gets one set of findings that deduplicate, rather than two copies of everything. + +Deduplication identity is the Spotlight vulnerability id, carried as `unique_id_from_tool`. + +### Severity + +Severity comes from the **CVE's** severity, not the vulnerability's, mirroring the connector: +`CRITICAL`→Critical, `HIGH`→High, `MEDIUM`→Medium, `LOW`→Low, and anything unrecognised→Info. The +comparison is case-insensitive. + +The CVSS vector and base score are imported, and the connector's severity justification sentence is +reproduced — it records CrowdStrike's own grade, the base score, and the ExPRT rating. + +### Neither static nor dynamic + +Findings are marked **neither** `static_finding` **nor** `dynamic_finding`, which is what the +connector does. Spotlight reports vulnerable software present on a host from the Falcon agent's +inventory: it does not analyse source, and it does not probe a running service. This parser mirrors +the connector rather than picking one. + +### Fields worth noting + +- **Component version** — CrowdStrike returns no discrete version field, so the version is whatever + remains of `product_name_version` once the normalized product name is stripped from the front. +- **CWE** — the first entry of `cve.cwes` that parses as `CWE-`; entries that do not parse are + skipped, and a list with none parseable leaves the CWE at 0. +- **Host** — the finding records `host_info.hostname`, falling back to `local_ip`. +- **Tags** — the ExPRT rating (`exprt:`), `cisa-kev` when the CVE is in the CISA Known + Exploited Vulnerabilities catalog, and the host's own Falcon tags. + +### Sample Scan Data + +Sample CrowdStrike Spotlight scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/crowdstrike_spotlight). + +The samples are constructed from the documented Spotlight combined-vulnerabilities schema, with +generic hostnames, private-range addresses and placeholder CVE identifiers. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool +- title +- severity +- vulnerability_ids diff --git a/docs/content/supported_tools/parsers/file/datadog.md b/docs/content/supported_tools/parsers/file/datadog.md new file mode 100644 index 00000000000..5bc68575bca --- /dev/null +++ b/docs/content/supported_tools/parsers/file/datadog.md @@ -0,0 +1,91 @@ +--- +title: "Datadog Cloud Security" +toc_hide: true +--- + +Import a [Datadog Cloud Security](https://www.datadoghq.com/product/cloud-security-management/) +findings export. + +This exists for organisations that cannot grant Datadog API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Datadog connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the `/api/v2/posture_management/findings` response, with rows under `data`. A bare array of +rows, or a single row, is accepted too. + +Note the attributes are nested **twice**: `data[].attributes.attributes` holds the finding itself, +while the outer `attributes` object carries the row's tags and timestamp. Reading the outer object as +the finding yields nothing. + +### One endpoint, several kinds of finding + +Datadog returns everything it calls a security finding through that one endpoint — +misconfigurations, library and code vulnerabilities, attack paths, identity risks, API security — +distinguished only by `finding_type`. So **static versus dynamic is decided per row, not per file**: + +| `finding_type` | Imported as | +| --- | --- | +| `runtime_code_vulnerability`, `api_security`, `attack_path`, `workload_activity`, `identity_risk` | dynamic | +| everything else | static | + +A runtime finding is something Datadog watched happen; everything else is something it read from a +configuration or an inventory. + +### Findings Datadog has already dealt with are skipped + +Three independent signals, all honoured — otherwise a queue somebody has already triaged comes +straight back: + +- `status` of `muted`, `resolved` or `auto_closed` +- an explicit `workflow.mute.is_muted` +- for a compliance rule, `compliance.evaluation` of `pass` — a passing rule is the tool reporting that + nothing is wrong + +A **failing** evaluation is imported and appears in the description. + +### Severity + +| Datadog `severity` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| anything else | Info | + +`base_severity` is deliberately **not** consulted: it is the rule's default before Datadog adjusts for +the environment, and the adjusted `severity` is the one worth importing. + +### Fields worth noting + +- **Dates are unix milliseconds**, not seconds — `first_seen_at`, falling back to + `detection_changed_at`. Reading them as seconds would date every finding in 1970. A row with + neither keeps DefectDojo's default of today. +- **CVSS** comes from `severity_details.base`, falling back to `.adjusted`. The first block carrying + either a vector or a positive score supplies **both** values — mixing a vector from one with a score + from the other would describe a scoring that never existed. +- **Vulnerability identifiers** come from the advisory (its CVE and aliases) *and* from the title and + description, because Datadog names them in the prose for some finding types and only in the advisory + object for others. The recognised forms are CVE, GHSA, Go (`GO-YYYY-N`) and RHSA. +- **Service** is read out of Datadog's own `service:` tag. +- **Tags are deduplicated but not sorted.** The connector preserves the order it built them in; + sorting would be tidier and wrong, since a tag list that reorders on every sync reads as a change. + +### Sample Scan Data + +Sample Datadog scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/datadog). + +The samples are constructed from Datadog's documented findings response and cover a library +vulnerability with full CVSS and advisory data, a failing compliance rule, an API-security finding with +no row id, an unrecognised severity, and one row for each of the five ways Datadog says a finding is +not actionable. Hostnames, account identifiers and resource names are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/deepsource.md b/docs/content/supported_tools/parsers/file/deepsource.md new file mode 100644 index 00000000000..669dd2f61d8 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/deepsource.md @@ -0,0 +1,99 @@ +--- +title: "DeepSource" +toc_hide: true +--- + +Import a [DeepSource](https://deepsource.com/) export. + +This exists for organisations that cannot grant DeepSource API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro DeepSource connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — a **saved GraphQL response**. DeepSource has no REST API; everything goes through +`POST /graphql/`, so the file to upload is whatever that endpoint returned: + +``` +curl -H "Authorization: Bearer $DEEPSOURCE_TOKEN" -H "Content-Type: application/json" -d '{"query": "query { repository(login: \"org\", vcsProvider: GITHUB, name: \"repo\") { issueOccurrences(first: 100) { edges { node { id path beginLine endLine title issue { shortcode title shortDescription category severity analyzer { name shortcode } } } } } analysisRuns(first: 1) { edges { node { runUid createdAt finishedAt } } } } }"}' https://api.deepsource.io/graphql/ > deepsource.json +``` + +The response is wrapped in `data.repository`, with each collection behind a GraphQL +connection (`edges[].node`): + +```json +{"data": {"repository": { + "analysisRuns": {"edges": [{"node": {"runUid": "...", "finishedAt": "..."}}]}, + "issueOccurrences": {"edges": [{"node": {"id": "...", "path": "...", "issue": {...}}}]}, + "dependencyVulnerabilityOccurrences": {"edges": [{"node": {...}}]}}}} +``` + +Both of DeepSource's queries return that same repository envelope, so one file may carry +**issue occurrences**, **dependency vulnerabilities**, or both. The `data` wrapper may be omitted if +you saved just the repository object. + +DeepSource reports two different things and they are mapped differently: + +- **Analysis issue occurrences** (`issueOccurrences`) — static issues found in the code +- **Dependency vulnerabilities** (`dependencyVulnerabilityOccurrences`) — advisories against your + dependencies + +The latest `analysisRuns` entry dates the issue findings. + +### Severity: two ladders, because the category decides + +DeepSource grades **every** issue `CRITICAL`, `MAJOR` or `MINOR` regardless of what the issue actually +is — a missing docstring can be `MAJOR`. So the category decides which ladder applies: + +| Category | `CRITICAL` | `MAJOR` | `MINOR` | +| --- | --- | --- | --- | +| `SECURITY` | Critical | High | Medium | +| `BUG_RISK`, `PERFORMANCE`, `TYPECHECK`, `ANTI_PATTERN` | High | Medium | Low | +| `STYLE`, `DOCUMENTATION`, `COVERAGE` | Info | Info | Info | +| anything else | Info | Info | Info | + +A security issue keeps its grade; a bug-risk issue drops a step, because it describes a defect rather +than a weakness. Applying one ladder to both would either inflate every lint finding or bury the real +ones. + +**A hit from the `secrets` analyzer is Critical whatever DeepSource graded it** — a committed +credential is a committed credential. + +### Dependency advisories + +A separate mapping, since these carry a CVE, a component and a score that analysis issues do not: + +- **Severity** — the CVSS v3 band when the advisory is scored (≥9.0 Critical, ≥7.0 High, ≥4.0 Medium, + otherwise **Low**; a scored advisory is never Info). Unscored, its `cvssV3Severity` then `severity` + word decides, accepting GitHub's `MODERATE` spelling of medium. +- **Identifiers** — the advisory id followed by its aliases, upper-cased and deduplicated, so a CVE + and its GHSA both land on the finding. +- **Mitigation** — the fixed versions offered as alternatives, or an explicit note that none has been + published. "No fix published" is useful triage information; an empty field just reads as unfinished. +- **Reachability and fixability** — imported into the description when DeepSource supplies them. + +### Scan type and deduplication + +The scan type is **`DeepSource - Connectors Import`** — identical to the string the DeepSource +connector reports, so a customer who uploads an export *and* later enables the connector gets one set +of findings that deduplicate rather than two copies of everything. + +Identity is the occurrence or vulnerability id, carried as `unique_id_from_tool`. + +### Sample Scan Data + +Sample DeepSource scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/deepsource). + +The samples are real GraphQL response envelopes — `data.repository` with `edges[]`/`node` connections — +built from DeepSource's documented schema. They cover both severity ladders, the secrets-analyzer +override, and both scored and unscored advisories, with generic file paths and placeholder advisory +identifiers. A test pins the envelope shape, because an earlier draft of this parser read an invented +flat `{"occurrences": [...]}` structure that no DeepSource user could have produced. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- file_path diff --git a/docs/content/supported_tools/parsers/file/defender_for_cloud.md b/docs/content/supported_tools/parsers/file/defender_for_cloud.md new file mode 100644 index 00000000000..583e2c71851 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/defender_for_cloud.md @@ -0,0 +1,101 @@ +--- +title: "Microsoft Defender for Cloud" +toc_hide: true +--- + +Import a [Microsoft Defender for Cloud](https://azure.microsoft.com/products/defender-for-cloud) +sub-assessments export. + +This exists for organisations that cannot grant Azure API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Defender for Cloud connector +pulls the same data over the API; this parser accepts the same data as a file. + +**Not to be confused with Microsoft Defender for Endpoint**, which DefectDojo already parses as +`ms_defender`. Different product, different scan type, different parser. + +### File Types + +JSON — the ARM `subAssessments` response, with rows under `value`. A bare array of sub-assessments, or a +single one, is accepted too. + +### Only open vulnerabilities are imported + +Two filters, both the connector's: + +- **Status** — only a `status.code` of `Unhealthy` is an open finding. Healthy and NotApplicable + sub-assessments are left out so a reimport **closes** them. +- **Kind** — Defender returns every sort of sub-assessment through one endpoint. `SqlServerVulnerability` + and `GeneralVulnerability` are posture and configuration checks carrying no CVEs, so they are + excluded; the server and container-registry vulnerability types are imported. + +For an **unfamiliar** resource type the presence of a CVE decides. A new Defender scanner should not be +dropped silently, and a configuration baseline should not arrive as a vulnerability. + +### One field, two shapes + +The same finding is described differently depending on which scanner produced it, and the parser reads +both: + +| | Container-registry finding | Server finding | +| --- | --- | --- | +| package | `softwareDetails.packageName` | `softwareName` | +| version | `softwareDetails.version` | `softwareVersion` | +| fixed version | `softwareDetails.fixedVersion` | `recommendedVersion` | + +Reading only one shape would leave every finding of the other kind with no component — and the +component is what a reviewer patches. A container finding also names the image and digest in the +description. + +### Severity and CVSS + +| Defender `severity` | Severity | +| --- | --- | +| `Critical` | Critical | +| `High` | High | +| `Medium` | Medium | +| `Low` | Low | +| anything else | Info | + +The raw label is always recorded as the severity justification, together with the CVSS base score and +which version it was — so a value that graded as Info because it was unrecognised is still auditable. + +Defender reports a flat `cvssV30Score` on some shapes and a version-keyed `cvss` map on others. The +**highest** base score wins, and only a **v3** base reaches `cvssv3_score`: the same number means +different things on the v2 and v3 scales. + +### Deduplication is the ARM id alone + +This scan type's configuration pairs `unique_id_from_tool_or_hash_code` with a hash of +**`unique_id_from_tool` and nothing else**. The ARM sub-assessment id already encodes the subscription, +the resource and the finding, so it is the whole identity; adding a volatile field would split a finding +that had merely been regraded. + +### Fields worth noting + +- **Title** appends the package only when the display name is a bare CVE — `CVE-2000-0001` alone says + nothing about what is affected, and one CVE usually appears against several packages on one host. A + descriptive name is left as it is. +- **CVE ids** are matched against an **anchored** pattern, so a reference title like + `supersedes CVE-2000-0009` does not contribute another finding's identifier. +- **Defender's TVM `cve` field** arrives as a list, a single object, or a bare string. All three are + accepted, matching the connector's decoder. +- **Mitigation** keeps both the version to update to and Defender's own remediation text: they answer + what to do and how, and neither is always present. +- **The assessed resource** falls back to the last segment of an ARM or native resource id — the whole + path would bury the one part a reader needs. +- **Impact** is Defender's own impact statement, imported as-is. + +### Sample Scan Data + +Sample Defender for Cloud scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/defender_for_cloud). + +The samples are constructed from Azure's documented sub-assessment shape and cover a server finding with +both CVSS versions, a container finding with an image and digest and only a v2 score, a posture +recommendation, a healthy finding, an unfamiliar resource type that does carry a CVE, and one that does +not. Subscription ids, registries and hostnames are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/detectify.md b/docs/content/supported_tools/parsers/file/detectify.md new file mode 100644 index 00000000000..a7ad9fa60d1 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/detectify.md @@ -0,0 +1,81 @@ +--- +title: "Detectify" +toc_hide: true +--- + +Import a [Detectify](https://detectify.com/) vulnerabilities export. + +This exists for organisations that cannot grant Detectify API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Detectify connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from Detectify's vulnerabilities endpoint. The API's `vulnerabilities` envelope is accepted, as +is a bare array. + +### Scan type + +The scan type is **`Detectify Scan`** — identical to the string the Detectify connector reports. Note +it does **not** follow the ` - Connectors Import` pattern the other connector scan types use, +so it cannot be guessed from the vendor name. Matching it exactly is what lets a customer upload an +export *and* enable the connector without getting two copies of every finding. + +### Which findings are imported + +| Detectify `status` | Imported? | +| --- | --- | +| `patched` | no — resolved | +| `false_positive` | no — dismissed | +| `accepted_risk` | **yes**, flagged `risk_accepted` | +| anything else | yes | + +An accepted risk is deliberately kept rather than skipped: discarding it would lose the record that +somebody accepted the risk. It is imported and flagged instead. + +### Severity + +Detectify's own severity word: `critical`→Critical, `high`→High, `medium`→Medium, `low`→Low, and +`information` / `info` / `informational`→Info. Anything unrecognised becomes Info. + +### CVSS + +Detectify reports separate 2.0, 3.0 and 3.1 blocks. The **3.1** block is preferred, falling back to +**3.0**; the 2.0 block is ignored, because `cvssv3` is a v3 field and putting a v2 vector in it would +be wrong. A block counts as present when it carries either a score *or* a vector, so a vector-only +entry is not discarded. + +### CVE identifiers come from prose + +Detectify has no dedicated CVE field. Identifiers are extracted from the finding title, the +definition's title, description and risk text, and every reference name and link — then deduplicated +in order. That is what the connector does. + +### Fields worth noting + +- **Impact** — the definition's `risk` text, which is Detectify's description of what an attacker + gains. +- **Mitigation** — Detectify supplies no remediation prose, only reference links, so the finding + points at them rather than leaving the field empty. +- **CWE** — reported by Detectify as a plain integer, not a `CWE-` string. +- **`vuln_id_from_tool`** — the definition's title, which is Detectify's stable rule name. +- **Endpoint** — the request URL where there is one; otherwise the host, with the location appended + only when it starts with `/` (otherwise it is not a path and concatenating it would produce a + nonsense host); otherwise the location alone. + +### Sample Scan Data + +Sample Detectify scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/detectify). + +The samples are constructed from Detectify's documented vulnerability schema, covering all three CVSS +blocks with deliberately different values, each of the closed-out statuses, an accepted risk, and each +branch of the endpoint preference order, with generic hosts and placeholder CVE identifiers. + +### Default Deduplication Hashcode Fields + +Detectify findings carry a stable uuid, which is imported as `unique_id_from_tool` and used as the +primary deduplication identity. By default, DefectDojo falls back to these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/dragos.md b/docs/content/supported_tools/parsers/file/dragos.md new file mode 100644 index 00000000000..75c43296f74 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/dragos.md @@ -0,0 +1,92 @@ +--- +title: "Dragos" +toc_hide: true +--- + +Import a [Dragos Platform](https://www.dragos.com/platform/) vulnerability-detection export. + +This exists for organisations that cannot grant Dragos API credentials — air-gapped networks, +procurement restrictions, a pending security review. Dragos monitors OT networks, which are often the +most isolated environments an organisation runs, so a file import is frequently the only way the data +can move at all. The DefectDojo Pro Dragos connector pulls the same data over the API; this parser +accepts the same data as a file. + +### File Types + +JSON — the detections response, `{"content": [...]}`, as Dragos pages it. A bare array is accepted, +as is an object naming the list `detections`, `data` or `results`. + +Each detection carries the **asset it was found on** rather than pointing at one, so a single list is +the whole export and nothing has to be joined. + +### Severity comes from CVSS, not from Dragos's own scale + +| Source | Used when | +| --- | --- | +| `score.base` (CVSS) | present and greater than zero | +| `severity` (Dragos 0–5) | no CVSS score | + +| CVSS base score | Severity | | Dragos `severity` | Severity | +| --- | --- | --- | --- | --- | +| ≥ 9.0 | Critical | | 5 | Critical | +| ≥ 7.0 | High | | 4 | High | +| ≥ 4.0 | Medium | | 3 | Medium | +| > 0 | Low | | 2 | Low | +| | | | 0–1 | Info | + +Dragos's own scale runs the **other way from a score** — 5 is the most severe — so reading one as the +other would invert the entire ladder. CVSS is the more portable signal, so it wins where both exist. + +### OT context is a justification, not a regrade + +Whether a flaw is actively exploited decides whether it is handled in the next maintenance window or +can wait for the next outage. Dragos's exploitability intel and its own risk score are recorded as +the **severity justification**: + +> Dragos OT context: actively exploited; public proof of concept exists; remotely exploitable; Dragos +> risk score 8.5. + +The severity itself is not moved, so the same advisory grades the same here as it does through the +API. `active_exploit` is also a tag, for filtering. + +### Purdue level 0 is a real level + +Dragos leaves `pera_level` **out** when it does not know the level, and level 0 is the physical +process layer — the most sensitive tier in the Purdue model. An absent level and level 0 therefore do +not render alike: the description reports `**Purdue level:** 0` for the latter and omits the line for +the former. + +### Fields worth noting + +- **Title** is the vulnerability title, then the Dragos advisory id, then the internal id. +- **`vuln_id_from_tool`** prefers the **Dragos advisory** (`report_id`) — that is what an OT engineer + looks up — then the enumeration, the reference, and only last the internal id. +- **Identity** is `dragos--`, so the same advisory on two devices stays + two findings. In an OT estate those two may sit at different Purdue levels, which is exactly why + they must not collapse. +- **The asset is the component**, named by its own name, then its first hostname, then its first + address, then its id — an OT device often has none of the first three. +- **Advisory ids** are read out of the reference, enumeration and title, and come back **sorted** + rather than in the order they appear: the connector's shared extractor sorts and drops + case-insensitive duplicates. `CVE-`, `GHSA-`, `GO-` and `RHSA-` forms are recognised. +- **The risk score** renders in its shortest form, so 6.0 reads as `6`. +- **Numbers may arrive quoted**, matching the connector's own decoder. + +### Sample Scan Data + +Sample Dragos scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/dragos). + +The samples are constructed from Dragos's documented detections response and cover a CVSS score that +disagrees with the Dragos severity, a quoted severity and score, Purdue level 0 alongside an absent +level, an asset named only by hostname, one named only by address, one known only by its id, a blank +hostname, two references to the same CVE in different fields, a GHSA identifier, an empty mitigation +list, and a detection with no exploitability intel at all. Asset names, addresses and vendors are +generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/elastic_security_cnvm.md b/docs/content/supported_tools/parsers/file/elastic_security_cnvm.md new file mode 100644 index 00000000000..d3e89a42a1b --- /dev/null +++ b/docs/content/supported_tools/parsers/file/elastic_security_cnvm.md @@ -0,0 +1,81 @@ +--- +title: "Elastic Security CNVM" +toc_hide: true +--- + +Import an [Elastic Security](https://www.elastic.co/security) export and report its Cloud Native +Vulnerability Management findings. + +This exists for organisations that cannot grant Elasticsearch API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Elastic Security connector +pulls the same data over the API; this parser accepts the same data as a file. + +### Three scan types, three parsers + +Elastic returns CNVM vulnerabilities, posture evaluations and detection alerts from the **same** +`_search` API, with the same ECS asset, host and cloud objects describing where each finding sits. The +connector imports them under three scan types, each behind its own toggle and with its own +deduplication key, so there are three parsers to match: + +| Data | Parser | +| --- | --- | +| Software CVEs found in workloads | Elastic Security CNVM (this one) | +| Failing cloud/Kubernetes benchmark rules | Elastic Security Posture | +| Detection-engine alerts | Elastic Security Detections | + +Each parser claims only its own documents, so the same export can be uploaded three times without one +document being imported under three scan types. **This parser imports documents carrying a +`vulnerability.id`.** + +### File Types + +JSON — an Elasticsearch search response (`{"hits": {"hits": [...]}}`). A bare array of documents and a +single document are accepted too. Each document keeps its `_id`, which is the finding's identity. + +### Severity + +Elastic's own `vulnerability.severity` label is used when it is one of `critical`, `high`, `medium`, +`low`, `informational`, `info`, `none` or `unknown` (case-insensitively). Anything else is **not** +graded by resemblance — the CVSS base score decides instead: + +| CVSS base | Severity | +| --- | --- | +| ≥ 9.0 | Critical | +| ≥ 7.0 | High | +| ≥ 4.0 | Medium | +| > 0 | Low | +| 0, or no score at all | Info | + +Note the label wins even when the score disagrees, which is the connector's behaviour: Elastic's label +already reflects its own enrichment. Only a **v3** score reaches the `cvssv3_score` field — Elastic +also reports v2 bases — but every score appears in the description with its version, so nothing is +lost. + +### Fields worth noting + +- **Identity** is the Elasticsearch document id, which is stable across syncs. Only a hand-assembled + export lacks one; then the asset, the CVE and the package stand in. +- **Asset context** — the resource, host, OS, cluster/namespace and cloud account are rendered into + the description, and the cloud provider, region and cluster become tags. +- **The endpoint** is the host, falling back to the resource or pod name: a bucket has no hostname but + is still worth recording. +- **Mitigation** names the fixed version when Elastic reported one, and otherwise says plainly that + none is published yet. + +### Sample Scan Data + +Sample Elastic Security scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/elastic_security_cnvm). + +The samples are constructed from Elastic's documented CNVM document shape and cover a label that +overrides the score, an unrecognised label graded from a v3 score, a v2-only score, a document with no +score at all, a Kubernetes pod asset, a document with no `_id`, and a document with no CVE id. They +also include posture and detection documents, which this parser must ignore. Hostnames and account +identifiers are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/elastic_security_detections.md b/docs/content/supported_tools/parsers/file/elastic_security_detections.md new file mode 100644 index 00000000000..42d6db31722 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/elastic_security_detections.md @@ -0,0 +1,75 @@ +--- +title: "Elastic Security Detections" +toc_hide: true +--- + +Import an [Elastic Security](https://www.elastic.co/security) export and report its detection-engine +alerts. + +This exists for organisations that cannot grant Elasticsearch API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Elastic Security connector +pulls the same data over the API; this parser accepts the same data as a file. + +### Three scan types, three parsers + +Elastic returns CNVM vulnerabilities, posture evaluations and detection alerts from the **same** +`_search` API. The connector imports them under three scan types, each behind its own toggle and with +its own deduplication key, so there are three parsers to match — see the Elastic Security CNVM page +for the table. **This parser imports documents carrying an alert**, under either `kibana.alert` (the +current detection engine) or a top-level `signal` object (the older one); an export taken from an +existing index may carry either. + +### File Types + +JSON — an Elasticsearch search response (`{"hits": {"hits": [...]}}`). A bare array of documents and a +single document are accepted too. + +### A detection is not a defect + +Detections describe observed activity rather than a fixable weakness. Two consequences, both the +connector's: + +- Findings are imported as **neither static nor dynamic** — no test found them; a rule matched a + stream of events. +- The mitigation is a **triage instruction**, not a fix. Closing a detection means completing an + investigation, and saying so keeps a triage queue from being read as a remediation backlog. + +### Severity + +The alert's own `severity` is used, falling back to the rule's. Recognised values are `critical`, +`high`, `medium`, `low`, `informational`, `info`, `none` and `unknown`; anything else — including an +absent label — becomes **Medium**. + +Elastic's `risk_score` is a 0-100 scale rather than a severity, so it is reported in the description +instead of being converted into one. It is taken from the alert, falling back to the rule that raised +it. + +### Fields worth noting + +- **Title** is the detection rule's name, falling back to the alert's own reason. An alert with + neither says nothing and is skipped. +- **Identity** is the Elasticsearch document id, falling back to the alert uuid. +- **`vuln_id_from_tool`** is the rule uuid, which is what this scan type's deduplication hash keys on: + it groups repeated firings of the same rule. +- **The workflow status** Elastic holds for the alert (`open`, `acknowledged`, `closed`) is recorded in + the description. It is deliberately not mapped onto the finding's own status — the connector leaves + DefectDojo's triage to DefectDojo. +- **Event categories** and rule tags become finding tags, so a detection can be filtered by what it + was about. + +### Sample Scan Data + +Sample Elastic Security scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/elastic_security_detections). + +The samples are constructed from Elastic's documented alert shape and cover a `kibana.alert` document +with a full rule, a legacy `signal` document with an unrecognised severity and a risk score sent as a +string, and an alert with neither a rule name nor a reason. They also include CNVM and posture +documents, which this parser must ignore. Hostnames and account identifiers are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/elastic_security_posture.md b/docs/content/supported_tools/parsers/file/elastic_security_posture.md new file mode 100644 index 00000000000..3715d95613b --- /dev/null +++ b/docs/content/supported_tools/parsers/file/elastic_security_posture.md @@ -0,0 +1,68 @@ +--- +title: "Elastic Security Posture" +toc_hide: true +--- + +Import an [Elastic Security](https://www.elastic.co/security) export and report the cloud and +Kubernetes benchmark rules that failed evaluation. + +This exists for organisations that cannot grant Elasticsearch API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Elastic Security connector +pulls the same data over the API; this parser accepts the same data as a file. + +### Three scan types, three parsers + +Elastic returns CNVM vulnerabilities, posture evaluations and detection alerts from the **same** +`_search` API. The connector imports them under three scan types, each behind its own toggle and with +its own deduplication key, so there are three parsers to match — see the Elastic Security CNVM page +for the table. **This parser imports documents carrying a `rule` whose `result.evaluation` is +`failed`.** + +### File Types + +JSON — an Elasticsearch search response (`{"hits": {"hits": [...]}}`). A bare array of documents and a +single document are accepted too. + +### Only failed evaluations are imported + +Elastic writes a document for every rule evaluation, passed or failed. A passing rule is not a +finding, and a rule with no name has nothing to report, so both are skipped. The evaluation is read +case-insensitively. + +### Severity + +Elastic's own `rule.severity` label is used when it is one of `critical`, `high`, `medium`, `low`, +`informational`, `info`, `none` or `unknown`. Anything else — including an absent label — becomes +**Medium**, not Info: a posture document has no score to fall back on, and a failing benchmark rule is +a real finding whatever Elastic called its severity. + +### Fields worth noting + +- **Identity** is the Elasticsearch document id, which is stable across syncs. Without one, the asset + and the rule stand in, which keeps the same rule failing on two assets apart. +- **`vuln_id_from_tool`** is the rule id, which is what this scan type's deduplication hash keys on + instead of a component — a benchmark rule is not about a package. Elastic's cloud benchmarks do not + always carry a rule id, so the benchmark's own numbering (`:`) stands in + before the rule name does. +- **The benchmark** is recorded as the component, with its version. +- **A repeated rationale is not printed twice.** Elastic often copies the rationale into the + description; printing the same paragraph twice reads as a rendering error. +- **The impact of remediating** is included — Elastic documents the cost of the fix, and it belongs + with the finding rather than being dropped. + +### Sample Scan Data + +Sample Elastic Security scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/elastic_security_posture). + +The samples are constructed from Elastic's documented posture document shape and cover a Kubernetes +benchmark rule, a cloud benchmark rule with no rule id, an uppercase `FAILED`, a passing rule, an +unnamed rule, and a rule whose description repeats its rationale. They also include CNVM and detection +documents, which this parser must ignore. Hostnames and account identifiers are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/endorlabs.md b/docs/content/supported_tools/parsers/file/endorlabs.md new file mode 100644 index 00000000000..862c2eb4e94 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/endorlabs.md @@ -0,0 +1,83 @@ +--- +title: "Endor Labs" +toc_hide: true +--- + +Import an [Endor Labs](https://www.endorlabs.com/) findings export. + +This exists for organisations that cannot grant Endor Labs API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Endor Labs connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from Endor Labs' findings list endpoint. The API's `list.objects` envelope is accepted, as is a +bare `objects` array or a bare array of findings. + +### Reachability is imported as the finding's impact + +Endor Labs is reachability-aware SCA: its distinguishing output is whether the vulnerable code is +actually *called*, not merely present. That verdict is imported as the finding's **impact**, which is +where the connector puts it, so triagers see it prominently rather than having to notice a tag. + +The verdict is resolved in the connector's order of precedence — a function-level verdict outranks a +dependency-level one, and a definite verdict outranks a "potentially": + +| Endor tag | Impact | +| --- | --- | +| reachable function | Reachable (vulnerable function is called) | +| unreachable function | Unreachable (vulnerable function is not called) | +| potentially reachable function | Potentially reachable (function reachability undetermined) | +| reachable dependency | Reachable (dependency is used) | +| unreachable dependency | Unreachable (dependency is not used) | +| potentially reachable dependency | Potentially reachable (dependency reachability undetermined) | + +### Severity + +Endor Labs grades its own findings, and the connector maps its levels directly: +`FINDING_LEVEL_CRITICAL`→Critical, `HIGH`→High, `MEDIUM`→Medium, `LOW`→Low. Anything unrecognised or +unspecified is clamped to Info. + +### Advisory text is flattened, never rendered + +Endor Labs advisory text arrives as HTML, sourced from upstream advisories. It is flattened to plain +text on import: `script` and `style` content is dropped, block tags become newlines, and the result is +HTML-escaped. Nothing from an upstream advisory can be injected into a rendered finding. + +### Fields worth noting + +- **CVSS** — the v3 score and vector, falling back to the **v4 base score** when Endor publishes only + v4. Without that fallback a v4-only advisory would import with no score at all. +- **Vulnerability IDs** — the primary identifier followed by Endor's aliases, deduplicated in order, + so a GHSA and its CVE both land on the finding. +- **Component** — the target dependency name, falling back to its package name. +- **EPSS** — the exploit-prediction probability is written into the description when Endor supplies it. +- **Tags** — Endor's finding tags and categories, with the enum prefix stripped and the rest + lower-cased and hyphenated. Values ending `_UNSPECIFIED` are dropped: they only record that Endor + did not determine something. +- **Findings with no vulnerability** — Endor also reports secrets and other non-CVE findings, which + carry no vulnerability block. Those import with no CVE, score or reachability. + +### Scan type and deduplication + +The scan type is **`Endor Labs - Connectors Import`** — identical to the string the Endor Labs +connector reports, so a customer who uploads an export *and* later enables the connector gets one set +of findings that deduplicate rather than two copies of everything. + +Identity is the Endor finding UUID, which is stable across syncs. When a finding arrives without one, +the connector composes `|:` instead, and so does this parser. + +### Sample Scan Data + +Sample Endor Labs scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/endorlabs). + +The samples are constructed from Endor Labs' documented findings schema, with generic package names, +placeholder identifiers and a generic tenant namespace. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/escape.md b/docs/content/supported_tools/parsers/file/escape.md new file mode 100644 index 00000000000..aa152860ff7 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/escape.md @@ -0,0 +1,71 @@ +--- +title: "Escape" +toc_hide: true +--- + +Import an [Escape](https://escape.tech/) API-security scan export. + +This exists for organisations that cannot grant Escape API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Escape connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. Escape nests issues under the scan that produced them, and the connector reads an application's +**latest** scan, so all of these are accepted: + +- a scan: `{"issues": [...]}` +- an application carrying one: `{"lastScan": {"issues": [...]}}` (or `scan`) +- an applications response: `{"applications": [{"lastScan": {"issues": [...]}}]}` +- the issue list itself: `[...]` + +### Severity + +| Escape `severity` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| `info` | Info | +| anything else | Info | + +Escape rates by OWASP API category and a severity word; an unrecognised label becomes Info rather than +a guess. + +### Deduplication hashes the endpoint + +This scan type's configuration pairs `unique_id_from_tool_or_hash_code` with a field set that +**includes `endpoints`**, so the parser always records the tested URL — scheme, host, port, path and +query. An unpopulated endpoint would leave the hash computed over nothing and every rescan would +reimport. + +The description's endpoint line carries the **method** alongside the URL (`POST https://…`) when Escape +reported one, because the same URL behaves differently per verb — which is the point of an API +scanner. The method also becomes an uppercased `method:` tag, while the description keeps Escape's own +casing, matching the connector. + +### Fields worth noting + +- **CWE** is read from `CWE-89` or a bare `89`. An unparseable value leaves the CWE unset but still + appears in the description, so nothing is lost. +- **Mitigation** is Escape's own remediation text and is left unset when it has none, rather than + filled with generic advice. +- **Every finding is active, dynamic and not static** — Escape exercises a running API. + +### Sample Scan Data + +Sample Escape scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/escape). + +The samples are constructed from Escape's documented scan and issue shape and cover a full issue with +CWE and remediation, a bare CWE number, an unparseable CWE, an unrecognised severity, an issue with no +method, an issue with no URL at all, and both the scan and application export shapes. Hostnames are +generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- endpoints diff --git a/docs/content/supported_tools/parsers/file/fairwinds.md b/docs/content/supported_tools/parsers/file/fairwinds.md new file mode 100644 index 00000000000..ee89b0a20bc --- /dev/null +++ b/docs/content/supported_tools/parsers/file/fairwinds.md @@ -0,0 +1,75 @@ +--- +title: "Fairwinds Insights" +toc_hide: true +--- + +Import a [Fairwinds Insights](https://www.fairwinds.com/insights) action-items export. + +This exists for organisations that cannot grant Fairwinds API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Fairwinds Insights connector +pulls the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from Fairwinds' action-items endpoint. A bare array is accepted, as is an object wrapping the +items under `ActionItems`, `items` or `data`. + +Note Fairwinds' JSON keys are **PascalCase** — `Title`, `Severity`, `ResourceKind`. + +### Severity is a 0.0–1.0 score, not a word + +Fairwinds normalises severity to a **float**. It is neither a severity word nor a CVSS score, and +treating the number as CVSS would put every finding at Info. The breakpoints are Fairwinds' own: + +| Fairwinds `Severity` | Severity | +| --- | --- | +| ≥ 0.9 | Critical | +| ≥ 0.7 | High | +| ≥ 0.4 | Medium | +| ≥ 0.1 | Low | +| below 0.1, or unparseable | Info | + +### One stream, several scanners + +Fairwinds aggregates Polaris, Trivy, OPA, kube-bench, Goldilocks and others into a single action-item +stream, so an item may be about a **container image** or a **Kubernetes manifest**. The component +reflects whichever it is: the image and tag when there is one, otherwise the Kubernetes resource name. + +The originating tool is imported as a `tool:` tag, so findings can be filtered by which +scanner produced them. + +### Fields worth noting + +- **A fixed item is imported closed** (`is_mitigated`, not active) — Fairwinds tracks the `Fixed` flag, + and importing it active would put resolved work back in the open queue. +- **Resource** is rendered `namespace/kind/name`, with `(container: …)` appended when Fairwinds + identified one. Any segment may be missing and is simply skipped. +- **CVEs** are extracted from the title and description; Fairwinds has no dedicated CVE field. +- **Tags** carry the tool, category, cluster, namespace, event type and Fairwinds' own tags. The + cluster tag is added unconditionally, so an item with no cluster gets a bare `cluster:` tag — that is + the connector's behaviour, reproduced rather than tidied, since tidying it here would be a + difference between a file import and an API sync. +- **Description text is flattened**, not rendered as markup. + +### Scan type and deduplication + +The scan type is **`Fairwinds Insights - Connectors Import`** — identical to the string the Fairwinds +Insights connector reports, so a customer who uploads an export *and* later enables the connector gets +one set of findings that deduplicate rather than two copies of everything. + +### Sample Scan Data + +Sample Fairwinds Insights scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fairwinds). + +The samples are constructed from Fairwinds' documented action-item schema and cover every severity band, +a Trivy image finding and a Polaris manifest finding, a fixed item, an item with no title or +coordinates, an OPA admission event, and a malformed severity value. Cluster, namespace and image names +are generic placeholders. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/finitestate.md b/docs/content/supported_tools/parsers/file/finitestate.md new file mode 100644 index 00000000000..afa65858f7c --- /dev/null +++ b/docs/content/supported_tools/parsers/file/finitestate.md @@ -0,0 +1,100 @@ +--- +title: "Finite State" +toc_hide: true +--- + +Import a [Finite State](https://finitestate.io/) findings export. + +This exists for organisations that cannot grant Finite State API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Finite State connector pulls +the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. Finite State answers GraphQL, so a saved export is its findings response: + +```json +{ + "asset": {"id": "asset-0001", "name": "Generic Router"}, + "assetVersion": {"id": "ver-0001", "name": "1.4.0", "relativeRiskScore": 2.5}, + "data": {"allFindings": [ ... ]} +} +``` + +One export is **one firmware build**, so the asset and build context is stated once for the file +rather than repeated on every row — though a row carrying its own `asset`/`assetVersion` overrides it, +for an export that repeats the context per finding. A finding on firmware means little without knowing +which build it is in, which is why the connector passes both into every conversion. + +An unwrapped `allFindings`, a `findings` list, and a bare array of findings are all accepted. An +export with no build context still imports; it simply carries no asset lines and no build tag. + +### VEX status is the part that carries real semantics + +A Finite State finding can carry a **VEX assertion** — a product team's statement about whether the +vulnerability actually applies to this build: + +| `currentStatus.status` | Imported as | +| --- | --- | +| `NOT_AFFECTED` | inactive, **out of scope** (plus false positive — see below) | +| `FIXED` | inactive, **mitigated**, recording when | +| `UNDER_INVESTIGATION` | active, **under review** | +| `AFFECTED`, anything unrecognised, or no status block | **active** | + +`NOT_AFFECTED` must not sit in DefectDojo as active: leaving it there would put an answered question +back in the queue on every import. It is additionally marked a **false positive** when the +justification says the vulnerable code is not there to be reached — +`COMPONENT_NOT_PRESENT`, `VULNERABLE_CODE_NOT_PRESENT`, `VULNERABLE_CODE_NOT_IN_EXECUTE_PATH`. A +justification like `INLINE_MITIGATIONS_ALREADY_EXIST` means the flaw is real but handled: out of +scope, and **not** a false positive. The distinction matters for metrics. + +Anything unrecognised stays **active**, which is the safe direction to be wrong in — a finding wrongly +left active gets triaged, while one wrongly closed is never seen again. + +### Severity + +| Source | Used when | +| --- | --- | +| `severity` | it is a word Finite State uses | +| `cvssSeverity` | `severity` is not one of those words | + +`critical`/`high`/`medium`/`low` map across; `info`, `none` and **`unknown`** all mean Info. Note that +`unknown` is a value the platform actually uses, so a finding graded `unknown` is Info even when its +CVSS severity says Critical — the fall-through only happens for a word the platform does not use at +all. Treating `unknown` as missing would silently upgrade every unscored finding. + +### Scores + +- **CVSS**: the finding's own `cvssScore` wins; otherwise the first CVE's `cvssBaseMetricV3` base + score. The vector string comes from the first CVE that carries one. +- **EPSS**: per-CVE, so the **highest** across the finding's CVEs is used — that is the finding's real + exploitation likelihood. The percentile travels with the score it belongs to rather than being + mixed in from another CVE. + +### Fields worth noting + +- **The first affected component** is the component; Finite State can list several, and the first is + the one the finding is filed against. +- **CWE** is the first `cweId` that parses, accepting `CWE-79` or `79`; an unparseable one is skipped + rather than ending the search. +- **Tags** carry `firmware-build:`, the category and subcategory, the source types, the + tools that produced the finding, `regression`, and `weaponized`/`exploited-in-the-wild` from any of + the finding's CVEs. The build tag is what lets a reader tell which firmware a finding belongs to + without opening it, which matters when several builds of one product share a product. +- **The description is not trimmed**, matching the connector, so it keeps its trailing newline. + +### Deduplication + +This scan type has **no curated hashcode field list**, so it deduplicates with DefectDojo's default +algorithm — which is what the connector's own findings already do. Choosing hashcode fields here would +also change how those findings deduplicate, so it is left alone. + +### Sample Scan Data + +Sample Finite State scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/finitestate). + +The samples are constructed from the platform's GraphQL schema and cover an affected finding with two +CVEs at different EPSS scores, a `NOT_AFFECTED` finding justified by absence, another justified by +inline mitigations, a fixed one, one under investigation, one with no status block, an unrecognised +severity in both fields, an unparseable CWE followed by a bare number, an unparseable timestamp, and +an export with no build context at all. Asset, build and component names are generic. diff --git a/docs/content/supported_tools/parsers/file/fleet_policies.md b/docs/content/supported_tools/parsers/file/fleet_policies.md new file mode 100644 index 00000000000..a5e1552f9d6 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/fleet_policies.md @@ -0,0 +1,71 @@ +--- +title: "Fleet Policies" +toc_hide: true +--- + +Import a [Fleet](https://fleetdm.com/) host export and report the compliance policies that are failing. + +This exists for organisations that cannot grant Fleet API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Fleet connector pulls the same +data over the API; this parser accepts the same data as a file. + +Software CVEs in the same export are a **separate scan type**, imported by the Fleet Vulnerabilities +parser. Fleet's own API models policy results and software vulnerabilities as different things, and +the connector imports them under two scan types with different deduplication keys, so splitting them +keeps a file import deduplicating against an API sync. + +### File Types + +JSON — a Fleet host response. A host list (`{"hosts": [...]}`), a single host (`{"host": {...}}`), a +bare array of hosts and a bare host object are all accepted. + +Fleet nests each host's policy results inside the host, so one file produces a finding per host per +failing policy. + +### Only failing policies are imported + +Fleet reports every policy's outcome for every host, not just the failures. A policy whose `response` +is `fail` (in any casing) becomes a finding; `pass` does not, and neither does an empty response, +which means the query has not run on that host yet. A policy with no name is skipped — there would be +nothing to report. + +### Severity + +| Fleet policy | Severity | +| --- | --- | +| `critical: true` | High | +| everything else | Medium | + +Fleet has no severity scale for policies; `critical` is the only signal it gives, and a critical +policy is also tagged `critical-policy`. + +### One finding per host + +The identity is `:policy:`, so a policy failing on two machines is two findings — +remediating one does not fix the other. Both carry the same `vuln_id_from_tool` +(`fleet-policy-`), which is what this scan type's deduplication hash keys on instead of a +component: a policy is not about a package. + +### Fields worth noting + +- **Mitigation** is the policy's own resolution text, which is where Fleet puts the remediation step. +- **The policy query** is included in the description as a SQL block, so a reviewer can see exactly + what was checked. +- **Tags** carry both the policy's platform and the host's platform, sorted and deduplicated as the + connector does — an unordered tag list would read as a change on every reimport. + +### Sample Scan Data + +Sample Fleet scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fleet_policies). + +The samples cover a critical and a non-critical failing policy, a passing policy, a policy with no +result yet, a policy with no name, the same policy failing on two hosts with different platforms, and +an uppercase `FAIL`. Hostnames are generic and addresses are private-range. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/fleet_vulnerabilities.md b/docs/content/supported_tools/parsers/file/fleet_vulnerabilities.md new file mode 100644 index 00000000000..a679a3048f5 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/fleet_vulnerabilities.md @@ -0,0 +1,68 @@ +--- +title: "Fleet Vulnerabilities" +toc_hide: true +--- + +Import a [Fleet](https://fleetdm.com/) host export and report the CVEs in each host's installed +software. + +This exists for organisations that cannot grant Fleet API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Fleet connector pulls the same +data over the API; this parser accepts the same data as a file. + +Failing compliance policies in the same export are a **separate scan type**, imported by the Fleet +Policies parser. Fleet's own API models software vulnerabilities and policy results as different +things, and the connector imports them under two scan types with different deduplication keys, so +splitting them keeps a file import deduplicating against an API sync. + +### File Types + +JSON — a Fleet host response. A host list (`{"hosts": [...]}`), a single host (`{"host": {...}}`), a +bare array of hosts and a bare host object are all accepted. + +Fleet nests the software inventory inside the host and the CVEs inside each software row, so one file +produces a finding per host per software per CVE. Ask Fleet for the software and vulnerability detail +when exporting — `GET /api/v1/fleet/hosts/{id}` includes it, the summary host list does not. + +### Severity + +| CVSS score | Severity | +| --- | --- | +| ≥ 9.0 | Critical | +| ≥ 7.0 | High | +| ≥ 4.0 | Medium | +| > 0 | Low | +| 0 | Info | +| **not scored** | **Medium** | + +An unscored CVE is Medium rather than Info: Fleet enriches from the NVD, so a missing score means +"not scored yet" rather than "no risk". An explicit zero is still Info. Note the difference between +a `null` score, which means unscored, and an empty string, which the connector's decoder reads as a +zero — this parser reads them the same way. Scores and probabilities may arrive as numbers or as +numeric strings, and both are accepted. + +A CVE on CISA's Known Exploited Vulnerabilities list is flagged in the description and tagged +`cisa-known-exploited`, but its severity still comes from the CVSS score — the connector does not +raise it, so neither does this parser. + +### One finding per host + +The identity is `:::`, so the same CVE on two machines is two +findings. Collapsing them would hide a machine still running the vulnerable version. + +### Sample Scan Data + +Sample Fleet scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fleet_vulnerabilities). + +The samples are constructed from Fleet's documented host and software schemas and cover every +severity floor, an unscored CVE, a score sent as a string, EPSS, a CISA KEV entry, a CVE with and +without a fixed version, the same CVE on two hosts, and a vulnerability row with no CVE id. Hostnames +are generic and addresses are private-range. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/fortytwocrunch.md b/docs/content/supported_tools/parsers/file/fortytwocrunch.md new file mode 100644 index 00000000000..bfa1885119f --- /dev/null +++ b/docs/content/supported_tools/parsers/file/fortytwocrunch.md @@ -0,0 +1,96 @@ +--- +title: "42Crunch" +toc_hide: true +--- + +Import a [42Crunch](https://42crunch.com/) Security Audit or Conformance Scan report. + +This exists for organisations that cannot grant 42Crunch API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro 42Crunch connector pulls the +same data over the API; this parser accepts the same data as a file. + +### Two reports, one scan type + +42Crunch produces two different reports for the same API, and the connector converts both under a +single scan type: + +| Report | What it examines | Imported as | +| --- | --- | --- | +| **Security Audit** | the OpenAPI definition | **static** findings, one per issue occurrence | +| **Conformance Scan** | the running API | **dynamic** findings, one per scan issue | + +A file is one or the other, and the shape decides: a scan report has a per-path/method issue tree +under `data.paths`, an audit report has an `index` table and its category sections. + +### Give the report the API id + +Every identity the connector builds begins with the **API id**, and a downloaded report does not +carry one. Wrap the report to supply it: + +```json +{"apiId": "your-api-id", "report": { ... }} +``` + +`apiId`, `api_id` and `apiID` are all accepted, and the report may sit under `report`, `audit` or +`scan`. An unwrapped report imports fine, but **its findings will not deduplicate against +connector-synced ones**, because the identities differ by that prefix. + +### Both reports refer to their strings by integer + +Neither report stores its text inline. An audit occurrence's `pointer` is an index into the report's +`index` array; a scan issue's `injectionDescription` and `jsonPointer` are indexes into +`data.index.injectionDescriptions` and `data.index.jsonPointers`. A finding built from the integers +alone would carry no text at all, so each is looked up. + +An index that is **out of range resolves to nothing** rather than failing the import. For an audit +occurrence, the identity then keeps the raw index (`.../#99`) — without it, two occurrences of one +issue with no resolvable location would collapse into a single finding. + +Scan descriptions are **templates**: each `%s` is filled from `injectionDescriptionParams` in turn, +one substitution per parameter. A template with more placeholders than parameters keeps the leftovers +verbatim. + +### Severity + +42Crunch grades both report types on one integer scale where **5 is the most severe**: + +| `criticality` | Severity | +| --- | --- | +| 5 | Critical | +| 4 | High | +| 3 | Medium | +| 2 | Low | +| 1, 0, out of range, absent | Info | + +Criticality 1 is 42Crunch's informational tier. + +### Fields worth noting + +- **Audit titles prefer the shared issue description; audit bodies prefer the specific one.** That is + deliberate: the title groups every occurrence of an issue type under one name, while the body says + what is wrong at this particular location. +- **Titles are truncated to 250 characters** with a trailing ellipsis, as the connector does. The full + text stays in the description. +- **`file_path`** on an audit finding is the JSON Pointer into the OpenAPI definition — an audit + finding *is* a place in a definition. +- **A scan issue's own id is a per-scan UUID**, so it is not stable across scans. The identity is the + operation plus the check index instead, which is the same for the same issue — so rescanning updates + a finding rather than creating a new one each time. +- **The endpoint is the URL's origin only** (scheme and host, no path): a conformance scan hits many + paths on one host, and the operation is already in the description and the identity. A URL with no + scheme, or a host DefectDojo would reject, adds no endpoint — the URL is still in the description. +- **`steps_to_reproduce`** is 42Crunch's own curl command, on scan findings. + +### Deduplication + +This scan type has **no curated hashcode field list**, so it deduplicates with DefectDojo's default +algorithm — which is what the connector's own findings already do. + +### Sample Scan Data + +Sample 42Crunch scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fortytwocrunch). + +The samples cover an audit report with all five categories, one issue type firing at two locations, an +out-of-range pointer, a negative pointer, criticality 0, and a group with no description; plus a scan +report with a two-parameter template, a template with no parameters, an unresolvable description, a +zero response status, and a URL that is not a URL. API paths, hosts and identifiers are generic. diff --git a/docs/content/supported_tools/parsers/file/fossa.md b/docs/content/supported_tools/parsers/file/fossa.md new file mode 100644 index 00000000000..b2242fe838c --- /dev/null +++ b/docs/content/supported_tools/parsers/file/fossa.md @@ -0,0 +1,93 @@ +--- +title: "FOSSA" +toc_hide: true +--- + +Import a [FOSSA](https://fossa.com/) v2 issues export. + +This exists for organisations that cannot grant FOSSA API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro FOSSA connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from FOSSA's `getIssues` endpoint: + +``` +curl -H "Authorization: Bearer $FOSSA_TOKEN" "https://app.fossa.com/api/v2/issues?category=all" > fossa.json +``` + +A bare JSON array of issues is accepted, as is the API's `issues` envelope. + +### What is imported + +Both of FOSSA's issue categories: + +- **Security vulnerabilities** (`type: vulnerability`) — with CVE, CVSS score and vector, CWE, + affected and patched version ranges, and FOSSA's upgrade advice. +- **Licensing and quality issues** — policy conflicts, unlicensed dependencies, denylisted + dependencies, and the `risk_*` quality signals. + +An issue is treated as a vulnerability when its type says so **or** when it carries +vulnerability-only fields (`cve`, `vulnId`, `cvssVector`). That belt-and-braces check is the +connector's: a missing or renamed type value must not silently downgrade a CVE to a licensing +finding, which would also change how it is graded. + +### No file or line + +FOSSA is SCA. An issue hangs off a dependency, never a file and line, so the dependency +coordinates — package locator, name, version, package manager, and direct/transitive depth — are the +only location a finding has. They are written into the description. + +### Severity + +For a **vulnerability**, FOSSA's own `critical`/`high`/`medium`/`low`. FOSSA reports `unknown` often +enough to matter, and in that case severity falls back to the standard CVSS v3 bands (≥9.0 Critical, +≥7.0 High, ≥4.0 Medium, >0 Low, otherwise Info). + +For **licensing and quality** issues FOSSA publishes no severity at all, so this table is the +connector's and is mirrored here: + +| FOSSA issue type | Severity | +| --- | --- | +| `policy_conflict` (a denial) | High | +| `blacklisted_dependency` | High | +| `policy_flag` (advisory) | Medium | +| `unlicensed_dependency`, `unlicensed_and_public` | Medium | +| `outdated_dependency`, `risk_*` quality signals | Low | +| an unrecognised `risk_*` signal | Low | +| anything else | Info | + +Both spellings of the `risk_*` types are mapped: FOSSA's documentation table hyphenates +(`risk_empty-package`) while fossa-cli's wire format uses underscores (`risk_empty_package`). + +### One issue, several projects + +A single FOSSA issue can affect several projects at once, and the connector emits **one finding per +project**, suffixing the tool id with the project locator (`1001:custom+1/generic-app`). Without that +suffix the same dependency issue in two DefectDojo products would share a tool id and collapse into +one finding. + +This parser reproduces that from the issue's own `projects` list. An export carrying no project +context cannot reproduce the suffix, so the tool id is then the issue id alone. + +### Scan type and deduplication + +The scan type is **`FOSSA - Connectors Import`** — identical to the string the FOSSA connector +reports, so a customer who uploads an export *and* later enables the connector gets one set of +findings that deduplicate rather than two copies of everything. + +### Sample Scan Data + +Sample FOSSA scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fossa). + +The samples are constructed from FOSSA's documented `getIssues` response schema, with generic package +names, placeholder CVE identifiers and generic project locators. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/gitguardian.md b/docs/content/supported_tools/parsers/file/gitguardian.md new file mode 100644 index 00000000000..09ec1c14748 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/gitguardian.md @@ -0,0 +1,85 @@ +--- +title: "GitGuardian" +toc_hide: true +--- + +Import a [GitGuardian](https://www.gitguardian.com/) secret-incidents export. + +This exists for organisations that cannot grant GitGuardian API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro GitGuardian connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from GitGuardian's secret-incidents endpoint: + +``` +curl -H "Authorization: Token $GITGUARDIAN_API_KEY" "https://api.gitguardian.com/v1/incidents/secrets" > gitguardian.json +``` + +A bare JSON array of incidents is accepted, as is an object wrapping them under `incidents`, +`results` or `data`. + +### One finding per incident + +A GitGuardian incident is one distinct exposed credential, however many times it appears. The parser +creates one finding per incident, not per occurrence, and reports the occurrence count in the +description — which is what the connector does. + +### No secret value is imported + +GitGuardian's incidents endpoint does not return the matched secret, and nothing here reconstructs +one. A test asserts it, so a future change that starts pulling occurrences cannot quietly begin +copying credentials into the DefectDojo database. + +### Validity: the useful part + +GitGuardian actively checks whether a discovered credential still authenticates, and that verdict is +the most actionable thing it reports. It is spelled out in the description rather than left as a bare +enum value: + +| GitGuardian `validity` | Reported as | +| --- | --- | +| `valid` | confirmed still live and actively exploitable | +| `invalid` | the credential no longer authenticates | +| `no_checker`, `not_checked`, `failed_to_check` | unverified — could not be checked automatically, verify manually | + +A finding is marked **verified** only when GitGuardian confirmed the credential is `valid`. An +unchecked credential is not evidence either way, so marking it verified would overstate what +GitGuardian knows. + +Whether the secret has since been marked revoked in GitGuardian is also reported. + +### Severity + +GitGuardian's own incident severity: `critical`→Critical, `high`→High, `medium`→Medium, `low`→Low, and +anything unrecognised→Info. + +### Mitigation + +Every incident is an exposed credential, so the remediation is always the same sequence — revoke and +rotate, remove from the codebase, purge from version-control history, and review the incident in +GitGuardian for the affected locations. The connector hardcodes that text and so does this parser. + +### Scan type and deduplication + +The scan type is **`GitGuardian - Connectors Import`** — identical to the string the GitGuardian +connector reports, so a customer who uploads an export *and* later enables the connector gets one set +of findings that deduplicate rather than two copies of everything. + +Incident ids are stable, so deduplication hashes on `unique_id_from_tool` +(`gitguardian-incident-`) alone. Note this scan type uses the plain `hash_code` algorithm rather +than `unique_id_from_tool_or_hash_code`, matching the connector's configuration. + +### Sample Scan Data + +Sample GitGuardian scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/gitguardian). + +The samples are constructed from GitGuardian's documented secret-incident schema, with generic +incident names and a generic dashboard host. No sample contains a credential-shaped value. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/googlescc.md b/docs/content/supported_tools/parsers/file/googlescc.md new file mode 100644 index 00000000000..3a3e0acdd01 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/googlescc.md @@ -0,0 +1,75 @@ +--- +title: "Google Cloud Security Command Center" +toc_hide: true +--- + +Import a [Google Cloud Security Command Center](https://cloud.google.com/security-command-center) +findings export. + +This exists for organisations that cannot grant a GCP service account to DefectDojo — air-gapped +networks, procurement restrictions, a pending security review. The DefectDojo Pro Google Cloud SCC +connector pulls the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the SCC `ListFindings` response: + +``` +gcloud scc findings list ORGANIZATION_ID --format=json > scc.json +``` + +Results live under `listFindingsResults`. A bare array is accepted too. + +**Each result pairs the finding with the resource it was found on, as siblings rather than nested:** + +```json +{"listFindingsResults": [{ + "finding": {"name": "...", "category": "PUBLIC_BUCKET_ACL", "severity": "HIGH"}, + "resource": {"displayName": "generic-app-assets", "type": "google.cloud.storage.Bucket"} +}]} +``` + +Both halves matter — the resource carries the display name and type that make the finding readable, +while the category and severity are on the finding. An export somebody has already flattened (the +finding's own fields at the top level) is also accepted. + +### Severity + +SCC's severity level: `CRITICAL`→Critical, `HIGH`→High, `MEDIUM`→Medium, `LOW`→Low. SCC's own +`SEVERITY_UNSPECIFIED` is not a DefectDojo severity and falls through to **Info**, as does anything +unrecognised. + +### Fields worth noting + +- **Title** is ` - `, falling back to the category alone, and to + `Security Command Center finding` when SCC set no category — an empty title would be useless in the + finding list. +- **`vuln_id_from_tool` is the SCC category**, which is its rule identifier (`PUBLIC_BUCKET_ACL`, + `MFA_NOT_ENFORCED`). +- **`unique_id_from_tool` is the finding's full resource name** + (`organizations/…/sources/…/findings/…`), which is globally unique across the organisation — and is + the entire dedup hash for this scan type. +- **CVE and CVSS are only present on vulnerability-class findings.** SCC reports several classes — + misconfiguration, threat, observation — and only some carry a `vulnerability.cve` block, nested two + objects deep. The score is recorded only when above zero. + +### Scan type and deduplication + +The scan type is **`Google Cloud SCC - Connectors Import`** — identical to the string the Google Cloud +SCC connector reports, so a customer who uploads an export *and* later enables the connector gets one +set of findings that deduplicate rather than two copies of everything. + +### Sample Scan Data + +Sample Google Cloud SCC scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/googlescc). + +The samples are constructed from SCC's documented `ListFindings` response and cover a misconfiguration +with no CVE, a vulnerability with a CVE and score, one with a zero score, a finding with no category and +`SEVERITY_UNSPECIFIED`, and an empty vulnerability block. Organisation, project and bucket identifiers +are numeric or generic placeholders. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/groupib.md b/docs/content/supported_tools/parsers/file/groupib.md new file mode 100644 index 00000000000..f8a57916cad --- /dev/null +++ b/docs/content/supported_tools/parsers/file/groupib.md @@ -0,0 +1,91 @@ +--- +title: "Group-IB ASM" +toc_hide: true +--- + +Import a [Group-IB](https://www.group-ib.com/products/attack-surface-management/) Attack Surface +Management issue export. + +This exists for organisations that cannot grant Group-IB API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Group-IB connector pulls the +same data over the API; this parser accepts the same data as a file. + +### Two fields called "status" mean different things + +This is the trap worth knowing about: + +| Field | Meaning | +| --- | --- | +| the issue's own `status` | its **lifecycle** state — `Detected`, `Under review`, `Solved`, `Ignored`, `False positive` | +| `body.status` | its **severity** label — e.g. `Critical severity` | + +Reading one for the other would grade every finding Info *and* leave every solved issue open. + +### Severity is matched by containment + +Group-IB writes the severity as a phrase, so equality would never match: + +| `body.status` contains | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| `info` | Info | +| none of them | Info | + +The order matters: `critical` is tested before `high`, so a label naming both is graded by the worse. + +### Lifecycle status + +| Issue `status` | Imported as | +| --- | --- | +| `Solved` | inactive, **mitigated** | +| `False positive` | inactive, **false positive** | +| `Ignored` | inactive, **out of scope** | +| `Detected`, `Under review`, anything unrecognised | **active** | + +The three closing states are kept apart because they mean different things: a solved issue was fixed, +an ignored one was accepted, and a false positive was never real. Anything unrecognised stays active, +the safe direction to be wrong in. + +### The asset is either an endpoint or the component + +Group-IB reports hosts, addresses and URLs **in the same field** as software names and SSL or +login-form descriptors. So an asset that looks like a host becomes an **endpoint**, and anything else +becomes the **component name** — never both, and never lost. Recording a software name as an endpoint +would make `Endpoint.clean()` raise and fail the whole import. + +An asset counts as host-shaped when it is a URL, an IP address (optionally with a port), an already +scheme-relative `//host`, or a dotted name whose last label is alphabetic and at least two characters. +Anything containing whitespace or a path separator is not. + +Group-IB sends bare hosts with no scheme. The connector prefixes `//` so DefectDojo reads the value as +an authority rather than a path; this parser builds the endpoint from its parts instead, reaching the +same result without the string trick. + +### Fields worth noting + +- **Title** is the issue type, falling back to its reason, then its category, then the issue id. +- **ASM findings are dynamic** — they come from external scanning. +- **MITRE ATT&CK techniques** become `mitre-attack:` tags, sorted. Group-IB sends them as a + map keyed by technique, so sorting is what makes the order stable. +- **An issue with no informative fields says so** rather than arriving with an empty description, which + would read as though the data had been lost in transit. +- **The hash is only the title and severity** — an ASM issue has neither a file nor a package. + +### Sample Scan Data + +Sample Group-IB ASM scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/groupib). + +The samples cover a critical detected issue with two MITRE techniques, a solved one, a false positive, an +ignored one, an issue under review with no details at all, an unrecognised severity label, an address +with a port, a URL asset, and a software name that must become the component rather than an endpoint. +Hosts, addresses and company names are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity diff --git a/docs/content/supported_tools/parsers/file/hackerone.md b/docs/content/supported_tools/parsers/file/hackerone.md new file mode 100644 index 00000000000..f04dd51208e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/hackerone.md @@ -0,0 +1,67 @@ +--- +title: "HackerOne" +toc_hide: true +--- + +Import a [HackerOne](https://www.hackerone.com/) reports export. + +This exists for organisations that cannot grant HackerOne API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro HackerOne connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from HackerOne's reports endpoint: + +``` +curl -u "$H1_USER:$H1_TOKEN" "https://api.hackerone.com/v1/reports?filter%5Bprogram%5D%5B%5D=your-program" > hackerone.json +``` + +HackerOne's API is **JSON:API**, so the reports arrive under `data` and severity, weakness and +reporter are **relationships**, not attributes — each nested under +`relationships..data.attributes`. Reading them off the top level, or off `attributes`, would +leave every finding at Info with no CWE and no reporter. An already-flattened export is accepted too, +for anyone exporting through a script. + +One finding per report. + +### Severity + +The severity relationship's `rating`: `critical`→Critical, `high`→High, `medium`→Medium, `low`→Low. +HackerOne also reports a rating of `none`, which is not a DefectDojo severity and falls through to +Info along with anything else unrecognised. The comparison is case-insensitive. + +The severity relationship's CVSS `score` is imported when HackerOne attached one above zero. + +### Fields worth noting + +- **CWE** — parsed from the weakness relationship's `external_id`, which HackerOne lower-cases as + `cwe-`. Not every HackerOne weakness maps to a CWE (some are CAPEC), and those leave the CWE at 0. +- **The report link is always present**, in both `url` and the description, even for a report with no + prose at all — a bug-bounty finding is not actionable without a way back to the report and its + comment thread. +- **`vuln_id_from_tool` and `unique_id_from_tool` are both the report id**, which is what the + connector does. + +### Scan type and deduplication + +The scan type is **`HackerOne - Connectors Import`** — identical to the string the HackerOne connector +reports, so a customer who uploads an export *and* later enables the connector gets one set of +findings that deduplicate rather than two copies of everything. + +Report ids are globally unique on the platform, so deduplication uses the plain `hash_code` algorithm +over `unique_id_from_tool` alone. + +### Sample Scan Data + +Sample HackerOne scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/hackerone). + +The samples are constructed from HackerOne's documented JSON:API report schema, covering a report with +full relationships, one with a `none` rating, one whose weakness is not a CWE, and one with no +relationships at all. Researcher usernames are generic placeholders. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/halosecurity.md b/docs/content/supported_tools/parsers/file/halosecurity.md new file mode 100644 index 00000000000..4fbcf855945 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/halosecurity.md @@ -0,0 +1,87 @@ +--- +title: "Halo Security" +toc_hide: true +--- + +Import a [Halo Security](https://www.halosecurity.com/) issues export. + +This exists for organisations that cannot grant Halo Security API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Halo Security connector pulls +the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the issue-list response, with rows under `list`. A bare array of rows is accepted too. + +**Include the per-issue details.** Halo splits an issue across two calls: the list row carries the +issue, target and status, while the **description, category, CVEs and PCI flag exist only on a +per-issue detail**. A row-only import produces findings with no prose at all. Supply the details as: + +- a top-level `details` object keyed by issue id, or +- a top-level `details` array of detail objects, or +- a `detail` object nested on each row. + +### Severity is an integer level, 5 highest + +| Halo `severity` | Severity | +| --- | --- | +| 5 | Critical | +| 4 | High | +| 3 | Medium | +| 2 | Low | +| 1, 0 | Info | +| anything else | Info | + +Note this is the **inverse of a priority number** — 5 is the most severe, not the least. The row's +level is used when set, falling back to the detail's, because the list response sometimes omits it. + +### One issue per host + +Halo reports the same issue once per affected target, so the identity is **`:`**. +Keying on the issue alone would collapse two hosts into one finding — and their statuses often differ, +which is exactly why they are kept apart. + +### Status + +| Halo `status` | Imported as | +| --- | --- | +| `new`, `investigating` | active, not verified | +| `confirmed`, `fixing` | active, **verified** | +| `fixed` | inactive, mitigated, verified | +| `ack_false_positive` | inactive, false positive | +| `ack_acceptable_risk` | inactive, risk accepted | + +Only `confirmed`, `fixing` and `fixed` count as verified — a new or investigating issue has not been +confirmed by anyone yet, and marking it verified would overstate what Halo knows. + +### Deduplication hashes the endpoint + +This scan type's configuration pairs `unique_id_from_tool_or_hash_code` with a field set that +**includes `endpoints`**, so the parser always records the scanned host. An unpopulated endpoint would +leave the hash computed over nothing and every rescan would reimport. + +### Fields worth noting + +- **PCI** — when Halo flags an issue as affecting PCI compliance, it becomes both a description line + and a `pci` tag. +- **Assignee** — Halo writes the literal `Nobody` to mean unassigned, and that is not reported. +- **Scans since found** — how many scans the issue has persisted for, reported even when zero. +- **Date** — Halo's list response carries no discovery date, so findings are stamped with today's + date. That is the connector's behaviour, mirrored rather than corrected. + +### Sample Scan Data + +Sample Halo Security scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/halosecurity). + +The samples are constructed from Halo's documented issue-list and issue-detail responses and cover the +same issue on two hosts with differing statuses, every status value, a PCI issue, a row with no detail, +a row whose severity is only on the detail, and duplicate CVE identifiers. Hostnames are generic and +addresses are private-range. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- endpoints diff --git a/docs/content/supported_tools/parsers/file/hiddenlayer.md b/docs/content/supported_tools/parsers/file/hiddenlayer.md new file mode 100644 index 00000000000..15cbf68e44e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/hiddenlayer.md @@ -0,0 +1,97 @@ +--- +title: "HiddenLayer" +toc_hide: true +--- + +Import a [HiddenLayer](https://hiddenlayer.com/) model-scan SARIF log. + +This exists for organisations that cannot grant HiddenLayer API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro HiddenLayer connector pulls +the same data over the API; this parser accepts the same data as a file. + +### Why not the generic SARIF parser? + +DefectDojo already parses SARIF, and HiddenLayer reports SARIF — but importing through the generic +parser records the findings under the **`SARIF` scan type**, where they will not deduplicate against +the HiddenLayer connector's findings. That is the whole reason this parser exists. The mapping is the +connector's, which itself mirrors `dojo/tools/sarif/parser.py`. + +### File Types + +JSON — a SARIF log, `{"runs": [...]}`. Every run contributes. + +Wrap it to supply the **scan id**, which is part of every identity the connector builds and which a +downloaded log does not carry: + +```json +{"scan_id": "your-scan-id", "sarif": { "runs": [ ... ] }} +``` + +`scan_id`, `scanId` and `scanID` are all accepted, and the log may sit under `sarif`, `log` or +`report`. A bare log imports fine, but **its findings will not deduplicate against connector-synced +ones**, because the identities differ by that prefix. + +### Results that are not failures are skipped + +SARIF's `kind` distinguishes a failure from a `pass`, `open`, `informational`, `notApplicable` or +`review` result. Only failures are imported — the rest are not findings, and importing them would fill +the product with noise. An **absent** `kind` means failure, per the SARIF specification. + +### A suppressed result is a false positive + +A result carrying `suppressions` is imported **inactive and marked a false positive**. SARIF +suppression is a reviewer saying this one does not count; recording it as inactive alone would leave it +in the open-findings count. + +### Severity + +| Source | Used when | +| --- | --- | +| the rule's `properties.security-severity`, as a CVSS number | it parses as a number | +| the same property, as a word | it is `critical`/`high`/`medium`/`low`/`info`/`informational` | +| the result's `level` | there is no usable property | + +| `level` | Severity | | CVSS score | Severity | +| --- | --- | --- | --- | --- | +| `note` | Info | | ≥ 9 | Critical | +| `warning` | Medium | | ≥ 7 | High | +| `error` | High | | ≥ 4 | Medium | +| **absent** | **Medium** | | > 0 | Low | + +A result with **no level is Medium, not Info**: SARIF makes `level` optional, and a tool that omits it +is not saying the result is harmless — defaulting to Info would silently bury it. + +`cvssv3_score` is only set when the property is a **number**; a word grades the finding but leaves no +score to record. + +### Fields worth noting + +- **Title** is the result message, then the rule's short description, full description, name and id. + Shortened to 150 characters with a trailing ellipsis; the full text stays in the description. +- **The description does not repeat itself** — a rule short description that merely restates the + result message, or a full description that restates the short one, is printed once. +- **CWE** comes from the rule's `relationships` target, then the rule's tags, then the result's tags. + SARIF has no CWE field, so a tool states the taxonomy one of those ways. `CWE-502`, `cwe-502` and + `external/cwe/cwe-502` are all read. +- **Tags** are the rule's then the result's, deduplicated, with the `external/cwe/` prefix stripped. +- **A rule id that is itself a CVE** becomes the vulnerability id, uppercased. +- **References** is the rule's `helpUri`, falling back to its help text *only when that text is a + link*. +- **Mitigation** is every fix description the result carries, one per line. + +### Sample Scan Data + +Sample HiddenLayer scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/hiddenlayer). + +The samples cover a scored critical result, a suppressed one, a result graded by a severity *word*, an +unparseable severity, a `pass` result that must not import, a result whose rule is not defined in the +run, a quoted start line, a location with no region, a result with no location at all, two fixes on one +result, and help text that is not a link. Model, file and rule names are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- file_path diff --git a/docs/content/supported_tools/parsers/file/holm_security.md b/docs/content/supported_tools/parsers/file/holm_security.md new file mode 100644 index 00000000000..0433e861e4e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/holm_security.md @@ -0,0 +1,85 @@ +--- +title: "Holm Security" +toc_hide: true +--- + +Import a [Holm Security](https://www.holmsecurity.com/) vulnerabilities export. + +This exists for organisations that cannot grant Holm Security API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Holm Security connector pulls +the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — a Holm paged response, with rows under `results`. A bare array of vulnerabilities is accepted too. + +**Say which scan class the rows came from.** Holm scans two ways — a network class and a web class — and +only the web class exercises a running application. The class is a property of the scan rather than of +the row, so state it as a top-level `"class"` (or `"asset_class"`) of `web` or `net`: + +| `class` | Imported as | Tag | +| --- | --- | --- | +| `web` | dynamic | `web-scan` | +| `net` | static | `net-scan` | +| absent | static | none | + +Absent means static, which is the connector's own default for anything that is not the web class. + +### Severity: name first, then Holm's number + +| Holm `severity` | Severity | +| --- | --- | +| `critical` / `high` / `medium` / `low` / `info` | as named | + +When the name is missing **or unrecognised**, the numeric `severity_level` decides: + +| `severity_level` | Severity | +| --- | --- | +| 4 | Critical | +| 3 | High | +| 2 | Medium | +| 1 | Low | +| 0, or anything else | Info | + +Note **4 is the most severe** — the inverse of a priority number. The order matters: consulting the +level only as a fallback means an unfamiliar name does not silently become Info while a usable level +sits next to it. Levels may arrive as numbers or numeric strings. + +### One finding per host per port + +The identity is `holm-[-][-]`. Holm reports the same weakness once per host and once +per listening port, and collapsing them would hide a second exposed service. A port of zero is left out +of the identity rather than recorded as port zero. + +### The endpoint is the URL alone + +This scan type's deduplication hash includes `endpoints`, and the parser records Holm's URL when it has +one. The separately-reported `detected_port` is **not** added to the endpoint — it appears in the +identity and the description instead, matching the connector. A network finding often has no URL at all, +and then nothing is recorded; the details stay in the description. + +### Fields worth noting + +- **CVSS** is the `cvss_base`, falling back to `cvss_score`. +- **Title** falls back to the first CVE, then to `Holm Security finding `. +- **`fixed`, `closed` and `resolved`** are inactive; everything else is active. +- **Date** is the last detection, falling back to the first. +- **Impact** and **references** are Holm's own impact statement and vendor reference. + +### Sample Scan Data + +Sample Holm Security scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/holm_security). + +The samples are constructed from Holm's documented vulnerability shape and cover a name that overrides +the level, a missing name with a level sent as a string, an unrecognised name with a usable level, a +fixed finding, a finding with no URL, a URL that cannot be a host, and both scan classes. Hostnames are +generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- endpoints +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/insightappsec.md b/docs/content/supported_tools/parsers/file/insightappsec.md new file mode 100644 index 00000000000..3ac610932f2 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/insightappsec.md @@ -0,0 +1,93 @@ +--- +title: "Rapid7 InsightAppSec" +toc_hide: true +--- + +Import a [Rapid7 InsightAppSec](https://www.rapid7.com/products/insightappsec/) vulnerability export. + +This exists for organisations that cannot grant InsightAppSec API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro InsightAppSec connector pulls +the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the vulnerability-list response, with rows under `data`. A bare array of rows is accepted too. + +**Include the attack-module metadata.** InsightAppSec names a vulnerability only by the *id* of the +attack module that found it; the readable name ("SQL Injection") and the explanatory prose come from a +separate call. Without it, every finding is titled `InsightAppSec finding`. Supply it as: + +- a top-level `modules` array of module objects, or +- a `modules` object keyed by module id, or +- the module endpoint's own `{"data": [...]}` response under `modules`. + +### Only open findings are imported + +A row is imported when its `status` is `UNREVIEWED` or `VERIFIED`. `REMEDIATED`, `DUPLICATE`, +`IGNORED` and `FALSE_POSITIVE` are left out so a reimport **closes** them in DefectDojo rather than +resurrecting them. + +Both the status and the severity are matched **case-sensitively**, against InsightAppSec's own +uppercase enums — a lowercase `verified` is not a value the API sends, and accepting it would be this +parser inventing tolerance the API path does not have. + +### Severity + +| InsightAppSec `severity` | Severity | +| --- | --- | +| `CRITICAL` | Critical | +| `HIGH` | High | +| `MEDIUM` | Medium | +| `LOW` | Low | +| `INFORMATIONAL`, `SAFE` | Info | +| anything else | Info | + +The raw label is always recorded as the severity justification, so a value that graded as Info because +it was unrecognised is still auditable. + +### Deduplication is the unique id alone + +This scan type's configuration pairs `unique_id_from_tool_or_hash_code` with a hash of +**`unique_id_from_tool` and nothing else** — no title, no severity. InsightAppSec's vulnerability id is +stable across scans, so it is the whole identity; adding a volatile field would split a finding that +had merely been regraded. + +### Evidence is flattened, not rendered + +The evidence InsightAppSec captures is the application's own response to an attack payload — the least +trustworthy text in the export. The connector flattens it: script and style content is dropped, tags +are removed, and the result is HTML-escaped. This parser reproduces that byte for byte, including Go's +spelling of the apostrophe entity (`'`, where Python would write `'`). + +One consequence worth knowing: **an attack payload that is only markup flattens to nothing.** A value +of `` leaves an empty `Attack value` line. That is the connector's behaviour, +mirrored here rather than corrected. + +Only the first **three** evidence entries are printed, followed by a count of the rest — a single +vulnerability can carry hundreds of variances, and the description is context rather than an evidence +archive. + +### Fields worth noting + +- **Title** is ` in "" parameter`. The quotes are the connector's and they matter: a + parameter called `id` is otherwise indistinguishable from prose. +- **`vuln_id_from_tool`** is the module's name, falling back to its id and then to the vulnerability id. +- **CVSS** is recorded only when the vector really is v3. InsightAppSec also reports v2 vectors, and + the same number means different things on the two scales. +- **References** are the InsightAppSec UI link, then the module's reference links in sorted key order — + the connector sorts because a Go map has no order, and matching that keeps the two paths identical. + +### Sample Scan Data + +Sample Rapid7 InsightAppSec scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/insightappsec). + +The samples are constructed from InsightAppSec's documented vulnerability and module responses and +cover five evidence entries (so the omission note appears), a markup-only attack payload, a CVSS v2 +vector, an unrecognised severity, a module with no metadata, and one row for each closed-out status. +Hostnames and identifiers are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/intigriti.md b/docs/content/supported_tools/parsers/file/intigriti.md new file mode 100644 index 00000000000..40a6f5c0cd8 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/intigriti.md @@ -0,0 +1,85 @@ +--- +title: "Intigriti" +toc_hide: true +--- + +Import an [Intigriti](https://www.intigriti.com/) submissions export. + +This exists for organisations that cannot grant Intigriti API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Intigriti connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. The API's `records` envelope is accepted, as are `submissions`, `data`, `items`, and a bare +array. + +Intigriti's API lists submissions and then serves each one's full report separately, so the connector +converts each finding from **two objects** — a list overview and a fetched detail — preferring the +overview wherever both carry a field. An export may nest the detail under `detail`, or carry the report +on the entry itself if it came from the detail endpoint; both are recognised. Missing the merged form +would lose the CWE, impact, recommended solution and the whole description body. + +### Severity + +Intigriti grades its most severe submissions **`Exceptional`**, not `Critical`. Both map to Critical; +mapping only `critical` would silently drop every top-tier submission to Info. `High`, `Medium` and +`Low` map directly and anything unrecognised becomes Info. + +### Status and close reason become the DefectDojo state + +For a **closed** or **archived** submission the *close reason* is what distinguishes a fix from a +rejection, so both are read: + +| Status | Close reason | Imported as | +| --- | --- | --- | +| `accepted` | — | active, verified | +| anything open (`new`, `triage`, `in progress`, …) | — | active, not verified | +| `closed` / `archived` | `accepted risk` | inactive, verified, risk accepted | +| `closed` / `archived` | `duplicate` | inactive, duplicate | +| `closed` / `archived` | `out of scope` | inactive, out of scope | +| `closed` / `archived` | `not applicable`, `not reproducible`, `false positive`, `spam`, `informative`, `won't fix`, `no` | inactive, false positive | +| `closed` / `archived` | anything else (solved, resolved, fixed, blank) | inactive, verified, mitigated | + +Note `no` is Intigriti's terse rejection reason — treating it as "closed without a reason" would mark a +rejected submission as fixed. Hyphenated and spaced spellings of each reason are both accepted. + +### Researcher prose is flattened, never rendered + +Intigriti submissions are written by external researchers. The proof of concept, impact, recommended +solution, asset and question answers are all flattened to escaped plain text: `script` and `style` +content is dropped, block tags become newlines. Nothing in a submission can be injected into a rendered +finding. Go's `html.EscapeString` entities are matched byte for byte. + +### Fields worth noting + +- **`unique_id_from_tool` and `vuln_id_from_tool` are both the submission code** (`GENERIC-0001`). +- **CWE** — from the report type's `cwe`, which Intigriti writes lower-cased as `cwe-`; anything + else leaves it at 0. +- **Asset** — the affected domain, falling back to the vulnerable component Intigriti recorded. +- **Report type** — rendered ` ()`, degrading to whichever of the two is present. +- **Question answers** — the researcher's answers to the programme's intake questions are imported; + empty question/answer pairs are skipped rather than producing an empty heading. + +### Scan type and deduplication + +The scan type is **`Intigriti - Connectors Import`** — identical to the string the Intigriti connector +reports, so a customer who uploads an export *and* later enables the connector gets one set of findings +that deduplicate rather than two copies of everything. + +Submission codes are globally unique on the platform, so deduplication uses the plain `hash_code` +algorithm over `unique_id_from_tool` alone. + +### Sample Scan Data + +Sample Intigriti scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/intigriti). + +The samples are constructed from Intigriti's documented submission schema and cover every row of the +state table above, the `Exceptional` grade, both the nested and merged detail shapes, and researcher +prose containing markup. Programme names, submission codes and hosts are generic placeholders. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/intruder.md b/docs/content/supported_tools/parsers/file/intruder.md new file mode 100644 index 00000000000..e5c2b1b227b --- /dev/null +++ b/docs/content/supported_tools/parsers/file/intruder.md @@ -0,0 +1,84 @@ +--- +title: "Intruder" +toc_hide: true +--- + +Import an [Intruder](https://www.intruder.io/) issues export. + +This exists for organisations that cannot grant Intruder API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Intruder connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the issues response, with rows under `results`. A bare array of rows is accepted too. + +**Include each issue's occurrences.** Intruder separates an issue — the weakness, its description and +remediation — from its occurrences, which are the targets it was found on. The occurrence *is* the +finding, so an issue with none produces nothing. Intruder's own issue object carries `occurrences` as a +**URL string**, not a list; that second call is what an export has to include: + +- a top-level `occurrences` object keyed by issue id, or +- an `occurrences` array nested on each issue. + +### Severity + +| Intruder `severity` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| anything else | Info | + +The **occurrence's** CVSS score is preferred over the issue's, because the same weakness scores +differently per target — a service reachable from the internet is not the same risk as one behind a +firewall. + +### Snoozing is how Intruder records triage + +| Occurrence | Imported as | +| --- | --- | +| not snoozed | active | +| snoozed, `FALSE_POSITIVE` | inactive, false positive | +| snoozed, `ACCEPT_RISK` | inactive, risk accepted | +| snoozed, `MITIGATING_CONTROLS` | inactive, risk accepted | +| snoozed, any other reason | inactive, neither flag | + +An unrecognised reason leaves the finding inactive with neither flag set. It is still triaged, just not +in a way DefectDojo has a field for, and guessing would misreport the reviewer's decision. + +### Deduplication uses the plain hash algorithm + +Intruder is the one connector scan type whose configuration uses **`hash_code`** rather than pairing it +with the unique id. The occurrence id sits *inside* the hash fields instead, with title and severity +guarding against id reuse. Copied from the connector's own settings, not chosen. + +### Fields worth noting + +- **The description shows the display address; the endpoint records the target.** The display address + is what a person recognises, the target is what was scanned. +- **A port of `0`** means Intruder had none, and it is not recorded — port zero is not a real port. +- **A target that cannot be a host** (Intruder targets can be labels) is not recorded as an endpoint, + because an unusable host fails the whole import rather than the one finding. It still appears in the + description and in a `target:` tag. +- **Extra information** is listed in sorted key order — the connector sorts because a Go map has no + order, and matching that keeps the two paths identical. +- **CVEs** come from the occurrence, then from any identifier in the issue's title or description. + +### Sample Scan Data + +Sample Intruder scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/intruder). + +The samples are constructed from Intruder's documented issue and occurrence responses and cover one +issue on two targets, every snooze reason including an unrecognised one, an issue with no occurrences, +a port of `0`, an IP target, a target that cannot be a host, an unparseable timestamp, and both the +nested and issue-keyed export shapes. Hostnames are generic and addresses are private-range. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool +- title +- severity diff --git a/docs/content/supported_tools/parsers/file/klocwork.md b/docs/content/supported_tools/parsers/file/klocwork.md new file mode 100644 index 00000000000..4b20c6b61d3 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/klocwork.md @@ -0,0 +1,83 @@ +--- +title: "Klocwork" +toc_hide: true +--- + +Import a [Klocwork](https://www.perforce.com/products/klocwork) issue export. + +This exists for organisations that cannot grant Klocwork API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Klocwork connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +**NDJSON** — Klocwork's `search` endpoint answers with one JSON object per line, not an array, so that is +what a saved export looks like and it is what this parser reads first. Calling a whole-document JSON +parser on it fails at the second line. + +Also accepted, for an export somebody has already reshaped: a JSON array of issues, an object with an +`issues` list, or a single issue object. + +Two lines are skipped rather than parsed, matching the connector's own decoder: + +- the trailing **summary** line, which describes the run rather than a finding — a search that matched + nothing answers with that line alone, and that is an empty result, not a malformed file +- any line that is not a JSON object, or that does not parse + +A row with **no `id`** is dropped: the id is the whole identity. + +### Severity: 1 is the most severe + +Klocwork grades with a severity **code**, which is the inverse of a score: + +| `severityCode` | Severity | +| --- | --- | +| 1 | Critical | +| 2 | High | +| 3 | Medium | +| 4 | Low | +| 5–10, 0, absent | Info | + +Reading the code as a score would invert the entire ladder. Codes 5–10 are Klocwork's informational +tiers. + +Numbers may arrive **quoted** (`"id": "101"`). Both forms are accepted, because the connector's own +decoder silently skips a line it cannot parse — a server quoting its numerics would otherwise produce a +clean, empty sync rather than an error. + +### Status + +| Klocwork `status` | Imported as | +| --- | --- | +| `Ignore`, `Not a problem`, `Filter` | inactive, **false positive** | +| everything else | active | + +The three are all a reviewer saying the issue is not real. The deferred states the connector's query +selects — `Defer`, `Fix in Next Release`, `Fix in Later Release` — stay **active**: a deferred finding is +still a finding. + +### Fields worth noting + +- **Title** is `: ` — the checker alone is opaque, the name alone is not searchable. +- **`file_path` and the checker are both in the deduplication hash**, so the same checker firing in two + files is two findings. +- **Dates** are unix milliseconds; reading them as seconds would date every finding in 1970. +- **References** is the issue's own Klocwork review URL, when it has one. + +### Sample Scan Data + +Sample Klocwork scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/klocwork). + +The samples are NDJSON, as Klocwork produces, and cover every severity code, quoted numerics, a triaged +`Ignore`, a deferred finding, a row with no name or checker, a row with no id, and the trailing summary +line. The same issues are also provided as a JSON array to exercise the reshaped path. File paths and +hostnames are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- file_path +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/lacework.md b/docs/content/supported_tools/parsers/file/lacework.md new file mode 100644 index 00000000000..75a76e45c10 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/lacework.md @@ -0,0 +1,63 @@ +--- +title: "Lacework (FortiCNAPP)" +toc_hide: true +--- + +Import a [Lacework / FortiCNAPP](https://www.lacework.com/) container or host vulnerability export. + +This exists for organisations that cannot grant Lacework API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Lacework connector pulls the +same data over the API; this parser accepts it as a file. + +### File Types + +JSON. Export the rows of a vulnerability query, for example: + +``` +lacework vulnerability container list-assessments --json > lacework.json +lacework vulnerability host list-cves --json > lacework.json +``` + +A bare JSON array of rows is accepted, as is the query envelope that wraps them under `data`. + +### Two shapes in one export + +Lacework reports container/image and host vulnerabilities differently, and the connector maps them +differently, so this parser does too: + +| | Container row | Host row | +| --- | --- | --- | +| Flag | `static_finding` | `dynamic_finding` | +| Identity | `imageId\|CVE\|package\|version` | `hostname\|CVE\|package\|version` | +| Version field | `featureKey.version` | `featureKey.version_installed` | +| `fix_available` | integer | string | +| Tags | `image:`, `registry:`, `source:container` | `host:`, `source:host` | + +A host row with no hostname falls back to `mid-`, as the connector does. + +### Scan type and deduplication + +The scan type is **`Lacework - Connectors Import`** — identical to the connector's, so a file import +and a later API sync produce one set of findings rather than two. + +### Severity and status + +`critical`/`high`/`medium`/`low` map straight across; anything else is Info. A row Lacework reports as +`Fixed` or `Resolved` is still imported, but inactive and mitigated — matching the connector's +`applyStatus`. A mitigation is only offered when the row reports both a fix available and a fixed +version. + +### Sample Scan Data + +Sample Lacework scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/lacework). + +Constructed from Lacework's documented row schema and the shapes the connector's converter tests +exercise, with generic registries and hostnames. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/netrise.md b/docs/content/supported_tools/parsers/file/netrise.md new file mode 100644 index 00000000000..46c9dcab48a --- /dev/null +++ b/docs/content/supported_tools/parsers/file/netrise.md @@ -0,0 +1,80 @@ +--- +title: "NetRise" +toc_hide: true +--- + +Import a [NetRise](https://www.netrise.io/) firmware-analysis export. + +This exists for organisations that cannot grant NetRise API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro NetRise connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. NetRise answers GraphQL in **Relay shape**, so each row arrives wrapped in a `node`: + +```json +{ + "asset": {"id": "artifact-0001", "name": "generic-firmware.bin", + "vendor": "Generic Networks", "product": "GN-1000", "version": "1.4.0"}, + "vulnerabilities": {"edges": [{"node": {"cve": "CVE-2000-0001", "name": "example-tls"}}]} +} +``` + +The edges are unwrapped, and a row that is already the node is used as-is — so a file somebody has +already flattened still imports. A `data` envelope is accepted, as is the artifact arriving in its own +`assetsRelay` envelope from the other query. + +### The artifact scopes the identity + +The identity is `netrise--`, so **the same CVE in two firmware builds +stays two findings** — two builds to re-release. Merging them would hide that one of the two is still +shipping. + +One export is one artifact, so the artifact is stated once for the file; a row carrying its own `asset` +overrides it. An export with no artifact still imports, with an empty artifact in the identity. + +### Severity + +| NetRise `severity` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| `info`, `informational`, `none` | Info | +| **anything else** | derived from `cvssScore` | + +An unrecognised word falls through to the **score**, not to Info: a finding NetRise graded with an +unfamiliar word still lands where its score says. Score bands are the standard ≥ 9 / ≥ 7 / ≥ 4 / > 0. +Scores may arrive quoted. + +### Reachability and CISA KEV are justifications, not regrades + +A reachable, actively-exploited flaw in firmware is more urgent than its score alone says. Both signals +are recorded as the **severity justification** and as tags (`reachable`, `cisa-kev`), and both appear in +the description — but neither moves the severity, which would make the finding disagree with an API +sync of the same data. + +### Fields worth noting + +- **Title** is ` in `, falling back to whichever of the two exists. +- **The component is the affected package**, and it is in the deduplication hash. +- **Mitigation** names the fixed versions NetRise lists; with none, there is no mitigation. +- **Vendor and product** come from the artifact and are also tags. + +### Sample Scan Data + +Sample NetRise scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/netrise). + +The samples cover a reachable KEV-listed critical finding, an unrecognised severity word with a quoted +score, a finding with no CVE, one with no component, one with neither, and empty fix versions. Artifact, +vendor, product and component names are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/nightfall.md b/docs/content/supported_tools/parsers/file/nightfall.md new file mode 100644 index 00000000000..98e7bc1ee31 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/nightfall.md @@ -0,0 +1,95 @@ +--- +title: "Nightfall AI" +toc_hide: true +--- + +Import a [Nightfall AI](https://www.nightfall.ai/) violations export. + +This exists for organisations that cannot grant Nightfall API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Nightfall AI connector pulls +the same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the violation-list response, with rows under `violations`. A bare array of rows is accepted +too. + +**Include each violation's detections.** Nightfall splits a violation across two calls: the violation +itself, and the detections that make it up, which carry the redacted evidence, the confidence and the +API-key verdict. The verdict is what raises a violation to Critical and what names the credential in +the title, so a violation-only import loses all three. Because a detection carries no violation id of +its own, supply them as: + +- a top-level `findings` (or `detections`) object keyed by violation id, or +- a `findings` array nested on each violation. + +### No sensitive data is imported + +Nightfall's API returns **redacted** detection text only — that is the field this parser reads, and +the only text field it reads from a detection. There is no field in the API carrying the raw secret, +and the parser ignores the surrounding redacted context. + +### Severity + +| Nightfall `risk` | Severity | +| --- | --- | +| `CRITICAL` | Critical | +| `HIGH` | High | +| `MEDIUM` | Medium | +| `LOW` | Low | +| `NO_RISK`, `UNSPECIFIED` | Info | +| anything else, or absent | Info | + +**A verified live credential is always Critical**, whatever the policy's risk says — Nightfall marks +a key `ACTIVE` when it successfully authenticated with it and `SIGNATURE_VERIFIED` when it verified +the key's signature, and either means a working secret is sitting somewhere it should not be. The +numeric `riskScore` is recorded as the severity justification rather than driving the grade. + +### Status + +| Nightfall `state` | Imported as | +| --- | --- | +| `ACTIVE` | active, verified | +| `PENDING` | active, **not** verified | +| `RESOLVED` | inactive, mitigated, verified | +| `EXPIRED` | inactive, **out of scope**, verified | +| absent | inactive, not verified | + +A pending violation has not been triaged by anyone yet, so marking it verified would overstate what +Nightfall knows. An expired one is out of scope rather than mitigated: Nightfall can no longer see the +resource, so it can confirm neither that the data is gone nor that it is still there. + +### Title and location + +The title reads ` exposed in ()`. The subject is the kind of +credential the detections identified, falling back to the policy that matched and then to a generic +label — a violation is worth reporting even when Nightfall cannot say what kind of secret it saw. A +credential of unspecified kind is called an `API` credential. + +Every integration nests its own metadata block under a different key and describes a location with +different fields, so `location` is a per-integration mapping — a Slack workspace and channel, a +GitHub `org/repo:path`, a Drive name and file, a Jira project and ticket, and so on. GitHub +violations are also the only ones with a code location, so they are the only ones that set +`file_path` and `line`. + +Three integrations report a sharing state that makes an exposure external, and it becomes an +`Exposure` line in the description: a Drive permission setting, a Notion page shared externally, and +a GitHub repository that is not private. Following the connector, a repository Nightfall did not call +private is treated as public. + +### Sample Scan Data + +Sample Nightfall AI scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/nightfall). + +The samples are constructed from Nightfall's documented violation and detection schemas and cover +every state, a live credential, an unrecognised risk label, detections keyed by violation id and +nested on the violation, a violation with no metadata block, and one with no creation time. All +detection values are redacted placeholders and all hostnames are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- description diff --git a/docs/content/supported_tools/parsers/file/nowsecure.md b/docs/content/supported_tools/parsers/file/nowsecure.md new file mode 100644 index 00000000000..dd3a7878c30 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/nowsecure.md @@ -0,0 +1,86 @@ +--- +title: "NowSecure" +toc_hide: true +--- + +Import a [NowSecure](https://www.nowsecure.com/) mobile-app assessment export. + +This exists for organisations that cannot grant NowSecure API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro NowSecure connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. NowSecure's findings endpoint answers with a **bare array**, so an export is either that array or +an object carrying it as `findings` alongside the `assessment` that produced it. + +Include the assessment when you can: the finding rows carry neither a date nor the platform, so +without it findings import with today's date and no platform tag. + +### Only findings that affect the app are imported + +NowSecure reports every check it ran, including the ones that found nothing. A row is imported only +when `affected` is true and `hidden` is false — a check that does not affect the app is not a finding, +and a hidden one has been suppressed in NowSecure itself. + +### Static and dynamic are decided per finding + +One assessment runs **both** a static and a dynamic analysis of the same app, and each finding says +which one found it: + +| `analysis_type` | Imported as | +| --- | --- | +| `static` | static | +| `dynamic` | dynamic | +| anything else | left to DefectDojo's defaults — which means **dynamic** | + +An unrecognised analysis type leaves both flags alone rather than guessing which kind of test ran. Note +that is not neutral: DefectDojo defaults `static_finding` to false and `dynamic_finding` to **true**, so +such a finding is recorded as dynamic. That is also what the connector's findings do, which is why it is +mirrored rather than forced to two falses — doing that would make a file import and an API sync disagree +about the same finding. + +### Severity + +| NowSecure `severity` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| `info`, `informational`, empty | Info | +| anything else | Info | + +### Identity + +`nowsecure-[-]`. The check id is the rule; the vulnerability id +distinguishes two hits of the same check in one app, and is omitted when NowSecure reports it as zero. +A finding with **no** check id falls back to a slug of its title — something stable is needed and the +title is all there is. + +### Fields worth noting + +- **Vulnerability identifiers** are read from the title, description and detail, then **sorted** and + deduplicated case-insensitively. That is the connector's extractor behaviour, and it differs from the + order-preserving path other connectors use — mirrored so the two import paths agree. +- **Mitigation** is NowSecure's developer recommendation, left empty when it has none. +- **The CVSS score is set unconditionally**, so an unscored finding lands as `0.0`. Mirrored for + parity; flagged as a follow-up for both sides. +- **Every finding is active** — NowSecure reports what it found in this assessment. + +### Sample Scan Data + +Sample NowSecure scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/nowsecure). + +The samples are constructed from NowSecure's documented assessment and finding responses and cover a +static and a dynamic finding, an unrecognised analysis type, a finding with no check id, duplicate CVEs +in mixed case, an unscored finding, a check that does not affect the app, and a hidden one. Package and +account identifiers are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/nozomi.md b/docs/content/supported_tools/parsers/file/nozomi.md new file mode 100644 index 00000000000..96ecca15ca4 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/nozomi.md @@ -0,0 +1,73 @@ +--- +title: "Nozomi Networks" +toc_hide: true +--- + +Import a [Nozomi Networks Vantage](https://www.nozominetworks.com/products/vantage) `node_cves` export. + +This exists for organisations that cannot grant Vantage API credentials — air-gapped networks, +procurement restrictions, a pending security review. OT networks are often the most isolated +environments an organisation runs, so a file import is frequently the only way the data can move at +all. The DefectDojo Pro Nozomi connector pulls the same data over the API; this parser accepts the same +data as a file. + +### File Types + +JSON — the `node_cves` query response, `{"result": [...]}`. A bare array works, as does an object naming +the list `results` or `data`. + +A `node_cves` record is **denormalised**: every row carries its own asset context, so nothing has to be +joined and one file is enough. + +### Resolved records are skipped + +The connector asks Vantage for `node_cves | where resolved != true`, so a resolved record is something +an API sync can **never** produce. A hand-run query can return them, and importing one would open a +finding Nozomi has already closed — so the query filter is mirrored here, not just the converter. Only +`resolved: true` skips a row; an absent or false flag is an open vulnerability. + +### Severity + +Nozomi sends no severity word, so the CVE's CVSS base score is the only signal: + +| `cve_score` | Severity | +| --- | --- | +| ≥ 9 | Critical | +| ≥ 7 | High | +| ≥ 4 | Medium | +| > 0 | Low | +| 0 or absent | Info | + +An unscored record is Info rather than dropped — in an OT estate the asset context is worth recording +even when the score is missing. Scores may arrive quoted. + +### Fields worth noting + +- **Title** is ` on `, then the CVE alone. There is deliberately **no asset-only form**: a + record with no CVE has nothing to name it by, and titling it after the device would read as though the + device itself were the finding. +- **Identity** is `nozomi-`, falling back to `nozomi--`. +- **The component is the OT product** and its version is the firmware version, so the same CVE on two + different devices stays two findings. +- **Mitigation** names the latest hotfix, falling back to the minimum one — the floor a device has to + reach. +- **The CVE summary** is separated from the asset context by a blank line. +- **Nothing is recorded as a dynamic finding.** Vantage builds its inventory passively, which is why it + is usable in OT at all; marking a finding dynamic would imply the device had been probed. +- **`likelihood`** is present in the response and is not imported, matching the connector. + +### Sample Scan Data + +Sample Nozomi scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/nozomi). + +The samples cover a scored critical record with two references, a quoted score with a bare CWE number, an +unscored record with no CVE, a resolved record that must not import, a record with no `resolved` key at +all, and an empty reference list. Asset labels, vendors, products and zones are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/ostorlab.md b/docs/content/supported_tools/parsers/file/ostorlab.md new file mode 100644 index 00000000000..478ab374475 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/ostorlab.md @@ -0,0 +1,95 @@ +--- +title: "Ostorlab" +toc_hide: true +--- + +Import an [Ostorlab](https://www.ostorlab.co/) scan export. + +This exists for organisations that cannot grant Ostorlab API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Ostorlab connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — Ostorlab answers GraphQL, and its own shape **doubles the key**: the outer `vulnerabilities` is +the connection, the inner one is the list. + +```json +{"data": {"scan": {"id": 4001, "assetType": "WEB", "createdTime": "2024-06-03T09:30:00Z", + "vulnerabilities": {"vulnerabilities": [ ... ]}}}} +``` + +The unwrapped forms and a bare array of vulnerabilities are accepted too. One export is one scan, so +the scan is stated once for the file. An export with no scan context still imports — the identity then +carries scan `0` and every finding is dynamic, because the asset type that would say otherwise is +missing. + +### A SECURE rating is a passed check + +Ostorlab reports checks that **passed** with a `SECURE` risk rating. Those are skipped: importing one +would file a passing check as a finding. Matched case-insensitively. + +### Severity + +| `riskRating` | Severity | +| --- | --- | +| `CRITICAL` | Critical | +| `HIGH` | High | +| `MEDIUM` | Medium | +| `LOW`, **`POTENTIALLY`** | Low | +| `HARDENING`, `IMPORTANT`, `INFO`, unrecognised, absent | Info | + +`POTENTIALLY` is a finding Ostorlab could not fully confirm, which it grades as Low. + +Note that **`IMPORTANT` grades as Info**. That reads oddly, and it is mirrored rather than corrected — +changing it here would make a file import disagree with an API sync of the same finding. Raised as a +follow-up on the connector side. + +### The asset type decides static versus dynamic + +Ostorlab scans mobile applications, web targets and networks from one platform. An asset type +containing `ANDROID`, `IOS`, `APP`, `FILE` or `STORE` is read **statically**; a web, network or domain +target is **exercised**. Deciding this per scan rather than for the tool is what keeps both honest. + +### Ostorlab has no CVE field + +Identifiers appear in the prose and the references, so they are extracted from the technical detail, the +title, the description, the summary, and every reference title and URL. `CVE-`, `GHSA-`, `GO-` and +`RHSA-` forms are recognised. The results come back **sorted** rather than in the order they appear, +matching the connector's shared extractor. + +### Fields worth noting + +- **Title** is the detail's title, falling back to `Ostorlab finding `. The detail is optional, so a + finding without one still imports rather than being dropped. +- **`cvssv3` is a vector string, not a score** — Ostorlab publishes no numeric score, so `cvssv3_score` + stays unset. +- **Location metadata is rendered under its own type** as the label, so whatever context Ostorlab + attached — a URL, a file path, a code location — reaches the reader without the parser knowing the + names in advance. Metadata with no type is skipped. +- **The endpoint** is the affected asset's name, falling back to its host. A mobile scan names a package + rather than a host, so it has no endpoint; a host DefectDojo would reject is left out too, since + `Endpoint.clean()` raising would fail the whole import. +- **Identity** is `ostorlab--`, so the same finding in two scans of one app is + two records — one per scan. Ids may arrive quoted. +- **Deduplication**: the copied hashcode list names `component_name`, and Ostorlab reports no component, + so that field hashes as empty and the hash is effectively title plus severity. Copied as it stands + rather than trimmed, because changing it would change how the connector's own findings hash. + +### Sample Scan Data + +Sample Ostorlab scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/ostorlab). + +The samples cover a static Android scan and a dynamic web scan, a `SECURE` check that must not import, +`POTENTIALLY`, `HARDENING` and `IMPORTANT` ratings, two CVEs named out of order in the prose, a +reference with only a URL and one with only a title, a finding with no detail block, location metadata +under several types, and a host DefectDojo cannot accept. Package names, hosts and identifiers are +generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/parasoft.md b/docs/content/supported_tools/parsers/file/parasoft.md new file mode 100644 index 00000000000..12e43c36c5f --- /dev/null +++ b/docs/content/supported_tools/parsers/file/parasoft.md @@ -0,0 +1,62 @@ +--- +title: "Parasoft DTP" +toc_hide: true +--- + +Import a [Parasoft DTP](https://www.parasoft.com/products/parasoft-dtp/) static-analysis violations +export. + +This exists for organisations that cannot grant DTP API credentials — air-gapped networks, procurement +restrictions, a pending security review. The DefectDojo Pro Parasoft connector pulls the same data over +the API; this parser accepts the same data as a file. + +### File Types + +JSON — the violations response, `{"staticAnalysisViolations": [...]}`. A bare array works, as does an +object naming the list `violations`, `data` or `results`. + +### Severity: 1 is the most severe + +DTP grades with a numeric severity that runs the **opposite way from a score**: + +| `severity` | Severity | +| --- | --- | +| 1 | Critical | +| 2 | High | +| 3 | Medium | +| 4 | Low | +| 5, 0, absent | Info | + +Reading it as a score would invert the entire ladder. Severity 5 is DTP's informational tier. Numbers +may arrive quoted. + +### Fields worth noting + +- **Title** is `: `, falling back to whichever exists. +- **Identity** prefers DTP's **violation hash** — the value that stays stable as a file is edited around + the violation — then the violation id, and only then the rule plus the file. That last fallback would + merge two violations of one rule in one file, which is why it is last. +- **`file_path` and the rule are both in the deduplication hash**, so the same rule firing in two files + is two violations to fix. +- **Advisory identifiers** named in the rule id or the message are linked. Rare for static analysis, but + a rule that names a CVE is worth connecting. `CVE-`, `GHSA-`, `GO-` and `RHSA-` forms are recognised, + and the results come back **sorted** rather than in the order they appear, matching the connector's + shared extractor. +- **`author`** is present in the response and is not imported, matching the connector. + +### Sample Scan Data + +Sample Parasoft DTP scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/parasoft). + +The samples cover every severity code, a quoted severity and line, a violation identified by hash, one by +id, one by rule-plus-file, a message naming two CVEs out of order, a violation with no rule, one with no +message, one with no line, and one with nothing at all. File paths and rule ids are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- file_path +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/probely.md b/docs/content/supported_tools/parsers/file/probely.md new file mode 100644 index 00000000000..d48fad3cdfb --- /dev/null +++ b/docs/content/supported_tools/parsers/file/probely.md @@ -0,0 +1,89 @@ +--- +title: "Probely" +toc_hide: true +--- + +Import a [Probely](https://probely.com/) findings export. + +This exists for organisations that cannot grant Probely API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Probely connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from Probely's findings endpoint. The API's `results` envelope is accepted, as is a bare array +of findings. + +### Scan type + +The scan type is **`Probely API Import`** — identical to the string the Probely connector reports. +Note it does **not** follow the ` - Connectors Import` pattern the other connector scan types +use, so it cannot be guessed from the vendor name. Matching it exactly is what lets a customer upload +an export *and* enable the connector without getting two copies of every finding. + +### Findings Probely has closed out are not imported + +Probely records a state per finding, and three of them mean the finding is done with: + +| State | Imported? | +| --- | --- | +| `fixed` | no — resolved | +| `invalid` | no — judged not a real issue | +| `accepted` | no — risk accepted | +| `retesting` | **yes** | +| anything else | yes | + +`retesting` is deliberately imported. A re-test means somebody is actively working the issue, so it is +assumed still open; skipping it would drop live findings whenever a re-test was queued. + +### Severity + +Probely reports severity as an **integer**, and only three values exist: + +| Probely `severity` | Severity | +| --- | --- | +| `30` | High | +| `20` | Medium | +| `10` | Low | +| anything else | Info | + +There is no Critical. The integer is not a score and not an index — treating it as either would +misgrade every finding. + +### Deduplication hashes the endpoint + +This scan type's configuration pairs the plain `hash_code` algorithm with a wide field set that +**includes `endpoints`**. The parser therefore always records the scanned origin (scheme, host and +port, reduced from the finding URL as the connector does). If the endpoint were left unpopulated the +hash would be computed over nothing and every rescan would reimport. + +### Fields worth noting + +- **Insertion point** — rendered as a readable label, so `url_query` becomes `**URL Query:**`. The + acronym fixes (`URL`, `JSON`, `GraphQL`) are the connector's; plain title casing gives "Url" and + "Json". +- **Mitigation** — Probely's `fix` text and its `extra` notes, joined with a newline exactly as the + connector joins them. +- **CWE** — read from the finding definition's `cwe_id` when the export carries one. The connector + fetches this separately per definition, so an export without it leaves the CWE at 0. +- **Description** — Probely names the definition's prose field `desc`, not `description`. + +### Sample Scan Data + +Sample Probely scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/probely). + +The samples are constructed from Probely's documented findings schema, covering all three severity +integers, each of the closed-out states, and a finding under re-test, with a generic scanned host. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- description +- severity +- vuln_id_from_tool +- unique_id_from_tool +- endpoints +- cwe +- mitigation diff --git a/docs/content/supported_tools/parsers/file/quay.md b/docs/content/supported_tools/parsers/file/quay.md new file mode 100644 index 00000000000..af76dcd3970 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/quay.md @@ -0,0 +1,78 @@ +--- +title: "Quay" +toc_hide: true +--- + +Import a [Quay](https://quay.io/) container security report. + +This exists for organisations that cannot grant Quay API credentials — air-gapped networks, procurement +restrictions, a pending security review. The DefectDojo Pro Quay connector pulls the same data over the +API; this parser accepts the same data as a file. + +### File Types + +JSON, from Quay's image security endpoint. Quay's scanner is **Clair**, so the report nests +vulnerabilities under the *features* (packages) they affect, and the keys are **capitalised**: + +```json +{"status": "scanned", "data": {"Layer": {"Features": [ + {"Name": "openssl", "Version": "3.0.11-1", "Vulnerabilities": [{"Name": "CVE-...", "Severity": "High"}]} +]}}} +``` + +A bare `Layer` object and a bare `Features` list are also accepted. One finding is created per +**feature/vulnerability pair**, so a package with three advisories produces three findings. + +If the export carries a top-level `tag`, it is reported as the image tag — Clair's output does not +include it, and the connector supplies it from the tag it scanned. + +### Severity + +Clair's severity word, with one addition worth knowing: **Clair grades `Defcon1` above `Critical`**, +and DefectDojo has nothing higher, so both map to Critical. Not mapping it would drop Clair's most +severe grade to Info. + +| Clair `Severity` | Severity | +| --- | --- | +| `Defcon1`, `Critical` | Critical | +| `High` | High | +| `Medium` | Medium | +| `Low` | Low | +| `Negligible`, `Unknown`, anything else | Info | + +### Fields worth noting + +- **Impact is always `No impact provided`.** Clair supplies no impact assessment, and the connector + states that rather than leaving the field blank. +- **The identity is the advisory id concatenated with the feature name, with no separator** + (`CVE-2000-0001openssl`). That is exactly what the connector builds; inserting a separator here + would give every finding a different tool id from the connector's and break the merge. +- **The fix, namespace and CVE lines appear even when empty**, because the connector writes them + unconditionally — a file import has to read the same way as an API sync for the same finding. +- **Advisory text is flattened, never rendered.** Clair advisory text comes from upstream distro + trackers; `script`/`style` content is dropped, block tags become newlines, and the result is escaped + with Go's entity spellings. + +### Scan type and deduplication + +The scan type is **`Quay - Connectors Import`** — identical to the string the Quay connector reports, so +a customer who uploads an export *and* later enables the connector gets one set of findings that +deduplicate rather than two copies of everything. + +### Sample Scan Data + +Sample Quay scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/quay). + +The samples are constructed from Quay's documented Clair-shaped report and cover a feature with two +advisories, a `Defcon1` grade, an advisory containing markup, one with no fix or namespace, an +unrecognised severity, and a feature with no vulnerabilities at all. Package names, digests and hosts +are generic placeholders. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name +- component_version diff --git a/docs/content/supported_tools/parsers/file/qwiet.md b/docs/content/supported_tools/parsers/file/qwiet.md new file mode 100644 index 00000000000..2358bd6b740 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/qwiet.md @@ -0,0 +1,88 @@ +--- +title: "Qwiet AI" +toc_hide: true +--- + +Import a [Qwiet AI](https://qwiet.ai/) findings export (formerly ShiftLeft). + +This exists for organisations that cannot grant Qwiet API credentials — air-gapped networks, procurement +restrictions, a pending security review. The DefectDojo Pro Qwiet connector pulls the same data over the +API; this parser accepts the same data as a file. + +### File Types + +JSON — the findings response for an app, wrapped as `{"ok": true, "response": [...]}`. A bare array of +findings, or an object with a `findings` list, is accepted too. + +### Most of the data is in tags, not fields + +Qwiet carries the interesting metadata as a **list of key/value tag objects** rather than as fields: + +| Tag key | Becomes | +| --- | --- | +| `cve` | the vulnerability id | +| `package_url` | `component_name` and `component_version` | +| `cvss_score` | `cvssv3_score` | +| `cwe_category` | `cwe` | +| `reachability` | the severity justification and a `reachability:` tag | + +They are objects in a list, not a map, so each is read by key — looking for fields of those names would +find nothing at all. + +The package URL is reduced to its **last path segment**: `pkg:maven/org.example/lib@1.2.3` is `lib` +version `1.2.3`. The namespace before it is the group, not the artefact DefectDojo matches a component +on. + +### Reachability + +Reachability is the reason to use this tool, and it is recorded as the **severity justification** rather +than changing the grade — so a reviewer can see why two findings of equal severity are not equally +urgent. + +A dependency finding (`type: oss_vuln`) that has `related_findings` is treated as reachable **even with +no reachability tag**: those related findings *are* the path Qwiet traced through the application. + +### Severity + +| Qwiet `severity` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| `info`, or anything unrecognised | Info | + +### One hash for two kinds of finding + +This scan type's hash spans `file_path`, `cwe` **and** `component_name`, because Qwiet reports both code +findings and dependency findings: a given finding has a file path or a component, rarely both, and the +unused half hashes as empty. + +### Fields worth noting + +- **File locations** are `:`. Only the **first** becomes `file_path` and `line` — a + data-flow finding spans several files and DefectDojo has one path — but the whole list stays in the + description. An unparseable line number keeps the path rather than discarding the location. +- **The source and sink methods** are the two ends of the flow Qwiet traced, which is what a reviewer + needs to judge whether the path is real. +- **Identity** is `qwiet-`, falling back to the display id; the internal id is what is + stable across scans. + +### Sample Scan Data + +Sample Qwiet AI scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/qwiet). + +The samples are constructed from Qwiet's documented findings response and cover a reachable code finding +with a two-file data flow, a dependency finding reachable through related findings, an explicitly +unreachable one, an unparseable score and CWE, an unparseable line number, a finding with no tags at all, +and one with no title or internal id. Package and class names are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- file_path +- cwe +- component_name diff --git a/docs/content/supported_tools/parsers/file/socket.md b/docs/content/supported_tools/parsers/file/socket.md new file mode 100644 index 00000000000..522c01c0a48 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/socket.md @@ -0,0 +1,53 @@ +--- +title: "Socket" +toc_hide: true +--- + +Import a [Socket](https://socket.dev/) full-scan artifact export. + +This exists for organisations that cannot grant Socket API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Socket connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON. Export the artifacts of a full scan from the Socket API or UI, for example: + +``` +curl -H "Authorization: Bearer $SOCKET_TOKEN" "https://api.socket.dev/v0/orgs//full-scans/" > socket.json +``` + +A bare JSON array of artifacts is accepted, as is an object wrapping them under `artifacts` or +`results`. + +One finding is created per **alert**, so an artifact carrying several alerts produces several findings. + +### Scan type and deduplication + +The scan type is **`Socket - Connectors Import`** — identical to the string the Socket connector +reports. That is deliberate: a customer who uploads an export *and* later enables the connector gets one +set of findings that deduplicate, rather than two copies of everything. + +Deduplication identity is the Socket alert key, carried as `unique_id_from_tool`, matching the +connector's `UniqueIDFromTool`. + +### Severity + +Socket grades alerts `low`, `middle`, `high`, `critical` — note **`middle`**, not `medium`. The mapping +mirrors the connector's: `critical`→Critical, `high`→High, `middle`/`medium`→Medium, `low`→Low, and +anything unrecognised→Info. + +### Sample Scan Data + +Sample Socket scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/socket). + +The samples are constructed from Socket's documented full-scan artifact schema and the shapes the +connector's own converter tests exercise, with generic package scopes. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/soos.md b/docs/content/supported_tools/parsers/file/soos.md new file mode 100644 index 00000000000..1e5d0c1cded --- /dev/null +++ b/docs/content/supported_tools/parsers/file/soos.md @@ -0,0 +1,89 @@ +--- +title: "SOOS" +toc_hide: true +--- + +Import a [SOOS](https://soos.io/) issue export. + +This exists for organisations that cannot grant SOOS API credentials — air-gapped networks, procurement +restrictions, a pending security review. The DefectDojo Pro SOOS connector pulls the same data over the +API; this parser accepts the same data as a file. + +### File Types + +JSON — the issues response, `{"entries": [...]}`. SOOS answers some lists as `{"items": [...]}` and its +own client accepts both, so both are read here; a bare array works too, as does an object naming the +list `issues`, `data` or `results`. + +### One API, five kinds of scan + +SOOS runs SCA, SAST, container, SBOM and DAST scans behind **one** API and stamps each issue with its +scan type, so whether a finding is static or dynamic is decided **per issue** rather than for the file: + +| `scanType` | Imported as | +| --- | --- | +| `sca`, `sast`, `csa`, `sbom` | **static** — an artifact is inspected | +| `dast` | **dynamic** — the application is exercised | +| unrecognised or absent | **dynamic** | + +An unrecognised scan type arriving as dynamic is the connector's own behaviour — it reads its lookup +table with a Go map access, which yields `false` for a missing key just as it does for the `dast` entry. +Mirrored rather than corrected here, so a file import and an API sync agree; worth raising on the +connector side, because a *new* SOOS scan type would arrive as dynamic. + +The scan type is also a tag, so an SCA and a DAST finding on the same product stay distinguishable. + +### Severity + +| SOOS `severity` | Severity | +| --- | --- | +| `Critical` | Critical | +| `High` | High | +| `Medium` | Medium | +| `Low` | Low | +| `Info` | Info | +| **`Unknown`** | Info | +| anything else | Info | + +`Unknown` is a **real SOOS value**, not a gap — a finding it could not grade is still a finding, so it +grades as Info rather than being dropped. + +### A SOOS-side dismissal is carried across + +Without this, an issue somebody dismissed in SOOS would resurface as an active finding on every sync. +The three kinds are kept apart, because they are three different statements: + +| SOOS `status` | Imported as | +| --- | --- | +| `False positive` | inactive, **false positive** — it was never real | +| `Accepted` | inactive, **risk accepted** — it is real and signed off | +| `Ignored`, `Dismissed`, `Resolved`, `Fixed` | inactive, **mitigated** — it is dealt with | +| everything else | **active** | + +Spaces are stripped before matching, so `False positive` and `falsepositive` are the same status. + +### Fields worth noting + +- **Mitigation** prefers SOOS's remediation text; without one it names the fixed version, and the package + too when SOOS named one. +- **An issue with no prose says which scan reported it** rather than arriving with an empty body, which + would read as though the data had been lost in transit. +- **A DAST issue's URL becomes the endpoint**; a source issue has a file path and line instead. A URL + DefectDojo would reject adds no endpoint, since `Endpoint.clean()` raising would fail the whole import. +- **CWE** accepts `CWE-79` or `79`. +- **`cvssv3` is the vector** and `cvssv3_score` the score; SOOS reports both. + +### Deduplication + +This scan type has **no curated hashcode field list**, so it deduplicates with DefectDojo's default +algorithm — which is what the connector's own findings already do. Choosing hashcode fields here would +also change how those findings deduplicate, so it is left alone. + +### Sample Scan Data + +Sample SOOS scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/soos). + +The samples cover one issue of each scan type, a false positive, an accepted risk, a resolved issue, an +`Unknown` severity, an unrecognised scan type and severity word, a bare CWE number, an unparseable one, a +line of zero, an unparseable timestamp, and a URL DefectDojo cannot accept. Package names, hosts and file +paths are generic. diff --git a/docs/content/supported_tools/parsers/file/uptycs.md b/docs/content/supported_tools/parsers/file/uptycs.md new file mode 100644 index 00000000000..cf78ebab9f6 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/uptycs.md @@ -0,0 +1,59 @@ +--- +title: "Uptycs" +toc_hide: true +--- + +Import an [Uptycs](https://www.uptycs.com/) vulnerabilities-query export. + +This exists for organisations that cannot grant Uptycs API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Uptycs connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the query response, `{"items": [...]}`. A bare array works, as does an object naming the list +`rows`, `data` or `results`. + +### One row becomes one finding per CVE + +Uptycs reports **one row per vulnerable package**, listing every CVE against it. Each CVE is separately +fixable and separately triaged, so each becomes its own finding rather than one finding titled after all +of them. A row naming **no** CVE still becomes a single package finding — a vulnerable package is worth +recording even when Uptycs has attached no identifier. + +The CVE list arrives either as a JSON array **or as a comma-separated string**; the string is split and +trimmed, because reading it whole would produce one finding titled after every CVE at once. + +Every finding fanned out of one row **shares that row's severity**: the row carries one CVSS score for +the package rather than one per CVE, so the fan-out cannot grade them apart. + +### Severity + +Uptycs sends no severity word, so the row's CVSS score is the only signal — the standard ≥ 9 / ≥ 7 / +≥ 4 / > 0 bands, with an unscored row landing in Info. Scores may arrive quoted. + +### Fields worth noting + +- **Title** is ` in `, or `Vulnerable package ` without one. A row with no package + name reads as the literal word `package`. +- **Identity** is `uptycs--[-]`, so the same CVE on two hosts stays two findings + — two machines to patch. The component is the package, so the hash alone would merge them; the asset id + is what keeps them apart. +- **The other CVEs are listed in the description only when the row names more than one.** With a single + CVE the title already says it. + +### Sample Scan Data + +Sample Uptycs scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/uptycs). + +The samples cover a row with three CVEs as an array, a row with two as a comma-separated string, a row +with none, a row with no package name, a quoted score, and a row with no asset group. Host, package and +group names are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/vanta.md b/docs/content/supported_tools/parsers/file/vanta.md new file mode 100644 index 00000000000..de22e8f7bfe --- /dev/null +++ b/docs/content/supported_tools/parsers/file/vanta.md @@ -0,0 +1,68 @@ +--- +title: "Vanta" +toc_hide: true +--- + +Import a [Vanta](https://www.vanta.com/) compliance export. + +This exists for organisations that cannot grant Vanta API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Vanta connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the tests response, with rows under `results.data`. A bare array of tests is accepted too. + +**Include each test's failing entities.** A Vanta finding is a *(test, failing entity)* pair: the test +is the control ("MFA is enabled for all users") and the entity is the resource failing it. A test with +no failing entity is the control working, and produces nothing. Because those rows carry no test id of +their own, supply them as: + +- a top-level `entities` object keyed by test id — each value either the paged + `{"results": {"data": [...]}}` response or a bare list, or +- an `entities` array (or paged response) nested on each test. + +Only entities Vanta reports as `FAILING` become findings. The connector asks Vanta for the failing ones +specifically, so an export carrying every entity is filtered here instead. An entity with **no** status +is taken at its word and treated as failing, since the connector never sees any other kind. + +### Severity is always Medium + +Vanta has no severity scale — a compliance test passes or fails. The connector grades every failing +entity **Medium** rather than inventing a ladder, and this parser does the same. Info would read as +non-actionable, and a failing control is actionable by definition. + +That makes `component_name` load-bearing: it is the failing entity, and it is what keeps two resources +failing the same control from merging, since they share a title and a severity. + +### One finding per failing resource + +The identity is `vanta--`. Both findings for one control carry the same +`vuln_id_from_tool` (the test id), so they group as the same control while remaining separate findings. + +### Fields worth noting + +- **Date** is when the *entity* started failing, falling back to when the test last flipped. One + control can have been failing for a year while a resource added last week has only just started + failing it. +- **Mitigation** is Vanta's own remediation description. +- **Tags** carry `compliance`, the test's category, its integrations and the entity's response type. +- **Every finding is static and active** — Vanta evaluates configuration and records, not a running + request, and a failing entity is failing now. + +### Sample Scan Data + +Sample Vanta scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/vanta). + +The samples are constructed from Vanta's documented tests and entities responses and cover one control +failing on two resources, a passing entity that must be skipped, a test with no failing entities, a +test with no name, an unparseable entity date, a test with no integrations, and both the paged and +bare-list entity shapes. Resource names and email addresses are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/venafi.md b/docs/content/supported_tools/parsers/file/venafi.md new file mode 100644 index 00000000000..15c56a1b594 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/venafi.md @@ -0,0 +1,85 @@ +--- +title: "CyberArk Certificate Manager (Venafi)" +toc_hide: true +--- + +Import a [CyberArk Certificate Manager](https://www.cyberark.com/products/certificate-manager/) +(formerly Venafi) certificate inventory export. + +This exists for organisations that cannot grant API credentials — air-gapped networks, procurement +restrictions, a pending security review. The DefectDojo Pro connector pulls the same data over the API; +this parser accepts the same data as a file. + +### Certificate posture is computed, not reported + +**Neither edition returns a compliance verdict.** The export is an inventory of certificates, and the +posture rules are computed from each certificate's own attributes: + +| Rule | Severity | Condition | +| --- | --- | --- | +| `expired` | Critical | the expiry is in the past | +| `expiring-soon` | High | the expiry is within **30 days** | +| `weak-key` | High | an **RSA** key shorter than **2048** bits | +| `weak-signature` | High | the signature hash is SHA1, MD5 or MD2 | +| `self-signed` | Medium | the certificate is self-signed | + +So **one certificate produces zero findings when it is healthy** and several when it breaks several +rules. A rule is **skipped when the attribute it needs is absent** rather than guessed: a certificate +with no recorded key size is not reported as weak, and one with no recorded expiry is not reported +either way — guessing would either raise a false alarm or hide a real lapse. + +Only **RSA** keys are measured against the 2048-bit floor. An elliptic-curve key is much shorter by +design, so applying the same floor would report every EC certificate as weak. + +Hyphens are stripped before the hash check, so `SHA-1` and `SHA1` are both recognised. + +**Expiry is judged against the time of import**, exactly as the connector judges it against sync time. +The same file imported later therefore reports more expiries — which is correct, not a defect: a +certificate that has since lapsed really has lapsed. + +### File Types + +JSON. Both editions are read, and they name their fields differently: + +- **SaaS**: `{"certificates": [...]}` — `fingerprint`, `subjectCN` (a **list**), `subjectDN`, + `subjectAlternativeNamesByType` (a map keyed by SAN type), `issuerCN`, `keyStrength`, + `encryptionType`, `signatureHashAlgorithm`, `validityEnd`, `selfSigned`. +- **Self-hosted**: `{"Certificates": [...]}` — capitalised throughout: `Thumbprint`, `Guid`, `DN`, `CN`, + `Subject`, `Issuer`, `KeySize`, `KeyAlgorithm`, `SignatureAlgorithm`, `ValidTo`. + +A bare array works too. Reading only one edition's names would silently produce **no findings at all** +against the other, because every rule's attribute would look absent. + +The self-hosted edition has **no self-signed flag**, so it is inferred from the subject matching the +issuer — which is what self-signed means. + +### Fields worth noting + +- **Title** is the posture problem with the certificate's common name in brackets. +- **Identity** is `venafi--`, preferring the fingerprint (or thumbprint) over the + record id, because that is the value that identifies the certificate itself. +- **The certificate is the component**, named by common name and falling back to the fingerprint, so + the same problem on two certificates stays two findings. +- **Subject alternative names are sorted here**, unlike the connector — it iterates a Go map, whose + order is randomised, so its own rendering of that line varies between syncs. Sorting keeps a file + import stable; the line is not part of the deduplication hash, so the two still match. +- **Timestamps** are accepted as RFC 3339, with milliseconds, without a timezone, or as a bare date. + One with no timezone is read as UTC. + +### Sample Scan Data + +Sample scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/venafi). + +The samples cover a SaaS certificate breaking four rules at once, an elliptic-curve certificate that must +*not* be flagged as weak, a healthy certificate producing nothing, one with no attributes at all, one +whose weak hash is named only in the fallback field, and a self-hosted export whose self-signed status has +to be inferred. Common names, issuers and fingerprints are generic, and no fixture contains a real +certificate or key. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/vmanplus.md b/docs/content/supported_tools/parsers/file/vmanplus.md new file mode 100644 index 00000000000..9d20e95c35a --- /dev/null +++ b/docs/content/supported_tools/parsers/file/vmanplus.md @@ -0,0 +1,75 @@ +--- +title: "ManageEngine Vulnerability Manager Plus" +toc_hide: true +--- + +Import a [ManageEngine Vulnerability Manager Plus](https://www.manageengine.com/vulnerability-management/) +export. + +This exists for organisations that cannot grant VMP API credentials — air-gapped networks, procurement +restrictions, a pending security review. The DefectDojo Pro VMP connector pulls the same data over the +API; this parser accepts the same data as a file. + +### File Types + +JSON — `{"vulnerabilities": [...]}` alongside VMP's paging metadata. A bare array works, as does an +object naming the list `data` or `results`. + +Rows are already **fused**: each carries both the vulnerability and the asset it was found on, so +nothing has to be joined. + +### Severity: the MSRC names do not mean what they say + +VMP grades on Microsoft's scale, whose names do not match DefectDojo's: + +| VMP `severity` | Severity | +| --- | --- | +| `Critical` | Critical | +| **`Important`** | **High** | +| `High` | High | +| **`Moderate`** | **Medium** | +| `Medium` | Medium | +| `Low` | Low | +| `Unrated`, absent | Info | + +DefectDojo has neither `Important` nor `Moderate`, so reading them literally would fall through to Info +and drop each a whole tier. + +### Status + +| VMP `vulnerability_status` | Imported as | +| --- | --- | +| `Close`, `Closed`, `Fixed`, `Remediated` | inactive | +| everything else | active | + +An unfamiliar status stays **active** — treating it as closed would silently hide a live vulnerability. + +### Fields worth noting + +- **Title** is the vulnerability name, falling back to its CVE ids and then the vulnerability id. +- **The host is the component**, not a package — named by resource name, then FQDN, then address — so the + same vulnerability on two machines stays two findings. +- **`cvss_3_score` wins over `cvss_2_score`**; VMP reports both for older advisories. +- **Every CVE for a vulnerability arrives in one `cveids` string**, so identifiers are extracted from it + rather than used whole. They come back **sorted**, matching the connector's shared extractor; the raw + field is still shown in the description as VMP wrote it. +- **Ids are strings that may arrive as numbers.** One read as a float would render as `50124.0` and never + match the API's `50124`, so integral floats render without the decimal point. +- **Timestamps are epoch MILLIseconds** — reading them as seconds would date every finding to 1970. +- **Mitigation** is the patch description and the patch id, the two things needed to act on the finding. + +### Sample Scan Data + +Sample VMP scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/vmanplus). + +The samples cover an `Important` and a `Moderate` finding, a closed one, a `Mitigated` one that must stay +active, a v3 score of zero falling back to v2, two CVEs in one field out of order, ids sent as numbers, a +zero timestamp, and a row with no patch. Host names and addresses are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/wallarm.md b/docs/content/supported_tools/parsers/file/wallarm.md new file mode 100644 index 00000000000..54e5d24535d --- /dev/null +++ b/docs/content/supported_tools/parsers/file/wallarm.md @@ -0,0 +1,83 @@ +--- +title: "Wallarm" +toc_hide: true +--- + +Import a [Wallarm](https://www.wallarm.com/) vulnerabilities export. + +This exists for organisations that cannot grant Wallarm API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Wallarm connector pulls the same +data over the API; this parser accepts the same data as a file. + +### File Types + +JSON — the `/v1/objects/vuln` response, with rows under `body`. A bare array of rows is accepted too. + +### Closed and false-positive rows are skipped + +A `status` of `closed` or `falsepositive` means Wallarm has already dealt with the vulnerability, so it +is not imported. Everything else is, including a row with no status at all. + +### The threat level is a number *or* a word + +Wallarm sends the threat level in a single `threat` field as either form, depending on which API +answered — so both ladders are needed. Reading a number as a label, or the other way round, would drop +everything to Info. + +| Numeric `threat` | Severity | +| --- | --- | +| ≥ 5 | Critical | +| 4 | High | +| 3 | Medium | +| 2 | Low | +| 1, 0 | Info | + +| Word `threat` | Severity | +| --- | --- | +| `critical` | Critical | +| `high` | High | +| `medium` | Medium | +| `low` | Low | +| `info`, `information`, `informational`, empty | Info | +| anything else | Info | + +Note **5 is the most severe**, the inverse of a priority number. This is the one part of the mapping +Wallarm's own documentation does not pin down; it is copied from the connector rather than inferred, and +is worth confirming against a live tenant. + +### Identity + +`wallarm-`, falling back to `wallarm-` and then to `wallarm-`. The location +fallback is last because it is the only one that is not an id — two vulnerabilities of different types +on one path would collide — but something stable beats nothing. + +### Fields worth noting + +- **Mitigation is Wallarm's exploit example**, not advice. It is the only remediation-shaped field + Wallarm has, and a reviewer can act on a reproduction. Mirrored from the connector; flagged as a + follow-up for both sides. +- **Only an absolute path is appended to the endpoint.** Wallarm uses the `path` field for a parameter + location on some vulnerability types, so a value not beginning with `/` is left out of the endpoint — + it still appears in the description. +- **Dates are unix seconds** (`validate_time`); a row without one keeps DefectDojo's default of today. +- **Vulnerability identifiers** are read from the title, type, template, description and additional + text, then **sorted** and deduplicated case-insensitively, matching the connector's extractor. +- **Every finding is active, dynamic and not static** — Wallarm watches live API traffic and validates + against the running service. + +### Sample Scan Data + +Sample Wallarm scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/wallarm). + +The samples are constructed from Wallarm's documented vulnerabilities response and cover a numeric and +a word threat level, an unrecognised label, a null threat, a row with no id (so the wid is used), a row +with neither (so the location is), a non-absolute path, duplicate CVEs in mixed case, and both a closed +and a false-positive row. Hostnames are generic. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- component_name diff --git a/docs/content/supported_tools/parsers/file/yeswehack.md b/docs/content/supported_tools/parsers/file/yeswehack.md new file mode 100644 index 00000000000..026a9650913 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/yeswehack.md @@ -0,0 +1,84 @@ +--- +title: "YesWeHack" +toc_hide: true +--- + +Import a [YesWeHack](https://www.yeswehack.com/) reports export. + +This exists for organisations that cannot grant YesWeHack API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro YesWeHack connector pulls the +same data over the API; this parser accepts the same data as a file. + +### File Types + +JSON, from YesWeHack's reports endpoint. The API's `items` envelope is accepted, as is a bare array +of reports. + +### The workflow state becomes the DefectDojo state + +A YesWeHack report's state carries real triage information, and importing everything as active would +put resolved, rejected and duplicate reports back in front of the team. Each state is translated: + +| YesWeHack `workflow_state` | Imported as | +| --- | --- | +| `new`, `under_review` | active | +| `accepted` | active **and verified** | +| `resolved`, `auto_close` | inactive, mitigated | +| `wont_fix` | inactive, risk accepted | +| `invalid`, `rejected` | inactive, false positive | +| `duplicate` | inactive, duplicate | +| `out_of_scope`, `informative` | inactive | +| anything else | active | + +An unrecognised state stays **active** — the safe side of the assumption, so a state YesWeHack adds +later cannot silently close a finding. + +### Severity + +Resolved from the first source that yields a recognised word, in this order: + +1. the CVSS block's `criticity` +2. the priority's `name` +3. the priority's `slug` + +Falling straight to Info when the criticity is unset would throw away a priority YesWeHack did set. +`critical`/`high`/`medium`/`low` map directly, and YesWeHack's `info`, `informative` and `none` all +become Info. + +The CVSS vector is imported, and the score when it is above zero. + +### Fields worth noting + +- **`vuln_id_from_tool` is the human-facing local id** (`GENERIC-2026-0001`), falling back to the + numeric id; `unique_id_from_tool` is always the numeric id. +- **Description** is built with the same markdown the connector uses — `* **Prefix** value` bullets + for the report id, bug type, category, scope and endpoint, then `### Description` and `### Impact` + sections. +- **CVEs** are extracted from the title, description, impact **and technical information** — YesWeHack + has no CVE field, and the technical information is the field easiest to overlook. +- **Endpoint** is the reported endpoint, falling back to the programme scope. +- **Dates** — several timestamp layouts are accepted, matching the connector, so a non-RFC3339 stamp + still dates the finding. + +### Scan type and deduplication + +The scan type is **`YesWeHack - Connectors Import`** — identical to the string the YesWeHack connector +reports, so a customer who uploads an export *and* later enables the connector gets one set of +findings that deduplicate rather than two copies of everything. + +Report ids are globally unique on the platform, so deduplication uses the plain `hash_code` algorithm +over `unique_id_from_tool` alone. + +### Sample Scan Data + +Sample YesWeHack scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/yeswehack). + +The samples are constructed from YesWeHack's documented report schema and cover every workflow state +in the table above plus an unrecognised one, all three severity sources, and each accepted timestamp +layout. Programme names, hosts and local ids are generic placeholders. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/zimperium.md b/docs/content/supported_tools/parsers/file/zimperium.md new file mode 100644 index 00000000000..559d2babba9 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/zimperium.md @@ -0,0 +1,75 @@ +--- +title: "Zimperium zScan" +toc_hide: true +--- + +Import a [Zimperium zScan](https://www.zimperium.com/zscan/) assessment SARIF report. + +This exists for organisations that cannot grant Zimperium API credentials — air-gapped networks, +procurement restrictions, a pending security review. The DefectDojo Pro Zimperium connector pulls the +same data over the API; this parser accepts the same data as a file. + +### The SARIF mapping is shared + +On the Go side the Zimperium connector calls the same shared SARIF utility the other SARIF-reporting +connectors use, parameterised by an identity prefix and a static/dynamic flag. This parser extends the +same shared mapping rather than restating it — the way the shipped `invicti` parser extends +`netsparker` — so a change to that mapping lands in one place, exactly as it does upstream. + +That means the SARIF behaviour is identical to the other SARIF-backed parsers: a result whose `kind` is +anything other than `fail` is skipped, an absent `kind` means failure, a **suppressed result is inactive +and a false positive**, and a result with **no `level` is Medium, not Info**. Severity prefers the rule's +`security-severity` property (as a CVSS number, then as a word) over the result level. + +### File Types + +JSON — a SARIF report. Wrap it to supply the app and build context a SARIF document does not carry: + +```json +{ + "assessment": {"id": "assess-0001", "appVersion": "3.2.0", "buildUploadedAt": "2024-06-02T09:00:00Z"}, + "app": {"name": "Generic Mobile App", "platform": "android"}, + "sarif": { "runs": [ ... ] } +} +``` + +Each field may also sit at the top level, and the log may be under `sarif`, `log` or `report`. A bare +SARIF report imports fine — it simply carries no component, version or platform. + +### The decoration is what makes a mobile finding actionable + +A SARIF document says nothing about **which app** or **which build** it came from. Two builds of one app +land in the same DefectDojo product, and without the version there is no telling them apart. So after +the shared mapping runs, the export's context supplies: + +- **`component_name`** — the app's name +- **`component_version`** — the build's `appVersion` +- **`date`** — the date the build was uploaded +- a **platform tag**, appended *after* the SARIF tags + +Each is only filled when the SARIF mapping left it empty, as the connector does. + +### Give the report the assessment id + +The identity is `zimperium---:`. One assessment is one scan of one +build, so the same rule firing in two builds stays two findings — which is what lets a reader see that a +problem survived a release. `assessment_id`, `assessmentId` and `id` are all accepted. Without one, +findings **will not deduplicate against connector-synced ones**. + +### Sample Scan Data + +Sample Zimperium scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/zimperium). + +The samples cover a scored high-severity Android finding, an iOS assessment with a hard-coded-secret +result, a suppressed weak-crypto result, a note-level result with no score, and a `pass` result that must +not import. App, bundle and file names are generic, and no fixture contains a real credential — the +"hard-coded secret" result names no value at all. + +### Default Deduplication Hashcode Fields + +By default, DefectDojo identifies duplicate findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- severity +- file_path +- vuln_id_from_tool diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 1b6cf038e75..034696de88c 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1218,6 +1218,102 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "pnpm Audit Scan": ["component_name", "component_version", "vuln_id_from_tool"], "Dotnet Vulnerable Packages Scan": ["component_name", "component_version", "vuln_id_from_tool"], "Mix Audit Scan": ["component_name", "component_version", "vuln_id_from_tool"], + # Copied verbatim from the Socket and Lacework blocks in dojo-pro pro_settings.py. These + # must agree or a file import and an API sync compute different hash codes for the same + # finding and stop deduplicating against each other. + "Socket - Connectors Import": ["title", "severity", "component_name"], + "Lacework - Connectors Import": ["title", "severity", "component_name"], + # Likewise copied verbatim from the CrowdStrike Spotlight block. Note it lists + # unique_id_from_tool among the hash fields as well as pairing with the + # unique_id_from_tool_or_hash_code algorithm; that is what the connector configures. + "CrowdStrike:Spotlight - Connectors Import": [ + "unique_id_from_tool", + "title", + "severity", + "vulnerability_ids", + ], + "FOSSA - Connectors Import": ["title", "severity", "component_name"], + "Endor Labs - Connectors Import": ["title", "severity", "vuln_id_from_tool"], + # GitGuardian incident ids are stable, so the connector hashes on the unique id alone. Note this + # one pairs with the plain hash_code algorithm, not unique_id_from_tool_or_hash_code. + "GitGuardian - Connectors Import": ["unique_id_from_tool"], + "Codacy - Connectors Import": ["title", "severity", "vuln_id_from_tool"], + "DeepSource - Connectors Import": ["title", "severity", "file_path"], + # Probely does not follow the " - Connectors Import" naming. Note this block pairs + # the plain hash_code algorithm with a wide field set that includes endpoints, so the + # endpoint must be populated for the hash to mean anything. + "Probely API Import": [ + "title", + "description", + "severity", + "vuln_id_from_tool", + "unique_id_from_tool", + "endpoints", + "cwe", + "mitigation", + ], + # Detectify also breaks the " - Connectors Import" naming. Findings carry a stable + # uuid, so the connector prefers it and falls back to these hash fields. + "Detectify Scan": ["title", "severity", "component_name"], + # HackerOne and YesWeHack report ids are globally unique on their platforms, so both + # connector blocks hash the unique id alone. + "HackerOne - Connectors Import": ["unique_id_from_tool"], + "YesWeHack - Connectors Import": ["unique_id_from_tool"], + "Intigriti - Connectors Import": ["unique_id_from_tool"], + "Google Cloud SCC - Connectors Import": ["unique_id_from_tool"], + "Fairwinds Insights - Connectors Import": ["title", "severity", "component_name"], + "AccuKnox - Connectors Import": ["title", "severity", "description"], + "Halo Security - Connectors Import": ["title", "severity", "endpoints"], + "Beagle Security - Connectors Import": ["title", "severity", "endpoints"], + "Nightfall AI - Connectors Import": ["title", "severity", "description"], + "Fleet:Vulnerabilities - Connectors Import": ["title", "severity", "component_name"], + "Fleet:Policies - Connectors Import": ["title", "severity", "vuln_id_from_tool"], + "Elastic Security:CNVM - Connectors Import": ["title", "severity", "component_name"], + "Elastic Security:Posture - Connectors Import": ["title", "severity", "vuln_id_from_tool"], + "Elastic Security:Detections - Connectors Import": ["title", "severity", "vuln_id_from_tool"], + "Action1 Scan": ["title", "severity", "component_name", "component_version"], + "Datadog Cloud Security": ["title", "severity", "component_name"], + "Escape - Connectors Import": ["title", "severity", "endpoints"], + "Rapid7 InsightAppSec - Connectors Import": ["unique_id_from_tool"], + "Intruder API Import": ["unique_id_from_tool", "title", "severity"], + "NowSecure": ["title", "severity", "component_name"], + "Vanta Compliance": ["title", "severity", "component_name"], + "Wallarm API Security": ["title", "severity", "component_name"], + "Bright - Connectors Import": ["title", "severity", "endpoints"], + "Microsoft Defender for Cloud - Connectors Import": ["unique_id_from_tool"], + "Akto Scan": ["title", "severity", "endpoints", "vuln_id_from_tool"], + "Holm Security Scan": ["title", "severity", "endpoints", "vuln_id_from_tool"], + "Klocwork Scan": ["title", "severity", "file_path", "vuln_id_from_tool"], + "Qwiet Scan": ["title", "severity", "file_path", "cwe", "component_name"], + "Automox Scan": ["title", "severity", "component_name"], + "BigID Scan": ["title", "severity", "component_name"], + "Calico Cloud Image Assurance Scan": [ + "title", + "severity", + "component_name", + "component_version", + ], + "Dragos Scan": ["title", "severity", "component_name"], + "HiddenLayer Model Scan": ["title", "severity", "file_path"], + "NetRise Scan": ["title", "severity", "component_name"], + "Nozomi Vantage Scan": ["title", "severity", "component_name"], + "Ostorlab Scan": ["title", "severity", "component_name"], + "Parasoft DTP Scan": ["title", "severity", "file_path", "vuln_id_from_tool"], + "Uptycs Scan": ["title", "severity", "component_name"], + "CyberArk Certificate Manager Scan": ["title", "severity", "component_name"], + "ManageEngine Vulnerability Manager Plus Scan": [ + "title", + "severity", + "component_name", + ], + "Zimperium zScan": ["title", "severity", "file_path", "vuln_id_from_tool"], + "Group-IB ASM - Connectors Import": ["title", "severity"], + "Quay - Connectors Import": [ + "title", + "severity", + "component_name", + "component_version", + ], # The network scanners below describe what they found in the description: a response size, a # detected version, a scan timestamp, or - for sqlmap - a payload built from random numbers. # All of those change between two scans of an unchanged target, so the legacy algorithm (which @@ -1366,6 +1462,59 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "pnpm Audit Scan": DEDUPE_ALGO_HASH_CODE, "Dotnet Vulnerable Packages Scan": DEDUPE_ALGO_HASH_CODE, "Mix Audit Scan": DEDUPE_ALGO_HASH_CODE, + "Socket - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Lacework - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "CrowdStrike:Spotlight - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "FOSSA - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Endor Labs - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "GitGuardian - Connectors Import": DEDUPE_ALGO_HASH_CODE, + "Codacy - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "DeepSource - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Probely API Import": DEDUPE_ALGO_HASH_CODE, + "Detectify Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "HackerOne - Connectors Import": DEDUPE_ALGO_HASH_CODE, + "YesWeHack - Connectors Import": DEDUPE_ALGO_HASH_CODE, + "Intigriti - Connectors Import": DEDUPE_ALGO_HASH_CODE, + "Google Cloud SCC - Connectors Import": DEDUPE_ALGO_HASH_CODE, + "Fairwinds Insights - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "AccuKnox - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Halo Security - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Beagle Security - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Nightfall AI - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Fleet:Vulnerabilities - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Fleet:Policies - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Elastic Security:CNVM - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Elastic Security:Posture - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Elastic Security:Detections - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Action1 Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Datadog Cloud Security": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Escape - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Rapid7 InsightAppSec - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Intruder API Import": DEDUPE_ALGO_HASH_CODE, + "NowSecure": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Vanta Compliance": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Wallarm API Security": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Bright - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Microsoft Defender for Cloud - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Akto Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Holm Security Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Klocwork Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Qwiet Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Automox Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "BigID Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Calico Cloud Image Assurance Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Dragos Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "HiddenLayer Model Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "NetRise Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Nozomi Vantage Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Ostorlab Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Parasoft DTP Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Uptycs Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "CyberArk Certificate Manager Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "ManageEngine Vulnerability Manager Plus Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Zimperium zScan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Group-IB ASM - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, + "Quay - Connectors Import": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, "Anchore Engine Scan": DEDUPE_ALGO_HASH_CODE, "AnchoreCTL Vuln Report": DEDUPE_ALGO_HASH_CODE, "AnchoreCTL Policies Report": DEDUPE_ALGO_HASH_CODE, diff --git a/dojo/tools/accuknox/__init__.py b/dojo/tools/accuknox/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/accuknox/parser.py b/dojo/tools/accuknox/parser.py new file mode 100644 index 00000000000..dbda95dd4b9 --- /dev/null +++ b/dojo/tools/accuknox/parser.py @@ -0,0 +1,304 @@ +import json +import re +from contextlib import suppress +from datetime import datetime + +from dojo.models import Finding + +# AccuKnox grades by "risk factor". Anything else, including an empty value, is Info. +SEVERITY_BY_RISK_FACTOR = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +STATUS_POTENTIAL = "potential" +STATUS_ACCEPTED_RISK = "accepted risk" +STATUS_DUPLICATE = "duplicate" +STATUS_FIXED = "fixed" + +# AccuKnox covers container, IaC, cloud-posture and runtime findings through one endpoint, and the +# row keys differ per data type. The connector probes a list of candidate names for each field +# rather than assuming one schema, because AccuKnox does not publish this part of it. +ID_FIELDS = ("finding_id", "id", "uuid") +TITLE_FIELDS = ("name", "title", "finding_name", "vulnerability_name") +DATA_TYPE_FIELDS = ("data_type",) +SEVERITY_FIELDS = ("risk_factor", "severity") +STATUS_FIELDS = ("status", "finding_status") +DESCRIPTION_FIELDS = ("description", "details", "summary", "message") +SOLUTION_FIELDS = ("solution", "remediation", "recommendation", "fix") +ASSET_TYPE_FIELDS = ("asset_type", "resource_type") +ASSET_NAME_FIELDS = ("asset_name", "resource_name", "asset", "resource") +CVE_FIELDS = ("cve", "cve_id", "cve_ids") +COMPONENT_FIELDS = ("package_name", "component_name", "package", "component") +VERSION_FIELDS = ("package_version", "component_version", "installed_version", "version") +LAST_SEEN_FIELDS = ("last_seen", "present_on_date") +DISCOVERED_FIELDS = ("date_discovered", "first_seen", "created_at") +IGNORED_FIELDS = ("ignored",) + +# Some AccuKnox rows prefix the vulnerability columns, so every candidate is tried both bare and +# prefixed. +NESTED_PREFIX = "vulnerability__" + +CVE_PATTERN = re.compile(r"CVE-\d{4}-\d{4,7}", re.IGNORECASE) + +DATE_FORMATS = ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d") + + +class AccuKnoxParser: + + """ + Parses an AccuKnox findings export. + + Mirrors pkg/tools/accuknox/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + AccuKnox returns heterogeneous rows - container scans, IaC, cloud posture, runtime - through one + endpoint, and the column names differ by data type. The connector therefore probes a list of + candidate keys per field instead of assuming one schema, and this parser does the same. See the + *_FIELDS tuples. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["AccuKnox - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "AccuKnox - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an AccuKnox findings export (JSON). Matches the scan type used by the AccuKnox " + "connector so file and API findings deduplicate. Handles AccuKnox's varying column names " + "across its container, IaC, cloud-posture and runtime data types." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the AccuKnox Parser. + + Mirrors the connector's RowToFinding: + - title: the row's name, falling back to "AccuKnox finding ". + - severity: the row's risk factor; anything unrecognised Info. + - description: the prose, then the finding class, status, asset and first/last seen dates. + - mitigation: the row's solution or remediation text. + - component_name / component_version: the affected package. + - service: the asset the finding is on. + - active / is_mitigated / risk_accepted / duplicate / verified / out_of_scope: from the row's + status and its ignored flag; see apply_status(). + - unique_id_from_tool: AccuKnox's finding id. + - vuln_id_from_tool: the data type, which is AccuKnox's finding class. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "component_name", + "component_version", + "service", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "is_mitigated", + "risk_accepted", + "duplicate", + "verified", + "out_of_scope", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the AccuKnox Parser. + + Copied from the AccuKnox block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "description"] + + def get_findings(self, filename, test): + data = json.load(filename) + rows = self.extract_rows(data) + + return [self.build_finding(row, test) for row in rows if isinstance(row, dict)] + + def extract_rows(self, data): + """AccuKnox's findings endpoint pages rows under "results"; a bare array is common too.""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("results", "findings", "data", "rows"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "An AccuKnox export is a JSON array of finding rows, or an object with a 'results' " + f"list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def value(self, row, candidates): + """ + Return the first non-empty value among the candidate keys. + + Each candidate is tried bare and with AccuKnox's "vulnerability__" column prefix, because + some data types nest the vulnerability columns that way. + """ + for candidate in candidates: + for name in (candidate, NESTED_PREFIX + candidate): + if name not in row: + continue + rendered = self.scalar(row[name]) + if rendered: + return rendered + return "" + + def scalar(self, value): + """Render a scalar as text; a list is joined so a cve_ids array is still searchable.""" + if value is None or isinstance(value, (dict,)): + return "" + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, list): + return ", ".join(str(v).strip() for v in value if str(v).strip()) + return str(value).strip() + + def flag(self, row, candidates): + """Read a boolean-ish value, tolerating the string forms AccuKnox uses.""" + for candidate in candidates: + for name in (candidate, NESTED_PREFIX + candidate): + if name not in row: + continue + value = row[name] + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"true", "yes", "1"} + if isinstance(value, (int, float)): + return bool(value) + return False + + def build_finding(self, row, test): + status = self.value(row, STATUS_FIELDS).lower() + + finding = Finding( + test=test, + title=self.title(row), + severity=self.severity(row), + date=self.date(row), + description=self.describe(row), + mitigation=self.value(row, SOLUTION_FIELDS) or None, + component_name=self.value(row, COMPONENT_FIELDS) or None, + component_version=self.value(row, VERSION_FIELDS) or None, + service=self.value(row, ASSET_NAME_FIELDS) or None, + unique_id_from_tool=self.value(row, ID_FIELDS) or None, + # AccuKnox's data_type is its finding class, e.g. container or iac. + vuln_id_from_tool=self.value(row, DATA_TYPE_FIELDS) or None, + # AccuKnox reports on stored artefacts and configuration, not a live probe. + static_finding=True, + dynamic_finding=False, + ) + self.apply_status(finding, status) + finding.unsaved_tags = self.tags(row) + + if self.flag(row, IGNORED_FIELDS): + # AccuKnox lets a user suppress a row; the connector records that rather than dropping it. + finding.out_of_scope = True + + cves = self.cves(row) + if cves: + finding.unsaved_vulnerability_ids = cves + return finding + + def apply_status(self, finding, status): + """ + Translate AccuKnox's status. + + Only fixed, accepted-risk and duplicate close a finding; the working states (active, in + progress, waiting for 3rd party, exception requested, waiting for verification) stay open. + A row with NO status is treated as verified, and only "potential" is explicitly unverified. + """ + finding.is_mitigated = status == STATUS_FIXED + finding.risk_accepted = status == STATUS_ACCEPTED_RISK + finding.duplicate = status == STATUS_DUPLICATE + finding.active = not (finding.is_mitigated or finding.risk_accepted or finding.duplicate) + # Note a blank status counts as UNVERIFIED here, alongside "potential". + finding.verified = status not in {"", STATUS_POTENTIAL} + + def title(self, row): + """ + The row's name, or the finding id, or a bare constant. + + AccuKnox does not publish the schema for every data type, so a row can arrive with no + recognisable title at all and still has to import. + """ + title = self.value(row, TITLE_FIELDS) + if title: + return title + identifier = self.value(row, ID_FIELDS) + if identifier: + return f"AccuKnox finding {identifier}" + return "AccuKnox finding" + + def severity(self, row): + raw = self.value(row, SEVERITY_FIELDS).lower() + return SEVERITY_BY_RISK_FACTOR.get(raw, DEFAULT_SEVERITY) + + def describe(self, row): + sections = [] + prose = self.value(row, DESCRIPTION_FIELDS) + if prose: + sections.append(prose) + + details = [ + f"**{label}:** {value}" for label, value in ( + ("Finding class", self.value(row, DATA_TYPE_FIELDS)), + ("AccuKnox status", self.value(row, STATUS_FIELDS)), + ("Asset", self.value(row, ASSET_NAME_FIELDS)), + ("Asset type", self.value(row, ASSET_TYPE_FIELDS)), + ("First seen", self.value(row, DISCOVERED_FIELDS)), + ("Last seen", self.value(row, LAST_SEEN_FIELDS)), + ) if value + ] + if details: + sections.append("\n".join(details)) + return "\n\n".join(sections) + + def tags(self, row): + return [ + value for value in + (self.value(row, DATA_TYPE_FIELDS), self.value(row, ASSET_TYPE_FIELDS)) if value + ] + + def cves(self, row): + """ + Extract CVEs from the CVE column, falling back to the title. + + AccuKnox often carries the identifier only in the finding name, and the column may hold + several, so the value is scanned rather than read. + """ + haystack = self.value(row, CVE_FIELDS) or self.value(row, TITLE_FIELDS) + found, seen = [], set() + for cve in CVE_PATTERN.findall(haystack): + upper = cve.upper() + if upper not in seen: + seen.add(upper) + found.append(upper) + return found + + def date(self, row): + """The discovery date, from whichever of AccuKnox's date columns the row carries.""" + raw = self.value(row, DISCOVERED_FIELDS) + if not raw: + return None + with suppress(ValueError): + return datetime.fromisoformat(raw).date() + for fmt in DATE_FORMATS: + with suppress(ValueError): + return datetime.strptime(raw, fmt).date() + return None diff --git a/dojo/tools/action1/__init__.py b/dojo/tools/action1/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/action1/parser.py b/dojo/tools/action1/parser.py new file mode 100644 index 00000000000..7f2893f3874 --- /dev/null +++ b/dojo/tools/action1/parser.py @@ -0,0 +1,341 @@ +import json +import re +from contextlib import suppress +from ipaddress import ip_address + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_BUCKET = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class Action1Parser: + + """ + Parses an Action1 vulnerability export. + + Mirrors pkg/tools/action1/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Action1 needs two calls to describe a finding: the vulnerability list, and the endpoints affected + by each CVE. The affected endpoint is what makes a finding - a vulnerability with none produces + nothing at all - so an export needs both; see extract(). A third call, the managed-endpoint list, + supplies each machine's operating system, which the connector treats as best-effort enrichment. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Action1 Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Action1 Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Action1 vulnerability export (JSON). Matches the scan type used by the Action1 " + "connector so file and API findings deduplicate. Include the endpoints affected by each " + "CVE - a vulnerability with no affected endpoint is not a finding." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Action1 Parser. + + Mirrors the connector's Convert: + - title: the vulnerability name, then the CVE id, then a constant. + - severity: the base severity, falling back to Action1's own score bucket; see severity(). + - description: the CVE, the endpoint, its OS and the remediation status. + - mitigation: the updates Action1 has available for the affected software. + - component_name / component_version: the vulnerable software on that endpoint. + - unique_id_from_tool: "action1--" - one CVE per affected machine. + """ + return [ + "title", + "severity", + "description", + "mitigation", + "component_name", + "component_version", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Action1 Parser. + + Copied from the Action1 block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The component version is among them, + so a machine that has been partly patched does not merge with one that has not. + """ + return ["title", "severity", "component_name", "component_version"] + + def get_findings(self, filename, test): + data = json.load(filename) + vulnerabilities, affected, operating_systems = self.extract(data) + + findings = [] + for vulnerability in vulnerabilities: + if not isinstance(vulnerability, dict): + continue + for endpoint in self.endpoints_for(vulnerability, affected): + identifier = str(endpoint.get("endpoint_id") or "").strip() + if not identifier: + # The connector skips an affected-endpoint row with no id: it cannot be + # identified, and the id is half of the finding's own identity. + continue + findings.append(self.build_finding(vulnerability, endpoint, operating_systems, test)) + return findings + + def extract(self, data): + """ + Return the vulnerabilities, the endpoints affected by each CVE, and each machine's OS. + + Action1 pages every list under "items". The affected endpoints come from a second call, one + per CVE, so an export carries them either as a top-level map keyed by CVE id or nested on each + vulnerability. The managed-endpoint list, keyed by endpoint id, supplies the OS. + """ + affected, operating_systems = {}, {} + vulnerabilities = None + + if isinstance(data, list): + vulnerabilities = data + elif isinstance(data, dict): + for key in ("items", "vulnerabilities"): + if isinstance(data.get(key), list): + vulnerabilities = data[key] + break + affected = self.index_affected(data) + operating_systems = self.index_operating_systems(data) + + if vulnerabilities is None: + msg = ( + "An Action1 export is the vulnerability-list response, a JSON object with an 'items' " + f"list; got {type(data).__name__}." + ) + raise TypeError(msg) + return vulnerabilities, affected, operating_systems + + def index_affected(self, data): + """The affected endpoints as a map keyed by CVE id, accepting a couple of spellings.""" + for key in ("endpoints", "affected_endpoints"): + source = data.get(key) + if isinstance(source, dict): + return { + str(cve): [row for row in rows if isinstance(row, dict)] + for cve, rows in source.items() + if isinstance(rows, list) + } + return {} + + def index_operating_systems(self, data): + """ + Managed endpoints, indexed id -> OS. + + Action1 spells the field "OS", capitalised, in its managed-endpoint response. + """ + indexed = {} + for key in ("managed_endpoints", "endpoints_managed"): + source = data.get(key) + rows = source.get("items") if isinstance(source, dict) else source + if not isinstance(rows, list): + continue + for row in rows: + if not isinstance(row, dict): + continue + identifier = str(row.get("id") or "").strip() + name = str(row.get("OS") or row.get("os") or "").strip() + if identifier and name: + indexed[identifier] = name + break + return indexed + + def endpoints_for(self, vulnerability, affected): + """The affected endpoints nested on the vulnerability, else those indexed by its CVE id.""" + for key in ("endpoints", "affected_endpoints"): + nested = vulnerability.get(key) + if isinstance(nested, list): + return [row for row in nested if isinstance(row, dict)] + if isinstance(nested, dict) and isinstance(nested.get("items"), list): + return [row for row in nested["items"] if isinstance(row, dict)] + return affected.get(str(vulnerability.get("cve_id") or "").strip(), []) + + def build_finding(self, vulnerability, endpoint, operating_systems, test): + cve = str(vulnerability.get("cve_id") or "").strip() + identifier = str(endpoint.get("endpoint_id") or "").strip() + software = self.primary_software(vulnerability, endpoint) + + finding = Finding( + test=test, + title=self.title(vulnerability, cve), + severity=self.severity(vulnerability), + description=self.describe(vulnerability, endpoint, operating_systems, cve), + mitigation=self.mitigation(software), + unique_id_from_tool=f"action1-{cve}-{identifier}", + vuln_id_from_tool=cve or None, + # Action1 reads an installed-software inventory from the agent; nothing is exercised. + static_finding=True, + dynamic_finding=False, + # The connector marks every finding active: Action1 only reports what is still present. + active=True, + ) + # Set unconditionally, as the connector does - an unscored vulnerability lands as 0.0. + finding.cvssv3_score = self.flex_float(vulnerability.get("cvss_score")) + + if cve: + finding.unsaved_vulnerability_ids = [cve] + if software is not None: + finding.component_name = str(software.get("product_name") or "").strip() or None + finding.component_version = self.installed_version(software) or None + + self.attach_endpoint(finding, endpoint) + return finding + + def title(self, vulnerability, cve): + if name := str(vulnerability.get("name") or "").strip(): + return name + if cve: + return cve + return "Action1 vulnerability" + + def severity(self, vulnerability): + """ + Grade the vulnerability. + + Action1 reports a base severity and, separately, a "score" that is also a word rather than a + number - Critical/High/Medium/Low. The base severity wins, the score is the fallback, and + anything unrecognised is Info. + """ + bucket = str(vulnerability.get("base_severity") or "").strip() + if not bucket: + bucket = str(vulnerability.get("score") or "").strip() + return SEVERITY_BY_BUCKET.get(bucket.lower(), DEFAULT_SEVERITY) + + def describe(self, vulnerability, endpoint, operating_systems, cve): + """ + The connector joins these with a SINGLE newline, not a blank line. + + Mirrored rather than tidied: the description is part of nothing that hashes here, but a + gratuitous difference between the two import paths is still a difference. + """ + lines = [] + + def add(label, value): + text = str(value or "").strip() + if text: + lines.append(f"**{label}:** {text}") + + add("CVE", cve) + add("Endpoint", self.endpoint_name(endpoint)) + add("OS", operating_systems.get(str(endpoint.get("endpoint_id") or "").strip())) + add("Remediation status", vulnerability.get("remediation_status")) + return "\n".join(lines) + + def endpoint_name(self, endpoint): + """The endpoint's name, falling back to its id.""" + if name := str(endpoint.get("endpoint_name") or "").strip(): + return name + return str(endpoint.get("endpoint_id") or "").strip() + + def mitigation(self, software): + """"Apply: , ..." from the updates Action1 has ready for that software.""" + if software is None: + return "" + updates = software.get("available_updates") + if not isinstance(updates, list): + return "" + + fixes = [] + for update in updates: + if not isinstance(update, dict): + continue + label = str(update.get("name") or "").strip() + if version := str(update.get("version") or "").strip(): + label = f"{label} {version}".strip() + if label: + fixes.append(label) + return "Apply: " + ", ".join(fixes) if fixes else "" + + def primary_software(self, vulnerability, endpoint): + """ + The first software entry on the affected endpoint, else the first on the vulnerability. + + Action1 lists the software per endpoint because the installed version differs by machine, + which is exactly why the endpoint's copy is preferred. + """ + for source in (endpoint, vulnerability): + rows = source.get("software") + if isinstance(rows, list): + for row in rows: + if isinstance(row, dict): + return row + return None + + def installed_version(self, software): + versions = software.get("versions") + if isinstance(versions, list): + for version in versions: + if isinstance(version, dict): + return str(version.get("version") or "").strip() + return "" + + def attach_endpoint(self, finding, endpoint): + """ + Record the affected machine. + + The connector groups findings by endpoint into products rather than writing an endpoint, so + this is additional context rather than a mirrored field - and it is dropped unless the name is + something DefectDojo will accept as a host, since an Action1 endpoint name is free text. + """ + name = self.endpoint_name(endpoint) + if not name or not self.usable_host(name): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(host=name)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=name)) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a machine name with a space in it - makes Endpoint.clean() raise, and that fails the whole + import rather than the one finding. The name is still in the description. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def flex_float(self, value): + """Action1 sends its CVSS score as either a number or a numeric string.""" + if value is None or isinstance(value, bool): + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 diff --git a/dojo/tools/akto/__init__.py b/dojo/tools/akto/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/akto/parser.py b/dojo/tools/akto/parser.py new file mode 100644 index 00000000000..499ba3c177e --- /dev/null +++ b/dojo/tools/akto/parser.py @@ -0,0 +1,326 @@ +import json +import re +from contextlib import suppress +from datetime import UTC, datetime +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_LABEL = { + "CRITICAL": "Critical", + "HIGH": "High", + "MEDIUM": "Medium", + "LOW": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Akto's issue statuses. IGNORED is how a reviewer marks a false positive; FIXED means it is gone. +STATUS_FIXED = "FIXED" +STATUS_IGNORED = "IGNORED" + +# The advisory identifiers the connector's shared extractor recognises. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class AktoParser: + + """ + Parses an Akto API-security export. + + Mirrors pkg/tools/akto/connector/finding_converter field for field so a file import and an API sync + deduplicate against each other instead of producing two copies of everything. + + Akto tests API endpoints, so a finding is a test run against one method and path. The endpoint is + part of both the identity and the deduplication hash, and it doubles as the component - which is + why component_name is " " rather than a package name. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Akto Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Akto Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Akto API-security export (JSON), the fetchIssuesFromCollections response. " + "Matches the scan type used by the Akto connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Akto Parser. + + Mirrors the connector's Convert: + - title: the test name, then its sub-category, then a constant. + - severity: Akto's own label, matched case-insensitively; anything unrecognised is Info. + - description: the endpoint, then Akto's description, impact and details. + - mitigation: Akto's remediation text. + - component_name: " " - the tested endpoint, which is what Akto reports against. + - cwe: parsed from "CWE-89" or a bare number. + - active / false_p: from the issue status; see status(). + - unique_id_from_tool: "akto----". + - vuln_id_from_tool: the test sub-category, which is Akto's rule identity. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "references", + "component_name", + "cwe", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "false_p", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Akto Parser. + + Copied from the Akto block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Both the endpoint and the test are in + the hash: the same test against two paths is two findings, and two different tests against one + path are as well. + """ + return ["title", "severity", "endpoints", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(issue, test) for issue in self.issues(data)] + + def issues(self, data): + """ + Return the issues in the export. + + Akto's issues endpoint nests them under "issueDetails"; a bare array is accepted too. + """ + if isinstance(data, list): + return [issue for issue in data if isinstance(issue, dict)] + if isinstance(data, dict): + for key in ("issueDetails", "issues"): + if isinstance(data.get(key), list): + return [issue for issue in data[key] if isinstance(issue, dict)] + + msg = ( + "An Akto export is the issues response, a JSON object with an 'issueDetails' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, issue, test): + component = self.component_name(issue) + active, false_positive = self.status(issue) + + finding = Finding( + test=test, + title=self.title(issue), + severity=self.severity(issue), + description=self.describe(issue, component), + mitigation=str(issue.get("testRemediation") or ""), + references=self.references(issue), + component_name=component or None, + cwe=self.cwe(issue.get("testCwe")), + unique_id_from_tool=self.unique_id(issue), + vuln_id_from_tool=str(issue.get("testSubCategory") or "") or None, + active=active, + false_p=false_positive, + # Akto exercises a running API. + static_finding=False, + dynamic_finding=True, + ) + finding.unsaved_tags = self.tags(issue) + + if identifiers := self.vulnerability_ids(issue): + finding.unsaved_vulnerability_ids = identifiers + if date := self.date(issue): + finding.date = date + + self.attach_endpoint(finding, issue) + return finding + + def unique_id(self, issue): + """ + "akto----". + + The path and the test are both in the identity because Akto runs every test against every + endpoint it knows: neither alone identifies a finding. + """ + return "-".join([ + "akto", + str(self.flex_int(issue.get("apiCollectionId"))), + str(issue.get("apiMethod") or ""), + str(issue.get("apiUrl") or ""), + str(issue.get("testSubCategory") or ""), + ]) + + def title(self, issue): + for key in ("testName", "testSubCategory"): + if value := str(issue.get(key) or ""): + return value + return "Akto API-security issue" + + def severity(self, issue): + label = str(issue.get("severity") or "").strip().upper() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def status(self, issue): + """ + Akto records triage in the status: IGNORED is a false positive, FIXED is gone. + + Both are inactive; only IGNORED sets the false-positive flag, because "fixed" is not a + judgement about whether the finding was real. + """ + status = str(issue.get("status") or "").strip().upper() + return status not in {STATUS_FIXED, STATUS_IGNORED}, status == STATUS_IGNORED + + def component_name(self, issue): + """ + " " - the tested endpoint. + + Akto has no package to report, so the endpoint is the component, and that is what the + deduplication hash's component slot means for this scan type. + """ + method = str(issue.get("apiMethod") or "").strip() + url = str(issue.get("apiUrl") or "").strip() + if method and url: + return f"{method} {url}" + return url + + def describe(self, issue, component): + lines = [] + for label, value in (("Endpoint", component), + ("Description", issue.get("testDescription")), + ("Impact", issue.get("testImpact")), + ("Details", issue.get("testDetails"))): + text = str(value or "").strip() + if text: + lines.append(f"**{label}:** {text}") + return "\n".join(lines) + + def references(self, issue): + """Akto's own issue link, then the test's reference links.""" + links = [] + if url := str(issue.get("issueUrl") or "").strip(): + links.append(url) + for reference in issue.get("testReferences") or []: + text = str(reference or "").strip() + if text: + links.append(text) + return "\n".join(links) + + def tags(self, issue): + tags = [] + if category := str(issue.get("testCategory") or "").strip(): + tags.append(category) + for tag in issue.get("testTags") or []: + text = str(tag or "").strip() + if text: + tags.append(text) + return tags + + def cwe(self, raw): + """Read a CWE id off "CWE-89" or a bare number; anything else is no CWE.""" + trimmed = str(raw or "").strip().upper().removeprefix("CWE-") + with suppress(ValueError): + return int(trimmed) + return 0 + + def vulnerability_ids(self, issue): + """ + Identifiers in Akto's CVE field, sorted and deduplicated case-insensitively. + + That is the shared extractor's behaviour, and the field is free text - an API-security test + usually has no CVE at all, but a dependency-related one may name several. + """ + found = sorted(VULNERABILITY_ID_PATTERN.findall(str(issue.get("testCve") or ""))) + identifiers = [] + for candidate in found: + if not identifiers or identifiers[-1].lower() != candidate.lower(): + identifiers.append(candidate) + return identifiers + + def date(self, issue): + """Akto timestamps in unix seconds.""" + value = self.flex_int(issue.get("creationTime")) + if value <= 0: + return None + with suppress(OSError, OverflowError, ValueError): + return datetime.fromtimestamp(value, tz=UTC).date() + return None + + def attach_endpoint(self, finding, issue): + """ + Record the tested URL, but only when Akto reported an absolute one. + + Akto's apiUrl is often just a path - "/api/v1/users" - which is not an endpoint on its own, and + the connector deliberately skips those rather than inventing a host. The path is still the + component and appears in the description, so nothing is lost. + """ + url = str(issue.get("apiUrl") or "").strip() + if not url.startswith(("http://", "https://")): + return + with suppress(ValueError): + parsed = urlparse(url) + try: + port = parsed.port + except ValueError: + return + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/"), query=parsed.query, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/") or None, query=parsed.query or None, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else + makes Endpoint.clean() raise, and that fails the whole import rather than the one finding. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def flex_int(self, value): + """Akto sends its ids and timestamps as either numbers or numeric strings.""" + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(value.strip() or 0) + return 0 diff --git a/dojo/tools/automox/__init__.py b/dojo/tools/automox/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/automox/parser.py b/dojo/tools/automox/parser.py new file mode 100644 index 00000000000..456f2d4dbb1 --- /dev/null +++ b/dojo/tools/automox/parser.py @@ -0,0 +1,268 @@ +import json +from contextlib import suppress +from datetime import datetime + +from dojo.models import Finding + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +# Automox also reports "none", "unknown" and "no_known_cves"; all are Info. +DEFAULT_SEVERITY = "Info" + +# Automox timestamps, e.g. "2022-05-13T18:02:45+0000". %z reads every offset form the connector's +# two Go layouts accept - "+0000", "+02:00" and "Z" - and rejects a timestamp with no offset at all, +# which those layouts also reject. The second format is for a fractional-seconds variant. +DATE_FORMATS = ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z") + + +class AutomoxParser: + + """ + Parses an Automox awaiting-patch export. + + Mirrors pkg/tools/automox/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Automox reports a missing patch per device, and the device's own details come from a SECOND + endpoint - so an export carries both lists and the parser joins them on the package's server_id; + see devices_by_id(). A package whose device is absent still becomes a finding, exactly as it does + in the connector, where the device is a map lookup that can miss. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Automox Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Automox Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Automox export of awaiting (missing) patches - the packages list, optionally " + "with the devices list so each patch reports its device. Matches the scan type used by " + "the Automox connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Automox Parser. + + Mirrors the connector's Convert: + - title: "Missing patch: ", falling back to the package name, then the id. + - severity: Automox's own severity word; "none"/"unknown"/"no_known_cves" are all Info. + - description: the package and version, repository, device, OS, CVEs and install status. + - mitigation: install the available patch, naming the version when there is one. + - component_name / component_version: the package name and version. + - cvssv3_score: Automox's cve_score. + - date: the package's create_time. + - unique_id_from_tool: "automox-". + """ + return [ + "title", + "severity", + "description", + "mitigation", + "component_name", + "component_version", + "cvssv3_score", + "date", + "unique_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Automox Parser. + + Copied from the Automox block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The component is the package, so the + same missing patch on two devices hashes the same - the package id in the identity is what + keeps them apart. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + devices = self.devices_by_id(data) + findings = [] + for row in self.packages(data): + package_id = self.flex_int(row.get("id")) + if package_id <= 0: + # The id is the whole identity, and every row without one would collapse onto + # "automox-0". Automox's own decoder rejects the whole page when an id is not + # numeric; dropping the single row keeps the rest of the export importable. + continue + findings.append(self.build_finding(row, devices.get(self.flex_int(row.get("server_id"))), test)) + return findings + + def packages(self, data): + """ + Return the awaiting-patch packages in the export. + + Automox answers /servers//packages with a bare array, so that is the shape a saved export + has. An object naming its lists is accepted too, because the device details a finding needs + come from a different endpoint and both have to travel in one file. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("packages", "data", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "An Automox export is the awaiting-patch list - a JSON array of packages, or an object " + f"with a 'packages' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def devices_by_id(self, data): + """ + Index the devices in the export by id, so a package's server_id can name its device. + + Automox reports the device separately from the patch. When the export carries no devices the + device-derived description lines are simply absent - the same result the connector produces + when its own lookup misses. + """ + if not isinstance(data, dict): + return {} + for key in ("devices", "servers"): + rows = data.get(key) + if isinstance(rows, list): + return { + self.flex_int(row.get("id")): row + for row in rows + if isinstance(row, dict) and self.flex_int(row.get("id")) > 0 + } + return {} + + def build_finding(self, row, device, test): + version = str(row.get("version") or "") + + finding = Finding( + test=test, + title=self.title(row), + severity=self.severity(row), + description=self.describe(row, device), + mitigation=self.mitigation(version), + component_name=str(row.get("name") or "") or None, + component_version=version or None, + unique_id_from_tool=f"automox-{self.flex_int(row.get('id'))}", + # Automox reads an installed package inventory; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.cvssv3_score = self.score(row) + finding.unsaved_tags = self.tags(row, device) + + if cves := self.cves(row): + finding.unsaved_vulnerability_ids = cves + if date := self.date(row): + finding.date = date + return finding + + def title(self, row): + for key in ("display_name", "name"): + if value := str(row.get(key) or ""): + return f"Missing patch: {value}" + return f"Missing patch: Automox package {self.flex_int(row.get('id'))}" + + def severity(self, row): + label = str(row.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def describe(self, row, device): + """ + The package, where it came from, the device it is missing on, and its CVEs. + + The device lines are omitted when the export carries no matching device, which is what the + connector does when its own device lookup misses. + """ + lines = [] + + def write(label, value): + # The connector tests this value for emptiness WITHOUT trimming, so a value that is only + # whitespace is written out - which a plain truthiness test reproduces exactly, since "" + # is falsy and " " is not. Mirrored rather than tidied, so both paths render alike. + if value: + lines.append(f"**{label}:** {value}") + + write("Package", f"{row.get('display_name') or ''} {row.get('version') or ''}".strip()) + write("Repository", str(row.get("repo") or "")) + + if device is not None: + write("Device", str(device.get("name") or "")) + write("OS", f"{device.get('os_name') or ''} {device.get('os_version') or ''}".strip()) + + if cves := self.cves(row): + write("CVEs", ", ".join(cves)) + if not row.get("installed"): + write("Status", "Patch available but not installed") + return "\n".join(lines).strip() + + def mitigation(self, version): + if version: + return f"Install the available patch (version {version})." + return "Install the available patch." + + def cves(self, row): + """The package's CVE identifiers, in the order Automox lists them.""" + rows = row.get("cves") + if not isinstance(rows, list): + return [] + return [str(cve).strip() for cve in rows if str(cve or "").strip()] + + def tags(self, row, device): + """ + The device's OS family and whether the patch needs a reboot. + + The connector's own comment says the severity is tagged too; its code does not tag it. The + code is mirrored here rather than the comment - see the PR notes. + """ + tags = [] + if device is not None and (family := str(device.get("os_family") or "")): + tags.append(family) + if row.get("requires_reboot"): + tags.append("requires-reboot") + return tags + + def date(self, row): + value = str(row.get("create_time") or "").strip() + if not value: + return None + for fmt in DATE_FORMATS: + with suppress(ValueError): + return datetime.strptime(value, fmt).date() + return None + + def score(self, row): + """ + Automox's cve_score, which may arrive quoted. + + A value that is not a number becomes 0.0 rather than an error: the connector's own decoder + tolerates a non-numeric score, so an import must not fail on one. + """ + with suppress(TypeError, ValueError): + return float(str(row.get("cve_score") or 0).strip() or 0) + return 0.0 + + def flex_int(self, value): + """Automox may send an id as a JSON number or a quoted string.""" + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 diff --git a/dojo/tools/beagle/__init__.py b/dojo/tools/beagle/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/beagle/parser.py b/dojo/tools/beagle/parser.py new file mode 100644 index 00000000000..0dd9000f824 --- /dev/null +++ b/dojo/tools/beagle/parser.py @@ -0,0 +1,553 @@ +import hashlib +import json +import re +from contextlib import suppress +from datetime import UTC, datetime +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + "informational": "Info", + "information": "Info", +} +DEFAULT_SEVERITY = "Info" + +# Used only when a report carries no severity label - a tenant that scores with CVSS rather than OWASP +# sends a bare number instead. +CVSS_CRITICAL_FLOOR = 9.0 +CVSS_HIGH_FLOOR = 7.0 +CVSS_MEDIUM_FLOOR = 4.0 + +# "04 Sep 2021" - the format Beagle stamps a report with. +REPORT_DATE_FORMAT = "%d %b %Y" + +# The report-level keys Beagle's documentation does confirm. They are also the keys the finding-array +# search skips, so a confirmed field is never mistaken for the finding list. +REPORT_STRING_FIELDS = ( + "project_name", + "application_name", + "url", + "generated_date", + "approved_date", +) + +# Plausible names for the finding array, in preference order. Beagle's reference cuts the real name out +# of its only sample, so these are tried first and the search then falls back to "the first array of +# objects in the document". +VULNERABILITY_ARRAY_KEYS = ( + "vulnerabilities", + "signatures", + "vulnerability_list", + "issues", + "findings", + "results", +) + +# Beagle publishes none of the per-finding field names either, so each is read from a set of plausible +# aliases, case-insensitively. The most plausible spelling leads each list, and the first alias that +# yields a usable value wins. +NAME_ALIASES = ( + "name", "title", "vulnerability_name", "vulnerabilityname", "signature", "signature_name", + "vulnerability", +) +SEVERITY_ALIASES = ( + "severity", "risk", "risk_level", "risklevel", "severity_level", "severitylevel", "priority", +) +SCORE_ALIASES = ("cvss_score", "cvssscore", "score", "cvss", "risk_score", "riskscore") +VECTOR_ALIASES = ("cvss_vector", "cvssvector", "vector", "cvss") +CWE_ALIASES = ("cwe", "cwe_id", "cweid", "cwes") +DESCRIPTION_ALIASES = ( + "description", "details", "detail", "summary", "impact", "vulnerability_description", +) +REMEDIATION_ALIASES = ( + "remediation", "solution", "recommendation", "recommendations", "fix", "mitigation", +) +# Leads with the vendor's own one-"r" spelling, verbatim from their reference. +OCCURRENCE_ALIASES = ("occurences", "occurrences", "instances") + +# Beagle's own spelling for the per-application id, from their projects response. An export that +# carries it gets connector-identical unique ids; see application_token(). +APPLICATION_TOKEN_KEYS = ("applicationToken", "application_token", "applicationtoken") + +STATUS_FIXED = "fixed" + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class BeagleParser: + + """ + Parses a Beagle Security test report. + + Mirrors pkg/tools/beagle/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + Beagle does not publish the schema of the report body. The report-level keys and the occurrence + block are documented; the per-finding field names are not, so - exactly like the connector - every + finding field is read from a set of plausible aliases and the finding array is located by name or, + failing that, by shape. See extract() and the *_ALIASES tables. + + Note the deduplication configuration for this scan type hashes the ENDPOINTS, so the tested URL has + to be populated or the hash is computed over nothing. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Beagle Security - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Beagle Security - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Beagle Security test report (JSON). Matches the scan type used by the Beagle " + "Security connector so file and API findings deduplicate. Both the report body and the " + "API envelope that carries it are accepted." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Beagle Security Parser. + + Mirrors the connector's finding(): + - title: the finding name, else "Beagle Security finding ()", else a bare label. + - severity: the severity label, else graded from the CVSS score; see severity(). + - description: the finding prose, then the method, URL, Beagle status and CWE. + - mitigation: the remediation text. + - cvssv3 / cvssv3_score: the CVSS vector and score when the tenant scores with CVSS. + - active / is_mitigated: an occurrence Beagle calls "Fixed" is imported as mitigated. + - unique_id_from_tool: see unique_id() - present only when the export carries the token. + - vuln_id_from_tool: the finding name, which is Beagle's signature identifier. + - param: the HTTP method the occurrence was found with. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "cwe", + "cvssv3", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "param", + "tags", + "active", + "is_mitigated", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Beagle Security Parser. + + Copied from the Beagle Security block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Note endpoints is among them, so the + tested URL must be populated for the hash to mean anything - and it is the hash, not the unique + id, that bridges a file import to an API sync when the export carries no application token. + """ + return ["title", "severity", "endpoints"] + + def get_findings(self, filename, test): + data = json.load(filename) + report = self.report(data) + vulnerabilities = self.extract(report) + token = self.application_token(data, report) + # The connector stamps every finding in a report with the report's own date. + date = self.report_date(report) + target = (report.get("url") or "").strip() + + findings = [] + for item in vulnerabilities: + if isinstance(item, dict): + findings.extend(self.vulnerability_findings(item, target, token, date, test)) + return findings + + def report(self, data): + """ + Return the report body. + + Beagle returns the report as a JSON *string* inside an envelope - {"result": "{...}"} - so an + export is either the envelope or the report body it carries. + """ + if isinstance(data, dict): + result = data.get("result") + if isinstance(result, str) and result.strip(): + with suppress(ValueError): + decoded = json.loads(result) + if isinstance(decoded, dict): + return decoded + if isinstance(result, dict): + return result + return data + + msg = ( + "A Beagle Security export is a test report, a JSON object carrying the report keys and a " + f"list of findings; got {type(data).__name__}." + ) + raise TypeError(msg) + + def extract(self, report): + """ + Return the report's finding list. + + Beagle's reference cuts the finding array's name out of its only sample, so it is looked up by + the plausible names first and then by shape: the first key, in sorted order, whose value is an + array of objects. Sorting keeps the choice deterministic rather than dict-order dependent. + """ + for candidate in VULNERABILITY_ARRAY_KEYS: + if self.is_array_of_objects(report.get(candidate)): + return report[candidate] + + for key in sorted(report): + if key in REPORT_STRING_FIELDS: + continue + if self.is_array_of_objects(report.get(key)): + return report[key] + + if not any(key in report for key in REPORT_STRING_FIELDS): + msg = ( + "This file carries neither Beagle Security's report keys (project_name, " + "application_name, url, generated_date, approved_date) nor a list of findings." + ) + raise TypeError(msg) + # A report Beagle produced with nothing to report. The connector logs the keys it saw and + # returns no findings rather than treating it as an error. + return [] + + def is_array_of_objects(self, value): + """Whether value is a JSON array whose first element is an object.""" + if not isinstance(value, list): + return False + for item in value: + return isinstance(item, dict) + return False + + def application_token(self, data, report): + """ + Return the application token, if the export carries one. + + The connector always has it - it is the parameter every Beagle API call takes - and hashes it + into the unique id. A report body does not carry it, so an export usually has none, and then no + unique id is set at all: a token-less hash would collide with nothing the connector produced, + whereas leaving it unset lets this scan type's other deduplication key, the hash over title, + severity and endpoints, match the API findings instead. + """ + for source in (report, data): + if not isinstance(source, dict): + continue + for key in APPLICATION_TOKEN_KEYS: + value = source.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + def report_date(self, report): + """The report's generated date, then its approved date, then today.""" + for key in ("generated_date", "approved_date"): + value = report.get(key) + if isinstance(value, str): + with suppress(ValueError): + return datetime.strptime(value.strip(), REPORT_DATE_FORMAT).replace(tzinfo=UTC).date() + return datetime.now(tz=UTC).date() + + def vulnerability_findings(self, item, target, token, date, test): + """ + One finding per occurrence. + + An occurrence is one place the finding was observed - a method and a URL - so a finding + reported on three URLs is three findings. A finding with no occurrences still gets one, aimed + at the application's own URL. + """ + fields = {str(key).strip().lower(): value for key, value in item.items()} + vulnerability = self.vulnerability(fields, item) + + occurrences = self.occurrences(fields) + if not occurrences: + return [self.build_finding(vulnerability, None, target, token, date, test)] + return [ + self.build_finding(vulnerability, occurrence, target, token, date, test) + for occurrence in occurrences + ] + + def vulnerability(self, fields, item): + """Resolve one finding's fields through the alias tables.""" + label, score = self.severity_fields(fields) + if better := self.first_number(fields, SCORE_ALIASES): + score = better + return { + "name": self.first_string(fields, NAME_ALIASES), + "severity_label": label, + "score": score, + "vector": self.first_vector(fields), + "cwe": self.first_scalar(fields, CWE_ALIASES), + "description": self.first_string(fields, DESCRIPTION_ALIASES), + "remediation": self.first_string(fields, REMEDIATION_ALIASES), + "keys": sorted(str(key) for key in item), + } + + def occurrences(self, fields): + for alias in OCCURRENCE_ALIASES: + value = fields.get(alias) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + def build_finding(self, vulnerability, occurrence, target, token, date, test): + method, endpoint = self.occurrence_target(occurrence, target) + fixed = self.is_fixed(occurrence) + + finding = Finding( + test=test, + title=self.title(vulnerability), + severity=self.severity(vulnerability), + date=date, + description=self.describe(vulnerability, occurrence), + mitigation=vulnerability["remediation"], + active=occurrence is None or not fixed, + is_mitigated=occurrence is not None and fixed, + # Beagle Security drives a browser against a running application. + static_finding=False, + dynamic_finding=True, + vuln_id_from_tool=vulnerability["name"] or None, + param=method or None, + ) + finding.unsaved_tags = self.tags(vulnerability) + + if vector := vulnerability["vector"]: + finding.cvssv3 = vector + if vulnerability["score"] > 0: + finding.cvssv3_score = vulnerability["score"] + if (cwe := self.cwe_number(vulnerability["cwe"])) is not None: + finding.cwe = cwe + if unique_id := self.unique_id(token, vulnerability["name"], method, endpoint): + finding.unique_id_from_tool = unique_id + + self.attach_endpoint(finding, endpoint) + return finding + + def occurrence_target(self, occurrence, target): + """ + The method and URL an occurrence was found at. + + A finding with no occurrence is aimed at the application's own URL, and an occurrence missing + its URL falls back to the same. + """ + if occurrence is None: + return "", target + + nested = occurrence.get("vulnerability") + nested = nested if isinstance(nested, dict) else {} + # Beagle really does capitalise these two, verbatim from their documentation. + method = str(nested.get("Method") or "").strip().upper() + endpoint = str(nested.get("Url") or "").strip() + return method, endpoint or target + + def is_fixed(self, occurrence): + """ + Whether Beagle considers this occurrence remediated. + + "Fixed" is the one status value their documentation shows; the rest of the enum is unpublished, + so anything else counts as open. + """ + if occurrence is None: + return False + return str(occurrence.get("status") or "").strip().lower() == STATUS_FIXED + + def title(self, vulnerability): + if name := vulnerability["name"]: + return name + if cwe := vulnerability["cwe"]: + return f"Beagle Security finding ({cwe})" + return "Beagle Security finding" + + def severity(self, vulnerability): + """ + Grade the finding. + + The severity label wins when there is one, and an unrecognised label is Info rather than a + guess. A tenant that scores reports with CVSS instead sends a bare number, which is graded + against the CVSS floors. Neither means Info. + """ + if label := vulnerability["severity_label"]: + return SEVERITY_BY_LABEL.get(label.lower(), DEFAULT_SEVERITY) + + score = vulnerability["score"] + if score > 0: + if score >= CVSS_CRITICAL_FLOOR: + return "Critical" + if score >= CVSS_HIGH_FLOOR: + return "High" + if score >= CVSS_MEDIUM_FLOOR: + return "Medium" + return "Low" + return DEFAULT_SEVERITY + + def describe(self, vulnerability, occurrence): + sections = [] + if description := vulnerability["description"]: + sections.append(description) + + details = [] + if occurrence is not None: + nested = occurrence.get("vulnerability") + nested = nested if isinstance(nested, dict) else {} + if method := str(nested.get("Method") or "").strip().upper(): + details.append(f"**Method:** {method}") + if url := str(nested.get("Url") or "").strip(): + details.append(f"**URL:** {url}") + if status := str(occurrence.get("status") or "").strip(): + details.append(f"**Beagle status:** {status}") + if cwe := vulnerability["cwe"]: + details.append(f"**CWE:** {cwe}") + + if details: + sections.append("\n".join(details)) + return "\n\n".join(sections) + + def tags(self, vulnerability): + tags = ["beagle-security"] + if label := vulnerability["severity_label"]: + tags.append(label) + return tags + + def unique_id(self, token, name, method, endpoint): + """ + The connector's identity: sha256 of the application token, name, method and URL. + + Computed only when the export carries the token - see application_token() - because an id + hashed over a token the connector never used would deduplicate against nothing. + """ + if not token: + return "" + digest = hashlib.sha256(f"{token}|{name}|{method}|{endpoint}".encode()) + return digest.hexdigest() + + def cwe_number(self, cwe): + """Read a CWE id off "CWE-215", "215", or the first entry of "215, 216".""" + trimmed = cwe.strip() + for prefix in ("CWE-", "cwe-"): + trimmed = trimmed.removeprefix(prefix) + for separator in (",", " "): + index = trimmed.find(separator) + if index > 0: + trimmed = trimmed[:index] + with suppress(ValueError): + number = int(trimmed) + if number > 0: + return number + return None + + def attach_endpoint(self, finding, url): + """ + Record the URL the finding was reported against. + + This scan type's deduplication hashes the endpoints, so an unpopulated endpoint would leave + the hash computed over nothing and every rescan would reimport. + """ + if not url: + return + with suppress(ValueError): + parsed = urlparse(url) + if not parsed.hostname or not self.usable_host(parsed.hostname): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=parsed.hostname, protocol=parsed.scheme or None, port=parsed.port, + path=parsed.path.lstrip("/"), query=parsed.query, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=parsed.hostname, protocol=parsed.scheme or None, port=parsed.port, + path=parsed.path.lstrip("/") or None, query=parsed.query or None, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def first_string(self, fields, aliases): + """The first alias holding a non-empty string.""" + for alias in aliases: + value = fields.get(alias) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + def first_number(self, fields, aliases): + """The first alias holding a number, accepting a numeric string too.""" + for alias in aliases: + value = fields.get(alias) + if isinstance(value, bool): + continue + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip()) + return 0.0 + + def first_scalar(self, fields, aliases): + """ + The first alias holding a string, a number, or a list of them, rendered as a string. + + A CWE id in particular arrives any of those ways. + """ + if value := self.first_string(fields, aliases): + return value + for alias in aliases: + value = fields.get(alias) + if isinstance(value, bool): + continue + if isinstance(value, int | float): + return str(value) + if isinstance(value, list): + rendered = [str(item).strip() for item in value if str(item).strip()] + if rendered: + return ", ".join(rendered) + return "" + + def severity_fields(self, fields): + """ + The severity, which Beagle renders either as a label or - when the tenant scores reports with + CVSS rather than OWASP - as a number under the same key. + """ + if label := self.first_string(fields, SEVERITY_ALIASES): + return label, 0.0 + return "", self.first_number(fields, SEVERITY_ALIASES) + + def first_vector(self, fields): + """A CVSS vector, which is distinguishable from a score by its "CVSS:" prefix.""" + for alias in VECTOR_ALIASES: + value = fields.get(alias) + if isinstance(value, str) and value.strip().upper().startswith("CVSS:"): + return value.strip() + return "" diff --git a/dojo/tools/bigid/__init__.py b/dojo/tools/bigid/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/bigid/parser.py b/dojo/tools/bigid/parser.py new file mode 100644 index 00000000000..93bd81c4d71 --- /dev/null +++ b/dojo/tools/bigid/parser.py @@ -0,0 +1,226 @@ +import json +from contextlib import suppress +from datetime import date as _date + +from dojo.models import Finding + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +# BigID has no Info tier of its own, so an unknown or missing level lands here. +DEFAULT_SEVERITY = "Info" + +# Statuses that mean BigID considers the case dealt with. +CLOSED_STATUSES = {"resolved", "remediated", "closed"} + +# BigID's case fields are camelCase, but the two timestamps are snake_case. +DATE_KEYS = ("updated_at", "created_at") + + +class BigidParser: + + """ + Parses a BigID DSPM case export. + + Mirrors pkg/tools/bigid/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + PRIVACY: a BigID case is about sensitive data that was found, so only the case identity, its + policy and data-source context and the COUNT of affected objects are read. No sample, preview or + value of the data itself is read out of the export or written into a finding, even if the file + contains one - see describe(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["BigID Scan"] + + def get_label_for_scan_types(self, scan_type): + return "BigID Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a BigID DSPM case export (JSON). Only the policy and data-source context and the " + "count of affected objects are read - never a sample of the data itself. Matches the scan " + "type used by the BigID connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the BigID Parser. + + Mirrors the connector's Convert: + - title: the case label, then the policy name, then the case id. + - severity: BigID's severityLevel; anything unrecognised is Info. + - description: the policy, data source, sensitivity, affected-object COUNT, status, assignee. + - mitigation: BigID's own remediation steps. + - component_name: the data source the case is about. + - active / is_mitigated: from the case status; see is_open(). + - unique_id_from_tool: "bigid-". + """ + return [ + "title", + "severity", + "description", + "mitigation", + "component_name", + "date", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "is_mitigated", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the BigID Parser. + + Copied from the BigID block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The data source is the component, so + the same policy failing on two data sources stays two findings. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for row in self.cases(data): + case_id = str(row.get("caseId") or "").strip() + if not case_id: + # The case id is the whole identity; without one every row would collapse onto + # "bigid-". + continue + findings.append(self.build_finding(row, case_id, test)) + return findings + + def cases(self, data): + """ + Return the cases in the export. + + BigID's own samples disagree about the shape, so its client accepts all three: a bare array, + a {"data": {"cases": [...]}} object, and a top-level {"cases": [...]}. The wrapped form wins + when it carries anything, which is the precedence the client applies. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + + if isinstance(data, dict): + wrapped = data.get("data") + if isinstance(wrapped, dict) and ( + isinstance(wrapped.get("cases"), list) or self.flex_int(wrapped.get("totalCount")) > 0 + ): + return [row for row in wrapped.get("cases") or [] if isinstance(row, dict)] + if isinstance(data.get("cases"), list): + return [row for row in data["cases"] if isinstance(row, dict)] + + msg = ( + "A BigID export is the cases response - a JSON array of cases, or an object with a " + f"'cases' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, case_id, test): + open_case = self.is_open(row) + + finding = Finding( + test=test, + title=self.title(row, case_id), + severity=self.severity(row), + description=self.describe(row), + mitigation=str(row.get("remediationSteps") or "") or None, + component_name=str(row.get("dataSourceName") or "") or None, + unique_id_from_tool=f"bigid-{case_id}", + vuln_id_from_tool=case_id, + # BigID inspects data at rest; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=open_case, + is_mitigated=not open_case, + ) + finding.unsaved_tags = self.tags(row) + + if date := self.date(row): + finding.date = date + return finding + + def title(self, row, case_id): + for key in ("caseLabel", "policyName"): + if value := str(row.get(key) or "").strip(): + return value + return f"BigID case {case_id}" + + def severity(self, row): + label = str(row.get("severityLevel") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def describe(self, row): + """ + The policy that failed, the data source it failed on, and how much is affected. + + Only the COUNT of affected objects is reported. A BigID case is about sensitive data, so no + sample or preview of it is read out of the export, whatever the file happens to carry. + """ + lines = [] + fields = ( + ("Policy", "policyName"), + ("Policy description", "policyDescription"), + ("Data source", "dataSourceName"), + ("Data source type", "dataSourceType"), + ("Sensitivity", "sensitivityGroup"), + ) + for label, key in fields: + if value := str(row.get(key) or "").strip(): + lines.append(f"**{label}:** {value}") + + if (count := self.flex_int(row.get("numberOfAffectedObjects"))) > 0: + lines.append(f"**Affected objects:** {count}") + + for label, key in (("Status", "caseStatus"), ("Assignee", "assignee")): + if value := str(row.get(key) or "").strip(): + lines.append(f"**{label}:** {value}") + return "\n".join(lines).strip() + + def is_open(self, row): + """A case stays active until BigID reports it resolved, remediated or closed.""" + return str(row.get("caseStatus") or "").strip().lower() not in CLOSED_STATUSES + + def tags(self, row): + """The data-source type and sensitivity classification, for filtering.""" + return [ + value + for value in (str(row.get(key) or "").strip() for key in ("dataSourceType", "sensitivityGroup")) + if value + ] + + def date(self, row): + """ + The date part of the update timestamp, falling back to creation. + + The connector takes the first ten characters rather than parsing, because it hands the API a + string. This has to produce a real date for the model field, so the same ten characters are + read as an ISO date; one that is not a date is skipped rather than failing the import. + """ + for key in DATE_KEYS: + value = str(row.get(key) or "").strip() + if len(value) >= 10: + with suppress(ValueError): + return _date.fromisoformat(value[:10]) + return None + + def flex_int(self, value): + """BigID may send a count as a JSON number or a quoted string.""" + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 diff --git a/dojo/tools/bright/__init__.py b/dojo/tools/bright/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/bright/parser.py b/dojo/tools/bright/parser.py new file mode 100644 index 00000000000..e2ab5ee3622 --- /dev/null +++ b/dojo/tools/bright/parser.py @@ -0,0 +1,252 @@ +import json +import re +from contextlib import suppress +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class BrightParser: + + """ + Parses a Bright Security scan export. + + Mirrors pkg/tools/bright/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + Bright is a DAST scanner: every issue is an entry point it attacked, which is why this scan type's + deduplication hash includes the endpoints and the parser always records one. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. + return ["Bright - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Bright - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Bright Security scan export (JSON), the issues of one scan. Matches the scan " + "type used by the Bright connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Bright Parser. + + Mirrors the connector's IssueToFinding: + - title: the issue name, else "Bright issue ". + - severity: Bright's own label; anything unrecognised is Info. + - description: the details, the entry point, protocol and CWE, then the request and response + that proved it. + - mitigation: Bright's remediation text, when it has one. + - references: the affected resources, one per line. + - cvssv3_score / cwe: when Bright reported them. + - unique_id_from_tool: the issue id. + """ + return [ + "title", + "severity", + "description", + "mitigation", + "references", + "cvssv3_score", + "cwe", + "unique_id_from_tool", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Bright Parser. + + Copied from the Bright block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Note endpoints is among them, so the + entry point must be populated for the hash to mean anything. + """ + return ["title", "severity", "endpoints"] + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(issue, test) for issue in self.issues(data)] + + def issues(self, data): + """ + Return the issues in the export. + + Bright's issues endpoint answers with a bare array, so an export is either that array or an + object carrying it - a scan with its issues nested. + """ + if isinstance(data, list): + return [issue for issue in data if isinstance(issue, dict)] + if isinstance(data, dict): + for key in ("issues", "items"): + if isinstance(data.get(key), list): + return [issue for issue in data[key] if isinstance(issue, dict)] + for key in ("scan", "data"): + nested = data.get(key) + if isinstance(nested, dict) and isinstance(nested.get("issues"), list): + return [issue for issue in nested["issues"] if isinstance(issue, dict)] + + msg = ( + "A Bright export is a scan's issues, a JSON array or an object with an 'issues' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, issue, test): + identifier = str(issue.get("id") or "") + + finding = Finding( + test=test, + title=self.title(issue, identifier), + severity=self.severity(issue), + description=self.describe(issue), + references="\n".join(self.resources(issue)), + unique_id_from_tool=identifier or None, + # Bright attacks a running application. + active=True, + static_finding=False, + dynamic_finding=True, + ) + + if remediation := str(issue.get("remediation") or ""): + finding.mitigation = remediation + if (score := self.score(issue)) > 0: + finding.cvssv3_score = score + if (cwe := self.cwe(issue.get("cwe"))) > 0: + finding.cwe = cwe + + self.attach_endpoints(finding, issue) + return finding + + def title(self, issue, identifier): + if name := str(issue.get("name") or ""): + return name + return f"Bright issue {identifier}" + + def severity(self, issue): + """Bright grades Critical/High/Medium/Low; anything else is Info rather than a guess.""" + label = str(issue.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def describe(self, issue): + """ + The details, the request context, then the exchange that proved the issue. + + The request and response go in fenced code blocks: they are raw HTTP captured from the target, + so they must not be read as markup, and a reviewer needs them verbatim to reproduce. + """ + parts = [] + if details := str(issue.get("details") or ""): + parts.append(details + "\n\n") + + for label, key in (("Entry Point", "entryPoint"), ("Protocol", "protocol"), ("CWE", "cwe")): + value = str(issue.get(key) or "") + if value: + parts.append(f"**{label}:** {value}\n") + + for label, key in (("Request", "request"), ("Response", "response")): + value = str(issue.get(key) or "") + if value: + parts.append(f"\n**{label}:**\n```\n{value}\n```\n") + return "".join(parts).rstrip("\n") + + def resources(self, issue): + rows = issue.get("resources") + if not isinstance(rows, list): + return [] + return [str(row).strip() for row in rows if str(row or "").strip()] + + def score(self, issue): + """Bright sends the CVSS score as either a number or a numeric string.""" + value = issue.get("cvss") + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 + + def cwe(self, raw): + """Read a CWE id off "CWE-89" or a bare number; anything else is no CWE.""" + trimmed = str(raw or "").strip().upper().removeprefix("CWE-") + if not trimmed: + return 0 + with suppress(ValueError): + return int(trimmed) + return 0 + + def attach_endpoints(self, finding, issue): + """ + Record the entry point, falling back to every affected resource. + + This scan type's deduplication hashes the endpoints, so an unpopulated endpoint would leave the + hash computed over nothing and every rescan would reimport. The fallback is a list because + Bright reports one issue against several resources when the same weakness is reachable from + more than one URL. + """ + entry_point = str(issue.get("entryPoint") or "").strip() + targets = [entry_point] if entry_point else self.resources(issue) + for target in targets: + self.attach_endpoint(finding, target) + + def attach_endpoint(self, finding, url): + if not url: + return + with suppress(ValueError): + parsed = urlparse(url if "//" in url else f"//{url}") + try: + port = parsed.port + except ValueError: + return + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/"), query=parsed.query, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/") or None, query=parsed.query or None, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else + makes Endpoint.clean() raise, and that fails the whole import rather than the one finding. The + entry point is still in the description. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False diff --git a/dojo/tools/calicocloud/__init__.py b/dojo/tools/calicocloud/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/calicocloud/parser.py b/dojo/tools/calicocloud/parser.py new file mode 100644 index 00000000000..66c1757d35c --- /dev/null +++ b/dojo/tools/calicocloud/parser.py @@ -0,0 +1,310 @@ +import json +from contextlib import suppress +from datetime import date as _date + +from dojo.models import Finding + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +# Calico also reports "negligible" and "unknown"; both are Info. +DEFAULT_SEVERITY = "Info" + +# Calico reports this while a registry scan is still being processed. +SCAN_RESULT_UNKNOWN = "unknown" + + +class CalicocloudParser: + + """ + Parses a Calico Cloud Image Assurance export. + + Mirrors pkg/tools/calicocloud/connector/finding_converter field for field so a file import and an + API sync deduplicate against each other instead of producing two copies of everything. + + Calico serves the image list and each image's vulnerabilities from two endpoints, so an export + carries both - the vulnerabilities either nested in their image or in a map keyed by image id; + see vulnerabilities_for(). An image whose scan result is still "Unknown" contributes nothing, the + same as in the connector: its results are not finished, so importing them would record a partial + scan as a complete one. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Calico Cloud Image Assurance Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Calico Cloud Image Assurance Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Calico Cloud Image Assurance export (JSON) - the scanned images with their " + "vulnerabilities. Matches the scan type used by the Calico Cloud connector so file and " + "API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Calico Cloud Parser. + + Mirrors the connector's Convert: + - title: ": ", or whichever of the two is present. + - severity: the CVSS v3 base score, falling back to Calico's severity word; see severity(). + - description: the advisory text, the image and digest, the package, and the fix. + - mitigation: upgrade the package to the fixed version, when Calico names one. + - cvssv3_score / component_name / component_version / references: straight across. + - unsaved_vulnerability_ids: the id, but only when it is a CVE. + - unique_id_from_tool: "calico-cloud--". + """ + return [ + "title", + "severity", + "description", + "mitigation", + "cvssv3_score", + "component_name", + "component_version", + "references", + "date", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Calico Cloud Parser. + + Copied from the Calico Cloud block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name", "component_version"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for image in self.images(data): + if not self.results_ready(image): + # Calico says "Unknown" while a registry scan is still being processed. The connector + # skips the image entirely rather than import a scan that has not finished. + continue + findings.extend( + self.build_finding(vuln, image, test) + for vuln in self.vulnerabilities_for(image, data) + ) + return findings + + def images(self, data): + """ + Return the scanned images in the export. + + A bare array of images is accepted, as is an object naming the list. A file that is only a + vulnerability list is accepted too, as a single image with no image context at all - the + identity then carries an empty image id, exactly as the connector's would. + """ + if isinstance(data, list): + rows = [row for row in data if isinstance(row, dict)] + if rows and self.looks_like_vulnerability(rows[0]): + return [{"vulnerabilities": rows}] + return rows + + if isinstance(data, dict): + for key in ("images", "data", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + if isinstance(data.get("vulnerabilities"), list): + return [data] + + msg = ( + "A Calico Cloud export is the scanned images with their vulnerabilities - a JSON array of " + f"images, or an object with an 'images' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def looks_like_vulnerability(self, row): + """A vulnerability names a package or a severity; an image names a repository or an image id.""" + vulnerability_keys = ("package", "package_name", "fixVersions", "cvss3Score") + image_keys = ("imageID", "repository", "registry", "digest") + return any(key in row for key in vulnerability_keys) and not any(key in row for key in image_keys) + + def vulnerabilities_for(self, image, data): + """ + The image's vulnerabilities, nested in the image or in a map keyed by image id. + + Calico serves them from a per-image endpoint, so both shapes are what a saved export of the + two calls looks like. + """ + rows = image.get("vulnerabilities") + if isinstance(rows, list): + return [row for row in rows if isinstance(row, dict)] + + if isinstance(data, dict): + keyed = data.get("vulnerabilities") + if isinstance(keyed, dict): + rows = keyed.get(str(image.get("imageID") or "")) + if isinstance(rows, list): + return [row for row in rows if isinstance(row, dict)] + return [] + + def results_ready(self, image): + """ + Whether Calico has finished processing the image's scan. + + "Unknown" in either status field means it has not. Matched case-insensitively, as the + connector does. + """ + for key in ("scan_result", "result"): + if str(image.get(key) or "").strip().lower() == SCAN_RESULT_UNKNOWN: + return False + return True + + def build_finding(self, vuln, image, test): + identifier = str(vuln.get("id") or "") + package = self.package(vuln) + fix = self.fix(vuln) + + finding = Finding( + test=test, + title=self.title(vuln, identifier), + severity=self.severity(vuln), + description=self.describe(vuln, image, package, fix), + mitigation=f"Upgrade {package} to {fix}." if fix else None, + component_name=package or None, + component_version=str(vuln.get("version") or "") or None, + references=str(vuln.get("url") or "") or None, + unique_id_from_tool=f"calico-cloud-{image.get('imageID') or ''}-{identifier}", + vuln_id_from_tool=identifier or None, + # Calico scans image contents; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.cvssv3_score = self.score(vuln) + + if identifier.strip().upper().startswith("CVE-"): + # Only a CVE goes in as a vulnerability id; Calico also issues its own advisory ids. + finding.unsaved_vulnerability_ids = [identifier] + if date := self.date(image): + finding.date = date + return finding + + def title(self, vuln, identifier): + name = str(vuln.get("name") or "").strip() + if identifier and name and name != identifier: + return f"{identifier}: {name}" + if identifier: + return identifier + if name: + return name + return "Calico Cloud image vulnerability" + + def severity(self, vuln): + """ + The CVSS v3 base score decides; Calico's severity word is the fallback. + + Calico's own Pass/Warn/Fail verdict is deliberately ignored - those thresholds are per-tenant + configuration, not a severity, so importing them would make the same CVE a different severity + in two tenants. + """ + score = self.score(vuln) + if score > 0: + if score >= 9.0: + return "Critical" + if score >= 7.0: + return "High" + if score >= 4.0: + return "Medium" + return "Low" + label = str(vuln.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def score(self, vuln): + """The dedicated cvss3Score field, then the nested cvss.base_score. Either may be quoted.""" + for value in (vuln.get("cvss3Score"), self.nested_score(vuln)): + if (score := self.flex_float(value)) > 0: + return score + return 0.0 + + def nested_score(self, vuln): + cvss = vuln.get("cvss") + return cvss.get("base_score") if isinstance(cvss, dict) else None + + def package(self, vuln): + """Calico sends the package name under either key; the explicit one wins.""" + if value := str(vuln.get("package_name") or "").strip(): + return value + return str(vuln.get("package") or "") + + def fix(self, vuln): + """The fixed versions, joined, or the single fix string.""" + rows = vuln.get("fixVersions") + if isinstance(rows, list) and rows: + return ", ".join(str(row) for row in rows) + return str(vuln.get("fix") or "") + + def describe(self, vuln, image, package, fix): + lines = [] + + def write(label, value): + if str(value or "").strip(): + lines.append(f"**{label}:** {value}") + + write("Description", str(vuln.get("description") or "")) + write("Image", self.image_reference(image)) + write("Digest", str(image.get("digest") or "")) + write("Package", package) + write("Installed version", str(vuln.get("version") or "")) + write("Fixed in", fix) + return "\n".join(lines).strip() + + def image_reference(self, image): + """ + "/:", falling back to the digest and then the image id. + + The registry is only prefixed when there is a repository to prefix, matching the connector. + """ + name = str(image.get("repository") or "").strip() + registry = str(image.get("registry") or "").strip() + if registry and name: + name = f"{registry.strip('/')}/{name}" + + tag = str(image.get("tag") or "") + if name and tag: + return f"{name}:{tag}" + if name: + return name + if digest := str(image.get("digest") or ""): + return digest + return str(image.get("imageID") or "") + + def date(self, image): + """ + The image's scan timestamp - the result time, then when it was scanned. + + The connector takes the first ten characters because it hands the API a string; this reads + the same ten as an ISO date, and skips one that is not a date rather than failing the import. + """ + for key in ("resultTime", "scanned"): + value = str(image.get(key) or "").strip() + if len(value) >= 10: + with suppress(ValueError): + return _date.fromisoformat(value[:10]) + return None + + def flex_float(self, value): + """Calico's numbers may arrive as JSON numbers or quoted strings.""" + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 diff --git a/dojo/tools/codacy/__init__.py b/dojo/tools/codacy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/codacy/parser.py b/dojo/tools/codacy/parser.py new file mode 100644 index 00000000000..de6d3a407a4 --- /dev/null +++ b/dojo/tools/codacy/parser.py @@ -0,0 +1,354 @@ +import json +import re +from contextlib import suppress +from datetime import UTC, datetime +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Codacy grades security items by priority; the connector logs and falls back to Info for anything +# else. +SEVERITY_BY_PRIORITY = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Codacy's scanType tells us whether the item came from a running target. Only DAST did. +SCAN_TYPE_DAST = "DAST" + +# Codacy's "cve" is a typed string documented as possibly holding several identifiers, so it is +# scanned rather than read. +CVE_PATTERN = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE) +CWE_PATTERN = re.compile(r"CWE-(\d+)", re.IGNORECASE) + +# An item Codacy has ignored for this reason is a false positive rather than a closed finding. The +# comparison strips spaces, so "False Positive" and "falsepositive" both match. +FALSE_POSITIVE_REASON = "falsepositive" + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class CodacyParser: + + """ + Parses a Codacy security-items export. + + Mirrors pkg/tools/codacy/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + Codacy reports several scan types through one endpoint - SCA, container and DAST among them - and + the connector flags a finding static or dynamic from that field rather than assuming. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Codacy - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Codacy - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Codacy security-items export (JSON). Matches the scan type used by the Codacy " + "connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Codacy Parser. + + Mirrors the connector's ItemToFinding: + - title: Codacy's own item title, falling back to "Codacy - item". + - severity: the item priority, anything unrecognised Info. + - description: the summary and additional info, then scan type, category, detector, + repository, container image, likelihood, effort to fix and any dependency paths. + - mitigation: Codacy's remediation advice, plus the versions the issue is fixed in. + - references: the Codacy item link. + - false_p: true when Codacy has ignored the item as a false positive. + - static_finding / dynamic_finding: a DAST item is dynamic, everything else static. + - component_name: the last entry of the first dependency chain, i.e. the vulnerable package. + - unique_id_from_tool: Codacy's internal item id. + - vuln_id_from_tool: the first CVE found in the item, else Codacy's own source id. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "references", + "cvssv3", + "cvssv3_score", + "cwe", + "component_name", + "component_version", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "false_p", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Codacy Parser. + + Copied from the Codacy block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Diverging would stop file findings + merging with API-synced ones. + """ + return ["title", "severity", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + items = self.extract_items(data) + + findings = {} + for item in items: + if not isinstance(item, dict): + continue + finding = self.build_finding(item, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_items(self, data): + """Codacy's search endpoints wrap the items under "data".""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("data", "items"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "A Codacy export is a JSON object with a 'data' list of security items, or a bare " + f"array of items; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, item, test): + dynamic = (item.get("scanType") or "").strip().upper() == SCAN_TYPE_DAST + + finding = Finding( + test=test, + title=self.title(item), + severity=SEVERITY_BY_PRIORITY.get( + (item.get("priority") or "").strip().lower(), DEFAULT_SEVERITY, + ), + date=self.date(item.get("openedAt")), + description=self.describe(item), + mitigation=self.mitigation(item) or None, + references=item.get("htmlUrl") or None, + cvssv3=item.get("cvssVector") or None, + cvssv3_score=item.get("cvssScore") or None, + component_name=self.vulnerable_package(item) or None, + component_version=item.get("affectedVersion") or None, + unique_id_from_tool=item.get("id"), + vuln_id_from_tool=self.vuln_id_from_tool(item) or None, + active=True, + false_p=self.is_false_positive(item), + # Only a DAST item looked at something running. + static_finding=not dynamic, + dynamic_finding=dynamic, + ) + finding.unsaved_tags = self.tags(item) + + cves = self.cves(item.get("cve")) + if cves: + finding.unsaved_vulnerability_ids = cves + cwe = self.cwe_number(item.get("cwe")) + if cwe: + finding.cwe = cwe + + self.attach_target(finding, item) + return finding + + def attach_target(self, finding, item): + """ + Record the scanned application or target, for the item types that have one. + + The connector emits this as an endpoint string and lets DefectDojo parse it. Here it is parsed + first, because the value is not always a bare host: a DAST item names an application, which + may be a URL, and a container item names an image, which carries a repository path. Putting + either in the host field unparsed fails validation for the whole import. + """ + for key in ("application", "affectedTargets"): + target = (item.get(key) or "").strip() + if target: + self.attach_location(finding, target) + return + + def attach_location(self, finding, target): + parsed = urlparse(target if "//" in target else f"//{target}") + try: + port = parsed.port + except ValueError: + # An image tag ("registry/image:1.2") is not a port, and the tag belongs to the path. + return + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return + path = parsed.path.lstrip("/") + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=port, path=path, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=port, path=path or None, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def title(self, item): + """Codacy's title when it has one, else a composed one, else the item id.""" + if (item.get("title") or "").strip(): + return item["title"] + parts = [ + part for part in (item.get("scanType"), item.get("securityCategory")) + if (part or "").strip() + ] + if not parts: + return f"Codacy security item {item.get('id')}" + return "Codacy " + " - ".join(parts) + " item" + + def vulnerable_package(self, item): + """The last entry of the first non-empty dependency chain, as the connector does.""" + chains = item.get("dependencyChains") + if not isinstance(chains, list): + return "" + for chain in chains: + if isinstance(chain, list) and chain: + return str(chain[-1]) + return "" + + def describe(self, item): + sections = [ + prose.strip() for prose in (item.get("summary"), item.get("additionalInfo")) + if (prose or "").strip() + ] + + details = [] + for label, value in ( + ("Scan type", item.get("scanType")), + ("Security category", item.get("securityCategory")), + ("Detected by", item.get("itemSource")), + ("Repository", item.get("repository")), + ("Container image", self.container_image(item)), + ("Likelihood", item.get("likelihood")), + ("Effort to fix", item.get("effortToFix")), + ): + if (value or "").strip(): + details.append(f"**{label}:** {value}") + + chains = item.get("dependencyChains") + if isinstance(chains, list): + details.extend( + "**Dependency path:** " + " → ".join(str(c) for c in chain) + for chain in chains if isinstance(chain, list) and chain + ) + + if details: + sections.append("\n".join(details)) + return "\n\n".join(sections) + + def container_image(self, item): + name = (item.get("imageName") or "").strip() + if not name: + return "" + tag = (item.get("imageTag") or "").strip() + return f"{name}:{tag}" if tag else name + + def mitigation(self, item): + parts = [] + if (item.get("remediation") or "").strip(): + parts.append(item["remediation"].strip()) + fixed = item.get("fixedVersion") + if isinstance(fixed, list): + versions = [str(v).strip() for v in fixed if str(v).strip()] + if versions: + parts.append("Fixed in: " + ", ".join(versions)) + return "\n\n".join(parts) + + def vuln_id_from_tool(self, item): + """The first CVE in the item, upper-cased, else Codacy's own source id.""" + match = CVE_PATTERN.search(item.get("cve") or "") + if match: + return match.group(0).upper() + return item.get("itemSourceId") or "" + + def cves(self, raw): + """Codacy's cve field can hold several identifiers; take them all, deduplicated.""" + found = CVE_PATTERN.findall(raw or "") + ids, seen = [], set() + for cve in found: + upper = cve.upper() + if upper not in seen: + seen.add(upper) + ids.append(upper) + return ids + + def cwe_number(self, raw): + match = CWE_PATTERN.search(raw or "") + if not match: + return 0 + with suppress(ValueError): + return int(match.group(1)) + return 0 + + def is_false_positive(self, item): + """ + Codacy can ignore an item, and the reason says whether it was a false positive. + + The comparison strips spaces so "False Positive" and "falsepositive" both match; any other + ignore reason is not a false positive. + """ + ignored = item.get("ignored") + if not isinstance(ignored, dict): + return False + reason = (ignored.get("reason") or "").strip().lower().replace(" ", "") + return reason == FALSE_POSITIVE_REASON + + def tags(self, item): + return [ + value.strip() for value in + (item.get("scanType"), item.get("securityCategory"), item.get("itemSource")) + if (value or "").strip() + ] + + def date(self, timestamp): + """ + Codacy's openedAt as a date. + + The connector falls back to today when the timestamp will not parse, so that a finding always + carries a date; that is mirrored here. + """ + with suppress(ValueError, AttributeError): + return datetime.fromisoformat((timestamp or "").strip()).astimezone(UTC).date() + return datetime.now(tz=UTC).date() diff --git a/dojo/tools/crowdstrike_spotlight/__init__.py b/dojo/tools/crowdstrike_spotlight/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/crowdstrike_spotlight/parser.py b/dojo/tools/crowdstrike_spotlight/parser.py new file mode 100644 index 00000000000..4bd7d1defb8 --- /dev/null +++ b/dojo/tools/crowdstrike_spotlight/parser.py @@ -0,0 +1,324 @@ +import json +import re +from contextlib import suppress +from datetime import datetime +from ipaddress import ip_address + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Mirrors mapCVESeverity() in the CrowdStrike connector's vuln_converter: the comparison is on the +# upper-cased CVE severity, and anything unrecognised becomes Info. +SEVERITY_MAP = { + "CRITICAL": "Critical", + "HIGH": "High", + "MEDIUM": "Medium", + "LOW": "Low", +} +DEFAULT_SEVERITY = "Info" + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class CrowdstrikeSpotlightParser: + + """ + Parses a CrowdStrike Falcon Spotlight vulnerability export. + + Mirrors pkg/tools/crowdstrike/connector/vuln_converter.go field for field, so that a file import + and an API sync produce findings that deduplicate against each other rather than two copies of + everything. + + Note that the connector marks these findings NEITHER static NOR dynamic. Spotlight reports + vulnerable software present on a host from the agent's inventory; it does not analyse source and + it does not probe a running service. The connector's choice is mirrored rather than corrected. + """ + + def get_scan_types(self): + # Byte-identical to ScanTypeSpotlight in the connector. The connector also defines + # "CrowdStrike:Detections - Connectors Import", which is a different shape and not this + # parser's concern. + return ["CrowdStrike:Spotlight - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "CrowdStrike:Spotlight - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a CrowdStrike Falcon Spotlight vulnerability export (JSON). Matches the scan " + "type used by the CrowdStrike Spotlight connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the CrowdStrike Spotlight Parser. + + Mirrors the connector's VulnConverter.Convert: + - title: ": ", degrading as the converter's title() does. + - severity: the CVE severity, anything unrecognised Info. + - severity_justification: the converter's sentence about severity, score and ExPRT rating. + - description: CVE description, then host, OS, affected product, ExPRT rating and CISA KEV. + - mitigation: each remediation entity's title, action and reference. + - references: CVE references, vendor advisories, and each entity's link and vendor URL. + - cvssv3 / cvssv3_score: the CVE vector and base score. + - cwe: the first parseable CWE-NNN. + - component_name / component_version: the first app's normalized name, and the version + derived by stripping that name from its product_name_version. + - unique_id_from_tool: the Spotlight vulnerability id. + - vuln_id_from_tool: the CVE id. + """ + return [ + "title", + "severity", + "severity_justification", + "description", + "mitigation", + "references", + "date", + "cvssv3", + "cvssv3_score", + "cwe", + "component_name", + "component_version", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the CrowdStrike Spotlight Parser. + + Copied from the CrowdStrike Spotlight block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Diverging would stop file findings + merging with API-synced ones. + """ + return ["unique_id_from_tool", "title", "severity", "vulnerability_ids"] + + def get_findings(self, filename, test): + data = json.load(filename) + rows = self.extract_rows(data) + + findings = {} + for row in rows: + if not isinstance(row, dict): + continue + finding = self.build_finding(row, test) + # The Spotlight vulnerability id is the connector's identity. + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_rows(self, data): + """The Falcon API wraps results under "resources"; a saved export is often the bare array.""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("resources", "vulnerabilities"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "A CrowdStrike Spotlight export is a JSON array of vulnerabilities, or an object with " + f"a 'resources' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, test): + cve = row.get("cve") or {} + host = row.get("host_info") or {} + apps = row.get("apps") or [] + app = apps[0] if apps and isinstance(apps[0], dict) else {} + + cve_id = cve.get("id") or "" + base_score = cve.get("base_score") or 0 + + finding = Finding( + test=test, + title=self.title(cve_id, self.product(app)), + severity=self.severity(cve), + severity_justification=self.justification(cve, base_score), + description=self.describe(cve, host, app), + mitigation=self.mitigation(row) or None, + references=self.references(row, cve) or None, + date=self.date(row.get("created_timestamp")), + cvssv3=cve.get("vector") or None, + cvssv3_score=base_score or None, + cwe=self.first_cwe(cve.get("cwes")), + component_name=app.get("product_name_normalized") or None, + component_version=self.component_version(app) or None, + unique_id_from_tool=row.get("id"), + vuln_id_from_tool=cve_id or None, + # The converter sets both to false: Spotlight reads an agent's software inventory, so it + # neither analyses source nor probes a running service. + static_finding=False, + dynamic_finding=False, + ) + finding.unsaved_tags = self.tags(cve, host) + if cve_id: + finding.unsaved_vulnerability_ids = [cve_id] + + self.attach_host(finding, host) + return finding + + def attach_host(self, finding, host): + """ + Record the affected host. + + The connector emits a protocol-relative "//" string because DefectDojo parses a bare + hostname as a URL path. Here the host is set directly instead, which is the same outcome + without the string round-trip. + """ + name = host.get("hostname") or host.get("local_ip") + if not name or not self.usable_host(str(name).strip()): + return + name = str(name).strip() + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(host=name)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=name)) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def title(self, cve_id, product): + """Build the title as the converter does: CVE and product, then either alone, then a constant.""" + if cve_id and product: + return f"{cve_id}: {product}" + if cve_id: + return cve_id + if product: + return product + return "CrowdStrike Spotlight Vulnerability" + + def product(self, app): + """Take the versioned product name, falling back to the plain one, as the converter does.""" + return app.get("product_name_version") or app.get("product_name_normalized") or "" + + def component_version(self, app): + """ + Converter componentVersion(). + + CrowdStrike returns no discrete version field, so the version is whatever is left of + product_name_version once the normalized product name is stripped from the front. + """ + normalized = app.get("product_name_normalized") or "" + versioned = app.get("product_name_version") or "" + if normalized and versioned.startswith(normalized): + return versioned[len(normalized):].strip() + return "" + + def severity(self, cve): + return SEVERITY_MAP.get((cve.get("severity") or "").strip().upper(), DEFAULT_SEVERITY) + + def justification(self, cve, base_score): + """Converter severityJustification(), including its markdown emphasis and score format.""" + justification = f"CrowdStrike severity of **{cve.get('severity') or ''}**" + if base_score: + justification += f" from a base CVSS score of **{base_score:.1f}**" + if cve.get("exprt_rating"): + justification += f" (ExPRT rating: {cve['exprt_rating']})" + return justification + + def describe(self, cve, host, app): + parts = [] + if cve.get("description"): + parts.append(cve["description"] + "\n") + + lines = [] + if host.get("hostname"): + lines.append(f"**Host:** {host['hostname']}") + if host.get("os_version"): + lines.append(f"**OS:** {host['os_version']}") + if product := self.product(app): + lines.append(f"**Affected product:** {product}") + if cve.get("exprt_rating"): + lines.append(f"**ExPRT rating:** {cve['exprt_rating']}") + if (cve.get("cisa_info") or {}).get("is_cisa_kev"): + lines.append( + "**CISA KEV:** listed in the CISA Known Exploited Vulnerabilities catalog", + ) + parts.extend(lines) + return "\n".join(parts).strip() + + def mitigation(self, row): + """Build one block per remediation entity, with a blank line between blocks.""" + blocks = [] + for entity in self.entities(row): + parts = [] + if entity.get("title"): + parts.append(f"**{entity['title']}**") + if entity.get("action"): + parts.append(entity["action"]) + if entity.get("reference"): + parts.append(f"Reference: {entity['reference']}") + if parts: + blocks.append("\n".join(parts)) + return "\n\n".join(blocks) + + def references(self, row, cve): + """Collect CVE references, then vendor advisories, then each entity's links.""" + refs = [] + for key in ("references", "vendor_advisory"): + value = cve.get(key) + if isinstance(value, list): + refs.extend(str(item) for item in value if item) + for entity in self.entities(row): + refs.extend(entity[key] for key in ("link", "vendor_url") if entity.get(key)) + return "\n".join(refs) + + def entities(self, row): + entities = (row.get("remediation") or {}).get("entities") + return [e for e in entities if isinstance(e, dict)] if isinstance(entities, list) else [] + + def tags(self, cve, host): + tags = [] + if cve.get("exprt_rating"): + tags.append(f"exprt:{cve['exprt_rating'].lower()}") + if (cve.get("cisa_info") or {}).get("is_cisa_kev"): + tags.append("cisa-kev") + host_tags = host.get("tags") + if isinstance(host_tags, list): + tags.extend(str(tag) for tag in host_tags if tag) + return tags + + def first_cwe(self, cwes): + """Converter firstCWE(): the first "CWE-NNN" that parses, else 0.""" + if not isinstance(cwes, list): + return 0 + for cwe in cwes: + parts = str(cwe).split("-") + if len(parts) != 2: + continue + with suppress(ValueError): + return int(parts[1]) + return 0 + + def date(self, timestamp): + """Converter formatDate(): an RFC3339 timestamp as a date, or nothing if it will not parse.""" + if not timestamp: + return None + text = str(timestamp).replace("Z", "+00:00") + try: + return datetime.fromisoformat(text).date() + except ValueError: + return None diff --git a/dojo/tools/datadog/__init__.py b/dojo/tools/datadog/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/datadog/parser.py b/dojo/tools/datadog/parser.py new file mode 100644 index 00000000000..c9d44f3329d --- /dev/null +++ b/dojo/tools/datadog/parser.py @@ -0,0 +1,363 @@ +import json +import re +from contextlib import suppress +from datetime import UTC, datetime + +from dojo.models import Finding + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Datadog statuses that mean the finding is not actionable. +IGNORED_STATUSES = {"muted", "resolved", "auto_closed"} + +# Finding types that describe something observed at runtime rather than read from a configuration or +# an inventory. Everything else is static. +DYNAMIC_FINDING_TYPES = { + "runtime_code_vulnerability", + "api_security", + "attack_path", + "workload_activity", + "identity_risk", +} + +# The advisory identifiers the connector's shared extractor recognises in free text. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + + +class DatadogParser: + + """ + Parses a Datadog Cloud Security findings export. + + Mirrors pkg/tools/datadog/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Datadog returns one endpoint for everything it calls a security finding - misconfigurations, code + and library vulnerabilities, attack paths, identity risks - distinguished by finding_type. That + single stream is why the same parser has to decide static versus dynamic per row rather than for + the file as a whole; see build_finding(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Datadog Cloud Security"] + + def get_label_for_scan_types(self, scan_type): + return "Datadog Cloud Security" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Datadog Cloud Security findings export (JSON), the /api/v2/posture_management/" + "findings response. Matches the scan type used by the Datadog connector so file and API " + "findings deduplicate. Muted, resolved and passing findings are skipped." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Datadog Parser. + + Mirrors the connector's Convert: + - title: the finding title, then the rule name, then the finding type. + - severity: Datadog's own severity label; anything unrecognised is Info. + - description: an Overview section, then the rule, finding type, resource, compliance + evaluation and advisory as bullets. + - date: when Datadog first saw the finding (unix milliseconds). + - cvssv3 / cvssv3_score: the base severity details, falling back to the adjusted ones. + - component_name / component_version: the affected package, for library findings. + - service: read out of Datadog's own "service:" tag. + - unique_id_from_tool: the finding's id. + """ + return [ + "title", + "severity", + "date", + "description", + "cvssv3", + "cvssv3_score", + "component_name", + "component_version", + "service", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Datadog Parser. + + Copied from the Datadog block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for row in self.rows(data): + attributes = self.attributes(row) + if attributes is None or self.ignored(attributes): + continue + findings.append(self.build_finding(row, attributes, test)) + return findings + + def rows(self, data): + """ + Return the findings in the export. + + Datadog's findings response nests them under "data"; a bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + if isinstance(data.get("data"), list): + return [row for row in data["data"] if isinstance(row, dict)] + if "attributes" in data: + return [data] + + msg = ( + "A Datadog Cloud Security export is the findings response, a JSON object with a 'data' " + f"list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def envelope(self, row): + value = row.get("attributes") + return value if isinstance(value, dict) else {} + + def attributes(self, row): + """ + The finding's own attributes. + + Datadog nests them twice - attributes.attributes - because the outer object also carries the + row's tags and timestamp. + """ + inner = self.envelope(row).get("attributes") + return inner if isinstance(inner, dict) else None + + def block(self, attributes, key): + value = attributes.get(key) + return value if isinstance(value, dict) else {} + + def strings(self, value): + if isinstance(value, str): + return [value.strip()] if value.strip() else [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item or "").strip()] + return [] + + def ignored(self, attributes): + """ + Whether Datadog has already dealt with this finding. + + Three separate ways it says so, and all three have to be honoured or a triaged queue comes + back: the status, an explicit workflow mute, and - for a compliance rule - an evaluation that + passed. A passing rule is the tool reporting that nothing is wrong. + """ + if str(attributes.get("status") or "").strip().lower() in IGNORED_STATUSES: + return True + mute = self.block(self.block(attributes, "workflow"), "mute") + if mute.get("is_muted") is True: + return True + evaluation = str(self.block(attributes, "compliance").get("evaluation") or "").strip() + return evaluation.lower() == "pass" + + def build_finding(self, row, attributes, test): + finding_type = str(attributes.get("finding_type") or "").strip() + # A runtime finding is something Datadog watched happen; everything else it read. + static = finding_type.lower() not in DYNAMIC_FINDING_TYPES + + finding = Finding( + test=test, + title=self.title(attributes, finding_type), + severity=self.severity(attributes), + description=self.describe(attributes, finding_type), + unique_id_from_tool=self.unique_id(row, attributes), + static_finding=static, + dynamic_finding=not static, + ) + finding.unsaved_tags = self.tags(row, attributes, finding_type) + + if date := self.date(attributes): + finding.date = date + if rule_id := str(self.block(attributes, "rule").get("id") or "").strip(): + finding.vuln_id_from_tool = rule_id + if identifiers := self.vulnerability_ids(attributes): + finding.unsaved_vulnerability_ids = identifiers + + vector, score = self.cvss(attributes) + if vector or score > 0: + finding.cvssv3 = vector + finding.cvssv3_score = score + + package = self.block(attributes, "package") + if package: + finding.component_name = str(package.get("name") or "").strip() or None + finding.component_version = str(package.get("version") or "").strip() or None + if service := self.tag_value(self.strings(self.envelope(row).get("tags")), "service"): + finding.service = service + return finding + + def unique_id(self, row, attributes): + """The row's id, falling back to the finding id Datadog repeats inside the attributes.""" + if identifier := str(row.get("id") or "").strip(): + return identifier + return str(attributes.get("finding_id") or "").strip() or None + + def title(self, attributes, finding_type): + if title := str(attributes.get("title") or "").strip(): + return title + if name := str(self.block(attributes, "rule").get("name") or "").strip(): + return name + if finding_type: + return f"Datadog finding: {finding_type}" + return "Datadog security finding" + + def severity(self, attributes): + """ + Datadog's own severity label. + + Note base_severity is deliberately NOT consulted: it is the rule's default before Datadog + adjusts for the environment, and the adjusted value is the one worth importing. + """ + label = str(attributes.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def date(self, attributes): + """Datadog timestamps these in unix MILLIseconds, not seconds.""" + for key in ("first_seen_at", "detection_changed_at"): + value = attributes.get(key) + if isinstance(value, int | float) and not isinstance(value, bool) and value > 0: + with suppress(OSError, OverflowError, ValueError): + return datetime.fromtimestamp(value / 1000, tz=UTC).date() + return None + + def describe(self, attributes, finding_type): + """ + The connector's shared formatter: a level-2 heading, then prefixed bullets. + + Reproduced exactly - a "## Overview" heading followed by a blank line, then "* **Rule:** ..." + lines - so the two import paths do not differ for no reason. + """ + parts = [] + if description := str(attributes.get("description") or "").strip(): + parts.append("## Overview\n\n" + description + "\n") + + bullets = [] + + def add(prefix, value): + text = str(value or "").strip() + if text: + bullets.append(f"* **{prefix}** {text}") + + add("Rule:", self.block(attributes, "rule").get("name")) + add("Finding type:", finding_type) + add("Resource:", self.resource_label(attributes)) + add("Compliance evaluation:", self.block(attributes, "compliance").get("evaluation")) + advisory = self.block(attributes, "advisory") + add("Advisory:", advisory.get("cve")) + add("Advisory summary:", advisory.get("summary")) + + if bullets: + parts.append("\n".join(bullets) + "\n") + return "".join(parts).rstrip("\n") + "\n" if parts else "" + + def resource_label(self, attributes): + """" ()" - the resource name, falling back to its id.""" + name = str(attributes.get("resource_name") or "").strip() + if not name: + name = str(attributes.get("resource_id") or "").strip() + if not name: + return "" + kind = str(attributes.get("resource_type") or "").strip() + return f"{name} ({kind})" if kind else name + + def vulnerability_ids(self, attributes): + """ + The advisory's CVE and aliases, then any identifier in the title or description. + + Datadog names the CVE in the prose for some finding types and only in the advisory object for + others, so both are read. Order is preserved and duplicates dropped, as the connector does. + """ + advisory = self.block(attributes, "advisory") + candidates = [str(advisory.get("cve") or "")] + candidates.extend(self.strings(advisory.get("aliases"))) + prose = "|".join([str(attributes.get("title") or ""), str(attributes.get("description") or "")]) + candidates.extend(VULNERABILITY_ID_PATTERN.findall(prose)) + + identifiers = [] + for candidate in candidates: + trimmed = candidate.strip() + if trimmed and trimmed not in identifiers: + identifiers.append(trimmed) + return identifiers + + def cvss(self, attributes): + """ + The base severity details, falling back to the adjusted ones. + + The first block carrying either a vector or a positive score wins, and both of its values are + taken together - mixing a vector from one with a score from the other would describe a + scoring that never existed. + """ + details = self.block(attributes, "severity_details") + for key in ("base", "adjusted"): + detail = self.block(details, key) + if not detail: + continue + vector = str(detail.get("vector") or "").strip() + score = detail.get("score") + score = float(score) if isinstance(score, int | float) and not isinstance(score, bool) else 0.0 + if vector or score > 0: + return vector, score + return "", 0.0 + + def tags(self, row, attributes, finding_type): + """ + Datadog's own tags, prefixed context, and the resource's cloud placement. + + Deduplicated but NOT sorted: the connector preserves the order it built them in, and a tag + list that reorders on every sync reads as a change. + """ + ordered = ["datadog"] + if finding_type: + ordered.append(f"finding_type:{finding_type}") + + resource = self.block(attributes, "cloud_resource") + if provider := str(resource.get("cloud_provider") or "").strip(): + ordered.append(f"cloud_provider:{provider.lower()}") + if region := str(resource.get("region") or "").strip(): + ordered.append(f"region:{region}") + if account := str(self.block(resource, "account").get("account_id") or "").strip(): + ordered.append(f"account:{account}") + + if resource_type := str(attributes.get("resource_type") or "").strip(): + ordered.append(f"resource_type:{resource_type}") + ordered.extend(self.strings(self.envelope(row).get("tags"))) + + deduped = [] + for tag in ordered: + trimmed = tag.strip() + if trimmed and trimmed not in deduped: + deduped.append(trimmed) + return deduped + + def tag_value(self, tags, key): + """Datadog writes its metadata as "key:value" tags.""" + prefix = f"{key}:" + for tag in tags: + if tag.startswith(prefix): + return tag[len(prefix):] + return "" diff --git a/dojo/tools/deepsource/__init__.py b/dojo/tools/deepsource/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/deepsource/parser.py b/dojo/tools/deepsource/parser.py new file mode 100644 index 00000000000..f9dc43f1fae --- /dev/null +++ b/dojo/tools/deepsource/parser.py @@ -0,0 +1,457 @@ +import json +from contextlib import suppress +from datetime import UTC, datetime + +from dojo.models import Finding + +CATEGORY_SECURITY = "SECURITY" +CATEGORY_STYLE = "STYLE" +CATEGORY_DOCUMENTATION = "DOCUMENTATION" +CATEGORY_COVERAGE = "COVERAGE" + +# Categories that describe a defect rather than a weakness. They are graded one step below the +# equivalent security issue, which is what the connector does. +RISK_CATEGORIES = frozenset({"BUG_RISK", "PERFORMANCE", "TYPECHECK", "ANTI_PATTERN"}) + +# DeepSource grades every issue CRITICAL / MAJOR / MINOR regardless of category, so the category +# decides which ladder applies. A security issue keeps its grade; a bug-risk issue drops a step. +SECURITY_SEVERITY = {"CRITICAL": "Critical", "MAJOR": "High", "MINOR": "Medium"} +RISK_SEVERITY = {"CRITICAL": "High", "MAJOR": "Medium", "MINOR": "Low"} + +# A hit from the secrets analyzer is a committed credential whatever DeepSource graded it. +SECRETS_ANALYZER = "secrets" + +# CVSS v3 band floors, used for dependency advisories that carry a score. +CVSS_CRITICAL_FLOOR = 9.0 +CVSS_HIGH_FLOOR = 7.0 +CVSS_MEDIUM_FLOOR = 4.0 + +# Advisory severity words, including GitHub's "MODERATE" spelling of medium. +NAMED_SEVERITY = { + "CRITICAL": "Critical", + "HIGH": "High", + "MEDIUM": "Medium", + "MODERATE": "Medium", + "LOW": "Low", +} + +DEFAULT_SEVERITY = "Info" + + +class DeepSourceParser: + + """ + Parses a DeepSource export. + + Mirrors pkg/tools/deepsource/converter field for field so a file import and an API sync + deduplicate against each other instead of producing two copies of everything. + + DeepSource reports two different things, and the connector converts them differently: static + ANALYSIS ISSUES found in the code, and DEPENDENCY VULNERABILITIES from advisories. Both are + accepted here; see get_fields() for the two mappings. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["DeepSource - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "DeepSource - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a DeepSource export (JSON). Accepts both analysis issue occurrences and " + "dependency vulnerabilities. Matches the scan type used by the DeepSource connector so " + "file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the DeepSource Parser. + + For an ANALYSIS ISSUE occurrence: + - title: the occurrence title, then the issue title, then its shortcode. + - severity: see severity_for_issue - the category decides which ladder applies. + - description: the issue's short description, then issue shortcode, analyzer, category, + DeepSource's own severity word, and the file location. + - file_path / line: where the occurrence is. + - vuln_id_from_tool: the DeepSource issue shortcode, e.g. PY-A6006. + + For a DEPENDENCY VULNERABILITY: + - title: " - ()", degrading as the converter's does. + - severity: the CVSS v3 band when scored, otherwise the advisory's severity word. + - description: the advisory summary, package, ecosystem, reachability, fixability, aliases. + - mitigation: the fixed versions, or a note that none has been published. + - component_name / component_version, cvssv3_score, epss_score, references. + + Both carry unique_id_from_tool - the occurrence or vulnerability id - and are static. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "references", + "cvssv3_score", + "epss_score", + "file_path", + "line", + "component_name", + "component_version", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the DeepSource Parser. + + Copied from the DeepSource block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Diverging would stop file findings + merging with API-synced ones. + """ + return ["title", "severity", "file_path"] + + def get_findings(self, filename, test): + data = json.load(filename) + occurrences, vulnerabilities, run = self.extract(data) + + findings = {} + for occurrence in occurrences: + if isinstance(occurrence, dict): + finding = self.build_occurrence(occurrence, run, test) + findings.setdefault(finding.unique_id_from_tool, finding) + for vulnerability in vulnerabilities: + if isinstance(vulnerability, dict): + finding = self.build_vulnerability(vulnerability, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract(self, data): + """ + Split a DeepSource GraphQL response into occurrences, vulnerabilities and the analysis run. + + DeepSource has no REST API - everything goes through POST /graphql/ - so the file a user can + actually produce is a saved GraphQL response: + + {"data": {"repository": { + "analysisRuns": {"edges": [{"node": {...}}]}, + "issueOccurrences": {"edges": [{"node": {...}}]}, + "dependencyVulnerabilityOccurrences": {"edges": [{"node": {...}}]}}}} + + Both queries return the same repository envelope, so one file may carry either connection or + both. The edges/node wrapping is unwrapped here. + """ + repository = self.repository(data) + if repository is not None: + occurrences = self.nodes(repository.get("issueOccurrences")) + vulnerabilities = self.nodes(repository.get("dependencyVulnerabilityOccurrences")) + runs = self.nodes(repository.get("analysisRuns")) + run = runs[0] if runs else None + return occurrences, vulnerabilities, run + + # Convenience shapes, for anyone who has already unwrapped the response themselves. + if isinstance(data, list): + occurrences = [row for row in data if not self.looks_like_vulnerability(row)] + vulnerabilities = [row for row in data if self.looks_like_vulnerability(row)] + return occurrences, vulnerabilities, None + + if isinstance(data, dict): + occurrences, found_occurrences = self.first_list(data, ("occurrences", "issues")) + vulnerabilities, found_vulnerabilities = self.first_list( + data, ("vulnerabilities", "dependencyVulnerabilities"), + ) + run = data.get("run") if isinstance(data.get("run"), dict) else None + # Presence of the key is what identifies the shape, not whether it has entries: a clean + # run legitimately reports both lists empty. + if found_occurrences or found_vulnerabilities: + return occurrences, vulnerabilities, run + + msg = ( + "A DeepSource export is a saved GraphQL response with data.repository containing " + "issueOccurrences or dependencyVulnerabilityOccurrences; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def repository(self, data): + """ + Find the repository object in a GraphQL response, whether or not the "data" wrapper is kept. + + Returns None when this is not a GraphQL response at all, so the caller can try the + convenience shapes. + """ + if not isinstance(data, dict): + return None + for candidate in (data.get("data"), data): + if isinstance(candidate, dict): + repository = candidate.get("repository") + if isinstance(repository, dict): + return repository + return None + + def nodes(self, connection): + """Unwrap a GraphQL connection's edges into a plain list of nodes.""" + if not isinstance(connection, dict): + return [] + edges = connection.get("edges") + if not isinstance(edges, list): + return [] + return [ + edge["node"] for edge in edges + if isinstance(edge, dict) and isinstance(edge.get("node"), dict) + ] + + def first_list(self, data, keys): + """Return the first list found under these keys, and whether any of them was present.""" + for key in keys: + if isinstance(data.get(key), list): + return data[key], True + return [], False + + def looks_like_vulnerability(self, row): + return isinstance(row, dict) and isinstance(row.get("vulnerability"), dict) + + def build_occurrence(self, occurrence, run, test): + issue = occurrence.get("issue") or {} + begin = occurrence.get("beginLine") or 0 + + finding = Finding( + test=test, + title=self.occurrence_title(occurrence, issue), + severity=self.severity_for_issue(issue), + date=self.run_date(run), + description=self.occurrence_description(occurrence, issue, begin), + file_path=occurrence.get("path") or None, + line=begin or None, + unique_id_from_tool=occurrence.get("id"), + vuln_id_from_tool=issue.get("shortcode") or None, + active=True, + # DeepSource analyses source, never a running service. + static_finding=True, + dynamic_finding=False, + ) + analyzer = issue.get("analyzer") or {} + finding.unsaved_tags = [ + value.strip() for value in + (analyzer.get("shortcode"), issue.get("category"), issue.get("severity")) + if (value or "").strip() + ] + return finding + + def occurrence_title(self, occurrence, issue): + for candidate in (occurrence.get("title"), issue.get("title"), issue.get("shortcode")): + if (candidate or "").strip(): + return candidate.strip() + return f"DeepSource issue {occurrence.get('id')}" + + def occurrence_description(self, occurrence, issue, begin): + sections = [] + if (issue.get("shortDescription") or "").strip(): + sections.append(issue["shortDescription"].strip()) + + analyzer = issue.get("analyzer") or {} + details = [] + if issue.get("shortcode"): + details.append(f"**Issue:** {issue['shortcode']}") + if analyzer.get("name") or analyzer.get("shortcode"): + details.append(f"**Analyzer:** {self.analyzer_label(analyzer)}") + if issue.get("category"): + details.append(f"**Category:** {issue['category']}") + if issue.get("severity"): + details.append(f"**DeepSource severity:** {issue['severity']}") + if occurrence.get("path"): + details.append(f"**Location:** {occurrence['path']}{self.line_range(occurrence, begin)}") + + if details: + sections.append("\n".join(details)) + return "\n\n".join(sections) + + def analyzer_label(self, analyzer): + name, shortcode = analyzer.get("name") or "", analyzer.get("shortcode") or "" + if name and shortcode: + return f"{name} ({shortcode})" + return name or shortcode + + def line_range(self, occurrence, begin): + """A range only when the occurrence genuinely spans more than one line.""" + if not begin: + return "" + end = occurrence.get("endLine") or 0 + return f":{begin}-{end}" if end > begin else f":{begin}" + + def severity_for_issue(self, issue): + """ + Grade an analysis issue. + + DeepSource grades everything CRITICAL / MAJOR / MINOR regardless of what the issue actually + is, so the category decides which ladder applies: a security issue keeps its grade, while a + bug-risk, performance, typecheck or anti-pattern issue drops a step, because those describe a + defect rather than a weakness. Style, documentation and coverage are Info. + + A hit from the secrets analyzer is a committed credential whatever the grade said, so it + outranks both ladders. + """ + analyzer = issue.get("analyzer") or {} + if (analyzer.get("shortcode") or "").strip().lower() == SECRETS_ANALYZER: + return "Critical" + + severity = (issue.get("severity") or "").strip().upper() + category = (issue.get("category") or "").strip().upper() + + if category == CATEGORY_SECURITY: + if severity in SECURITY_SEVERITY: + return SECURITY_SEVERITY[severity] + elif category in RISK_CATEGORIES: + if severity in RISK_SEVERITY: + return RISK_SEVERITY[severity] + elif category in {CATEGORY_STYLE, CATEGORY_DOCUMENTATION, CATEGORY_COVERAGE}: + return "Info" + return DEFAULT_SEVERITY + + def build_vulnerability(self, occurrence, test): + vulnerability = occurrence.get("vulnerability") or {} + package = occurrence.get("package") or {} + version = (occurrence.get("packageVersion") or {}).get("version") or "" + + references = vulnerability.get("referenceUrls") + finding = Finding( + test=test, + title=self.vulnerability_title(occurrence, vulnerability, package, version), + severity=self.severity_for_vulnerability(vulnerability), + date=self.published_date(vulnerability.get("publishedAt")), + description=self.vulnerability_description(occurrence, vulnerability, package, version), + mitigation=self.vulnerability_mitigation(vulnerability, package), + references="\n".join(references) if isinstance(references, list) else None, + cvssv3_score=vulnerability.get("cvssV3BaseScore") or None, + epss_score=vulnerability.get("epssScore") or None, + component_name=package.get("name") or None, + component_version=version or None, + unique_id_from_tool=occurrence.get("id"), + vuln_id_from_tool=vulnerability.get("identifier") or None, + active=True, + static_finding=True, + dynamic_finding=False, + ) + finding.unsaved_tags = ["sca"] + [ + value.strip() for value in (package.get("ecosystem"), occurrence.get("reachability")) + if (value or "").strip() + ] + identifiers = self.vulnerability_ids(vulnerability) + if identifiers: + finding.unsaved_vulnerability_ids = identifiers + return finding + + def vulnerability_title(self, occurrence, vulnerability, package, version): + identifier = (vulnerability.get("identifier") or "").strip() + component = (package.get("name") or "").strip() + if identifier and component and version: + return f"{identifier} - {component} ({version})" + if identifier and component: + return f"{identifier} - {component}" + if identifier: + return identifier + if (vulnerability.get("summary") or "").strip(): + return vulnerability["summary"].strip() + return f"DeepSource dependency vulnerability {occurrence.get('id')}" + + def vulnerability_description(self, occurrence, vulnerability, package, version): + sections = [] + if (vulnerability.get("summary") or "").strip(): + sections.append(vulnerability["summary"].strip()) + + details = [] + if package.get("name"): + component = f"{package['name']} {version}".strip() if version else package["name"] + details.append(f"**Package:** {component}") + if package.get("ecosystem"): + details.append(f"**Ecosystem:** {package['ecosystem']}") + if occurrence.get("reachability"): + details.append(f"**Reachability:** {occurrence['reachability']}") + if occurrence.get("fixability"): + details.append(f"**Fixability:** {occurrence['fixability']}") + if vulnerability.get("cvssV3Severity"): + details.append(f"**CVSS v3 severity:** {vulnerability['cvssV3Severity']}") + aliases = vulnerability.get("aliases") + if isinstance(aliases, list) and aliases: + details.append("**Aliases:** " + ", ".join(str(a) for a in aliases)) + + if details: + sections.append("\n".join(details)) + return "\n\n".join(sections) + + def vulnerability_mitigation(self, vulnerability, package): + """ + Name the versions that fix the advisory. + + The connector says so explicitly when there are none, rather than leaving the field empty - + "no fix published" is itself useful triage information. + """ + fixed = vulnerability.get("fixedVersions") + versions = [str(v).strip() for v in fixed if str(v).strip()] if isinstance(fixed, list) else [] + if not versions: + return "No fixed version has been published for this advisory." + component = package.get("name") or "the affected package" + return f"Upgrade {component} to {' or '.join(versions)}." + + def severity_for_vulnerability(self, vulnerability): + """A scored advisory is graded by CVSS band; otherwise its severity word decides.""" + score = vulnerability.get("cvssV3BaseScore") or 0 + if score > 0: + return self.cvss_band(score) + for candidate in (vulnerability.get("cvssV3Severity"), vulnerability.get("severity")): + named = NAMED_SEVERITY.get((candidate or "").strip().upper()) + if named: + return named + return DEFAULT_SEVERITY + + def cvss_band(self, score): + """Note the converter's lowest band is Low, not Info - a scored advisory is never Info.""" + if score >= CVSS_CRITICAL_FLOOR: + return "Critical" + if score >= CVSS_HIGH_FLOOR: + return "High" + if score >= CVSS_MEDIUM_FLOOR: + return "Medium" + return "Low" + + def vulnerability_ids(self, vulnerability): + """The advisory identifier then its aliases, upper-cased and deduplicated in order.""" + aliases = vulnerability.get("aliases") + candidates = [vulnerability.get("identifier") or ""] + if isinstance(aliases, list): + candidates.extend(str(alias) for alias in aliases) + + identifiers, seen = [], set() + for candidate in candidates: + upper = candidate.strip().upper() + if upper and upper not in seen: + seen.add(upper) + identifiers.append(upper) + return identifiers + + def run_date(self, run): + """ + The analysis run's finish time, falling back to when it was created, then to today. + + The connector always dates a finding rather than leaving it unset; mirrored here. + """ + if isinstance(run, dict): + for candidate in (run.get("finishedAt"), run.get("createdAt")): + parsed = self.parse_date(candidate) + if parsed: + return parsed + return datetime.now(tz=UTC).date() + + def published_date(self, published_at): + return self.parse_date(published_at) or datetime.now(tz=UTC).date() + + def parse_date(self, value): + with suppress(ValueError, AttributeError): + return datetime.fromisoformat((value or "").strip()).astimezone(UTC).date() + return None diff --git a/dojo/tools/defender_for_cloud/__init__.py b/dojo/tools/defender_for_cloud/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/defender_for_cloud/parser.py b/dojo/tools/defender_for_cloud/parser.py new file mode 100644 index 00000000000..4916f84d699 --- /dev/null +++ b/dojo/tools/defender_for_cloud/parser.py @@ -0,0 +1,404 @@ +import json +import re +from contextlib import suppress +from datetime import datetime + +from dojo.models import Finding + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Assessed resource types the connector imports: Defender Vulnerability Management emits the Azure* +# names, the older per-solution scanners used the unprefixed ones. +VULNERABILITY_RESOURCE_TYPES = frozenset({ + "ServerVulnerabilityTvm", + "AzureContainerRegistryVulnerability", + "AzureServerVulnerabilityAssessment", + "ContainerRegistryVulnerability", + "ServerVulnerabilityAssessment", +}) +CONTAINER_RESOURCE_TYPES = frozenset({ + "AzureContainerRegistryVulnerability", + "ContainerRegistryVulnerability", +}) +# Posture and configuration sub-assessments, explicitly excluded: they carry no CVEs. +POSTURE_RESOURCE_TYPES = frozenset({"SqlServerVulnerability", "GeneralVulnerability"}) + +# The status code for a resource with an open finding. +STATUS_UNHEALTHY = "Unhealthy" + +# Anchored, as the connector's own pattern is: a reference title is a CVE id or it is prose. +CVE_PATTERN = re.compile(r"^CVE-\d{4}-\d+$", re.IGNORECASE) + + +class DefenderForCloudParser: + + """ + Parses a Microsoft Defender for Cloud sub-assessments export. + + Mirrors pkg/tools/defendercloud/connector/finding_converter field for field so a file import and an + API sync deduplicate against each other instead of producing two copies of everything. + + Defender for Cloud returns every kind of sub-assessment through one endpoint - server + vulnerabilities, container-registry image vulnerabilities, SQL baselines, posture checks - and the + same field means different things in each. The vulnerable package lives under softwareDetails for a + container finding and in a flat softwareName for a server one, which is why component_name reads + both; see component_name(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. + return ["Microsoft Defender for Cloud - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Microsoft Defender for Cloud - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Microsoft Defender for Cloud sub-assessments export (JSON), the ARM " + "subAssessments response. Matches the scan type used by the Defender for Cloud connector " + "so file and API findings deduplicate. Only open vulnerability sub-assessments are " + "imported - SQL baselines and posture checks are not." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Microsoft Defender for Cloud Parser. + + Mirrors the connector's Convert: + - title: the display name, with the package appended when the name is a bare CVE. + - severity: Defender's own label; anything unrecognised is Info. + - severity_justification: the label and the CVSS base score it came with. + - description: the assessed resource, the image and digest for a container finding, the + package, the fixed version, then Defender's prose. + - mitigation: the version to update to, then Defender's own remediation text. + - impact: Defender's impact statement, imported as-is. + - component_name / component_version: the vulnerable package, read from either shape. + - unique_id_from_tool: the sub-assessment's ARM id, which is the whole deduplication hash. + """ + return [ + "title", + "severity", + "severity_justification", + "date", + "description", + "mitigation", + "impact", + "references", + "component_name", + "component_version", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Microsoft Defender for Cloud Parser. + + Copied from the Defender for Cloud block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with a hash of the unique id ALONE. The ARM sub-assessment id + already encodes the subscription, the resource and the finding, so it is the whole identity. + """ + return ["unique_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for row in self.rows(data): + if not self.is_open(row) or not self.is_vulnerability(row): + # Healthy sub-assessments are left out so a reimport closes them, and SQL/posture + # sub-assessments are not vulnerabilities at all. + continue + findings.append(self.build_finding(row, test)) + return findings + + def rows(self, data): + """ + Return the sub-assessments in the export. + + The ARM envelope nests them under "value"; a bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("value", "subAssessments"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + if "properties" in data: + return [data] + + msg = ( + "A Defender for Cloud export is the subAssessments response, a JSON object with a 'value' " + f"list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def properties(self, row): + value = row.get("properties") + return value if isinstance(value, dict) else {} + + def block(self, source, key): + value = source.get(key) + return value if isinstance(value, dict) else {} + + def additional(self, row): + return self.block(self.properties(row), "additionalData") + + def is_open(self, row): + """Only an Unhealthy sub-assessment is an open finding.""" + return str(self.block(self.properties(row), "status").get("code") or "") == STATUS_UNHEALTHY + + def is_vulnerability(self, row): + """ + Whether this sub-assessment is a vulnerability rather than a posture check. + + The resource type decides when it is one the connector knows. For an unfamiliar type the + presence of a CVE decides instead - a new Defender scanner should not be dropped silently, but + a configuration baseline should not arrive as a vulnerability either. + """ + resource_type = str(self.additional(row).get("assessedResourceType") or "") + if resource_type in VULNERABILITY_RESOURCE_TYPES: + return True + if resource_type in POSTURE_RESOURCE_TYPES: + return False + return bool(self.cve_ids(row)) + + def build_finding(self, row, test): + properties = self.properties(row) + raw_severity = str(self.block(properties, "status").get("severity") or "") + score, is_v3, found = self.best_cvss(row) + + finding = Finding( + test=test, + title=self.title(row), + severity=SEVERITY_BY_LABEL.get(raw_severity.lower(), DEFAULT_SEVERITY), + severity_justification=self.severity_justification(raw_severity, score, is_v3, found), + description=self.describe(row), + mitigation=self.mitigation(row), + impact=str(properties.get("impact") or ""), + references=self.references(row), + component_name=self.component_name(row) or None, + component_version=self.component_version(row) or None, + unique_id_from_tool=str(row.get("id") or "") or None, + vuln_id_from_tool=self.vuln_id(row), + # Defender reads an inventory of installed software and images; nothing is exercised. + static_finding=False, + dynamic_finding=True, + ) + + if identifiers := self.cve_ids(row): + finding.unsaved_vulnerability_ids = identifiers + # Only a v3 base goes in the v3 field; Defender also reports v2 scores. + if found and is_v3 and score > 0: + finding.cvssv3_score = score + if date := self.date(properties): + finding.date = date + return finding + + def vuln_id(self, row): + """The CVE, then the display name, then Defender's own finding id.""" + properties = self.properties(row) + vulnerability = self.block(self.additional(row), "vulnerabilityDetails") + for candidate in (vulnerability.get("cveId"), properties.get("displayName"), properties.get("id")): + value = str(candidate or "") + if value: + return value + return None + + def title(self, row): + """ + The display name, with the package appended when the name is a bare CVE. + + "CVE-2000-0001" alone says nothing about what is affected, and one CVE usually appears against + several packages on the same host. + """ + name = str(self.properties(row).get("displayName") or "") + if not name: + return self.vuln_id(row) or "" + package = self.component_name(row) + if package and name.upper().startswith("CVE-"): + return f"{name} in {package}" + return name + + def component_name(self, row): + """The container shape puts the package under softwareDetails; the server shape flattens it.""" + additional = self.additional(row) + if name := str(self.block(additional, "softwareDetails").get("packageName") or ""): + return name + return str(additional.get("softwareName") or "") + + def component_version(self, row): + additional = self.additional(row) + if version := str(self.block(additional, "softwareDetails").get("version") or ""): + return version + return str(additional.get("softwareVersion") or "") + + def fixed_version(self, row): + additional = self.additional(row) + if version := str(self.block(additional, "softwareDetails").get("fixedVersion") or ""): + return version + return str(additional.get("recommendedVersion") or "") + + def describe(self, row): + properties = self.properties(row) + additional = self.additional(row) + parts = [] + + if resource := self.resource_label(row): + parts.append(f"**Assessed resource:** {resource}\n") + + if str(additional.get("assessedResourceType") or "") in CONTAINER_RESOURCE_TYPES: + artifact = self.block(additional, "artifactDetails") + repository = str(artifact.get("repositoryName") or "") + if repository: + parts.append(f"**Image:** {artifact.get('registryHost') or ''}/{repository}\n") + if digest := str(artifact.get("digest") or ""): + parts.append(f"**Digest:** {digest}\n") + + if package := self.component_name(row): + parts.append(f"**Package:** {package} {self.component_version(row)}\n") + if fixed := self.fixed_version(row): + parts.append(f"**Fixed version:** {fixed}\n") + if description := str(properties.get("description") or ""): + parts.append(f"\n{description}\n") + return "".join(parts).rstrip("\n") + + def mitigation(self, row): + """ + The version to update to, then Defender's own remediation text. + + Both are kept because they answer different questions: the version says what to do, and + Defender's text says how - and neither is always present. + """ + parts = [] + package, fixed = self.component_name(row), self.fixed_version(row) + if package and fixed: + parts.append(f"Update {package} to {fixed} or later.") + if remediation := str(self.properties(row).get("remediation") or "").strip(): + parts.append(remediation) + return "\n\n".join(parts) + + def severity_justification(self, raw_severity, score, is_v3, found): + justification = f"Defender for Cloud assigned severity **{raw_severity}**." + if found: + version = "v3" if is_v3 else "v2" + justification += f" CVSS {version} base score {score:.1f}." + return justification + + def references(self, row): + links = [] + vulnerability = self.block(self.additional(row), "vulnerabilityDetails") + for reference in vulnerability.get("references") or []: + if not isinstance(reference, dict): + continue + if link := str(reference.get("link") or ""): + links.append(link) + return "\n".join(links) + + def cve_ids(self, row): + """ + The CVE, the display name, the reference titles and the TVM CVE list. + + Every candidate is matched against an ANCHORED pattern, because a reference title is either a + CVE id or it is prose - a substring match would pull an id out of a sentence. + """ + properties = self.properties(row) + additional = self.additional(row) + vulnerability = self.block(additional, "vulnerabilityDetails") + + candidates = [vulnerability.get("cveId"), properties.get("displayName")] + candidates.extend( + reference.get("title") + for reference in vulnerability.get("references") or [] + if isinstance(reference, dict) + ) + candidates.extend(self.tvm_cve_titles(additional.get("cve"))) + + identifiers, seen = [], set() + for candidate in candidates: + value = str(candidate or "").strip() + if not CVE_PATTERN.match(value): + continue + if value.upper() not in seen: + seen.add(value.upper()) + identifiers.append(value) + return identifiers + + def tvm_cve_titles(self, source): + """ + Defender's TVM CVE field arrives as a list, a single object, or a bare string. + + The connector's decoder accepts all three, so a file that carries any of them reads the same. + """ + if isinstance(source, str): + return [source] + rows = source if isinstance(source, list) else [source] + titles = [] + for row in rows: + if isinstance(row, str): + titles.append(row) + elif isinstance(row, dict): + titles.append(str(row.get("title") or "")) + return titles + + def best_cvss(self, row): + """ + The highest CVSS base score reported, and whether it is v3. + + Defender reports a flat v3.0 score on some shapes and a version-keyed map on others, and the + highest wins - which is the connector's choice, so a file import grades the same way. + """ + additional = self.additional(row) + best, is_v3, found = 0.0, False, False + + flat = additional.get("cvssV30Score") + if isinstance(flat, int | float) and not isinstance(flat, bool) and flat > 0: + best, is_v3, found = float(flat), True, True + + scores = self.block(self.block(additional, "vulnerabilityDetails"), "cvss") + for version, entry in scores.items(): + if not isinstance(entry, dict): + continue + base = entry.get("base") + if not isinstance(base, int | float) or isinstance(base, bool) or base <= best: + continue + best, is_v3, found = float(base), str(version).startswith("3"), True + return best, is_v3, found + + def resource_label(self, row): + """ + The resource name, else the last segment of an ARM or native resource id. + + An ARM id is a path, and its last segment is the resource - printing the whole path would bury + the one part a reader needs. + """ + details = self.block(self.properties(row), "resourceDetails") + if name := str(details.get("ResourceName") or ""): + return name + for key in ("NativeResourceId", "id"): + candidate = str(details.get(key) or "") + if not candidate: + continue + index = candidate.rfind("/") + if 0 <= index < len(candidate) - 1: + return candidate[index + 1:] + return candidate + return "" + + def date(self, properties): + """Defender timestamps are RFC 3339; the connector keeps the first ten characters.""" + generated = str(properties.get("timeGenerated") or "") + if len(generated) < 10: + return None + with suppress(ValueError): + return datetime.strptime(generated[:10], "%Y-%m-%d").date() + return None diff --git a/dojo/tools/detectify/__init__.py b/dojo/tools/detectify/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/detectify/parser.py b/dojo/tools/detectify/parser.py new file mode 100644 index 00000000000..1389b3bd2a5 --- /dev/null +++ b/dojo/tools/detectify/parser.py @@ -0,0 +1,324 @@ +import json +import re +from contextlib import suppress +from datetime import datetime +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Statuses the connector treats as not worth importing. Note "accepted_risk" is NOT here: those are +# imported and flagged risk-accepted instead, so the acceptance is recorded rather than discarded. +IGNORED_STATUSES = frozenset({"patched", "false_positive"}) +STATUS_ACCEPTED_RISK = "accepted_risk" + +SEVERITY_MAP = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "information": "Info", + "info": "Info", + "informational": "Info", +} +DEFAULT_SEVERITY = "Info" + +# Detectify puts CVE identifiers in prose rather than a dedicated field, so they are extracted from +# every text field the connector scans. +CVE_PATTERN = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE) + + +class DetectifyParser: + + """ + Parses a Detectify vulnerabilities export. + + Mirrors pkg/tools/detectify/connector/finding_converter.go field for field so a file import and + an API sync deduplicate against each other instead of producing two copies of everything. + + Detectify is EASM and DAST, so every finding is dynamic and carries the affected host. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). Note it does NOT follow the + # " - Connectors Import" pattern the other connector scan types use. + return ["Detectify Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Detectify Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Detectify vulnerabilities export (JSON). Matches the scan type used by the " + "Detectify connector so file and API findings deduplicate. Findings Detectify records " + "as patched or false positive are not imported." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Detectify Parser. + + Mirrors the connector's Convert: + - title: Detectify's own title, then the definition's title, then the finding UUID. + - severity: Detectify's severity word; "information" is one of its spellings. + - description: host, location, scan source and status, then the definition's description. + - impact: the definition's risk text. + - mitigation: the reference links, when there are any. + - references: the reference names and links as a markdown list. + - cwe: Detectify reports this as a plain integer. + - cvssv3 / cvssv3_score: the CVSS 3.1 block, falling back to 3.0. + - risk_accepted: set when Detectify's status is accepted_risk. + - unique_id_from_tool: the finding UUID. + - vuln_id_from_tool: the definition's title, which is Detectify's stable rule name. + """ + return [ + "title", + "severity", + "date", + "description", + "impact", + "mitigation", + "references", + "cwe", + "cvssv3", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "risk_accepted", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Detectify Parser. + + Copied from the Detectify block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Diverging would stop file findings + merging with API-synced ones. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + rows = self.extract_rows(data) + + findings = {} + for row in rows: + if not isinstance(row, dict): + continue + if self.is_ignored(row): + continue + finding = self.build_finding(row, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_rows(self, data): + """Detectify's list endpoint nests the results under "vulnerabilities".""" + if isinstance(data, list): + return data + if isinstance(data, dict) and isinstance(data.get("vulnerabilities"), list): + return data["vulnerabilities"] + msg = ( + "A Detectify export is a JSON object with a 'vulnerabilities' list, or a bare array of " + f"vulnerabilities; got {type(data).__name__}." + ) + raise TypeError(msg) + + def is_ignored(self, row): + """ + Skip findings Detectify has closed out. + + Only patched and false-positive are skipped. An accepted risk is still imported, because + discarding it would lose the record that somebody accepted it; it is flagged instead. + """ + return (row.get("status") or "").strip().lower() in IGNORED_STATUSES + + def build_finding(self, row, test): + definition = row.get("definition") if isinstance(row.get("definition"), dict) else None + score, vector = self.cvss(row.get("cvss_scores")) + + finding = Finding( + test=test, + title=self.title(row, definition), + severity=SEVERITY_MAP.get( + (row.get("severity") or "").strip().lower(), DEFAULT_SEVERITY, + ), + date=self.date(row.get("created_at")), + description=self.describe(row, definition), + impact=(definition or {}).get("risk") or None, + mitigation=self.mitigation(row) or None, + references=self.references(row) or None, + cwe=row.get("cwe") or 0, + cvssv3=vector or None, + cvssv3_score=score or None, + unique_id_from_tool=row.get("uuid"), + vuln_id_from_tool=(definition or {}).get("title") or None, + active=True, + # Detectify is EASM and DAST: it probes running targets. + static_finding=False, + dynamic_finding=True, + ) + finding.unsaved_tags = self.tags(row) + + cves = self.cves(row, definition) + if cves: + finding.unsaved_vulnerability_ids = cves + if (row.get("status") or "").strip().lower() == STATUS_ACCEPTED_RISK: + finding.risk_accepted = True + + self.attach_endpoint(finding, row) + return finding + + def title(self, row, definition): + if row.get("title"): + return row["title"] + if definition and definition.get("title"): + return definition["title"] + return f"Detectify finding {row.get('uuid')}" + + def describe(self, row, definition): + """The connector's writeField() lines, then the definition's prose on its own line.""" + lines = [ + f"**{label}:** {value}" for label, value in ( + ("Host", row.get("host")), + ("Location", row.get("location")), + ("Scan source", row.get("scan_source")), + ("Status", row.get("status")), + ) if value + ] + if definition and definition.get("description"): + lines.append(definition["description"]) + return "\n".join(lines).strip() + + def mitigation(self, row): + """ + Detectify supplies no remediation text, only reference links. + + The connector points at them rather than leaving the field empty. + """ + links = [ + ref["link"] for ref in self.references_list(row) + if isinstance(ref, dict) and ref.get("link") + ] + if not links: + return "" + return "See references:\n" + "\n".join(links) + + def references(self, row): + lines = [] + for ref in self.references_list(row): + if not isinstance(ref, dict): + continue + name, link = ref.get("name") or "", ref.get("link") or "" + if name and link: + lines.append(f"- {name}: {link}") + elif link: + lines.append(f"- {link}") + elif name: + lines.append(f"- {name}") + return "\n".join(lines) + + def references_list(self, row): + refs = row.get("references") + return refs if isinstance(refs, list) else [] + + def cvss(self, scores): + """ + Prefer the CVSS 3.1 block, falling back to 3.0. + + A block counts as present when it has either a score or a vector, so a vector-only entry is + not skipped. Detectify also reports a 2.0 block, which the connector ignores because + Finding.cvssv3 is a v3 field. + """ + if not isinstance(scores, dict): + return 0, "" + for key in ("cvss_3_1", "cvss_3_0"): + score = scores.get(key) + if isinstance(score, dict) and ((score.get("score") or 0) > 0 or score.get("vector")): + return score.get("score") or 0, score.get("vector") or "" + return 0, "" + + def cves(self, row, definition): + """ + Extract CVE identifiers from prose. + + Detectify has no dedicated CVE field, so the connector scans the title, the definition's + title, description and risk text, and every reference name and link. + """ + sources = [row.get("title") or ""] + if definition: + sources.extend([ + definition.get("title") or "", + definition.get("description") or "", + definition.get("risk") or "", + ]) + for ref in self.references_list(row): + if isinstance(ref, dict): + sources.extend([ref.get("name") or "", ref.get("link") or ""]) + + found, seen = [], set() + for source in sources: + for cve in CVE_PATTERN.findall(str(source)): + upper = cve.upper() + if upper not in seen: + seen.add(upper) + found.append(upper) + return found + + def tags(self, row): + tags = [ + tag["name"] for tag in (row.get("tags") or []) + if isinstance(tag, dict) and tag.get("name") + ] + if row.get("scan_source"): + tags.append(row["scan_source"]) + return tags + + def attach_endpoint(self, finding, row): + """ + Record where the finding is, in the connector's order of preference. + + The request URL is best; otherwise the host, with the location appended when it is a path; + otherwise the location alone. + """ + request = row.get("request") if isinstance(row.get("request"), dict) else None + host, location = row.get("host") or "", row.get("location") or "" + + if request and request.get("url"): + self.add_location(finding, request["url"]) + elif host: + self.add_location(finding, host + location if location.startswith("/") else host) + elif location: + self.add_location(finding, location) + + def add_location(self, finding, raw): + """ + Record one location. + + A bare host has no scheme, so it is parsed as a protocol-relative reference to keep the value + in the host field rather than the path - the same reason the connector prefixes "//". + """ + with suppress(ValueError): + parsed = urlparse(raw if "//" in raw else f"//{raw}") + if not parsed.hostname: + return + data = {"host": parsed.hostname, "protocol": parsed.scheme or None, "port": parsed.port} + path = parsed.path or None + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(path=path, **data)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(path=path.lstrip("/") if path else None, **data)) + + def date(self, timestamp): + """An RFC3339 created_at as a date; the connector leaves it unset when it will not parse.""" + if not timestamp: + return None + with suppress(ValueError): + return datetime.fromisoformat(str(timestamp).strip()).date() + return None diff --git a/dojo/tools/dragos/__init__.py b/dojo/tools/dragos/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/dragos/parser.py b/dojo/tools/dragos/parser.py new file mode 100644 index 00000000000..dc88c9ce0bb --- /dev/null +++ b/dojo/tools/dragos/parser.py @@ -0,0 +1,342 @@ +import json +import re +from contextlib import suppress + +from dojo.models import Finding + +# The advisory identifiers the connector's shared extractor recognises in free text. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + + +class DragosParser: + + """ + Parses a Dragos Platform vulnerability-detection export. + + Mirrors pkg/tools/dragos/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Dragos covers OT assets, and a detection carries the asset it was found on rather than pointing + at one - so the whole export is a single list and no join is needed. The OT context that makes a + finding actionable (the Purdue level, the zone, the vendor and model of the device, and whether + the flaw is known to be exploited in the wild) is what the description and the severity + justification carry; see describe() and justification(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Dragos Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Dragos Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Dragos Platform vulnerability-detection export (JSON), each detection carrying " + "the OT asset it was found on. Matches the scan type used by the Dragos connector so file " + "and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Dragos Parser. + + Mirrors the connector's Convert: + - title: the vulnerability title, then the Dragos advisory id, then the internal id. + - severity: the CVSS base score, falling back to Dragos's own 0-5 scale; see severity(). + - severity_justification: Dragos's OT exploitability context; see justification(). + - description: the summary, the advisory, and the asset's vendor, model, firmware, zone, + Purdue level and addresses. + - mitigation: Dragos's mitigations, one per line. + - component_name / component_version: the OT asset and its firmware version. + - unsaved_vulnerability_ids: advisory ids found in the reference, enumeration and title. + - unique_id_from_tool: "dragos--". + """ + return [ + "title", + "severity", + "severity_justification", + "description", + "mitigation", + "cvssv3_score", + "component_name", + "component_version", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Dragos Parser. + + Copied from the Dragos block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The asset is the component, so the + same flaw on two devices stays two findings - which is the point in an OT estate, where the + two devices may sit at different Purdue levels. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(row, test) for row in self.detections(data)] + + def detections(self, data): + """ + Return the detections in the export. + + Dragos pages its detections endpoint as {"content": [...]}, so that is the shape a saved + export has. A bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("content", "detections", "data", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A Dragos export is the detections response, a JSON object with a 'content' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def block(self, row, key): + value = row.get(key) + return value if isinstance(value, dict) else {} + + def build_finding(self, row, test): + vuln = self.block(row, "vulnerability") + host = self.block(row, "host") + asset = self.asset_name(host) + + finding = Finding( + test=test, + title=self.title(vuln), + severity=self.severity(vuln), + description=self.describe(vuln, host, asset), + mitigation=self.mitigation(vuln), + component_name=asset or None, + component_version=str(self.firmware(host) or "") or None, + unique_id_from_tool=f"dragos-{vuln.get('id') or ''}-{host.get('id') or ''}", + vuln_id_from_tool=self.vuln_id(vuln), + # Dragos matches an asset inventory against advisories; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.cvssv3_score = self.base_score(vuln) + finding.unsaved_tags = self.tags(vuln, host) + + if identifiers := self.vulnerability_ids(vuln): + finding.unsaved_vulnerability_ids = identifiers + if justification := self.justification(vuln): + finding.severity_justification = justification + return finding + + def vuln_id(self, vuln): + """The Dragos advisory id, then the enumeration, the reference and the internal id.""" + for key in ("report_id", "enumeration", "reference", "id"): + if value := str(vuln.get(key) or "").strip(): + return value + return None + + def title(self, vuln): + for key in ("title", "report_id"): + if str(vuln.get(key) or "").strip(): + return str(vuln.get(key)) + return f"Dragos vulnerability {vuln.get('id') or ''}" + + def severity(self, vuln): + """ + The CVSS base score decides; Dragos's own 0-5 scale is the fallback. + + CVSS is the most portable signal Dragos exposes. Its own scale runs the other way from a + score - 5 is the most severe - so reading one as the other would inverte the whole ladder. + """ + if (score := self.base_score(vuln)) > 0: + if score >= 9.0: + return "Critical" + if score >= 7.0: + return "High" + if score >= 4.0: + return "Medium" + return "Low" + + level = self.flex_float(vuln.get("severity")) + if level >= 5: + return "Critical" + if level >= 4: + return "High" + if level >= 3: + return "Medium" + if level >= 2: + return "Low" + return "Info" + + def base_score(self, vuln): + return self.flex_float(self.block(vuln, "score").get("base")) + + def asset_name(self, host): + """ + The OT asset: its name, then its first hostname, then its first address, then its id. + + An OT device often has no name and no hostname at all, which is why the address is a + fallback - a finding with no component would lose which device has to be patched. The + hostname is only used when it is not blank; the address is used whenever the list has an + entry at all, which is the connector's own asymmetry. + """ + if value := str(host.get("name") or "").strip(): + return value + + hostnames = self.items(host.get("hostname")) + if hostnames and hostnames[0].strip(): + return hostnames[0] + + addresses = self.items(host.get("ip")) + if addresses: + return addresses[0] + return str(host.get("id") or "") + + def firmware(self, host): + return self.block(self.block(host, "hardware"), "firmware").get("version") + + def vendor(self, host): + """The asset's own vendor, falling back to the vendor of its hardware.""" + if value := str(host.get("vendor") or "").strip(): + return value + return str(self.block(host, "hardware").get("vendor") or "") + + def purdue_level(self, host): + """ + The Purdue (PERA) level, which is ABSENT rather than zero when Dragos does not know it. + + Level 0 is the physical process layer - a real level, and the most sensitive one - so an + absent level and level 0 must not render alike. + """ + value = host.get("pera_level") + if value is None: + return "" + return str(self.flex_int(value)) + + def describe(self, vuln, host, asset): + lines = [] + + def write(label, value): + if str(value or "").strip(): + lines.append(f"**{label}:** {value}") + + hardware = self.block(host, "hardware") + write("Summary", vuln.get("summary")) + write("Description", vuln.get("description")) + write("Dragos advisory", vuln.get("report_id")) + write("Asset", asset) + write("Vendor", self.vendor(host)) + write("Model", hardware.get("model")) + write("Firmware", self.firmware(host)) + write("Zone", self.block(host, "zone").get("name")) + write("Purdue level", self.purdue_level(host)) + write("IP", ", ".join(self.items(host.get("ip")))) + return "\n".join(lines).strip() + + def mitigation(self, vuln): + """Dragos's mitigations, one per line, exactly as it lists them.""" + return "\n".join(self.items(vuln.get("mitigations"))) or None + + def justification(self, vuln): + """ + Dragos's OT exploitability context and its own risk score. + + In an OT estate this is the difference between a flaw that has to be handled in the next + maintenance window and one that can wait for the next outage, so it is recorded as the + severity justification rather than being used to move the severity. + """ + intel = self.block(vuln, "intel") + reasons = [] + if intel.get("active_exploit"): + reasons.append("actively exploited") + if intel.get("poc_exists"): + reasons.append("public proof of concept exists") + if intel.get("remotely_exploitable"): + reasons.append("remotely exploitable") + if (score := self.flex_float(vuln.get("dragos_score"))) > 0: + reasons.append(f"Dragos risk score {self.number(score)}") + + if not reasons: + return "" + return "Dragos OT context: " + "; ".join(reasons) + "." + + def tags(self, vuln, host): + """The asset's vendor, model, zone and type, plus OT and exploited flags, for filtering.""" + hardware = self.block(host, "hardware") + tags = [ + value + for value in ( + self.vendor(host), + str(hardware.get("model") or ""), + str(self.block(host, "zone").get("name") or ""), + str(host.get("type") or ""), + ) + if value.strip() + ] + if host.get("is_ot"): + tags.append("ot-asset") + if self.block(vuln, "intel").get("active_exploit"): + tags.append("active-exploit") + return tags + + def vulnerability_ids(self, vuln): + """ + Advisory ids read out of the reference, the enumeration and the title. + + The connector's shared extractor joins those three with "|", matches them all, then SORTS and + drops case-insensitive duplicates - so the order here is alphabetical rather than the order + they appear in, unlike the connectors that use the order-preserving call. + """ + text = "|".join(str(vuln.get(key) or "") for key in ("reference", "enumeration", "title")) + matches = sorted(VULNERABILITY_ID_PATTERN.findall(text)) + + identifiers = [] + for match in matches: + # Adjacent-only dedupe after the sort, which is what slices.CompactFunc does. + if not identifiers or identifiers[-1].lower() != match.lower(): + identifiers.append(match) + return identifiers + + def items(self, value): + """ + The list as strings, keeping blanks. + + The connector joins these lists without filtering, so dropping a blank entry here would + render a different description than an API sync of the same data. + """ + if not isinstance(value, list): + return [] + return [str(item) if item is not None else "" for item in value] + + def number(self, value): + """Shortest round-tripping form, as Go's FormatFloat(v, 'f', -1, 64) produces.""" + if value == int(value): + return str(int(value)) + return repr(value) + + def flex_float(self, value): + """Dragos's numbers may arrive as JSON numbers or quoted strings.""" + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 + + def flex_int(self, value): + return int(self.flex_float(value)) diff --git a/dojo/tools/elastic_security_cnvm/__init__.py b/dojo/tools/elastic_security_cnvm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/elastic_security_cnvm/parser.py b/dojo/tools/elastic_security_cnvm/parser.py new file mode 100644 index 00000000000..e05d683b64b --- /dev/null +++ b/dojo/tools/elastic_security_cnvm/parser.py @@ -0,0 +1,459 @@ +import json +import re +from contextlib import suppress +from datetime import datetime +from ipaddress import ip_address + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Elastic's severity labels. Anything outside this set is NOT graded by resemblance - each scan type +# falls back differently, which is why severity_from_string reports whether it recognised the label. +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "informational": "Info", + "info": "Info", + "none": "Info", + "unknown": "Info", +} + +CVSS_CRITICAL_FLOOR = 9.0 +CVSS_HIGH_FLOOR = 7.0 +CVSS_MEDIUM_FLOOR = 4.0 + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class ElasticSecurityDocuments: + + """ + Shared reading of an Elasticsearch search response for the three Elastic Security scan types. + + Elastic returns every one of them - CNVM vulnerabilities, posture evaluations and detection alerts + - as documents from the same _search API, with the same ECS asset, host and cloud objects + describing where the finding sits. The three parsers therefore share the document walk and the + asset rendering, and differ only in which documents they claim and how they map them. Kept here, + on the CNVM parser's module, the same way the shipped Invicti parser extends the Netsparker one. + """ + + def documents(self, data): + """ + Return the documents in the export. + + An Elasticsearch search response nests them under hits.hits; a bare array of documents and a + single document are accepted too. + """ + if isinstance(data, list): + return [doc for doc in data if isinstance(doc, dict)] + if isinstance(data, dict): + hits = data.get("hits") + if isinstance(hits, dict) and isinstance(hits.get("hits"), list): + return [doc for doc in hits["hits"] if isinstance(doc, dict)] + if isinstance(hits, list): + return [doc for doc in hits if isinstance(doc, dict)] + if "_source" in data: + return [data] + + msg = ( + "An Elastic Security export is an Elasticsearch search response, a JSON object with " + f"hits.hits documents; got {type(data).__name__}." + ) + raise TypeError(msg) + + def source(self, doc): + source = doc.get("_source") + return source if isinstance(source, dict) else None + + def block(self, source, key): + value = source.get(key) + return value if isinstance(value, dict) else {} + + def strings(self, value): + """A list of non-empty strings, tolerating a single string.""" + if isinstance(value, str): + return [value.strip()] if value.strip() else [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item or "").strip()] + return [] + + def severity_from_string(self, value): + """ + Grade an Elastic severity label. + + Returns (severity, recognised). An unrecognised label is NOT guessed at: the caller decides + the fallback, because a CNVM document can be graded from its CVSS score while a posture or + detection document has nothing else to go on. + """ + label = str(value or "").strip().lower() + if label in SEVERITY_BY_LABEL: + return SEVERITY_BY_LABEL[label], True + return "Info", False + + def asset_name(self, source): + """The cloud resource, then the Kubernetes pod, then the host.""" + if name := str(self.block(source, "resource").get("name") or "").strip(): + return name + pod = self.block(self.block(source, "kubernetes"), "pod") + if name := str(pod.get("name") or "").strip(): + return name + return self.host_identity(source) + + def host_identity(self, source): + host = self.block(source, "host") + for key in ("name", "hostname"): + if value := str(host.get(key) or "").strip(): + return value + return "" + + def asset_lines(self, source): + """The ECS context lines every Elastic Security finding carries, in the connector's order.""" + lines = [ + self.resource_line(source), + self.host_line(source), + self.os_line(source), + self.cluster_line(source), + self.cloud_line(source), + ] + return [line for line in lines if line] + + def resource_line(self, source): + resource = self.block(source, "resource") + name = str(resource.get("name") or "").strip() + if not name: + return "" + line = f"**Resource:** {name}" + kind = str(resource.get("type") or "").strip() + if not kind: + return line + line += f" ({kind}" + if sub_type := str(resource.get("sub_type") or "").strip(): + line += f"/{sub_type}" + return line + ")" + + def host_line(self, source): + host = self.host_identity(source) + return f"**Host:** {host}" if host else "" + + def os_line(self, source): + operating_system = self.os_description(source) + return f"**OS:** {operating_system}" if operating_system else "" + + def os_description(self, source): + """Elastic's full OS string when it has one, else the name and version.""" + operating_system = self.block(self.block(source, "host"), "os") + if full := str(operating_system.get("full") or "").strip(): + return full + name = str(operating_system.get("name") or "") + version = str(operating_system.get("version") or "") + return f"{name} {version}".strip() + + def cluster_identity(self, source): + orchestrator = self.block(source, "orchestrator") + if name := str(self.block(orchestrator, "cluster").get("name") or "").strip(): + return name + return str(orchestrator.get("cluster_name") or "").strip() + + def cluster_line(self, source): + cluster = self.cluster_identity(source) + if not cluster: + return "" + line = f"**Cluster:** {cluster}" + if namespace := str(self.block(source, "kubernetes").get("namespace") or "").strip(): + line += f", namespace {namespace}" + return line + + def cloud_line(self, source): + cloud = self.block(source, "cloud") + if not cloud: + return "" + bits = [] + if provider := str(cloud.get("provider") or "").strip(): + bits.append(provider) + if account := str(self.block(cloud, "account").get("name") or "").strip(): + bits.append(f"account {account}") + if region := str(cloud.get("region") or "").strip(): + bits.append(region) + return "**Cloud:** " + ", ".join(bits) if bits else "" + + def cloud_tags(self, source): + tags = [] + cloud = self.block(source, "cloud") + if provider := str(cloud.get("provider") or "").strip(): + tags.append(provider) + if region := str(cloud.get("region") or "").strip(): + tags.append(region) + if cluster := self.cluster_identity(source): + tags.append(f"cluster:{cluster}") + return tags + + def dedupe(self, values): + """ + Sort and deduplicate, as the connector does. + + Worth mirroring rather than tidying: a tag set that differs only in order still reads as a + change on every reimport. + """ + return sorted({str(value).strip() for value in values if str(value or "").strip()}) + + def date_only(self, timestamp): + """Elastic timestamps are RFC 3339; only the date is kept.""" + text = str(timestamp or "").strip() + if not text: + return None + with suppress(ValueError): + return datetime.strptime(text.split("T")[0], "%Y-%m-%d").date() + return None + + def attach_asset(self, finding, source): + """ + Record the host the document came from. + + Elastic reports on machines and cloud resources rather than URLs, so the endpoint is the host + identity, falling back to the resource or pod name. + """ + name = self.host_identity(source) or self.asset_name(source) + if not name or not self.usable_host(name): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(host=name)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=name)) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def flex_float(self, value): + """Elastic sends these numbers as either a number or a numeric string.""" + if value is None or isinstance(value, bool): + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + trimmed = value.strip().strip('"') + if trimmed in {"", "null"}: + return 0.0 + with suppress(ValueError): + return float(trimmed) + return 0.0 + + def render_number(self, value): + """Render a float the way the connector does - 7.5 stays 7.5, but 9.0 prints as 9.""" + return str(int(value)) if value == int(value) else repr(value) + + +class ElasticSecurityCnvmParser(ElasticSecurityDocuments): + + """ + Parses an Elastic Security export, importing Cloud Native Vulnerability Management findings. + + Mirrors the vulnerability half of pkg/tools/elasticsecurity/connector/converter field for field so + a file import and an API sync deduplicate against each other instead of producing two copies of + everything. Posture evaluations and detection alerts in the same export are separate scan types - + see the Elastic Security Posture and Detections parsers - because Elastic models them as different + kinds of data and the connector imports each behind its own toggle. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeVulnerabilities. + return ["Elastic Security:CNVM - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Elastic Security:CNVM - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Elastic Security export (JSON) and report its Cloud Native Vulnerability " + "Management findings. Matches the scan type used by the Elastic Security connector so " + "file and API findings deduplicate. Posture and detection documents in the same export " + "are imported by the Elastic Security Posture and Detections parsers." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Elastic Security CNVM Parser. + + Mirrors the connector's vulnerabilityFinding: + - title: " - on ". + - severity: Elastic's own label, falling back to the CVSS score; see severity(). + - description: the CVE description, the package, the ECS asset context and the CVSS score. + - mitigation: the fixed version when Elastic reported one. + - component_name / component_version: the vulnerable package. + - unique_id_from_tool: the Elasticsearch document id. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "references", + "component_name", + "component_version", + "cvssv3_score", + "publish_date", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Elastic Security CNVM Parser. + + Copied from the Elastic Security CNVM block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for doc in self.documents(data): + source = self.source(doc) + if source is None: + continue + vulnerability = self.block(source, "vulnerability") + if not str(vulnerability.get("id") or "").strip(): + # Not a CNVM document, or one Elastic could not attach to a CVE. + continue + findings.append(self.build_finding(doc, source, vulnerability, test)) + return findings + + def build_finding(self, doc, source, vulnerability, test): + identifier = str(vulnerability["id"]).strip() + package = self.block(source, "package") + score = self.block(vulnerability, "score") + base = self.flex_float(score.get("base")) if score else 0.0 + + finding = Finding( + test=test, + title=self.title(source, vulnerability, package, identifier), + severity=self.severity(vulnerability, score, base), + description=self.describe(source, vulnerability, package, score, base), + mitigation=self.mitigation(package), + references=str(vulnerability.get("reference") or "").strip(), + component_name=str(package.get("name") or "").strip() or None, + component_version=str(package.get("version") or "").strip() or None, + unique_id_from_tool=self.unique_id(doc, source, vulnerability, package, identifier), + vuln_id_from_tool=identifier, + # CNVM reads a workload's package inventory; nothing is exercised. + static_finding=True, + dynamic_finding=False, + ) + finding.unsaved_vulnerability_ids = [identifier] + finding.unsaved_tags = self.dedupe(["vulnerability", "cnvm", *self.cloud_tags(source)]) + + if date := self.date_only(source.get("@timestamp")): + finding.date = date + if published := self.date_only(vulnerability.get("published_date")): + finding.publish_date = published + # Only a v3 vector's base goes in the v3 field; Elastic also reports v2 scores. + if base > 0 and str(score.get("version") or "").startswith("3"): + finding.cvssv3_score = base + + self.attach_asset(finding, source) + return finding + + def title(self, source, vulnerability, package, identifier): + title = identifier + if name := str(package.get("name") or "").strip(): + title += f" - {name}" + if version := str(package.get("version") or "").strip(): + title += f" {version}" + if asset := self.asset_name(source): + title += f" on {asset}" + return title + + def unique_id(self, doc, source, vulnerability, package, identifier): + """ + The Elasticsearch document id, which is stable across syncs. + + Only a hand-assembled export lacks one; then the asset, the CVE and the package stand in. + """ + if document_id := str(doc.get("_id") or "").strip(): + return document_id + parts = [self.asset_name(source), identifier] + if package: + parts.extend([str(package.get("name") or ""), str(package.get("version") or "")]) + return ":".join(parts) + + def severity(self, vulnerability, score, base): + """ + Elastic's own label wins; an unrecognised one falls back to the CVSS score. + + A CNVM document has a score to fall back on, which is why an unknown label is not simply + Medium here as it is for the other two scan types. + """ + severity, recognised = self.severity_from_string(vulnerability.get("severity")) + if recognised: + return severity + if not score: + return "Info" + if base >= CVSS_CRITICAL_FLOOR: + return "Critical" + if base >= CVSS_HIGH_FLOOR: + return "High" + if base >= CVSS_MEDIUM_FLOOR: + return "Medium" + if base > 0: + return "Low" + return "Info" + + def describe(self, source, vulnerability, package, score, base): + parts = [] + if description := str(vulnerability.get("description") or "").strip(): + parts.append(description) + + if name := str(package.get("name") or "").strip(): + line = f"**Package:** {name}" + if version := str(package.get("version") or "").strip(): + line += f" {version}" + if kind := str(package.get("type") or "").strip(): + line += f" ({kind})" + parts.append(line) + + parts.extend(self.asset_lines(source)) + + if base > 0: + line = f"**CVSS:** {self.render_number(base)}" + if version := str(score.get("version") or "").strip(): + line += f" (v{version})" + parts.append(line) + return "\n\n".join(parts) + + def mitigation(self, package): + if fix := str(package.get("fixed_version") or "").strip(): + name = str(package.get("name") or "").strip() or "the affected package" + return ( + f"Upgrade {name} to {fix} or later, then rebuild and redeploy the affected workload " + "image." + ) + return ( + "No fixed version is published for this CVE yet. Track the vendor advisory, and mitigate " + "by reducing the workload's exposure in the meantime." + ) diff --git a/dojo/tools/elastic_security_detections/__init__.py b/dojo/tools/elastic_security_detections/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/elastic_security_detections/parser.py b/dojo/tools/elastic_security_detections/parser.py new file mode 100644 index 00000000000..bf511023553 --- /dev/null +++ b/dojo/tools/elastic_security_detections/parser.py @@ -0,0 +1,194 @@ +import json + +from dojo.models import Finding + +# The shared document walk and ECS asset rendering live on the CNVM parser's module, the same way the +# shipped Invicti parser extends the Netsparker one. Elastic returns all three scan types from the same +# _search API with the same asset objects. +from dojo.tools.elastic_security_cnvm.parser import ElasticSecurityDocuments + +# A detection has no score to fall back on, so an unrecognised severity label is Medium. +UNRECOGNISED_SEVERITY = "Medium" + +# Verbatim from the connector. A detection is not a defect with a patch, and saying so keeps a triage +# queue from being read as a remediation backlog. +DETECTION_MITIGATION = ( + "Triage this detection in Elastic Security. Detections describe observed activity rather than a " + "fixable defect, so closing it means completing an investigation, not shipping a patch." +) + + +class ElasticSecurityDetectionsParser(ElasticSecurityDocuments): + + """ + Parses an Elastic Security export, importing detection-engine alerts. + + Mirrors the detection half of pkg/tools/elasticsecurity/connector/converter field for field so a + file import and an API sync deduplicate against each other instead of producing two copies of + everything. CNVM vulnerabilities and posture evaluations in the same export are separate scan types + - see the Elastic Security CNVM and Posture parsers - because Elastic models them as different + kinds of data and the connector imports each behind its own toggle. + + Detections are event-stream data rather than remediable weaknesses, which is why they are imported + neither as static nor as dynamic findings and carry a triage instruction instead of a fix. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeDetections. + return ["Elastic Security:Detections - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Elastic Security:Detections - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Elastic Security export (JSON) and report its detection-engine alerts. Matches " + "the scan type used by the Elastic Security connector so file and API findings " + "deduplicate. CNVM and posture documents in the same export are imported by the Elastic " + "Security CNVM and Posture parsers." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Elastic Security Detections Parser. + + Mirrors the connector's detectionFinding: + - title: the detection rule's name, falling back to the alert's own reason. + - severity: the alert's label, then the rule's; an unrecognised one is Medium. + - description: the reason, the rule description, the message, the ECS asset context, the risk + score, the workflow status and the event category. + - mitigation: a triage instruction, not a fix. + - unique_id_from_tool: the Elasticsearch document id, falling back to the alert uuid. + - vuln_id_from_tool: the rule uuid, which is what the deduplication hash keys on. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "references", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Elastic Security Detections Parser. + + Copied from the Elastic Security detections block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for doc in self.documents(data): + source = self.source(doc) + if source is None: + continue + alert = self.alert(source) + if alert is None: + # Not a detection document. + continue + if not self.title(alert): + # An alert with neither a rule name nor a reason says nothing. + continue + findings.append(self.build_finding(doc, source, alert, test)) + return findings + + def alert(self, source): + """ + The alert, from either place Elastic puts one. + + Current Elastic nests it under kibana.alert; the older detection engine wrote a top-level + signal object, and an export from an existing index may still carry either. + """ + alert = self.block(self.block(source, "kibana"), "alert") + if alert: + return alert + signal = source.get("signal") + return signal if isinstance(signal, dict) else None + + def build_finding(self, doc, source, alert, test): + rule = self.block(alert, "rule") + severity, recognised = self.severity_from_string(self.severity_label(alert, rule)) + + finding = Finding( + test=test, + title=self.title(alert), + severity=severity if recognised else UNRECOGNISED_SEVERITY, + description=self.describe(source, alert, rule), + mitigation=DETECTION_MITIGATION, + unique_id_from_tool=self.unique_id(doc, alert), + # A detection is observed activity: neither a static nor a dynamic test found it. + static_finding=False, + dynamic_finding=False, + ) + finding.unsaved_tags = self.tags(source, alert, rule) + + if rule: + finding.vuln_id_from_tool = str(rule.get("uuid") or "").strip() or None + finding.references = "\n".join(self.strings(rule.get("references"))) + if date := self.date_only(source.get("@timestamp")): + finding.date = date + + self.attach_asset(finding, source) + return finding + + def title(self, alert): + """The rule's name, falling back to the alert's own reason.""" + rule = self.block(alert, "rule") + if name := str(rule.get("name") or "").strip(): + return name + return str(alert.get("reason") or "").strip() + + def severity_label(self, alert, rule): + """The alert's severity, then the rule's.""" + if severity := str(alert.get("severity") or "").strip(): + return severity + return str(rule.get("severity") or "").strip() + + def unique_id(self, doc, alert): + if document_id := str(doc.get("_id") or "").strip(): + return document_id + return str(alert.get("uuid") or "").strip() or None + + def risk_score(self, alert, rule): + """Elastic's 0-100 risk score, from the alert or from the rule that raised it.""" + score = self.flex_float(alert.get("risk_score")) + if score > 0: + return score + return self.flex_float(rule.get("risk_score")) + + def describe(self, source, alert, rule): + parts = [] + if reason := str(alert.get("reason") or "").strip(): + parts.append(reason) + if description := str(rule.get("description") or "").strip(): + parts.append(description) + if message := str(source.get("message") or "").strip(): + parts.append(f"**Message:** {message}") + + parts.extend(self.asset_lines(source)) + + if score := self.risk_score(alert, rule): + parts.append(f"**Risk score:** {self.render_number(score)} (Elastic's 0-100 scale)") + if state := str(alert.get("workflow_status") or "").strip(): + parts.append(f"**Workflow status in Elastic:** {state}") + if categories := self.strings(self.block(source, "event").get("category")): + parts.append("**Event category:** " + ", ".join(categories)) + return "\n\n".join(parts) + + def tags(self, source, alert, rule): + tags = ["detection", "alert"] + tags.extend(self.strings(rule.get("tags"))) + event = self.block(source, "event") + tags.extend(self.strings(event.get("category"))) + tags.append(str(event.get("module") or "")) + tags.extend(self.cloud_tags(source)) + return self.dedupe(tags) diff --git a/dojo/tools/elastic_security_posture/__init__.py b/dojo/tools/elastic_security_posture/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/elastic_security_posture/parser.py b/dojo/tools/elastic_security_posture/parser.py new file mode 100644 index 00000000000..9dc1c852ba8 --- /dev/null +++ b/dojo/tools/elastic_security_posture/parser.py @@ -0,0 +1,195 @@ +import json + +from dojo.models import Finding + +# The shared document walk and ECS asset rendering live on the CNVM parser's module, the same way the +# shipped Invicti parser extends the Netsparker one. Elastic returns all three scan types from the same +# _search API with the same asset objects. +from dojo.tools.elastic_security_cnvm.parser import ElasticSecurityDocuments + +# The result.evaluation value the connector imports. A passed evaluation is not a finding. +EVALUATION_FAILED = "failed" + +# A posture document has no score to fall back on, so an unrecognised severity label is Medium. +UNRECOGNISED_SEVERITY = "Medium" + + +class ElasticSecurityPostureParser(ElasticSecurityDocuments): + + """ + Parses an Elastic Security export, importing the benchmark rules that failed evaluation. + + Mirrors the posture half of pkg/tools/elasticsecurity/connector/converter field for field so a file + import and an API sync deduplicate against each other instead of producing two copies of + everything. CNVM vulnerabilities and detection alerts in the same export are separate scan types - + see the Elastic Security CNVM and Detections parsers - because Elastic models them as different + kinds of data and the connector imports each behind its own toggle. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypePosture. + return ["Elastic Security:Posture - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Elastic Security:Posture - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Elastic Security export (JSON) and report the cloud and Kubernetes benchmark " + "rules that failed. Matches the scan type used by the Elastic Security connector so file " + "and API findings deduplicate. CNVM and detection documents in the same export are " + "imported by the Elastic Security CNVM and Detections parsers." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Elastic Security Posture Parser. + + Mirrors the connector's postureFinding: + - title: the rule name. + - severity: Elastic's own label; an unrecognised one is Medium. + - description: the rationale, the description, the benchmark, the section, the ECS asset + context and the impact of remediating. + - mitigation: the rule's own remediation text. + - component_name / component_version: the benchmark the rule belongs to. + - unique_id_from_tool: the Elasticsearch document id. + - vuln_id_from_tool: the rule id, which is what the deduplication hash keys on. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "references", + "component_name", + "component_version", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Elastic Security Posture Parser. + + Copied from the Elastic Security posture block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Note it hashes vuln_id_from_tool - + the rule - rather than a component, because a benchmark rule is not about a package. + """ + return ["title", "severity", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for doc in self.documents(data): + source = self.source(doc) + if source is None: + continue + rule = self.block(source, "rule") + if not rule or not self.failed(source): + # Not a posture document, or a rule that passed. + continue + if not str(rule.get("name") or "").strip(): + # Without a name there is nothing to report. + continue + findings.append(self.build_finding(doc, source, rule, test)) + return findings + + def failed(self, source): + """Elastic writes "failed" or "passed"; only a failure is a finding.""" + evaluation = self.block(source, "result").get("evaluation") + return str(evaluation or "").strip().lower() == EVALUATION_FAILED + + def build_finding(self, doc, source, rule, test): + benchmark = self.block(rule, "benchmark") + severity, recognised = self.severity_from_string(rule.get("severity")) + + finding = Finding( + test=test, + title=str(rule["name"]).strip(), + severity=severity if recognised else UNRECOGNISED_SEVERITY, + description=self.describe(source, rule, benchmark), + mitigation=str(rule.get("remediation") or "").strip(), + references=str(rule.get("references") or "").strip(), + unique_id_from_tool=self.unique_id(doc, source, rule), + vuln_id_from_tool=self.rule_id(rule, benchmark), + # A benchmark rule is evaluated against configuration, not against a running request. + static_finding=True, + dynamic_finding=False, + ) + finding.unsaved_tags = self.tags(source, rule, benchmark) + + if name := str(benchmark.get("name") or "").strip(): + finding.component_name = name + finding.component_version = str(benchmark.get("version") or "").strip() or None + if date := self.date_only(source.get("@timestamp")): + finding.date = date + + self.attach_asset(finding, source) + return finding + + def unique_id(self, doc, source, rule): + """ + The Elasticsearch document id, which is stable across syncs. + + Only a hand-assembled export lacks one; then the asset and the rule stand in, which keeps the + same rule failing on two assets as two findings. + """ + if document_id := str(doc.get("_id") or "").strip(): + return document_id + return ":".join([ + self.asset_name(source), + str(rule.get("id") or ""), + str(rule.get("name") or ""), + ]) + + def rule_id(self, rule, benchmark): + """The rule id, then the benchmark's own numbering, then the rule name.""" + if identifier := str(rule.get("id") or "").strip(): + return identifier + if rule_number := str(benchmark.get("rule_number") or "").strip(): + return str(benchmark.get("id") or "") + ":" + rule_number + return str(rule.get("name") or "").strip() + + def describe(self, source, rule, benchmark): + parts = [] + rationale = str(rule.get("rationale") or "").strip() + if rationale: + parts.append(rationale) + description = str(rule.get("description") or "").strip() + # Elastic often repeats the rationale as the description; printing it twice reads as an error. + if description and description != rationale: + parts.append(description) + + parts.append("This benchmark rule **failed** evaluation.") + + if name := str(benchmark.get("name") or "").strip(): + line = f"**Benchmark:** {name}" + if version := str(benchmark.get("version") or "").strip(): + line += f" {version}" + if rule_number := str(benchmark.get("rule_number") or "").strip(): + line += f", rule {rule_number}" + parts.append(line) + + if section := str(rule.get("section") or "").strip(): + parts.append(f"**Section:** {section}") + + parts.extend(self.asset_lines(source)) + + if impact := str(rule.get("impact") or "").strip(): + parts.append(f"**Impact of remediation:** {impact}") + return "\n\n".join(parts) + + def tags(self, source, rule, benchmark): + tags = ["posture", "compliance", "configuration"] + if benchmark: + tags.extend([ + str(benchmark.get("name") or ""), + str(benchmark.get("posture_type") or ""), + ]) + tags.extend(self.strings(rule.get("tags"))) + tags.extend(self.cloud_tags(source)) + return self.dedupe(tags) diff --git a/dojo/tools/endorlabs/__init__.py b/dojo/tools/endorlabs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/endorlabs/parser.py b/dojo/tools/endorlabs/parser.py new file mode 100644 index 00000000000..d1986300b4f --- /dev/null +++ b/dojo/tools/endorlabs/parser.py @@ -0,0 +1,413 @@ +import json +from html.parser import HTMLParser + +from dojo.models import Finding + +# Endor Labs grades findings with these enum values; the connector clamps anything else to Info. +SEVERITY_MAP = { + "FINDING_LEVEL_CRITICAL": "Critical", + "FINDING_LEVEL_HIGH": "High", + "FINDING_LEVEL_MEDIUM": "Medium", + "FINDING_LEVEL_LOW": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Reachability is Endor's headline signal, so the connector surfaces it as the finding impact rather +# than burying it in tags. The order here is the connector's: a function verdict outranks a +# dependency verdict, and a definite verdict outranks a "potentially". +REACHABILITY_SUMMARY = [ + ("FINDING_TAGS_REACHABLE_FUNCTION", "Reachable (vulnerable function is called)"), + ("FINDING_TAGS_UNREACHABLE_FUNCTION", "Unreachable (vulnerable function is not called)"), + ("FINDING_TAGS_POTENTIALLY_REACHABLE_FUNCTION", + "Potentially reachable (function reachability undetermined)"), + ("FINDING_TAGS_REACHABLE_DEPENDENCY", "Reachable (dependency is used)"), + ("FINDING_TAGS_UNREACHABLE_DEPENDENCY", "Unreachable (dependency is not used)"), + ("FINDING_TAGS_POTENTIALLY_REACHABLE_DEPENDENCY", + "Potentially reachable (dependency reachability undetermined)"), +] + +FINDING_TAG_PREFIX = "FINDING_TAGS_" +FINDING_CATEGORY_PREFIX = "FINDING_CATEGORY_" + +# Tags that only say "we did not determine this" carry no information. +UNSPECIFIED_SUFFIX = "_UNSPECIFIED" + +# Block-level tags the connector's HTML flattener turns into newlines. +BLOCK_TAGS = frozenset({ + "br", "p", "div", "li", "tr", "ul", "ol", "table", "blockquote", + "h1", "h2", "h3", "h4", "h5", "h6", +}) +# Tags whose text content is dropped entirely. +DROPPED_TAGS = frozenset({"script", "style"}) + +# Go's html.EscapeString, which the connector applies last. Python's html.escape differs on the +# apostrophe (' rather than '), so the replacement is spelled out to match byte for byte. +GO_HTML_ESCAPES = ( + ("&", "&"), + ("<", "<"), + (">", ">"), + ('"', """), + ("'", "'"), +) + + +class _HtmlFlattener(HTMLParser): + + """ + Mirror of the connector's htmlFlattener. + + Endor Labs advisory text arrives as HTML, and the connector flattens it rather than rendering it: + script and style content is dropped, block tags become newlines, and everything else becomes + plain text. + """ + + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self.skip_depth = 0 + + def handle_starttag(self, tag, attrs): + if tag in DROPPED_TAGS: + self.skip_depth += 1 + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_startendtag(self, tag, attrs): + # A self-closing script or style has no content to skip, so only the newline case applies. + if tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_endtag(self, tag): + if tag in DROPPED_TAGS: + self.skip_depth = max(0, self.skip_depth - 1) + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_data(self, data): + if self.skip_depth == 0: + self.parts.append(data) + + def text(self): + return "".join(self.parts) + + +def inert_text(raw_html): + """ + Flatten HTML to escaped plain text, as the connector's InertText does. + + Advisory text from Endor Labs is attacker-influenced in the sense that it comes from upstream + advisories, so it is never passed through as markup. + """ + if not raw_html: + return "" + flattener = _HtmlFlattener() + flattener.feed(raw_html) + flattener.close() + return go_escape(collapse_whitespace(flattener.text())) + + +def collapse_whitespace(text): + """Trim every line, collapse runs of blank lines to one, and drop trailing blanks.""" + out = [] + blank = True + for raw in text.split("\n"): + line = raw.strip() + if not line: + if not blank: + out.append("") + blank = True + continue + out.append(line) + blank = False + while out and not out[-1]: + out.pop() + return "\n".join(out) + + +def go_escape(text): + for character, entity in GO_HTML_ESCAPES: + text = text.replace(character, entity) + return text + + +class EndorLabsParser: + + """ + Parses an Endor Labs findings export. + + Mirrors pkg/tools/endorlabs/connector/converter.go field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Endor Labs is reachability-aware SCA: its distinguishing output is whether the vulnerable code is + actually called. That verdict is set as the finding's impact, which is where the connector puts + it. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Endor Labs - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Endor Labs - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Endor Labs findings export (JSON). Matches the scan type used by the Endor " + "Labs connector so file and API findings deduplicate. Endor's reachability verdict is " + "imported as the finding's impact." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Endor Labs Parser. + + Mirrors the connector's FindingConverter.Convert: + - title: Endor's own finding name, falling back to " in :". + - severity: the finding level enum, anything unrecognised Info. + - description: summary, explanation, reachability, vulnerability summary, EPSS, references. + - impact: the reachability verdict. + - mitigation: Endor's remediation advice. + - cvssv3 / cvssv3_score: the CVSS v3 vector and score, falling back to the v4 base score. + - component_name / component_version: the target dependency. + - unique_id_from_tool: the Endor finding UUID, which is stable across syncs. + - vuln_id_from_tool: the primary vulnerability identifier. + """ + return [ + "title", + "severity", + "description", + "impact", + "mitigation", + "cvssv3", + "cvssv3_score", + "component_name", + "component_version", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Endor Labs Parser. + + Copied from the Endor Labs block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Diverging would stop file findings + merging with API-synced ones. + """ + return ["title", "severity", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + rows = self.extract_rows(data) + + findings = {} + for row in rows: + if not isinstance(row, dict): + continue + finding = self.build_finding(row, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_rows(self, data): + """Endor's list endpoints nest the results under "list"."objects".""" + if isinstance(data, list): + return data + if isinstance(data, dict): + listing = data.get("list") + if isinstance(listing, dict) and isinstance(listing.get("objects"), list): + return listing["objects"] + if isinstance(data.get("objects"), list): + return data["objects"] + msg = ( + "An Endor Labs export is a JSON object with a 'list' containing 'objects', or a bare " + f"array of findings; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, test): + spec = row.get("spec") or {} + vulnerability = self.vulnerability(row) + name, version = self.component(spec) + tags = spec.get("finding_tags") or [] + reachability = self.reachability_summary(tags) + + finding = Finding( + test=test, + title=self.title(row, spec, name, version), + severity=SEVERITY_MAP.get((spec.get("level") or "").strip().upper(), DEFAULT_SEVERITY), + description=self.describe(spec, vulnerability, reachability), + component_name=name or None, + component_version=version or None, + unique_id_from_tool=self.unique_id(row, spec, name, version), + vuln_id_from_tool=self.primary_vuln_id(vulnerability) or None, + # Endor Labs reads a dependency graph, never a running service. + static_finding=True, + dynamic_finding=False, + ) + + if reachability: + # The connector promotes reachability to impact so triagers see it prominently. + finding.impact = reachability + if remediation := (spec.get("remediation") or "").strip(): + finding.mitigation = inert_text(remediation) + if score := self.cvss_score(vulnerability): + finding.cvssv3_score = score + if vector := self.cvss_v3_vector(vulnerability): + finding.cvssv3 = vector + if ids := self.vulnerability_ids(vulnerability): + finding.unsaved_vulnerability_ids = ids + + finding.unsaved_tags = self.tags(spec) + return finding + + def vulnerability(self, row): + metadata = (row.get("spec") or {}).get("finding_metadata") or {} + vulnerability = metadata.get("vulnerability") + return vulnerability if isinstance(vulnerability, dict) else None + + def component(self, spec): + """The dependency name, preferring the resolved name over the package name.""" + name = (spec.get("target_dependency_name") or "").strip() + if not name: + name = (spec.get("target_dependency_package_name") or "").strip() + return name, (spec.get("target_dependency_version") or "").strip() + + def unique_id(self, row, spec, name, version): + """ + The Endor finding UUID, which is stable across syncs. + + The converter falls back to a vulnerability and component composite when a finding somehow + arrives without one. + """ + if uuid := (row.get("uuid") or "").strip(): + return uuid + return f"{self.primary_vuln_id(self.vulnerability(row))}|{name}:{version}" + + def title(self, row, spec, name, version): + if title := ((row.get("meta") or {}).get("name") or "").strip(): + return title + vuln_id = self.primary_vuln_id(self.vulnerability(row)) + if vuln_id and name: + return f"{vuln_id} in {name}:{version}" + return "Endor Labs finding" + + def primary_vuln_id(self, vulnerability): + if not vulnerability: + return "" + return ((vulnerability.get("meta") or {}).get("name") or "").strip() + + def vulnerability_ids(self, vulnerability): + """The primary identifier followed by Endor's aliases, deduplicated in order.""" + if not vulnerability: + return [] + candidates = [self.primary_vuln_id(vulnerability)] + aliases = (vulnerability.get("spec") or {}).get("aliases") + if isinstance(aliases, list): + candidates.extend(str(alias) for alias in aliases) + + ids, seen = [], set() + for candidate in candidates: + identifier = candidate.strip() + if identifier and identifier not in seen: + seen.add(identifier) + ids.append(identifier) + return ids + + def cvss_score(self, vulnerability): + """ + The CVSS v3 score, falling back to the v4 base score. + + Endor publishes both, and the converter prefers v3 because that is what Finding.cvssv3_score + holds; a v4-only advisory would otherwise import with no score at all. + """ + if not vulnerability: + return 0 + spec = vulnerability.get("spec") or {} + v3 = spec.get("cvss_v3_severity") + if isinstance(v3, dict) and (v3.get("score") or 0) > 0: + return v3["score"] + v4 = spec.get("cvss_v4_severity") + if isinstance(v4, dict) and (v4.get("base_score") or 0) > 0: + return v4["base_score"] + return 0 + + def cvss_v3_vector(self, vulnerability): + if not vulnerability: + return "" + v3 = (vulnerability.get("spec") or {}).get("cvss_v3_severity") + return (v3.get("vector") or "").strip() if isinstance(v3, dict) else "" + + def epss_probability(self, vulnerability): + if not vulnerability: + return 0 + epss = (vulnerability.get("spec") or {}).get("epss_score") + return (epss.get("probability_score") or 0) if isinstance(epss, dict) else 0 + + def reachability_summary(self, tags): + """First match wins, in the connector's order of precedence.""" + present = set(tags) + for tag, summary in REACHABILITY_SUMMARY: + if tag in present: + return summary + return "" + + def describe(self, spec, vulnerability, reachability): + parts = [] + if summary := (spec.get("summary") or "").strip(): + parts.append(inert_text(summary)) + if explanation := (spec.get("explanation") or "").strip(): + parts.append("**Explanation**: " + inert_text(explanation)) + if reachability: + parts.append("**Reachability**: " + reachability) + if vulnerability: + vuln_summary = ((vulnerability.get("spec") or {}).get("summary") or "").strip() + if vuln_summary: + parts.append("**Vulnerability**: " + inert_text(vuln_summary)) + if epss := self.epss_probability(vulnerability): + parts.append(f"**EPSS probability**: {epss:.4f}") + if references := self.references(vulnerability): + parts.append("**References**:\n" + references) + return "\n\n".join(parts) + + def references(self, vulnerability): + """Endor's reference URLs as an inert markdown bullet list.""" + if not vulnerability: + return "" + refs = (vulnerability.get("spec") or {}).get("references") + if not isinstance(refs, list): + return "" + lines = [f"- {inert_text(str(ref).strip())}" for ref in refs if str(ref).strip()] + return "\n".join(lines) + + def tags(self, spec): + """ + Endor's finding tags and categories, humanised and deduplicated in order. + + A tag and a category can collide, which is why the connector deduplicates after joining. + """ + tags = self.humanise(spec.get("finding_tags"), FINDING_TAG_PREFIX) + tags.extend(self.humanise(spec.get("finding_categories"), FINDING_CATEGORY_PREFIX)) + + out, seen = [], set() + for tag in tags: + if tag not in seen: + seen.add(tag) + out.append(tag) + return out + + def humanise(self, values, prefix): + """Strip the enum prefix, lower-case, and hyphenate; drop the _UNSPECIFIED placeholders.""" + if not isinstance(values, list): + return [] + out = [] + for value in values: + trimmed = str(value).strip() + if not trimmed or trimmed.endswith(UNSPECIFIED_SUFFIX): + continue + human = trimmed.removeprefix(prefix).lower().replace("_", "-") + if human: + out.append(human) + return out diff --git a/dojo/tools/escape/__init__.py b/dojo/tools/escape/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/escape/parser.py b/dojo/tools/escape/parser.py new file mode 100644 index 00000000000..efea50bb0b7 --- /dev/null +++ b/dojo/tools/escape/parser.py @@ -0,0 +1,247 @@ +import json +import re +from contextlib import suppress +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class EscapeParser: + + """ + Parses an Escape API-security scan export. + + Mirrors pkg/tools/escape/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + Escape scans an API rather than a site: every issue is a method and a URL, which is why this scan + type's deduplication hash includes the endpoints and the parser always records the tested URL. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. + return ["Escape - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Escape - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Escape scan export (JSON), the issues of an application's latest scan. Matches " + "the scan type used by the Escape connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Escape Parser. + + Mirrors the connector's IssueToFinding: + - title: the issue name, else "Escape issue ". + - severity: Escape's own label; anything unrecognised is Info. + - description: the issue prose, then the method and URL, the OWASP category and the CWE. + - mitigation: Escape's remediation text, when it has one. + - cwe: parsed from "CWE-89" or a bare number. + - unique_id_from_tool: the issue id. + """ + return [ + "title", + "severity", + "description", + "mitigation", + "cwe", + "unique_id_from_tool", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Escape Parser. + + Copied from the Escape block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Note endpoints is among them, so the + tested URL must be populated for the hash to mean anything. + """ + return ["title", "severity", "endpoints"] + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(issue, test) for issue in self.issues(data)] + + def issues(self, data): + """ + Return the issues in the export. + + Escape nests them under the scan that produced them - the connector reads an application's + latest scan - so an export is a scan object, an application carrying one, or the issue list + itself. + """ + if isinstance(data, list): + return [issue for issue in data if isinstance(issue, dict)] + if isinstance(data, dict): + if isinstance(data.get("issues"), list): + return [issue for issue in data["issues"] if isinstance(issue, dict)] + for key in ("lastScan", "scan"): + nested = data.get(key) + if isinstance(nested, dict) and isinstance(nested.get("issues"), list): + return [issue for issue in nested["issues"] if isinstance(issue, dict)] + # An applications response: take the issues of every application's latest scan. + applications = data.get("applications") + if isinstance(applications, list): + collected = [] + for application in applications: + if not isinstance(application, dict): + continue + scan = application.get("lastScan") + if isinstance(scan, dict) and isinstance(scan.get("issues"), list): + collected.extend(issue for issue in scan["issues"] if isinstance(issue, dict)) + return collected + + msg = ( + "An Escape export is a scan, a JSON object with an 'issues' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, issue, test): + identifier = str(issue.get("id") or "").strip() + + finding = Finding( + test=test, + title=self.title(issue, identifier), + severity=self.severity(issue), + description=self.describe(issue), + unique_id_from_tool=identifier or None, + # Escape exercises a running API. + active=True, + static_finding=False, + dynamic_finding=True, + ) + finding.unsaved_tags = self.tags(issue) + + if remediation := str(issue.get("remediation") or "").strip(): + finding.mitigation = remediation + if (cwe := self.cwe(issue.get("cwe"))) > 0: + finding.cwe = cwe + + self.attach_endpoint(finding, str(issue.get("url") or "").strip()) + return finding + + def title(self, issue, identifier): + if name := str(issue.get("name") or "").strip(): + return name + return f"Escape issue {identifier}" + + def severity(self, issue): + """ + Escape's own label. + + It rates by OWASP category and severity word - high/medium/low/info - and an unrecognised + label is Info rather than a guess. + """ + label = str(issue.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def describe(self, issue): + """ + The issue prose, then the endpoint, OWASP category and CWE. + + The endpoint line carries the method when Escape reported one - "GET https://..." - because + the same URL behaves differently per verb, which is the point of an API scanner. + """ + parts = [] + if description := str(issue.get("description") or "").strip(): + parts.append(description + "\n\n") + + method = str(issue.get("method") or "").strip() + url = str(issue.get("url") or "").strip() + if method and url: + parts.append(f"**Endpoint:** {method} {url}\n") + elif url: + parts.append(f"**Endpoint:** {url}\n") + + for label, value in (("OWASP", issue.get("owasp")), ("CWE", issue.get("cwe"))): + text = str(value or "").strip() + if text: + parts.append(f"**{label}:** {text}\n") + return "".join(parts).rstrip("\n") + + def tags(self, issue): + tags = [] + if owasp := str(issue.get("owasp") or "").strip(): + tags.append(f"owasp:{owasp}") + if method := str(issue.get("method") or "").strip(): + tags.append(f"method:{method.upper()}") + return tags + + def cwe(self, raw): + """Read a CWE id off "CWE-89" or a bare number; anything else is no CWE.""" + trimmed = str(raw or "").strip().upper().removeprefix("CWE-") + if not trimmed: + return 0 + with suppress(ValueError): + return int(trimmed) + return 0 + + def attach_endpoint(self, finding, url): + """ + Record the tested URL. + + This scan type's deduplication hashes the endpoints, so an unpopulated endpoint would leave + the hash computed over nothing and every rescan would reimport. + """ + if not url: + return + with suppress(ValueError): + parsed = urlparse(url if "//" in url else f"//{url}") + try: + port = parsed.port + except ValueError: + return + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/"), query=parsed.query, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/") or None, query=parsed.query or None, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else + makes Endpoint.clean() raise, and that fails the whole import rather than the one finding. + The URL is still in the description. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False diff --git a/dojo/tools/fairwinds/__init__.py b/dojo/tools/fairwinds/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/fairwinds/parser.py b/dojo/tools/fairwinds/parser.py new file mode 100644 index 00000000000..2e9f791ef0e --- /dev/null +++ b/dojo/tools/fairwinds/parser.py @@ -0,0 +1,332 @@ +import json +import re +from html.parser import HTMLParser + +from dojo.models import Finding + +# Fairwinds scores severity as a FLOAT from 0.0 to 1.0, not a word. These are the connector's +# breakpoints; anything below the Low floor is Info. +SEVERITY_CRITICAL_FLOOR = 0.9 +SEVERITY_HIGH_FLOOR = 0.7 +SEVERITY_MEDIUM_FLOOR = 0.4 +SEVERITY_LOW_FLOOR = 0.1 +DEFAULT_SEVERITY = "Info" + +CVE_PATTERN = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE) + +# The connector's HTML flattener, duplicated per tool in the connectors repo rather than shared. +BLOCK_TAGS = frozenset({ + "br", "p", "div", "li", "tr", "ul", "ol", "table", "blockquote", + "h1", "h2", "h3", "h4", "h5", "h6", +}) +DROPPED_TAGS = frozenset({"script", "style"}) +GO_HTML_ESCAPES = ( + ("&", "&"), ("<", "<"), (">", ">"), ('"', """), ("'", "'"), +) + + +class _HtmlFlattener(HTMLParser): + + """Mirror of the connector's htmlFlattener: drop script/style, block tags become newlines.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self.skip_depth = 0 + + def handle_starttag(self, tag, attrs): + if tag in DROPPED_TAGS: + self.skip_depth += 1 + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_startendtag(self, tag, attrs): + if tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_endtag(self, tag): + if tag in DROPPED_TAGS: + self.skip_depth = max(0, self.skip_depth - 1) + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_data(self, data): + if self.skip_depth == 0: + self.parts.append(data) + + def text(self): + return "".join(self.parts) + + +def inert_text(raw_html): + """Flatten text to escaped plain text, as the connector's InertText does.""" + if not raw_html: + return "" + flattener = _HtmlFlattener() + flattener.feed(raw_html) + flattener.close() + return go_escape(collapse_whitespace(flattener.text())) + + +def collapse_whitespace(text): + out, blank = [], True + for raw in text.split("\n"): + line = raw.strip() + if not line: + if not blank: + out.append("") + blank = True + continue + out.append(line) + blank = False + while out and not out[-1]: + out.pop() + return "\n".join(out) + + +def go_escape(text): + for character, entity in GO_HTML_ESCAPES: + text = text.replace(character, entity) + return text + + +class FairwindsParser: + + """ + Parses a Fairwinds Insights action-items export. + + Mirrors pkg/tools/fairwinds/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + Fairwinds aggregates several Kubernetes scanners - Polaris, Trivy, OPA, kube-bench - into one + action-item stream, and its JSON keys are PascalCase ("Title", "Severity", "ResourceKind"). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Fairwinds Insights - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Fairwinds Insights - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Fairwinds Insights action-items export (JSON). Matches the scan type used by " + "the Fairwinds Insights connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Fairwinds Parser. + + Mirrors the connector's ActionItemToFinding: + - title: the action item's title, falling back to "Fairwinds action item ". + - severity: from Fairwinds' 0.0-1.0 severity score; see severity(). + - description: the description, then the Kubernetes resource, image, event type and notes. + - mitigation: the item's remediation advice. + - component_name / component_version: the container image and tag, else the resource name. + - active / is_mitigated: from the item's Fixed flag. + - unique_id_from_tool: the Fairwinds action-item id. + """ + return [ + "title", + "severity", + "description", + "mitigation", + "component_name", + "component_version", + "unique_id_from_tool", + "tags", + "active", + "is_mitigated", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Fairwinds Parser. + + Copied from the Fairwinds Insights block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + items = self.extract_items(data) + + findings = {} + for item in items: + if not isinstance(item, dict): + continue + finding = self.build_finding(item, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_items(self, data): + """Fairwinds' action-items endpoint returns a bare array; envelopes are accepted too.""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("ActionItems", "actionItems", "items", "data"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "A Fairwinds Insights export is a JSON array of action items, or an object with an " + f"'ActionItems' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, item, test): + fixed = bool(item.get("Fixed")) + name, version = self.component(item) + + finding = Finding( + test=test, + title=self.title(item), + severity=self.severity(item.get("Severity")), + description=self.describe(item), + component_name=name or None, + component_version=version or None, + unique_id_from_tool=str(item.get("ID")), + # Fairwinds tracks whether the item has been fixed, so a fixed item is imported closed + # rather than sitting in the open queue. + active=not fixed, + is_mitigated=fixed, + # Fairwinds reads cluster manifests and images, not a running service. + static_finding=True, + dynamic_finding=False, + ) + + remediation = (item.get("Remediation") or "").strip() + if remediation: + finding.mitigation = inert_text(remediation) + + cves = self.cves(item) + if cves: + finding.unsaved_vulnerability_ids = cves + finding.unsaved_tags = self.tags(item) + return finding + + def title(self, item): + title = (item.get("Title") or "").strip() + if title: + return inert_text(title) + return f"Fairwinds action item {item.get('ID')}" + + def severity(self, score): + """ + Grade Fairwinds' 0.0-1.0 severity score. + + It is a normalised float, not a word and not a CVSS score, so the breakpoints are Fairwinds' + own: 0.9 Critical, 0.7 High, 0.4 Medium, 0.1 Low, below that Info. + """ + try: + value = float(score) + except (TypeError, ValueError): + return DEFAULT_SEVERITY + if value >= SEVERITY_CRITICAL_FLOOR: + return "Critical" + if value >= SEVERITY_HIGH_FLOOR: + return "High" + if value >= SEVERITY_MEDIUM_FLOOR: + return "Medium" + if value >= SEVERITY_LOW_FLOOR: + return "Low" + return DEFAULT_SEVERITY + + def describe(self, item): + parts = [] + description = (item.get("Description") or "").strip() + if description: + parts.append(inert_text(description)) + if resource := self.resource_line(item): + parts.append(f"**Resource:** {resource}") + if image := self.image_ref(item): + parts.append(f"**Image:** {inert_text(image)}") + if event_type := (item.get("EventType") or "").strip(): + parts.append(f"**Event type:** {inert_text(event_type)}") + if notes := (item.get("Notes") or "").strip(): + parts.append(f"**Notes:** {inert_text(notes)}") + return "\n\n".join(parts) + + def resource_line(self, item): + """ + The Kubernetes coordinates as "namespace/kind/name", with the container appended. + + Any of the three segments may be missing, and the container qualifier only appears when + Fairwinds identified one. + """ + segments = [ + segment for segment in ( + (item.get("ResourceNamespace") or "").strip(), + (item.get("ResourceKind") or "").strip(), + (item.get("ResourceName") or "").strip(), + ) if segment + ] + line = "/".join(segments) + container = (item.get("ResourceContainer") or "").strip() + if container: + line = f"{line} (container: {container})".strip() + return inert_text(line) if line else "" + + def image_ref(self, item): + name = (item.get("ImageName") or "").strip() + if not name: + return "" + tag = (item.get("ImageTag") or "").strip() + return f"{name}:{tag}" if tag else name + + def component(self, item): + """ + The container image is the component when there is one; otherwise the Kubernetes resource. + + A Trivy image finding and a Polaris manifest finding are both action items, so the component + has to come from whichever the item is about. + """ + name = (item.get("ImageName") or "").strip() + if name: + return name, (item.get("ImageTag") or "").strip() + return (item.get("ResourceName") or "").strip(), "" + + def cves(self, item): + """Fairwinds has no CVE field; the connector scans the title and description.""" + found, seen = [], set() + for source in (item.get("Title") or "", item.get("Description") or ""): + for cve in CVE_PATTERN.findall(str(source)): + upper = cve.upper() + if upper not in seen: + seen.add(upper) + found.append(upper) + return found + + def tags(self, item): + """ + The connector's tag set, deduplicated in order. + + Note the cluster tag is added unconditionally, so an item with no cluster still gets a bare + "cluster:" tag - reproduced rather than tidied. + """ + out, seen = [], set() + + def add(tag): + tag = (tag or "").strip() + if tag and tag not in seen: + seen.add(tag) + out.append(tag) + + if report_type := (item.get("ReportType") or "").strip(): + add(f"tool:{report_type}") + if category := (item.get("Category") or "").strip(): + add(f"category:{category.lower()}") + add("cluster:" + (item.get("Cluster") or "").strip()) + if namespace := (item.get("ResourceNamespace") or "").strip(): + add(f"namespace:{namespace}") + if event_type := (item.get("EventType") or "").strip(): + add(f"event:{event_type}") + tags = item.get("Tags") + if isinstance(tags, list): + for tag in tags: + add(str(tag)) + return out diff --git a/dojo/tools/finitestate/__init__.py b/dojo/tools/finitestate/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/finitestate/parser.py b/dojo/tools/finitestate/parser.py new file mode 100644 index 00000000000..807f41bbf9a --- /dev/null +++ b/dojo/tools/finitestate/parser.py @@ -0,0 +1,402 @@ +import json +from contextlib import suppress +from datetime import date as _date +from datetime import datetime + +from dojo.models import Finding + +SEVERITY_BY_NAME = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + "none": "Info", + "unknown": "Info", +} +DEFAULT_SEVERITY = "Info" + +# Finite State's VEX statuses. +STATUS_NOT_AFFECTED = "NOT_AFFECTED" +STATUS_FIXED = "FIXED" +STATUS_UNDER_INVESTIGATION = "UNDER_INVESTIGATION" + +# The NOT_AFFECTED justifications that mean the vulnerable code is not there to be reached, which +# makes the finding a false positive as well as out of scope. +FALSE_POSITIVE_JUSTIFICATIONS = { + "COMPONENT_NOT_PRESENT", + "VULNERABLE_CODE_NOT_PRESENT", + "VULNERABLE_CODE_NOT_IN_EXECUTE_PATH", +} + + +class FinitestateParser: + + """ + Parses a Finite State findings export. + + Mirrors pkg/tools/finitestate/connector/finding_converter field for field so a file import and an + API sync deduplicate against each other instead of producing two copies of everything. + + The VEX status is the part that carries real semantics. A NOT_AFFECTED finding is a product team's + assertion that the vulnerability does not apply to this build, and it must not sit in DefectDojo + as active - so it becomes out of scope, and a false positive too where the justification says the + vulnerable code is not present or not reachable. See apply_status(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. + return ["Finite State - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Finite State - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Finite State findings export (JSON) for one firmware build, optionally with the " + "asset and build context. Matches the scan type used by the Finite State connector so file " + "and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Finite State Parser. + + Mirrors the connector's ConvertFinding: + - title / description: Finite State's own, with the build context appended. + - severity: the platform's severity, then its CVSS-derived one; see severity(). + - cwe: the first CWE that parses, accepting "CWE-79" or "79". + - cvssv3 / cvssv3_score: the finding's score, then the first CVE's vector and base score. + - epss_score / epss_percentile: the HIGHEST EPSS across the finding's CVEs. + - component_name / component_version: the first affected SBOM component. + - active / out_of_scope / false_p / is_mitigated / under_review: the VEX status. + - unique_id_from_tool: the finding id; vuln_id_from_tool: Finite State's own. + """ + return [ + "title", + "description", + "severity", + "date", + "cwe", + "cvssv3", + "cvssv3_score", + "epss_score", + "epss_percentile", + "component_name", + "component_version", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "out_of_scope", + "false_p", + "is_mitigated", + "under_review", + "static_finding", + "dynamic_finding", + ] + + # No get_dedupe_fields: this scan type has no curated hash-field list, so it deduplicates with + # DefectDojo's default algorithm - which is exactly what the connector's own findings do today. + # Choosing hash fields here would also change how those findings deduplicate, which is not this + # parser's call to make. Noted in the PR as a follow-up on the connector side, because the + # connector does write a stable unique_id_from_tool that the default algorithm cannot use. + + def get_findings(self, filename, test): + data = json.load(filename) + asset, version = self.context(data) + return [ + self.build_finding(row, self.block(row, "asset") or asset, + self.block(row, "assetVersion") or version, test) + for row in self.rows(data) + ] + + def rows(self, data): + """ + Return the findings in the export. + + Finite State answers GraphQL, so a saved export is {"data": {"allFindings": [...]}}. The + unwrapped forms and a bare array are accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + + if isinstance(data, dict): + for holder in (self.block(data, "data"), data): + for key in ("allFindings", "findings"): + if isinstance(holder.get(key), list): + return [row for row in holder[key] if isinstance(row, dict)] + + msg = ( + "A Finite State export is the findings response, a JSON object with a " + f"'data.allFindings' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def context(self, data): + """ + The asset and firmware build the whole file belongs to. + + One export is one firmware build, so the context is stated once for the file rather than + repeated on every row - though a row carrying its own overrides it. A finding on firmware + means little without knowing which build it is in, which is why the connector passes both + into every conversion. + """ + if not isinstance(data, dict): + return None, None + for holder in (data, self.block(data, "data")): + asset = self.block(holder, "asset") or None + version = self.block(holder, "assetVersion") or self.block(holder, "version") or None + if asset or version: + return asset, version + return None, None + + def block(self, row, key): + if not isinstance(row, dict): + return {} + value = row.get(key) + return value if isinstance(value, dict) else {} + + def blocks(self, row, key): + value = row.get(key) + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + def build_finding(self, row, asset, version, test): + finding = Finding( + test=test, + title=str(row.get("title") or "") or None, + description=self.describe(row, asset, version), + severity=self.severity(row), + cwe=self.cwe(row), + unique_id_from_tool=str(row.get("id") or "") or None, + vuln_id_from_tool=str(row.get("vulnIdFromTool") or "") or None, + # Firmware and binary analysis inspect an artifact without running it. + static_finding=True, + dynamic_finding=False, + ) + finding.unsaved_tags = self.tags(row, version) + + # The first affected SBOM component is the component. Finite State can list several, but + # DefectDojo has one - and the first is the one the finding is filed against. + if affects := self.blocks(row, "affects"): + finding.component_name = str(affects[0].get("name") or "") or None + finding.component_version = str(affects[0].get("version") or "") or None + + if identifiers := self.vulnerability_ids(row): + finding.unsaved_vulnerability_ids = identifiers + if date := self.date(row): + finding.date = date + + self.apply_scores(finding, row) + self.apply_status(finding, row) + return finding + + def severity(self, row): + """ + Finite State's own severity, then its CVSS-derived one. + + Anything the platform does not recognise is Info - the connector logs a warning and does the + same, because inventing a grade for an unknown word would be worse than under-reporting it. + """ + for key in ("severity", "cvssSeverity"): + label = str(row.get(key) or "").strip().lower() + if label in SEVERITY_BY_NAME: + return SEVERITY_BY_NAME[label] + return DEFAULT_SEVERITY + + def cwe(self, row): + """The first CWE that parses as a number, accepting "CWE-79" or "79".""" + for cwe in self.blocks(row, "cwes"): + digits = str(cwe.get("cweId") or "").strip().upper().removeprefix("CWE-") + with suppress(ValueError): + return int(digits) + return 0 + + def vulnerability_ids(self, row): + """The finding's CVE identifiers, in the order Finite State lists them.""" + return [ + value + for value in (str(cve.get("cveId") or "").strip() for cve in self.blocks(row, "cves")) + if value + ] + + def apply_scores(self, finding, row): + """ + CVSS and EPSS, preferring the finding's own score and falling back to its first CVE's. + + EPSS is per-CVE, so the HIGHEST is taken: that is the finding's real exploitation likelihood, + and its percentile travels with it rather than being mixed from another CVE. + """ + finding.cvssv3_score = self.number(row.get("cvssScore")) + + for cve in self.blocks(row, "cves"): + metric = self.block(self.block(cve, "cvssBaseMetricV3"), "cvssv3") + if metric: + if not finding.cvssv3: + finding.cvssv3 = str(metric.get("vectorString") or "") or None + if not finding.cvssv3_score: + finding.cvssv3_score = self.number(metric.get("baseScore")) + + epss = self.block(cve, "epss") + if epss: + score = self.number(epss.get("epssScore")) + if score > (finding.epss_score or 0): + finding.epss_score = score + finding.epss_percentile = self.number(epss.get("epssPercentile")) + + def apply_status(self, finding, row): + """ + Translate the VEX status into DefectDojo's status flags. + + AFFECTED and anything unrecognised stay ACTIVE, which is the safe direction to be wrong in: + a finding wrongly left active gets triaged, while one wrongly closed is never seen again. + """ + current = row.get("currentStatus") + if not isinstance(current, dict): + # No assertion has been made, so the finding stands as reported. + finding.active = True + return + + status = str(current.get("status") or "").strip().upper() + justification = str(current.get("justification") or "").strip().upper() + + if status == STATUS_NOT_AFFECTED: + # A product team has asserted the vulnerability does not apply to this build. + finding.active = False + finding.out_of_scope = True + if justification in FALSE_POSITIVE_JUSTIFICATIONS: + # The vulnerable code is not there to be reached, so it is a false positive as well + # as out of scope. The distinction matters for metrics. + finding.false_p = True + elif status == STATUS_FIXED: + finding.active = False + finding.is_mitigated = True + if mitigated := self.timestamp(current.get("updatedAt")): + finding.mitigated = mitigated + elif status == STATUS_UNDER_INVESTIGATION: + # Still active: nobody has ruled it out yet. + finding.active = True + finding.under_review = True + else: + finding.active = True + + def tags(self, row, version): + """ + The firmware build, the finding category, the tools that produced it, and exploit intel. + + The build tag is what lets a reader tell which firmware a finding belongs to without opening + it, which matters when several builds of one product are in the same product. + """ + tags = [] + + def add(value): + trimmed = str(value or "").strip() + if trimmed and trimmed not in tags: + tags.append(trimmed) + + if version: + add("firmware-build:" + self.first(version.get("name"), version.get("id"))) + + add(row.get("category")) + add(row.get("subcategory")) + for source in row.get("sourceTypes") or []: + add(source) + for tool in self.blocks(self.block(row, "test"), "tools"): + add(tool.get("name")) + if row.get("regression"): + add("regression") + + for cve in self.blocks(row, "cves"): + exploits = self.block(cve, "exploitsInfo") + if exploits.get("weaponized"): + add("weaponized") + if exploits.get("reportedInTheWild"): + add("exploited-in-the-wild") + return tags + + def describe(self, row, asset, version): + """ + Finite State's own description, then the build context a reader needs. + + The connector does NOT trim this, so a description that ends in a field line keeps its + trailing newline; reproduced rather than tidied so both paths render identically. + """ + parts = [] + if description := str(row.get("description") or ""): + parts.append(description + "\n\n") + + if asset: + parts.append(f"**Asset:** {asset.get('name') or ''}\n") + if version: + parts.append(f"**Firmware build:** {self.first(version.get('name'), version.get('id'))}\n") + if (risk := self.number(version.get("relativeRiskScore"))) > 0: + parts.append(f"**Build relative risk score:** {self.plain(risk)}\n") + + for label, key in (("Category", "category"), ("Origin", "origin")): + if value := str(row.get(key) or ""): + parts.append(f"**{label}:** {value}\n") + + parts.append(self.vex_section(row.get("currentStatus"))) + + if (risk := self.number(row.get("riskScore"))) > 0: + parts.append(f"**Risk score:** {self.plain(risk)}\n") + return "".join(parts) + + def vex_section(self, current): + """The VEX assertion, which is the part of a firmware finding a reader most needs.""" + if not isinstance(current, dict) or not str(current.get("status") or ""): + return "" + + lines = [f"**VEX status:** {current.get('status')}\n"] + for label, key in (("VEX justification", "justification"), ("VEX comment", "comment")): + if value := str(current.get(key) or ""): + lines.append(f"**{label}:** {value}\n") + return "".join(lines) + + def date(self, row): + """ + The finding's date, then when it was created, cut back to the calendar date. + + The connector cuts at the "T" because it hands the API a string; this reads the same value as + a date, and skips one that is not a date rather than failing the import. + """ + value = self.first(row.get("date"), row.get("createdAt")) + if not value: + return None + with suppress(ValueError): + return _date.fromisoformat(value.split("T")[0]) + return None + + def timestamp(self, value): + """A full timestamp, for the date a fix was asserted.""" + text = str(value or "").strip() + if not text: + return None + with suppress(ValueError): + return datetime.fromisoformat(text) + return None + + def first(self, *values): + for value in values: + if trimmed := str(value or "").strip(): + return trimmed + return "" + + def plain(self, value): + """Shortest round-tripping form, as Go's FormatFloat(v, 'f', -1, 64) produces.""" + if value == int(value): + return str(int(value)) + return repr(value) + + def number(self, value): + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 diff --git a/dojo/tools/fleet_policies/__init__.py b/dojo/tools/fleet_policies/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/fleet_policies/parser.py b/dojo/tools/fleet_policies/parser.py new file mode 100644 index 00000000000..80f934bd498 --- /dev/null +++ b/dojo/tools/fleet_policies/parser.py @@ -0,0 +1,242 @@ +import json +import re +from contextlib import suppress +from ipaddress import ip_address + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Fleet reports a policy's outcome per host; only a failing one is a finding. +POLICY_RESPONSE_FAIL = "fail" + +CRITICAL_POLICY_SEVERITY = "High" +POLICY_SEVERITY = "Medium" + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class FleetPoliciesParser: + + """ + Parses a Fleet host export, importing the compliance policies that are failing. + + Mirrors the policy half of pkg/tools/fleet/connector/converter field for field so a file import + and an API sync deduplicate against each other instead of producing two copies of everything. The + software CVEs in the same export are a separate scan type - see the Fleet Vulnerabilities parser - + because Fleet's own API models them as different things and the two carry different deduplication + keys. + + A Fleet host response nests the policy results inside the host, so one file produces a finding per + host per failing policy. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypePolicies. + return ["Fleet:Policies - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Fleet:Policies - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Fleet host export (JSON) and report the compliance policies failing on each " + "host. Matches the scan type used by the Fleet connector so file and API findings " + "deduplicate. Software CVEs in the same export are imported by the Fleet " + "Vulnerabilities parser." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Fleet Policies Parser. + + Mirrors the connector's policyFinding: + - title: the policy name. + - severity: High for a policy Fleet marks critical, Medium otherwise. + - description: the policy description, that it is failing, the host, and the policy's query. + - mitigation: the policy's own resolution text. + - unique_id_from_tool: ":policy:". + - vuln_id_from_tool: "fleet-policy-", the policy itself. + """ + return [ + "title", + "severity", + "description", + "mitigation", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Fleet Policies Parser. + + Copied from the Fleet policies block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Note it hashes vuln_id_from_tool - + the policy - rather than a component, because a policy is not about a package. + """ + return ["title", "severity", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for host in self.hosts(data): + for policy in self.rows(host.get("policies")): + finding = self.build_finding(host, policy, test) + if finding is not None: + findings.append(finding) + return findings + + def hosts(self, data): + """ + Return the hosts in the export. + + Fleet's host list nests them under "hosts" and its single-host response under "host", so both + are accepted; a bare array of hosts works too. + """ + if isinstance(data, list): + return [host for host in data if isinstance(host, dict)] + if isinstance(data, dict): + if isinstance(data.get("hosts"), list): + return [host for host in data["hosts"] if isinstance(host, dict)] + if isinstance(data.get("host"), dict): + return [data["host"]] + if any(key in data for key in ("software", "policies", "hostname", "display_name")): + return [data] + + msg = ( + "A Fleet export is a host response, a JSON object with a 'hosts' list or a single 'host'; " + f"got {type(data).__name__}." + ) + raise TypeError(msg) + + def rows(self, value): + return [row for row in value if isinstance(row, dict)] if isinstance(value, list) else [] + + def build_finding(self, host, policy, test): + if not self.failing(policy): + # A passing policy is not a finding, and Fleet reports every policy's outcome. + return None + title = str(policy.get("name") or "").strip() + if not title: + # Without a name there is nothing to report; the connector drops the row. + return None + + critical = policy.get("critical") is True + policy_id = self.flex_int(policy.get("id")) + + finding = Finding( + test=test, + title=title, + severity=CRITICAL_POLICY_SEVERITY if critical else POLICY_SEVERITY, + description=self.describe(host, policy), + mitigation=str(policy.get("resolution") or "").strip(), + unique_id_from_tool=f"{self.flex_int(host.get('id'))}:policy:{policy_id}", + vuln_id_from_tool=f"fleet-policy-{policy_id}", + # A policy is an osquery query run against the host's own state. + static_finding=True, + dynamic_finding=False, + ) + finding.unsaved_tags = self.tags(host, policy, critical) + + self.attach_host(finding, host) + return finding + + def failing(self, policy): + """Fleet writes the outcome as "fail" or "pass", and leaves it empty when it has no result.""" + return str(policy.get("response") or "").strip().lower() == POLICY_RESPONSE_FAIL + + def describe(self, host, policy): + parts = [] + if description := str(policy.get("description") or "").strip(): + parts.append(description) + parts.append("This Fleet policy is **failing** on this host.") + parts.extend(self.host_lines(host)) + if query := str(policy.get("query") or "").strip(): + parts.append("**Policy query**\n\n```sql\n" + query + "\n```") + return "\n\n".join(parts) + + def tags(self, host, policy, critical): + """ + The connector sorts and deduplicates its tags, so these come out alphabetical. + + Worth mirroring rather than tidying: a tag set that differs only in order still reads as a + change on every reimport. + """ + tags = ["policy", "compliance", "endpoint"] + if critical: + tags.append("critical-policy") + tags.extend([ + str(policy.get("platform") or "").strip(), + str(host.get("platform") or "").strip(), + ]) + return sorted({tag for tag in tags if tag}) + + def host_name(self, host): + """Fleet's display name, then the computer name, then the hostname.""" + for key in ("display_name", "computer_name", "hostname"): + if value := str(host.get(key) or "").strip(): + return value + return "" + + def host_lines(self, host): + lines = [] + if name := self.host_name(host): + line = f"**Host:** {name}" + if address := str(host.get("primary_ip") or ""): + line += f" ({address})" + lines.append(line) + if operating_system := self.operating_system(host): + lines.append(f"**OS:** {operating_system}") + return lines + + def operating_system(self, host): + platform = str(host.get("platform") or "").strip() + version = str(host.get("os_version") or "").strip() + return f"{platform} {version}".strip() + + def attach_host(self, finding, host): + """The endpoint is the host itself - Fleet inventories machines, not URLs.""" + name = self.host_name(host) or str(host.get("primary_ip") or "").strip() + if not name or not self.usable_host(name): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(host=name)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=name)) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def flex_int(self, value): + """Fleet sends ids as either a number or a numeric string.""" + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + trimmed = value.strip().strip('"') + with suppress(ValueError): + return int(trimmed) + return 0 diff --git a/dojo/tools/fleet_vulnerabilities/__init__.py b/dojo/tools/fleet_vulnerabilities/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/fleet_vulnerabilities/parser.py b/dojo/tools/fleet_vulnerabilities/parser.py new file mode 100644 index 00000000000..6bfebe5a4a3 --- /dev/null +++ b/dojo/tools/fleet_vulnerabilities/parser.py @@ -0,0 +1,356 @@ +import json +import re +from contextlib import suppress +from datetime import datetime +from ipaddress import ip_address + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# CVSS floors. Note the default when a CVE carries NO score at all is Medium, not Info: Fleet enriches +# from the NVD, and a CVE it has not scored yet is an unknown rather than a non-issue. +CVSS_CRITICAL_FLOOR = 9.0 +CVSS_HIGH_FLOOR = 7.0 +CVSS_MEDIUM_FLOOR = 4.0 +UNSCORED_SEVERITY = "Medium" + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class FleetVulnerabilitiesParser: + + """ + Parses a Fleet host export, importing the CVEs found in installed software. + + Mirrors the vulnerability half of pkg/tools/fleet/connector/converter field for field so a file + import and an API sync deduplicate against each other instead of producing two copies of + everything. The compliance-policy half of the same export is a separate scan type - see the Fleet + Policies parser - because Fleet's own API models them as different things and the two carry + different deduplication keys. + + A Fleet host response nests software inside the host and the CVEs inside each software row, so one + file produces a finding per host per software per CVE. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeVulnerabilities. + return ["Fleet:Vulnerabilities - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Fleet:Vulnerabilities - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Fleet host export (JSON) and report the CVEs in each host's installed " + "software. Matches the scan type used by the Fleet connector so file and API findings " + "deduplicate. Failing compliance policies in the same export are imported by the Fleet " + "Policies parser." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Fleet Vulnerabilities Parser. + + Mirrors the connector's vulnerabilityFinding: + - title: " - on ". + - severity: graded from the CVSS score; an unscored CVE is Medium. + - description: the CVE summary, the software, the host, the CPE and the risk lines. + - mitigation: the version that resolves the CVE, when Fleet reported one. + - component_name / component_version: the installed software. + - cvssv3_score / publish_date: from Fleet's NVD enrichment. + - unique_id_from_tool: ":::". + """ + return [ + "title", + "severity", + "description", + "mitigation", + "component_name", + "component_version", + "cvssv3_score", + "publish_date", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Fleet Vulnerabilities Parser. + + Copied from the Fleet vulnerabilities block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for host in self.hosts(data): + for software in self.rows(host.get("software")): + for cve in self.rows(software.get("vulnerabilities")): + finding = self.build_finding(host, software, cve, test) + if finding is not None: + findings.append(finding) + return findings + + def hosts(self, data): + """ + Return the hosts in the export. + + Fleet's host list nests them under "hosts" and its single-host response under "host", so both + are accepted; a bare array of hosts works too. + """ + if isinstance(data, list): + return [host for host in data if isinstance(host, dict)] + if isinstance(data, dict): + if isinstance(data.get("hosts"), list): + return [host for host in data["hosts"] if isinstance(host, dict)] + if isinstance(data.get("host"), dict): + return [data["host"]] + # A host response, used directly. + if any(key in data for key in ("software", "policies", "hostname", "display_name")): + return [data] + + msg = ( + "A Fleet export is a host response, a JSON object with a 'hosts' list or a single 'host'; " + f"got {type(data).__name__}." + ) + raise TypeError(msg) + + def rows(self, value): + return [row for row in value if isinstance(row, dict)] if isinstance(value, list) else [] + + def build_finding(self, host, software, cve, test): + identifier = str(cve.get("cve") or "").strip() + if not identifier: + # The connector drops a vulnerability row with no CVE id: there is nothing to report. + return None + + name = str(software.get("name") or "") + version = str(software.get("version") or "") + score = self.score(cve) + + finding = Finding( + test=test, + title=self.title(host, name, version, identifier), + severity=self.severity(score), + description=self.describe(host, software, cve, name, version, score), + mitigation=self.mitigation(name, cve), + component_name=name.strip() or None, + component_version=version or None, + unique_id_from_tool=self.unique_id(host, name, version, identifier), + vuln_id_from_tool=identifier, + # Fleet reads an installed-software inventory from the host; nothing is exercised. + static_finding=True, + dynamic_finding=False, + ) + finding.unsaved_vulnerability_ids = [identifier] + finding.unsaved_tags = self.tags(host, cve) + + if score is not None: + finding.cvssv3_score = score + if published := self.published(cve): + finding.publish_date = published + + self.attach_host(finding, host) + return finding + + def title(self, host, name, version, identifier): + title = identifier + if name: + title += f" - {name}" + if version: + title += f" {version}" + if hostname := self.host_name(host): + title += f" on {hostname}" + return title + + def unique_id(self, host, name, version, identifier): + """":::" - the same CVE on two hosts is two findings.""" + return ":".join([str(self.flex_int(host.get("id"))), name, version, identifier]) + + def severity(self, score): + """ + Grade the CVSS score. + + A CVE Fleet has not scored is Medium rather than Info - it is an unknown, not a non-issue - + but an explicit zero is Info. + """ + if score is None: + return UNSCORED_SEVERITY + if score >= CVSS_CRITICAL_FLOOR: + return "Critical" + if score >= CVSS_HIGH_FLOOR: + return "High" + if score >= CVSS_MEDIUM_FLOOR: + return "Medium" + if score > 0: + return "Low" + return "Info" + + def describe(self, host, software, cve, name, version, score): + parts = [] + if summary := self.text(cve.get("cve_description")): + parts.append(summary) + + if name: + line = f"**Software:** {name}" + if version: + line += f" {version}" + extra = [value for value in (software.get("source"), software.get("vendor")) if value] + if extra: + line += " (" + ", ".join(str(value) for value in extra) + ")" + parts.append(line) + + parts.extend(self.host_lines(host)) + + if cpe := str(software.get("generated_cpe") or "").strip(): + parts.append(f"**CPE:** {cpe}") + + parts.extend(self.risk_lines(cve, score)) + return "\n\n".join(parts) + + def risk_lines(self, cve, score): + lines = [] + if score is not None: + lines.append(f"**CVSS:** {self.render_number(score)}") + epss = self.flex_float(cve.get("epss_probability")) + if epss is not None: + lines.append( + f"**EPSS:** {self.render_number(epss)} " + "(probability of exploitation in the next 30 days)", + ) + if cve.get("cisa_known_exploit") is True: + lines.append( + "**CISA KEV:** this CVE is on CISA's Known Exploited Vulnerabilities list and should " + "be prioritized regardless of its CVSS score.", + ) + return lines + + def mitigation(self, name, cve): + if fix := self.text(cve.get("resolved_in_version")): + return f"Upgrade {name or 'the affected software'} to {fix} or later, which resolves this CVE." + return ( + "Update the affected software to a release that resolves this CVE, following the vendor's " + "security advisory. Fleet did not report a fixed version for this vulnerability." + ) + + def tags(self, host, cve): + """ + The connector sorts and deduplicates its tags, so these come out alphabetical. + + Worth mirroring rather than tidying: a tag set that differs only in order still reads as a + change on every reimport. + """ + tags = ["vulnerability", "endpoint", str(host.get("platform") or "").strip()] + if cve.get("cisa_known_exploit") is True: + tags.append("cisa-known-exploited") + return sorted({tag for tag in tags if tag}) + + def published(self, cve): + """Fleet sends the NVD publication timestamp; only the date is kept.""" + published = self.text(cve.get("cve_published")) + if not published: + return None + with suppress(ValueError): + return datetime.strptime(published.split("T")[0], "%Y-%m-%d").date() + return None + + def host_name(self, host): + """Fleet's display name, then the computer name, then the hostname.""" + for key in ("display_name", "computer_name", "hostname"): + if value := str(host.get(key) or "").strip(): + return value + return "" + + def host_lines(self, host): + lines = [] + if name := self.host_name(host): + line = f"**Host:** {name}" + if address := str(host.get("primary_ip") or ""): + line += f" ({address})" + lines.append(line) + if operating_system := self.operating_system(host): + lines.append(f"**OS:** {operating_system}") + return lines + + def operating_system(self, host): + platform = str(host.get("platform") or "").strip() + version = str(host.get("os_version") or "").strip() + return f"{platform} {version}".strip() + + def attach_host(self, finding, host): + """The endpoint is the host itself - Fleet inventories machines, not URLs.""" + name = self.host_name(host) or str(host.get("primary_ip") or "").strip() + if not name or not self.usable_host(name): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(host=name)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=name)) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def score(self, cve): + return self.flex_float(cve.get("cvss_score")) + + def flex_float(self, value): + """ + Fleet sends these numbers as either a number or a numeric string. + + An absent or null value means "not scored"; an empty string is a zero, which is how the + connector's own decoder reads it. + """ + if value is None or isinstance(value, bool): + return None + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + trimmed = value.strip().strip('"') + if trimmed in {"", "null"}: + return 0.0 + with suppress(ValueError): + return float(trimmed) + return None + + def flex_int(self, value): + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + trimmed = value.strip().strip('"') + with suppress(ValueError): + return int(trimmed) + return 0 + + def render_number(self, value): + """Render a float the way the connector does - 7.5 stays 7.5, but 9.0 prints as 9.""" + return str(int(value)) if value == int(value) else repr(value) + + def text(self, value): + return str(value).strip() if value is not None else "" diff --git a/dojo/tools/fortytwocrunch/__init__.py b/dojo/tools/fortytwocrunch/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/fortytwocrunch/parser.py b/dojo/tools/fortytwocrunch/parser.py new file mode 100644 index 00000000000..d72359ac32b --- /dev/null +++ b/dojo/tools/fortytwocrunch/parser.py @@ -0,0 +1,388 @@ +import json +import re +from contextlib import suppress +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# 42Crunch grades both audit and scan issues on one integer scale, 1 = info and 5 = critical. +SEVERITY_BY_CRITICALITY = {5: "Critical", 4: "High", 3: "Medium", 2: "Low"} +DEFAULT_SEVERITY = "Info" + +# The audit sections, in the connector's own order, with the category label each one carries. +AUDIT_CATEGORIES = ( + ("security", "security"), + ("data", "data-validation"), + ("warnings", "warning"), + ("semanticErrors", "semantic-error"), + ("validationErrors", "validation-error"), +) + +TOOL_TAG = "42crunch" +TITLE_MAX_LENGTH = 250 + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class FortytwocrunchParser: + + """ + Parses a 42Crunch Security Audit or Conformance Scan report. + + Mirrors pkg/tools/fortytwocrunch/connector/converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + 42Crunch produces two different reports for one API and the connector converts both under a single + scan type: an audit of the OpenAPI definition (static) and a conformance scan of the running API + (dynamic). This parser accepts either, deciding by shape; see get_findings(). + + Both reports store their strings in INDEX TABLES and refer to them by integer, so a description or + a location has to be looked up rather than read - a finding built from the integers alone would + carry no text at all. See audit_pointer(), scan_description() and scan_pointer(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. + return ["42Crunch - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "42Crunch - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a 42Crunch Security Audit report (static, of the OpenAPI definition) or " + "Conformance Scan report (dynamic, of the running API). Matches the scan type used by the " + "42Crunch connector so file and API findings deduplicate - give the report's apiId to " + "deduplicate against connector findings." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the 42Crunch Parser. + + Mirrors the connector's ConvertAuditReport and ConvertScanReport: + - title: the issue description, then the specific one, then the issue id (audit); the resolved + injection description, then the operation (scan). Truncated to 250 characters. + - severity: 42Crunch's criticality integer, where 5 is the most severe. + - description: the prose, then the category and OpenAPI location (audit); the operation, URL, + response status and OpenAPI location (scan). + - file_path: the JSON Pointer into the OpenAPI definition (audit only). + - steps_to_reproduce: 42Crunch's own curl command (scan only). + - static_finding / dynamic_finding: an audit is static, a scan is dynamic. + - unique_id_from_tool: "/audit//" or + "/scan/ /". + """ + return [ + "title", + "severity", + "description", + "file_path", + "steps_to_reproduce", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + # No get_dedupe_fields: this scan type has no curated hash-field list, so it deduplicates with + # DefectDojo's default algorithm - which is exactly what the connector's own findings do today. + # Choosing hash fields here would also change how those findings deduplicate. + + def get_findings(self, filename, test): + data = json.load(filename) + if not isinstance(data, dict): + msg = ( + "A 42Crunch report is a JSON object - a Security Audit report or a Conformance Scan " + f"report; got {type(data).__name__}." + ) + raise TypeError(msg) + + api_id, report = self.unwrap(data) + + if self.is_scan_report(report): + return self.scan_findings(api_id, report, test) + if self.is_audit_report(report): + return self.audit_findings(api_id, report, test) + + msg = ( + "This is neither a 42Crunch Security Audit report (an 'index' table with 'security', " + "'data' or 'warnings' sections) nor a Conformance Scan report (a 'data.paths' object)." + ) + raise TypeError(msg) + + def unwrap(self, data): + """ + Return the API id and the report itself. + + The API id is part of every identity the connector builds, and a downloaded report does not + carry it - so it is read from the file when a wrapper states it. WITHOUT it the identities + differ from the connector's and file findings will not deduplicate against synced ones, which + is why the docs ask for it. + """ + api_id = "" + for key in ("apiId", "api_id", "apiID"): + if value := str(data.get(key) or "").strip(): + api_id = value + break + + report = data + for key in ("report", "audit", "scan"): + if isinstance(data.get(key), dict): + report = data[key] + break + return api_id, report + + def is_audit_report(self, report): + return isinstance(report.get("index"), list) or any( + isinstance(report.get(key), dict) for key, _ in AUDIT_CATEGORIES + ) + + def is_scan_report(self, report): + """A scan report is recognised by its per-path/method issue tree.""" + return bool(self.block(self.block(report, "data"), "paths")) + + def block(self, row, key): + if not isinstance(row, dict): + return {} + value = row.get(key) + return value if isinstance(value, dict) else {} + + # --- Security Audit report (static) --- + + def audit_findings(self, api_id, report, test): + index = [str(entry) for entry in report.get("index") or []] + findings = [] + for key, category in AUDIT_CATEGORIES: + issues = self.block(self.block(report, key), "issues") + for issue_id, group in issues.items(): + if not isinstance(group, dict): + continue + occurrences = group.get("issues") + if not isinstance(occurrences, list): + continue + findings.extend( + self.audit_finding(api_id, category, str(issue_id), group, occurrence, index, test) + for occurrence in occurrences + if isinstance(occurrence, dict) + ) + return findings + + def audit_finding(self, api_id, category, issue_id, group, occurrence, index, test): + pointer = self.audit_pointer(occurrence, index) + description = str(group.get("description") or "") + specific = str(occurrence.get("specificDescription") or "") + + identifier = f"{api_id}/audit/{issue_id}/" + if pointer: + identifier += pointer + else: + # No resolvable location, so the raw pointer index keeps two occurrences of one issue + # apart. Without it they would collapse into a single finding. + identifier += f"#{self.integer(occurrence.get('pointer'))}" + + finding = Finding( + test=test, + title=self.truncate(self.first(description, specific, issue_id)), + severity=self.severity(group.get("criticality")), + description=self.audit_description(specific, description, category, pointer), + file_path=pointer or None, + unique_id_from_tool=identifier, + vuln_id_from_tool=issue_id, + # An audit reads the OpenAPI definition; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.unsaved_tags = [TOOL_TAG, "audit", category] + return finding + + def audit_pointer(self, occurrence, index): + """ + Resolve an occurrence's integer pointer against the report's index table. + + An out-of-range index resolves to nothing rather than raising: the report is still importable, + and the finding keeps its identity through the raw index instead. + """ + position = self.integer(occurrence.get("pointer")) + if 0 <= position < len(index): + return index[position] + return "" + + def audit_description(self, specific, description, category, pointer): + parts = [] + if paragraph := self.first(specific, description): + parts.append(f"{paragraph}\n\n") + for prefix, value in (("Category:", category), ("OpenAPI location:", pointer)): + if str(value or "").strip(): + parts.append(f"* **{prefix}** {value}\n") + return "".join(parts) + + # --- Conformance Scan report (dynamic) --- + + def scan_findings(self, api_id, report, test): + data = self.block(report, "data") + index = self.block(data, "index") + pointers = [str(entry) for entry in index.get("jsonPointers") or []] + templates = [str(entry) for entry in index.get("injectionDescriptions") or []] + + findings = [] + for path, methods in self.block(data, "paths").items(): + if not isinstance(methods, dict): + continue + for method, holder in methods.items(): + issues = holder.get("issues") if isinstance(holder, dict) else None + if not isinstance(issues, list): + continue + findings.extend( + self.scan_finding(api_id, str(path), str(method), issue, pointers, templates, test) + for issue in issues + if isinstance(issue, dict) + ) + return findings + + def scan_finding(self, api_id, path, method, issue, pointers, templates, test): + operation = f"{method.upper()} {path}" + description = self.scan_description(issue, templates) + check = self.integer(issue.get("injectionDescription")) + + # The scan issue's own id is a per-scan UUID, so it is NOT stable across scans. The identity + # uses the operation plus the check index instead, which is the same for the same issue. + finding = Finding( + test=test, + title=self.truncate(self.first(description, operation)), + severity=self.severity(issue.get("criticality")), + description=self.scan_description_block(issue, description, operation, pointers), + steps_to_reproduce=str(issue.get("curl") or "") or None, + unique_id_from_tool=f"{api_id}/scan/{operation}/{check}", + # A conformance scan exercises the running API. + static_finding=False, + dynamic_finding=True, + active=True, + ) + finding.unsaved_tags = [TOOL_TAG, "scan", method.upper()] + self.add_endpoint(finding, str(issue.get("url") or "")) + return finding + + def scan_description(self, issue, templates): + """ + Resolve the issue's injection-description template and substitute its parameters. + + The template is referenced by integer and its parameters are a separate list, so each "%s" is + filled in turn - one substitution per parameter, as the connector does. Reading the integer + alone would leave the finding with no description. + """ + position = self.integer(issue.get("injectionDescription")) + if not (0 <= position < len(templates)): + return "" + + result = templates[position] + for param in issue.get("injectionDescriptionParams") or []: + result = result.replace("%s", str(param), 1) + return result + + def scan_description_block(self, issue, description, operation, pointers): + parts = [] + if description.strip(): + parts.append(f"{description}\n\n") + for prefix, value in (("Operation:", operation), ("URL:", str(issue.get("url") or ""))): + if str(value or "").strip(): + parts.append(f"* **{prefix}** {value}\n") + + if (status := self.integer(issue.get("responseHttpStatusCode"))) != 0: + parts.append(f"* **Response status:** {status}\n") + if pointer := self.scan_pointer(issue, pointers): + parts.append(f"* **OpenAPI location:** {pointer}\n") + return "".join(parts) + + def scan_pointer(self, issue, pointers): + position = self.integer(issue.get("jsonPointer")) + if 0 <= position < len(pointers): + return pointers[position] + return "" + + def add_endpoint(self, finding, raw_url): + """ + The endpoint is the URL's ORIGIN only - scheme and host, no path. + + That is what the connector records: a conformance scan hits many paths on one host, and the + operation is already in the description and the identity. + """ + if not raw_url: + return + with suppress(ValueError): + parsed = urlparse(raw_url) + host = parsed.hostname or "" + if not parsed.scheme or not host or not self.usable_host(host): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append( + LocationData.url(host=host, protocol=parsed.scheme, port=parsed.port), + ) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append( + Endpoint(host=host, protocol=parsed.scheme, port=parsed.port), + ) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else + makes Endpoint.clean() raise, and that fails the WHOLE import rather than the one finding. + The URL is still in the description either way. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + # --- shared --- + + def severity(self, criticality): + """ + 42Crunch's criticality integer, where 5 is the most severe. + + This is the inverse of nothing - it is its own scale - but 1 means informational, so reading a + low number as a low severity is right here and wrong for a scanner that grades 1 as critical. + Zero and out-of-range values are Info. + """ + return SEVERITY_BY_CRITICALITY.get(self.integer(criticality), DEFAULT_SEVERITY) + + def truncate(self, value): + """At most 250 characters, ending in an ellipsis when it is cut, as the connector does.""" + if len(value) <= TITLE_MAX_LENGTH: + return value + return value[: TITLE_MAX_LENGTH - 3] + "..." + + def first(self, *values): + """ + The first value that is not the empty string. + + The connector does NOT trim before testing, so a value that is only whitespace counts as + present - which a plain truthiness test reproduces exactly, since "" is falsy and " " is not. + """ + for value in values: + if value: + return value + return "" + + def integer(self, value): + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 diff --git a/dojo/tools/fossa/__init__.py b/dojo/tools/fossa/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/fossa/parser.py b/dojo/tools/fossa/parser.py new file mode 100644 index 00000000000..e9c540c4c80 --- /dev/null +++ b/dojo/tools/fossa/parser.py @@ -0,0 +1,411 @@ +import json +from contextlib import suppress +from datetime import datetime + +from dojo.models import Finding + +# The issue "type" FOSSA reports for security vulnerabilities; every other value is a licensing or +# quality issue. +TYPE_VULNERABILITY = "vulnerability" + +# CVSS v3 band floors, used only when FOSSA reports "unknown" (or no) severity. +CVSS_CRITICAL_FLOOR = 9.0 +CVSS_HIGH_FLOOR = 7.0 +CVSS_MEDIUM_FLOOR = 4.0 + +# Grades FOSSA's licensing and quality issue types, none of which carry a severity field. FOSSA +# publishes no severity for these, so this table is the connector's: a policy conflict is a denial +# (High), a flag is advisory (Medium), unlicensed code is a compliance risk (Medium), a denylisted +# dependency is High, and the remaining quality signals are Low. +# +# Both spellings of the risk_* types are mapped, because FOSSA's docs table uses hyphens +# (risk_empty-package) while fossa-cli's wire format uses underscores. +LICENSING_SEVERITY_BY_TYPE = { + "policy_conflict": "High", + "policy_flag": "Medium", + "unlicensed_dependency": "Medium", + "unlicensed_and_public": "Medium", + "blacklisted_dependency": "High", + "outdated_dependency": "Low", + "risk_abandonware": "Low", + "risk_empty_package": "Low", + "risk_empty-package": "Low", + "risk_native_code": "Low", + "risk_native-code": "Low", +} + +VULNERABILITY_SEVERITY = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} + + +class FossaParser: + + """ + Parses a FOSSA v2 issues export. + + Mirrors pkg/tools/fossa/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + FOSSA is an SCA and licence-compliance tool: an issue hangs off a dependency, never a file and + line, so the dependency coordinates are the only location a finding has. One issue can affect + several projects at once, and the connector makes that one finding per project - see + unique_id_from_tool in get_fields(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["FOSSA - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "FOSSA - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a FOSSA v2 issues export (JSON). Matches the scan type used by the FOSSA " + "connector so file and API findings deduplicate. Covers both FOSSA's security " + "vulnerabilities and its licensing and quality issues." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the FOSSA Parser. + + Mirrors the connector's IssueToFinding: + - title: for a vulnerability " - ()"; for a licensing + issue " - in ". Both degrade as the converter's do. + - severity: FOSSA's own for a vulnerability, falling back to the CVSS v3 bands when FOSSA + says "unknown"; the LICENSING_SEVERITY_BY_TYPE table otherwise. + - description: FOSSA's advisory text, then the dependency coordinates and version ranges. + - mitigation / references / cvssv3 / cvssv3_score / cwe: vulnerabilities only. + - component_name / component_version: the affected dependency's name and version. + - unique_id_from_tool: ":". The suffix matters - one FOSSA issue + can affect several projects, becoming one finding per DefectDojo product, and those + findings must not share a tool id. + """ + return [ + "title", + "severity", + "description", + "mitigation", + "references", + "date", + "cvssv3", + "cvssv3_score", + "cwe", + "component_name", + "component_version", + "unique_id_from_tool", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the FOSSA Parser. + + Copied from the FOSSA block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Diverging would stop file findings + merging with API-synced ones. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + issues = self.extract_issues(data) + + findings = {} + for issue in issues: + if not isinstance(issue, dict): + continue + for locator in self.locators(issue): + finding = self.build_finding(issue, locator, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_issues(self, data): + """FOSSA's getIssues response wraps the list under "issues"; a bare array is accepted too.""" + if isinstance(data, list): + return data + if isinstance(data, dict) and isinstance(data.get("issues"), list): + return data["issues"] + msg = ( + "A FOSSA export is a JSON object with an 'issues' list, or a bare array of issues; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def locators(self, issue): + """ + Return the project locators this issue should produce a finding for. + + The connector converts an issue once per project it syncs, suffixing the tool id with that + project's locator. An export carries the same locators under the issue's own "projects", so + one finding per project reproduces the connector exactly. An export with no project context + cannot reproduce that suffix, so the tool id is the issue id alone. + """ + projects = issue.get("projects") + if isinstance(projects, list): + found = [p["id"] for p in projects if isinstance(p, dict) and p.get("id")] + if found: + return found + return [None] + + def build_finding(self, issue, locator, test): + source = issue.get("source") or {} + issue_id = issue.get("id") + component = source.get("name") or "" + version = source.get("version") or "" + + finding = Finding( + test=test, + severity=self.severity(issue), + date=self.date(issue.get("createdAt")), + component_name=component or None, + component_version=version or None, + unique_id_from_tool=f"{issue_id}:{locator}" if locator else str(issue_id), + active=True, + # FOSSA reads a dependency graph, never a running service. + static_finding=True, + dynamic_finding=False, + ) + finding.unsaved_tags = self.tags(issue) + + if self.is_vulnerability(issue): + self.apply_vulnerability_fields(finding, issue, component, version) + else: + finding.title = self.licensing_title(issue, component) + finding.description = self.licensing_description(issue) + return finding + + def apply_vulnerability_fields(self, finding, issue, component, version): + """Fill in the fields that exist only on FOSSA's vulnerability issues.""" + finding.title = self.vulnerability_title(issue, component, version) + finding.description = self.vulnerability_description(issue) + finding.mitigation = self.mitigation(issue) or None + finding.references = self.references(issue) or None + finding.cvssv3_score = issue.get("cvss") or None + finding.cvssv3 = issue.get("cvssVector") or None + + if issue.get("cve"): + finding.unsaved_vulnerability_ids = [issue["cve"]] + cwe = self.cwe_number(issue.get("cwes")) + if cwe: + finding.cwe = cwe + + def is_vulnerability(self, issue): + """ + Decide whether an issue is a security vulnerability. + + FOSSA sets type="vulnerability" for those, but the converter also treats an issue carrying + vulnerability-only fields as one, so a missing or renamed type value cannot silently + downgrade a CVE to a licensing finding. + """ + if (issue.get("type") or "").strip().lower() == TYPE_VULNERABILITY: + return True + return bool(issue.get("cve") or issue.get("vulnId") or issue.get("cvssVector")) + + def severity(self, issue): + if self.is_vulnerability(issue): + graded = VULNERABILITY_SEVERITY.get((issue.get("severity") or "").strip().lower()) + # FOSSA reports "unknown" often enough that the CVSS bands matter. + return graded or self.severity_from_cvss(issue.get("cvss") or 0) + + issue_type = (issue.get("type") or "").strip().lower() + if issue_type in LICENSING_SEVERITY_BY_TYPE: + return LICENSING_SEVERITY_BY_TYPE[issue_type] + # An unrecognised risk_* signal is still quality noise; anything else is genuinely unknown. + if issue_type.startswith("risk_"): + return "Low" + return "Info" + + def severity_from_cvss(self, score): + """Grade a CVSS base score using the standard v3 bands.""" + with suppress(TypeError, ValueError): + score = float(score) + if score >= CVSS_CRITICAL_FLOOR: + return "Critical" + if score >= CVSS_HIGH_FLOOR: + return "High" + if score >= CVSS_MEDIUM_FLOOR: + return "Medium" + if score > 0: + return "Low" + return "Info" + + def vulnerability_title(self, issue, component, version): + """Prefer " - ()", degrading as the converter does.""" + identifier = issue.get("cve") or issue.get("vulnId") or "" + if not identifier: + return issue.get("title") or f"FOSSA vulnerability {issue.get('id')}" + if not component: + return identifier + title = f"{identifier} - {component}" + if version: + title += f" ({version})" + return title + + def licensing_title(self, issue, component): + """Render " - in ", omitting whichever parts FOSSA left empty.""" + title = (issue.get("type") or "").strip() + if not title: + title = f"FOSSA licensing issue {issue.get('id')}" + if issue.get("license"): + title += f" - {issue['license']}" + if component: + title += f" in {component}" + return title + + def vulnerability_description(self, issue): + lines = [] + if issue.get("details"): + lines.append(issue["details"].strip() + "\n") + + self.write_package_lines(lines, issue) + self.write_list_line(lines, "Affected versions", issue.get("affectedVersionRanges")) + self.write_list_line(lines, "Patched versions", issue.get("patchedVersionRanges")) + self.write_list_line(lines, "CWEs", issue.get("cwes")) + self.write_list_line(lines, "CPEs", issue.get("cpes")) + + if issue.get("published"): + lines.append(f"**Published:** {issue['published']}") + if issue.get("cveStatus"): + lines.append(f"**CVE status:** {issue['cveStatus']}") + return "\n".join(lines).rstrip("\n") + + def licensing_description(self, issue): + lines = [] + if issue.get("type"): + lines.append(f"**Issue type:** {issue['type']}") + if issue.get("license"): + lines.append(f"**License:** {issue['license']}") + self.write_package_lines(lines, issue) + return "\n".join(lines).rstrip("\n") + + def write_package_lines(self, lines, issue): + """ + Write the dependency coordinates and depths shared by every issue category. + + FOSSA is SCA, so these coordinates are the only location a finding has. + """ + source = issue.get("source") + if isinstance(source, dict): + if source.get("id"): + lines.append(f"**Package:** {source['id']}") + if name := source.get("name"): + version = source.get("version") or "" + lines.append(f"**Dependency:** {name}@{version}" if version else f"**Dependency:** {name}") + if source.get("packageManager"): + lines.append(f"**Package manager:** {source['packageManager']}") + if source.get("url"): + lines.append(f"**Package URL:** {source['url']}") + + depths = issue.get("depths") + if isinstance(depths, dict): + direct, deep = depths.get("direct") or 0, depths.get("deep") or 0 + if direct > 0 or deep > 0: + lines.append(f"**Dependency depths:** direct {direct}, transitive {deep}") + + lines.append(f"**FOSSA issue ID:** {issue.get('id')}") + + def write_list_line(self, lines, label, values): + """Write one "**Label:** a, b, c" line, or nothing when the list is empty.""" + if not isinstance(values, list): + return + joined = ", ".join(str(v) for v in values if str(v).strip()) + if joined: + lines.append(f"**{label}:** {joined}") + + def mitigation(self, issue): + """ + Render FOSSA's upgrade advice. + + The complete fix, which resolves the issue, comes first, then any partial fix, each with the + semver distance FOSSA reports for it. + """ + remediation = issue.get("remediation") + if not isinstance(remediation, dict): + return "" + + lines = [] + for label, fix_key, distance_key in ( + ("Complete fix", "completeFix", "completeFixDistance"), + ("Partial fix", "partialFix", "partialFixDistance"), + ): + if fix := remediation.get(fix_key): + suffix = "" + if distance := remediation.get(distance_key): + suffix = f" ({distance} version bump)" + lines.append(f"**{label}:** upgrade to {fix}{suffix}") + return "\n".join(lines) + + def references(self, issue): + """ + Join the issue's reference links, one per line. + + FOSSA sends a reference as either a bare string or a {url, title} object, and the connector's + Reference.Link() prefers the URL then the title. + """ + refs = issue.get("references") + if not isinstance(refs, list): + return "" + + links = [] + for reference in refs: + if isinstance(reference, str): + link = reference + elif isinstance(reference, dict): + link = reference.get("url") or reference.get("title") or "" + else: + continue + if link: + links.append(link) + return "\n".join(links) + + def tags(self, issue): + category = TYPE_VULNERABILITY if self.is_vulnerability(issue) else "licensing" + tags = [] + if issue.get("type"): + tags.append(f"fossa:{issue['type']}") + tags.append(f"category:{category}") + + source = issue.get("source") + if isinstance(source, dict): + if source.get("packageManager"): + tags.append(f"package-manager:{source['packageManager']}") + if source.get("id"): + tags.append(source["id"]) + return tags + + def cwe_number(self, cwes): + """Return the first parseable CWE in the list as an integer ("CWE-1321" -> 1321).""" + if not isinstance(cwes, list): + return 0 + for cwe in cwes: + raw = str(cwe).strip().removeprefix("CWE-") + with suppress(ValueError): + number = int(raw) + if number > 0: + return number + return 0 + + def date(self, timestamp): + """ + Convert a FOSSA timestamp to a date. + + The converter falls back to the leading date portion for any timestamp shape the RFC3339 + parse rejects, so a truncated or oddly-formatted value still dates the finding. + """ + trimmed = (timestamp or "").strip() + if not trimmed: + return None + with suppress(ValueError): + return datetime.fromisoformat(trimmed).date() + with suppress(ValueError): + return datetime.strptime(trimmed[:10], "%Y-%m-%d").date() + return None diff --git a/dojo/tools/gitguardian/__init__.py b/dojo/tools/gitguardian/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/gitguardian/parser.py b/dojo/tools/gitguardian/parser.py new file mode 100644 index 00000000000..a40ea6f21bf --- /dev/null +++ b/dojo/tools/gitguardian/parser.py @@ -0,0 +1,175 @@ +import json + +from dojo.models import Finding + +# Mirrors severityFromString() in the GitGuardian connector's converter; anything unrecognised +# becomes Info. +SEVERITY_MAP = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# The connector's validityNarrative(). GitGuardian actively checks whether a discovered credential +# still authenticates, and that verdict is the most useful thing it reports, so it is spelled out +# rather than left as a bare enum value. +VALIDITY_NARRATIVE = { + "valid": "**Validity:** valid — GitGuardian confirmed this credential is still live and actively exploitable.", + "invalid": "**Validity:** invalid — the credential no longer authenticates.", + "no_checker": "**Validity:** unverified — GitGuardian could not automatically check whether this credential is live; verify manually.", + "not_checked": "**Validity:** unverified — GitGuardian could not automatically check whether this credential is live; verify manually.", + "failed_to_check": "**Validity:** unverified — GitGuardian could not automatically check whether this credential is live; verify manually.", +} + +# The connector's fixed mitigation text: every incident here is an exposed credential, and the +# remediation is always the same sequence. +MITIGATION = ( + "Revoke and rotate the exposed credential, then remove it from the codebase and purge it " + "from version-control history. Review the incident in GitGuardian for the affected locations." +) + + +class GitGuardianParser: + + """ + Parses a GitGuardian secret-incidents export. + + Mirrors pkg/tools/gitguardian/connector/converter.go field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + One finding per GitGuardian incident, not per occurrence: an incident is a distinct exposed + credential, and its occurrence count is reported in the description. + + Note the parser imports no secret value. GitGuardian's incidents endpoint does not return the + matched secret, and nothing here reconstructs one. + """ + + def get_scan_types(self): + # Byte-identical to ScanTypeName in the connector. + return ["GitGuardian - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "GitGuardian - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a GitGuardian secret-incidents export (JSON). Matches the scan type used by the " + "GitGuardian connector so file and API findings deduplicate. One finding per incident." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the GitGuardian Parser. + + Mirrors the connector's toFinding: + - title: GitGuardian's own incident name, falling back to " detected". + - severity: the incident severity, anything unrecognised Info. + - description: what was exposed, the detector's family and category, GitGuardian's validity + verdict, whether the secret is revoked, the occurrence count, and the incident link. + - mitigation: the fixed revoke-rotate-purge sequence. + - url: the GitGuardian incident link. + - verified: true only when GitGuardian confirmed the credential is still live. + - vuln_id_from_tool: the detector name, e.g. aws_iam. + - unique_id_from_tool: "gitguardian-incident-". + """ + return [ + "title", + "severity", + "description", + "mitigation", + "url", + "verified", + "vuln_id_from_tool", + "unique_id_from_tool", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the GitGuardian Parser. + + Copied from the GitGuardian block in the Pro connector settings, which pairs the plain + hash_code algorithm with the unique id alone - incident ids are stable, so nothing else is + needed. Diverging would stop file findings merging with API-synced ones. + """ + return ["unique_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + incidents = self.extract_incidents(data) + + findings = {} + for incident in incidents: + if not isinstance(incident, dict): + continue + finding = self.build_finding(incident, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_incidents(self, data): + """GitGuardian's incidents endpoint returns a bare array; envelopes are accepted too.""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("incidents", "results", "data"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "A GitGuardian export is a JSON array of secret incidents, or an object with an " + f"'incidents' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, incident, test): + detector = incident.get("detector") or {} + + finding = Finding( + test=test, + title=self.title(incident, detector), + severity=SEVERITY_MAP.get( + (incident.get("severity") or "").strip().lower(), DEFAULT_SEVERITY, + ), + description=self.describe(incident, detector), + mitigation=MITIGATION, + vuln_id_from_tool=detector.get("name") or None, + unique_id_from_tool=f"gitguardian-incident-{incident.get('id')}", + ) + + if incident.get("gitguardian_url"): + finding.url = incident["gitguardian_url"] + # A confirmed-live secret is a verified finding. The converter sets this only for "valid"; + # an unchecked credential is not evidence of anything either way. + if (incident.get("validity") or "").strip().lower() == "valid": + finding.verified = True + return finding + + def title(self, incident, detector): + """GitGuardian's own incident name when it has one, otherwise " detected".""" + if incident.get("incident_name"): + return incident["incident_name"] + return f"{detector.get('display_name') or 'Secret'} detected" + + def describe(self, incident, detector): + kind = detector.get("display_name") or "secret" + parts = [f"GitGuardian detected an exposed **{kind}**."] + + details = [] + if detector.get("family"): + details.append(f"family {detector['family']}") + if detector.get("category"): + details.append(f"category {detector['category']}") + if details: + parts.append("**Detector:** " + ", ".join(details)) + + narrative = VALIDITY_NARRATIVE.get((incident.get("validity") or "").strip().lower()) + if narrative: + parts.append(narrative) + + if incident.get("secret_revoked"): + parts.append("**Revoked:** the secret has been marked revoked in GitGuardian.") + if (incident.get("occurrences_count") or 0) > 0: + parts.append(f"**Occurrences:** {incident['occurrences_count']}") + if incident.get("gitguardian_url"): + parts.append("**Details:** " + incident["gitguardian_url"]) + return "\n\n".join(parts) diff --git a/dojo/tools/googlescc/__init__.py b/dojo/tools/googlescc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/googlescc/parser.py b/dojo/tools/googlescc/parser.py new file mode 100644 index 00000000000..02124eb2727 --- /dev/null +++ b/dojo/tools/googlescc/parser.py @@ -0,0 +1,192 @@ +import json + +from dojo.models import Finding + +# Mirrors severityFromLevel() in the Google Cloud SCC connector's converter. SCC also emits +# SEVERITY_UNSPECIFIED, which falls through to Info like anything else unrecognised. +SEVERITY_MAP = { + "CRITICAL": "Critical", + "HIGH": "High", + "MEDIUM": "Medium", + "LOW": "Low", +} +DEFAULT_SEVERITY = "Info" + +# SCC does not always set a category, and the connector names the finding rather than leaving the +# title empty. +DEFAULT_CATEGORY = "Security Command Center finding" + + +class GoogleSCCParser: + + """ + Parses a Google Cloud Security Command Center findings export. + + Mirrors pkg/tools/googlescc/connector/converter.go field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + SCC's ListFindings response pairs each finding with the resource it was found on, and the two are + siblings rather than nested - the resource carries the display name and type that make the finding + readable, so both halves have to be read. + """ + + def get_scan_types(self): + # Byte-identical to ScanTypeName in the connector. + return ["Google Cloud SCC - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Google Cloud SCC - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Google Cloud Security Command Center findings export (JSON, the ListFindings " + "response). Matches the scan type used by the Google Cloud SCC connector so file and API " + "findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Google Cloud SCC Parser. + + Mirrors the connector's toFinding: + - title: " - ", or the category alone. + - severity: SCC's severity level; anything unrecognised Info. + - description: SCC's description, the finding class, the affected resource, and the link. + - url: SCC's externalUri, when it has one. + - vuln_id_from_tool: the SCC category, which is its rule identifier. + - unique_id_from_tool: the finding's full resource name, which is globally unique. + - cvssv3_score: the CVE's CVSS v3 base score, for vulnerability-class findings. + """ + return [ + "title", + "severity", + "description", + "url", + "cvssv3_score", + "vuln_id_from_tool", + "unique_id_from_tool", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Google Cloud SCC Parser. + + Copied from the Google Cloud SCC block in the Pro connector settings: the finding's full + resource name is globally unique, so the plain hash_code algorithm hashes it alone. + """ + return ["unique_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + results = self.extract_results(data) + + findings = {} + for result in results: + if not isinstance(result, dict): + continue + finding_data, resource = self.split(result) + if finding_data is None: + continue + finding = self.build_finding(finding_data, resource, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_results(self, data): + """SCC's ListFindings response pages the results under "listFindingsResults".""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("listFindingsResults", "results", "findings"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "A Google Cloud SCC export is the ListFindings response, a JSON object with a " + f"'listFindingsResults' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def split(self, result): + """ + Separate the finding from the resource it was found on. + + They are siblings in the response, not nested. A result that carries the finding's own fields + directly is accepted too, for an export somebody has already flattened. + """ + finding = result.get("finding") + if isinstance(finding, dict): + resource = result.get("resource") + return finding, resource if isinstance(resource, dict) else {} + # Flattened: the entry is the finding itself. + if result.get("name") or result.get("category"): + resource = result.get("resource") + return result, resource if isinstance(resource, dict) else {} + return None, {} + + def build_finding(self, finding_data, resource, test): + category = finding_data.get("category") or "" + external_uri = finding_data.get("externalUri") or "" + + finding = Finding( + test=test, + title=self.title(category, resource), + severity=SEVERITY_MAP.get( + (finding_data.get("severity") or "").strip().upper(), DEFAULT_SEVERITY, + ), + description=self.describe(finding_data, resource, external_uri), + # The SCC category is its rule identifier, e.g. PUBLIC_BUCKET_ACL. + vuln_id_from_tool=category or None, + # The finding's full resource name is globally unique across the organisation. + unique_id_from_tool=finding_data.get("name"), + ) + + if external_uri: + finding.url = external_uri + + cve = self.cve(finding_data) + if cve: + finding.unsaved_vulnerability_ids = [cve["id"]] + cvss = cve.get("cvssv3") + if isinstance(cvss, dict) and (cvss.get("baseScore") or 0) > 0: + finding.cvssv3_score = cvss["baseScore"] + return finding + + def title(self, category, resource): + """The category, qualified by the resource it was found on when SCC names one.""" + label = category or DEFAULT_CATEGORY + display_name = resource.get("displayName") or "" + if display_name: + return f"{label} - {display_name}" + return label + + def describe(self, finding_data, resource, external_uri): + parts = [] + if finding_data.get("description"): + parts.append(finding_data["description"]) + if finding_data.get("findingClass"): + parts.append(f"**Finding class:** {finding_data['findingClass']}") + + # The resource type and name, space-joined, in the connector's order. + details = [ + value for value in (resource.get("type"), resource.get("name")) if value + ] + if details: + parts.append("**Resource:** " + " ".join(details)) + + if external_uri: + parts.append(f"**Reference:** {external_uri}") + return "\n\n".join(parts) + + def cve(self, finding_data): + """ + The CVE on a vulnerability-class finding, if there is one. + + SCC reports many classes - misconfiguration, threat, observation - and only some carry a CVE, + so this is nested behind two optional objects. + """ + vulnerability = finding_data.get("vulnerability") + if not isinstance(vulnerability, dict): + return None + cve = vulnerability.get("cve") + if isinstance(cve, dict) and cve.get("id"): + return cve + return None diff --git a/dojo/tools/groupib/__init__.py b/dojo/tools/groupib/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/groupib/parser.py b/dojo/tools/groupib/parser.py new file mode 100644 index 00000000000..ef7488f82bf --- /dev/null +++ b/dojo/tools/groupib/parser.py @@ -0,0 +1,333 @@ +import json +import re +from contextlib import suppress +from datetime import date as _date +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Group-IB writes its severity as a phrase, e.g. "Critical severity", so it is matched by CONTAINMENT +# in this order rather than by equality. +SEVERITY_KEYWORDS = ( + ("critical", "Critical"), + ("high", "High"), + ("medium", "Medium"), + ("low", "Low"), + ("info", "Info"), +) +DEFAULT_SEVERITY = "Info" + +# The ASM issue lifecycle. "Detected" and "Under review" are open. +STATUS_SOLVED = "solved" +STATUS_FALSE_POSITIVE = "false positive" +STATUS_IGNORED = "ignored" + +NO_DETAILS = "No additional details were provided by Group-IB ASM." + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class GroupibParser: + + """ + Parses a Group-IB ASM (Attack Surface Management) issue export. + + Mirrors pkg/tools/groupib/connector/issue_converter field for field so a file import and an API sync + deduplicate against each other instead of producing two copies of everything. + + Two fields are both called "status" and mean entirely different things: the issue's own status is + its LIFECYCLE state (Detected, Solved, Ignored, False positive), while the status inside its body is + the SEVERITY label. Reading one for the other would grade every finding Info and leave every solved + issue open; see severity() and apply_status(). + + ASM findings come from external scanning, so they are dynamic. The affected asset becomes an + ENDPOINT when it looks like a host, an address or a URL, and the COMPONENT otherwise - Group-IB + reports software names and SSL descriptors in the same field; see add_asset(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. + return ["Group-IB ASM - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Group-IB ASM - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Group-IB ASM issue export (JSON). Matches the scan type used by the Group-IB " + "connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Group-IB Parser. + + Mirrors the connector's ConvertIssue: + - title: the issue type, then its reason, then its category, then the issue id. + - severity: the SEVERITY label inside the body; see severity(). + - description: the category, type, asset and its status, the reason, details and context. + - date: when the issue was first seen. + - active / is_mitigated / false_p / out_of_scope: the issue's LIFECYCLE status. + - tags: the MITRE ATT&CK techniques, as "mitre-attack:". + - unique_id_from_tool: the issue id; vuln_id_from_tool: the issue type. + """ + return [ + "title", + "severity", + "description", + "component_name", + "date", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "is_mitigated", + "false_p", + "out_of_scope", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Group-IB Parser. + + Copied from the Group-IB ASM block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields - only the title and the severity, since + an ASM issue has neither a file nor a package to hash. + """ + return ["title", "severity"] + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(row, test) for row in self.rows(data)] + + def rows(self, data): + """ + Return the issues in the export. + + Group-IB answers {"items": [...]}, and its own client tolerates several spellings of that key, + so all of them are accepted. A bare array works too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("items", "data", "results", "issues"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A Group-IB ASM export is the issues response, a JSON object with an 'items' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, test): + body = self.block(row, "body") + + finding = Finding( + test=test, + title=self.title(row, body), + severity=self.severity(body), + description=self.describe(body), + unique_id_from_tool=str(row.get("id") or "") or None, + vuln_id_from_tool=str(body.get("type") or "") or None, + # ASM findings come from external scanning, so they are observed rather than read. + static_finding=False, + dynamic_finding=True, + ) + if tags := self.mitre_tags(body.get("alertMitreInfo")): + finding.unsaved_tags = tags + if date := self.date(row.get("firstSeen")): + finding.date = date + + self.add_asset(finding, str(body.get("asset") or "").strip()) + self.apply_status(finding, str(row.get("status") or "")) + return finding + + def block(self, row, key): + if not isinstance(row, dict): + return {} + value = row.get(key) + return value if isinstance(value, dict) else {} + + def title(self, row, body): + for key in ("type", "reason", "category"): + if value := str(body.get(key) or ""): + return value + return f"Group-IB ASM issue {row.get('id') or ''}" + + def severity(self, body): + """ + The severity label inside the issue body - NOT the issue's own status field. + + Group-IB writes it as a phrase, "Critical severity", so it is matched by containment. The order + matters: "critical" is tested before "high" so a label naming both is graded by the worse one. + An unrecognised label is Info, which the connector logs a warning for. + """ + label = str(body.get("status") or "").lower() + for keyword, severity in SEVERITY_KEYWORDS: + if keyword in label: + return severity + return DEFAULT_SEVERITY + + def describe(self, body): + """ + The informative fields, one per line. + + An issue with none of them says so rather than arriving with an empty description, which would + read as though the data had been lost in transit. + """ + fields = ( + ("Category", "category"), + ("Type", "type"), + ("Asset", "asset"), + ("Asset status", "assetStatus"), + ("Asset discovered", "assetDiscovered"), + ("Reason", "reason"), + ("Details", "descriptions"), + ("Context", "context"), + ) + lines = [ + f"**{label}:** {value}" + for label, key in fields + if (value := str(body.get(key) or "").strip()) + ] + return "\n".join(lines) or NO_DETAILS + + def mitre_tags(self, mitre): + """ + The MITRE ATT&CK techniques, as "mitre-attack:", sorted. + + Group-IB sends them as a MAP keyed by technique, so the keys are the techniques and the sort is + what makes the order stable. + """ + if not isinstance(mitre, dict): + return [] + return sorted(f"mitre-attack:{technique}" for technique in mitre if technique) + + def apply_status(self, finding, status): + """ + The issue's LIFECYCLE status - not the severity label in its body. + + "Detected", "Under review" and anything unrecognised stay ACTIVE, which is the safe direction to + be wrong in. The three closing states are distinguished because they mean different things: a + solved issue was fixed, an ignored one was accepted, and a false positive was never real. + """ + normalised = status.strip().lower() + if normalised == STATUS_SOLVED: + finding.active = False + finding.is_mitigated = True + elif normalised == STATUS_FALSE_POSITIVE: + finding.active = False + finding.false_p = True + elif normalised == STATUS_IGNORED: + finding.active = False + finding.out_of_scope = True + else: + finding.active = True + + def add_asset(self, finding, asset): + """ + The affected asset - an endpoint when it names one, the component otherwise. + + Group-IB reports software names and SSL or login-form descriptors in the same field as hosts and + URLs, so anything that is not host-shaped becomes the component instead. Recording a software + name as an endpoint would make Endpoint.clean() raise and fail the whole import. + """ + if not asset: + return + + host, port, protocol = self.endpoint_parts(asset) + if not host: + finding.component_name = asset + return + + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append( + LocationData.url(host=host, port=port, protocol=protocol), + ) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=host, port=port, protocol=protocol)) + + def endpoint_parts(self, asset): + """ + Split a host-shaped asset into host, port and protocol; return no host when it is not one. + + Group-IB sends a bare host or address with NO scheme. The connector prefixes "//" so + Endpoint.from_uri reads it as an authority rather than a path - this builds the endpoint from + its parts instead, which reaches the same result without the string trick. + """ + if any(character.isspace() for character in asset): + return "", None, None + + if "://" in asset: + parsed = urlparse(asset) + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return "", None, None + with suppress(ValueError): + return host, parsed.port, parsed.scheme or None + return host, None, parsed.scheme or None + + # An already scheme-relative value: the connector leaves it as it is, because that is exactly + # the form its "//" prefix produces. The authority is what follows the slashes. + asset = asset.removeprefix("//") + if not asset: + return "", None, None + + host, port = self.split_port(asset) + if not host: + return "", None, None + + with suppress(ValueError): + ip_address(host) + return host, port, None + + # A domain needs a dot and an alphabetic top-level label; a software name like + # "OpenSSL 1.0.2" has neither, and one with a path separator is not a host at all. + if "/" in host or "\\" in host or "." not in host: + return "", None, None + tld = host.rsplit(".", 1)[-1] + if len(tld) >= 2 and tld.isalpha() and self.usable_host(host): + return host, port, None + return "", None, None + + def split_port(self, asset): + """"host:port" split, tolerating a bare IPv6 address, which has colons of its own.""" + if asset.count(":") == 1: + host, _, port = asset.partition(":") + if port.isdigit(): + return host, int(port) + return asset, None + return asset, None + + def usable_host(self, value): + """A host is letters, digits, dot, hyphen, underscore or plus, or an IP address.""" + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def date(self, value): + """ + When the issue was first seen, as a date. + + The connector parses this strictly and returns nothing when it cannot, which leaves the import + default in place rather than failing the file. + """ + text = str(value or "").strip() + if len(text) < 10: + return None + with suppress(ValueError): + return _date.fromisoformat(text[:10]) + return None diff --git a/dojo/tools/hackerone/__init__.py b/dojo/tools/hackerone/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/hackerone/parser.py b/dojo/tools/hackerone/parser.py new file mode 100644 index 00000000000..91f55270fda --- /dev/null +++ b/dojo/tools/hackerone/parser.py @@ -0,0 +1,176 @@ +import json +from contextlib import suppress + +from dojo.models import Finding + +# Mirrors severityFromRating() in the HackerOne connector's converter. HackerOne also reports a +# rating of "none", which falls through to Info like anything else unrecognised. +SEVERITY_MAP = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +REPORT_URL = "https://hackerone.com/reports/{}" + + +class HackerOneParser: + + """ + Parses a HackerOne reports export. + + Mirrors pkg/tools/hackerone/connector/converter.go field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + HackerOne's API is JSON:API, so severity, weakness and reporter arrive as relationships rather + than plain attributes. This parser reads that shape, and also accepts an already-flattened export. + """ + + def get_scan_types(self): + # Byte-identical to ScanTypeName in the connector. + return ["HackerOne - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "HackerOne - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a HackerOne reports export (JSON:API). Matches the scan type used by the " + "HackerOne connector so file and API findings deduplicate. One finding per report." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the HackerOne Parser. + + Mirrors the connector's toFinding: + - title: the report title. + - severity: the severity relationship's rating; anything unrecognised Info. + - description: the vulnerability information, the weakness name, the reporting researcher, + and a link to the report. + - url: the report's public URL. + - cwe: parsed from the weakness relationship's external_id, e.g. "cwe-79". + - cvssv3_score: the severity relationship's score, when it is above zero. + - unique_id_from_tool / vuln_id_from_tool: both the HackerOne report id. + """ + return [ + "title", + "severity", + "description", + "url", + "cwe", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the HackerOne Parser. + + Copied from the HackerOne block in the Pro connector settings: report ids are globally unique + on the platform, so the plain hash_code algorithm hashes the unique id alone. Diverging would + stop file findings merging with API-synced ones. + """ + return ["unique_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + reports = self.extract_reports(data) + + findings = {} + for report in reports: + if not isinstance(report, dict): + continue + finding = self.build_finding(report, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_reports(self, data): + """HackerOne's JSON:API responses put the reports under "data".""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("data", "reports"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "A HackerOne export is a JSON:API object with a 'data' list of reports, or a bare array " + f"of reports; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, report, test): + attributes = report.get("attributes") or {} + severity = self.relationship(report, "severity") + weakness = self.relationship(report, "weakness") + reporter = self.relationship(report, "reporter") + + report_id = str(report.get("id") or "") + score = severity.get("score") or 0 + + finding = Finding( + test=test, + title=attributes.get("title") or report.get("title") or None, + severity=SEVERITY_MAP.get( + (severity.get("rating") or "").strip().lower(), DEFAULT_SEVERITY, + ), + description=self.describe(report, attributes, weakness, reporter, report_id), + url=REPORT_URL.format(report_id), + cwe=self.cwe(weakness.get("external_id")), + unique_id_from_tool=report_id, + vuln_id_from_tool=report_id or None, + ) + # The converter only sets a score when HackerOne actually attached one. + if score > 0: + finding.cvssv3_score = score + return finding + + def relationship(self, report, name): + """ + Read one JSON:API relationship's attributes. + + Severity, weakness and reporter are relationships on a HackerOne report, not attributes, so + each is nested under relationships..data.attributes. An already-flattened export is + also accepted, which is why the top-level object is checked as a fallback. + """ + relationships = report.get("relationships") + if isinstance(relationships, dict): + entry = relationships.get(name) + if isinstance(entry, dict): + inner = entry.get("data") + if isinstance(inner, dict): + attributes = inner.get("attributes") + if isinstance(attributes, dict): + return attributes + # Flattened form: the fields sit on the report itself. + flat = report.get(name) + return flat if isinstance(flat, dict) else {} + + def describe(self, report, attributes, weakness, reporter, report_id): + parts = [] + information = attributes.get("vulnerability_information") or report.get( + "vulnerability_information", + ) + if information: + parts.append(information) + if weakness.get("name"): + parts.append(f"**Weakness:** {weakness['name']}") + if reporter.get("username"): + parts.append(f"**Reported by:** {reporter['username']}") + # The converter always appends the report link, even with nothing else to say. + parts.append(f"**Report:** {REPORT_URL.format(report_id)}") + return "\n\n".join(parts) + + def cwe(self, external_id): + """The weakness external_id is lower-cased, e.g. "cwe-79"; anything else leaves 0.""" + raw = str(external_id or "").strip().lower() + if not raw.startswith("cwe-"): + return 0 + with suppress(ValueError): + number = int(raw[4:]) + if number > 0: + return number + return 0 diff --git a/dojo/tools/halosecurity/__init__.py b/dojo/tools/halosecurity/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/halosecurity/parser.py b/dojo/tools/halosecurity/parser.py new file mode 100644 index 00000000000..684afab7e33 --- /dev/null +++ b/dojo/tools/halosecurity/parser.py @@ -0,0 +1,350 @@ +import json +import re +from contextlib import suppress +from datetime import UTC, datetime +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Halo Security grades severity as an INTEGER level, 5 highest. Level 1 and 0 both mean Info - 0 +# appears in scan counts rather than on a real issue. +SEVERITY_BY_LEVEL = {5: "Critical", 4: "High", 3: "Medium", 2: "Low", 1: "Info", 0: "Info"} +DEFAULT_SEVERITY = "Info" + +STATUS_CONFIRMED = "confirmed" +STATUS_FIXING = "fixing" +STATUS_FIXED = "fixed" +STATUS_ACK_FALSE_POSITIVE = "ack_false_positive" +STATUS_ACK_ACCEPTABLE_RISK = "ack_acceptable_risk" + +# Halo Security uses this literal to mean "unassigned", so it is not worth reporting. +UNASSIGNED = "nobody" + +CVE_PATTERN = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE) + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class HaloSecurityParser: + + """ + Parses a Halo Security issues export. + + Mirrors pkg/tools/halosecurity/converter field for field so a file import and an API sync + deduplicate against each other instead of producing two copies of everything. + + Halo Security splits an issue across two calls: a list row carrying the issue, target and status, + and a per-issue detail carrying the description, category, CVEs and PCI flag. An export needs both + to produce a complete finding - see detail_for(). + + Note the deduplication configuration for this scan type hashes the ENDPOINTS, so the target has to + be populated or the hash is computed over nothing. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Halo Security - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Halo Security - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Halo Security issues export (JSON). Matches the scan type used by the Halo " + "Security connector so file and API findings deduplicate. Include the per-issue details " + "so findings carry their description, category and CVEs." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Halo Security Parser. + + Mirrors the connector's RowToFinding: + - title: the issue name from the row, then from the detail, then "Halo Security issue ". + - severity: the integer severity level, 5 highest; see severity(). + - description: the detail's description, then target, status, category, PCI, assignee and the + number of scans the issue has persisted for. + - active / is_mitigated / false_p / risk_accepted / verified: from the row's status. + - unique_id_from_tool: ":" - one issue on two hosts is two findings. + - vuln_id_from_tool: the issue id, which is Halo's rule identifier. + """ + return [ + "title", + "severity", + "date", + "description", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "is_mitigated", + "false_p", + "risk_accepted", + "verified", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Halo Security Parser. + + Copied from the Halo Security block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Note endpoints is among them, so the + target must be populated for the hash to mean anything. + """ + return ["title", "severity", "endpoints"] + + def get_findings(self, filename, test): + data = json.load(filename) + rows, details = self.extract(data) + + findings = {} + for row in rows: + if not isinstance(row, dict): + continue + issue = row.get("issue") + if not isinstance(issue, dict): + # The connector skips a row with no issue block at all. + continue + finding = self.build_finding(row, issue, self.detail_for(row, issue, details), test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract(self, data): + """ + Return the issue rows and any per-issue details. + + Halo's issue list nests rows under "list". The details come from a second endpoint, so an + export carries them either as a top-level "details" map or array keyed by issue id, or nested + on each row as "detail". + """ + details = {} + rows = None + + if isinstance(data, list): + rows = data + elif isinstance(data, dict): + for key in ("list", "issues", "rows"): + if isinstance(data.get(key), list): + rows = data[key] + break + details = self.index_details(data.get("details")) + + if rows is None: + msg = ( + "A Halo Security export is the issue-list response, a JSON object with a 'list' of " + f"issue rows; got {type(data).__name__}." + ) + raise TypeError(msg) + return rows, details + + def index_details(self, source): + """Accept the details as a map keyed by issue id, or as a plain array of detail objects.""" + indexed = {} + if isinstance(source, dict): + for key, value in source.items(): + detail = value.get("issue") if isinstance(value, dict) and "issue" in value else value + if isinstance(detail, dict): + indexed[str(key)] = detail + elif isinstance(source, list): + for value in source: + detail = value.get("issue") if isinstance(value, dict) and "issue" in value else value + if isinstance(detail, dict) and detail.get("issue_id") is not None: + indexed[str(detail["issue_id"])] = detail + return indexed + + def detail_for(self, row, issue, details): + """The detail nested on the row, else the one indexed by this row's issue id.""" + nested = row.get("detail") + if isinstance(nested, dict): + return nested.get("issue") if isinstance(nested.get("issue"), dict) else nested + return details.get(str(issue.get("issue_id")), {}) + + def build_finding(self, row, issue, detail, test): + status_block = row.get("status") if isinstance(row.get("status"), dict) else {} + status = (status_block.get("status") or "").strip().lower() + unique_id = self.unique_id(row, issue, status_block) + + false_positive = status == STATUS_ACK_FALSE_POSITIVE + risk_accepted = status == STATUS_ACK_ACCEPTABLE_RISK + mitigated = status == STATUS_FIXED + + finding = Finding( + test=test, + title=self.title(issue, detail, unique_id), + severity=self.severity(issue, detail), + # The connector stamps today's date; Halo's list response carries no discovery date. + date=datetime.now(tz=UTC).date(), + description=self.describe(row, detail, status_block), + unique_id_from_tool=unique_id, + vuln_id_from_tool=str(issue["issue_id"]) if issue.get("issue_id") is not None else None, + active=not (mitigated or false_positive or risk_accepted), + is_mitigated=mitigated, + false_p=false_positive, + risk_accepted=risk_accepted, + # Only these three states mean a human has confirmed the issue is real. + verified=status in {STATUS_CONFIRMED, STATUS_FIXING, STATUS_FIXED}, + # Halo Security probes live hosts. + static_finding=False, + dynamic_finding=True, + ) + finding.unsaved_tags = self.tags(detail, status_block) + + cves = self.cves(detail) + if cves: + finding.unsaved_vulnerability_ids = cves + + self.attach_target(finding, row) + return finding + + def unique_id(self, row, issue, status_block): + """ + ":". + + The target is part of the identity because Halo reports the same issue once per affected host; + keying on the issue alone would collapse them into one finding. + """ + issue_id = issue.get("issue_id") or 0 + target_id = status_block.get("target_id") or 0 + if not target_id: + target = row.get("target") + if isinstance(target, dict): + target_id = target.get("target_id") or 0 + return f"{issue_id}:{target_id}" + + def title(self, issue, detail, unique_id): + name = (issue.get("name") or "").strip() + if name: + return name + detail_name = (detail.get("name") or "").strip() + if detail_name: + return detail_name + return f"Halo Security issue {unique_id}" + + def severity(self, issue, detail): + """ + Grade Halo's integer level. + + The row's level wins when set; the detail's is the fallback, because the list response + sometimes omits it. A level outside 0-5 is Info. + """ + level = issue.get("severity") or 0 + if not level: + level = detail.get("severity") or 0 + return SEVERITY_BY_LEVEL.get(level, DEFAULT_SEVERITY) + + def describe(self, row, detail, status_block): + sections = [] + description = (detail.get("description") or "").strip() + if description: + sections.append(description) + + details = [] + + def add(label, value): + text = str(value).strip() if value is not None else "" + if text: + details.append(f"**{label}:** {text}") + + add("Target", self.target(row)) + add("Halo status", status_block.get("status")) + add("Category", detail.get("category")) + if detail.get("pci") == 1: + add("PCI", "this issue affects PCI compliance") + assigned = (status_block.get("assigned_to") or "").strip() + if assigned and assigned.lower() != UNASSIGNED: + add("Assigned to", assigned) + if status_block: + # Reported even when zero, since the connector formats the integer unconditionally. + add("Scans since found", status_block.get("scans_since_found") or 0) + + if details: + sections.append("\n".join(details)) + return "\n\n".join(sections) + + def tags(self, detail, status_block): + tags = [] + category = (detail.get("category") or "").strip() + if category: + tags.append(category) + if detail.get("pci") == 1: + tags.append("pci") + status = (status_block.get("status") or "").strip() + if status: + tags.append(status) + return tags + + def cves(self, detail): + """Halo lists CVEs on the detail; the values are scanned so a joined string also works.""" + raw = detail.get("cve_ids") + if isinstance(raw, str): + candidates = [raw] + elif isinstance(raw, list): + candidates = [str(item) for item in raw] + else: + return [] + + found, seen = [], set() + for candidate in candidates: + for cve in CVE_PATTERN.findall(candidate): + upper = cve.upper() + if upper not in seen: + seen.add(upper) + found.append(upper) + return found + + def target(self, row): + target = row.get("target") + return (target.get("target") or "").strip() if isinstance(target, dict) else "" + + def attach_target(self, finding, row): + """ + Record the scanned host. + + This scan type's deduplication hashes the endpoints, so leaving it unpopulated would leave the + hash computed over nothing and every rescan would reimport. + """ + target = self.target(row) + if not target: + return + parsed = urlparse(target if "//" in target else f"//{target}") + try: + port = parsed.port + except ValueError: + return + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=port, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=port, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False diff --git a/dojo/tools/hiddenlayer/__init__.py b/dojo/tools/hiddenlayer/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/hiddenlayer/parser.py b/dojo/tools/hiddenlayer/parser.py new file mode 100644 index 00000000000..057cacd3841 --- /dev/null +++ b/dojo/tools/hiddenlayer/parser.py @@ -0,0 +1,418 @@ +import json +import re +from contextlib import suppress + +from dojo.models import Finding + +# Mirrors the connector's own regular expressions, which in turn mirror DefectDojo's SARIF parser. +CWE_PATTERN = re.compile(r"cwe-(\d+)", re.IGNORECASE) +CVE_PATTERN = re.compile(r"CVE-\d{4}-\d+", re.IGNORECASE) + +SEVERITY_BY_LEVEL = {"note": "Info", "warning": "Medium", "error": "High"} +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + "informational": "Info", +} +# A SARIF result with no level is Medium, NOT Info - see severity(). +DEFAULT_SEVERITY = "Medium" + +TITLE_MAX_LENGTH = 150 +CWE_TAG_PREFIX = "external/cwe/" + + +class SarifConnectorFindings: + + """ + The SARIF mapping shared by every connector that reports SARIF. + + On the Go side these connectors share one utility (pkg/utils/sarif), parameterised by a PREFIX for + the identity and a static/dynamic flag. Mirroring that here as a mixin is what keeps the parsers + from drifting apart: a change to the shared mapping has to land in one place, exactly as it does + upstream. The shipped invicti parser extends netsparker the same way. + + NOT registered as a parser itself - dojo/tools/factory.py only registers the class whose lowercased + name matches its module, so this one is invisible to it. + """ + + # Overridden per vendor: the identity prefix, and whether the tool reads an artifact or runs it. + tool_prefix = "" + is_static = True + + def scope_id(self, data): + """The id that namespaces every identity - a scan, an assessment. Overridden per vendor.""" + return "" + + def prepare(self, data): + """ + Hook run once, before any finding is built. + + A vendor that decorates its findings with context the SARIF document does not carry reads that + context here, rather than as a side effect of something else. Does nothing by default. + """ + + def get_findings(self, filename, test): + data = json.load(filename) + if not isinstance(data, dict): + msg = ( + f"A {self.vendor} export is a SARIF log, a JSON object with a 'runs' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + self.prepare(data) + scope = self.scope_id(data) + log = self.log(data) + runs = log.get("runs") + if not isinstance(runs, list): + msg = f"A {self.vendor} export is a SARIF log; this object has no 'runs' list." + raise TypeError(msg) + + findings = [] + for run in runs: + if not isinstance(run, dict): + continue + rules = self.rules_by_id(run) + for result in run.get("results") or []: + if not isinstance(result, dict): + continue + if finding := self.build_finding(result, rules, scope, test): + findings.append(finding) + return findings + + def log(self, data): + """ + The SARIF log itself, unwrapped from whatever envelope supplied the scope id. + + A downloaded log has no envelope at all, and then it is the log. + """ + for key in ("sarif", "log", "report"): + if isinstance(data.get(key), dict): + return data[key] + return data + + def scope_from(self, data, keys): + """ + The scope id, read from whichever spelling the file uses. + + It is part of every identity the connector builds and a downloaded SARIF log does not carry it, + so WITHOUT it the identities differ from the connector's and file findings will not deduplicate + against synced ones. + """ + for key in keys: + if value := str(data.get(key) or "").strip(): + return value + return "" + + def rules_by_id(self, run): + """The run's rule definitions, keyed by id, so a result can be read with its rule.""" + driver = self.block(self.block(run, "tool"), "driver") + rules = {} + for rule in driver.get("rules") or []: + if isinstance(rule, dict): + rules[str(rule.get("id") or "")] = rule + return rules + + def block(self, row, key): + if not isinstance(row, dict): + return {} + value = row.get(key) + return value if isinstance(value, dict) else {} + + def text(self, holder, key): + """A SARIF multiformatMessageString: {"text": "..."}.""" + return str(self.block(holder, key).get("text") or "") + + def build_finding(self, result, rules, scope, test): + kind = str(result.get("kind") or "") + if kind and kind != "fail": + # SARIF uses kind for results that are not failures at all - "pass", "open", + # "informational". Importing those would fill the product with non-findings. + return None + + rule_id = str(result.get("ruleId") or "") + rule = rules.get(rule_id) + suppressed = bool(result.get("suppressions")) + file_path, line = self.location(result) + + finding = Finding( + test=test, + title=self.title(result, rule), + severity=self.severity(result, rule), + description=self.describe(result, rule), + file_path=file_path or None, + line=line or None, + references=self.references(rule) or None, + unique_id_from_tool=f"{self.tool_prefix}-{scope}-{rule_id}-{file_path}:{line}", + vuln_id_from_tool=rule_id or None, + # Whether the tool reads an artifact or exercises it is the one thing the shared + # mapping cannot decide for itself. + static_finding=self.is_static, + dynamic_finding=not self.is_static, + # A suppressed result is BOTH inactive and a false positive: SARIF suppression is a + # reviewer saying this one does not count, which is what false_p records. + active=not suppressed, + false_p=suppressed, + ) + finding.cvssv3_score = self.security_severity_score(rule) + finding.unsaved_tags = self.tags(result, rule) + + if match := CVE_PATTERN.search(rule_id): + finding.unsaved_vulnerability_ids = [match.group(0).upper()] + if cwe := self.cwe(result, rule): + finding.cwe = cwe + if fix := self.fixes(result): + finding.mitigation = fix + self.decorate(finding, result) + return finding + + def decorate(self, finding, result): + """Hook for the context a SARIF document does not carry. Does nothing by default.""" + + def title(self, result, rule): + if message := self.text(result, "message"): + return self.shorten(message) + if rule is not None: + for key in ("shortDescription", "fullDescription"): + if value := self.text(rule, key): + return self.shorten(value) + return str(rule.get("name") or "") or str(rule.get("id") or "") + return str(result.get("ruleId") or "") + + def describe(self, result, rule): + """ + The result message, then the rule's name and descriptions - each only when it adds something. + + A rule whose short description merely repeats the message, or whose full description repeats + the short one, is not printed twice. + """ + message = self.text(result, "message") + lines = [] + if message: + lines.append(f"**Result message:** {message}") + + if rule is not None: + if name := str(rule.get("name") or ""): + lines.append(f"**Rule name:** {name}") + short = self.text(rule, "shortDescription") + if short and short != message: + lines.append(f"**Rule short description:** {short}") + full = self.text(rule, "fullDescription") + if full and full != short: + lines.append(f"**Rule full description:** {full}") + return "\n".join(lines).strip() + + def severity(self, result, rule): + """ + The rule's security-severity property, read as a CVSS score and then as a word. + + A result with NO level is Medium rather than Info: SARIF makes level optional and a tool that + omits it is not saying the result is harmless. Defaulting to Info would silently bury it. + """ + if rule is not None: + raw = str(self.block(rule, "properties").get("security-severity") or "") + if raw: + with suppress(ValueError): + return self.cvss_severity(float(raw)) + if mapped := SEVERITY_BY_LABEL.get(raw.strip().lower()): + return mapped + return SEVERITY_BY_LEVEL.get(str(result.get("level") or ""), DEFAULT_SEVERITY) + + def cvss_severity(self, score): + if score >= 9: + return "Critical" + if score >= 7: + return "High" + if score >= 4: + return "Medium" + if score > 0: + return "Low" + return "Info" + + def security_severity_score(self, rule): + """The security-severity property when it is a number; a word scores nothing.""" + if rule is None: + return 0.0 + with suppress(ValueError): + return float(str(self.block(rule, "properties").get("security-severity") or "")) + return 0.0 + + def location(self, result): + """The first physical location - the file inside the model archive, and its line.""" + for location in result.get("locations") or []: + if not isinstance(location, dict): + continue + physical = self.block(location, "physicalLocation") + if not physical: + continue + file_path = str(self.block(physical, "artifactLocation").get("uri") or "") + line = self.integer(self.block(physical, "region").get("startLine")) + return file_path, line + return "", 0 + + def references(self, rule): + """The rule's help URI, or its help text when that is itself a link.""" + if rule is None: + return "" + if uri := str(rule.get("helpUri") or ""): + return uri + help_text = self.text(rule, "help") + if help_text.startswith("http"): + return help_text + return "" + + def cwe(self, result, rule): + """ + A CWE id from the rule's relationships, then its tags, then the result's tags. + + SARIF has no CWE field: a tool states the taxonomy either as a relationship target or as a + tag like "external/cwe/cwe-502", so both are searched. + """ + if rule is not None: + for relationship in rule.get("relationships") or []: + if isinstance(relationship, dict): + if cwe := self.parse_cwe(str(self.block(relationship, "target").get("id") or "")): + return cwe + if cwe := self.first_cwe(self.block(rule, "properties").get("tags")): + return cwe + return self.first_cwe(self.block(result, "properties").get("tags")) + + def first_cwe(self, tags): + for tag in tags or []: + if cwe := self.parse_cwe(str(tag)): + return cwe + return 0 + + def parse_cwe(self, value): + if match := CWE_PATTERN.search(value): + with suppress(ValueError): + return int(match.group(1)) + return 0 + + def fixes(self, result): + """Every fix description the result carries, one per line.""" + texts = [ + self.text(fix, "description") + for fix in result.get("fixes") or [] + if isinstance(fix, dict) and self.text(fix, "description") + ] + return "\n".join(texts) + + def tags(self, result, rule): + """ + The rule's tags then the result's, deduplicated, with the CWE taxonomy prefix stripped. + + "external/cwe/cwe-502" reads as "cwe-502", matching DefectDojo's own SARIF parser. + """ + tags = [] + for holder in ((rule if rule is not None else {}), result): + for tag in self.block(holder, "properties").get("tags") or []: + clean = str(tag).removeprefix(CWE_TAG_PREFIX) + if clean and clean not in tags: + tags.append(clean) + return tags + + def shorten(self, text): + """At most 150 characters, ending in an ellipsis when cut, as the connector does.""" + if len(text) <= TITLE_MAX_LENGTH: + return text + return text[: TITLE_MAX_LENGTH - 3] + "..." + + def integer(self, value): + """SARIF numbers may arrive quoted, which the connector's own decoder tolerates.""" + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 + + +class HiddenlayerParser(SarifConnectorFindings): + + """ + Parses a HiddenLayer model-scan SARIF log. + + Mirrors pkg/tools/hiddenlayer/connector/finding_converter field for field so a file import and an + API sync deduplicate against each other instead of producing two copies of everything. + + HiddenLayer scans machine-learning models and reports SARIF. DefectDojo ships a generic SARIF + parser, but importing through it would record the findings under the "SARIF" scan type, where they + would NOT deduplicate against the HiddenLayer connector's - which is the whole reason this exists. + The mapping is the connector's, which itself mirrors dojo/tools/sarif/parser.py. + """ + + vendor = "HiddenLayer" + tool_prefix = "hiddenlayer" + # A model scan reads an artifact; nothing is exercised. + is_static = True + + def scope_id(self, data): + """HiddenLayer namespaces its identities by scan id.""" + return self.scope_from(data, ("scan_id", "scanId", "scanID")) + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["HiddenLayer Model Scan"] + + def get_label_for_scan_types(self, scan_type): + return "HiddenLayer Model Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a HiddenLayer model-scan SARIF log. Matches the scan type used by the HiddenLayer " + "connector so file and API findings deduplicate - give the scan's scan_id to deduplicate " + "against connector findings." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the HiddenLayer Parser. + + Mirrors the connector's convertResult: + - title: the result message, then the rule's short then full description, then its name or id. + Shortened to 150 characters. + - severity: the rule's security-severity property as a CVSS score, then as a word, then the + result level - defaulting to Medium; see severity(). + - description: the result message and the rule's name and descriptions. + - cvssv3_score: the security-severity property, when it is a number. + - cwe: from the rule's relationships, then the rule's tags, then the result's tags. + - file_path / line: the first physical location. + - active / false_p: a suppressed result is inactive AND a false positive. + - unique_id_from_tool: "hiddenlayer---:". + """ + return [ + "title", + "severity", + "description", + "cvssv3_score", + "cwe", + "file_path", + "line", + "mitigation", + "references", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "false_p", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the HiddenLayer Parser. + + Copied from the HiddenLayer block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The file path is in the hash because + one rule firing on two files in a model archive is two findings. + """ + return ["title", "severity", "file_path"] diff --git a/dojo/tools/holm_security/__init__.py b/dojo/tools/holm_security/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/holm_security/parser.py b/dojo/tools/holm_security/parser.py new file mode 100644 index 00000000000..fb3fe284a7a --- /dev/null +++ b/dojo/tools/holm_security/parser.py @@ -0,0 +1,326 @@ +import json +import re +from contextlib import suppress +from datetime import datetime +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_NAME = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", +} +# Holm's numeric scale, used when the name is missing or unrecognised. 4 is the most severe. +SEVERITY_BY_LEVEL = {4: "Critical", 3: "High", 2: "Medium", 1: "Low", 0: "Info"} +DEFAULT_SEVERITY = "Info" + +# Statuses that mean Holm no longer sees the vulnerability. +CLOSED_STATUSES = {"fixed", "closed", "resolved"} + +# Holm splits its scanning into a network class and a web class. Only the web class exercises a running +# application, which is why the class decides static versus dynamic. +CLASS_NET = "net" +CLASS_WEB = "web" + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class HolmSecurityParser: + + """ + Parses a Holm Security vulnerabilities export. + + Mirrors pkg/tools/holmsecurity/connector/finding_converter field for field so a file import and an + API sync deduplicate against each other instead of producing two copies of everything. + + Holm scans two ways - a network class and a web class - and the connector treats only the web class + as dynamic. The class is not on the vulnerability row itself, so an export has to say which it is; + see extract(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Holm Security Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Holm Security Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Holm Security vulnerabilities export (JSON). Matches the scan type used by the " + "Holm Security connector so file and API findings deduplicate. Say whether the rows came " + "from a net or a web scan - only the web class is imported as dynamic." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Holm Security Parser. + + Mirrors the connector's Convert: + - title: the vulnerability name, then its first CVE, then the Holm id. + - severity: the severity name, falling back to Holm's numeric level; see severity(). + - description: the detection information, the Holm id, CVEs, URL, port and status. + - mitigation: Holm's solution text. + - impact: Holm's own impact statement. + - references: the vendor reference. + - cvssv3_score: the CVSS base, falling back to the score. + - static_finding / dynamic_finding: from the asset class, not the row. + - unique_id_from_tool: "holm-[-][-]". + - vuln_id_from_tool: the Holm id, which is its rule identity. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "impact", + "references", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Holm Security Parser. + + Copied from the Holm Security block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Note endpoints is among them, so the + scanned URL is recorded whenever Holm reports one. + """ + return ["title", "severity", "endpoints", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + rows, asset_class = self.extract(data) + return [self.build_finding(row, asset_class, test) for row in rows] + + def extract(self, data): + """ + Return the vulnerabilities and the asset class they came from. + + Holm pages its lists under "results". The class - net or web - is a property of the scan rather + than the row, so an export states it as a top-level "class" (or "asset_class"). Without it the + findings are imported as static, which is the connector's own default for anything that is not + the web class. + """ + asset_class = "" + rows = None + + if isinstance(data, list): + rows = data + elif isinstance(data, dict): + for key in ("results", "vulnerabilities"): + if isinstance(data.get(key), list): + rows = data[key] + break + for key in ("class", "asset_class"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + asset_class = value.strip().lower() + break + + if rows is None: + msg = ( + "A Holm Security export is the vulnerabilities response, a JSON object with a " + f"'results' list; got {type(data).__name__}." + ) + raise TypeError(msg) + return [row for row in rows if isinstance(row, dict)], asset_class + + def build_finding(self, row, asset_class, test): + is_web = asset_class == CLASS_WEB + cves = [str(cve).strip() for cve in row.get("cve_ids") or [] if str(cve or "").strip()] + + finding = Finding( + test=test, + title=self.title(row, cves), + severity=self.severity(row), + description=self.describe(row, cves), + mitigation=str(row.get("solution") or ""), + impact=str(row.get("vulnerability_impact") or ""), + references=str(row.get("vendor_reference") or ""), + unique_id_from_tool=self.unique_id(row), + vuln_id_from_tool=str(row.get("hid") or "") or None, + active=self.is_active(row), + # Only the web class exercises a running application. + static_finding=not is_web, + dynamic_finding=is_web, + ) + finding.cvssv3_score = self.score(row) + finding.unsaved_tags = self.tags(asset_class) + + if cves: + finding.unsaved_vulnerability_ids = cves + if date := self.date(row): + finding.date = date + + self.attach_endpoint(finding, str(row.get("url") or "").strip()) + return finding + + def unique_id(self, row): + """ + "holm-[-][-]". + + The asset and port are part of the identity because Holm reports the same weakness once per + host and once per listening port - collapsing them would hide a second exposed service. + """ + identifier = "holm-" + str(row.get("hid") or "") + if asset := str(row.get("asset_uuid") or ""): + identifier += f"-{asset}" + port = self.flex_int(row.get("detected_port")) + if port > 0: + identifier += f"-{port}" + return identifier + + def title(self, row, cves): + if name := str(row.get("vulnerability_name") or "").strip(): + return name + if cves: + return cves[0] + return "Holm Security finding " + str(row.get("hid") or "") + + def severity(self, row): + """ + The severity name wins; Holm's numeric level is the fallback. + + The numeric scale runs 0-4 with 4 the most severe - the inverse of a priority number - and it + is only consulted when the name is missing or unrecognised, so an unfamiliar name does not + silently become Info while a usable level sits next to it. + """ + name = str(row.get("severity") or "").strip().lower() + if name in SEVERITY_BY_NAME: + return SEVERITY_BY_NAME[name] + return SEVERITY_BY_LEVEL.get(self.flex_int(row.get("severity_level")), DEFAULT_SEVERITY) + + def score(self, row): + """The CVSS base, falling back to the score Holm reports alongside it.""" + for key in ("cvss_base", "cvss_score"): + value = self.flex_float(row.get(key)) + if value > 0: + return value + return 0.0 + + def is_active(self, row): + return str(row.get("status") or "").strip().lower() not in CLOSED_STATUSES + + def describe(self, row, cves): + lines = [] + for label, value in (("Detection", row.get("detection_information")), + ("Holm ID", row.get("hid")), + ("CVEs", ", ".join(cves)), + ("URL", row.get("url")), + ("Port", self.port_label(row)), + ("Status", row.get("status"))): + text = str(value or "").strip() + if text: + lines.append(f"**{label}:** {text}") + return "\n".join(lines) + + def port_label(self, row): + """"443/tcp" - the port with its protocol when Holm reported one.""" + port = self.flex_int(row.get("detected_port")) + if port <= 0: + return "" + label = str(port) + if protocol := str(row.get("detected_port_protocol") or "").strip(): + label += f"/{protocol}" + return label + + def tags(self, asset_class): + if asset_class == CLASS_WEB: + return ["web-scan"] + if asset_class == CLASS_NET: + return ["net-scan"] + return [] + + def date(self, row): + """When Holm last saw it, falling back to when it first did.""" + for key in ("last_detected", "first_detected"): + value = str(row.get(key) or "").strip() + if len(value) < 10: + continue + with suppress(ValueError): + return datetime.strptime(value[:10], "%Y-%m-%d").date() + return None + + def attach_endpoint(self, finding, url): + """ + Record the scanned URL. + + This scan type's deduplication hashes the endpoints, so it is recorded whenever Holm reports + one - a network finding often has none, and then the host stays in the description. + """ + if not url: + return + with suppress(ValueError): + parsed = urlparse(url if "//" in url else f"//{url}") + try: + port = parsed.port + except ValueError: + return + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/"), query=parsed.query, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/") or None, query=parsed.query or None, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else + makes Endpoint.clean() raise, and that fails the whole import rather than the one finding. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def flex_int(self, value): + """Holm sends its numbers as either numbers or numeric strings.""" + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 + + def flex_float(self, value): + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 diff --git a/dojo/tools/insightappsec/__init__.py b/dojo/tools/insightappsec/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/insightappsec/parser.py b/dojo/tools/insightappsec/parser.py new file mode 100644 index 00000000000..7f1bea0f5db --- /dev/null +++ b/dojo/tools/insightappsec/parser.py @@ -0,0 +1,457 @@ +import json +import re +from contextlib import suppress +from datetime import datetime +from html.parser import HTMLParser +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# InsightAppSec's severity enum, matched CASE-SENSITIVELY as the connector does. +SEVERITY_BY_LABEL = { + "CRITICAL": "Critical", + "HIGH": "High", + "MEDIUM": "Medium", + "LOW": "Low", + "INFORMATIONAL": "Info", + "SAFE": "Info", +} +DEFAULT_SEVERITY = "Info" + +# Statuses the connector imports. Everything else - remediated, duplicate, ignored, false positive - +# is left out so a reimport closes it in DefectDojo. +OPEN_STATUSES = frozenset({"UNREVIEWED", "VERIFIED"}) + +# How many evidence entries the connector prints before summarising the rest. +MAX_VARIANCES_IN_DESCRIPTION = 3 + +# Block-level tags the connector's HTML flattener turns into newlines. +BLOCK_TAGS = frozenset({ + "br", "p", "div", "li", "tr", "ul", "ol", "table", "blockquote", + "h1", "h2", "h3", "h4", "h5", "h6", +}) +# Tags whose text content is dropped entirely. +DROPPED_TAGS = frozenset({"script", "style"}) + +# Go's html.EscapeString, which the connector applies last. Python's html.escape differs on the +# apostrophe (' rather than '), so the replacement is spelled out to match byte for byte. +GO_HTML_ESCAPES = ( + ("&", "&"), + ("<", "<"), + (">", ">"), + ('"', """), + ("'", "'"), +) + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class _HtmlFlattener(HTMLParser): + + """ + Mirror of the connector's htmlFlattener. + + An InsightAppSec attack module's description, and the evidence it captured from the application, + arrive as HTML. Both are flattened rather than rendered: script and style content is dropped, + block tags become newlines, and everything else becomes plain text. + """ + + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self.skip_depth = 0 + + def handle_starttag(self, tag, attrs): + if tag in DROPPED_TAGS: + self.skip_depth += 1 + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_startendtag(self, tag, attrs): + # A self-closing script or style has no content to skip, so only the newline case applies. + if tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_endtag(self, tag): + if tag in DROPPED_TAGS: + self.skip_depth = max(0, self.skip_depth - 1) + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_data(self, data): + if self.skip_depth == 0: + self.parts.append(data) + + def text(self): + return "".join(self.parts) + + +def inert_text(raw_html): + """ + Flatten HTML to escaped plain text, as the connector's InertText does. + + The evidence InsightAppSec captures is the application's own response to an attack payload, so it + is attacker-influenced by construction and is never passed through as markup. + """ + if not raw_html: + return "" + flattener = _HtmlFlattener() + flattener.feed(raw_html) + flattener.close() + return go_escape(collapse_whitespace(flattener.text())) + + +def collapse_whitespace(text): + """Trim every line, collapse runs of blank lines to one, and drop trailing blanks.""" + out = [] + blank = True + for raw in text.split("\n"): + line = raw.strip() + if not line: + if not blank: + out.append("") + blank = True + continue + out.append(line) + blank = False + while out and not out[-1]: + out.pop() + return "\n".join(out) + + +def go_escape(text): + for needle, replacement in GO_HTML_ESCAPES: + text = text.replace(needle, replacement) + return text + + +class InsightAppSecParser: + + """ + Parses a Rapid7 InsightAppSec vulnerability export. + + Mirrors pkg/tools/insightappsec/connector/finding_converter field for field so a file import and + an API sync deduplicate against each other instead of producing two copies of everything. + + InsightAppSec names a vulnerability only by the id of the attack module that found it, so the + human-readable title, the rule identity and the description prose all come from a separate + module-metadata call. An export without it produces findings called "InsightAppSec finding"; see + extract(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. + return ["Rapid7 InsightAppSec - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Rapid7 InsightAppSec - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Rapid7 InsightAppSec vulnerability export (JSON). Matches the scan type used by " + "the InsightAppSec connector so file and API findings deduplicate. Include the attack-module " + "metadata so findings carry a readable title and description." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Rapid7 InsightAppSec Parser. + + Mirrors the connector's Convert: + - title: the attack module's name, plus the parameter it was found in. + - severity: InsightAppSec's own enum, matched case-sensitively. + - severity_justification: the raw label, so a regrade is auditable. + - description: the URL and parameter, the module's prose, then up to three evidence entries. + - references: the InsightAppSec UI link, then the module's reference links. + - cvssv3_score: only when the vector really is CVSS v3. + - unique_id_from_tool: the vulnerability id, which is also the whole deduplication hash. + """ + return [ + "title", + "severity", + "severity_justification", + "date", + "description", + "references", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Rapid7 InsightAppSec Parser. + + Copied from the InsightAppSec block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with a hash of the unique id ALONE - no title, no severity. + InsightAppSec's vulnerability id is stable across scans, so it is the whole identity, and + adding a volatile field would split a finding that had merely been regraded. + """ + return ["unique_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + vulnerabilities, modules = self.extract(data) + + findings = [] + for vulnerability in vulnerabilities: + if not isinstance(vulnerability, dict): + continue + if not self.is_open(vulnerability): + # Remediated, duplicate, ignored and false-positive findings are left out so a + # reimport closes them rather than resurrecting them. + continue + findings.append(self.build_finding(vulnerability, modules, test)) + return findings + + def extract(self, data): + """ + Return the vulnerabilities and the attack-module metadata. + + InsightAppSec pages its vulnerability list under "data". The module metadata comes from a + separate endpoint, one call per module, so an export carries it as a map keyed by module id or + as a list of module objects. + """ + modules = {} + vulnerabilities = None + + if isinstance(data, list): + vulnerabilities = data + elif isinstance(data, dict): + for key in ("data", "vulnerabilities"): + if isinstance(data.get(key), list): + vulnerabilities = data[key] + break + modules = self.index_modules(data) + + if vulnerabilities is None: + msg = ( + "A Rapid7 InsightAppSec export is the vulnerability-list response, a JSON object with " + f"a 'data' list; got {type(data).__name__}." + ) + raise TypeError(msg) + return vulnerabilities, modules + + def index_modules(self, data): + """Attack-module metadata as a map keyed by module id, accepting a list of modules too.""" + for key in ("modules", "module_metadata"): + source = data.get(key) + if isinstance(source, dict): + if isinstance(source.get("data"), list): + return self.index_modules({"modules": source["data"]}) + return { + str(identifier): module + for identifier, module in source.items() + if isinstance(module, dict) + } + if isinstance(source, list): + indexed = {} + for module in source: + if isinstance(module, dict) and str(module.get("id") or "").strip(): + indexed[str(module["id"]).strip()] = module + return indexed + return {} + + def is_open(self, vulnerability): + """Matched case-sensitively, as the connector's own status constants are.""" + return str(vulnerability.get("status") or "") in OPEN_STATUSES + + def variances(self, vulnerability): + rows = vulnerability.get("variances") + return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + + def module(self, vulnerability, modules): + """The metadata of the first variance whose module is present in the export.""" + for variance in self.variances(vulnerability): + identifier = str(self.block(variance, "module").get("id") or "").strip() + if identifier and identifier in modules: + return modules[identifier] + return None + + def block(self, source, key): + value = source.get(key) + return value if isinstance(value, dict) else {} + + def build_finding(self, vulnerability, modules, test): + module = self.module(vulnerability, modules) + raw_severity = str(vulnerability.get("severity") or "") + + finding = Finding( + test=test, + title=self.title(vulnerability, module), + severity=SEVERITY_BY_LABEL.get(raw_severity, DEFAULT_SEVERITY), + severity_justification=f"InsightAppSec assigned severity **{raw_severity}**.", + description=self.describe(vulnerability, module), + references=self.references(vulnerability), + unique_id_from_tool=str(vulnerability.get("id") or "").strip() or None, + vuln_id_from_tool=self.vuln_id(vulnerability, module), + # InsightAppSec attacks a running application. + static_finding=False, + dynamic_finding=True, + ) + + if score := self.cvssv3_score(vulnerability): + finding.cvssv3_score = score + if date := self.date(vulnerability): + finding.date = date + + self.attach_endpoint(finding, str(self.block(vulnerability, "root_cause").get("url") or "").strip()) + return finding + + def vuln_id(self, vulnerability, module): + """ + The rule identity: the module's name, then its id, then the vulnerability id. + + The name is preferred because it is what a person recognises - "SQL Injection" rather than a + uuid - and it is stable across InsightAppSec versions. + """ + if module is not None and (name := str(module.get("name") or "").strip()): + return name + for variance in self.variances(vulnerability): + identifier = str(self.block(variance, "module").get("id") or "").strip() + if identifier: + return identifier + return str(vulnerability.get("id") or "").strip() or None + + def title(self, vulnerability, module): + """ + " in "" parameter". + + The quotes around the parameter are the connector's, and they matter: a parameter called + "id" is otherwise indistinguishable from prose. + """ + name = "InsightAppSec finding" + if module is not None and (module_name := str(module.get("name") or "").strip()): + name = module_name + parameter = str(self.block(vulnerability, "root_cause").get("parameter") or "").strip() + if parameter: + return f'{name} in "{parameter}" parameter' + return name + + def describe(self, vulnerability, module): + parts = [] + root = self.block(vulnerability, "root_cause") + + url = str(root.get("url") or "").strip() + if url: + method = str(root.get("method") or "") + parts.append(f"**URL:** {method} {inert_text(url)}\n") + if parameter := str(root.get("parameter") or "").strip(): + parts.append(f"**Parameter:** {inert_text(parameter)}\n") + if module is not None and (prose := str(module.get("description") or "").strip()): + parts.append(f"\n{inert_text(prose)}\n") + + variances = self.variances(vulnerability) + for index, variance in enumerate(variances): + if index >= MAX_VARIANCES_IN_DESCRIPTION: + omitted = len(variances) - MAX_VARIANCES_IN_DESCRIPTION + parts.append(f"\n_({omitted} further evidence entries omitted)_\n") + break + parts.append(self.variance_text(variance)) + return "".join(parts).rstrip("\n") + + def variance_text(self, variance): + """ + One evidence block per variance. + + Every value is flattened: this is the application's own response to an attack payload, so it + is the least trustworthy text in the export. + """ + lines = ["\n**Evidence:**\n"] + if attack_value := str(variance.get("attack_value") or "").strip(): + lines.append(f"- Attack value: {inert_text(attack_value)}\n") + if message := str(variance.get("message") or "").strip(): + lines.append(f"- {inert_text(message)}\n") + if proof := str(variance.get("proof") or "").strip(): + lines.append(f"- Proof: {inert_text(proof)}\n") + return "".join(lines) + + def references(self, vulnerability): + """ + The InsightAppSec UI link, then each variance's reference links. + + The links are taken in sorted key order - the connector sorts because a Go map has no order, + and matching that keeps the two import paths byte-identical. + """ + links = [] + if ui_url := str(vulnerability.get("insight_ui_url") or "").strip(): + links.append(ui_url) + + for variance in self.variances(vulnerability): + references = self.block(variance, "references") + for key in sorted(references): + link = str(references[key] or "").strip() + if link and link not in links: + links.append(link) + return "\n".join(links) + + def cvssv3_score(self, vulnerability): + """ + The score, but only when the vector really is CVSS v3. + + InsightAppSec also reports v2 vectors, and a v2 base in a v3 field would be read as a v3 + score - the same number means different things on the two scales. + """ + score = vulnerability.get("vulnerability_score") + if not isinstance(score, int | float) or isinstance(score, bool) or score <= 0: + return 0.0 + if not str(vulnerability.get("vector_string") or "").startswith("CVSS:3"): + return 0.0 + return float(score) + + def date(self, vulnerability): + """InsightAppSec timestamps are RFC 3339; the connector keeps the first ten characters.""" + discovered = str(vulnerability.get("first_discovered") or "") + if len(discovered) < 10: + return None + with suppress(ValueError): + return datetime.strptime(discovered[:10], "%Y-%m-%d").date() + return None + + def attach_endpoint(self, finding, url): + """The URL the attack was delivered to.""" + if not url: + return + with suppress(ValueError): + parsed = urlparse(url if "//" in url else f"//{url}") + try: + port = parsed.port + except ValueError: + return + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/"), query=parsed.query, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=port, + path=parsed.path.lstrip("/") or None, query=parsed.query or None, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else + makes Endpoint.clean() raise, and that fails the whole import rather than the one finding. + The URL is still in the description. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False diff --git a/dojo/tools/intigriti/__init__.py b/dojo/tools/intigriti/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/intigriti/parser.py b/dojo/tools/intigriti/parser.py new file mode 100644 index 00000000000..8b6d01b9a00 --- /dev/null +++ b/dojo/tools/intigriti/parser.py @@ -0,0 +1,385 @@ +import json +from contextlib import suppress +from html.parser import HTMLParser + +from dojo.models import Finding + +# Mirrors severityFromValue() in the Intigriti connector's converter. Intigriti grades its top tier +# "exceptional" rather than "critical", and both are accepted. +SEVERITY_MAP = { + "exceptional": "Critical", + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Close reasons that mean the submission was accepted as a risk rather than fixed. +RISK_ACCEPTED_REASONS = frozenset({"accepted risk", "risk accepted", "accepted-risk"}) +OUT_OF_SCOPE_REASONS = frozenset({"out of scope", "out-of-scope", "outofscope"}) +# Close reasons that mean the submission was not a real issue. Intigriti's "no" is its terse +# rejection reason. +FALSE_POSITIVE_REASONS = frozenset({ + "not applicable", "not-applicable", "not reproducible", "not-reproducible", + "false positive", "spam", "informative", "won't fix", "wont fix", "no", +}) + +# Block-level tags the connector's HTML flattener turns into newlines, and the tags whose content is +# dropped. The connectors repo duplicates this sanitizer per tool rather than sharing it, so it is +# reproduced here rather than imported from another parser. +BLOCK_TAGS = frozenset({ + "br", "p", "div", "li", "tr", "ul", "ol", "table", "blockquote", + "h1", "h2", "h3", "h4", "h5", "h6", +}) +DROPPED_TAGS = frozenset({"script", "style"}) + +# Go's html.EscapeString, applied last. Python's html.escape differs on the apostrophe, so the +# replacements are spelled out to match byte for byte. +GO_HTML_ESCAPES = ( + ("&", "&"), ("<", "<"), (">", ">"), ('"', """), ("'", "'"), +) + + +class _HtmlFlattener(HTMLParser): + + """Mirror of the connector's htmlFlattener: drop script/style, block tags become newlines.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self.skip_depth = 0 + + def handle_starttag(self, tag, attrs): + if tag in DROPPED_TAGS: + self.skip_depth += 1 + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_startendtag(self, tag, attrs): + if tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_endtag(self, tag): + if tag in DROPPED_TAGS: + self.skip_depth = max(0, self.skip_depth - 1) + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_data(self, data): + if self.skip_depth == 0: + self.parts.append(data) + + def text(self): + return "".join(self.parts) + + +def inert_text(raw_html): + """ + Flatten researcher-submitted HTML to escaped plain text, as the connector's InertText does. + + Intigriti submissions are written by external researchers, so their prose is never passed through + as markup. + """ + if not raw_html: + return "" + flattener = _HtmlFlattener() + flattener.feed(raw_html) + flattener.close() + return go_escape(collapse_whitespace(flattener.text())) + + +def collapse_whitespace(text): + """Trim every line, collapse runs of blank lines to one, and drop trailing blanks.""" + out, blank = [], True + for raw in text.split("\n"): + line = raw.strip() + if not line: + if not blank: + out.append("") + blank = True + continue + out.append(line) + blank = False + while out and not out[-1]: + out.pop() + return "\n".join(out) + + +def go_escape(text): + for character, entity in GO_HTML_ESCAPES: + text = text.replace(character, entity) + return text + + +class IntigritiParser: + + """ + Parses an Intigriti submissions export. + + Mirrors pkg/tools/intigriti/connector/converter.go field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + The connector converts each submission from TWO objects - a list overview and a fetched detail - + preferring the overview for any field both carry. An export may hold either or both; see split(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Intigriti - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Intigriti - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Intigriti submissions export (JSON). Matches the scan type used by the " + "Intigriti connector so file and API findings deduplicate. The submission's status and " + "close reason are translated into the corresponding DefectDojo state." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Intigriti Parser. + + Mirrors the connector's Convert: + - title: the overview title, falling back to the detail's. + - severity: the severity value; Intigriti's top tier is "exceptional". + - description: report type, asset, proof of concept, the researcher's question answers, and + the submission code. + - impact / mitigation: the report's impact and recommended solution, flattened to text. + - url / references: the Intigriti portal link for the submission. + - cvssv3: the severity vector. + - cwe: from the report type's cwe, e.g. "cwe-79". + - unique_id_from_tool / vuln_id_from_tool: both the submission code. + - active / verified / is_mitigated / risk_accepted / false_p / duplicate / out_of_scope: from + the status and close reason; see apply_state(). + """ + return [ + "title", + "severity", + "description", + "impact", + "mitigation", + "url", + "references", + "cvssv3", + "cwe", + "unique_id_from_tool", + "vuln_id_from_tool", + "active", + "verified", + "is_mitigated", + "risk_accepted", + "false_p", + "duplicate", + "out_of_scope", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Intigriti Parser. + + Copied from the Intigriti block in the Pro connector settings: submission codes are globally + unique on the platform, so the plain hash_code algorithm hashes the unique id alone. + """ + return ["unique_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + entries = self.extract_entries(data) + + findings = {} + for entry in entries: + if not isinstance(entry, dict): + continue + finding = self.build_finding(entry, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_entries(self, data): + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("records", "submissions", "data", "items"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "An Intigriti export is a JSON object with a 'records' list of submissions, or a bare " + f"array of submissions; got {type(data).__name__}." + ) + raise TypeError(msg) + + def split(self, entry): + """ + Separate the overview from the detail. + + The connector holds them apart because it lists submissions and then fetches each one. An + export may nest the detail under "detail", or - if it was taken from the detail endpoint - + carry the report on the entry itself, which is what the "report" check picks up. + """ + detail = entry.get("detail") if isinstance(entry.get("detail"), dict) else None + if detail is None and isinstance(entry.get("report"), dict): + detail = entry + return entry, detail + + def build_finding(self, entry, test): + overview, detail = self.split(entry) + code = overview.get("code") or (detail or {}).get("code") or "" + + severity_value = self.first( + self.nested(overview, "severity", "value"), + self.nested(detail, "severity", "value"), + ) + vector = self.first( + self.nested(overview, "severity", "vector"), + self.nested(detail, "severity", "vector"), + ) + portal_url = self.first( + self.nested(overview, "webLinks", "details"), + self.nested(detail, "webLinks", "details"), + ) + + finding = Finding( + test=test, + title=self.first(overview.get("title"), (detail or {}).get("title")) or None, + severity=SEVERITY_MAP.get(severity_value.strip().lower(), DEFAULT_SEVERITY), + description=self.describe(overview, detail, code), + unique_id_from_tool=code, + vuln_id_from_tool=code or None, + # A bug-bounty submission is a researcher testing a running target. + static_finding=False, + dynamic_finding=True, + ) + + if portal_url: + finding.url = portal_url + finding.references = f"Intigriti submission: {portal_url}" + if vector: + finding.cvssv3 = vector + + if detail: + report = detail.get("report") if isinstance(detail.get("report"), dict) else {} + cwe = self.cwe(self.nested(report, "type", "cwe")) + if cwe: + finding.cwe = cwe + if impact := inert_text(report.get("impact")): + finding.impact = impact + if solution := inert_text(report.get("recommendedSolution")): + finding.mitigation = solution + + self.apply_state(finding, *self.state(overview, detail)) + return finding + + def state(self, overview, detail): + """The overview's status and close reason, each falling back to the detail's.""" + status = self.first( + self.nested(overview, "state", "status", "value"), + self.nested(detail, "state", "status", "value"), + ) + close_reason = self.first( + self.nested(overview, "state", "closeReason", "value"), + self.nested(detail, "state", "closeReason", "value"), + ) + return status, close_reason + + def apply_state(self, finding, status, close_reason): + """ + Translate Intigriti's status, and for a closed submission its close reason, into state. + + Importing everything as active would put closed submissions back in front of the team, and + the close reason is what distinguishes a fix from a rejection or an accepted risk. + """ + normalised = self.normalise(status) + if normalised in {"closed", "archived"}: + self.apply_closed_state(finding, self.normalise(close_reason)) + return + finding.active = True + finding.verified = normalised == "accepted" + + def apply_closed_state(self, finding, reason): + finding.active = False + if reason in RISK_ACCEPTED_REASONS: + finding.verified = True + finding.risk_accepted = True + elif reason == "duplicate": + finding.duplicate = True + elif reason in OUT_OF_SCOPE_REASONS: + finding.out_of_scope = True + elif reason in FALSE_POSITIVE_REASONS: + finding.false_p = True + else: + # Solved, resolved, fixed, or closed with no specific reason. + finding.verified = True + finding.is_mitigated = True + + def describe(self, overview, detail, code): + parts = [] + if detail: + report = detail.get("report") if isinstance(detail.get("report"), dict) else {} + if label := self.report_type_label(report.get("type")): + parts.append(f"**Type:** {label}") + if asset := self.report_asset(report): + parts.append(f"**Asset:** {inert_text(asset)}") + if poc := inert_text(report.get("pocDescription")): + parts.append(f"**Proof of concept:**\n{poc}") + questions = report.get("questions") + if isinstance(questions, list): + for question in questions: + if not isinstance(question, dict): + continue + if not (question.get("question") or question.get("answer")): + continue + heading = inert_text(question.get("question")) + parts.append(f"**{heading}**\n{inert_text(question.get('answer'))}") + # The converter always appends the submission code. + parts.append(f"**Submission:** {code}") + return "\n\n".join(parts) + + def report_type_label(self, report_type): + """" ()", degrading to whichever of the two Intigriti supplied.""" + if not isinstance(report_type, dict): + return "" + name = inert_text(report_type.get("name")) + category = inert_text(report_type.get("category")) + if name and category: + return f"{name} ({category})" + return name or category + + def report_asset(self, report): + """The affected domain, falling back to the vulnerable component Intigriti recorded.""" + domain = report.get("domain") + if isinstance(domain, dict) and domain.get("name"): + return domain["name"] + return report.get("endpointVulnerableComponent") or "" + + def cwe(self, raw): + """Intigriti writes this lower-cased as "cwe-"; anything else leaves 0.""" + trimmed = str(raw or "").strip().lower() + if not trimmed.startswith("cwe-"): + return 0 + with suppress(ValueError): + number = int(trimmed[4:]) + if number > 0: + return number + return 0 + + def nested(self, source, *keys): + """Walk nested dicts, returning "" as soon as anything is missing or the wrong type.""" + current = source + for key in keys: + if not isinstance(current, dict): + return "" + current = current.get(key) + return current if isinstance(current, str) else "" + + def first(self, *values): + for value in values: + if value: + return value + return "" + + def normalise(self, value): + return (value or "").strip().lower() diff --git a/dojo/tools/intruder/__init__.py b/dojo/tools/intruder/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/intruder/parser.py b/dojo/tools/intruder/parser.py new file mode 100644 index 00000000000..4ca1c00f234 --- /dev/null +++ b/dojo/tools/intruder/parser.py @@ -0,0 +1,350 @@ +import json +import re +from contextlib import suppress +from datetime import datetime +from ipaddress import ip_address + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Intruder's snooze reasons, which is how it records triage. +SNOOZE_FALSE_POSITIVE = "FALSE_POSITIVE" +SNOOZE_ACCEPT_RISK = "ACCEPT_RISK" +SNOOZE_MITIGATING_CONTROL = "MITIGATING_CONTROLS" + +# The advisory identifiers the connector's shared extractor recognises in free text. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class IntruderParser: + + """ + Parses an Intruder issues export. + + Mirrors pkg/tools/intruder/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Intruder separates an issue - the weakness, its description and remediation - from its occurrences, + which are the targets it was found on. One finding is produced per occurrence, so an export needs + both; see extract(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Intruder API Import"] + + def get_label_for_scan_types(self, scan_type): + return "Intruder API Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Intruder issues export (JSON). Matches the scan type used by the Intruder " + "connector so file and API findings deduplicate. Include each issue's occurrences - the " + "occurrence is the finding." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Intruder Parser. + + Mirrors the connector's ConvertOccurrence: + - title: the issue title, shared by every occurrence of it. + - severity: the issue's severity label; anything unrecognised is Info. + - description: the target, port, protocol, first-seen date, exploit likelihood and any extra + information, then the issue's own prose. + - mitigation: the issue's remediation text. + - cvssv3_score: the occurrence's score, falling back to the issue's. + - date: when the occurrence was first seen. + - active / false_p / risk_accepted: from the occurrence's snooze state; see triage(). + - unique_id_from_tool: the occurrence id, which is stable per issue, target and port. + - vuln_id_from_tool: the issue id. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "false_p", + "risk_accepted", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Intruder Parser. + + Copied from the Intruder block in the Pro connector settings, which - unlike most connector + scan types - uses the plain hash_code algorithm rather than pairing it with the unique id. The + occurrence id is part of the HASH instead, with title and severity guarding against id reuse. + """ + return ["unique_id_from_tool", "title", "severity"] + + def get_findings(self, filename, test): + data = json.load(filename) + issues, occurrences = self.extract(data) + + findings = [] + for issue in issues: + if not isinstance(issue, dict): + continue + findings.extend( + self.build_finding(issue, occurrence, test) + for occurrence in self.occurrences_for(issue, occurrences) + ) + return findings + + def extract(self, data): + """ + Return the issues and the occurrences of each. + + Intruder pages every list under "results". The occurrences come from a second call, one per + issue, and carry no issue id of their own, so an export keys them by issue id or nests them on + the issue. + """ + occurrences = {} + issues = None + + if isinstance(data, list): + issues = data + elif isinstance(data, dict): + for key in ("results", "issues"): + if isinstance(data.get(key), list): + issues = data[key] + break + occurrences = self.index_occurrences(data) + + if issues is None: + msg = ( + "An Intruder export is the issues response, a JSON object with a 'results' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + return issues, occurrences + + def index_occurrences(self, data): + """The occurrences as a map keyed by issue id.""" + source = data.get("occurrences") + if not isinstance(source, dict): + return {} + indexed = {} + for issue_id, value in source.items(): + # Accept the occurrences endpoint's own paged response as well as a bare list. + rows = value.get("results") if isinstance(value, dict) else value + if isinstance(rows, list): + indexed[str(issue_id)] = [row for row in rows if isinstance(row, dict)] + return indexed + + def occurrences_for(self, issue, occurrences): + """The occurrences nested on the issue, else those indexed by its id.""" + nested = issue.get("occurrences") + if isinstance(nested, list): + return [row for row in nested if isinstance(row, dict)] + if isinstance(nested, dict) and isinstance(nested.get("results"), list): + return [row for row in nested["results"] if isinstance(row, dict)] + # Intruder's own issue object carries "occurrences" as a URL string, which is not a list - + # that is the second call the export has to include. + return occurrences.get(str(issue.get("id")), []) + + def build_finding(self, issue, occurrence, test): + finding = Finding( + test=test, + title=str(issue.get("title") or ""), + severity=self.severity(issue), + description=self.describe(issue, occurrence), + mitigation=str(issue.get("remediation") or ""), + unique_id_from_tool=self.identifier(occurrence.get("occurrence_id")), + vuln_id_from_tool=self.identifier(issue.get("id")), + # Intruder scans live hosts and services. + static_finding=False, + dynamic_finding=True, + ) + finding.cvssv3_score = self.score(issue, occurrence) + finding.unsaved_tags = self.tags(occurrence) + + if identifiers := self.vulnerability_ids(issue, occurrence): + finding.unsaved_vulnerability_ids = identifiers + if date := self.date(occurrence): + finding.date = date + + self.triage(finding, occurrence) + self.attach_endpoint(finding, occurrence) + return finding + + def identifier(self, value): + """Intruder's ids are integers; the connector renders them as strings.""" + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int | float): + return str(int(value)) + text = str(value).strip() + return text or None + + def severity(self, issue): + label = str(issue.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def score(self, issue, occurrence): + """ + The occurrence's score, falling back to the issue's. + + The same weakness scores differently per target - a service reachable from the internet is not + the same risk as one behind a firewall - so the occurrence's own score wins. + """ + for source in (occurrence, issue): + value = source.get("cvss_score") + if isinstance(value, int | float) and not isinstance(value, bool) and value != 0: + return float(value) + return 0.0 + + def triage(self, finding, occurrence): + """ + Intruder records triage by snoozing an occurrence, with a reason. + + A snoozed occurrence is inactive, and the reason decides whether it is a false positive or an + accepted risk. A snooze reason the connector does not recognise leaves the finding inactive + without either flag - it is still triaged, just not in a way DefectDojo has a field for. + """ + if not occurrence.get("snoozed"): + finding.active = True + return + finding.active = False + reason = str(occurrence.get("snooze_reason") or "").strip() + if reason == SNOOZE_FALSE_POSITIVE: + finding.false_p = True + elif reason in {SNOOZE_ACCEPT_RISK, SNOOZE_MITIGATING_CONTROL}: + finding.risk_accepted = True + + def vulnerability_ids(self, issue, occurrence): + """ + The occurrence's CVEs, then any identifier in the issue's title or description. + + Order is preserved and duplicates dropped, as the connector does. + """ + candidates = [] + cves = occurrence.get("cves") + if isinstance(cves, list): + candidates.extend(str(cve) for cve in cves) + prose = "|".join([str(issue.get("title") or ""), str(issue.get("description") or "")]) + candidates.extend(VULNERABILITY_ID_PATTERN.findall(prose)) + + identifiers = [] + for candidate in candidates: + trimmed = candidate.strip() + if trimmed and trimmed not in identifiers: + identifiers.append(trimmed) + return identifiers + + def date(self, occurrence): + """Intruder timestamps in RFC 3339; only the date is kept.""" + first_seen = str(occurrence.get("first_seen_at") or "").strip() + if not first_seen: + return None + with suppress(ValueError): + return datetime.strptime(first_seen.split("T")[0], "%Y-%m-%d").date() + return None + + def describe(self, issue, occurrence): + """ + The connector's shared formatter: prefixed bullets, then a level-3 Description heading. + + Note the target line prefers the DISPLAY address while the endpoint prefers the target - the + display address is what a person recognises, the target is what was scanned. + """ + bullets = [] + + def add(prefix, value): + text = str(value or "").strip() + if text: + bullets.append(f"* **{prefix}** {text}") + + target = str(occurrence.get("display_address") or "").strip() + if not target: + target = str(occurrence.get("target") or "").strip() + add("Target:", target) + add("Port:", occurrence.get("port")) + add("Protocol:", occurrence.get("protocol")) + add("First seen:", occurrence.get("first_seen_at")) + add("Exploit likelihood:", occurrence.get("exploit_likelihood")) + + extra = occurrence.get("extra_info") + if isinstance(extra, dict): + # Sorted because a Go map has no order, and matching that keeps the two paths identical. + for key in sorted(extra): + add(f"{key}:", extra[key]) + + parts = ["\n".join(bullets) + "\n"] if bullets else [] + if prose := str(issue.get("description") or "").strip(): + parts.append("\n### Description\n\n" + prose + "\n") + return "".join(parts).rstrip("\n") + "\n" if parts else "" + + def tags(self, occurrence): + tags = ["intruder"] + if target := str(occurrence.get("target") or "").strip(): + tags.append(f"target:{target}") + return tags + + def attach_endpoint(self, finding, occurrence): + """ + Record the scanned target. + + The connector emits ":" as an endpoint string; here host and port are set as + separate fields, which is the same outcome without the string round-trip. A port of "0" means + Intruder had none, and the connector drops it rather than recording port zero. + """ + host = str(occurrence.get("target") or "").strip() + if not host: + host = str(occurrence.get("display_address") or "").strip() + if not host or not self.usable_host(host): + return + + port = None + raw_port = str(occurrence.get("port") or "").strip() + if raw_port and raw_port != "0": + with suppress(ValueError): + port = int(raw_port) + + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(host=host, port=port)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=host, port=port)) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else + makes Endpoint.clean() raise, and that fails the whole import rather than the one finding. + The target is still in the description. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False diff --git a/dojo/tools/klocwork/__init__.py b/dojo/tools/klocwork/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/klocwork/parser.py b/dojo/tools/klocwork/parser.py new file mode 100644 index 00000000000..f6d8a66fd06 --- /dev/null +++ b/dojo/tools/klocwork/parser.py @@ -0,0 +1,245 @@ +import json +from contextlib import suppress +from datetime import UTC, datetime + +from dojo.models import Finding + +# Klocwork's severity CODE, 1 the most severe. Codes 5-10 are its informational tiers. +SEVERITY_BY_CODE = {1: "Critical", 2: "High", 3: "Medium", 4: "Low"} +DEFAULT_SEVERITY = "Info" + +# Statuses that mean a reviewer decided the issue is not a real problem. +FALSE_POSITIVE_STATUSES = {"ignore", "not a problem", "filter"} + + +class KlocworkParser: + + """ + Parses a Klocwork issue export. + + Mirrors pkg/tools/klocwork/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Klocwork's search endpoint answers with NDJSON - one JSON object per line, not an array - so that is + the shape a saved export has, and it is what this parser reads first; see issues(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Klocwork Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Klocwork Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Klocwork issue export. Klocwork's search endpoint answers with NDJSON - one " + "issue per line - and that shape is read first; a JSON array is accepted too. Matches the " + "scan type used by the Klocwork connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Klocwork Parser. + + Mirrors the connector's Convert: + - title: ": ", falling back to either alone, then the issue id. + - severity: Klocwork's severity CODE, where 1 is the most severe; see severity(). + - description: the message, checker, method, taxonomy and status. + - file_path / line: where Klocwork found it - the file path is in the deduplication hash. + - references: the issue's own Klocwork URL. + - active / false_p: from the status; see false_positive(). + - unique_id_from_tool: "klocwork-". + - vuln_id_from_tool: the checker code, which is Klocwork's rule identity. + """ + return [ + "title", + "severity", + "date", + "description", + "references", + "file_path", + "line", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "false_p", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Klocwork Parser. + + Copied from the Klocwork block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The file path and the checker are both + in the hash: the same checker firing in two files is two findings. + """ + return ["title", "severity", "file_path", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + rows = self.issues(filename) + findings = [] + for row in rows: + if self.flex_int(row.get("id")) == 0: + # The connector drops a row with no id: the id is the whole identity. + continue + findings.append(self.build_finding(row, test)) + return findings + + def issues(self, filename): + """ + Return the issues in the export. + + Klocwork answers with NDJSON, so each line is parsed on its own. A line that is not an object, + or that carries the run "summary" rather than an issue, is skipped - exactly as the connector's + decoder does. A whole-document JSON array or an object with an "issues" list is accepted too, + for an export somebody has already reshaped. + """ + content = filename.read() + if isinstance(content, bytes): + content = content.decode("utf-8", errors="replace") + + with suppress(ValueError): + data = json.loads(content) + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("issues", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + # A single issue object. + if "id" in data: + return [data] + # A search that matched nothing answers with the summary line alone, which is a valid + # JSON document on its own. That is an empty result, not a malformed file - the same + # rule the NDJSON path applies line by line. + if "summary" in data: + return [] + msg = ( + "A Klocwork export is the search response - NDJSON, one issue per line - or a " + "JSON array of issues; this object carries neither an 'issues' list nor an id." + ) + raise TypeError(msg) + + rows = [] + for line in content.split("\n"): + stripped = line.strip() + # The summary line reports the run rather than an issue, and the connector skips it by + # this same test rather than by parsing it. + if not stripped.startswith("{") or '"summary"' in stripped: + continue + with suppress(ValueError): + row = json.loads(stripped) + if isinstance(row, dict): + rows.append(row) + + if not rows and content.strip(): + msg = ( + "A Klocwork export is the search response - NDJSON, one issue per line - or a JSON " + "array of issues; no issue line was found." + ) + raise TypeError(msg) + return rows + + def build_finding(self, row, test): + false_positive = self.false_positive(row) + + finding = Finding( + test=test, + title=self.title(row), + severity=self.severity(row), + description=self.describe(row), + file_path=str(row.get("file") or "") or None, + line=self.flex_int(row.get("line")) or None, + unique_id_from_tool=f"klocwork-{self.flex_int(row.get('id'))}", + vuln_id_from_tool=str(row.get("code") or "") or None, + # Klocwork analyses source; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=not false_positive, + false_p=false_positive, + ) + finding.unsaved_tags = self.tags(row) + + if url := str(row.get("url") or ""): + finding.references = url + if date := self.date(row): + finding.date = date + return finding + + def title(self, row): + """": " - the checker alone is opaque, the name alone is not searchable.""" + name = str(row.get("name") or "") + code = str(row.get("code") or "") + if name and code: + return f"{code}: {name}" + if name: + return name + if code: + return code + return f"Klocwork issue {self.flex_int(row.get('id'))}" + + def severity(self, row): + """ + Grade Klocwork's severity CODE, where 1 is the most severe. + + This is the inverse of a score: reading it as one would inverte the whole ladder. Codes 5-10 + are Klocwork's informational tiers and all become Info, as does an absent code. + """ + return SEVERITY_BY_CODE.get(self.flex_int(row.get("severityCode")), DEFAULT_SEVERITY) + + def false_positive(self, row): + """ + Klocwork records triage in the status. + + "Ignore", "Not a problem" and "Filter" are all a reviewer saying it is not real, so they are + false positives and inactive. Everything else - including the deferred states the connector's + query selects - stays active. + """ + return str(row.get("status") or "").strip().lower() in FALSE_POSITIVE_STATUSES + + def describe(self, row): + lines = [] + for label, key in (("Message", "message"), ("Checker", "code"), ("Method", "method"), + ("Taxonomy", "taxonomyName"), ("Status", "status")): + value = str(row.get(key) or "") + if value: + lines.append(f"**{label}:** {value}") + return "\n".join(lines) + + def tags(self, row): + tags = [] + for key in ("taxonomyName", "code", "severity"): + value = str(row.get(key) or "").strip() + if value: + tags.append(value) + return tags + + def date(self, row): + """Klocwork timestamps in unix MILLIseconds.""" + value = self.flex_int(row.get("dateOriginated")) + if value <= 0: + return None + with suppress(OSError, OverflowError, ValueError): + return datetime.fromtimestamp(value / 1000, tz=UTC).date() + return None + + def flex_int(self, value): + """ + Klocwork may send a number as a JSON number or a quoted string. + + The connector models both because its own decoder silently skips a line it cannot parse - so a + server quoting its numerics would produce a clean, empty sync rather than an error. + """ + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 diff --git a/dojo/tools/lacework/__init__.py b/dojo/tools/lacework/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/lacework/parser.py b/dojo/tools/lacework/parser.py new file mode 100644 index 00000000000..ed8f38fe406 --- /dev/null +++ b/dojo/tools/lacework/parser.py @@ -0,0 +1,232 @@ +import json + +from dojo.models import Finding + +# Mirrors toSeverity() in the Lacework connector's converter; anything unrecognised becomes Info. +SEVERITY_MAP = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Lacework marks a fixed or otherwise resolved vulnerability in the row itself, and the connector's +# applyStatus() keeps those out of the active set rather than dropping them. +INACTIVE_STATUSES = {"fixed", "resolved"} + + +class LaceworkParser: + + """ + Parses a Lacework (FortiCNAPP) vulnerability export. + + Lacework reports two different things, and the connector's converter treats them differently: + container/image vulnerabilities are STATIC findings keyed by image, host vulnerabilities are + DYNAMIC findings keyed by hostname. Both mappings are mirrored from + pkg/tools/lacework/converter so a file import and an API sync deduplicate against each other. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Lacework - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Lacework - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Lacework (FortiCNAPP) container or host vulnerability export (JSON). Matches " + "the scan type used by the Lacework connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Lacework Parser. + + Mirrors the connector's converter: + - title: " - ()", falling back as the converter's title() does. + - severity: critical/high/medium/low, anything else Info. + - description: CVE, severity, image or host context, package, namespace, installed version. + - component_name / component_version: the feature key's name and version. + - mitigation: set only when the row reports a fix available AND a fixed version. + - references: the CVE link, host rows only. + - unique_id_from_tool: "|||". + - static_finding / dynamic_finding: container rows are static, host rows are dynamic. + - active: false for a row Lacework reports as fixed or resolved. + """ + return [ + "title", + "severity", + "description", + "component_name", + "component_version", + "mitigation", + "references", + "unique_id_from_tool", + "tags", + "static_finding", + "dynamic_finding", + "active", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Lacework Parser. + + Copied from the Lacework block in dojo-pro pro_settings.py, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Diverging would stop file findings + merging with API-synced ones. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + rows = self.extract_rows(data) + + findings = {} + for row in rows: + if not isinstance(row, dict): + continue + finding = ( + self.build_host_finding(row, test) if self.is_host_row(row) + else self.build_container_finding(row, test) + ) + key = finding.unique_id_from_tool + if key not in findings: + findings[key] = finding + return list(findings.values()) + + def extract_rows(self, data): + """Lacework's query API wraps rows under "data"; a saved export is often the bare array.""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("data", "rows"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "A Lacework export is a JSON array of vulnerability rows, or an object with a 'data' " + f"list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def is_host_row(self, row): + """ + Decide which of the connector's two mappings applies. + + A host row carries the installed version under featureKey.version_installed and identifies + the machine by hostname or mid; a container row carries featureKey.version and an imageId. + """ + feature = row.get("featureKey") or {} + if "version_installed" in feature: + return True + return "mid" in row or "imageId" not in row + + def build_container_finding(self, row, test): + feature = row.get("featureKey") or {} + image_info = ((row.get("evalCtx") or {}).get("image_info")) or {} + package = feature.get("name") or "" + version = feature.get("version") or "" + vuln_id = row.get("vulnId") or "" + image = image_info.get("repo") or row.get("imageId") or "" + + finding = Finding( + test=test, + title=self.title(vuln_id, package, version), + severity=self.severity(row), + description=self.fields([ + ("CVE", vuln_id), + ("Severity", row.get("severity")), + ("Image", image), + ("Registry", image_info.get("registry")), + ("Image Digest", image_info.get("digest")), + ("Package", package), + ("Namespace", feature.get("namespace")), + ("Installed Version", version), + ]), + component_name=package or None, + component_version=version or None, + # An image scan reads a built artifact, so the converter marks it static. + static_finding=True, + dynamic_finding=False, + unique_id_from_tool="|".join([row.get("imageId") or "", vuln_id, package, version]), + ) + finding.unsaved_tags = [ + f"image:{image}", f"registry:{image_info.get('registry') or ''}", "source:container", + ] + self.apply_status(finding, row.get("status")) + # The converter only offers a fix when Lacework reports both a fix flag and a version. + fix = row.get("fixInfo") or {} + if fix.get("fix_available") == 1 and fix.get("fixed_version"): + finding.mitigation = f"Upgrade {package} to {fix['fixed_version']}." + if vuln_id: + finding.unsaved_vulnerability_ids = [vuln_id] + return finding + + def build_host_finding(self, row, test): + feature = row.get("featureKey") or {} + cve_props = row.get("cveProps") or {} + package = feature.get("name") or "" + version = feature.get("version_installed") or "" + vuln_id = row.get("vulnId") or "" + host = ((row.get("evalCtx") or {}).get("hostname")) or "" + if not host: + # The converter falls back to the machine id when the hostname is missing. + host = f"mid-{row.get('mid')}" + + finding = Finding( + test=test, + title=self.title(vuln_id, package, version), + severity=self.severity(row), + description=self.fields([ + ("CVE", vuln_id), + ("Severity", row.get("severity")), + ("Host", (row.get("evalCtx") or {}).get("hostname")), + ("Package", package), + ("Namespace", feature.get("namespace")), + ("Installed Version", version), + ("Description", cve_props.get("description")), + ]), + component_name=package or None, + component_version=version or None, + references=cve_props.get("link") or None, + # A host scan looks at a running machine, so the converter marks it dynamic. + static_finding=False, + dynamic_finding=True, + unique_id_from_tool=f"{host}|{vuln_id}|{package}|{version}", + ) + finding.unsaved_tags = [f"host:{host}", "source:host"] + self.apply_status(finding, row.get("status")) + # Host rows report fix_available as a STRING, unlike the container rows' integer. + fix = row.get("fixInfo") or {} + available = str(fix.get("fix_available") or "") + if available not in {"", "0"} and fix.get("fixed_version"): + finding.mitigation = f"Upgrade {package} to {fix['fixed_version']}." + if vuln_id: + finding.unsaved_vulnerability_ids = [vuln_id] + return finding + + def title(self, vuln_id, package, version): + """Build the title as the converter does: identifier, then package, then version.""" + identifier = vuln_id or "Vulnerability" + if package and version: + return f"{identifier} - {package} ({version})" + if package: + return f"{identifier} - {package}" + return identifier + + def severity(self, row): + return SEVERITY_MAP.get((row.get("severity") or "").strip().lower(), DEFAULT_SEVERITY) + + def apply_status(self, finding, status): + """Converter applyStatus(): a fixed or resolved row is imported, but not as active.""" + if (status or "").strip().lower() in INACTIVE_STATUSES: + finding.active = False + finding.is_mitigated = True + else: + finding.active = True + + def fields(self, pairs): + """Converter writeField(): "**Label:** value" lines, skipping the empty ones.""" + return "\n".join(f"**{label}:** {value}" for label, value in pairs if value) diff --git a/dojo/tools/netrise/__init__.py b/dojo/tools/netrise/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/netrise/parser.py b/dojo/tools/netrise/parser.py new file mode 100644 index 00000000000..8241b48b563 --- /dev/null +++ b/dojo/tools/netrise/parser.py @@ -0,0 +1,279 @@ +import json +from contextlib import suppress + +from dojo.models import Finding + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + "informational": "Info", + "none": "Info", +} + + +class NetriseParser: + + """ + Parses a NetRise firmware-analysis export. + + Mirrors pkg/tools/netrise/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + NetRise answers GraphQL in Relay shape - a list of {"node": {...}} edges - and serves the firmware + artifacts and each artifact's vulnerabilities from separate queries, so an export carries both and + the edges are unwrapped; see rows(). The artifact matters to the finding: the identity is scoped to + it, so the same CVE in two firmware builds stays two findings. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["NetRise Scan"] + + def get_label_for_scan_types(self, scan_type): + return "NetRise Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a NetRise export (JSON) - a firmware artifact with its vulnerabilities, in the " + "GraphQL Relay shape NetRise answers with. Matches the scan type used by the NetRise " + "connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the NetRise Parser. + + Mirrors the connector's Convert: + - title: " in ", or whichever of the two is present. + - severity: NetRise's severity word, falling back to its CVSS score; see severity(). + - severity_justification: reachability and CISA KEV listing, spelled out. + - description: the component, the artifact and its vendor, product and firmware version. + - mitigation: upgrade to a fixed version, when NetRise names one. + - component_name / cvssv3_score: the affected component and the CVSS base score. + - unique_id_from_tool: "netrise--". + """ + return [ + "title", + "severity", + "severity_justification", + "description", + "mitigation", + "component_name", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the NetRise Parser. + + Copied from the NetRise block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + asset = self.asset(data) + return [ + self.build_finding(row, self.block(row, "asset") or asset, test) + for row in self.rows(data) + ] + + def rows(self, data): + """ + Return the vulnerabilities in the export. + + NetRise answers GraphQL Relay: {"vulnerabilities": {"edges": [{"node": {...}}]}}. The edges are + unwrapped, and a plain list of vulnerabilities is accepted too - so an export somebody has + already flattened still imports. + """ + if isinstance(data, list): + return self.nodes(data) + + if isinstance(data, dict): + for holder in (self.block(data, "data"), data): + for key in ("vulnerabilities", "allVulnerabilities", "findings"): + value = holder.get(key) + if isinstance(value, dict) and isinstance(value.get("edges"), list): + return self.nodes(value["edges"]) + if isinstance(value, list): + return self.nodes(value) + + msg = ( + "A NetRise export is the vulnerabilities response, a JSON object with a " + f"'vulnerabilities' object or list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def nodes(self, rows): + """Unwrap Relay edges, accepting rows that are already the node itself.""" + unwrapped = [] + for row in rows: + if not isinstance(row, dict): + continue + node = row.get("node") + unwrapped.append(node if isinstance(node, dict) else row) + return unwrapped + + def asset(self, data): + """ + The firmware artifact the whole file belongs to. + + One export is one artifact, so it is stated once for the file - though a row carrying its own + overrides it. The artifact is what the identity is scoped to, so the same CVE in two firmware + builds stays two findings rather than merging into one. + """ + if not isinstance(data, dict): + return {} + for holder in (data, self.block(data, "data")): + for key in ("asset", "artifact"): + if candidate := self.block(holder, key): + return candidate + relay = self.block(holder, "assetsRelay") + if isinstance(relay.get("edges"), list): + nodes = self.nodes(relay["edges"]) + if nodes: + return nodes[0] + return {} + + def block(self, row, key): + if not isinstance(row, dict): + return {} + value = row.get(key) + return value if isinstance(value, dict) else {} + + def build_finding(self, row, asset, test): + cve = str(row.get("cve") or "") + component = str(row.get("name") or "") + + finding = Finding( + test=test, + title=self.title(cve, component), + severity=self.severity(row), + description=self.describe(row, asset, component), + mitigation=self.mitigation(row), + component_name=component or None, + unique_id_from_tool=f"netrise-{asset.get('id') or ''}-{cve or component}", + vuln_id_from_tool=cve or None, + # Firmware analysis inspects an artifact without running it. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.cvssv3_score = self.score(row) + finding.unsaved_tags = self.tags(row, asset) + + if cve: + finding.unsaved_vulnerability_ids = [cve] + if justification := self.justification(row): + finding.severity_justification = justification + return finding + + def title(self, cve, component): + if cve and component: + return f"{cve} in {component}" + if cve: + return cve + if component: + return component + return "NetRise vulnerability" + + def severity(self, row): + """ + NetRise's severity word, falling back to its CVSS score. + + An unrecognised word falls through to the SCORE rather than to Info, so a finding NetRise + grades with a word this parser does not know still lands at the severity its score implies. + """ + label = str(row.get("severity") or "").strip().lower() + if label in SEVERITY_BY_LABEL: + return SEVERITY_BY_LABEL[label] + + score = self.score(row) + if score >= 9: + return "Critical" + if score >= 7: + return "High" + if score >= 4: + return "Medium" + if score > 0: + return "Low" + return "Info" + + def score(self, row): + """NetRise's CVSS base score, which may arrive quoted.""" + value = row.get("cvssScore") + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 + + def describe(self, row, asset, component): + lines = [] + + def write(label, value): + # The connector tests for emptiness WITHOUT trimming, which a plain truthiness test + # reproduces exactly for strings. + if value: + lines.append(f"**{label}:** {value}") + + write("Component", component) + write("Artifact", str(asset.get("name") or "")) + write("Vendor", str(asset.get("vendor") or "")) + write("Product", str(asset.get("product") or "")) + write("Firmware version", str(asset.get("version") or "")) + + if row.get("isReachable"): + write("Reachable", "yes") + if row.get("inKnownExploitedVulnerabilities"): + write("CISA KEV", "yes") + return "\n".join(lines).strip() + + def mitigation(self, row): + versions = [str(value) for value in row.get("fixVersions") or []] + if versions: + return "Upgrade to a fixed version: " + ", ".join(versions) + "." + return None + + def justification(self, row): + """ + Reachability and CISA KEV listing, spelled out. + + Both are recorded as the justification rather than moving the severity: a reachable, + actively-exploited flaw in firmware is more urgent than its score says, and a reviewer needs + to see why - but changing the grade would make it disagree with an API sync. + """ + notes = [] + if row.get("isReachable"): + notes.append("NetRise marks this vulnerability as reachable in the firmware.") + if row.get("inKnownExploitedVulnerabilities"): + notes.append("Listed in CISA's Known Exploited Vulnerabilities catalog.") + return " ".join(notes) + + def tags(self, row, asset): + """The artifact's vendor and product, plus reachability and KEV markers, for filtering.""" + tags = [ + value + for value in (str(asset.get("vendor") or "").strip(), str(asset.get("product") or "").strip()) + if value + ] + if row.get("isReachable"): + tags.append("reachable") + if row.get("inKnownExploitedVulnerabilities"): + tags.append("cisa-kev") + return tags diff --git a/dojo/tools/nightfall/__init__.py b/dojo/tools/nightfall/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/nightfall/parser.py b/dojo/tools/nightfall/parser.py new file mode 100644 index 00000000000..a38c81e70e9 --- /dev/null +++ b/dojo/tools/nightfall/parser.py @@ -0,0 +1,524 @@ +import json +from contextlib import suppress +from datetime import UTC, datetime + +from dojo.models import Finding + +# Verbatim from Nightfall's ViolationRisk enum. +SEVERITY_BY_RISK = { + "CRITICAL": "Critical", + "HIGH": "High", + "MEDIUM": "Medium", + "LOW": "Low", + "NO_RISK": "Info", + "UNSPECIFIED": "Info", +} +DEFAULT_SEVERITY = "Info" + +# Verbatim from Nightfall's ViolationState enum. +STATE_ACTIVE = "ACTIVE" +STATE_PENDING = "PENDING" +STATE_RESOLVED = "RESOLVED" +STATE_EXPIRED = "EXPIRED" + +# An API key Nightfall could reach, or whose signature it verified, is a live credential. +LIVE_KEY_STATUSES = {"ACTIVE", "SIGNATURE_VERIFIED"} + +# Each integration keeps its own metadata block, and only one of them is ever set. The order is the +# connector's, because it decides which block wins if a violation somehow carries two. +LOCATION_BY_INTEGRATION = ( + ("slackMetadata", " / ", ("workspaceName", "location")), + ("githubMetadata", "/", ("organization", "repository")), + ("gdriveMetadata", " / ", ("drive", "fileName")), + ("jiraMetadata", " ", ("projectName", "ticketNumber")), + ("confluenceMetadata", " / ", ("spaceName", "itemName")), + ("salesforceMetadata", " / ", ("orgName", "objectName")), + ("zendeskMetadata", " ", ("ticketTitle", "ticketID")), + ("notionMetadata", " / ", ("workspaceName", "pageTitle")), + ("m365TeamsMetadata", " / ", ("teamName", "channelName")), + ("m365OnedriveMetadata", " / ", ("driveOwnerName", "driveItemName")), + ("browserMetadata", " / ", ("browserName", "location")), + ("inlineEmailMetadata", " / ", ("domain", "subject")), +) + +# The link field each integration's metadata block carries, in the same precedence order. +LINK_BY_INTEGRATION = ( + ("slackMetadata", "messagePermalink"), + ("githubMetadata", "githubPermalink"), + ("gdriveMetadata", "fileLink"), + ("jiraMetadata", "ticketLink"), + ("confluenceMetadata", "permalink"), + ("salesforceMetadata", "objectLink"), + ("notionMetadata", "privatePageLink"), + ("m365TeamsMetadata", "channelWebURL"), + ("m365OnedriveMetadata", "driveItemURL"), +) + + +class NightfallParser: + + """ + Parses a Nightfall AI violations export. + + Mirrors pkg/tools/nightfall/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + Nightfall splits a violation across two calls: the violation itself, and the detections that make + it up, which carry the redacted evidence, the confidence and the API-key verdict. The evidence is + what raises a violation to Critical and what names the credential in the title, so an export needs + both - see extract(). + + Nothing sensitive is imported. Nightfall only ever returns REDACTED detection text, and this parser + reads that field and no other; there is no field in the API carrying the raw secret. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Nightfall AI - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Nightfall AI - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Nightfall AI violations export (JSON). Matches the scan type used by the " + "Nightfall AI connector so file and API findings deduplicate. Include each violation's " + "detections so findings carry their evidence and credential verdict." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Nightfall AI Parser. + + Mirrors the connector's ViolationToFinding: + - title: " exposed in ()"; see title(). + - severity: the risk label, except that a verified live credential is always Critical. + - description: the integration, location, policies, state, owner, file and exposure note, + then the redacted detections. + - mitigation: the connector's three remediation steps. + - references: the resource link, the integration's own permalink and the file permalink. + - severity_justification: Nightfall's numeric risk score and its source. + - active / is_mitigated / out_of_scope / verified: from the violation state. + - file_path / line: set for GitHub violations, which are the only ones with a code location. + - service: the integration the violation was found in. + - unique_id_from_tool: the violation id. + - vuln_id_from_tool: the first policy that matched. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "references", + "severity_justification", + "file_path", + "line", + "service", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "is_mitigated", + "out_of_scope", + "verified", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Nightfall AI Parser. + + Copied from the Nightfall AI block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "description"] + + def get_findings(self, filename, test): + data = json.load(filename) + violations, detections = self.extract(data) + + return [ + self.build_finding(violation, self.detections_for(violation, detections), test) + for violation in violations + if isinstance(violation, dict) + ] + + def extract(self, data): + """ + Return the violations and the detections belonging to each. + + Nightfall's violation list nests rows under "violations". The detections come from a second + endpoint, one call per violation, and carry no violation id of their own, so an export keys + them by violation id under "findings" (or "detections"), or nests them on each violation. + """ + detections = {} + violations = None + + if isinstance(data, list): + violations = data + elif isinstance(data, dict): + for key in ("violations", "results"): + if isinstance(data.get(key), list): + violations = data[key] + break + for key in ("findings", "detections"): + if isinstance(data.get(key), dict): + detections = {str(k): v for k, v in data[key].items() if isinstance(v, list)} + break + + if violations is None: + msg = ( + "A Nightfall AI export is the violation-list response, a JSON object with a " + f"'violations' list; got {type(data).__name__}." + ) + raise TypeError(msg) + return violations, detections + + def detections_for(self, violation, detections): + """The detections nested on the violation, else the ones indexed by its id.""" + for key in ("findings", "detections"): + nested = violation.get(key) + if isinstance(nested, list): + return [item for item in nested if isinstance(item, dict)] + if isinstance(nested, dict) and isinstance(nested.get("findings"), list): + return [item for item in nested["findings"] if isinstance(item, dict)] + indexed = detections.get(str(violation.get("id")), []) + return [item for item in indexed if isinstance(item, dict)] + + def build_finding(self, violation, detections, test): + state = str(violation.get("state") or "").strip().upper() + integration = str(violation.get("integration") or "").strip() + + finding = Finding( + test=test, + title=self.title(violation, detections, integration), + severity=self.severity(violation, detections), + date=self.date(violation), + description=self.describe(violation, detections, integration), + mitigation=self.mitigation(integration), + references=self.references(violation), + active=state in {STATE_ACTIVE, STATE_PENDING}, + is_mitigated=state == STATE_RESOLVED, + # Nightfall expires a violation whose resource it can no longer see. + out_of_scope=state == STATE_EXPIRED, + # A pending violation has not been triaged yet, so it is not verified. + verified=state not in {"", STATE_PENDING}, + # Nightfall inspects data at rest in a SaaS app, not a running application. + static_finding=True, + dynamic_finding=False, + service=integration or None, + unique_id_from_tool=str(violation.get("id")) if violation.get("id") else None, + vuln_id_from_tool=self.first_policy(violation) or None, + ) + finding.unsaved_tags = self.tags(violation, detections, integration) + + if file_path := self.file_path(violation): + finding.file_path = file_path + if line := self.line_number(detections): + finding.line = line + if (score := self.risk_score(violation)) > 0: + finding.severity_justification = self.severity_justification(violation, score) + return finding + + def title(self, violation, detections, integration): + """ + " exposed in ()". + + The subject is the credential the detections identified, falling back to the policy that + matched and then to a generic label - a violation is worth reporting even when Nightfall + cannot say what kind of secret it saw. + """ + subject = self.detected_subject(detections) or self.first_policy(violation) or "Sensitive data" + label = self.integration_label(integration) + location = self.location(violation) + if location: + return f"{subject} exposed in {label} ({location})" + if label: + return f"{subject} exposed in {label}" + return f"{subject} exposed" + + def detected_subject(self, detections): + """The kind of credential the first API-key detection identified.""" + for detection in detections: + key = self.api_key(detection) + if key is None: + continue + kind = str(key.get("kind") or "").strip() + if not kind or kind.upper() == "UNSPECIFIED": + kind = "API" + if self.is_live(key): + return f"Verified live {kind} credential" + return f"{kind} credential" + return "" + + def integration_label(self, integration): + """Nightfall names integrations M365_TEAMS; the connector prints them M365 TEAMS.""" + return integration.replace("_", " ") + + def api_key(self, detection): + metadata = detection.get("metadata") + if not isinstance(metadata, dict): + return None + key = metadata.get("apiKeyMetaData") + return key if isinstance(key, dict) else None + + def is_live(self, key): + """ + Whether Nightfall could confirm the credential works. + + Either it authenticated with it, or it verified the key's signature. + """ + if not isinstance(key, dict): + return False + return str(key.get("status") or "").strip().upper() in LIVE_KEY_STATUSES + + def severity(self, violation, detections): + """ + Grade the violation. + + A credential Nightfall verified as live is Critical whatever the policy's risk says - it is a + working secret in a place it should not be. Otherwise the risk label is used, and an + unrecognised one is Info rather than a guess. + """ + for detection in detections: + if self.is_live(self.api_key(detection)): + return "Critical" + risk = str(violation.get("risk") or "").strip().upper() + if not risk: + return DEFAULT_SEVERITY + return SEVERITY_BY_RISK.get(risk, DEFAULT_SEVERITY) + + def date(self, violation): + """Nightfall sends the creation time as unix seconds.""" + created = violation.get("createdAt") + if isinstance(created, int | float) and not isinstance(created, bool) and created > 0: + with suppress(OSError, OverflowError, ValueError): + return datetime.fromtimestamp(created, tz=UTC).date() + return datetime.now(tz=UTC).date() + + def describe(self, violation, detections, integration): + sections = [] + details = [] + + def add(label, value): + text = str(value).strip() if value is not None else "" + if text: + details.append(f"**{label}:** {text}") + + add("Integration", self.integration_label(integration)) + add("Location", self.location(violation)) + add("Policies", ", ".join(self.policy_names(violation))) + add("Nightfall state", violation.get("state")) + + user_info = violation.get("userInfo") + if isinstance(user_info, dict): + add("Resource owner", self.first_non_empty(user_info.get("username"), user_info.get("userEmail"))) + + file_details = violation.get("fileDetails") + if isinstance(file_details, dict): + add("File", file_details.get("fileName")) + add("File type", file_details.get("mimeType")) + + add("Exposure", self.exposure_note(violation)) + + if details: + sections.append("\n".join(details)) + if evidence := self.evidence(detections): + sections.append("**Redacted detections**\n" + evidence) + return "\n\n".join(sections) + + def exposure_note(self, violation): + """ + Why this violation is worse than a private one. + + Only three integrations report a sharing state that makes the exposure external. + """ + metadata = violation.get("metadata") + if not isinstance(metadata, dict): + return "" + gdrive = metadata.get("gdriveMetadata") + if isinstance(gdrive, dict) and str(gdrive.get("permissionSetting") or "").strip(): + return "Drive permission " + str(gdrive["permissionSetting"]).strip() + notion = metadata.get("notionMetadata") + if isinstance(notion, dict) and notion.get("sharedExternally"): + return "the Notion page is shared externally" + github = metadata.get("githubMetadata") + # An absent isRepoPrivate reads as public, matching the connector: a repository Nightfall did + # not call private is treated as one it could not confirm was private. + if isinstance(github, dict) and not github.get("isRepoPrivate"): + return "the GitHub repository is public" + return "" + + def evidence(self, detections): + """ + One line per detection: the credential kind, the REDACTED value, the confidence and where it + sat in the resource. + + Nightfall's API only ever returns redacted detection text, and that is the only text field + read here, so no secret is imported. + """ + lines = [] + for detection in detections: + parts = [] + key = self.api_key(detection) + if key is not None: + descriptor = str(key.get("kind") or "").strip() + " key" + if status := str(key.get("status") or "").strip(): + descriptor += f" ({status})" + parts.append(descriptor) + if redacted := str(detection.get("redactedSensitiveText") or "").strip(): + parts.append(f"redacted value `{redacted}`") + if confidence := str(detection.get("confidence") or "").strip(): + parts.append(f"confidence {confidence}") + if location := self.detection_location(detection): + parts.append(location) + if parts: + lines.append("- " + ", ".join(parts)) + return "\n".join(lines) + + def detection_location(self, detection): + if sub := str(detection.get("subLocation") or "").strip(): + return sub + if line := self.line(detection): + return f"line {line}" + return "" + + def line(self, detection): + location = detection.get("redactedLocation") + if not isinstance(location, dict): + return 0 + line_range = location.get("lineRange") + if not isinstance(line_range, dict): + return 0 + start = line_range.get("start") + if isinstance(start, int | float) and not isinstance(start, bool) and start > 0: + return int(start) + return 0 + + def line_number(self, detections): + """The first detection that knows which line it was on.""" + for detection in detections: + if line := self.line(detection): + return line + return 0 + + def mitigation(self, integration): + """The connector's remediation steps, which are the same three for every integration.""" + return "\n".join([ + f"Remove or redact the sensitive data from the {self.integration_label(integration)} resource.", + ("Rotate any credential that was exposed — assume it is compromised, whether or not " + "Nightfall could verify it."), + "Review who had access to the resource while the data was exposed.", + ]) + + def references(self, violation): + """The resource link, the integration's own permalink and the file permalink, deduplicated.""" + file_details = violation.get("fileDetails") + permalink = file_details.get("permalink") if isinstance(file_details, dict) else "" + + links = [] + for link in (violation.get("resourceLink"), self.link(violation), permalink): + trimmed = str(link or "").strip() + if trimmed and trimmed not in links: + links.append(trimmed) + return "\n".join(links) + + def location(self, violation): + """ + Where in the SaaS app the data was found. + + Every integration nests its own metadata block under a different key and describes a location + with different fields, so this is a table rather than a formula. + """ + metadata = violation.get("metadata") + if not isinstance(metadata, dict): + return "" + for key, separator, fields in LOCATION_BY_INTEGRATION: + block = metadata.get(key) + if not isinstance(block, dict): + continue + parts = [str(block.get(field) or "").strip() for field in fields] + if key == "zendeskMetadata": + parts[1] = self.ticket_reference(parts[1]) + joined = separator.join(part for part in parts if part) + if key == "githubMetadata": + joined += self.path_suffix(block.get("filePath")) + return joined + return "" + + def link(self, violation): + metadata = violation.get("metadata") + if not isinstance(metadata, dict): + return "" + for key, field in LINK_BY_INTEGRATION: + block = metadata.get(key) + if isinstance(block, dict): + return str(block.get(field) or "").strip() + return "" + + def ticket_reference(self, ticket_id): + """A numeric Zendesk ticket id is rendered as #123; a non-numeric one is left alone.""" + if not ticket_id: + return "" + return f"#{ticket_id}" if ticket_id.isdigit() else ticket_id + + def path_suffix(self, path): + trimmed = str(path or "").strip() + return f":{trimmed}" if trimmed else "" + + def file_path(self, violation): + """Only a GitHub violation has a path in a repository.""" + metadata = violation.get("metadata") + if not isinstance(metadata, dict): + return "" + github = metadata.get("githubMetadata") + if not isinstance(github, dict): + return "" + return str(github.get("filePath") or "").strip() + + def policy_names(self, violation): + names = violation.get("policyNames") + if not isinstance(names, list): + return [] + return [str(name).strip() for name in names if str(name or "").strip()] + + def first_policy(self, violation): + names = self.policy_names(violation) + return names[0] if names else "" + + def risk_score(self, violation): + score = violation.get("riskScore") + if isinstance(score, int | float) and not isinstance(score, bool): + return float(score) + return 0.0 + + def severity_justification(self, violation, score): + justification = f"Nightfall risk score: {self.render_score(score)}" + if source := str(violation.get("riskSource") or "").strip(): + justification += f" (source: {source})" + return justification + + def render_score(self, score): + """Render the score the way the connector does - 8.5 stays 8.5, but 9.0 prints as 9.""" + return str(int(score)) if score == int(score) else repr(score) + + def tags(self, violation, detections, integration): + tags = ["dlp"] + tags.extend(value for value in (integration, str(violation.get("risk") or "").strip()) if value) + for detection in detections: + key = self.api_key(detection) + if key is None: + continue + kind = str(key.get("kind") or "").strip() + if kind and kind not in tags: + tags.append(kind) + return tags + + def first_non_empty(self, *values): + for value in values: + trimmed = str(value or "").strip() + if trimmed: + return trimmed + return "" diff --git a/dojo/tools/nowsecure/__init__.py b/dojo/tools/nowsecure/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/nowsecure/parser.py b/dojo/tools/nowsecure/parser.py new file mode 100644 index 00000000000..a3224ff73c9 --- /dev/null +++ b/dojo/tools/nowsecure/parser.py @@ -0,0 +1,275 @@ +import json +import re +from contextlib import suppress +from datetime import datetime + +from dojo.models import Finding + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + "informational": "Info", + "": "Info", +} +DEFAULT_SEVERITY = "Info" + +ANALYSIS_STATIC = "static" +ANALYSIS_DYNAMIC = "dynamic" + +# The advisory identifiers the connector's shared extractor recognises in free text. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + + +class NowSecureParser: + + """ + Parses a NowSecure assessment export. + + Mirrors pkg/tools/nowsecure/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + A NowSecure assessment runs both a static and a dynamic analysis of the same mobile app, and each + finding says which one found it - so static-versus-dynamic is decided per finding, not per file. + The assessment also supplies the date and the platform, so an export should carry it; see extract(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName - just "NowSecure", which does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived. + return ["NowSecure"] + + def get_label_for_scan_types(self, scan_type): + return "NowSecure" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a NowSecure assessment export (JSON), the findings of one mobile-app assessment. " + "Matches the scan type used by the NowSecure connector so file and API findings " + "deduplicate. Only findings NowSecure marks as affecting the app are imported." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the NowSecure Parser. + + Mirrors the connector's Convert: + - title: the finding title, then "NowSecure: ", then a constant. + - severity: NowSecure's own label; anything unrecognised is Info. + - description: the category, check and analysis type, then the description and detail. + - mitigation: the developer recommendation. + - cvssv3 / cvssv3_score: NowSecure's vector and score. + - date: when the assessment was created. + - static_finding / dynamic_finding: from the finding's own analysis type. + - unique_id_from_tool: "nowsecure-[-]". + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "cvssv3", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the NowSecure Parser. + + Copied from the NowSecure block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + rows, assessment = self.extract(data) + + findings = [] + for row in rows: + if not isinstance(row, dict): + continue + if not row.get("affected") or row.get("hidden"): + # NowSecure reports every check it ran. Only one that actually affects the app is a + # finding, and a hidden one has been suppressed in NowSecure itself. + continue + findings.append(self.build_finding(row, assessment, test)) + return findings + + def extract(self, data): + """ + Return the findings and the assessment they belong to. + + NowSecure's findings endpoint answers with a bare array, so an export is either that array or + an object carrying it alongside the assessment - which is where the date and platform live. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)], {} + if isinstance(data, dict): + for key in ("findings", "results"): + if isinstance(data.get(key), list): + rows = [row for row in data[key] if isinstance(row, dict)] + assessment = data.get("assessment") + return rows, assessment if isinstance(assessment, dict) else {} + + msg = ( + "A NowSecure export is an assessment's findings, a JSON array or an object with a " + f"'findings' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, assessment, test): + finding = Finding( + test=test, + title=self.title(row), + severity=self.severity(row), + description=self.describe(row), + mitigation=self.mitigation(row), + unique_id_from_tool=self.unique_id(row), + vuln_id_from_tool=str(row.get("check_id") or "") or None, + # NowSecure reports what it found in this assessment, so every finding is current. + active=True, + ) + # Set unconditionally, as the connector does. + finding.cvssv3_score = self.score(row) + finding.cvssv3 = str(row.get("cvss_vector") or "") or None + finding.unsaved_tags = self.tags(row, assessment) + + if identifiers := self.vulnerability_ids(row): + finding.unsaved_vulnerability_ids = identifiers + if date := self.date(assessment): + finding.date = date + self.set_analysis_type(finding, row) + return finding + + def unique_id(self, row): + """ + "nowsecure-[-]". + + The check id is the rule; the vulnerability id distinguishes two hits of the same check in one + app. A finding with no check id falls back to a slug of its title, because something stable is + needed and the title is all there is. + """ + base = str(row.get("check_id") or "") + if not base: + base = str(row.get("title") or "").lower().replace(" ", "-") + identifier = row.get("unique_vulnerability_id") + if isinstance(identifier, int | float) and not isinstance(identifier, bool) and identifier != 0: + base += f"-{int(identifier)}" + return f"nowsecure-{base}" + + def title(self, row): + if title := str(row.get("title") or ""): + return title + if check := str(row.get("check_id") or ""): + return f"NowSecure: {check}" + return "NowSecure finding" + + def severity(self, row): + label = str(row.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def score(self, row): + value = row.get("cvss") + if isinstance(value, int | float) and not isinstance(value, bool): + return float(value) + return 0.0 + + def describe(self, row): + """ + The category, check and analysis type as single-newline bullets, then the prose sections. + + The prose is separated by a blank line because it is paragraphs rather than fields - that is + the connector's own distinction, mirrored here. + """ + lines = [] + for label, key in (("Category", "category"), ("Check", "check_id"), ("Analysis", "analysis_type")): + value = str(row.get(key) or "") + if value: + lines.append(f"**{label}:** {value}") + text = "\n".join(lines) + + for label, key in (("Description", "description"), ("Detail", "vulnerability_detail")): + value = str(row.get(key) or "") + if not value: + continue + if text: + text += "\n\n" + text += f"**{label}:**\n{value}" + return text.strip() + + def mitigation(self, row): + """NowSecure writes its advice for the developer; there is no other recommendation field.""" + recommendations = row.get("recommendations") + if isinstance(recommendations, dict): + return str(recommendations.get("developer") or "") + return "" + + def vulnerability_ids(self, row): + """ + Identifiers found in the title, description and detail. + + The connector's shared extractor SORTS these and drops case-insensitive duplicates, unlike the + order-preserving path other connectors use - mirrored so the two import paths agree. + """ + prose = "|".join([ + str(row.get("title") or ""), + str(row.get("description") or ""), + str(row.get("vulnerability_detail") or ""), + ]) + found = sorted(VULNERABILITY_ID_PATTERN.findall(prose)) + + identifiers = [] + for candidate in found: + if not identifiers or identifiers[-1].lower() != candidate.lower(): + identifiers.append(candidate) + return identifiers + + def date(self, assessment): + """The assessment's creation time, which is when the app was analysed.""" + created = str(assessment.get("created") or "").strip() + if not created: + return None + with suppress(ValueError): + return datetime.strptime(created.split("T")[0], "%Y-%m-%d").date() + return None + + def tags(self, row, assessment): + tags = [] + for value in (row.get("category"), row.get("analysis_type"), assessment.get("platform")): + text = str(value or "") + if text: + tags.append(text) + return tags + + def set_analysis_type(self, finding, row): + """ + Static or dynamic, per finding. + + One NowSecure assessment runs both analyses of the same app, so the file cannot decide this. + + An analysis type the connector does not recognise leaves both flags alone, which is NOT + neutral: DefectDojo's own defaults are static_finding False and dynamic_finding **True**, so + such a finding is recorded as dynamic. That is what the connector's findings do too, which is + why it is mirrored rather than "corrected" to two Falses - doing that would make a file import + and an API sync disagree about the same finding. + """ + analysis = str(row.get("analysis_type") or "").strip().lower() + if analysis == ANALYSIS_STATIC: + finding.static_finding = True + finding.dynamic_finding = False + elif analysis == ANALYSIS_DYNAMIC: + finding.static_finding = False + finding.dynamic_finding = True diff --git a/dojo/tools/nozomi/__init__.py b/dojo/tools/nozomi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/nozomi/parser.py b/dojo/tools/nozomi/parser.py new file mode 100644 index 00000000000..0ef49491cee --- /dev/null +++ b/dojo/tools/nozomi/parser.py @@ -0,0 +1,252 @@ +import json +from contextlib import suppress + +from dojo.models import Finding + + +class NozomiParser: + + """ + Parses a Nozomi Vantage node_cves export. + + Mirrors pkg/tools/nozomi/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + It also mirrors the connector's QUERY, not only its converter: the connector asks Vantage for + "node_cves | where resolved != true", so a resolved record is something an API sync can never + produce. A hand-run query can return them, and importing one would create an active finding for + something Nozomi has already closed - so they are skipped here too. See get_findings(). + + A node_cves record is denormalised: every row carries its own asset context, so nothing has to be + joined. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Nozomi Vantage Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Nozomi Vantage Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Nozomi Vantage node_cves export (JSON) - per-asset OT/ICS vulnerabilities with " + "their asset context. Resolved records are skipped, matching the connector's query. " + "Matches the scan type used by the Nozomi connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Nozomi Parser. + + Mirrors the connector's Convert: + - title: " on ", then the CVE alone. + - severity: derived from the CVE's CVSS base score; Nozomi sends no severity word. + - description: the asset's label, type, vendor, product, firmware, OS and zone, the weakness + name, then the CVE summary. + - mitigation: apply the latest hotfix, or at least the minimum one. + - component_name / component_version: the asset's product and firmware version. + - references: every CVE reference, one per line. + - cwe: parsed from "CWE-###". + - unique_id_from_tool: "nozomi-", or "nozomi--" without one. + """ + return [ + "title", + "severity", + "description", + "mitigation", + "component_name", + "component_version", + "cvssv3_score", + "references", + "cwe", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Nozomi Parser. + + Copied from the Nozomi block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The component is the OT product, so + the same CVE on two different devices stays two findings. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for row in self.rows(data): + if row.get("resolved") is True: + # The connector's query is "where resolved != true", so an API sync can never produce + # this record. Importing it would open a finding Nozomi has already closed. + continue + findings.append(self.build_finding(row, test)) + return findings + + def rows(self, data): + """ + Return the node_cves records in the export. + + Vantage's query endpoint answers {"result": [...]}, so that is the shape a saved export has. + A bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("result", "results", "data"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A Nozomi export is a node_cves query response, a JSON object with a 'result' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, test): + cve = str(row.get("cve") or "") + + finding = Finding( + test=test, + title=self.title(row, cve), + severity=self.severity(row), + description=self.describe(row), + mitigation=self.mitigation(row), + component_name=str(row.get("node_product_name") or "") or None, + component_version=str(row.get("node_firmware_version") or "") or None, + references=self.references(row), + cwe=self.cwe(row), + unique_id_from_tool=self.unique_id(row, cve), + # Vantage matches a passively-built asset inventory against advisories; nothing is + # exercised, which matters in OT where active probing is not acceptable. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.cvssv3_score = self.score(row) + finding.unsaved_tags = self.tags(row) + + if cve: + finding.unsaved_vulnerability_ids = [cve] + finding.vuln_id_from_tool = cve + return finding + + def unique_id(self, row, cve): + """Vantage's own record id, falling back to the CVE and the asset it was found on.""" + if record_id := str(row.get("id") or ""): + return f"nozomi-{record_id}" + return f"nozomi-{cve}-{row.get('asset_id') or ''}" + + def title(self, row, cve): + """ + " on ", then the CVE alone. + + Note there is no asset-only form: a record with no CVE has nothing to name it by, so it falls + through to the generic title rather than being titled after the device. + """ + label = str(row.get("node_label") or "") + if cve and label: + return f"{cve} on {label}" + if cve: + return cve + return "Nozomi vulnerability" + + def severity(self, row): + """ + Derived from the CVE's CVSS base score - Nozomi sends no severity word to prefer. + + An unscored record is Info rather than being dropped: in an OT estate the asset context is + worth recording even when the score is missing. + """ + score = self.score(row) + if score >= 9: + return "Critical" + if score >= 7: + return "High" + if score >= 4: + return "Medium" + if score > 0: + return "Low" + return "Info" + + def score(self, row): + """Vantage's CVSS base score, which may arrive quoted.""" + value = row.get("cve_score") + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 + + def describe(self, row): + """ + The asset context first, then the CVE summary, separated by a blank line. + + A node_cves record is denormalised, so all of this travels on the row - which is what makes an + OT finding readable without a second lookup. + """ + fields = ( + ("Asset", "node_label"), + ("Type", "node_type"), + ("Vendor", "node_vendor"), + ("Product", "node_product_name"), + ("Firmware", "node_firmware_version"), + ("OS", "node_os"), + ("Zone", "zone"), + ("Weakness", "cwe_name"), + ) + lines = [] + for label, key in fields: + # The connector tests for emptiness WITHOUT trimming, which a plain truthiness test + # reproduces exactly for strings. + if value := str(row.get(key) or ""): + lines.append(f"**{label}:** {value}") + + text = "\n".join(lines) + if summary := str(row.get("cve_summary") or ""): + if text: + text += "\n\n" + text += summary + return text.strip() + + def mitigation(self, row): + """The latest hotfix, then the minimum one - the floor a device has to reach.""" + if hotfix := str(row.get("latest_hotfix") or ""): + return f"Apply hotfix {hotfix}." + if minimum := str(row.get("minimum_hotfix") or ""): + return f"Apply at least hotfix {minimum}." + return None + + def references(self, row): + rows = row.get("cve_references") + if not isinstance(rows, list): + return None + return "\n".join(str(value) for value in rows) or None + + def cwe(self, row): + trimmed = str(row.get("cwe_id") or "").strip().upper().removeprefix("CWE-") + with suppress(ValueError): + return int(trimmed) + return 0 + + def tags(self, row): + """The asset's vendor, type, product and zone, for filtering an OT estate.""" + return [ + value + for value in ( + str(row.get(key) or "").strip() + for key in ("node_vendor", "node_type", "node_product_name", "zone") + ) + if value + ] diff --git a/dojo/tools/ostorlab/__init__.py b/dojo/tools/ostorlab/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/ostorlab/parser.py b/dojo/tools/ostorlab/parser.py new file mode 100644 index 00000000000..df1598a7765 --- /dev/null +++ b/dojo/tools/ostorlab/parser.py @@ -0,0 +1,362 @@ +import json +import re +from contextlib import suppress +from datetime import date as _date +from ipaddress import ip_address + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_RATING = { + "CRITICAL": "Critical", + "HIGH": "High", + "MEDIUM": "Medium", + "LOW": "Low", + # Ostorlab's "POTENTIALLY" means a finding it could not fully confirm, which it grades as Low. + "POTENTIALLY": "Low", +} +# HARDENING, IMPORTANT, INFO and anything unrecognised all land here. +DEFAULT_SEVERITY = "Info" + +# A SECURE rating is a check that PASSED, not a finding. +RATING_SECURE = "SECURE" + +# Asset types Ostorlab analyses statically - a mobile binary or an uploaded file - as opposed to a +# web, network or domain target it exercises. +STATIC_ASSET_MARKERS = ("ANDROID", "IOS", "APP", "FILE", "STORE") + +# The advisory identifiers the connector's shared extractor recognises in free text. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class OstorlabParser: + + """ + Parses an Ostorlab scan export. + + Mirrors pkg/tools/ostorlab/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Ostorlab scans mobile applications, web targets and networks from one platform, so whether a + finding is static or dynamic is decided per scan by its asset type rather than for the tool; see + is_static(). A SECURE rating is a check that PASSED and is skipped - importing it would file a + passing check as a finding. + + Ostorlab has no CVE field at all: identifiers appear in the prose and the references, so they are + extracted from the text; see vulnerability_ids(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # " - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Ostorlab Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Ostorlab Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Ostorlab scan export (JSON) - a scan with its vulnerabilities. Passed (SECURE) " + "checks are skipped. Matches the scan type used by the Ostorlab connector so file and API " + "findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Ostorlab Parser. + + Mirrors the connector's Convert: + - title: the vulnerability detail's title, then "Ostorlab finding ". + - severity: Ostorlab's risk rating; see severity(). + - description: the detail's description and summary, the technical detail, then each + location metadata entry as its own section. + - mitigation: the detail's recommendation. + - cvssv3: the detail's CVSS v3 VECTOR - Ostorlab gives no score. + - references: each reference as "- : <url>". + - unsaved_vulnerability_ids: identifiers found in the prose and the references. + - static_finding / dynamic_finding: decided by the scan's asset type. + - unique_id_from_tool: "ostorlab-<scan id>-<vulnerability id>". + """ + return [ + "title", + "severity", + "description", + "mitigation", + "cvssv3", + "references", + "date", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Ostorlab Parser. + + Copied from the Ostorlab block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + + Note that neither the connector nor this parser ever sets component_name - Ostorlab reports no + component - so that third field hashes as empty and the hash is effectively title plus + severity. Copied as it stands rather than trimmed, because changing it would change how the + connector's own findings hash; raised in the PR as a follow-up. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + scan = self.scan(data) + findings = [] + for row in self.rows(data): + if self.rating(row).upper() == RATING_SECURE: + # A SECURE rating is a check that PASSED. Importing it would file a passing check as + # a finding, which is what the connector's IsIgnored exists to prevent. + continue + findings.append(self.build_finding(row, self.block(row, "scan") or scan, test)) + return findings + + def rows(self, data): + """ + Return the vulnerabilities in the export. + + Ostorlab answers GraphQL: {"data": {"scan": {"vulnerabilities": {"vulnerabilities": [...]}}}}. + The doubled key is Ostorlab's own shape - the outer one is the connection, the inner one the + list. Unwrapped forms and a bare array are accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + + if isinstance(data, dict): + holders = (self.block(self.block(data, "data"), "scan"), self.block(data, "scan"), data) + for holder in holders: + connection = holder.get("vulnerabilities") + if isinstance(connection, dict) and isinstance( + connection.get("vulnerabilities"), list, + ): + return [row for row in connection["vulnerabilities"] if isinstance(row, dict)] + if isinstance(connection, list): + return [row for row in connection if isinstance(row, dict)] + + msg = ( + "An Ostorlab export is the vulnerabilities response, a JSON object with a " + f"'vulnerabilities' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def scan(self, data): + """ + The scan the whole file belongs to - its id, asset type and creation time. + + The scan id is in every identity, and the asset type decides whether the findings are static + or dynamic, so an export without it loses both. One export is one scan, so it is stated once. + """ + if not isinstance(data, dict): + return {} + for holder in (data, self.block(data, "data")): + if candidate := self.block(holder, "scan"): + # The scan object also holds the vulnerabilities connection; that is fine, only its + # own fields are read. + return candidate + return {} + + def block(self, row, key): + if not isinstance(row, dict): + return {} + value = row.get(key) + return value if isinstance(value, dict) else {} + + def build_finding(self, row, scan, test): + detail = self.block(row, "detail") + static = self.is_static(str(scan.get("assetType") or "")) + title = str(detail.get("title") or "") + + finding = Finding( + test=test, + title=title or f"Ostorlab finding {self.integer(row.get('id'))}", + severity=self.severity(row), + description=self.describe(row, detail), + mitigation=str(detail.get("recommendation") or "") or None, + cvssv3=str(detail.get("cvssV3Vector") or "") or None, + references=self.references(detail) or None, + unique_id_from_tool=( + f"ostorlab-{self.integer(scan.get('id'))}-{self.integer(row.get('id'))}" + ), + # Ostorlab scans mobile binaries statically and web or network targets dynamically, so + # this is decided per scan rather than for the whole tool. + static_finding=static, + dynamic_finding=not static, + active=True, + ) + finding.unsaved_tags = self.tags(row, scan) + + if title: + finding.vuln_id_from_tool = title + if identifiers := self.vulnerability_ids(row, detail): + finding.unsaved_vulnerability_ids = identifiers + if date := self.date(scan): + finding.date = date + self.add_endpoint(finding, row) + return finding + + def rating(self, row): + """Ostorlab's risk rating, which lives on the vulnerability's detail.""" + return str(self.block(row, "detail").get("riskRating") or "").strip() + + def severity(self, row): + """ + Ostorlab's risk rating. + + "POTENTIALLY" is a finding it could not fully confirm and grades as Low. HARDENING, IMPORTANT + and INFO all land in Info - which for IMPORTANT reads oddly, and is mirrored rather than + corrected; see the PR notes. + """ + return SEVERITY_BY_RATING.get(self.rating(row).upper(), DEFAULT_SEVERITY) + + def is_static(self, asset_type): + """ + Whether the scan's asset type is analysed without running it. + + A mobile binary, an uploaded file or a store listing is read statically; a web, network or + domain target is exercised. Matched on substrings because Ostorlab's asset union spells the + same idea several ways. + """ + upper = asset_type.upper() + return any(marker in upper for marker in STATIC_ASSET_MARKERS) + + def describe(self, row, detail): + """ + Sections, each "**Label:**" then its text, separated by blank lines. + + The location metadata is rendered with ITS OWN type as the label, so a finding carries whatever + context Ostorlab attached - a URL, a code location, a request - without the parser having to + know the names in advance. + """ + sections = [] + + def add(label, value): + if str(value or ""): + sections.append(f"**{label}:**\n{value}") + + add("Description", detail.get("description")) + add("Summary", detail.get("shortDescription")) + add("Technical detail", row.get("technicalDetail")) + + location = self.block(row, "vulnerabilityLocation") + for meta in location.get("metadata") or []: + if isinstance(meta, dict) and str(meta.get("metadataType") or ""): + add(str(meta["metadataType"]), meta.get("metadataValue")) + return "\n\n".join(sections).strip() + + def references(self, detail): + """Each reference as "- <title>: <url>", or whichever of the two it has.""" + lines = [] + for reference in detail.get("references") or []: + if not isinstance(reference, dict): + continue + title = str(reference.get("title") or "") + url = str(reference.get("url") or "") + if title and url: + lines.append(f"- {title}: {url}") + elif url: + lines.append(f"- {url}") + elif title: + lines.append(f"- {title}") + return "\n".join(lines) + + def vulnerability_ids(self, row, detail): + """ + Identifiers read out of the prose and the references. + + Ostorlab exposes NO CVE field, so a finding that names one names it in its text. The + connector's shared extractor sorts its results and drops case-insensitive duplicates, so the + order here is alphabetical rather than the order they appear in. + """ + sources = [str(row.get("technicalDetail") or "")] + sources.extend( + str(detail.get(key) or "") for key in ("title", "description", "shortDescription") + ) + for reference in detail.get("references") or []: + if isinstance(reference, dict): + sources.extend([str(reference.get("title") or ""), str(reference.get("url") or "")]) + + matches = sorted(VULNERABILITY_ID_PATTERN.findall("|".join(sources))) + identifiers = [] + for match in matches: + # Adjacent-only dedupe after the sort, which is what slices.CompactFunc does. + if not identifiers or identifiers[-1].lower() != match.lower(): + identifiers.append(match) + return identifiers + + def tags(self, row, scan): + """The risk rating and the scan's asset type.""" + tags = [] + if rating := self.rating(row): + tags.append(rating) + if asset_type := str(scan.get("assetType") or ""): + tags.append(asset_type) + return tags + + def date(self, scan): + """The scan's creation time, which the connector parses strictly as RFC 3339.""" + value = str(scan.get("createdTime") or "").strip() + if not value: + return None + with suppress(ValueError): + return _date.fromisoformat(value.split("T")[0]) + return None + + def add_endpoint(self, finding, row): + """ + The affected host, for a web or network target. + + Ostorlab names it either way round, so the asset's name is tried before its host. A mobile + scan has neither, and then there is no endpoint to record. + """ + asset = self.block(self.block(row, "vulnerabilityLocation"), "asset") + for key in ("name", "host"): + host = str(asset.get(key) or "").strip() + if not host: + continue + if not self.usable_host(host): + # A host DefectDojo rejects makes Endpoint.clean() raise, and that fails the WHOLE + # import rather than the one finding. + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(host=host)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=host)) + return + + def usable_host(self, value): + """A host is letters, digits, dot, hyphen, underscore or plus, or an IP address.""" + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def integer(self, value): + """Ostorlab's ids may arrive as JSON numbers or quoted strings.""" + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 diff --git a/dojo/tools/parasoft/__init__.py b/dojo/tools/parasoft/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/parasoft/parser.py b/dojo/tools/parasoft/parser.py new file mode 100644 index 00000000000..ad5d9cd57ed --- /dev/null +++ b/dojo/tools/parasoft/parser.py @@ -0,0 +1,209 @@ +import json +import re +from contextlib import suppress + +from dojo.models import Finding + +# DTP's own scale, where 1 is the MOST severe and 5 is informational. +SEVERITY_BY_CODE = {1: "Critical", 2: "High", 3: "Medium", 4: "Low"} +DEFAULT_SEVERITY = "Info" + +# The advisory identifiers the connector's shared extractor recognises in free text. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + + +class ParasoftParser: + + """ + Parses a Parasoft DTP static-analysis violations export. + + Mirrors pkg/tools/parasoft/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + DTP grades with a numeric severity where 1 is the MOST severe - the inverse of a score - so reading + it as one would invert the whole ladder; see severity(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Parasoft DTP Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Parasoft DTP Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Parasoft DTP static-analysis violations export (JSON). Matches the scan type used " + "by the Parasoft connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Parasoft Parser. + + Mirrors the connector's Convert: + - title: "<rule>: <message>", or whichever of the two is present. + - severity: DTP's numeric severity, where 1 is the most severe; see severity(). + - description: the message, rule, category, analyzer and language. + - file_path / line: where DTP found the violation. + - vuln_id_from_tool: the rule id, which is DTP's rule identity. + - unique_id_from_tool: "parasoft-<hash>", then the violation id, then rule plus file. + """ + return [ + "title", + "severity", + "description", + "file_path", + "line", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Parasoft Parser. + + Copied from the Parasoft block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The file path and the rule are both in + the hash: the same rule firing in two files is two violations to fix. + """ + return ["title", "severity", "file_path", "vuln_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(row, test) for row in self.rows(data)] + + def rows(self, data): + """ + Return the violations in the export. + + DTP answers {"staticAnalysisViolations": [...]}, so that is the shape a saved export has. A + bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("staticAnalysisViolations", "violations", "data", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A Parasoft export is the violations response, a JSON object with a " + f"'staticAnalysisViolations' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, test): + rule = str(row.get("rule") or "") + message = str(row.get("message") or "") + + finding = Finding( + test=test, + title=self.title(rule, message), + severity=self.severity(row), + description=self.describe(row, rule, message), + file_path=str(row.get("locFile") or "") or None, + line=self.integer(row.get("locStartLine")) or None, + unique_id_from_tool=self.unique_id(row, rule), + vuln_id_from_tool=rule or None, + # DTP analyses source; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.unsaved_tags = self.tags(row) + + if identifiers := self.vulnerability_ids(rule, message): + finding.unsaved_vulnerability_ids = identifiers + return finding + + def unique_id(self, row, rule): + """ + DTP's own violation hash, then its id, then the rule and the file. + + The hash is what stays stable as a file is edited around the violation, which is why it is + preferred - the rule-plus-file fallback would merge two violations of one rule in one file. + """ + for key in ("hash", "id"): + if value := str(row.get(key) or ""): + return f"parasoft-{value}" + return f"parasoft-{rule}-{row.get('locFile') or ''}" + + def title(self, rule, message): + if rule and message: + return f"{rule}: {message}" + if message: + return message + if rule: + return rule + return "Parasoft DTP violation" + + def severity(self, row): + """ + DTP's numeric severity, where 1 is the MOST severe and 5 is informational. + + This is the inverse of a score: reading it as one would invert the entire ladder. Severity 5, + 0 and anything unrecognised are Info. + """ + return SEVERITY_BY_CODE.get(self.integer(row.get("severity")), DEFAULT_SEVERITY) + + def describe(self, row, rule, message): + lines = [] + + def write(label, value): + # The connector tests for emptiness WITHOUT trimming, which a plain truthiness test + # reproduces exactly for strings. + if value: + lines.append(f"**{label}:** {value}") + + write("Message", message) + write("Rule", rule) + write("Category", str(row.get("ruleCategory") or "")) + write("Analyzer", str(row.get("analyzerId") or "")) + write("Language", str(row.get("language") or "")) + return "\n".join(lines).strip() + + def tags(self, row): + """The rule, category, analyzer and language, for filtering.""" + return [ + value + for value in ( + str(row.get(key) or "").strip() + for key in ("rule", "ruleCategory", "analyzerId", "language") + ) + if value + ] + + def vulnerability_ids(self, rule, message): + """ + Any advisory identifier named in the rule id or the message. + + Rare for static analysis, but cheap - and a rule that names a CVE is worth linking. The + connector's shared extractor sorts its results and drops case-insensitive duplicates. + """ + matches = sorted(VULNERABILITY_ID_PATTERN.findall(f"{rule} {message}")) + identifiers = [] + for match in matches: + # Adjacent-only dedupe after the sort, which is what slices.CompactFunc does. + if not identifiers or identifiers[-1].lower() != match.lower(): + identifiers.append(match) + return identifiers + + def integer(self, value): + """DTP's numbers may arrive as JSON numbers or quoted strings.""" + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 diff --git a/dojo/tools/probely/__init__.py b/dojo/tools/probely/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/probely/parser.py b/dojo/tools/probely/parser.py new file mode 100644 index 00000000000..27004fbc005 --- /dev/null +++ b/dojo/tools/probely/parser.py @@ -0,0 +1,260 @@ +import json +import re +from contextlib import suppress +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Probely reports severity as an integer, and only these three values exist. Anything else becomes +# Info, which is what the connector does after logging it. +SEVERITY_MAP = { + 10: "Low", + 20: "Medium", + 30: "High", +} +DEFAULT_SEVERITY = "Info" + +# States the connector treats as not worth importing. "retesting" is deliberately NOT here: a +# finding being re-tested is assumed still open and still being worked on. +IGNORED_STATES = frozenset({"invalid", "accepted", "fixed"}) + +# Insertion-point words that should not be title-cased naively. +ACRONYMS = (("Url", "URL"), ("Json", "JSON"), ("Graphql", "GraphQL")) + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class ProbelyParser: + + """ + Parses a Probely findings export. + + Mirrors pkg/tools/probely/connector/finding_converter.go field for field so a file import and an + API sync deduplicate against each other instead of producing two copies of everything. + + Probely is a DAST scanner, so every finding is dynamic and carries the scanned URL. Note the + deduplication configuration for this scan type hashes the ENDPOINTS, so the endpoint has to be + populated or the hash is computed over nothing. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern the other connector scan types use. + return ["Probely API Import"] + + def get_label_for_scan_types(self, scan_type): + return "Probely API Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Probely findings export (JSON). Matches the scan type used by the Probely " + "connector so file and API findings deduplicate. Findings Probely records as fixed, " + "invalid or accepted are not imported." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Probely Parser. + + Mirrors the connector's ConvertFinding: + - title: the finding definition's name. + - severity: Probely's integer severity, 10/20/30; anything else Info. + - severity_justification: the connector's sentence about severity, score and vector. + - description: path, the insertion point and parameter, method, the definition's + description, and Probely's evidence. + - mitigation: Probely's fix text followed by its extra notes. + - cvssv3 / cvssv3_score: the reported vector and score. + - cwe: parsed from the finding definition's cwe_id when the export carries one. + - unique_id_from_tool: the Probely finding id. + - vuln_id_from_tool: the finding definition's id. + """ + return [ + "title", + "severity", + "severity_justification", + "description", + "mitigation", + "cvssv3", + "cvssv3_score", + "cwe", + "unique_id_from_tool", + "vuln_id_from_tool", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Probely Parser. + + Copied from the Probely block in the Pro connector settings. Note it pairs the plain + hash_code algorithm with a wide field set that includes endpoints, so the endpoint must be + populated for the hash to mean anything. + """ + return [ + "title", + "description", + "severity", + "vuln_id_from_tool", + "unique_id_from_tool", + "endpoints", + "cwe", + "mitigation", + ] + + def get_findings(self, filename, test): + data = json.load(filename) + rows = self.extract_rows(data) + + findings = {} + for row in rows: + if not isinstance(row, dict): + continue + if self.is_ignored(row): + continue + finding = self.build_finding(row, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_rows(self, data): + """Probely's findings endpoint pages the results under "results".""" + if isinstance(data, list): + return data + if isinstance(data, dict) and isinstance(data.get("results"), list): + return data["results"] + msg = ( + "A Probely export is a JSON object with a 'results' list of findings, or a bare array " + f"of findings; got {type(data).__name__}." + ) + raise TypeError(msg) + + def is_ignored(self, row): + """ + Skip findings Probely has closed out. + + A finding being re-tested is NOT skipped: the connector assumes a re-test means the issue is + still open and being worked on, and importing it is the safe side of that assumption. + """ + return (row.get("state") or "").strip().lower() in IGNORED_STATES + + def build_finding(self, row, test): + definition = row.get("definition") or {} + score = row.get("cvss_score") or 0 + severity = self.severity(row) + + finding = Finding( + test=test, + title=definition.get("name") or None, + severity=severity, + severity_justification=self.justification(severity, score, row.get("cvss_vector")), + description=self.describe(row, definition), + mitigation=f"{row.get('fix') or ''}\n{row.get('extra') or ''}", + cvssv3=row.get("cvss_vector") or None, + cvssv3_score=score or None, + cwe=self.cwe(definition), + unique_id_from_tool=str(row.get("id")), + vuln_id_from_tool=definition.get("id") or None, + # Probely is DAST: it probes a running target. + static_finding=False, + dynamic_finding=True, + ) + self.attach_endpoint(finding, row.get("url")) + return finding + + def attach_endpoint(self, finding, url): + """ + Record the scanned origin. + + The connector reduces the finding URL to scheme and host. That matters here beyond tidiness: + this scan type's deduplication hashes the endpoints, so an unpopulated endpoint would leave + the hash computed over nothing. + """ + if not url: + return + with suppress(ValueError): + parsed = urlparse(url) + if not parsed.hostname or not self.usable_host(parsed.hostname): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=parsed.hostname, protocol=parsed.scheme or None, port=parsed.port, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=parsed.hostname, protocol=parsed.scheme or None, port=parsed.port, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def severity(self, row): + raw = row.get("severity") + with suppress(TypeError, ValueError): + return SEVERITY_MAP.get(int(raw), DEFAULT_SEVERITY) + return DEFAULT_SEVERITY + + def justification(self, severity, score, vector): + """The connector's sentence, including its one-decimal score and italicised vector.""" + return ( + f"Probely has issued a severity level of **{severity}** from a base CVSS score of " + f"**{score:.1f}**.\n*{vector or ''}*" + ) + + def describe(self, row, definition): + description = f"**Path:** {row.get('path') or ''}\n" + description += self.parameter_info(row) + if row.get("method"): + description += f"**Method:** {row['method'].upper()}\n" + # Probely names this field "desc", not "description". + description += f"**Description:** \n{definition.get('desc') or ''}\n\n" + description += f"**Evidence:** \n{row.get('evidence') or ''}" + return description + + def parameter_info(self, row): + """ + Render the insertion point as a label, e.g. "url_path" becomes "**URL Path:**". + + The acronym fixes are the connector's: naive title casing would produce "Url" and "Json". + """ + insertion_point = row.get("insertion_point") or "" + if not insertion_point: + return "" + label = insertion_point.replace("_", " ").title() + for wrong, right in ACRONYMS: + label = label.replace(wrong, right) + return f"**{label}:** {row.get('parameter') or ''}\n" + + def cwe(self, definition): + """ + Parse the CWE the export carries on the finding definition. + + The connector fetches this separately per definition and expects a "CWE-<number>" string; an + export that does not carry one leaves the CWE at 0, which is the field's default. + """ + raw = str(definition.get("cwe_id") or "").strip() + if not raw.startswith("CWE-"): + return 0 + with suppress(ValueError): + return int(raw[4:]) + return 0 diff --git a/dojo/tools/quay/__init__.py b/dojo/tools/quay/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/quay/parser.py b/dojo/tools/quay/parser.py new file mode 100644 index 00000000000..983ae6b4617 --- /dev/null +++ b/dojo/tools/quay/parser.py @@ -0,0 +1,257 @@ +import json +from html.parser import HTMLParser + +from dojo.models import Finding + +# Quay's scanner is Clair, which grades "Defcon1" above Critical. Both land on Critical, which is the +# highest DefectDojo has. +SEVERITY_MAP = { + "critical": "Critical", + "defcon1": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Clair supplies no impact assessment, and the connector says so rather than leaving it blank. +IMPACT_NOT_PROVIDED = "No impact provided" + +# The connector's HTML flattener, duplicated per tool in the connectors repo rather than shared. +BLOCK_TAGS = frozenset({ + "br", "p", "div", "li", "tr", "ul", "ol", "table", "blockquote", + "h1", "h2", "h3", "h4", "h5", "h6", +}) +DROPPED_TAGS = frozenset({"script", "style"}) +GO_HTML_ESCAPES = ( + ("&", "&"), ("<", "<"), (">", ">"), ('"', """), ("'", "'"), +) + + +class _HtmlFlattener(HTMLParser): + + """Mirror of the connector's htmlFlattener: drop script/style, block tags become newlines.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self.skip_depth = 0 + + def handle_starttag(self, tag, attrs): + if tag in DROPPED_TAGS: + self.skip_depth += 1 + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_startendtag(self, tag, attrs): + if tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_endtag(self, tag): + if tag in DROPPED_TAGS: + self.skip_depth = max(0, self.skip_depth - 1) + elif tag in BLOCK_TAGS: + self.parts.append("\n") + + def handle_data(self, data): + if self.skip_depth == 0: + self.parts.append(data) + + def text(self): + return "".join(self.parts) + + +def inert_text(raw_html): + """ + Flatten advisory text to escaped plain text, as the connector's InertText does. + + Clair advisory text comes from upstream distro trackers, so it is never passed through as markup. + """ + if not raw_html: + return "" + flattener = _HtmlFlattener() + flattener.feed(raw_html) + flattener.close() + return go_escape(collapse_whitespace(flattener.text())) + + +def collapse_whitespace(text): + out, blank = [], True + for raw in text.split("\n"): + line = raw.strip() + if not line: + if not blank: + out.append("") + blank = True + continue + out.append(line) + blank = False + while out and not out[-1]: + out.pop() + return "\n".join(out) + + +def go_escape(text): + for character, entity in GO_HTML_ESCAPES: + text = text.replace(character, entity) + return text + + +class QuayParser: + + """ + Parses a Quay container security report. + + Mirrors pkg/tools/quay/connector/converter.go field for field so a file import and an API sync + deduplicate against each other instead of producing two copies of everything. + + Quay's scanner is Clair, so the report nests vulnerabilities under the features (packages) they + affect, and the JSON keys are Capitalised - "Layer", "Features", "Name". One finding per + feature/vulnerability pair. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanType(). + return ["Quay - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Quay - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Quay container security report (JSON, Clair-shaped). Matches the scan type " + "used by the Quay connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Quay Parser. + + Mirrors the connector's Convert: + - title: "<advisory id> - (<feature>, <version>)". + - severity: Clair's severity word, with Defcon1 also Critical. + - description: the advisory text, then the feature, version, fix, namespace, CVE and tag. + - component_name / component_version: the vulnerable feature and its version. + - impact: always "No impact provided" - Clair supplies none. + - mitigation: Clair's FixedBy, when it has one. + - references: Clair's advisory link. + - unique_id_from_tool: the advisory id concatenated with the feature name. + - vuln_id_from_tool: the advisory id. + """ + return [ + "title", + "severity", + "description", + "component_name", + "component_version", + "impact", + "mitigation", + "references", + "unique_id_from_tool", + "vuln_id_from_tool", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Quay Parser. + + Copied from the Quay block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name", "component_version"] + + def get_findings(self, filename, test): + data = json.load(filename) + features, tag_name = self.extract(data) + + findings = {} + for feature in features: + if not isinstance(feature, dict): + continue + vulnerabilities = feature.get("Vulnerabilities") + if not isinstance(vulnerabilities, list): + continue + for vulnerability in vulnerabilities: + if not isinstance(vulnerability, dict): + continue + finding = self.build_finding(feature, vulnerability, tag_name, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract(self, data): + """ + Return the Clair features and the image tag. + + Quay wraps the Clair output in data.Layer.Features. The tag is not in the report body - the + connector supplies it from the tag it scanned - so an export should carry it alongside. + """ + if isinstance(data, list): + return data, "" + if isinstance(data, dict): + tag_name = data.get("tag") or data.get("tag_name") or "" + layer = ((data.get("data") or {}) if isinstance(data.get("data"), dict) else {}).get("Layer") + if isinstance(layer, dict) and isinstance(layer.get("Features"), list): + return layer["Features"], tag_name + # A bare Layer, or an already-unwrapped features list. + if isinstance(data.get("Layer"), dict) and isinstance(data["Layer"].get("Features"), list): + return data["Layer"]["Features"], tag_name + if isinstance(data.get("Features"), list): + return data["Features"], tag_name + msg = ( + "A Quay security report is a JSON object with data.Layer.Features (the Clair shape); got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, feature, vulnerability, tag_name, test): + name = feature.get("Name") or "" + version = feature.get("Version") or "" + advisory = vulnerability.get("Name") or "" + + finding = Finding( + test=test, + title=f"{advisory} - ({name}, {version})", + severity=SEVERITY_MAP.get( + (vulnerability.get("Severity") or "").strip().lower(), DEFAULT_SEVERITY, + ), + description=self.describe(feature, vulnerability, tag_name, name, version, advisory), + component_name=name or None, + component_version=version or None, + # Clair reports no impact assessment at all. + impact=IMPACT_NOT_PROVIDED, + # The connector concatenates these with no separator. + unique_id_from_tool=f"{advisory}{name}", + vuln_id_from_tool=advisory or None, + # A registry scan reads a built image, never a running service. + static_finding=True, + dynamic_finding=False, + ) + + fixed_by = (vulnerability.get("FixedBy") or "").strip() + if fixed_by: + finding.mitigation = inert_text(fixed_by) + link = (vulnerability.get("Link") or "").strip() + if link: + finding.references = inert_text(link) + if advisory: + finding.unsaved_vulnerability_ids = [advisory] + return finding + + def describe(self, feature, vulnerability, tag_name, name, version, advisory): + """ + The connector's block, in its order. + + Note the fix, namespace and CVE lines are written even when empty, which is why they are not + conditional here - a file import has to read the same way as an API sync. + """ + description = inert_text(vulnerability.get("Description")) + description += f"\n**Vulnerable feature:** {inert_text(name)}" + description += f"\n**Vulnerable version:** {inert_text(version)}" + description += f"\n**Fixed by:** {inert_text(vulnerability.get('FixedBy'))}" + description += f"\n**Namespace:** {inert_text(vulnerability.get('NamespaceName'))}" + description += f"\n**CVE:** {inert_text(advisory)}" + if tag_name: + description += f"\n**Image tag:** {inert_text(tag_name)}" + return description diff --git a/dojo/tools/qwiet/__init__.py b/dojo/tools/qwiet/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/qwiet/parser.py b/dojo/tools/qwiet/parser.py new file mode 100644 index 00000000000..5423a8a5007 --- /dev/null +++ b/dojo/tools/qwiet/parser.py @@ -0,0 +1,309 @@ +import json +from contextlib import suppress + +from dojo.models import Finding + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + +# Qwiet carries its interesting metadata as key/value tags rather than fields. +TAG_REACHABILITY = "reachability" +TAG_CVE = "cve" +TAG_PACKAGE_URL = "package_url" +TAG_CVSS_SCORE = "cvss_score" +TAG_CWE_CATEGORY = "cwe_category" + +TYPE_OSS_VULN = "oss_vuln" +REACHABLE = "reachable" + + +class QwietParser: + + """ + Parses a Qwiet AI (formerly ShiftLeft) findings export. + + Mirrors pkg/tools/qwiet/connector/finding_converter field for field so a file import and an API sync + deduplicate against each other instead of producing two copies of everything. + + Qwiet reports most of what matters as key/value TAGS rather than fields - the CVE, the package URL, + the CVSS score, the CWE category and the reachability verdict all live there - so the parser reads + them out by key; see tag_value(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Qwiet Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Qwiet Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Qwiet AI findings export (JSON), the findings response for an app. Matches the " + "scan type used by the Qwiet connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Qwiet Parser. + + Mirrors the connector's Convert: + - title: the finding title, then its category, then the id. + - severity: Qwiet's own label; anything unrecognised is Info. + - severity_justification: Qwiet's reachability verdict, spelled out; see justification(). + - description: the type, category, OWASP category, the source and sink methods, the file + locations, then Qwiet's prose. + - cwe / cvssv3_score / unsaved_vulnerability_ids: read out of the tags. + - component_name / component_version: parsed from the package URL tag. + - file_path / line: the first file location, which is "<path>:<line>". + - unique_id_from_tool: "qwiet-<internal id>", falling back to the finding id. + """ + return [ + "title", + "severity", + "severity_justification", + "description", + "cwe", + "cvssv3_score", + "component_name", + "component_version", + "file_path", + "line", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Qwiet Parser. + + Copied from the Qwiet block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. Qwiet reports both code findings and + dependency findings, which is why the hash spans a file path, a CWE and a component: a given + finding has one or the other, and the unused half hashes as empty. + """ + return ["title", "severity", "file_path", "cwe", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(row, test) for row in self.rows(data)] + + def rows(self, data): + """ + Return the findings in the export. + + Qwiet wraps every response as {"ok": true, "response": [...]}; a bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("response", "findings"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A Qwiet export is the findings response, a JSON object with a 'response' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, test): + finding = Finding( + test=test, + title=self.title(row), + severity=self.severity(row), + description=self.describe(row), + cwe=self.cwe(row), + unique_id_from_tool=self.unique_id(row), + vuln_id_from_tool=self.vuln_id(row), + # Qwiet analyses code and dependencies; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.cvssv3_score = self.score(row) + finding.unsaved_tags = self.tags(row) + + if cve := self.tag_value(row, TAG_CVE): + finding.unsaved_vulnerability_ids = [cve] + + name, version = self.component(self.tag_value(row, TAG_PACKAGE_URL)) + if name: + finding.component_name = name + finding.component_version = version or None + + path, line = self.file_location(row) + if path: + finding.file_path = path + finding.line = line or None + + if justification := self.justification(row): + finding.severity_justification = justification + return finding + + def unique_id(self, row): + """Qwiet's internal id is stable across scans; the display id is the fallback.""" + for key in ("internal_id", "id"): + if value := str(row.get(key) or ""): + return f"qwiet-{value}" + return "qwiet-" + + def vuln_id(self, row): + """The internal id, then the category - the rule rather than this instance of it.""" + for key in ("internal_id", "category"): + if value := str(row.get(key) or ""): + return value + return None + + def title(self, row): + for key in ("title", "category"): + if value := str(row.get(key) or ""): + return value + return "Qwiet finding " + str(row.get("id") or "") + + def severity(self, row): + label = str(row.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def details(self, row): + value = row.get("details") + return value if isinstance(value, dict) else {} + + def describe(self, row): + """ + The classification, then the data-flow path, then Qwiet's prose. + + The source and sink methods are the two ends of the flow Qwiet traced, which is the part a + reviewer needs to judge whether the path is real. + """ + lines = [] + for label, key in (("Type", "type"), ("Category", "category"), ("OWASP", "owasp_category")): + value = str(row.get(key) or "") + if value: + lines.append(f"**{label}:** {value}") + + details = self.details(row) + for label, key in (("Source", "source_method"), ("Sink", "sink_method")): + value = str(details.get(key) or "") + if value: + lines.append(f"**{label}:** {value}") + if locations := self.file_locations(row): + lines.append("**Locations:** " + ", ".join(locations)) + + text = "\n".join(lines) + if description := str(row.get("description") or ""): + if text: + text += "\n\n" + text += description + return text.strip() + + def file_locations(self, row): + rows = self.details(row).get("file_locations") + if not isinstance(rows, list): + return [] + return [str(item).strip() for item in rows if str(item or "").strip()] + + def file_location(self, row): + """ + The first file location, which Qwiet formats as "<path>:<line>". + + Only the first is used for the fields; the whole list is in the description, because a data-flow + finding legitimately spans several files and DefectDojo has one file_path. + """ + locations = self.file_locations(row) + if not locations: + return "", 0 + location = locations[0] + path, separator, line = location.partition(":") + if not separator: + return location, 0 + with suppress(ValueError): + return path, int(line) + return path, 0 + + def tag_value(self, row, key): + """Qwiet's tags are a list of {"key": ..., "value": ...} objects, not a map.""" + for tag in row.get("tags") or []: + if isinstance(tag, dict) and str(tag.get("key") or "") == key: + return str(tag.get("value") or "").strip() + return "" + + def cwe(self, row): + trimmed = self.tag_value(row, TAG_CWE_CATEGORY).upper().removeprefix("CWE-") + with suppress(ValueError): + return int(trimmed) + return 0 + + def score(self, row): + with suppress(ValueError): + return float(self.tag_value(row, TAG_CVSS_SCORE)) + return 0.0 + + def component(self, purl): + """ + Read the package name and version off a package URL. + + Only the last path segment matters - "pkg:maven/org.example/lib@1.2.3" is lib 1.2.3 - because + the namespace before it is the group, not the artefact DefectDojo matches on. + """ + if not purl: + return "", "" + last = purl.rsplit("/", 1)[-1] + name, separator, version = last.partition("@") + if not separator: + return last, "" + return name, version + + def is_reachable(self, row): + """ + Whether Qwiet traced a path from application input to the vulnerable code. + + A dependency finding with related findings is reachable even without the tag: the related + findings ARE the path Qwiet found through the application. + """ + if self.tag_value(row, TAG_REACHABILITY).lower() == REACHABLE: + return True + return str(row.get("type") or "") == TYPE_OSS_VULN and bool(row.get("related_findings")) + + def justification(self, row): + """ + Qwiet's reachability verdict, spelled out. + + Reachability is the whole point of the tool, and it does not change the severity here - it is + recorded as the justification so a reviewer can see why two findings of equal severity are not + equally urgent. + """ + value = self.tag_value(row, TAG_REACHABILITY) + if not value and str(row.get("type") or "") == TYPE_OSS_VULN and row.get("related_findings"): + value = REACHABLE + if not value: + return "" + if self.is_reachable(row): + return ( + "Qwiet marks this finding as reachable: an attacker-controlled data-flow path " + "connects application input to the vulnerable code." + ) + return f"Qwiet marks this finding as {value}." + + def tags(self, row): + tags = [] + for key in ("type", "owasp_category"): + value = str(row.get(key) or "") + if value: + tags.append(value) + if reachability := self.tag_value(row, TAG_REACHABILITY): + tags.append(f"reachability:{reachability}") + elif self.is_reachable(row): + tags.append("reachability:reachable") + return tags diff --git a/dojo/tools/socket/__init__.py b/dojo/tools/socket/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/socket/parser.py b/dojo/tools/socket/parser.py new file mode 100644 index 00000000000..44f0212d6f3 --- /dev/null +++ b/dojo/tools/socket/parser.py @@ -0,0 +1,190 @@ +import json + +from dojo.models import Finding + +# Mirrors toSeverity() in the Socket connector's converter. Socket's own ladder is +# low | middle | high | critical - note "middle", not "medium" - and anything unrecognised +# becomes Info there, so it does here too. +SEVERITY_MAP = { + "critical": "Critical", + "high": "High", + "middle": "Medium", + "medium": "Medium", + "low": "Low", +} +DEFAULT_SEVERITY = "Info" + + +class SocketParser: + + """ + Parses a Socket full-scan artifact export. + + The scan type, title construction, severity ladder, component naming, unique id and tags are + mirrored from the Socket connector's converter (pkg/tools/socket/converter) so that a file + import and an API sync produce the same findings and deduplicate against each other. A customer + who cannot grant Socket API credentials gets the same data by exporting the scan instead. + """ + + def get_scan_types(self): + # MUST match the connector's ScanType() exactly, or file findings and API-synced findings + # land in different test types and never deduplicate. + return ["Socket - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "Socket - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Socket full-scan artifact export (JSON). Matches the scan type used by the " + "Socket connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Socket Parser. + + Fields mirror the connector's converter: + - title: "<alert type> in <component>". + - severity: Socket's low/middle/high/critical ladder; anything else is Info. + - description: Alert type, category, package URL, ecosystem and the alert props. + - component_name: "<namespace>/<name>" when the artifact has a namespace, else the name. + - component_version: Artifact version. + - file_path: Alert file, when the alert names one. + - unique_id_from_tool: Alert key, which is what the connector dedupes on. + - tags: socket:<type>, category:<category>, ecosystem:<type> and the package URL. + - static_finding: True, dynamic_finding: False - as the converter sets them. + """ + return [ + "title", + "severity", + "description", + "component_name", + "component_version", + "file_path", + "unique_id_from_tool", + "tags", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Socket Parser. + + The connector sets UniqueIDFromTool to the alert key, so that is the dedupe identity here + too. Diverging would stop file findings merging with API-synced ones. + """ + return ["unique_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + artifacts = self.extract_artifacts(data) + + findings = {} + for artifact in artifacts: + if not isinstance(artifact, dict): + continue + for alert in artifact.get("alerts") or []: + if not isinstance(alert, dict): + continue + key = alert.get("key") or "" + # The connector's unique id is the alert key; two alerts sharing one are the same + # finding. + if key and key in findings: + continue + finding = self.build_finding(alert, artifact, test) + findings[key or id(alert)] = finding + return list(findings.values()) + + def extract_artifacts(self, data): + """ + Accept either a bare list of artifacts or the API envelope that wraps them. + + Socket's full-scan endpoint streams artifacts, and what people save varies: a JSON array, or + an object with the artifacts under "artifacts" or "results". + """ + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("artifacts", "results"): + if isinstance(data.get(key), list): + return data[key] + # A single artifact saved on its own. + if "alerts" in data: + return [data] + msg = ( + "A Socket export is a JSON array of artifacts, or an object with an 'artifacts' or " + f"'results' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, alert, artifact, test): + component = self.component_name(artifact) + purl = self.package_url(artifact) + alert_type = alert.get("type") or "Unknown alert" + + finding = Finding( + test=test, + # converter: fmt.Sprintf("%s in %s", alert.Type, component) + title=f"{alert_type} in {component}" if component else alert_type, + severity=SEVERITY_MAP.get( + (alert.get("severity") or "").strip().lower(), DEFAULT_SEVERITY, + ), + description=self.build_description(alert, artifact, purl), + component_name=component or None, + component_version=artifact.get("version") or None, + unique_id_from_tool=alert.get("key") or None, + # The converter marks Socket findings static: it reads a dependency manifest rather than + # exercising a running application. + static_finding=True, + dynamic_finding=False, + ) + if file_path := alert.get("file"): + finding.file_path = file_path + finding.unsaved_tags = self.build_tags(alert, artifact, purl) + return finding + + def component_name(self, artifact): + """converter: namespace + "/" + name when a namespace is present, else name.""" + name = artifact.get("name") or "" + namespace = artifact.get("namespace") or "" + return f"{namespace}/{name}" if namespace else name + + def package_url(self, artifact): + """converter: pkg:<type>/<namespace>/<name>@<version>, empty when type or name is missing.""" + ecosystem = artifact.get("type") or "" + name = artifact.get("name") or "" + if not ecosystem or not name: + return "" + if namespace := artifact.get("namespace"): + name = f"{namespace}/{name}" + purl = f"pkg:{ecosystem}/{name}" + if version := artifact.get("version"): + purl += f"@{version}" + return purl + + def build_description(self, alert, artifact, purl): + parts = [ + f"**Socket alert:** {alert.get('type') or ''}", + f"**Category:** {alert.get('category') or ''}", + ] + if purl: + parts.append(f"**Package:** {purl}") + if ecosystem := artifact.get("type"): + parts.append(f"**Ecosystem:** {ecosystem}") + # The converter renders the free-form alert props as sorted key/value lines. + props = alert.get("props") + if isinstance(props, dict): + parts.extend(f"**{key}:** {props[key]}" for key in sorted(props)) + return "\n".join(parts) + + def build_tags(self, alert, artifact, purl): + tags = [f"socket:{alert.get('type') or ''}"] + if category := alert.get("category"): + tags.append(f"category:{category}") + if ecosystem := artifact.get("type"): + tags.append(f"ecosystem:{ecosystem}") + if purl: + tags.append(purl) + return tags diff --git a/dojo/tools/soos/__init__.py b/dojo/tools/soos/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/soos/parser.py b/dojo/tools/soos/parser.py new file mode 100644 index 00000000000..e9d5c8b6be5 --- /dev/null +++ b/dojo/tools/soos/parser.py @@ -0,0 +1,310 @@ +import json +import re +from contextlib import suppress +from datetime import date as _date +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_NAME = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + # "Unknown" is a real SOOS value, not a gap, so it grades rather than being dropped. + "unknown": "Info", +} +DEFAULT_SEVERITY = "Info" + +# SOOS runs several kinds of scan through one API. Only DAST exercises anything, so everything else +# it reports - and anything it reports that is not in this set - is read rather than run. +STATIC_SCAN_TYPES = {"sca", "sast", "csa", "sbom"} + +# SOOS-side dispositions that must not resurface as active findings on every sync. +DISMISSED_FALSE_POSITIVE = {"falsepositive", "false_positive"} +DISMISSED_RISK_ACCEPTED = {"accepted"} +DISMISSED_MITIGATED = {"ignored", "dismissed", "resolved", "fixed"} + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class SoosParser: + + """ + Parses a SOOS issue export. + + Mirrors pkg/tools/soos/connector/finding_converter field for field so a file import and an API sync + deduplicate against each other instead of producing two copies of everything. + + SOOS runs SCA, SAST, container, SBOM and DAST scans through ONE API and stamps each issue with its + scan type, so whether a finding is static or dynamic is decided per issue rather than for the file; + see is_static(). Only DAST exercises anything. + + A SOOS-side dismissal is carried across so it does not resurface as an active finding on every + sync - and the three kinds of dismissal are kept apart, because a false positive, an accepted risk + and a fix are three different statements; see apply_status(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. + return ["SOOS - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "SOOS - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a SOOS issue export (JSON) - SCA, SAST, container, SBOM or DAST issues, each " + "stamped with its scan type. Matches the scan type used by the SOOS connector so file and " + "API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the SOOS Parser. + + Mirrors the connector's ConvertFinding: + - title / description: SOOS's own, with the scan type noted when there is no prose at all. + - severity: SOOS's severity word, with "Unknown" grading as Info. + - mitigation: the remediation text, else naming the fixed version. + - component_name / component_version: the affected package. + - file_path / line: for a source finding; the URL becomes an endpoint for a DAST one. + - cvssv3 / cvssv3_score: the vector and the score. + - cwe: accepting "CWE-79" or "79". + - active / false_p / risk_accepted / is_mitigated: the SOOS-side disposition. + - static_finding / dynamic_finding: decided by the issue's scan type. + """ + return [ + "title", + "description", + "severity", + "mitigation", + "references", + "component_name", + "component_version", + "file_path", + "line", + "cvssv3", + "cvssv3_score", + "cwe", + "date", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "false_p", + "risk_accepted", + "is_mitigated", + "static_finding", + "dynamic_finding", + ] + + # No get_dedupe_fields: this scan type has no curated hash-field list in the connector settings, so + # it deduplicates with DefectDojo's default algorithm - which is exactly what the connector's own + # findings do today. Choosing hash fields here would change those too, which is not this parser's + # call to make. + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(row, test) for row in self.rows(data)] + + def rows(self, data): + """ + Return the issues in the export. + + SOOS pages its lists as {"entries": [...]} and, in places, {"items": [...]} - its own client + accepts both, so both are read here. A bare array works too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("entries", "items", "issues", "data", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A SOOS export is the issues response, a JSON object with an 'entries' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, test): + scan_type = str(row.get("scanType") or "").strip().lower() + static = self.is_static(scan_type) + package = str(row.get("packageName") or "") + + finding = Finding( + test=test, + title=str(row.get("title") or "") or None, + description=self.describe(row), + severity=self.severity(row), + mitigation=self.mitigation(row, package) or None, + references=str(row.get("references") or "") or None, + component_name=package or None, + component_version=str(row.get("packageVersion") or "") or None, + file_path=str(row.get("fileName") or "") or None, + line=self.integer(row.get("line")) or None, + cvssv3=str(row.get("cvssVector") or "") or None, + cwe=self.cwe(row), + unique_id_from_tool=str(row.get("id") or "") or None, + # Only a DAST issue is exercised; SCA, SAST, container and SBOM all inspect artifacts. + static_finding=static, + dynamic_finding=not static, + ) + finding.cvssv3_score = self.number(row.get("cvssScore")) + finding.unsaved_tags = [scan_type] if scan_type else [] + + if cve := str(row.get("cve") or "").strip(): + finding.unsaved_vulnerability_ids = [cve] + finding.vuln_id_from_tool = cve + if date := self.date(row.get("firstDetected")): + finding.date = date + + self.add_endpoint(finding, str(row.get("url") or "").strip()) + self.apply_status(finding, str(row.get("status") or "")) + return finding + + def is_static(self, scan_type): + """ + Whether the issue's scan type inspects an artifact rather than running it. + + SOOS puts every kind of scan behind one API, so this is decided per ISSUE. DAST is dynamic, and + so is an UNRECOGNISED or absent scan type: the connector reads its lookup table with a Go map + access, which yields false for a missing key just as it does for the dast entry. Mirrored rather + than corrected, and noted in the PR - a new SOOS scan type would arrive as dynamic. + """ + return scan_type in STATIC_SCAN_TYPES + + def severity(self, row): + """ + SOOS's severity word. + + "Unknown" is a real SOOS value rather than a gap, and it grades as Info instead of being + dropped - a finding it could not grade is still a finding. + """ + label = str(row.get("severity") or "").strip().lower() + return SEVERITY_BY_NAME.get(label, DEFAULT_SEVERITY) + + def describe(self, row): + """ + SOOS's own prose, and when there is none, which scan reported the issue. + + An empty body would read as though the data had been lost in transit. + """ + if description := str(row.get("description") or "").strip(): + return description + if scan_type := str(row.get("scanType") or "").strip(): + return f"Reported by the SOOS {scan_type} scan." + return "Reported by SOOS." + + def mitigation(self, row, package): + """The remediation text, else naming the fixed version - and the package when it is known.""" + if remediation := str(row.get("remediation") or "").strip(): + return remediation + fixed = str(row.get("fixedVersion") or "").strip() + if not fixed: + return "" + if component := package.strip(): + return f"Upgrade {component} to {fixed} or later." + return f"Upgrade to {fixed} or later." + + def cwe(self, row): + """The CWE as a number, accepting "CWE-79" or "79".""" + digits = str(row.get("cwe") or "").strip().upper().removeprefix("CWE-") + with suppress(ValueError): + return int(digits) + return 0 + + def apply_status(self, finding, status): + """ + Carry a SOOS-side dismissal across, so it does not resurface as active on every sync. + + The three kinds are kept apart because they are three different statements: a false positive was + never real, an accepted risk is real and signed off, and a resolved or ignored issue is treated + as dealt with. Anything else - including an unrecognised status - stays ACTIVE. + """ + normalised = str(status or "").strip().lower().replace(" ", "") + + if normalised in DISMISSED_FALSE_POSITIVE: + finding.active = False + finding.false_p = True + elif normalised in DISMISSED_RISK_ACCEPTED: + finding.active = False + finding.risk_accepted = True + elif normalised in DISMISSED_MITIGATED: + finding.active = False + finding.is_mitigated = True + else: + finding.active = True + + def add_endpoint(self, finding, url): + """ + The DAST location, when the issue has one. + + A source finding has a file path instead, and no URL at all. + """ + if not url: + return + with suppress(ValueError): + parsed = urlparse(url) + host = parsed.hostname or "" + if not host or not self.usable_host(host): + # A host DefectDojo rejects makes Endpoint.clean() raise, which fails the WHOLE + # import. The URL is still in the description either way. + return + path = (parsed.path or "").lstrip("/") + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=parsed.port, + path=path, query=parsed.query, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=parsed.port, + path=path or None, query=parsed.query or None, + )) + + def usable_host(self, value): + """A host is letters, digits, dot, hyphen, underscore or plus, or an IP address.""" + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def date(self, value): + """ + When SOOS first detected the issue, cut back to the calendar date. + + The connector cuts at the "T" because it hands the API a string; this reads the same value as a + date and skips one that is not a date rather than failing the import. + """ + text = str(value or "").strip() + if len(text) < 10: + return None + with suppress(ValueError): + return _date.fromisoformat(text.split("T")[0]) + return None + + def number(self, value): + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 + + def integer(self, value): + return int(self.number(value)) diff --git a/dojo/tools/uptycs/__init__.py b/dojo/tools/uptycs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/uptycs/parser.py b/dojo/tools/uptycs/parser.py new file mode 100644 index 00000000000..dd475d9a508 --- /dev/null +++ b/dojo/tools/uptycs/parser.py @@ -0,0 +1,219 @@ +import json +from contextlib import suppress + +from dojo.models import Finding + + +class UptycsParser: + + """ + Parses an Uptycs vulnerabilities-query export. + + Mirrors pkg/tools/uptycs/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + Uptycs reports ONE ROW PER VULNERABLE PACKAGE, listing every CVE against it, so one row becomes + one finding PER CVE - each is separately fixable and separately triaged. A row naming no CVE still + becomes a single package finding, because a vulnerable package is worth recording even when Uptycs + has not attached an identifier. See get_findings(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Uptycs Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Uptycs Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import an Uptycs vulnerabilities-query export (JSON). One row per vulnerable package " + "becomes one finding per CVE. Matches the scan type used by the Uptycs connector so file " + "and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Uptycs Parser. + + Mirrors the connector's Convert: + - title: "<CVE> in <package>", or "Vulnerable package <package>" without one. + - severity: derived from the row's CVSS score; Uptycs sends no severity word. + - description: the package and version, the host, the OS, the asset group, and the row's other + CVEs when it lists more than one. + - component_name / component_version: the vulnerable package. + - unique_id_from_tool: "uptycs-<asset id>-<package>[-<CVE>]". + """ + return [ + "title", + "severity", + "description", + "component_name", + "component_version", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Uptycs Parser. + + Copied from the Uptycs block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The component is the package, so the + same CVE on two hosts hashes the same - the asset id in the identity is what keeps them apart. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for row in self.rows(data): + cves = self.cves(row) + if not cves: + # A vulnerable package with no identifier is still worth recording. + findings.append(self.build_finding(row, "", cves, test)) + continue + findings.extend(self.build_finding(row, cve, cves, test) for cve in cves) + return findings + + def rows(self, data): + """ + Return the query rows in the export. + + Uptycs answers its query endpoint with {"items": [...]}, so that is the shape a saved export + has. A bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("items", "rows", "data", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "An Uptycs export is a vulnerabilities-query response, a JSON object with an 'items' " + f"list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def cves(self, row): + """ + The row's CVE list, which Uptycs sends either as an array or as a COMMA-SEPARATED string. + + Its own decoder accepts both, so a string is split and trimmed - reading it whole would make + one finding titled after every CVE at once. + """ + value = row.get("cve_list") + if isinstance(value, list): + return [str(item).strip() for item in value if str(item or "").strip()] + if isinstance(value, str): + return [part.strip() for part in value.split(",") if part.strip()] + return [] + + def build_finding(self, row, cve, cves, test): + package = str(row.get("package_name") or "") + + finding = Finding( + test=test, + title=self.title(package, cve), + severity=self.severity(row), + description=self.describe(row, package, cves), + component_name=package or None, + component_version=str(row.get("package_version") or "") or None, + unique_id_from_tool=self.unique_id(row, package, cve), + # Uptycs reads an installed package inventory; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.cvssv3_score = self.score(row) + finding.unsaved_tags = self.tags(row) + + if cve: + finding.unsaved_vulnerability_ids = [cve] + finding.vuln_id_from_tool = cve + return finding + + def unique_id(self, row, package, cve): + base = f"uptycs-{row.get('upt_asset_id') or ''}-{package}" + if cve: + return f"{base}-{cve}" + return base + + def title(self, package, cve): + """The package name, or the literal word "package" when Uptycs did not name one.""" + name = package or "package" + if cve: + return f"{cve} in {name}" + return f"Vulnerable package {name}" + + def severity(self, row): + """ + Derived from the row's CVSS score - Uptycs sends no severity word to prefer. + + Every CVE fanned out of one row therefore shares the row's severity, because the row carries + one score for the package rather than one per CVE. + """ + score = self.score(row) + if score >= 9: + return "Critical" + if score >= 7: + return "High" + if score >= 4: + return "Medium" + if score > 0: + return "Low" + return "Info" + + def score(self, row): + """Uptycs' CVSS score, which may arrive quoted.""" + value = row.get("cvss_score") + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 + + def describe(self, row, package, cves): + """ + The package, the host it is installed on, and the row's other CVEs. + + The CVE list is printed only when the row names MORE than one: with a single CVE the title + already says it, and repeating it would add nothing. + """ + lines = [] + + def write(label, value): + # The connector tests for emptiness WITHOUT trimming, which a plain truthiness test + # reproduces exactly for strings. + if value: + lines.append(f"**{label}:** {value}") + + write("Package", f"{package} {row.get('package_version') or ''}".strip()) + write("Host", str(row.get("upt_hostname") or "")) + write("OS", str(row.get("os") or "")) + write("Asset group", str(row.get("upt_asset_group_name") or "")) + + if len(cves) > 1: + write("CVEs", ", ".join(cves)) + return "\n".join(lines).strip() + + def tags(self, row): + """The OS and the asset group, for filtering a fleet.""" + return [ + value + for value in ( + str(row.get(key) or "").strip() for key in ("os", "upt_asset_group_name") + ) + if value + ] diff --git a/dojo/tools/vanta/__init__.py b/dojo/tools/vanta/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/vanta/parser.py b/dojo/tools/vanta/parser.py new file mode 100644 index 00000000000..7df6bd542ce --- /dev/null +++ b/dojo/tools/vanta/parser.py @@ -0,0 +1,263 @@ +import json +from contextlib import suppress +from datetime import datetime + +from dojo.models import Finding + +# Vanta grades nothing: a compliance test either passes or fails. The connector imports every failing +# entity at Medium rather than inventing a scale the tool does not have. +DEFAULT_SEVERITY = "Medium" + +# The entity status the connector asks Vanta for. +ENTITY_STATUS_FAILING = "FAILING" + + +class VantaParser: + + """ + Parses a Vanta compliance export. + + Mirrors pkg/tools/vanta/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + A Vanta finding is a (test, failing entity) pair: the test is the control - "MFA is enabled" - and + the entity is the resource failing it. The failing entities come from a second call per test, so an + export needs both; see extract(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Vanta Compliance"] + + def get_label_for_scan_types(self, scan_type): + return "Vanta Compliance" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Vanta compliance export (JSON). Matches the scan type used by the Vanta " + "connector so file and API findings deduplicate. Include each test's failing entities - " + "the failing entity is the finding." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Vanta Parser. + + Mirrors the connector's Convert: + - title: the test's name, else "Vanta test <id>". + - severity: always Medium; Vanta has no severity scale. See severity(). + - description: the failing resource and its type, the category, the integrations, then the + test's description and why it failed. + - mitigation: the test's remediation description. + - component_name: the failing entity, so two resources failing one test stay apart. + - unique_id_from_tool: "vanta-<test id>-<entity id>". + - vuln_id_from_tool: the test id, which is the control. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "component_name", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Vanta Parser. + + Copied from the Vanta block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The component - the failing entity - + is what keeps two resources failing the same control from merging into one finding, which + matters here because every finding shares the same severity. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test_object): + data = json.load(filename) + tests, entities = self.extract(data) + + findings = [] + for test in tests: + if not isinstance(test, dict): + continue + findings.extend( + self.finding(test, entity, test_object) + for entity in self.entities_for(test, entities) + ) + return findings + + def extract(self, data): + """ + Return the tests and the failing entities of each. + + Vanta's tests response nests rows under results.data. The failing entities come from a second + call per test and carry no test id of their own, so an export keys them by test id or nests + them on the test. + """ + entities = {} + tests = None + + if isinstance(data, list): + tests = data + elif isinstance(data, dict): + tests = self.rows(data, ("tests",)) + entities = self.index_entities(data) + + if tests is None: + msg = ( + "A Vanta export is the tests response, a JSON object with results.data rows; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + return tests, entities + + def rows(self, source, keys): + """ + Vanta wraps every list as {"results": {"data": [...]}}. + + A bare list under the named key, or a plain list, is accepted too. + """ + results = source.get("results") + if isinstance(results, dict) and isinstance(results.get("data"), list): + return [row for row in results["data"] if isinstance(row, dict)] + for key in keys: + value = source.get(key) + if isinstance(value, list): + return [row for row in value if isinstance(row, dict)] + if isinstance(value, dict): + nested = value.get("results") + if isinstance(nested, dict) and isinstance(nested.get("data"), list): + return [row for row in nested["data"] if isinstance(row, dict)] + return None + + def index_entities(self, data): + """The failing entities as a map keyed by test id.""" + for key in ("entities", "failing_entities"): + source = data.get(key) + if not isinstance(source, dict): + continue + indexed = {} + for test_id, value in source.items(): + rows = self.rows({"entities": value}, ("entities",)) + if rows is None and isinstance(value, list): + rows = [row for row in value if isinstance(row, dict)] + if rows is not None: + indexed[str(test_id)] = rows + return indexed + return {} + + def entities_for(self, test, entities): + """ + The failing entities nested on the test, else those indexed by its id. + + Only entities Vanta reports as FAILING are findings - a passing one is the control working. + The connector asks Vanta for the failing ones specifically, so an export that carries every + entity is filtered here instead. + """ + rows = self.rows(test, ("entities", "failing_entities")) + if rows is None: + rows = entities.get(str(test.get("id")), []) + return [row for row in rows if self.is_failing(row)] + + def is_failing(self, entity): + status = str(entity.get("entityStatus") or "").strip().upper() + return status in {"", ENTITY_STATUS_FAILING} + + def finding(self, test, entity, test_object): + finding = Finding( + test=test_object, + title=self.title(test), + severity=self.severity(), + description=self.describe(test, entity), + mitigation=str(test.get("remediationDescription") or ""), + component_name=str(entity.get("displayName") or "") or None, + unique_id_from_tool=f"vanta-{test.get('id') or ''}-{entity.get('id') or ''}", + vuln_id_from_tool=str(test.get("id") or "") or None, + # Vanta evaluates configuration and records, not a running request. + static_finding=True, + dynamic_finding=False, + # A failing entity is currently failing; that is what makes it a finding. + active=True, + ) + finding.unsaved_tags = self.tags(test, entity) + if date := self.date(test, entity): + finding.date = date + return finding + + def severity(self): + """ + Always Medium. + + Vanta has no severity scale - a compliance test passes or fails - so the connector grades + every failing entity the same rather than inventing a ladder. Info would read as + non-actionable, and a failing control is actionable by definition. + """ + return DEFAULT_SEVERITY + + def title(self, test): + if name := str(test.get("name") or ""): + return name + return f"Vanta test {test.get('id') or ''}" + + def describe(self, test, entity): + """ + The failing resource and context as single-newline fields, then the prose sections. + + The prose is separated by a blank line because it is paragraphs rather than fields - the + connector's own distinction. + """ + lines = [] + for label, value in ( + ("Failing resource", entity.get("displayName")), + ("Resource type", entity.get("responseType")), + ("Category", test.get("category")), + ): + text = str(value or "") + if text: + lines.append(f"**{label}:** {text}") + + integrations = [str(item) for item in test.get("integrations") or [] if str(item or "")] + if integrations: + lines.append("**Integrations:** " + ", ".join(integrations)) + text = "\n".join(lines) + + for label, key in (("Description", "description"), ("Why this failed", "failureDescription")): + value = str(test.get(key) or "") + if not value: + continue + if text: + text += "\n\n" + text += f"**{label}:**\n{value}" + return text.strip() + + def tags(self, test, entity): + tags = ["compliance"] + if category := str(test.get("category") or ""): + tags.append(category) + tags.extend(str(item) for item in test.get("integrations") or [] if str(item or "")) + if response_type := str(entity.get("responseType") or ""): + tags.append(response_type) + return tags + + def date(self, test, entity): + """ + When the entity started failing, falling back to when the test last flipped. + + The entity's own date is preferred because one control can have been failing for a year while + a resource added last week has only just started failing it. + """ + for value in (entity.get("createdDate"), test.get("latestFlipDate")): + timestamp = str(value or "").strip() + if not timestamp: + continue + with suppress(ValueError): + return datetime.strptime(timestamp.split("T")[0], "%Y-%m-%d").date() + return None diff --git a/dojo/tools/venafi/__init__.py b/dojo/tools/venafi/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/venafi/parser.py b/dojo/tools/venafi/parser.py new file mode 100644 index 00000000000..f250d987bd7 --- /dev/null +++ b/dojo/tools/venafi/parser.py @@ -0,0 +1,415 @@ +import json +from contextlib import suppress +from datetime import UTC, datetime, timedelta + +from dojo.models import Finding + +# How far ahead an upcoming expiry is still reported. +EXPIRING_SOON_WINDOW = timedelta(days=30) + +# The smallest RSA key length considered acceptable. +MINIMUM_KEY_SIZE = 2048 + +# Hashes no longer considered collision resistant. +WEAK_HASHES = ("SHA1", "MD5", "MD2") + +# The timestamp layouts the connector accepts, in its order. +TIMESTAMP_FORMATS = ( + "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%dT%H:%M:%S.%f%z", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d", +) + + +class VenafiParser: + + """ + Parses a CyberArk Certificate Manager (formerly Venafi) certificate inventory export. + + Mirrors pkg/tools/venafi/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + NEITHER EDITION RETURNS A COMPLIANCE VERDICT, so the posture rules are computed from each + certificate's own attributes: expired, expiring within thirty days, a weak RSA key, a weak + signature hash, and self-signed. One certificate therefore produces zero findings when it is + healthy and several when it breaks several rules; see violations(). + + A rule is SKIPPED when the attribute it needs is absent rather than guessed - a certificate with no + recorded key size is not reported as weak. + + The two editions name their fields differently, so both are normalised the way the client does; see + certificate(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["CyberArk Certificate Manager Scan"] + + def get_label_for_scan_types(self, scan_type): + return "CyberArk Certificate Manager Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a CyberArk Certificate Manager (Venafi) certificate inventory export (JSON), from " + "either the SaaS or the self-hosted edition. Certificate posture is computed from the " + "certificate's own attributes. Matches the scan type used by the connector so file and API " + "findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Venafi Parser. + + Mirrors the connector's Convert: + - title: the posture problem, then the certificate's common name in brackets. + - severity: fixed per rule - expired is Critical, a weak key or signature or an imminent + expiry is High, self-signed is Medium. + - description: what was found, then the certificate's names, issuer, key, signature, expiry + and fingerprint. + - mitigation: the remediation for that rule. + - component_name: the certificate, named by common name or fingerprint. + - vuln_id_from_tool: the rule that was broken. + - unique_id_from_tool: "venafi-<fingerprint>-<rule>". + """ + return [ + "title", + "severity", + "description", + "mitigation", + "component_name", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Venafi Parser. + + Copied from the CyberArk Certificate Manager block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The certificate is the component, so + the same problem on two certificates stays two findings. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + # Expiry is judged against NOW, exactly as the connector judges it against sync time. The same + # file imported later therefore reports more expiries - which is correct, not a defect: a + # certificate that has since lapsed really has lapsed. + now = datetime.now(tz=UTC) + + findings = [] + for row in self.rows(data): + certificate = self.certificate(row) + findings.extend( + self.build_finding(certificate, violation, test) + for violation in self.violations(certificate, now) + ) + return findings + + def rows(self, data): + """ + Return the certificates in the export. + + The SaaS edition answers {"certificates": [...]}; the self-hosted one answers + {"Certificates": [...]} - capitalised, as its whole API is. A bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("certificates", "Certificates", "data", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A CyberArk Certificate Manager export is a certificate inventory, a JSON object with a " + f"'certificates' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def certificate(self, row): + """ + Normalise either edition's certificate into the shape the posture rules read. + + The SaaS edition sends subjectCN as a LIST and its SANs as a map keyed by type; the self-hosted + one sends capitalised scalar fields. Reading only one edition's names would silently produce no + findings against the other, because every rule's attribute would look absent. + """ + if self.is_self_hosted(row): + subject = str(row.get("Subject") or "") + issuer = str(row.get("Issuer") or "") + return { + "id": self.first(row.get("Thumbprint"), row.get("Guid"), row.get("DN")), + "common_name": self.first(row.get("CN"), subject, row.get("DN")), + "sans": [], + "issuer": issuer, + "key_algorithm": str(row.get("KeyAlgorithm") or ""), + "key_size": self.integer(row.get("KeySize")), + "signature_algorithm": str(row.get("SignatureAlgorithm") or ""), + "not_after": self.timestamp(row.get("ValidTo")), + # The self-hosted edition has no self-signed flag, so it is inferred from the subject + # matching the issuer - which is what self-signed means. + "self_signed": bool( + subject.strip() and issuer.strip() + and subject.strip().casefold() == issuer.strip().casefold(), + ), + } + + return { + "id": self.first(row.get("fingerprint"), row.get("id")), + "common_name": self.first_of(row.get("subjectCN"), row.get("subjectDN")), + "sans": self.flatten_sans(row.get("subjectAlternativeNamesByType")), + "issuer": self.first_of(row.get("issuerCN"), row.get("issuerDN")), + "key_algorithm": str(row.get("encryptionType") or ""), + "key_size": self.integer(row.get("keyStrength")), + "signature_algorithm": self.first( + row.get("signatureHashAlgorithm"), row.get("signatureAlgorithm"), + ), + "not_after": self.timestamp(row.get("validityEnd")), + "self_signed": bool(row.get("selfSigned")), + } + + def is_self_hosted(self, row): + """The self-hosted edition capitalises every field name, which is how the two are told apart.""" + return any(key in row for key in ("Thumbprint", "Guid", "DN", "CN", "ValidTo")) + + def violations(self, certificate, now): + """ + Every posture rule the certificate breaks. + + A healthy certificate breaks none and produces no findings at all - which is the point of a + computed check: the inventory is not itself a finding. + """ + found = [] + + if expiry := self.expiry_violation(certificate, now): + found.append(expiry) + + key_size = certificate["key_size"] + if 0 < key_size < MINIMUM_KEY_SIZE and self.is_rsa(certificate["key_algorithm"]): + found.append({ + "kind": "weak-key", + "title": f"Certificate uses a weak key ({key_size} bits)", + "severity": "High", + "detail": ( + f"The key is {key_size} bits; at least {MINIMUM_KEY_SIZE} bits is required." + ), + "mitigation": ( + f"Rekey the certificate with a key of at least {MINIMUM_KEY_SIZE} bits and " + "replace it on every deployment." + ), + }) + + signature = certificate["signature_algorithm"] + if self.is_weak_signature(signature): + found.append({ + "kind": "weak-signature", + "title": f"Certificate is signed with a weak algorithm ({signature})", + "severity": "High", + "detail": ( + f"The signature algorithm is {signature}, which is no longer considered " + "collision resistant." + ), + "mitigation": "Reissue the certificate with a SHA-256 or stronger signature.", + }) + + if certificate["self_signed"]: + found.append({ + "kind": "self-signed", + "title": "Certificate is self-signed", + "severity": "Medium", + "detail": ( + "The certificate is self-signed, so relying parties cannot validate it against a " + "trusted issuer." + ), + "mitigation": ( + "Replace the certificate with one issued by a trusted certificate authority." + ), + }) + return found + + def expiry_violation(self, certificate, now): + """ + An expired certificate, or one expiring within thirty days. + + A certificate with no recorded expiry is not reported either way - guessing would either raise + a false alarm or hide a real lapse. + """ + not_after = certificate["not_after"] + if not_after is None: + return None + + if not_after < now: + return { + "kind": "expired", + "title": "Certificate has expired", + "severity": "Critical", + "detail": f"The certificate expired on {self.render(not_after)}.", + "mitigation": "Renew the certificate and replace it on every deployment.", + } + + if not_after < now + EXPIRING_SOON_WINDOW: + days = int((not_after - now).total_seconds() // 3600 // 24) + return { + "kind": "expiring-soon", + "title": "Certificate expires within 30 days", + "severity": "High", + "detail": ( + f"The certificate expires on {self.render(not_after)} (in {days} days)." + ), + "mitigation": "Schedule renewal before the expiry date.", + } + return None + + def build_finding(self, certificate, violation, test): + label = self.label(certificate) + + finding = Finding( + test=test, + title=f"{violation['title']} ({label})", + severity=violation["severity"], + description=self.describe(certificate, violation, label), + mitigation=violation["mitigation"], + component_name=label or None, + unique_id_from_tool=f"venafi-{certificate['id']}-{violation['kind']}", + vuln_id_from_tool=violation["kind"], + # A certificate's posture is read from its attributes; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=True, + ) + finding.unsaved_tags = self.tags(certificate, violation) + return finding + + def label(self, certificate): + """The certificate's common name, falling back to its fingerprint.""" + if name := certificate["common_name"].strip(): + return name + return certificate["id"] + + def describe(self, certificate, violation, label): + lines = [] + + def write(field, value): + if str(value or "").strip(): + lines.append(f"**{field}:** {value}") + + write("Finding", violation["detail"]) + write("Common name", certificate["common_name"]) + write("Subject alternative names", ", ".join(certificate["sans"])) + write("Issuer", certificate["issuer"]) + write("Key", self.key_label(certificate)) + write("Signature algorithm", certificate["signature_algorithm"]) + write("Expires", self.render(certificate["not_after"])) + write("Fingerprint", certificate["id"]) + return "\n".join(lines).strip() or f"**Finding:** {label}" + + def key_label(self, certificate): + """"RSA 2048 bits", or whichever half is known.""" + algorithm = certificate["key_algorithm"] + size = certificate["key_size"] + if algorithm and size > 0: + return f"{algorithm} {size} bits" + if size > 0: + return f"{size} bits" + return algorithm + + def tags(self, certificate, violation): + """The rule that was broken and the key algorithm, for filtering.""" + tags = [violation["kind"]] + if algorithm := certificate["key_algorithm"].strip(): + tags.append(algorithm) + return tags + + def is_rsa(self, algorithm): + """ + Whether the 2048-bit floor applies. + + An elliptic-curve key is much shorter by design, so applying the RSA floor to it would report + every EC certificate as weak. + """ + return "RSA" in algorithm.strip().upper() + + def is_weak_signature(self, algorithm): + """ + Whether the signature uses a broken hash. + + Hyphens are stripped first so "SHA-1" and "SHA1" are both recognised. + """ + normalised = algorithm.strip().upper().replace("-", "") + if not normalised: + return False + return any(weak in normalised for weak in WEAK_HASHES) + + def timestamp(self, value): + """ + The certificate's expiry, in whichever of the connector's layouts it arrives. + + A value with no timezone is read as UTC, so it can be compared with an aware "now" - the + connector compares against a UTC instant for the same reason. + """ + text = str(value or "").strip() + if not text: + return None + for fmt in TIMESTAMP_FORMATS: + with suppress(ValueError): + parsed = datetime.strptime(text, fmt) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + return None + + def render(self, moment): + """RFC 3339 in UTC, as the connector renders it.""" + if moment is None: + return "" + return moment.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + def flatten_sans(self, by_type): + """ + Every subject alternative name, whatever type it was filed under. + + SORTED, unlike the connector: it iterates a Go map, whose order is randomised, so its own + rendering of this line varies between syncs. Sorting keeps a file import stable and readable. + The line is not part of the deduplication hash, so the two still match. + """ + if not isinstance(by_type, dict): + return [] + names = [ + str(value).strip() + for values in by_type.values() + if isinstance(values, list) + for value in values + if str(value or "").strip() + ] + return sorted(names) + + def first(self, *values): + for value in values: + if trimmed := str(value or "").strip(): + return trimmed + return "" + + def first_of(self, listed, fallback): + """The SaaS edition sends the common name and issuer as LISTS; the first entry is the name.""" + if isinstance(listed, list): + for value in listed: + if trimmed := str(value or "").strip(): + return trimmed + elif trimmed := str(listed or "").strip(): + return trimmed + return str(fallback or "").strip() + + def integer(self, value): + if isinstance(value, bool) or value is None: + return 0 + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + with suppress(ValueError): + return int(float(value.strip() or 0)) + return 0 diff --git a/dojo/tools/vmanplus/__init__.py b/dojo/tools/vmanplus/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/vmanplus/parser.py b/dojo/tools/vmanplus/parser.py new file mode 100644 index 00000000000..feb20c6d3f4 --- /dev/null +++ b/dojo/tools/vmanplus/parser.py @@ -0,0 +1,263 @@ +import json +import re +from contextlib import suppress +from datetime import UTC, datetime + +from dojo.models import Finding + +# VMP grades on Microsoft's MSRC scale, whose names do not match DefectDojo's: "Important" is High and +# "Moderate" is Medium. Reading them as literal DefectDojo names would drop both a tier. +SEVERITY_BY_LABEL = { + "critical": "Critical", + "important": "High", + "high": "High", + "moderate": "Medium", + "medium": "Medium", + "low": "Low", +} +# "Unrated" and anything unrecognised land here. +DEFAULT_SEVERITY = "Info" + +# Statuses that mean the vulnerability has been dealt with. +CLOSED_STATUSES = {"close", "closed", "fixed", "remediated"} + +# The advisory identifiers the connector's shared extractor recognises in free text. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + + +class VmanplusParser: + + """ + Parses a ManageEngine Vulnerability Manager Plus export. + + Mirrors pkg/tools/vmanplus/connector/finding_converter field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + VMP grades on Microsoft's MSRC scale, where "Important" and "Moderate" mean High and Medium - names + that would each drop a tier if read as DefectDojo's own; see severity(). + + Its rows are already fused: each carries both the vulnerability and the asset it was found on, so + nothing has to be joined. + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["ManageEngine Vulnerability Manager Plus Scan"] + + def get_label_for_scan_types(self, scan_type): + return "ManageEngine Vulnerability Manager Plus Scan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a ManageEngine Vulnerability Manager Plus export (JSON), each row carrying the " + "vulnerability and the asset it was found on. Matches the scan type used by the VMP " + "connector so file and API findings deduplicate." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the VMP Parser. + + Mirrors the connector's Convert: + - title: the vulnerability name, then its CVE ids, then the vulnerability id. + - severity: VMP's MSRC-style severity word; see severity(). + - description: the vulnerability, its CVE ids, the host, its address and the status. + - mitigation: the patch description and the patch id. + - cvssv3_score: the CVSS v3 score, falling back to v2. + - component_name: the host, named by resource name, FQDN, or address. + - active: false once VMP reports the vulnerability closed; see is_open(). + - date: the row's update time, in epoch MILLIseconds. + - unique_id_from_tool: "vmanplus-<resource id>-<vulnerability id>". + """ + return [ + "title", + "severity", + "description", + "mitigation", + "cvssv3_score", + "component_name", + "references", + "date", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the VMP Parser. + + Copied from the ManageEngine Vulnerability Manager Plus block in the Pro connector settings, + which pairs unique_id_from_tool_or_hash_code with these hash fields. The HOST is the component + here, not a package, so the same vulnerability on two machines stays two findings. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + return [self.build_finding(row, test) for row in self.rows(data)] + + def rows(self, data): + """ + Return the vulnerability rows in the export. + + VMP answers {"vulnerabilities": [...]} alongside its paging metadata, so that is the shape a + saved export has. A bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("vulnerabilities", "data", "results"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A ManageEngine Vulnerability Manager Plus export is a JSON object with a " + f"'vulnerabilities' list; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, row, test): + host = self.host(row) + cve_ids = str(row.get("cveids") or "") + + finding = Finding( + test=test, + title=self.title(row, cve_ids), + severity=self.severity(row), + description=self.describe(row, host, cve_ids), + mitigation=self.mitigation(row) or None, + component_name=host or None, + references=str(row.get("reference_links") or "") or None, + unique_id_from_tool=( + f"vmanplus-{self.text(row.get('resource_id'))}-" + f"{self.text(row.get('vulnerabilityid'))}" + ), + vuln_id_from_tool=self.text(row.get("vulnerabilityid")) or None, + # VMP compares an installed inventory against advisories; nothing is exercised. + static_finding=True, + dynamic_finding=False, + active=self.is_open(row), + ) + finding.cvssv3_score = self.score(row) + + if identifiers := self.vulnerability_ids(cve_ids): + finding.unsaved_vulnerability_ids = identifiers + if date := self.date(row): + finding.date = date + return finding + + def title(self, row, cve_ids): + if name := str(row.get("vulnerabilityname") or ""): + return name + if cve_ids: + return cve_ids + return f"ManageEngine VMP vulnerability {self.text(row.get('vulnerabilityid'))}" + + def severity(self, row): + """ + VMP's MSRC-style severity word. + + "Important" is High and "Moderate" is Medium - DefectDojo has neither name, so reading them + literally would fall through to Info and drop both a tier. "Unrated" is Info. + """ + label = str(row.get("severity") or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def score(self, row): + """The CVSS v3 score, falling back to v2 - VMP reports both for older advisories.""" + for key in ("cvss_3_score", "cvss_2_score"): + if (score := self.number(row.get(key))) > 0: + return score + return 0.0 + + def host(self, row): + """The machine, named by its resource name, then its FQDN, then its address.""" + for key in ("resource_name", "fqdn_name", "ip_address"): + if value := str(row.get(key) or "").strip(): + return value + return "" + + def is_open(self, row): + """A vulnerability stays active until VMP reports it closed, fixed or remediated.""" + return str(row.get("vulnerability_status") or "").strip().lower() not in CLOSED_STATUSES + + def describe(self, row, host, cve_ids): + lines = [] + + def write(label, value): + if str(value or "").strip(): + lines.append(f"**{label}:** {value}") + + write("Vulnerability", str(row.get("vulnerabilityname") or "")) + write("CVE IDs", cve_ids) + write("Host", host) + write("IP address", str(row.get("ip_address") or "")) + write("Status", str(row.get("vulnerability_status") or "")) + return "\n".join(lines).strip() + + def mitigation(self, row): + """The patch VMP would deploy, and its id - the two things needed to act on the finding.""" + lines = [] + for label, key in (("Patch", "patch_description"), ("Patch ID", "patchid")): + if value := self.text(row.get(key)): + lines.append(f"**{label}:** {value}") + return "\n".join(lines).strip() + + def vulnerability_ids(self, cve_ids): + """ + Identifiers read out of the CVE id field, which VMP sends as ONE string. + + It may hold several, so they are extracted rather than used whole. The connector's shared + extractor sorts its results and drops case-insensitive duplicates. + """ + matches = sorted(VULNERABILITY_ID_PATTERN.findall(cve_ids)) + identifiers = [] + for match in matches: + # Adjacent-only dedupe after the sort, which is what slices.CompactFunc does. + if not identifiers or identifiers[-1].lower() != match.lower(): + identifiers.append(match) + return identifiers + + def date(self, row): + """VMP timestamps in epoch MILLIseconds; reading them as seconds would date everything to 1970.""" + value = self.integer(row.get("updatedtime")) + if value <= 0: + return None + with suppress(OSError, OverflowError, ValueError): + return datetime.fromtimestamp(value / 1000, tz=UTC).date() + return None + + def text(self, value): + """ + VMP's ids are strings that may arrive as JSON numbers. + + Its own decoder accepts either, and an id read as a float would render as "1.0" and never match + the API's "1". + """ + if value is None or isinstance(value, bool): + return "" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + if isinstance(value, int | float): + return str(value) + return str(value).strip() + + def number(self, value): + if isinstance(value, bool) or value is None: + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + with suppress(ValueError): + return float(value.strip() or 0) + return 0.0 + + def integer(self, value): + return int(self.number(value)) diff --git a/dojo/tools/wallarm/__init__.py b/dojo/tools/wallarm/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/wallarm/parser.py b/dojo/tools/wallarm/parser.py new file mode 100644 index 00000000000..e0db25773e8 --- /dev/null +++ b/dojo/tools/wallarm/parser.py @@ -0,0 +1,301 @@ +import json +import re +from contextlib import suppress +from datetime import UTC, datetime +from ipaddress import ip_address + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +SEVERITY_BY_LABEL = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + "information": "Info", + "informational": "Info", + "": "Info", +} +DEFAULT_SEVERITY = "Info" + +# Wallarm statuses that mean the vulnerability is not actionable. +IGNORED_STATUSES = {"closed", "falsepositive"} + +# The advisory identifiers the connector's shared extractor recognises in free text. +VULNERABILITY_ID_PATTERN = re.compile( + r"CVE-\d{4}-\d+|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|GO-\d{4}-\d+|RHSA-\d{4}:\d+", +) + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class WallarmParser: + + """ + Parses a Wallarm vulnerabilities export. + + Mirrors pkg/tools/wallarm/converter field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + Wallarm reports the threat level as EITHER a number or a word, in the same field, depending on + which API answered - so severity() has to handle both. See severity(). + """ + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Wallarm API Security"] + + def get_label_for_scan_types(self, scan_type): + return "Wallarm API Security" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Wallarm vulnerabilities export (JSON), the /v1/objects/vuln response. Matches " + "the scan type used by the Wallarm connector so file and API findings deduplicate. Closed " + "and false-positive vulnerabilities are skipped." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Wallarm Parser. + + Mirrors the connector's Convert: + - title: the vulnerability title, then "Wallarm: <type>", then the id. + - severity: from the threat level, which may be a number or a word; see severity(). + - description: the type, domain, method, path, parameter and detection method, then the + description and any additional prose. + - mitigation: Wallarm's exploit example, which is what it offers instead of advice. + - date: the validation time, in unix seconds. + - unique_id_from_tool: "wallarm-<id>", falling back to the wid and then the location. + - vuln_id_from_tool: the vulnerability type, which is Wallarm's rule identity. + """ + return [ + "title", + "severity", + "date", + "description", + "mitigation", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Wallarm Parser. + + Copied from the Wallarm block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. + """ + return ["title", "severity", "component_name"] + + def get_findings(self, filename, test): + data = json.load(filename) + findings = [] + for row in self.rows(data): + if self.ignored(row): + # Closed and false-positive vulnerabilities have already been dealt with in Wallarm. + continue + findings.append(self.build_finding(row, test)) + return findings + + def rows(self, data): + """ + Return the vulnerabilities in the export. + + Wallarm answers with {"status": 200, "body": [...]}; a bare array is accepted too. + """ + if isinstance(data, list): + return [row for row in data if isinstance(row, dict)] + if isinstance(data, dict): + for key in ("body", "vulnerabilities"): + if isinstance(data.get(key), list): + return [row for row in data[key] if isinstance(row, dict)] + + msg = ( + "A Wallarm export is the vulnerabilities response, a JSON object with a 'body' list; got " + f"{type(data).__name__}." + ) + raise TypeError(msg) + + def ignored(self, row): + return str(row.get("status") or "").strip().lower() in IGNORED_STATUSES + + def build_finding(self, row, test): + finding = Finding( + test=test, + title=self.title(row), + severity=self.severity(row), + description=self.describe(row), + mitigation=str(row.get("exploit_example") or ""), + unique_id_from_tool=self.unique_id(row), + vuln_id_from_tool=str(row.get("type") or "") or None, + # Wallarm watches live API traffic and validates against the running service. + static_finding=False, + dynamic_finding=True, + active=True, + ) + finding.unsaved_tags = self.tags(row) + + if identifiers := self.vulnerability_ids(row): + finding.unsaved_vulnerability_ids = identifiers + if date := self.date(row): + finding.date = date + + self.attach_endpoint(finding, row) + return finding + + def unique_id(self, row): + """ + "wallarm-<id>", falling back to the wid and then to the location. + + The location fallback is last because it is the only one that is not an id: two vulnerabilities + of different types on one path would collide, but something stable is better than nothing. + """ + identifier = row.get("id") + if isinstance(identifier, int | float) and not isinstance(identifier, bool) and identifier != 0: + return f"wallarm-{int(identifier)}" + if wid := str(row.get("wid") or ""): + return f"wallarm-{wid}" + return "wallarm-" + str(row.get("domain") or "") + str(row.get("path") or "") + + def title(self, row): + if title := str(row.get("title") or ""): + return title + if kind := str(row.get("type") or ""): + return f"Wallarm: {kind}" + identifier = row.get("id") + number = int(identifier) if isinstance(identifier, int | float) and not isinstance(identifier, bool) else 0 + return f"Wallarm vulnerability {number}" + + def severity(self, row): + """ + Grade the threat level, which Wallarm sends as either a number or a word. + + Which one depends on the API that answered, and the two need different ladders: the numeric + scale runs 1-5 with 5 the most severe, while the labels are the usual words. A number is + graded numerically even if it arrives as a JSON float, matching the connector's own decoder. + """ + threat = row.get("threat") + if isinstance(threat, bool): + return DEFAULT_SEVERITY + if isinstance(threat, int | float): + value = int(threat) + if value >= 5: + return "Critical" + if value == 4: + return "High" + if value == 3: + return "Medium" + if value == 2: + return "Low" + return "Info" + label = str(threat or "").strip().lower() + return SEVERITY_BY_LABEL.get(label, DEFAULT_SEVERITY) + + def describe(self, row): + """ + The location fields as single-newline lines, then the prose sections. + + The prose is separated by a blank line because it is paragraphs rather than fields - the + connector's own distinction. + """ + lines = [] + for label, key in (("Type", "type"), ("Domain", "domain"), ("Method", "method"), + ("Path", "path"), ("Parameter", "parameter"), + ("Detection method", "detection_method")): + value = str(row.get(key) or "") + if value: + lines.append(f"**{label}:** {value}") + text = "\n".join(lines) + + for label, key in (("Description", "description"), ("Additional", "additional")): + value = str(row.get(key) or "") + if not value: + continue + if text: + text += "\n\n" + text += f"**{label}:**\n{value}" + return text.strip() + + def vulnerability_ids(self, row): + """ + Identifiers found in the title, type, template, description and additional text. + + The connector's shared extractor SORTS these and drops case-insensitive duplicates, so the + same is done here rather than preserving document order. + """ + prose = "|".join([ + str(row.get("title") or ""), + str(row.get("type") or ""), + str(row.get("template") or ""), + str(row.get("description") or ""), + str(row.get("additional") or ""), + ]) + found = sorted(VULNERABILITY_ID_PATTERN.findall(prose)) + + identifiers = [] + for candidate in found: + if not identifiers or identifiers[-1].lower() != candidate.lower(): + identifiers.append(candidate) + return identifiers + + def date(self, row): + """Wallarm timestamps the validation in unix SECONDS.""" + value = row.get("validate_time") + if isinstance(value, int | float) and not isinstance(value, bool) and value > 0: + with suppress(OSError, OverflowError, ValueError): + return datetime.fromtimestamp(value, tz=UTC).date() + return None + + def tags(self, row): + tags = [] + for value in (row.get("type"), row.get("status")): + text = str(value or "") + if text: + tags.append(text) + return tags + + def attach_endpoint(self, finding, row): + """ + Record the domain, plus the path when Wallarm reported an absolute one. + + The connector only appends a path beginning with "/", because anything else is not a path - + Wallarm uses that field for a parameter location on some vulnerability types. + """ + host = str(row.get("domain") or "").strip() + if not host or not self.usable_host(host): + return + path = str(row.get("path") or "") + path = path.lstrip("/") if path.startswith("/") else "" + + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url(host=host, path=path)) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint(host=host, path=path or None)) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else + makes Endpoint.clean() raise, and that fails the whole import rather than the one finding. The + domain is still in the description. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False diff --git a/dojo/tools/yeswehack/__init__.py b/dojo/tools/yeswehack/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/yeswehack/parser.py b/dojo/tools/yeswehack/parser.py new file mode 100644 index 00000000000..989a3d7a497 --- /dev/null +++ b/dojo/tools/yeswehack/parser.py @@ -0,0 +1,334 @@ +import json +import re +from contextlib import suppress +from datetime import datetime +from ipaddress import ip_address +from urllib.parse import urlparse + +from django.conf import settings + +from dojo.models import Endpoint, Finding +from dojo.tools.locations import LocationData + +# Mirrors severityFromString() in the YesWeHack connector's converter. An unrecognised word returns +# nothing at all so the caller can try the next source, which is why this is separate from the +# Info default. +SEVERITY_WORDS = { + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "info": "Info", + "informative": "Info", + "none": "Info", +} +DEFAULT_SEVERITY = "Info" + +# The connector's applyStatus(): YesWeHack's workflow state decides whether a report is still live, +# and which closed state it landed in. Anything unrecognised stays active. +STATUS_NEW = "new" +STATUS_UNDER_REVIEW = "under_review" +STATUS_ACCEPTED = "accepted" +STATUS_RESOLVED = "resolved" +STATUS_AUTO_CLOSE = "auto_close" +STATUS_WONT_FIX = "wont_fix" +STATUS_INVALID = "invalid" +STATUS_REJECTED = "rejected" +STATUS_DUPLICATE = "duplicate" +STATUS_OUT_OF_SCOPE = "out_of_scope" +STATUS_INFORMATIVE = "informative" + +# The shared formatter the connector builds descriptions with: "* **Prefix** text\n" bullets and +# "### Title\n\n" headings. +BULLET = "* **{}** {}\n" +HEADING = "### {}\n\n" + +CVE_PATTERN = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE) + +# The timestamp layouts the connector tries, in order. +DATE_FORMATS = ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d") + + +# The host DefectDojo accepts: letters, digits, dot, hyphen, underscore or plus, at least two +# characters - or an IP address. See Endpoint.clean(). +HOST_PATTERN = re.compile(r"^[A-Za-z0-9_\-+][A-Za-z0-9_.\-+]+$") + + +class YesWeHackParser: + + """ + Parses a YesWeHack reports export. + + Mirrors pkg/tools/yeswehack/connector/converter.go field for field so a file import and an API + sync deduplicate against each other instead of producing two copies of everything. + + YesWeHack's workflow state carries real triage information - resolved, won't fix, invalid, + duplicate - and the connector translates each into the matching DefectDojo state rather than + importing everything as active. + """ + + def get_scan_types(self): + # Byte-identical to ScanTypeName in the connector. + return ["YesWeHack - Connectors Import"] + + def get_label_for_scan_types(self, scan_type): + return "YesWeHack - Connectors Import" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a YesWeHack reports export (JSON). Matches the scan type used by the YesWeHack " + "connector so file and API findings deduplicate. The report's workflow state is " + "translated into the corresponding DefectDojo state." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the YesWeHack Parser. + + Mirrors the connector's ToFinding: + - title: the report title, then its local id, then "YesWeHack report <id>". + - severity: the CVSS criticity, then the priority name, then the priority slug, then Info. + - description: the local id, bug type and category, scope and endpoint, then the report + description and impact as headed sections. + - cvssv3 / cvssv3_score: the reported vector, and the score when above zero. + - unique_id_from_tool: the numeric report id. + - vuln_id_from_tool: the local id, falling back to the numeric id. + - active / verified / is_mitigated / risk_accepted / false_p / duplicate: from the workflow + state; see apply_status(). + """ + return [ + "title", + "severity", + "description", + "date", + "cvssv3", + "cvssv3_score", + "unique_id_from_tool", + "vuln_id_from_tool", + "tags", + "active", + "verified", + "is_mitigated", + "risk_accepted", + "false_p", + "duplicate", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the YesWeHack Parser. + + Copied from the YesWeHack block in the Pro connector settings: report ids are globally unique + on the platform, so the plain hash_code algorithm hashes the unique id alone. + """ + return ["unique_id_from_tool"] + + def get_findings(self, filename, test): + data = json.load(filename) + reports = self.extract_reports(data) + + findings = {} + for report in reports: + if not isinstance(report, dict): + continue + finding = self.build_finding(report, test) + findings.setdefault(finding.unique_id_from_tool, finding) + return list(findings.values()) + + def extract_reports(self, data): + """YesWeHack's list endpoint pages the reports under "items".""" + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("items", "reports"): + if isinstance(data.get(key), list): + return data[key] + msg = ( + "A YesWeHack export is a JSON object with an 'items' list of reports, or a bare array " + f"of reports; got {type(data).__name__}." + ) + raise TypeError(msg) + + def build_finding(self, report, test): + cvss = report.get("cvss") if isinstance(report.get("cvss"), dict) else {} + score = cvss.get("score") or 0 + + finding = Finding( + test=test, + title=self.title(report), + severity=self.severity(report, cvss), + description=self.describe(report), + date=self.date(report.get("created_at")), + cvssv3=cvss.get("vector") or None, + unique_id_from_tool=str(report.get("id")), + vuln_id_from_tool=self.vuln_id(report), + # A bug-bounty report is a human testing a running target. + static_finding=False, + dynamic_finding=True, + ) + finding.unsaved_tags = ["yeswehack"] + if score > 0: + finding.cvssv3_score = score + + cves = self.cves(report) + if cves: + finding.unsaved_vulnerability_ids = cves + + status = report.get("status") if isinstance(report.get("status"), dict) else {} + self.apply_status(finding, status.get("workflow_state")) + self.attach_endpoint(finding, report) + return finding + + def title(self, report): + if report.get("title"): + return report["title"] + if report.get("local_id"): + return report["local_id"] + return f"YesWeHack report {report.get('id')}" + + def vuln_id(self, report): + return report.get("local_id") or str(report.get("id")) + + def severity(self, report, cvss): + """ + Resolve severity from the first source that yields a recognised word. + + The CVSS criticity is preferred, then the priority's name, then its slug. Falling straight to + Info when the criticity is unset would lose the priority YesWeHack did set. + """ + for candidate in (cvss.get("criticity"),): + word = SEVERITY_WORDS.get((candidate or "").strip().lower()) + if word: + return word + priority = report.get("priority") if isinstance(report.get("priority"), dict) else {} + for candidate in (priority.get("name"), priority.get("slug")): + word = SEVERITY_WORDS.get((candidate or "").strip().lower()) + if word: + return word + return DEFAULT_SEVERITY + + def describe(self, report): + parts = [] + if report.get("local_id"): + parts.append(BULLET.format("Report:", report["local_id"])) + + bug_type = report.get("bug_type") if isinstance(report.get("bug_type"), dict) else None + if bug_type: + if bug_type.get("name"): + parts.append(BULLET.format("Bug type:", bug_type["name"])) + category = bug_type.get("category") + if isinstance(category, dict) and category.get("name"): + parts.append(BULLET.format("Category:", category["name"])) + + if report.get("scope"): + parts.append(BULLET.format("Scope:", report["scope"])) + if report.get("end_point"): + parts.append(BULLET.format("Endpoint:", report["end_point"])) + + for heading, key in (("Description", "description_html"), ("Impact", "impact")): + if report.get(key): + parts.extend(("\n", HEADING.format(heading), report[key], "\n")) + return "".join(parts) + + def apply_status(self, finding, workflow_state): + """ + Translate YesWeHack's workflow state into DefectDojo state. + + Importing every report as active would put resolved, rejected and duplicate reports back in + front of the team; the connector maps each one instead. An unrecognised state stays active, + which is the safe side of the assumption. + """ + state = (workflow_state or "").strip().lower() + finding.active = True + if state == STATUS_ACCEPTED: + finding.verified = True + elif state in {STATUS_RESOLVED, STATUS_AUTO_CLOSE}: + finding.active = False + finding.is_mitigated = True + elif state == STATUS_WONT_FIX: + finding.active = False + finding.risk_accepted = True + elif state in {STATUS_INVALID, STATUS_REJECTED}: + finding.active = False + finding.false_p = True + elif state == STATUS_DUPLICATE: + finding.active = False + finding.duplicate = True + elif state in {STATUS_OUT_OF_SCOPE, STATUS_INFORMATIVE}: + finding.active = False + + def attach_endpoint(self, finding, report): + """ + The reported endpoint, falling back to the programme scope. + + Parsed rather than string-split: a researcher writes whatever the scope allows, so the + value may carry a scheme, a port and a path, and an unparsed "host:port" in the host field + fails validation for the whole import. + """ + location = (report.get("end_point") or "").strip() or (report.get("scope") or "").strip() + if not location: + return + parsed = urlparse(location if "//" in location else f"//{location}") + try: + port = parsed.port + except ValueError: + return + host = parsed.hostname or "" + if not host or not self.usable_host(host): + return + if settings.V3_FEATURE_LOCATIONS: + finding.unsaved_locations.append(LocationData.url( + host=host, protocol=parsed.scheme or None, port=port, + )) + else: + # TODO: Delete this after the move to Locations + finding.unsaved_endpoints.append(Endpoint( + host=host, protocol=parsed.scheme or None, port=port, + )) + + def usable_host(self, value): + """ + Whether DefectDojo will accept this as an endpoint host. + + A host is letters, digits, dot, hyphen, underscore or plus, or an IP address. Anything else - + a path, a space, a container image tag - makes Endpoint.clean() raise, and that fails the + whole import rather than the one finding, so it is dropped here instead. The value is still + reported in the description, so nothing is lost. + """ + if HOST_PATTERN.match(value): + return True + with suppress(ValueError): + ip_address(value) + return True + return False + + def cves(self, report): + """The connector scans the title, description, impact and technical information for CVEs.""" + sources = ( + report.get("title") or "", + report.get("description_html") or "", + report.get("impact") or "", + report.get("technical_information_html") or "", + ) + found, seen = [], set() + for source in sources: + for cve in CVE_PATTERN.findall(str(source)): + upper = cve.upper() + if upper not in seen: + seen.add(upper) + found.append(upper) + return found + + def date(self, timestamp): + """The connector tries several layouts before giving up, so a non-RFC3339 stamp still dates.""" + trimmed = (timestamp or "").strip() + if not trimmed: + return None + with suppress(ValueError): + return datetime.fromisoformat(trimmed).date() + for fmt in DATE_FORMATS: + with suppress(ValueError): + return datetime.strptime(trimmed, fmt).date() + return None diff --git a/dojo/tools/zimperium/__init__.py b/dojo/tools/zimperium/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/zimperium/parser.py b/dojo/tools/zimperium/parser.py new file mode 100644 index 00000000000..b58465351d1 --- /dev/null +++ b/dojo/tools/zimperium/parser.py @@ -0,0 +1,166 @@ +from contextlib import suppress +from datetime import date as _date + +from dojo.tools.hiddenlayer.parser import SarifConnectorFindings + + +class ZimperiumParser(SarifConnectorFindings): + + """ + Parses a Zimperium zScan (MAPS) assessment SARIF report. + + Mirrors pkg/tools/zimperium/connector field for field so a file import and an API sync deduplicate + against each other instead of producing two copies of everything. + + The SARIF mapping is shared: on the Go side the Zimperium connector calls pkg/utils/sarif, the same + utility other SARIF-reporting connectors use, parameterised by an identity prefix and a + static/dynamic flag. This parser therefore extends the shared mixin rather than restating the + mapping - the same way the shipped invicti parser extends netsparker. See + dojo/tools/hiddenlayer/parser.py. + + What is Zimperium's own is the DECORATION: a SARIF document says nothing about which app or build it + came from, so the connector adds the app name, the build version, the upload date and the platform + afterwards. Those are what a mobile finding needs to be actionable, and a file has to supply them; + see decorate(). + """ + + vendor = "Zimperium" + tool_prefix = "zimperium" + # zScan analyses an uploaded build; nothing is exercised. + is_static = True + + def __init__(self): + # The app and assessment context for the file being parsed, read once in get_findings(). + self.context = {} + + def get_scan_types(self): + # Byte-identical to the connector's ScanTypeName. Note it does NOT follow the + # "<Vendor> - Connectors Import" pattern, so it cannot be derived - it has to be copied. + return ["Zimperium zScan"] + + def get_label_for_scan_types(self, scan_type): + return "Zimperium zScan" + + def get_description_for_scan_types(self, scan_type): + return ( + "Import a Zimperium zScan assessment SARIF report, optionally with the app and build " + "context a SARIF document does not carry. Matches the scan type used by the Zimperium " + "connector so file and API findings deduplicate - give the assessment id to deduplicate " + "against connector findings." + ) + + def get_fields(self) -> list[str]: + """ + Return the list of fields used in the Zimperium Parser. + + The SARIF fields come from the shared mapping; see SarifConnectorFindings. On top of those: + - component_name / component_version: the app and its build version, from the export's context. + - date: the date the build was uploaded. + - tags: the SARIF tags, plus the app's platform. + - unique_id_from_tool: "zimperium-<assessment id>-<rule id>-<file>:<line>". + """ + return [ + "title", + "severity", + "description", + "cvssv3_score", + "cwe", + "file_path", + "line", + "component_name", + "component_version", + "mitigation", + "references", + "date", + "unique_id_from_tool", + "vuln_id_from_tool", + "unsaved_vulnerability_ids", + "tags", + "active", + "false_p", + "static_finding", + "dynamic_finding", + ] + + def get_dedupe_fields(self) -> list[str]: + """ + Return the list of fields used for deduplication in the Zimperium Parser. + + Copied from the Zimperium block in the Pro connector settings, which pairs + unique_id_from_tool_or_hash_code with these hash fields. The file path is in the hash because + one rule firing on two files in an app bundle is two findings. + """ + return ["title", "severity", "file_path", "vuln_id_from_tool"] + + def scope_id(self, data): + """ + The ASSESSMENT id, which is one scan of one build. + + A downloaded SARIF report does not carry it, so without it the identities differ from the + connector's and file findings will not deduplicate against synced ones. + """ + context = self.assessment(data) + return self.scope_from(data, ("assessment_id", "assessmentId", "id")) or self.scope_from( + context, ("assessment_id", "assessmentId", "id"), + ) + + def prepare(self, data): + """ + Read the app and build context before any finding is built. + + decorate() applies it to every finding as the shared mapping produces it, so it has to be in + place first. + """ + self.context = self.assessment(data) + + def assessment(self, data): + """ + The app and assessment context: the app's name and platform, the build version and upload date. + + A file may state them at the top level or under an "assessment"/"app" object, which is how a + saved export of the two calls looks. + """ + if not isinstance(data, dict): + return {} + context = {} + for key in ("assessment", "app", "context"): + if isinstance(data.get(key), dict): + context.update(data[key]) + for key in ("name", "appVersion", "buildNumber", "buildUploadedAt", "platform", "os", + "bundleIdentifier", "assessment_id", "assessmentId", "id"): + if key in data and not isinstance(data.get(key), dict | list): + context.setdefault(key, data[key]) + return context + + def decorate(self, finding, result): + """ + Add the app and build context the SARIF document does not carry. + + A mobile finding is only actionable once you know which app and which build it is in - two + builds of one app land in the same product, and without the version there is no telling them + apart. Each field is only filled when the SARIF mapping left it empty, as the connector does. + """ + context = self.context + if not finding.component_name: + finding.component_name = str(context.get("name") or "") or None + if not finding.component_version: + finding.component_version = str(context.get("appVersion") or "") or None + if date := self.upload_date(context): + finding.date = date + if platform := str(context.get("platform") or ""): + # Appended rather than merged, so the platform is last - the connector adds it after the + # SARIF tags for the same reason. + finding.unsaved_tags = [*(finding.unsaved_tags or []), platform] + + def upload_date(self, context): + """ + The date the build was uploaded. + + The connector takes the first ten characters because it hands the API a string; this reads the + same ten as a date, and skips one that is not a date rather than failing the import. + """ + value = str(context.get("buildUploadedAt") or "").strip() + if len(value) >= 10: + with suppress(ValueError): + return _date.fromisoformat(value[:10]) + return None diff --git a/unittests/scans/accuknox/accuknox_many_vuln.json b/unittests/scans/accuknox/accuknox_many_vuln.json new file mode 100644 index 00000000000..dfe89f38221 --- /dev/null +++ b/unittests/scans/accuknox/accuknox_many_vuln.json @@ -0,0 +1,83 @@ +{ + "count": 7, + "next": null, + "results": [ + { + "finding_id": "af-0000000001", + "name": "CVE-2000-0001 in openssl", + "data_type": "container_image", + "risk_factor": "Critical", + "status": "active", + "description": "The image ships a vulnerable openssl.", + "solution": "Rebuild the image on a patched base.", + "asset_type": "image", + "asset_name": "registry.example.com/generic-api:1.4.0", + "cve": "CVE-2000-0001", + "package_name": "openssl", + "package_version": "3.0.11-1", + "date_discovered": "2026-07-20T09:00:00+0000", + "last_seen": "2026-07-31T09:00:00+0000", + "ignored": false + }, + { + "id": "af-0000000002", + "title": "S3 bucket is publicly readable", + "data_type": "cloud_posture", + "severity": "High", + "finding_status": "in progress", + "details": "The bucket policy grants read to everyone.", + "remediation": "Remove the public grant.", + "resource_type": "s3_bucket", + "resource_name": "generic-app-assets", + "first_seen": "2026-07-21 10:00:00", + "present_on_date": "2026-07-31 10:00:00" + }, + { + "uuid": "af-0000000003", + "vulnerability__name": "CVE-2000-0002 in curl", + "vulnerability__risk_factor": "medium", + "vulnerability__status": "fixed", + "vulnerability__description": "A flaw in URL parsing.", + "vulnerability__cve_id": "CVE-2000-0002", + "vulnerability__package_name": "curl", + "vulnerability__installed_version": "7.81.0-1", + "data_type": "container_image", + "asset_name": "registry.example.com/generic-worker:2.0.0", + "date_discovered": "2026-07-22" + }, + { + "finding_id": "af-0000000004", + "name": "Terraform security group allows 0.0.0.0/0", + "data_type": "iac", + "risk_factor": "high", + "status": "accepted risk", + "description": "Ingress is open to the internet.", + "asset_type": "terraform", + "asset_name": "infra/main.tf" + }, + { + "finding_id": "af-0000000005", + "name": "Duplicate of an earlier finding", + "data_type": "iac", + "risk_factor": "low", + "status": "duplicate", + "description": "Already reported." + }, + { + "finding_id": "af-0000000006", + "name": "Possible misconfiguration", + "data_type": "cloud_posture", + "risk_factor": "not-a-risk-factor", + "status": "potential", + "description": "Needs confirmation.", + "ignored": "true" + }, + { + "finding_id": "af-0000000007", + "data_type": "runtime", + "risk_factor": "", + "status": "", + "cve_ids": ["CVE-2000-0003", "CVE-2000-0004", "CVE-2000-0003"] + } + ] +} diff --git a/unittests/scans/accuknox/accuknox_no_vuln.json b/unittests/scans/accuknox/accuknox_no_vuln.json new file mode 100644 index 00000000000..310cd97fb82 --- /dev/null +++ b/unittests/scans/accuknox/accuknox_no_vuln.json @@ -0,0 +1,5 @@ +{ + "count": 0, + "next": null, + "results": [] +} diff --git a/unittests/scans/accuknox/accuknox_one_vuln.json b/unittests/scans/accuknox/accuknox_one_vuln.json new file mode 100644 index 00000000000..199241136c4 --- /dev/null +++ b/unittests/scans/accuknox/accuknox_one_vuln.json @@ -0,0 +1,23 @@ +{ + "count": 1, + "next": null, + "results": [ + { + "finding_id": "af-0000000001", + "name": "CVE-2000-0001 in openssl", + "data_type": "container_image", + "risk_factor": "Critical", + "status": "active", + "description": "The image ships a vulnerable openssl.", + "solution": "Rebuild the image on a patched base.", + "asset_type": "image", + "asset_name": "registry.example.com/generic-api:1.4.0", + "cve": "CVE-2000-0001", + "package_name": "openssl", + "package_version": "3.0.11-1", + "date_discovered": "2026-07-20T09:00:00+0000", + "last_seen": "2026-07-31T09:00:00+0000", + "ignored": false + } + ] +} diff --git a/unittests/scans/action1/action1_many_vuln.json b/unittests/scans/action1/action1_many_vuln.json new file mode 100644 index 00000000000..548a1fee9ad --- /dev/null +++ b/unittests/scans/action1/action1_many_vuln.json @@ -0,0 +1,85 @@ +{ + "items": [ + { + "cve_id": "CVE-2000-0001", + "name": "Remote code execution in Example Browser", + "cvss_score": 9.8, + "base_severity": "Critical", + "score": "Critical", + "remediation_status": "Overdue", + "software": [ + { + "product_name": "Example Browser", + "versions": [{"version": "120.0.1"}], + "available_updates": [{"package_id": "pkg-1", "name": "Example Browser", "version": "120.0.2"}] + } + ] + }, + { + "cve_id": "CVE-2000-0002", + "name": "Information disclosure in Example Reader", + "cvss_score": "6.5", + "base_severity": "", + "score": "Medium", + "remediation_status": "Control_applied", + "software": [ + {"product_name": "Example Reader", "versions": [{"version": "9.1"}], "available_updates": []} + ] + }, + { + "cve_id": "CVE-2000-0003", + "name": "", + "cvss_score": 0, + "base_severity": "Not graded", + "remediation_status": "" + }, + { + "cve_id": "CVE-2000-0004", + "name": "A vulnerability nothing is affected by", + "cvss_score": 7.1, + "base_severity": "High" + } + ], + "endpoints": { + "CVE-2000-0001": [ + { + "endpoint_id": "ep-0001", + "endpoint_name": "workstation-01", + "software": [ + { + "product_name": "Example Browser", + "versions": [{"version": "119.0.4"}], + "available_updates": [ + {"package_id": "pkg-1", "name": "Example Browser", "version": "120.0.2"}, + {"package_id": "pkg-2", "name": "Example Browser security update"} + ] + } + ] + }, + { + "endpoint_id": "ep-0002", + "endpoint_name": "workstation-02", + "software": [ + {"product_name": "Example Browser", "versions": [{"version": "120.0.1"}], "available_updates": []} + ] + }, + {"endpoint_id": "", "endpoint_name": "a row with no id"} + ], + "CVE-2000-0002": [ + {"endpoint_id": "ep-0001", "endpoint_name": "workstation-01"} + ], + "CVE-2000-0003": [ + {"endpoint_id": "ep-0003", "endpoint_name": "Reception Desk PC"} + ] + }, + "managed_endpoints": { + "items": [ + {"id": "ep-0001", "name": "workstation-01", "OS": "Windows 11 Pro 23H2", "status": "Online"}, + {"id": "ep-0002", "name": "workstation-02", "OS": "Windows 10 Pro 22H2", "status": "Offline"}, + {"id": "ep-0003", "name": "Reception Desk PC", "OS": "Windows 11 Pro 23H2", "status": "Online"} + ] + }, + "total_items": 4, + "from": 0, + "limit": 100 +} diff --git a/unittests/scans/action1/action1_no_vuln.json b/unittests/scans/action1/action1_no_vuln.json new file mode 100644 index 00000000000..a4daa0cd1d0 --- /dev/null +++ b/unittests/scans/action1/action1_no_vuln.json @@ -0,0 +1,6 @@ +{ + "items": [], + "total_items": 0, + "from": 0, + "limit": 100 +} diff --git a/unittests/scans/action1/action1_one_vuln.json b/unittests/scans/action1/action1_one_vuln.json new file mode 100644 index 00000000000..cd6d9365878 --- /dev/null +++ b/unittests/scans/action1/action1_one_vuln.json @@ -0,0 +1,35 @@ +{ + "items": [ + { + "cve_id": "CVE-2000-0001", + "name": "Remote code execution in Example Browser", + "cvss_score": 9.8, + "base_severity": "Critical", + "score": "Critical", + "remediation_status": "Overdue", + "software": [ + { + "product_name": "Example Browser", + "versions": [{"version": "120.0.1"}], + "available_updates": [{"package_id": "pkg-1", "name": "Example Browser", "version": "120.0.2"}] + } + ], + "endpoints": [ + { + "endpoint_id": "ep-0001", + "endpoint_name": "workstation-01", + "software": [ + { + "product_name": "Example Browser", + "versions": [{"version": "119.0.4"}], + "available_updates": [{"package_id": "pkg-1", "name": "Example Browser", "version": "120.0.2"}] + } + ] + } + ] + } + ], + "managed_endpoints": [ + {"id": "ep-0001", "name": "workstation-01", "OS": "Windows 11 Pro 23H2", "status": "Online"} + ] +} diff --git a/unittests/scans/akto/akto_many_vuln.json b/unittests/scans/akto/akto_many_vuln.json new file mode 100644 index 00000000000..9045ceeaee6 --- /dev/null +++ b/unittests/scans/akto/akto_many_vuln.json @@ -0,0 +1,71 @@ +{ + "issueDetails": [ + { + "apiCollectionId": 1700000001, + "apiUrl": "https://api.example.com/v1/users/{id}", + "apiMethod": "GET", + "testSubCategory": "BOLA", + "issueUrl": "https://app.example.com/dashboard/issues/1", + "testName": "Broken Object Level Authorization", + "testCategory": "BOLA", + "testDescription": "The endpoint returned another user's record.", + "testDetails": "Changing {id} to a second account's id returned that account's data.", + "testImpact": "Any authenticated user can read every user's record.", + "testRemediation": "Check that the requested object belongs to the caller.", + "severity": "CRITICAL", + "status": "OPEN", + "creationTime": 1719792000, + "lastSeen": 1721001600, + "testCwe": "CWE-639", + "testCve": "", + "testTags": ["OWASP API1:2023", "Authorization"], + "testReferences": ["https://owasp.example.com/api1"] + }, + { + "apiCollectionId": "1700000001", + "apiUrl": "/v1/reports", + "apiMethod": "POST", + "testSubCategory": "SSRF", + "testName": "Server-Side Request Forgery", + "testCategory": "SSRF", + "testDescription": "The endpoint fetched a URL supplied by the caller.", + "testRemediation": "Allow-list the hosts the service may fetch.", + "severity": "high", + "status": "OPEN", + "creationTime": 1717200000, + "testCwe": "918", + "testCve": "CVE-2000-0002 and cve-2000-0002 and CVE-2000-0001", + "testTags": ["OWASP API7:2023"] + }, + { + "apiCollectionId": 1700000002, + "apiUrl": "https://api.example.com/v1/health", + "apiMethod": "GET", + "testSubCategory": "INFO_DISCLOSURE", + "testName": "Verbose health endpoint", + "severity": "not a label", + "status": "IGNORED", + "creationTime": 0, + "testCwe": "not a cwe" + }, + { + "apiCollectionId": 1700000002, + "apiUrl": "https://api.example.com/v1/legacy", + "apiMethod": "DELETE", + "testSubCategory": "NO_AUTH", + "testName": "Unauthenticated destructive endpoint", + "severity": "HIGH", + "status": "FIXED", + "creationTime": 1717200000 + }, + { + "apiCollectionId": 1700000003, + "apiUrl": "", + "apiMethod": "", + "testSubCategory": "", + "testName": "", + "severity": "LOW", + "status": "OPEN" + } + ] +} diff --git a/unittests/scans/akto/akto_no_vuln.json b/unittests/scans/akto/akto_no_vuln.json new file mode 100644 index 00000000000..a8d725f3490 --- /dev/null +++ b/unittests/scans/akto/akto_no_vuln.json @@ -0,0 +1,3 @@ +{ + "issueDetails": [] +} diff --git a/unittests/scans/akto/akto_one_vuln.json b/unittests/scans/akto/akto_one_vuln.json new file mode 100644 index 00000000000..9fc54b7be46 --- /dev/null +++ b/unittests/scans/akto/akto_one_vuln.json @@ -0,0 +1,30 @@ +{ + "issueDetails": [ + { + "apiCollectionId": 1700000001, + "apiUrl": "https://api.example.com/v1/users/{id}", + "apiMethod": "GET", + "testSubCategory": "BOLA", + "issueUrl": "https://app.example.com/dashboard/issues/1", + "testName": "Broken Object Level Authorization", + "testCategory": "BOLA", + "testDescription": "The endpoint returned another user's record.", + "testDetails": "Changing {id} to a second account's id returned that account's data.", + "testImpact": "Any authenticated user can read every user's record.", + "testRemediation": "Check that the requested object belongs to the caller.", + "severity": "CRITICAL", + "status": "OPEN", + "creationTime": 1719792000, + "lastSeen": 1721001600, + "testCwe": "CWE-639", + "testCve": "", + "testTags": [ + "OWASP API1:2023", + "Authorization" + ], + "testReferences": [ + "https://owasp.example.com/api1" + ] + } + ] +} diff --git a/unittests/scans/automox/automox_many_vuln.json b/unittests/scans/automox/automox_many_vuln.json new file mode 100644 index 00000000000..0a58ace72e5 --- /dev/null +++ b/unittests/scans/automox/automox_many_vuln.json @@ -0,0 +1,99 @@ +{ + "devices": [ + { + "id": 1001, + "name": "generic-host-01", + "os_family": "Windows", + "os_name": "Windows Server", + "os_version": "2019", + "server_group_id": 21, + "organization_id": 5, + "connected": true + }, + { + "id": "1002", + "name": "generic-host-02", + "os_family": "Linux", + "os_name": "Ubuntu", + "os_version": "22.04", + "server_group_id": 22, + "organization_id": 5, + "connected": true + } + ], + "packages": [ + { + "id": 900001, + "server_id": 1001, + "installed": false, + "name": "example-runtime", + "display_name": "Example Runtime", + "version": "4.8.1", + "repo": "Vendor Update Catalog", + "cves": ["CVE-2000-0001"], + "cve_score": 8.8, + "severity": "CRITICAL", + "requires_reboot": true, + "create_time": "2024-05-13T18:02:45+0000" + }, + { + "id": "900002", + "server_id": "1002", + "installed": false, + "name": "example-library", + "display_name": "", + "version": "1.2.3", + "repo": "distro-updates", + "cves": [], + "cve_score": "5.4", + "severity": "medium", + "requires_reboot": false, + "create_time": "2024-05-14T09:15:00Z" + }, + { + "id": 900003, + "server_id": 9999, + "installed": true, + "name": "example-agent", + "display_name": "Example Agent", + "version": "", + "repo": "", + "cves": ["CVE-2000-0003", " ", "CVE-2000-0004"], + "cve_score": "not a number", + "severity": "no_known_cves", + "requires_reboot": false, + "create_time": "not a timestamp" + }, + { + "id": 900004, + "server_id": 1001, + "installed": false, + "name": "", + "display_name": "", + "version": "9.0", + "repo": "Vendor Update Catalog", + "cve_score": 0, + "severity": "", + "requires_reboot": false, + "create_time": "2024-06-01T00:00:00+02:00" + }, + { + "id": 0, + "server_id": 1001, + "installed": false, + "name": "example-dropped", + "display_name": "Dropped, no usable id", + "version": "1.0", + "severity": "low" + }, + { + "id": "not numeric", + "server_id": 1001, + "installed": false, + "name": "example-dropped-two", + "display_name": "Dropped, id is not a number", + "version": "1.0", + "severity": "low" + } + ] +} diff --git a/unittests/scans/automox/automox_no_vuln.json b/unittests/scans/automox/automox_no_vuln.json new file mode 100644 index 00000000000..41663986c07 --- /dev/null +++ b/unittests/scans/automox/automox_no_vuln.json @@ -0,0 +1,16 @@ +{ + "devices": [ + { + "id": 1001, + "name": "generic-host-01", + "os_family": "Windows", + "os_name": "Windows Server", + "os_version": "2019", + "server_group_id": 21, + "organization_id": 5, + "connected": true, + "tags": [] + } + ], + "packages": [] +} diff --git a/unittests/scans/automox/automox_one_vuln.json b/unittests/scans/automox/automox_one_vuln.json new file mode 100644 index 00000000000..01eb56f2103 --- /dev/null +++ b/unittests/scans/automox/automox_one_vuln.json @@ -0,0 +1,35 @@ +{ + "devices": [ + { + "id": 1001, + "name": "generic-host-01", + "custom_name": "", + "os_family": "Windows", + "os_name": "Windows Server", + "os_version": "2019", + "server_group_id": 21, + "organization_id": 5, + "connected": true, + "tags": ["production"] + } + ], + "packages": [ + { + "id": 900001, + "server_id": 1001, + "installed": false, + "name": "example-runtime", + "display_name": "Example Runtime", + "version": "4.8.1", + "repo": "Vendor Update Catalog", + "cves": ["CVE-2000-0001", "CVE-2000-0002"], + "cve_score": 8.8, + "severity": "high", + "requires_reboot": true, + "os_name": "Windows Server", + "os_version": "2019", + "organization_id": 5, + "create_time": "2024-05-13T18:02:45+0000" + } + ] +} diff --git a/unittests/scans/automox/automox_packages_only.json b/unittests/scans/automox/automox_packages_only.json new file mode 100644 index 00000000000..739a3bb3025 --- /dev/null +++ b/unittests/scans/automox/automox_packages_only.json @@ -0,0 +1,16 @@ +[ + { + "id": 900010, + "server_id": 1001, + "installed": false, + "name": "example-runtime", + "display_name": "Example Runtime", + "version": "4.8.1", + "repo": "Vendor Update Catalog", + "cves": ["CVE-2000-0001"], + "cve_score": 8.8, + "severity": "high", + "requires_reboot": true, + "create_time": "2024-05-13T18:02:45+0000" + } +] diff --git a/unittests/scans/beagle/beagle_alias_keys.json b/unittests/scans/beagle/beagle_alias_keys.json new file mode 100644 index 00000000000..02a2194faae --- /dev/null +++ b/unittests/scans/beagle/beagle_alias_keys.json @@ -0,0 +1,28 @@ +{ + "project_name": "Default project", + "application_name": "generic-app", + "url": "https://app.example.com/portal", + "generated_date": "not a date", + "approved_date": "12 Jan 2022", + "scan_items": [ + { + "vulnerability_name": "Session cookie missing the Secure attribute", + "risk_level": "LOW", + "vulnerability_description": "A session cookie is set without the Secure attribute.", + "recommendations": "Set the Secure attribute on every session cookie.", + "cweid": 614, + "occurences": [ + { + "status": "open", + "vulnerability": {"Method": "GET", "Url": "http://app.example.com/portal/signin"} + } + ] + }, + { + "signature": "Directory listing enabled", + "risk_score": 5.3, + "detail": "The web server returns an index of the directory contents.", + "mitigation": "Disable automatic directory indexes." + } + ] +} diff --git a/unittests/scans/beagle/beagle_many_vuln.json b/unittests/scans/beagle/beagle_many_vuln.json new file mode 100644 index 00000000000..3a46eda1d4d --- /dev/null +++ b/unittests/scans/beagle/beagle_many_vuln.json @@ -0,0 +1,67 @@ +{ + "project_name": "Default project", + "application_name": "generic-app", + "url": "https://app.example.com", + "generated_date": "04 Sep 2021", + "approved_date": "05 Sep 2021", + "vulnerabilities": [ + { + "name": "Information disclosure in HTTP headers", + "severity": "Medium", + "cwe": "CWE-215", + "cvss": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", + "description": "The server discloses version information in its response headers.", + "solution": "* Remove headers that disclose information.\n* Remove any unnecessary comments.", + "occurences": [ + { + "status": "Open", + "vulnerability": {"Method": "get", "Url": "https://app.example.com/status.asp"} + }, + { + "status": "Fixed", + "vulnerability": {"Method": "post", "Url": "https://app.example.com/login"} + } + ] + }, + { + "title": "Reflected cross-site scripting", + "risk": 8.2, + "cwe_id": 79, + "details": "A request parameter is reflected into the response without encoding.", + "remediation": "Encode user input before rendering it.", + "occurrences": [ + { + "status": "Open", + "vulnerability": {"Method": "get", "Url": "https://app.example.com/search?q=1"} + } + ] + }, + { + "name": "Missing security headers", + "severity": "Somewhat risky", + "cwes": ["CWE-693", "CWE-1021"], + "summary": "Several recommended response headers were absent.", + "occurences": [] + }, + { + "signature_name": "Outdated TLS protocol offered", + "priority": "critical", + "cvss_score": "9.4", + "impact": "The host still negotiates a deprecated protocol version.", + "fix": "Disable the deprecated protocol version.", + "instances": [ + { + "status": "Reopened", + "vulnerability": {"Method": "", "Url": "https://api.example.com:8443/v1/health"} + } + ] + }, + { + "cwe": "215, 216", + "score": 2.4, + "occurences": [ + {"status": "Open", "vulnerability": {"Method": "head", "Url": ""}} + ] + } + ] +} diff --git a/unittests/scans/beagle/beagle_no_vuln.json b/unittests/scans/beagle/beagle_no_vuln.json new file mode 100644 index 00000000000..cae9fd4f443 --- /dev/null +++ b/unittests/scans/beagle/beagle_no_vuln.json @@ -0,0 +1,8 @@ +{ + "project_name": "Default project", + "application_name": "generic-app", + "url": "https://app.example.com", + "generated_date": "04 Sep 2021", + "approved_date": "04 Sep 2021", + "vulnerabilities": [] +} diff --git a/unittests/scans/beagle/beagle_one_vuln.json b/unittests/scans/beagle/beagle_one_vuln.json new file mode 100644 index 00000000000..34b1a7500d5 --- /dev/null +++ b/unittests/scans/beagle/beagle_one_vuln.json @@ -0,0 +1,6 @@ +{ + "code": "0", + "message": "success", + "applicationToken": "example-application-token", + "result": "{\"project_name\": \"Default project\", \"application_name\": \"generic-app\", \"url\": \"https://app.example.com\", \"generated_date\": \"04 Sep 2021\", \"approved_date\": \"04 Sep 2021\", \"vulnerabilities\": [{\"name\": \"SQL injection\", \"severity\": \"Critical\", \"cwe\": \"CWE-89\", \"cvss_score\": 9.8, \"cvss_vector\": \"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H\", \"description\": \"A request parameter is concatenated into a database query.\", \"solution\": \"Use parameterised queries.\", \"occurences\": [{\"status\": \"Open\", \"vulnerability\": {\"Method\": \"post\", \"Url\": \"https://app.example.com/api/report\"}}]}]}" +} diff --git a/unittests/scans/bigid/bigid_many_vuln.json b/unittests/scans/bigid/bigid_many_vuln.json new file mode 100644 index 00000000000..390854100b9 --- /dev/null +++ b/unittests/scans/bigid/bigid_many_vuln.json @@ -0,0 +1,61 @@ +{ + "cases": [ + { + "caseId": "case-0001", + "caseLabel": "Unprotected personal data in a reporting database", + "policyName": "Personal data outside an approved store", + "policyDescription": "Personal data must only live in an approved, access-controlled store.", + "severityLevel": "critical", + "caseStatus": "open", + "dataSourceName": "reporting-db", + "dataSourceType": "PostgreSQL", + "sensitivityGroup": "Restricted", + "numberOfAffectedObjects": 412, + "remediationSteps": "Move the data to an approved store and revoke the broad read grant.", + "assignee": "data-owner", + "created_at": "2024-05-10T08:00:00Z", + "updated_at": "2024-06-02T14:30:00Z" + }, + { + "caseId": "case-0002", + "caseLabel": "", + "policyName": "Payment data in an object store", + "severityLevel": "HIGH", + "caseStatus": "remediated", + "dataSourceName": "generic-bucket", + "dataSourceType": "S3", + "sensitivityGroup": "Confidential", + "numberOfAffectedObjects": "37", + "remediationSteps": "", + "created_at": "2024-04-01T00:00:00Z" + }, + { + "caseId": "case-0003", + "policyName": "", + "severityLevel": "not a level", + "caseStatus": "RESOLVED", + "dataSourceName": "", + "numberOfAffectedObjects": 0, + "created_at": "2024" + }, + { + "caseId": "case-0004", + "caseLabel": "A case in an unfamiliar state", + "policyName": "Personal data in a log store", + "severityLevel": "low", + "caseStatus": "in review", + "dataSourceName": "log-store", + "dataSourceType": "Elasticsearch", + "numberOfAffectedObjects": -5, + "updated_at": "not a date at all" + }, + { + "caseId": "", + "caseLabel": "Dropped, no case id", + "severityLevel": "critical", + "caseStatus": "open", + "dataSourceName": "generic-source" + } + ], + "totalCount": 5 +} diff --git a/unittests/scans/bigid/bigid_no_vuln.json b/unittests/scans/bigid/bigid_no_vuln.json new file mode 100644 index 00000000000..c39893a4ca6 --- /dev/null +++ b/unittests/scans/bigid/bigid_no_vuln.json @@ -0,0 +1,6 @@ +{ + "data": { + "cases": [], + "totalCount": 0 + } +} diff --git a/unittests/scans/bigid/bigid_one_vuln.json b/unittests/scans/bigid/bigid_one_vuln.json new file mode 100644 index 00000000000..456d9ab919a --- /dev/null +++ b/unittests/scans/bigid/bigid_one_vuln.json @@ -0,0 +1,23 @@ +{ + "data": { + "cases": [ + { + "caseId": "case-0001", + "caseLabel": "Unprotected personal data in a reporting database", + "policyName": "Personal data outside an approved store", + "policyDescription": "Personal data must only live in an approved, access-controlled store.", + "severityLevel": "critical", + "caseStatus": "open", + "dataSourceName": "reporting-db", + "dataSourceType": "PostgreSQL", + "sensitivityGroup": "Restricted", + "numberOfAffectedObjects": 412, + "remediationSteps": "Move the data to an approved store and revoke the broad read grant.", + "assignee": "data-owner", + "created_at": "2024-05-10T08:00:00Z", + "updated_at": "2024-06-02T14:30:00Z" + } + ], + "totalCount": 1 + } +} diff --git a/unittests/scans/bigid/bigid_with_sample_values.json b/unittests/scans/bigid/bigid_with_sample_values.json new file mode 100644 index 00000000000..05850b0be2c --- /dev/null +++ b/unittests/scans/bigid/bigid_with_sample_values.json @@ -0,0 +1,22 @@ +[ + { + "caseId": "case-0010", + "caseLabel": "Personal data in an unapproved store", + "policyName": "Personal data outside an approved store", + "severityLevel": "high", + "caseStatus": "open", + "dataSourceName": "generic-store", + "dataSourceType": "MySQL", + "sensitivityGroup": "Restricted", + "numberOfAffectedObjects": 9, + "remediationSteps": "Move the data to an approved store.", + "created_at": "2024-05-01T00:00:00Z", + "sampleValues": ["placeholder-not-real-data-1", "placeholder-not-real-data-2"], + "preview": "placeholder-not-real-data-3", + "matchedValues": ["placeholder-not-real-data-4"], + "objectDetails": { + "objectName": "placeholder-not-real-data-5", + "columnValues": ["placeholder-not-real-data-6"] + } + } +] diff --git a/unittests/scans/bright/bright_many_vuln.json b/unittests/scans/bright/bright_many_vuln.json new file mode 100644 index 00000000000..1dc9ae0ced7 --- /dev/null +++ b/unittests/scans/bright/bright_many_vuln.json @@ -0,0 +1,48 @@ +[ + { + "id": "issue-0001", + "name": "Reflected cross-site scripting", + "severity": "High", + "cvss": 6.1, + "cwe": "CWE-79", + "details": "A request parameter is reflected into the response without encoding.", + "remediation": "Encode user input before rendering it.", + "resources": ["https://app.example.com/search?q=1", "https://app.example.com/find?q=1"], + "request": "GET /search?q=%3Cscript%3E HTTP/1.1\nHost: app.example.com", + "response": "HTTP/1.1 200 OK\n\n<html><script>", + "entryPoint": "https://app.example.com/search?q=1", + "protocol": "http", + "time": "2024-07-01T12:00:00.000Z" + }, + { + "id": "issue-0002", + "name": "Missing security header", + "severity": "low", + "cvss": "3.1", + "cwe": "693", + "details": "The response omitted a recommended header.", + "resources": ["https://app.example.com/", "https://api.example.com:8443/v1/health"], + "protocol": "https" + }, + { + "id": "issue-0003", + "name": "", + "severity": "not a label", + "cvss": 0, + "cwe": "not a cwe", + "details": "", + "resources": [], + "entryPoint": "an internal service" + }, + { + "id": "issue-0004", + "name": "SQL injection", + "severity": "Critical", + "cvss": 9.8, + "cwe": "CWE-89", + "details": "A parameter reaches a database query.", + "remediation": "Use parameterised queries.", + "resources": ["https://api.example.com/v1/reports"], + "entryPoint": "" + } +] diff --git a/unittests/scans/bright/bright_no_vuln.json b/unittests/scans/bright/bright_no_vuln.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/unittests/scans/bright/bright_no_vuln.json @@ -0,0 +1 @@ +[] diff --git a/unittests/scans/bright/bright_one_vuln.json b/unittests/scans/bright/bright_one_vuln.json new file mode 100644 index 00000000000..605542d8a20 --- /dev/null +++ b/unittests/scans/bright/bright_one_vuln.json @@ -0,0 +1,26 @@ +{ + "scan": { + "id": "scan-0001", + "name": "Nightly scan of generic-app", + "status": "done", + "projectId": "project-0001", + "projectName": "generic-app", + "issues": [ + { + "id": "issue-0001", + "name": "Reflected cross-site scripting", + "severity": "High", + "cvss": 6.1, + "cwe": "CWE-79", + "details": "A request parameter is reflected into the response without encoding.", + "remediation": "Encode user input before rendering it.", + "resources": ["https://app.example.com/search?q=1"], + "request": "GET /search?q=%3Cscript%3E HTTP/1.1\nHost: app.example.com", + "response": "HTTP/1.1 200 OK\n\n<html><script>", + "entryPoint": "https://app.example.com/search?q=1", + "protocol": "http", + "time": "2024-07-01T12:00:00.000Z" + } + ] + } +} diff --git a/unittests/scans/calicocloud/calicocloud_keyed_vulnerabilities.json b/unittests/scans/calicocloud/calicocloud_keyed_vulnerabilities.json new file mode 100644 index 00000000000..f1dc28ff8a6 --- /dev/null +++ b/unittests/scans/calicocloud/calicocloud_keyed_vulnerabilities.json @@ -0,0 +1,45 @@ +{ + "images": [ + { + "imageID": "img-0001", + "registry": "registry.example.com", + "repository": "generic-app", + "tag": "1.4.0", + "scan_result": "Fail", + "result": "Fail", + "resultTime": "2024-06-02T10:05:00Z" + }, + { + "imageID": "img-0002", + "registry": "registry.example.com", + "repository": "generic-worker", + "tag": "2.0.0", + "scan_result": "Fail", + "result": "Fail", + "resultTime": "2024-06-02T10:06:00Z" + } + ], + "vulnerabilities": { + "img-0001": [ + { + "id": "CVE-2000-0001", + "name": "Heap overflow in the example compression library", + "severity": "high", + "cvss3Score": 9.1, + "package_name": "example-compress", + "version": "1.2.3", + "fixVersions": ["1.2.4"] + } + ], + "img-0002": [ + { + "id": "CVE-2000-0006", + "name": "Denial of service in the example parser", + "severity": "medium", + "cvss3Score": 5.3, + "package_name": "example-parse", + "version": "2.1.0" + } + ] + } +} diff --git a/unittests/scans/calicocloud/calicocloud_many_vuln.json b/unittests/scans/calicocloud/calicocloud_many_vuln.json new file mode 100644 index 00000000000..320e61527a8 --- /dev/null +++ b/unittests/scans/calicocloud/calicocloud_many_vuln.json @@ -0,0 +1,99 @@ +{ + "images": [ + { + "imageID": "img-0001", + "registry": "registry.example.com/", + "repository": "generic-app", + "tag": "1.4.0", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "scan_result": "Fail", + "result": "Fail", + "resultTime": "2024-06-02T10:05:00Z", + "vulnerabilities": [ + { + "id": "CVE-2000-0001", + "name": "Heap overflow in the example compression library", + "severity": "low", + "description": "A crafted archive can overflow a heap buffer during decompression.", + "cvss3Score": 9.1, + "package_name": "example-compress", + "version": "1.2.3", + "fixVersions": ["1.2.4", "1.3.0"], + "url": "https://example.com/advisories/CVE-2000-0001" + }, + { + "id": "CVE-2000-0002", + "name": "CVE-2000-0002", + "severity": "critical", + "cvss": {"base_score": "7.5"}, + "package": "example-tls", + "version": "3.0.1", + "fix": "3.0.2" + }, + { + "id": "CALICO-2024-0003", + "name": "A Calico advisory with no score at all", + "severity": "medium", + "package_name": "example-utils", + "version": "0.9.0" + }, + { + "id": "CVE-2000-0004", + "name": "A negligible finding", + "severity": "negligible", + "cvss3Score": 0, + "package_name": "example-docs", + "version": "2.0.0" + } + ] + }, + { + "imageID": "img-0002", + "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "scan_result": "Unknown", + "result": "Unknown", + "scanned": "2024-06-03T08:00:00Z", + "vulnerabilities": [ + { + "id": "CVE-2000-0099", + "name": "Never imported: the image scan is still being processed", + "severity": "critical", + "cvss3Score": 9.8, + "package_name": "example-pending", + "version": "1.0.0" + } + ] + }, + { + "imageID": "img-0003", + "digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", + "scan_result": "Fail", + "result": "Fail", + "scanned": "2024-06-04T08:00:00Z", + "vulnerabilities": [ + { + "id": "", + "name": "", + "severity": "high", + "package_name": "example-nameless", + "version": "1.0.0" + } + ] + }, + { + "imageID": "img-0004", + "scan_result": "Fail", + "result": "Fail", + "vulnerabilities": [ + { + "id": "CVE-2000-0005", + "name": "An image known only by its id", + "severity": "medium", + "cvss3Score": 5.4, + "package_name": "example-lib", + "version": "4.0.0" + } + ] + } + ] +} diff --git a/unittests/scans/calicocloud/calicocloud_no_vuln.json b/unittests/scans/calicocloud/calicocloud_no_vuln.json new file mode 100644 index 00000000000..ae179323f05 --- /dev/null +++ b/unittests/scans/calicocloud/calicocloud_no_vuln.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "imageID": "img-0001", + "registry": "registry.example.com", + "repository": "generic-app", + "tag": "1.4.0", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "scan_result": "Pass", + "result": "Pass", + "scanned": "2024-06-01T10:00:00Z", + "resultTime": "2024-06-01T10:05:00Z", + "vulnerabilities": [] + } + ] +} diff --git a/unittests/scans/calicocloud/calicocloud_one_vuln.json b/unittests/scans/calicocloud/calicocloud_one_vuln.json new file mode 100644 index 00000000000..b2bcbe9bcd6 --- /dev/null +++ b/unittests/scans/calicocloud/calicocloud_one_vuln.json @@ -0,0 +1,30 @@ +{ + "images": [ + { + "imageID": "img-0001", + "registry": "registry.example.com", + "repository": "generic-app", + "tag": "1.4.0", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "scan_result": "Fail", + "result": "Fail", + "scanned": "2024-06-01T10:00:00Z", + "resultTime": "2024-06-02T10:05:00Z", + "vulnerabilities": [ + { + "id": "CVE-2000-0001", + "name": "Heap overflow in the example compression library", + "severity": "high", + "description": "A crafted archive can overflow a heap buffer during decompression.", + "cvss3Score": 9.1, + "cvss": {"base_score": 8.8}, + "package": "example-compress", + "package_name": "example-compress", + "version": "1.2.3", + "fixVersions": ["1.2.4", "1.3.0"], + "url": "https://example.com/advisories/CVE-2000-0001" + } + ] + } + ] +} diff --git a/unittests/scans/codacy/codacy_many_vuln.json b/unittests/scans/codacy/codacy_many_vuln.json new file mode 100644 index 00000000000..1459bbc1901 --- /dev/null +++ b/unittests/scans/codacy/codacy_many_vuln.json @@ -0,0 +1,122 @@ +{ + "data": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "itemSource": "Trivy", + "itemSourceId": "TRIVY-0001", + "title": "CVE-2000-0001 in generic-lib", + "repository": "generic-app", + "priority": "Critical", + "status": "Overdue", + "openedAt": "2026-06-10T08:00:00Z", + "closedAt": "", + "dueAt": "2026-06-24T08:00:00Z", + "ignored": null, + "htmlUrl": "https://app.example.com/items/1", + "securityCategory": "Vulnerability", + "scanType": "SCA", + "summary": "generic-lib is vulnerable to remote code execution.", + "additionalInfo": "The library evaluates untrusted input during deserialization.", + "remediation": "Upgrade generic-lib to 1.2.4 or later.", + "cwe": "CWE-502", + "cve": "CVE-2000-0001", + "cvssScore": 9.8, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "likelihood": "High", + "effortToFix": "Low", + "affectedVersion": "1.2.3", + "fixedVersion": ["1.2.4", "2.0.0"], + "application": "", + "affectedTargets": "", + "imageName": "", + "imageTag": "", + "dependencyChains": [["generic-app", "generic-framework", "generic-lib"]] + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "itemSource": "ZAP", + "itemSourceId": "ZAP-40012", + "title": "", + "repository": "generic-app", + "priority": "High", + "status": "OnTrack", + "openedAt": "2026-06-11T09:00:00Z", + "ignored": null, + "htmlUrl": "https://app.example.com/items/2", + "securityCategory": "Injection", + "scanType": "DAST", + "summary": "Reflected cross-site scripting on the search endpoint.", + "additionalInfo": "", + "remediation": "Encode user input before rendering it.", + "cwe": "CWE-79", + "cve": "", + "cvssScore": 7.4, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "likelihood": "Medium", + "effortToFix": "", + "affectedVersion": "", + "fixedVersion": [], + "application": "app.example.com", + "affectedTargets": "", + "imageName": "", + "imageTag": "", + "dependencyChains": [] + }, + { + "id": "00000000-0000-4000-8000-000000000003", + "itemSource": "Grype", + "itemSourceId": "GRYPE-0003", + "title": "", + "repository": "generic-app", + "priority": "Medium", + "status": "Ignored", + "openedAt": "2026-06-12T10:00:00Z", + "ignored": { + "at": "2026-06-13T10:00:00Z", + "authorId": 7, + "authorName": "Reviewer", + "reason": "False Positive" + }, + "htmlUrl": "https://app.example.com/items/3", + "securityCategory": "Vulnerability", + "scanType": "Container", + "summary": "Vulnerable package present in the base image.", + "remediation": "", + "cwe": "not-a-cwe", + "cve": "CVE-2000-0003 and also CVE-2000-0004, plus CVE-2000-0003 again", + "cvssScore": 5.3, + "cvssVector": "", + "affectedVersion": "3.1.0", + "fixedVersion": [], + "application": "", + "affectedTargets": "registry.example.com/generic-app", + "imageName": "registry.example.com/generic-app", + "imageTag": "1.4.0", + "dependencyChains": [[], ["generic-base", "generic-openssl"]] + }, + { + "id": "00000000-0000-4000-8000-000000000004", + "itemSource": "", + "itemSourceId": "", + "title": "", + "repository": "", + "priority": "not-a-priority", + "status": "OnTrack", + "openedAt": "not-a-timestamp", + "ignored": {"reason": "Acceptable risk"}, + "htmlUrl": "", + "securityCategory": "", + "scanType": "", + "summary": "", + "remediation": "", + "cwe": "", + "cve": "", + "cvssScore": 0, + "cvssVector": "", + "affectedVersion": "", + "fixedVersion": [], + "dependencyChains": [] + } + ], + "pagination": {"cursor": "", "limit": 100, "total": 4} +} diff --git a/unittests/scans/codacy/codacy_no_vuln.json b/unittests/scans/codacy/codacy_no_vuln.json new file mode 100644 index 00000000000..35e997a3523 --- /dev/null +++ b/unittests/scans/codacy/codacy_no_vuln.json @@ -0,0 +1,8 @@ +{ + "data": [], + "pagination": { + "cursor": "", + "limit": 100, + "total": 0 + } +} diff --git a/unittests/scans/codacy/codacy_one_vuln.json b/unittests/scans/codacy/codacy_one_vuln.json new file mode 100644 index 00000000000..8043030e470 --- /dev/null +++ b/unittests/scans/codacy/codacy_one_vuln.json @@ -0,0 +1,48 @@ +{ + "data": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "itemSource": "Trivy", + "itemSourceId": "TRIVY-0001", + "title": "CVE-2000-0001 in generic-lib", + "repository": "generic-app", + "priority": "Critical", + "status": "Overdue", + "openedAt": "2026-06-10T08:00:00Z", + "closedAt": "", + "dueAt": "2026-06-24T08:00:00Z", + "ignored": null, + "htmlUrl": "https://app.example.com/items/1", + "securityCategory": "Vulnerability", + "scanType": "SCA", + "summary": "generic-lib is vulnerable to remote code execution.", + "additionalInfo": "The library evaluates untrusted input during deserialization.", + "remediation": "Upgrade generic-lib to 1.2.4 or later.", + "cwe": "CWE-502", + "cve": "CVE-2000-0001", + "cvssScore": 9.8, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "likelihood": "High", + "effortToFix": "Low", + "affectedVersion": "1.2.3", + "fixedVersion": [ + "1.2.4", + "2.0.0" + ], + "application": "", + "affectedTargets": "", + "imageName": "", + "imageTag": "", + "dependencyChains": [ + [ + "generic-app", + "generic-framework", + "generic-lib" + ] + ] + } + ], + "pagination": { + "total": 1 + } +} diff --git a/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_many_vuln.json b/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_many_vuln.json new file mode 100644 index 00000000000..62d0c59f8d4 --- /dev/null +++ b/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_many_vuln.json @@ -0,0 +1,358 @@ +{ + "resources": [ + { + "id": "spotlight-0000000000000000000000000000001", + "aid": "aid0000000000000000000000000000001", + "cid": "cid0000000000000000000000000000001", + "status": "open", + "created_timestamp": "2026-01-15T09:24:11Z", + "updated_timestamp": "2026-01-20T11:02:44Z", + "apps": [ + { + "product_name_version": "OpenSSL 3.0.11", + "product_name_normalized": "OpenSSL", + "vendor_normalized": "openssl" + } + ], + "cve": { + "id": "CVE-2000-0001", + "base_score": 9.8, + "severity": "CRITICAL", + "exprt_rating": "HIGH", + "exploit_status": 60, + "remediation_level": "O", + "description": "A flaw in certificate verification allows an attacker to bypass validation.", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": [ + "https://example.com/advisories/cve-2000-0001" + ], + "vendor_advisory": [ + "https://vendor.example.com/security/2000-0001" + ], + "cwes": [ + "CWE-295", + "CWE-297" + ], + "spotlight_published_date": "2026-01-14T00:00:00Z", + "published_date": "2026-01-10T00:00:00Z", + "cisa_info": { + "is_cisa_kev": true, + "due_date": "2026-02-15", + "published_date": "2026-01-12" + } + }, + "host_info": { + "hostname": "host01.example.com", + "local_ip": "10.0.0.11", + "machine_domain": "example.com", + "os_version": "Ubuntu 22.04", + "platform": "Linux", + "product_type_desc": "Server", + "tags": [ + "env/production", + "team/platform" + ], + "asset_criticality": "Critical", + "internet_exposure": "Yes" + }, + "remediation": { + "entities": [ + { + "id": "remediation-0000000000000000000000000001", + "title": "Update OpenSSL", + "action": "Upgrade OpenSSL to 3.0.13 or later.", + "link": "https://example.com/remediation/openssl", + "reference": "VENDOR-SA-2026-0001", + "vendor_url": "https://vendor.example.com/downloads/openssl" + } + ] + } + }, + { + "id": "spotlight-2", + "aid": "aid0000000000000000000000000000001", + "cid": "cid0000000000000000000000000000001", + "status": "open", + "created_timestamp": "2026-01-15T09:24:11Z", + "updated_timestamp": "2026-01-20T11:02:44Z", + "apps": [ + { + "product_name_version": "OpenSSL 3.0.11", + "product_name_normalized": "OpenSSL", + "vendor_normalized": "openssl" + } + ], + "cve": { + "id": "CVE-2000-0002", + "base_score": 7.5, + "severity": "HIGH", + "exprt_rating": "MEDIUM", + "exploit_status": 60, + "remediation_level": "O", + "description": "A flaw in certificate verification allows an attacker to bypass validation.", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": [ + "https://example.com/advisories/cve-2000-0001" + ], + "vendor_advisory": [ + "https://vendor.example.com/security/2000-0001" + ], + "cwes": [ + "CWE-79" + ], + "spotlight_published_date": "2026-01-14T00:00:00Z", + "published_date": "2026-01-10T00:00:00Z", + "cisa_info": { + "is_cisa_kev": false + } + }, + "host_info": { + "hostname": "host01.example.com", + "local_ip": "10.0.0.11", + "machine_domain": "example.com", + "os_version": "Ubuntu 22.04", + "platform": "Linux", + "product_type_desc": "Server", + "tags": [ + "env/production", + "team/platform" + ], + "asset_criticality": "Critical", + "internet_exposure": "Yes" + }, + "remediation": { + "entities": [ + { + "id": "remediation-0000000000000000000000000001", + "title": "Update OpenSSL", + "action": "Upgrade OpenSSL to 3.0.13 or later.", + "link": "https://example.com/remediation/openssl", + "reference": "VENDOR-SA-2026-0001", + "vendor_url": "https://vendor.example.com/downloads/openssl" + } + ] + } + }, + { + "id": "spotlight-3", + "aid": "aid0000000000000000000000000000001", + "cid": "cid0000000000000000000000000000001", + "status": "open", + "created_timestamp": "2026-01-15T09:24:11Z", + "updated_timestamp": "2026-01-20T11:02:44Z", + "apps": [ + { + "product_name_version": "OpenSSL 3.0.11", + "product_name_normalized": "OpenSSL", + "vendor_normalized": "openssl" + } + ], + "cve": { + "id": "CVE-2000-0003", + "base_score": 5.3, + "severity": "MEDIUM", + "exprt_rating": "", + "exploit_status": 60, + "remediation_level": "O", + "description": "A flaw in certificate verification allows an attacker to bypass validation.", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": [ + "https://example.com/advisories/cve-2000-0001" + ], + "vendor_advisory": [ + "https://vendor.example.com/security/2000-0001" + ], + "cwes": [], + "spotlight_published_date": "2026-01-14T00:00:00Z", + "published_date": "2026-01-10T00:00:00Z", + "cisa_info": { + "is_cisa_kev": false + } + }, + "host_info": { + "hostname": "", + "local_ip": "10.0.0.33", + "os_version": "Windows Server 2022", + "tags": [] + }, + "remediation": { + "entities": [ + { + "id": "remediation-0000000000000000000000000001", + "title": "Update OpenSSL", + "action": "Upgrade OpenSSL to 3.0.13 or later.", + "link": "https://example.com/remediation/openssl", + "reference": "VENDOR-SA-2026-0001", + "vendor_url": "https://vendor.example.com/downloads/openssl" + } + ] + } + }, + { + "id": "spotlight-4", + "aid": "aid0000000000000000000000000000001", + "cid": "cid0000000000000000000000000000001", + "status": "open", + "created_timestamp": "2026-01-15T09:24:11Z", + "updated_timestamp": "2026-01-20T11:02:44Z", + "apps": [], + "cve": { + "id": "CVE-2000-0004", + "base_score": 3.1, + "severity": "LOW", + "exprt_rating": "", + "exploit_status": 60, + "remediation_level": "O", + "description": "A flaw in certificate verification allows an attacker to bypass validation.", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": [ + "https://example.com/advisories/cve-2000-0001" + ], + "vendor_advisory": [ + "https://vendor.example.com/security/2000-0001" + ], + "cwes": [ + "not-a-cwe", + "CWE-improper" + ], + "spotlight_published_date": "2026-01-14T00:00:00Z", + "published_date": "2026-01-10T00:00:00Z", + "cisa_info": { + "is_cisa_kev": false + } + }, + "host_info": { + "hostname": "host01.example.com", + "local_ip": "10.0.0.11", + "machine_domain": "example.com", + "os_version": "Ubuntu 22.04", + "platform": "Linux", + "product_type_desc": "Server", + "tags": [ + "env/production", + "team/platform" + ], + "asset_criticality": "Critical", + "internet_exposure": "Yes" + }, + "remediation": { + "entities": [] + } + }, + { + "id": "spotlight-5", + "aid": "aid0000000000000000000000000000001", + "cid": "cid0000000000000000000000000000001", + "status": "open", + "created_timestamp": "not-a-timestamp", + "updated_timestamp": "2026-01-20T11:02:44Z", + "apps": [], + "cve": { + "id": "", + "base_score": 0, + "severity": "NOT-A-SEVERITY", + "exprt_rating": "", + "exploit_status": 60, + "remediation_level": "O", + "description": "", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": [], + "vendor_advisory": [], + "cwes": [], + "spotlight_published_date": "2026-01-14T00:00:00Z", + "published_date": "2026-01-10T00:00:00Z", + "cisa_info": { + "is_cisa_kev": false + } + }, + "host_info": { + "hostname": "host01.example.com", + "local_ip": "10.0.0.11", + "machine_domain": "example.com", + "os_version": "Ubuntu 22.04", + "platform": "Linux", + "product_type_desc": "Server", + "tags": [ + "env/production", + "team/platform" + ], + "asset_criticality": "Critical", + "internet_exposure": "Yes" + }, + "remediation": { + "entities": [] + } + }, + { + "id": "spotlight-6", + "aid": "aid0000000000000000000000000000001", + "cid": "cid0000000000000000000000000000001", + "status": "open", + "created_timestamp": "2026-01-15T09:24:11Z", + "updated_timestamp": "2026-01-20T11:02:44Z", + "apps": [ + { + "product_name_version": "OpenSSL 3.0.11", + "product_name_normalized": "OpenSSL", + "vendor_normalized": "openssl" + } + ], + "cve": { + "id": "CVE-2000-0006", + "base_score": 8.1, + "severity": "HIGH", + "exprt_rating": "CRITICAL", + "exploit_status": 60, + "remediation_level": "O", + "description": "A flaw in certificate verification allows an attacker to bypass validation.", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": [ + "https://example.com/advisories/cve-2000-0001" + ], + "vendor_advisory": [ + "https://vendor.example.com/security/2000-0001" + ], + "cwes": [ + "CWE-400" + ], + "spotlight_published_date": "2026-01-14T00:00:00Z", + "published_date": "2026-01-10T00:00:00Z", + "cisa_info": { + "is_cisa_kev": true + } + }, + "host_info": { + "hostname": "host01.example.com", + "local_ip": "10.0.0.11", + "machine_domain": "example.com", + "os_version": "Ubuntu 22.04", + "platform": "Linux", + "product_type_desc": "Server", + "tags": [ + "env/production", + "team/platform" + ], + "asset_criticality": "Critical", + "internet_exposure": "Yes" + }, + "remediation": { + "entities": [ + { + "title": "Apply patch", + "action": "Install KB0000001.", + "reference": "KB0000001", + "link": "https://example.com/kb/1", + "vendor_url": "" + }, + { + "title": "Workaround", + "action": "Disable the affected feature.", + "reference": "", + "link": "", + "vendor_url": "https://vendor.example.com/workaround" + } + ] + } + } + ] +} diff --git a/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_no_vuln.json b/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_no_vuln.json new file mode 100644 index 00000000000..d02ef58d6e3 --- /dev/null +++ b/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_no_vuln.json @@ -0,0 +1,3 @@ +{ + "resources": [] +} diff --git a/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_one_vuln.json b/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_one_vuln.json new file mode 100644 index 00000000000..e8481887137 --- /dev/null +++ b/unittests/scans/crowdstrike_spotlight/crowdstrike_spotlight_one_vuln.json @@ -0,0 +1,72 @@ +{ + "resources": [ + { + "id": "spotlight-0000000000000000000000000000001", + "aid": "aid0000000000000000000000000000001", + "cid": "cid0000000000000000000000000000001", + "status": "open", + "created_timestamp": "2026-01-15T09:24:11Z", + "updated_timestamp": "2026-01-20T11:02:44Z", + "apps": [ + { + "product_name_version": "OpenSSL 3.0.11", + "product_name_normalized": "OpenSSL", + "vendor_normalized": "openssl" + } + ], + "cve": { + "id": "CVE-2000-0001", + "base_score": 9.8, + "severity": "CRITICAL", + "exprt_rating": "HIGH", + "exploit_status": 60, + "remediation_level": "O", + "description": "A flaw in certificate verification allows an attacker to bypass validation.", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": [ + "https://example.com/advisories/cve-2000-0001" + ], + "vendor_advisory": [ + "https://vendor.example.com/security/2000-0001" + ], + "cwes": [ + "CWE-295", + "CWE-297" + ], + "spotlight_published_date": "2026-01-14T00:00:00Z", + "published_date": "2026-01-10T00:00:00Z", + "cisa_info": { + "is_cisa_kev": true, + "due_date": "2026-02-15", + "published_date": "2026-01-12" + } + }, + "host_info": { + "hostname": "host01.example.com", + "local_ip": "10.0.0.11", + "machine_domain": "example.com", + "os_version": "Ubuntu 22.04", + "platform": "Linux", + "product_type_desc": "Server", + "tags": [ + "env/production", + "team/platform" + ], + "asset_criticality": "Critical", + "internet_exposure": "Yes" + }, + "remediation": { + "entities": [ + { + "id": "remediation-0000000000000000000000000001", + "title": "Update OpenSSL", + "action": "Upgrade OpenSSL to 3.0.13 or later.", + "link": "https://example.com/remediation/openssl", + "reference": "VENDOR-SA-2026-0001", + "vendor_url": "https://vendor.example.com/downloads/openssl" + } + ] + } + } + ] +} diff --git a/unittests/scans/datadog/datadog_many_vuln.json b/unittests/scans/datadog/datadog_many_vuln.json new file mode 100644 index 00000000000..8148d35b223 --- /dev/null +++ b/unittests/scans/datadog/datadog_many_vuln.json @@ -0,0 +1,126 @@ +{ + "data": [ + { + "id": "AAAAA-BBBB-CCCC-0001", + "type": "finding", + "attributes": { + "tags": ["service:payments-api", "env:prod", "team:platform"], + "timestamp": 1719792000000, + "attributes": { + "finding_id": "finding-0001", + "finding_type": "vulnerability", + "title": "openssl 3.0.2 is affected by CVE-2000-0001", + "description": "The installed openssl build is affected by a memory-safety flaw.", + "severity": "critical", + "base_severity": "high", + "status": "open", + "resource_id": "i-0000000000000001", + "resource_name": "web-node-1", + "resource_type": "aws_ec2_instance", + "first_seen_at": 1719792000000, + "detection_changed_at": 1721001600000, + "rule": {"id": "rule-0001", "name": "Vulnerable library detected", "type": "vulnerability", "version": 3}, + "advisory": { + "cve": "CVE-2000-0001", + "aliases": ["GHSA-aaaa-bbbb-cccc", "CVE-2000-0001"], + "id": "adv-1", + "summary": "Memory-safety flaw in openssl.", + "type": "cve" + }, + "severity_details": { + "base": {"score": 9.8, "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", "value": "critical"}, + "adjusted": {"score": 7.2, "vector": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", "value": "high"} + }, + "package": {"name": "openssl", "version": "3.0.2", "manager": "deb"}, + "cloud_resource": { + "cloud_provider": "AWS", + "region": "us-east-1", + "account": {"account_id": "acct-1234", "name": "example-account"} + } + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0002", + "attributes": { + "tags": ["env:prod"], + "attributes": { + "finding_type": "misconfiguration", + "title": "", + "severity": "medium", + "status": "open", + "resource_id": "arn:aws:s3:::example-bucket", + "resource_type": "aws_s3_bucket", + "first_seen_at": 1717200000000, + "rule": {"id": "rule-0002", "name": "S3 bucket should not allow public reads"}, + "compliance": {"evaluation": "fail"} + } + } + }, + { + "attributes": { + "tags": [], + "attributes": { + "finding_id": "finding-0003", + "finding_type": "api_security", + "title": "Unauthenticated endpoint accepts writes", + "description": "Observed unauthenticated POST traffic reaching a write endpoint.", + "severity": "high", + "status": "open", + "resource_name": "payments-api", + "resource_type": "service", + "detection_changed_at": 1721001600000 + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0004", + "attributes": { + "attributes": { + "finding_type": "misconfiguration", + "title": "A muted finding", + "severity": "high", + "status": "muted", + "rule": {"id": "rule-0004", "name": "Muted rule"} + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0005", + "attributes": { + "attributes": { + "finding_type": "misconfiguration", + "title": "A finding muted through the workflow", + "severity": "high", + "status": "open", + "workflow": {"mute": {"is_muted": true, "is_muted_by_rule": true, "reason": "accepted risk"}} + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0006", + "attributes": { + "attributes": { + "finding_type": "misconfiguration", + "title": "A compliance rule that passed", + "severity": "high", + "status": "open", + "compliance": {"evaluation": "pass"} + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0007", + "attributes": { + "attributes": { + "finding_type": "attack_path", + "severity": "not a label", + "status": "open", + "resource_name": "web-node-1" + } + } + } + ], + "meta": {"page": {"after": "next-page-token"}, "status": "done"}, + "links": {"next": "https://api.example.com/api/v2/posture_management/findings?page%5Bcursor%5D=next"} +} diff --git a/unittests/scans/datadog/datadog_no_vuln.json b/unittests/scans/datadog/datadog_no_vuln.json new file mode 100644 index 00000000000..a6135b8b7a9 --- /dev/null +++ b/unittests/scans/datadog/datadog_no_vuln.json @@ -0,0 +1,79 @@ +{ + "data": [ + { + "id": "AAAAA-BBBB-CCCC-0004", + "attributes": { + "attributes": { + "finding_type": "misconfiguration", + "title": "A muted finding", + "severity": "high", + "status": "muted", + "rule": { + "id": "rule-0004", + "name": "Muted rule" + } + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0005", + "attributes": { + "attributes": { + "finding_type": "misconfiguration", + "title": "A finding muted through the workflow", + "severity": "high", + "status": "open", + "workflow": { + "mute": { + "is_muted": true, + "is_muted_by_rule": true, + "reason": "accepted risk" + } + } + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0006", + "attributes": { + "attributes": { + "finding_type": "misconfiguration", + "title": "A compliance rule that passed", + "severity": "high", + "status": "open", + "compliance": { + "evaluation": "pass" + } + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0008", + "attributes": { + "attributes": { + "finding_type": "misconfiguration", + "title": "A resolved finding", + "severity": "critical", + "status": "resolved" + } + } + }, + { + "id": "AAAAA-BBBB-CCCC-0009", + "attributes": { + "attributes": { + "finding_type": "misconfiguration", + "title": "An auto-closed finding", + "severity": "critical", + "status": "auto_closed" + } + } + } + ], + "meta": { + "page": { + "after": "" + }, + "status": "done" + } +} diff --git a/unittests/scans/datadog/datadog_one_vuln.json b/unittests/scans/datadog/datadog_one_vuln.json new file mode 100644 index 00000000000..077f44baaa5 --- /dev/null +++ b/unittests/scans/datadog/datadog_one_vuln.json @@ -0,0 +1,77 @@ +{ + "data": [ + { + "id": "AAAAA-BBBB-CCCC-0001", + "type": "finding", + "attributes": { + "tags": [ + "service:payments-api", + "env:prod", + "team:platform" + ], + "timestamp": 1719792000000, + "attributes": { + "finding_id": "finding-0001", + "finding_type": "vulnerability", + "title": "openssl 3.0.2 is affected by CVE-2000-0001", + "description": "The installed openssl build is affected by a memory-safety flaw.", + "severity": "critical", + "base_severity": "high", + "status": "open", + "resource_id": "i-0000000000000001", + "resource_name": "web-node-1", + "resource_type": "aws_ec2_instance", + "first_seen_at": 1719792000000, + "detection_changed_at": 1721001600000, + "rule": { + "id": "rule-0001", + "name": "Vulnerable library detected", + "type": "vulnerability", + "version": 3 + }, + "advisory": { + "cve": "CVE-2000-0001", + "aliases": [ + "GHSA-aaaa-bbbb-cccc", + "CVE-2000-0001" + ], + "id": "adv-1", + "summary": "Memory-safety flaw in openssl.", + "type": "cve" + }, + "severity_details": { + "base": { + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "value": "critical" + }, + "adjusted": { + "score": 7.2, + "vector": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", + "value": "high" + } + }, + "package": { + "name": "openssl", + "version": "3.0.2", + "manager": "deb" + }, + "cloud_resource": { + "cloud_provider": "AWS", + "region": "us-east-1", + "account": { + "account_id": "acct-1234", + "name": "example-account" + } + } + } + } + } + ], + "meta": { + "page": { + "after": "" + }, + "status": "done" + } +} diff --git a/unittests/scans/deepsource/deepsource_many_vuln.json b/unittests/scans/deepsource/deepsource_many_vuln.json new file mode 100644 index 00000000000..c9d75c1603c --- /dev/null +++ b/unittests/scans/deepsource/deepsource_many_vuln.json @@ -0,0 +1,240 @@ +{ + "data": { + "repository": { + "id": "UmVwb3NpdG9yeTpnZW5lcmljLWFwcA==", + "name": "generic-app", + "analysisRuns": { + "edges": [ + { + "node": { + "runUid": "00000000-0000-4000-8000-0000000000aa", + "commitOid": "abcdef1234567890abcdef1234567890abcdef12", + "branchName": "main", + "status": "SUCCESS", + "createdAt": "2026-06-20T08:00:00Z", + "finishedAt": "2026-06-20T08:12:00Z" + } + } + ] + }, + "issueOccurrences": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "edges": [ + { + "node": { + "id": "occ-0001", + "path": "src/generic_app/views.py", + "beginLine": 42, + "beginColumn": 4, + "endLine": 47, + "endColumn": 20, + "title": "Possible SQL injection through string formatting", + "issue": { + "shortcode": "PY-A6006", + "title": "SQL injection risk", + "shortDescription": "Query is built with string formatting instead of parameters.", + "category": "SECURITY", + "severity": "CRITICAL", + "isRecommended": true, + "analyzer": { + "name": "Python", + "shortcode": "python" + } + } + } + }, + { + "node": { + "id": "occ-0002", + "path": "src/generic_app/utils.py", + "beginLine": 10, + "endLine": 10, + "title": "", + "issue": { + "shortcode": "PY-W0012", + "title": "Mutable default argument", + "shortDescription": "A mutable object is used as a default argument value.", + "category": "BUG_RISK", + "severity": "CRITICAL", + "analyzer": { + "name": "Python", + "shortcode": "python" + } + } + } + }, + { + "node": { + "id": "occ-0003", + "path": "src/generic_app/models.py", + "beginLine": 88, + "title": "", + "issue": { + "shortcode": "PY-D0003", + "title": "Missing docstring", + "shortDescription": "", + "category": "DOCUMENTATION", + "severity": "MINOR", + "analyzer": { + "shortcode": "python" + } + } + } + }, + { + "node": { + "id": "occ-0004", + "path": "deploy/settings.py", + "beginLine": 3, + "title": "Hardcoded credential detected", + "issue": { + "shortcode": "SCRT-0001", + "title": "Committed credential", + "shortDescription": "A credential appears to be committed to the repository.", + "category": "SECURITY", + "severity": "MINOR", + "analyzer": { + "name": "Secrets", + "shortcode": "secrets" + } + } + } + }, + { + "node": { + "id": "occ-0005", + "path": "", + "beginLine": 0, + "title": "", + "issue": { + "shortcode": "", + "title": "", + "category": "SOME_NEW_CATEGORY", + "severity": "MAJOR", + "analyzer": {} + } + } + } + ] + }, + "dependencyVulnerabilityOccurrences": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "edges": [ + { + "node": { + "id": "dep-0001", + "reachability": "REACHABLE", + "fixability": "FIXABLE", + "vulnerability": { + "identifier": "CVE-2000-0001", + "aliases": [ + "GHSA-0000-0000-0001", + "cve-2000-0001" + ], + "summary": "Remote code execution in generic-lib.", + "severity": "CRITICAL", + "cvssV3BaseScore": 9.8, + "cvssV3Severity": "CRITICAL", + "epssScore": 0.0431, + "publishedAt": "2026-05-01T00:00:00Z", + "fixedVersions": [ + "1.2.4", + "2.0.0" + ], + "referenceUrls": [ + "https://example.com/advisories/cve-2000-0001", + "https://example.com/commit/abcdef1" + ] + }, + "package": { + "name": "generic-lib", + "ecosystem": "PYPI" + }, + "packageVersion": { + "version": "1.2.3" + } + } + }, + { + "node": { + "id": "dep-0002", + "reachability": "UNREACHABLE", + "fixability": "UNFIXABLE", + "vulnerability": { + "identifier": "GHSA-0000-0000-0002", + "aliases": [], + "summary": "Denial of service in generic-parser.", + "severity": "MODERATE", + "cvssV3BaseScore": 0, + "cvssV3Severity": "", + "epssScore": 0, + "publishedAt": "not-a-timestamp", + "fixedVersions": [], + "referenceUrls": [] + }, + "package": { + "name": "generic-parser", + "ecosystem": "NPM" + }, + "packageVersion": { + "version": "3.1.0" + } + } + }, + { + "node": { + "id": "dep-0003", + "reachability": "", + "fixability": "", + "vulnerability": { + "identifier": "", + "aliases": [], + "summary": "An advisory with no identifier at all.", + "severity": "not-a-severity", + "cvssV3BaseScore": 0, + "publishedAt": "2026-05-05T00:00:00Z", + "fixedVersions": [], + "referenceUrls": [] + }, + "package": { + "name": "", + "ecosystem": "" + }, + "packageVersion": { + "version": "" + } + } + }, + { + "node": { + "id": "dep-0004", + "vulnerability": { + "identifier": "CVE-2000-0004", + "summary": "A low-scored advisory.", + "cvssV3BaseScore": 2.1, + "publishedAt": "2026-05-06T00:00:00Z", + "fixedVersions": [ + "4.0.1" + ], + "referenceUrls": [] + }, + "package": { + "name": "generic-widget", + "ecosystem": "MAVEN" + }, + "packageVersion": { + "version": "4.0.0" + } + } + } + ] + } + } + } +} diff --git a/unittests/scans/deepsource/deepsource_no_vuln.json b/unittests/scans/deepsource/deepsource_no_vuln.json new file mode 100644 index 00000000000..6eeb49e8a32 --- /dev/null +++ b/unittests/scans/deepsource/deepsource_no_vuln.json @@ -0,0 +1,36 @@ +{ + "data": { + "repository": { + "id": "UmVwb3NpdG9yeTpnZW5lcmljLWFwcA==", + "name": "generic-app", + "analysisRuns": { + "edges": [ + { + "node": { + "runUid": "00000000-0000-4000-8000-0000000000aa", + "commitOid": "abcdef1234567890abcdef1234567890abcdef12", + "branchName": "main", + "status": "SUCCESS", + "createdAt": "2026-06-20T08:00:00Z", + "finishedAt": "2026-06-20T08:12:00Z" + } + } + ] + }, + "issueOccurrences": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "edges": [] + }, + "dependencyVulnerabilityOccurrences": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "edges": [] + } + } + } +} diff --git a/unittests/scans/deepsource/deepsource_one_vuln.json b/unittests/scans/deepsource/deepsource_one_vuln.json new file mode 100644 index 00000000000..dfad5dc9b24 --- /dev/null +++ b/unittests/scans/deepsource/deepsource_one_vuln.json @@ -0,0 +1,60 @@ +{ + "data": { + "repository": { + "id": "UmVwb3NpdG9yeTpnZW5lcmljLWFwcA==", + "name": "generic-app", + "analysisRuns": { + "edges": [ + { + "node": { + "runUid": "00000000-0000-4000-8000-0000000000aa", + "commitOid": "abcdef1234567890abcdef1234567890abcdef12", + "branchName": "main", + "status": "SUCCESS", + "createdAt": "2026-06-20T08:00:00Z", + "finishedAt": "2026-06-20T08:12:00Z" + } + } + ] + }, + "issueOccurrences": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "edges": [ + { + "node": { + "id": "occ-0001", + "path": "src/generic_app/views.py", + "beginLine": 42, + "beginColumn": 4, + "endLine": 47, + "endColumn": 20, + "title": "Possible SQL injection through string formatting", + "issue": { + "shortcode": "PY-A6006", + "title": "SQL injection risk", + "shortDescription": "Query is built with string formatting instead of parameters.", + "category": "SECURITY", + "severity": "CRITICAL", + "isRecommended": true, + "analyzer": { + "name": "Python", + "shortcode": "python" + } + } + } + } + ] + }, + "dependencyVulnerabilityOccurrences": { + "pageInfo": { + "hasNextPage": false, + "endCursor": null + }, + "edges": [] + } + } + } +} diff --git a/unittests/scans/defender_for_cloud/defender_for_cloud_many_vuln.json b/unittests/scans/defender_for_cloud/defender_for_cloud_many_vuln.json new file mode 100644 index 00000000000..832c7e6ceef --- /dev/null +++ b/unittests/scans/defender_for_cloud/defender_for_cloud_many_vuln.json @@ -0,0 +1,140 @@ +{ + "value": [ + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/example-rg/providers/Microsoft.Compute/virtualMachines/web-node-1/providers/Microsoft.Security/assessments/aaaa/subAssessments/sub-0001", + "name": "sub-0001", + "type": "Microsoft.Security/assessments/subAssessments", + "properties": { + "id": "finding-0001", + "displayName": "CVE-2000-0001", + "description": "A memory-safety flaw allows remote code execution.", + "impact": "An attacker who reaches the service can run code as the service account.", + "remediation": "Apply the vendor update through your patching process.", + "timeGenerated": "2024-07-01T12:00:00.0000000Z", + "status": {"code": "Unhealthy", "severity": "High", "cause": ""}, + "resourceDetails": { + "ResourceProvider": "Microsoft.Compute", + "NativeResourceId": "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/example-rg/providers/Microsoft.Compute/virtualMachines/web-node-1", + "ResourceName": "web-node-1", + "ResourceType": "virtualMachines", + "source": "Azure" + }, + "additionalData": { + "assessedResourceType": "ServerVulnerabilityTvm", + "softwareName": "openssl", + "softwareVersion": "3.0.2", + "softwareVendor": "OpenSSL", + "recommendedVersion": "3.0.13", + "cvssV30Score": 9.8, + "vulnerabilityDetails": { + "cveId": "CVE-2000-0001", + "severity": "High", + "publishedDate": "2024-05-01T00:00:00Z", + "cvss": { + "2.0": {"base": 7.5, "cvssVectorString": "AV:N/AC:L/Au:N/C:P/I:P/A:P"}, + "3.0": {"base": 9.8, "cvssVectorString": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"} + }, + "references": [ + {"title": "CVE-2000-0001", "link": "https://nvd.example.com/vuln/detail/CVE-2000-0001"}, + {"title": "Vendor advisory", "link": "https://vendor.example.com/advisory/1"} + ] + }, + "cve": [{"title": "CVE-2000-0001", "severity": "High", "cvssVectorString": "CVSS:3.0/AV:N"}] + } + } + }, + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/example-rg/providers/Microsoft.ContainerRegistry/registries/exampleacr/providers/Microsoft.Security/assessments/bbbb/subAssessments/sub-0002", + "properties": { + "id": "finding-0002", + "displayName": "CVE-2000-0002", + "description": "An integer overflow crashes the parser.", + "impact": "", + "remediation": "", + "timeGenerated": "2024-06-01T00:00:00.0000000Z", + "status": {"code": "Unhealthy", "severity": "medium"}, + "resourceDetails": { + "id": "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/example-rg/providers/Microsoft.ContainerRegistry/registries/exampleacr", + "source": "Azure" + }, + "additionalData": { + "assessedResourceType": "AzureContainerRegistryVulnerability", + "softwareDetails": { + "packageName": "libxml2", + "version": "2.9.13", + "fixedVersion": "2.9.14", + "fixStatus": "FixAvailable", + "vendor": "libxml2", + "category": "OS", + "osDetails": {"osPlatform": "linux", "osVersion": "debian-12"} + }, + "artifactDetails": { + "repositoryName": "generic-app", + "registryHost": "exampleacr.azurecr.example.com", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000001", + "artifactType": "ContainerImage" + }, + "vulnerabilityDetails": { + "cveId": "CVE-2000-0002", + "cvss": {"2.0": {"base": 5.0, "cvssVectorString": "AV:N/AC:L/Au:N/C:P/I:N/A:N"}}, + "references": [{"title": "CVE-2000-0002", "link": "https://nvd.example.com/vuln/detail/CVE-2000-0002"}] + } + } + } + }, + { + "id": "sub-0003", + "properties": { + "id": "finding-0003", + "displayName": "Machines should have a vulnerability assessment solution", + "description": "A posture recommendation, not a vulnerability.", + "timeGenerated": "2024-06-01T00:00:00.0000000Z", + "status": {"code": "Unhealthy", "severity": "Medium"}, + "resourceDetails": {"ResourceName": "web-node-2"}, + "additionalData": {"assessedResourceType": "GeneralVulnerability"} + } + }, + { + "id": "sub-0004", + "properties": { + "id": "finding-0004", + "displayName": "CVE-2000-0004", + "description": "A finding Defender now reports as healthy.", + "timeGenerated": "2024-06-01T00:00:00.0000000Z", + "status": {"code": "Healthy", "severity": "High"}, + "resourceDetails": {"ResourceName": "web-node-1"}, + "additionalData": { + "assessedResourceType": "ServerVulnerabilityTvm", + "vulnerabilityDetails": {"cveId": "CVE-2000-0004"} + } + } + }, + { + "id": "sub-0005", + "properties": { + "id": "finding-0005", + "displayName": "A future scanner's finding", + "description": "An unfamiliar resource type that does carry a CVE.", + "timeGenerated": "2024-06-01T00:00:00.0000000Z", + "status": {"code": "Unhealthy", "severity": "not a label"}, + "resourceDetails": {"ResourceName": "web-node-3"}, + "additionalData": { + "assessedResourceType": "SomeFutureVulnerabilityType", + "cve": "CVE-2000-0005" + } + } + }, + { + "id": "sub-0006", + "properties": { + "id": "finding-0006", + "displayName": "An unfamiliar type with no CVE", + "timeGenerated": "2024-06-01T00:00:00.0000000Z", + "status": {"code": "Unhealthy", "severity": "Low"}, + "resourceDetails": {"ResourceName": "web-node-4"}, + "additionalData": {"assessedResourceType": "SomeFutureConfigurationCheck"} + } + } + ], + "nextLink": "" +} diff --git a/unittests/scans/defender_for_cloud/defender_for_cloud_no_vuln.json b/unittests/scans/defender_for_cloud/defender_for_cloud_no_vuln.json new file mode 100644 index 00000000000..ce82aec2bdb --- /dev/null +++ b/unittests/scans/defender_for_cloud/defender_for_cloud_no_vuln.json @@ -0,0 +1,64 @@ +{ + "value": [ + { + "id": "sub-0003", + "properties": { + "id": "finding-0003", + "displayName": "Machines should have a vulnerability assessment solution", + "description": "A posture recommendation, not a vulnerability.", + "timeGenerated": "2024-06-01T00:00:00.0000000Z", + "status": { + "code": "Unhealthy", + "severity": "Medium" + }, + "resourceDetails": { + "ResourceName": "web-node-2" + }, + "additionalData": { + "assessedResourceType": "GeneralVulnerability" + } + } + }, + { + "id": "sub-0004", + "properties": { + "id": "finding-0004", + "displayName": "CVE-2000-0004", + "description": "A finding Defender now reports as healthy.", + "timeGenerated": "2024-06-01T00:00:00.0000000Z", + "status": { + "code": "Healthy", + "severity": "High" + }, + "resourceDetails": { + "ResourceName": "web-node-1" + }, + "additionalData": { + "assessedResourceType": "ServerVulnerabilityTvm", + "vulnerabilityDetails": { + "cveId": "CVE-2000-0004" + } + } + } + }, + { + "id": "sub-0006", + "properties": { + "id": "finding-0006", + "displayName": "An unfamiliar type with no CVE", + "timeGenerated": "2024-06-01T00:00:00.0000000Z", + "status": { + "code": "Unhealthy", + "severity": "Low" + }, + "resourceDetails": { + "ResourceName": "web-node-4" + }, + "additionalData": { + "assessedResourceType": "SomeFutureConfigurationCheck" + } + } + } + ], + "nextLink": "" +} diff --git a/unittests/scans/defender_for_cloud/defender_for_cloud_one_vuln.json b/unittests/scans/defender_for_cloud/defender_for_cloud_one_vuln.json new file mode 100644 index 00000000000..b72d38275bb --- /dev/null +++ b/unittests/scans/defender_for_cloud/defender_for_cloud_one_vuln.json @@ -0,0 +1,70 @@ +{ + "value": [ + { + "id": "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/example-rg/providers/Microsoft.Compute/virtualMachines/web-node-1/providers/Microsoft.Security/assessments/aaaa/subAssessments/sub-0001", + "name": "sub-0001", + "type": "Microsoft.Security/assessments/subAssessments", + "properties": { + "id": "finding-0001", + "displayName": "CVE-2000-0001", + "description": "A memory-safety flaw allows remote code execution.", + "impact": "An attacker who reaches the service can run code as the service account.", + "remediation": "Apply the vendor update through your patching process.", + "timeGenerated": "2024-07-01T12:00:00.0000000Z", + "status": { + "code": "Unhealthy", + "severity": "High", + "cause": "" + }, + "resourceDetails": { + "ResourceProvider": "Microsoft.Compute", + "NativeResourceId": "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/example-rg/providers/Microsoft.Compute/virtualMachines/web-node-1", + "ResourceName": "web-node-1", + "ResourceType": "virtualMachines", + "source": "Azure" + }, + "additionalData": { + "assessedResourceType": "ServerVulnerabilityTvm", + "softwareName": "openssl", + "softwareVersion": "3.0.2", + "softwareVendor": "OpenSSL", + "recommendedVersion": "3.0.13", + "cvssV30Score": 9.8, + "vulnerabilityDetails": { + "cveId": "CVE-2000-0001", + "severity": "High", + "publishedDate": "2024-05-01T00:00:00Z", + "cvss": { + "2.0": { + "base": 7.5, + "cvssVectorString": "AV:N/AC:L/Au:N/C:P/I:P/A:P" + }, + "3.0": { + "base": 9.8, + "cvssVectorString": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + }, + "references": [ + { + "title": "CVE-2000-0001", + "link": "https://nvd.example.com/vuln/detail/CVE-2000-0001" + }, + { + "title": "Vendor advisory", + "link": "https://vendor.example.com/advisory/1" + } + ] + }, + "cve": [ + { + "title": "CVE-2000-0001", + "severity": "High", + "cvssVectorString": "CVSS:3.0/AV:N" + } + ] + } + } + } + ], + "nextLink": "" +} diff --git a/unittests/scans/detectify/detectify_many_vuln.json b/unittests/scans/detectify/detectify_many_vuln.json new file mode 100644 index 00000000000..8cffd76e31b --- /dev/null +++ b/unittests/scans/detectify/detectify_many_vuln.json @@ -0,0 +1,124 @@ +{ + "vulnerabilities": [ + { + "uuid": "00000000-0000-4000-8000-000000000001", + "title": "Remote code execution via CVE-2000-0001", + "asset_token": "asset-0001", + "host": "app.example.com", + "location": "/api/upload", + "status": "active", + "severity": "critical", + "created_at": "2026-07-01T09:00:00Z", + "updated_at": "2026-07-01T09:00:00Z", + "scan_source": "surface-monitoring", + "scan_profile_token": "profile-0001", + "cvss_scores": { + "cvss_2_0": {"score": 9.0, "severity": "high", "vector": "AV:N/AC:L/Au:N/C:C/I:C/A:C"}, + "cvss_3_0": {"score": 9.5, "severity": "critical", "vector": "CVSS:3.0/AV:N/AC:L"}, + "cvss_3_1": {"score": 9.8, "severity": "critical", "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"} + }, + "cwe": 502, + "definition": { + "title": "Insecure Deserialization", + "description": "The endpoint deserializes untrusted input.", + "risk": "An attacker can execute arbitrary code on the host." + }, + "tags": [{"uuid": "tag-1", "name": "production"}, {"uuid": "tag-2", "name": "external"}], + "references": [ + {"uuid": "ref-1", "name": "Advisory CVE-2000-0002", "source": "nvd", + "link": "https://example.com/advisories/cve-2000-0001"}, + {"uuid": "ref-2", "name": "", "source": "", "link": "https://example.com/guidance"}, + {"uuid": "ref-3", "name": "Vendor bulletin", "source": "", "link": ""} + ], + "request": { + "method": "POST", + "url": "https://app.example.com/api/upload", + "body": "{\"payload\":\"...\"}" + }, + "response": {"status_code": 500, "body": "Internal Server Error"} + }, + { + "uuid": "00000000-0000-4000-8000-000000000002", + "title": "", + "host": "shop.example.com", + "location": "/checkout", + "status": "accepted_risk", + "severity": "information", + "created_at": "2026-07-02T10:00:00Z", + "scan_source": "asset-monitoring", + "cvss_scores": {"cvss_3_0": {"score": 0, "severity": "", "vector": "CVSS:3.0/AV:N/AC:H"}}, + "cwe": 0, + "definition": {"title": "Information Disclosure", "description": "", "risk": ""}, + "tags": [], + "references": [], + "request": null, + "response": null + }, + { + "uuid": "00000000-0000-4000-8000-000000000003", + "title": "", + "host": "", + "location": "https://legacy.example.com/old-path", + "status": "active", + "severity": "not-a-severity", + "created_at": "not-a-timestamp", + "scan_source": "", + "cvss_scores": null, + "cwe": 0, + "definition": null, + "tags": [{"uuid": "tag-3", "name": ""}], + "references": [], + "request": null, + "response": null + }, + { + "uuid": "00000000-0000-4000-8000-000000000004", + "title": "A patched finding", + "host": "app.example.com", + "location": "/old", + "status": "patched", + "severity": "high", + "created_at": "2026-07-03T11:00:00Z", + "scan_source": "surface-monitoring", + "cwe": 79, + "definition": {"title": "Cross-site Scripting", "description": "", "risk": ""}, + "tags": [], + "references": [] + }, + { + "uuid": "00000000-0000-4000-8000-000000000005", + "title": "A false positive", + "host": "app.example.com", + "location": "/fp", + "status": "false_positive", + "severity": "medium", + "created_at": "2026-07-04T12:00:00Z", + "scan_source": "surface-monitoring", + "cwe": 200, + "definition": {"title": "Information Disclosure", "description": "", "risk": ""}, + "tags": [], + "references": [] + }, + { + "uuid": "00000000-0000-4000-8000-000000000006", + "title": "Low severity header issue", + "host": "api.example.com", + "location": "no-leading-slash", + "status": "active", + "severity": "low", + "created_at": "2026-07-05T13:00:00Z", + "scan_source": "surface-monitoring", + "cvss_scores": {"cvss_3_1": {"score": 3.1, "severity": "low", "vector": ""}}, + "cwe": 693, + "definition": {"title": "Missing Security Header", "description": "No header returned.", "risk": ""}, + "tags": [], + "references": [], + "request": null, + "response": null + } + ], + "current_marker": "", + "next_marker": "", + "has_more": false, + "total_vulnerabilities": 6 +} diff --git a/unittests/scans/detectify/detectify_no_vuln.json b/unittests/scans/detectify/detectify_no_vuln.json new file mode 100644 index 00000000000..00c60b0be3e --- /dev/null +++ b/unittests/scans/detectify/detectify_no_vuln.json @@ -0,0 +1,5 @@ +{ + "vulnerabilities": [], + "has_more": false, + "total_vulnerabilities": 0 +} diff --git a/unittests/scans/detectify/detectify_one_vuln.json b/unittests/scans/detectify/detectify_one_vuln.json new file mode 100644 index 00000000000..3965e39a682 --- /dev/null +++ b/unittests/scans/detectify/detectify_one_vuln.json @@ -0,0 +1,81 @@ +{ + "vulnerabilities": [ + { + "uuid": "00000000-0000-4000-8000-000000000001", + "title": "Remote code execution via CVE-2000-0001", + "asset_token": "asset-0001", + "host": "app.example.com", + "location": "/api/upload", + "status": "active", + "severity": "critical", + "created_at": "2026-07-01T09:00:00Z", + "updated_at": "2026-07-01T09:00:00Z", + "scan_source": "surface-monitoring", + "scan_profile_token": "profile-0001", + "cvss_scores": { + "cvss_2_0": { + "score": 9.0, + "severity": "high", + "vector": "AV:N/AC:L/Au:N/C:C/I:C/A:C" + }, + "cvss_3_0": { + "score": 9.5, + "severity": "critical", + "vector": "CVSS:3.0/AV:N/AC:L" + }, + "cvss_3_1": { + "score": 9.8, + "severity": "critical", + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + }, + "cwe": 502, + "definition": { + "title": "Insecure Deserialization", + "description": "The endpoint deserializes untrusted input.", + "risk": "An attacker can execute arbitrary code on the host." + }, + "tags": [ + { + "uuid": "tag-1", + "name": "production" + }, + { + "uuid": "tag-2", + "name": "external" + } + ], + "references": [ + { + "uuid": "ref-1", + "name": "Advisory CVE-2000-0002", + "source": "nvd", + "link": "https://example.com/advisories/cve-2000-0001" + }, + { + "uuid": "ref-2", + "name": "", + "source": "", + "link": "https://example.com/guidance" + }, + { + "uuid": "ref-3", + "name": "Vendor bulletin", + "source": "", + "link": "" + } + ], + "request": { + "method": "POST", + "url": "https://app.example.com/api/upload", + "body": "{\"payload\":\"...\"}" + }, + "response": { + "status_code": 500, + "body": "Internal Server Error" + } + } + ], + "has_more": false, + "total_vulnerabilities": 1 +} diff --git a/unittests/scans/dragos/dragos_many_vuln.json b/unittests/scans/dragos/dragos_many_vuln.json new file mode 100644 index 00000000000..cdc07f6a00b --- /dev/null +++ b/unittests/scans/dragos/dragos_many_vuln.json @@ -0,0 +1,94 @@ +{ + "content": [ + { + "host": { + "id": "asset-0001", + "name": "generic-plc-01", + "ip": ["10.10.0.11"], + "vendor": "Generic Controls", + "type": "PLC", + "is_ot": true, + "zone": {"id": "zone-1", "name": "Cell Zone A"}, + "pera_level": 1, + "hardware": {"model": "GC-9000", "vendor": "Generic Controls", + "firmware": {"version": "2.4.1"}} + }, + "vulnerability": { + "id": "vuln-0001", + "report_id": "DRA-2024-0001", + "reference": "CVE-2000-0002 and CVE-2000-0001", + "enumeration": "CVE-2000-0001", + "title": "Unauthenticated command execution on the controller", + "summary": "The controller accepts control commands without authentication.", + "severity": 2, + "score": {"base": 9.8, "version": "3.1"}, + "dragos_score": 8.5, + "mitigations": ["Segment the control network."], + "intel": {"active_exploit": true, "poc_exists": false, "remotely_exploitable": true} + } + }, + { + "host": { + "id": "asset-0002", + "name": "", + "hostname": ["generic-hmi-02.plant.example.com"], + "ip": ["10.10.0.21"], + "type": "HMI", + "is_ot": true, + "zone": {"name": ""}, + "pera_level": 0, + "hardware": {"model": "HMI-100", "vendor": "Generic HMI Works", + "firmware": {"version": "5.0.0"}} + }, + "vulnerability": { + "id": "vuln-0002", + "report_id": "DRA-2024-0002", + "reference": "", + "enumeration": "", + "title": "Default credentials on the operator panel", + "severity": "4", + "score": {"base": "0"}, + "dragos_score": 6, + "mitigations": [], + "intel": {"active_exploit": false, "poc_exists": true, "remotely_exploitable": false} + } + }, + { + "host": { + "id": "asset-0003", + "hostname": [" "], + "ip": ["10.10.0.31"], + "is_ot": false, + "hardware": {"firmware": {}} + }, + "vulnerability": { + "id": "vuln-0003", + "report_id": "", + "reference": "GHSA-aaaa-bbbb-cccc", + "enumeration": "", + "title": "", + "severity": 0, + "score": {"base": 0}, + "dragos_score": 0, + "intel": {} + } + }, + { + "host": { + "id": "asset-0004", + "hardware": {} + }, + "vulnerability": { + "id": "vuln-0004", + "title": "A finding on an asset known only by its id", + "severity": 3, + "score": {}, + "intel": {"active_exploit": false} + } + } + ], + "pageSize": 100, + "pageNumber": 0, + "totalCount": 4, + "totalPages": 1 +} diff --git a/unittests/scans/dragos/dragos_no_vuln.json b/unittests/scans/dragos/dragos_no_vuln.json new file mode 100644 index 00000000000..1d50262af97 --- /dev/null +++ b/unittests/scans/dragos/dragos_no_vuln.json @@ -0,0 +1,7 @@ +{ + "content": [], + "pageSize": 100, + "pageNumber": 0, + "totalCount": 0, + "totalPages": 0 +} diff --git a/unittests/scans/dragos/dragos_one_vuln.json b/unittests/scans/dragos/dragos_one_vuln.json new file mode 100644 index 00000000000..5ec9e7e8bdc --- /dev/null +++ b/unittests/scans/dragos/dragos_one_vuln.json @@ -0,0 +1,53 @@ +{ + "content": [ + { + "host": { + "id": "asset-0001", + "name": "generic-plc-01", + "hostname": ["generic-plc-01.plant.example.com"], + "ip": ["10.10.0.11", "10.10.0.12"], + "mac": ["00:00:5e:00:53:01"], + "vendor": "Generic Controls", + "type": "PLC", + "is_ot": true, + "zone": {"id": "zone-1", "name": "Cell Zone A"}, + "pera_level": 1, + "hardware": { + "model": "GC-9000", + "family": "GC", + "series": "9000", + "vendor": "Generic Controls", + "firmware": {"version": "2.4.1"} + } + }, + "vulnerability": { + "id": "vuln-0001", + "report_id": "DRA-2024-0001", + "reference": "CVE-2000-0001", + "enumeration": "CVE-2000-0001", + "title": "Unauthenticated command execution on the controller", + "summary": "The controller accepts control commands without authentication.", + "description": "A device on the same network segment can issue control commands.", + "severity": 5, + "score": {"base": 9.8, "version": "3.1"}, + "dragos_score": 8.5, + "mitigations": [ + "Restrict the control protocol to the engineering workstation segment.", + "Apply vendor firmware 2.4.3 at the next maintenance window." + ], + "state": "open", + "intel": { + "active_exploit": true, + "poc_exists": true, + "remotely_exploitable": true, + "code_execution": true, + "privilege_escalation": false + } + } + } + ], + "pageSize": 100, + "pageNumber": 0, + "totalCount": 1, + "totalPages": 1 +} diff --git a/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_many_vuln.json b/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_many_vuln.json new file mode 100644 index 00000000000..05018329706 --- /dev/null +++ b/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_many_vuln.json @@ -0,0 +1,379 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 13, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-cnvm-1", + "_index": "logs-cloud_security_posture.vulnerabilities-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0001", + "title": "openssl: remote code execution", + "description": "A memory-safety flaw allows remote code execution.", + "severity": "HIGH", + "reference": "https://nvd.example.com/vuln/detail/CVE-2000-0001", + "classification": "CVSS", + "score": { + "base": 9.8, + "version": "3.1" + }, + "published_date": "2024-05-01T00:00:00.000Z" + }, + "package": { + "name": "openssl", + "version": "3.0.2", + "fixed_version": "3.0.13", + "type": "deb" + }, + "resource": { + "id": "i-0000000000000001", + "name": "web-node-1", + "type": "instance", + "sub_type": "ec2" + }, + "host": { + "name": "web-node-1", + "hostname": "web-node-1.example.com", + "os": { + "name": "Ubuntu", + "version": "22.04", + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0002", + "description": "An integer overflow crashes the parser.", + "severity": "SEVERE", + "score": { + "base": 7.5, + "version": "3.0" + } + }, + "package": { + "name": "libxml2", + "version": "2.9.13", + "type": "deb" + }, + "host": { + "name": "web-node-1" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-3", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0003", + "description": "A legacy issue scored under CVSS v2 only.", + "severity": "not a label", + "score": { + "base": "5.0", + "version": "2.0" + } + }, + "package": { + "name": "zlib", + "version": "1.2.11" + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-cnvm-4", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0004", + "severity": "unrecognised" + }, + "kubernetes": { + "namespace": "payments", + "pod": { + "name": "api-7c9f-abcde" + } + }, + "orchestrator": { + "cluster": { + "id": "cluster-1", + "name": "example-cluster" + } + } + } + }, + { + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0005", + "severity": "low" + }, + "package": { + "name": "curl", + "version": "7.81.0" + }, + "resource": { + "name": "batch-node-2", + "type": "instance" + } + } + }, + { + "_id": "doc-cnvm-not-a-vulnerability", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "description": "A document with no CVE id." + }, + "host": { + "name": "web-node-1" + } + } + }, + { + "_id": "doc-posture-1", + "_index": "logs-cloud_security_posture.findings-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0001", + "name": "Ensure the audit log is enabled", + "description": "Checks the API server's audit-log configuration.", + "rationale": "An audit log records administrative actions for later review.", + "remediation": "Enable the audit log in the cluster configuration.", + "impact": "Log volume increases.", + "section": "Logging", + "tags": [ + "CIS", + "CIS 3.2.1", + "Kubernetes" + ], + "references": "https://docs.example.com/cis/3.2.1", + "severity": "medium", + "benchmark": { + "id": "cis_k8s", + "name": "CIS Kubernetes V1.23", + "version": "v1.0.1", + "posture_type": "kspm", + "rule_number": "3.2.1" + } + }, + "result": { + "evaluation": "failed" + }, + "orchestrator": { + "cluster_name": "example-cluster" + }, + "kubernetes": { + "namespace": "kube-system", + "pod": { + "name": "kube-apiserver-1" + } + }, + "host": { + "name": "control-plane-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + } + } + }, + { + "_id": "doc-posture-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "name": "Ensure encryption at rest is configured", + "description": "The same text as the rationale.", + "rationale": "The same text as the rationale.", + "remediation": "Enable encryption at rest.", + "severity": "not a label", + "benchmark": { + "id": "cis_aws", + "name": "CIS AWS Foundations", + "version": "v1.5.0", + "posture_type": "cspm", + "rule_number": "2.1.1" + } + }, + "result": { + "evaluation": "FAILED" + }, + "resource": { + "id": "bucket-1", + "name": "example-bucket", + "type": "s3", + "sub_type": "bucket" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-posture-passed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0003", + "name": "Ensure logging is enabled", + "severity": "high" + }, + "result": { + "evaluation": "passed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-posture-unnamed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0004", + "severity": "high" + }, + "result": { + "evaluation": "failed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-detection-1", + "_index": ".internal.alerts-security.alerts-default-000001", + "_source": { + "@timestamp": "2024-07-02T08:30:00.000Z", + "message": "Suspicious process started", + "event": { + "kind": "signal", + "category": [ + "process", + "malware" + ], + "module": "endpoint" + }, + "kibana": { + "alert": { + "uuid": "alert-0001", + "reason": "malware detected on web-node-1", + "severity": "critical", + "risk_score": 99, + "workflow_status": "open", + "rule": { + "uuid": "rule-uuid-0001", + "name": "Malware Detection Alert", + "description": "Detects known malware signatures on an endpoint.", + "severity": "critical", + "risk_score": 99, + "tags": [ + "Elastic", + "Endpoint Security" + ], + "references": [ + "https://docs.example.com/rules/malware", + "https://attack.example.com/techniques/T0000" + ] + } + } + }, + "host": { + "name": "web-node-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-detection-2", + "_source": { + "@timestamp": "2024-07-02T09:00:00.000Z", + "event": { + "category": [ + "network" + ] + }, + "signal": { + "uuid": "alert-0002", + "reason": "unusual outbound connection from batch-node-2", + "rule": { + "uuid": "rule-uuid-0002", + "severity": "not a label", + "risk_score": "47" + } + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-detection-3", + "_source": { + "@timestamp": "2024-07-02T09:30:00.000Z", + "kibana": { + "alert": { + "uuid": "alert-0003", + "reason": "", + "severity": "low", + "rule": { + "uuid": "rule-uuid-0003", + "name": "" + } + } + }, + "host": { + "name": "web-node-1" + } + } + } + ] + } +} diff --git a/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_no_vuln.json b/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_no_vuln.json new file mode 100644 index 00000000000..9f9e7ea1fac --- /dev/null +++ b/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_no_vuln.json @@ -0,0 +1,226 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 7, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-posture-1", + "_index": "logs-cloud_security_posture.findings-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0001", + "name": "Ensure the audit log is enabled", + "description": "Checks the API server's audit-log configuration.", + "rationale": "An audit log records administrative actions for later review.", + "remediation": "Enable the audit log in the cluster configuration.", + "impact": "Log volume increases.", + "section": "Logging", + "tags": [ + "CIS", + "CIS 3.2.1", + "Kubernetes" + ], + "references": "https://docs.example.com/cis/3.2.1", + "severity": "medium", + "benchmark": { + "id": "cis_k8s", + "name": "CIS Kubernetes V1.23", + "version": "v1.0.1", + "posture_type": "kspm", + "rule_number": "3.2.1" + } + }, + "result": { + "evaluation": "failed" + }, + "orchestrator": { + "cluster_name": "example-cluster" + }, + "kubernetes": { + "namespace": "kube-system", + "pod": { + "name": "kube-apiserver-1" + } + }, + "host": { + "name": "control-plane-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + } + } + }, + { + "_id": "doc-posture-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "name": "Ensure encryption at rest is configured", + "description": "The same text as the rationale.", + "rationale": "The same text as the rationale.", + "remediation": "Enable encryption at rest.", + "severity": "not a label", + "benchmark": { + "id": "cis_aws", + "name": "CIS AWS Foundations", + "version": "v1.5.0", + "posture_type": "cspm", + "rule_number": "2.1.1" + } + }, + "result": { + "evaluation": "FAILED" + }, + "resource": { + "id": "bucket-1", + "name": "example-bucket", + "type": "s3", + "sub_type": "bucket" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-posture-passed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0003", + "name": "Ensure logging is enabled", + "severity": "high" + }, + "result": { + "evaluation": "passed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-posture-unnamed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0004", + "severity": "high" + }, + "result": { + "evaluation": "failed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-detection-1", + "_index": ".internal.alerts-security.alerts-default-000001", + "_source": { + "@timestamp": "2024-07-02T08:30:00.000Z", + "message": "Suspicious process started", + "event": { + "kind": "signal", + "category": [ + "process", + "malware" + ], + "module": "endpoint" + }, + "kibana": { + "alert": { + "uuid": "alert-0001", + "reason": "malware detected on web-node-1", + "severity": "critical", + "risk_score": 99, + "workflow_status": "open", + "rule": { + "uuid": "rule-uuid-0001", + "name": "Malware Detection Alert", + "description": "Detects known malware signatures on an endpoint.", + "severity": "critical", + "risk_score": 99, + "tags": [ + "Elastic", + "Endpoint Security" + ], + "references": [ + "https://docs.example.com/rules/malware", + "https://attack.example.com/techniques/T0000" + ] + } + } + }, + "host": { + "name": "web-node-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-detection-2", + "_source": { + "@timestamp": "2024-07-02T09:00:00.000Z", + "event": { + "category": [ + "network" + ] + }, + "signal": { + "uuid": "alert-0002", + "reason": "unusual outbound connection from batch-node-2", + "rule": { + "uuid": "rule-uuid-0002", + "severity": "not a label", + "risk_score": "47" + } + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-detection-3", + "_source": { + "@timestamp": "2024-07-02T09:30:00.000Z", + "kibana": { + "alert": { + "uuid": "alert-0003", + "reason": "", + "severity": "low", + "rule": { + "uuid": "rule-uuid-0003", + "name": "" + } + } + }, + "host": { + "name": "web-node-1" + } + } + } + ] + } +} diff --git a/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_one_vuln.json b/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_one_vuln.json new file mode 100644 index 00000000000..8b0c817f0dc --- /dev/null +++ b/unittests/scans/elastic_security_cnvm/elastic_security_cnvm_one_vuln.json @@ -0,0 +1,60 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 1, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-cnvm-1", + "_index": "logs-cloud_security_posture.vulnerabilities-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0001", + "title": "openssl: remote code execution", + "description": "A memory-safety flaw allows remote code execution.", + "severity": "HIGH", + "reference": "https://nvd.example.com/vuln/detail/CVE-2000-0001", + "classification": "CVSS", + "score": { + "base": 9.8, + "version": "3.1" + }, + "published_date": "2024-05-01T00:00:00.000Z" + }, + "package": { + "name": "openssl", + "version": "3.0.2", + "fixed_version": "3.0.13", + "type": "deb" + }, + "resource": { + "id": "i-0000000000000001", + "name": "web-node-1", + "type": "instance", + "sub_type": "ec2" + }, + "host": { + "name": "web-node-1", + "hostname": "web-node-1.example.com", + "os": { + "name": "Ubuntu", + "version": "22.04", + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + } + ] + } +} diff --git a/unittests/scans/elastic_security_detections/elastic_security_detections_many_vuln.json b/unittests/scans/elastic_security_detections/elastic_security_detections_many_vuln.json new file mode 100644 index 00000000000..05018329706 --- /dev/null +++ b/unittests/scans/elastic_security_detections/elastic_security_detections_many_vuln.json @@ -0,0 +1,379 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 13, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-cnvm-1", + "_index": "logs-cloud_security_posture.vulnerabilities-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0001", + "title": "openssl: remote code execution", + "description": "A memory-safety flaw allows remote code execution.", + "severity": "HIGH", + "reference": "https://nvd.example.com/vuln/detail/CVE-2000-0001", + "classification": "CVSS", + "score": { + "base": 9.8, + "version": "3.1" + }, + "published_date": "2024-05-01T00:00:00.000Z" + }, + "package": { + "name": "openssl", + "version": "3.0.2", + "fixed_version": "3.0.13", + "type": "deb" + }, + "resource": { + "id": "i-0000000000000001", + "name": "web-node-1", + "type": "instance", + "sub_type": "ec2" + }, + "host": { + "name": "web-node-1", + "hostname": "web-node-1.example.com", + "os": { + "name": "Ubuntu", + "version": "22.04", + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0002", + "description": "An integer overflow crashes the parser.", + "severity": "SEVERE", + "score": { + "base": 7.5, + "version": "3.0" + } + }, + "package": { + "name": "libxml2", + "version": "2.9.13", + "type": "deb" + }, + "host": { + "name": "web-node-1" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-3", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0003", + "description": "A legacy issue scored under CVSS v2 only.", + "severity": "not a label", + "score": { + "base": "5.0", + "version": "2.0" + } + }, + "package": { + "name": "zlib", + "version": "1.2.11" + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-cnvm-4", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0004", + "severity": "unrecognised" + }, + "kubernetes": { + "namespace": "payments", + "pod": { + "name": "api-7c9f-abcde" + } + }, + "orchestrator": { + "cluster": { + "id": "cluster-1", + "name": "example-cluster" + } + } + } + }, + { + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0005", + "severity": "low" + }, + "package": { + "name": "curl", + "version": "7.81.0" + }, + "resource": { + "name": "batch-node-2", + "type": "instance" + } + } + }, + { + "_id": "doc-cnvm-not-a-vulnerability", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "description": "A document with no CVE id." + }, + "host": { + "name": "web-node-1" + } + } + }, + { + "_id": "doc-posture-1", + "_index": "logs-cloud_security_posture.findings-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0001", + "name": "Ensure the audit log is enabled", + "description": "Checks the API server's audit-log configuration.", + "rationale": "An audit log records administrative actions for later review.", + "remediation": "Enable the audit log in the cluster configuration.", + "impact": "Log volume increases.", + "section": "Logging", + "tags": [ + "CIS", + "CIS 3.2.1", + "Kubernetes" + ], + "references": "https://docs.example.com/cis/3.2.1", + "severity": "medium", + "benchmark": { + "id": "cis_k8s", + "name": "CIS Kubernetes V1.23", + "version": "v1.0.1", + "posture_type": "kspm", + "rule_number": "3.2.1" + } + }, + "result": { + "evaluation": "failed" + }, + "orchestrator": { + "cluster_name": "example-cluster" + }, + "kubernetes": { + "namespace": "kube-system", + "pod": { + "name": "kube-apiserver-1" + } + }, + "host": { + "name": "control-plane-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + } + } + }, + { + "_id": "doc-posture-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "name": "Ensure encryption at rest is configured", + "description": "The same text as the rationale.", + "rationale": "The same text as the rationale.", + "remediation": "Enable encryption at rest.", + "severity": "not a label", + "benchmark": { + "id": "cis_aws", + "name": "CIS AWS Foundations", + "version": "v1.5.0", + "posture_type": "cspm", + "rule_number": "2.1.1" + } + }, + "result": { + "evaluation": "FAILED" + }, + "resource": { + "id": "bucket-1", + "name": "example-bucket", + "type": "s3", + "sub_type": "bucket" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-posture-passed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0003", + "name": "Ensure logging is enabled", + "severity": "high" + }, + "result": { + "evaluation": "passed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-posture-unnamed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0004", + "severity": "high" + }, + "result": { + "evaluation": "failed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-detection-1", + "_index": ".internal.alerts-security.alerts-default-000001", + "_source": { + "@timestamp": "2024-07-02T08:30:00.000Z", + "message": "Suspicious process started", + "event": { + "kind": "signal", + "category": [ + "process", + "malware" + ], + "module": "endpoint" + }, + "kibana": { + "alert": { + "uuid": "alert-0001", + "reason": "malware detected on web-node-1", + "severity": "critical", + "risk_score": 99, + "workflow_status": "open", + "rule": { + "uuid": "rule-uuid-0001", + "name": "Malware Detection Alert", + "description": "Detects known malware signatures on an endpoint.", + "severity": "critical", + "risk_score": 99, + "tags": [ + "Elastic", + "Endpoint Security" + ], + "references": [ + "https://docs.example.com/rules/malware", + "https://attack.example.com/techniques/T0000" + ] + } + } + }, + "host": { + "name": "web-node-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-detection-2", + "_source": { + "@timestamp": "2024-07-02T09:00:00.000Z", + "event": { + "category": [ + "network" + ] + }, + "signal": { + "uuid": "alert-0002", + "reason": "unusual outbound connection from batch-node-2", + "rule": { + "uuid": "rule-uuid-0002", + "severity": "not a label", + "risk_score": "47" + } + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-detection-3", + "_source": { + "@timestamp": "2024-07-02T09:30:00.000Z", + "kibana": { + "alert": { + "uuid": "alert-0003", + "reason": "", + "severity": "low", + "rule": { + "uuid": "rule-uuid-0003", + "name": "" + } + } + }, + "host": { + "name": "web-node-1" + } + } + } + ] + } +} diff --git a/unittests/scans/elastic_security_detections/elastic_security_detections_no_vuln.json b/unittests/scans/elastic_security_detections/elastic_security_detections_no_vuln.json new file mode 100644 index 00000000000..2bb2a6dd21a --- /dev/null +++ b/unittests/scans/elastic_security_detections/elastic_security_detections_no_vuln.json @@ -0,0 +1,282 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 10, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-cnvm-1", + "_index": "logs-cloud_security_posture.vulnerabilities-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0001", + "title": "openssl: remote code execution", + "description": "A memory-safety flaw allows remote code execution.", + "severity": "HIGH", + "reference": "https://nvd.example.com/vuln/detail/CVE-2000-0001", + "classification": "CVSS", + "score": { + "base": 9.8, + "version": "3.1" + }, + "published_date": "2024-05-01T00:00:00.000Z" + }, + "package": { + "name": "openssl", + "version": "3.0.2", + "fixed_version": "3.0.13", + "type": "deb" + }, + "resource": { + "id": "i-0000000000000001", + "name": "web-node-1", + "type": "instance", + "sub_type": "ec2" + }, + "host": { + "name": "web-node-1", + "hostname": "web-node-1.example.com", + "os": { + "name": "Ubuntu", + "version": "22.04", + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0002", + "description": "An integer overflow crashes the parser.", + "severity": "SEVERE", + "score": { + "base": 7.5, + "version": "3.0" + } + }, + "package": { + "name": "libxml2", + "version": "2.9.13", + "type": "deb" + }, + "host": { + "name": "web-node-1" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-3", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0003", + "description": "A legacy issue scored under CVSS v2 only.", + "severity": "not a label", + "score": { + "base": "5.0", + "version": "2.0" + } + }, + "package": { + "name": "zlib", + "version": "1.2.11" + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-cnvm-4", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0004", + "severity": "unrecognised" + }, + "kubernetes": { + "namespace": "payments", + "pod": { + "name": "api-7c9f-abcde" + } + }, + "orchestrator": { + "cluster": { + "id": "cluster-1", + "name": "example-cluster" + } + } + } + }, + { + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0005", + "severity": "low" + }, + "package": { + "name": "curl", + "version": "7.81.0" + }, + "resource": { + "name": "batch-node-2", + "type": "instance" + } + } + }, + { + "_id": "doc-cnvm-not-a-vulnerability", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "description": "A document with no CVE id." + }, + "host": { + "name": "web-node-1" + } + } + }, + { + "_id": "doc-posture-1", + "_index": "logs-cloud_security_posture.findings-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0001", + "name": "Ensure the audit log is enabled", + "description": "Checks the API server's audit-log configuration.", + "rationale": "An audit log records administrative actions for later review.", + "remediation": "Enable the audit log in the cluster configuration.", + "impact": "Log volume increases.", + "section": "Logging", + "tags": [ + "CIS", + "CIS 3.2.1", + "Kubernetes" + ], + "references": "https://docs.example.com/cis/3.2.1", + "severity": "medium", + "benchmark": { + "id": "cis_k8s", + "name": "CIS Kubernetes V1.23", + "version": "v1.0.1", + "posture_type": "kspm", + "rule_number": "3.2.1" + } + }, + "result": { + "evaluation": "failed" + }, + "orchestrator": { + "cluster_name": "example-cluster" + }, + "kubernetes": { + "namespace": "kube-system", + "pod": { + "name": "kube-apiserver-1" + } + }, + "host": { + "name": "control-plane-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + } + } + }, + { + "_id": "doc-posture-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "name": "Ensure encryption at rest is configured", + "description": "The same text as the rationale.", + "rationale": "The same text as the rationale.", + "remediation": "Enable encryption at rest.", + "severity": "not a label", + "benchmark": { + "id": "cis_aws", + "name": "CIS AWS Foundations", + "version": "v1.5.0", + "posture_type": "cspm", + "rule_number": "2.1.1" + } + }, + "result": { + "evaluation": "FAILED" + }, + "resource": { + "id": "bucket-1", + "name": "example-bucket", + "type": "s3", + "sub_type": "bucket" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-posture-passed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0003", + "name": "Ensure logging is enabled", + "severity": "high" + }, + "result": { + "evaluation": "passed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-posture-unnamed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0004", + "severity": "high" + }, + "result": { + "evaluation": "failed" + }, + "host": { + "name": "control-plane-1" + } + } + } + ] + } +} diff --git a/unittests/scans/elastic_security_detections/elastic_security_detections_one_vuln.json b/unittests/scans/elastic_security_detections/elastic_security_detections_one_vuln.json new file mode 100644 index 00000000000..9917cf9aee1 --- /dev/null +++ b/unittests/scans/elastic_security_detections/elastic_security_detections_one_vuln.json @@ -0,0 +1,65 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 1, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-detection-1", + "_index": ".internal.alerts-security.alerts-default-000001", + "_source": { + "@timestamp": "2024-07-02T08:30:00.000Z", + "message": "Suspicious process started", + "event": { + "kind": "signal", + "category": [ + "process", + "malware" + ], + "module": "endpoint" + }, + "kibana": { + "alert": { + "uuid": "alert-0001", + "reason": "malware detected on web-node-1", + "severity": "critical", + "risk_score": 99, + "workflow_status": "open", + "rule": { + "uuid": "rule-uuid-0001", + "name": "Malware Detection Alert", + "description": "Detects known malware signatures on an endpoint.", + "severity": "critical", + "risk_score": 99, + "tags": [ + "Elastic", + "Endpoint Security" + ], + "references": [ + "https://docs.example.com/rules/malware", + "https://attack.example.com/techniques/T0000" + ] + } + } + }, + "host": { + "name": "web-node-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + } + ] + } +} diff --git a/unittests/scans/elastic_security_posture/elastic_security_posture_many_vuln.json b/unittests/scans/elastic_security_posture/elastic_security_posture_many_vuln.json new file mode 100644 index 00000000000..05018329706 --- /dev/null +++ b/unittests/scans/elastic_security_posture/elastic_security_posture_many_vuln.json @@ -0,0 +1,379 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 13, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-cnvm-1", + "_index": "logs-cloud_security_posture.vulnerabilities-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0001", + "title": "openssl: remote code execution", + "description": "A memory-safety flaw allows remote code execution.", + "severity": "HIGH", + "reference": "https://nvd.example.com/vuln/detail/CVE-2000-0001", + "classification": "CVSS", + "score": { + "base": 9.8, + "version": "3.1" + }, + "published_date": "2024-05-01T00:00:00.000Z" + }, + "package": { + "name": "openssl", + "version": "3.0.2", + "fixed_version": "3.0.13", + "type": "deb" + }, + "resource": { + "id": "i-0000000000000001", + "name": "web-node-1", + "type": "instance", + "sub_type": "ec2" + }, + "host": { + "name": "web-node-1", + "hostname": "web-node-1.example.com", + "os": { + "name": "Ubuntu", + "version": "22.04", + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0002", + "description": "An integer overflow crashes the parser.", + "severity": "SEVERE", + "score": { + "base": 7.5, + "version": "3.0" + } + }, + "package": { + "name": "libxml2", + "version": "2.9.13", + "type": "deb" + }, + "host": { + "name": "web-node-1" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-3", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0003", + "description": "A legacy issue scored under CVSS v2 only.", + "severity": "not a label", + "score": { + "base": "5.0", + "version": "2.0" + } + }, + "package": { + "name": "zlib", + "version": "1.2.11" + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-cnvm-4", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0004", + "severity": "unrecognised" + }, + "kubernetes": { + "namespace": "payments", + "pod": { + "name": "api-7c9f-abcde" + } + }, + "orchestrator": { + "cluster": { + "id": "cluster-1", + "name": "example-cluster" + } + } + } + }, + { + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0005", + "severity": "low" + }, + "package": { + "name": "curl", + "version": "7.81.0" + }, + "resource": { + "name": "batch-node-2", + "type": "instance" + } + } + }, + { + "_id": "doc-cnvm-not-a-vulnerability", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "description": "A document with no CVE id." + }, + "host": { + "name": "web-node-1" + } + } + }, + { + "_id": "doc-posture-1", + "_index": "logs-cloud_security_posture.findings-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0001", + "name": "Ensure the audit log is enabled", + "description": "Checks the API server's audit-log configuration.", + "rationale": "An audit log records administrative actions for later review.", + "remediation": "Enable the audit log in the cluster configuration.", + "impact": "Log volume increases.", + "section": "Logging", + "tags": [ + "CIS", + "CIS 3.2.1", + "Kubernetes" + ], + "references": "https://docs.example.com/cis/3.2.1", + "severity": "medium", + "benchmark": { + "id": "cis_k8s", + "name": "CIS Kubernetes V1.23", + "version": "v1.0.1", + "posture_type": "kspm", + "rule_number": "3.2.1" + } + }, + "result": { + "evaluation": "failed" + }, + "orchestrator": { + "cluster_name": "example-cluster" + }, + "kubernetes": { + "namespace": "kube-system", + "pod": { + "name": "kube-apiserver-1" + } + }, + "host": { + "name": "control-plane-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + } + } + }, + { + "_id": "doc-posture-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "name": "Ensure encryption at rest is configured", + "description": "The same text as the rationale.", + "rationale": "The same text as the rationale.", + "remediation": "Enable encryption at rest.", + "severity": "not a label", + "benchmark": { + "id": "cis_aws", + "name": "CIS AWS Foundations", + "version": "v1.5.0", + "posture_type": "cspm", + "rule_number": "2.1.1" + } + }, + "result": { + "evaluation": "FAILED" + }, + "resource": { + "id": "bucket-1", + "name": "example-bucket", + "type": "s3", + "sub_type": "bucket" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-posture-passed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0003", + "name": "Ensure logging is enabled", + "severity": "high" + }, + "result": { + "evaluation": "passed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-posture-unnamed", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0004", + "severity": "high" + }, + "result": { + "evaluation": "failed" + }, + "host": { + "name": "control-plane-1" + } + } + }, + { + "_id": "doc-detection-1", + "_index": ".internal.alerts-security.alerts-default-000001", + "_source": { + "@timestamp": "2024-07-02T08:30:00.000Z", + "message": "Suspicious process started", + "event": { + "kind": "signal", + "category": [ + "process", + "malware" + ], + "module": "endpoint" + }, + "kibana": { + "alert": { + "uuid": "alert-0001", + "reason": "malware detected on web-node-1", + "severity": "critical", + "risk_score": 99, + "workflow_status": "open", + "rule": { + "uuid": "rule-uuid-0001", + "name": "Malware Detection Alert", + "description": "Detects known malware signatures on an endpoint.", + "severity": "critical", + "risk_score": 99, + "tags": [ + "Elastic", + "Endpoint Security" + ], + "references": [ + "https://docs.example.com/rules/malware", + "https://attack.example.com/techniques/T0000" + ] + } + } + }, + "host": { + "name": "web-node-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-detection-2", + "_source": { + "@timestamp": "2024-07-02T09:00:00.000Z", + "event": { + "category": [ + "network" + ] + }, + "signal": { + "uuid": "alert-0002", + "reason": "unusual outbound connection from batch-node-2", + "rule": { + "uuid": "rule-uuid-0002", + "severity": "not a label", + "risk_score": "47" + } + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-detection-3", + "_source": { + "@timestamp": "2024-07-02T09:30:00.000Z", + "kibana": { + "alert": { + "uuid": "alert-0003", + "reason": "", + "severity": "low", + "rule": { + "uuid": "rule-uuid-0003", + "name": "" + } + } + }, + "host": { + "name": "web-node-1" + } + } + } + ] + } +} diff --git a/unittests/scans/elastic_security_posture/elastic_security_posture_no_vuln.json b/unittests/scans/elastic_security_posture/elastic_security_posture_no_vuln.json new file mode 100644 index 00000000000..05d0c4bb7b0 --- /dev/null +++ b/unittests/scans/elastic_security_posture/elastic_security_posture_no_vuln.json @@ -0,0 +1,261 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 9, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-cnvm-1", + "_index": "logs-cloud_security_posture.vulnerabilities-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0001", + "title": "openssl: remote code execution", + "description": "A memory-safety flaw allows remote code execution.", + "severity": "HIGH", + "reference": "https://nvd.example.com/vuln/detail/CVE-2000-0001", + "classification": "CVSS", + "score": { + "base": 9.8, + "version": "3.1" + }, + "published_date": "2024-05-01T00:00:00.000Z" + }, + "package": { + "name": "openssl", + "version": "3.0.2", + "fixed_version": "3.0.13", + "type": "deb" + }, + "resource": { + "id": "i-0000000000000001", + "name": "web-node-1", + "type": "instance", + "sub_type": "ec2" + }, + "host": { + "name": "web-node-1", + "hostname": "web-node-1.example.com", + "os": { + "name": "Ubuntu", + "version": "22.04", + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-2", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0002", + "description": "An integer overflow crashes the parser.", + "severity": "SEVERE", + "score": { + "base": 7.5, + "version": "3.0" + } + }, + "package": { + "name": "libxml2", + "version": "2.9.13", + "type": "deb" + }, + "host": { + "name": "web-node-1" + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-cnvm-3", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0003", + "description": "A legacy issue scored under CVSS v2 only.", + "severity": "not a label", + "score": { + "base": "5.0", + "version": "2.0" + } + }, + "package": { + "name": "zlib", + "version": "1.2.11" + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-cnvm-4", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0004", + "severity": "unrecognised" + }, + "kubernetes": { + "namespace": "payments", + "pod": { + "name": "api-7c9f-abcde" + } + }, + "orchestrator": { + "cluster": { + "id": "cluster-1", + "name": "example-cluster" + } + } + } + }, + { + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "id": "CVE-2000-0005", + "severity": "low" + }, + "package": { + "name": "curl", + "version": "7.81.0" + }, + "resource": { + "name": "batch-node-2", + "type": "instance" + } + } + }, + { + "_id": "doc-cnvm-not-a-vulnerability", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "vulnerability": { + "description": "A document with no CVE id." + }, + "host": { + "name": "web-node-1" + } + } + }, + { + "_id": "doc-detection-1", + "_index": ".internal.alerts-security.alerts-default-000001", + "_source": { + "@timestamp": "2024-07-02T08:30:00.000Z", + "message": "Suspicious process started", + "event": { + "kind": "signal", + "category": [ + "process", + "malware" + ], + "module": "endpoint" + }, + "kibana": { + "alert": { + "uuid": "alert-0001", + "reason": "malware detected on web-node-1", + "severity": "critical", + "risk_score": 99, + "workflow_status": "open", + "rule": { + "uuid": "rule-uuid-0001", + "name": "Malware Detection Alert", + "description": "Detects known malware signatures on an endpoint.", + "severity": "critical", + "risk_score": 99, + "tags": [ + "Elastic", + "Endpoint Security" + ], + "references": [ + "https://docs.example.com/rules/malware", + "https://attack.example.com/techniques/T0000" + ] + } + } + }, + "host": { + "name": "web-node-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + }, + "cloud": { + "provider": "aws", + "region": "us-east-1", + "account": { + "id": "acct-1234", + "name": "example-account" + } + } + } + }, + { + "_id": "doc-detection-2", + "_source": { + "@timestamp": "2024-07-02T09:00:00.000Z", + "event": { + "category": [ + "network" + ] + }, + "signal": { + "uuid": "alert-0002", + "reason": "unusual outbound connection from batch-node-2", + "rule": { + "uuid": "rule-uuid-0002", + "severity": "not a label", + "risk_score": "47" + } + }, + "host": { + "hostname": "batch-node-2.example.com" + } + } + }, + { + "_id": "doc-detection-3", + "_source": { + "@timestamp": "2024-07-02T09:30:00.000Z", + "kibana": { + "alert": { + "uuid": "alert-0003", + "reason": "", + "severity": "low", + "rule": { + "uuid": "rule-uuid-0003", + "name": "" + } + } + }, + "host": { + "name": "web-node-1" + } + } + } + ] + } +} diff --git a/unittests/scans/elastic_security_posture/elastic_security_posture_one_vuln.json b/unittests/scans/elastic_security_posture/elastic_security_posture_one_vuln.json new file mode 100644 index 00000000000..5124d4343c4 --- /dev/null +++ b/unittests/scans/elastic_security_posture/elastic_security_posture_one_vuln.json @@ -0,0 +1,59 @@ +{ + "pit_id": "example-pit-id", + "hits": { + "total": { + "value": 1, + "relation": "eq" + }, + "hits": [ + { + "_id": "doc-posture-1", + "_index": "logs-cloud_security_posture.findings-default", + "_source": { + "@timestamp": "2024-07-01T12:00:00.000Z", + "rule": { + "id": "rule-0001", + "name": "Ensure the audit log is enabled", + "description": "Checks the API server's audit-log configuration.", + "rationale": "An audit log records administrative actions for later review.", + "remediation": "Enable the audit log in the cluster configuration.", + "impact": "Log volume increases.", + "section": "Logging", + "tags": [ + "CIS", + "CIS 3.2.1", + "Kubernetes" + ], + "references": "https://docs.example.com/cis/3.2.1", + "severity": "medium", + "benchmark": { + "id": "cis_k8s", + "name": "CIS Kubernetes V1.23", + "version": "v1.0.1", + "posture_type": "kspm", + "rule_number": "3.2.1" + } + }, + "result": { + "evaluation": "failed" + }, + "orchestrator": { + "cluster_name": "example-cluster" + }, + "kubernetes": { + "namespace": "kube-system", + "pod": { + "name": "kube-apiserver-1" + } + }, + "host": { + "name": "control-plane-1", + "os": { + "full": "Ubuntu 22.04.4 LTS" + } + } + } + } + ] + } +} diff --git a/unittests/scans/endorlabs/endorlabs_many_vuln.json b/unittests/scans/endorlabs/endorlabs_many_vuln.json new file mode 100644 index 00000000000..5f7a969e27d --- /dev/null +++ b/unittests/scans/endorlabs/endorlabs_many_vuln.json @@ -0,0 +1,109 @@ +{ + "list": { + "objects": [ + { + "uuid": "00000000-0000-4000-8000-000000000001", + "meta": { + "name": "CVE-2000-0001 in generic-lib", + "kind": "Finding", + "create_time": "2026-06-01T12:00:00.000Z" + }, + "tenant_meta": {"namespace": "example-tenant"}, + "spec": { + "level": "FINDING_LEVEL_CRITICAL", + "finding_categories": ["FINDING_CATEGORY_VULNERABILITY", "FINDING_CATEGORY_SCA"], + "finding_tags": [ + "FINDING_TAGS_REACHABLE_FUNCTION", + "FINDING_TAGS_DIRECT", + "FINDING_TAGS_FIX_AVAILABLE", + "FINDING_TAGS_UNSPECIFIED" + ], + "summary": "generic-lib is vulnerable to remote code execution.", + "explanation": "<p>The <b>parse()</b> helper evaluates untrusted input.</p><script>alert('x')</script>", + "remediation": "Upgrade generic-lib to 1.2.4 or later.", + "ecosystem": "ECOSYSTEM_NPM", + "project_uuid": "00000000-0000-4000-8000-0000000000aa", + "target_dependency_name": "generic-lib", + "target_dependency_version": "1.2.3", + "target_dependency_package_name": "npm://generic-lib", + "finding_metadata": { + "vulnerability": { + "uuid": "00000000-0000-4000-8000-0000000000bb", + "meta": {"name": "CVE-2000-0001"}, + "spec": { + "aliases": ["GHSA-0000-0000-0001", "CVE-2000-0001"], + "summary": "Remote code execution in generic-lib.", + "references": [ + "https://example.com/advisories/cve-2000-0001", + "https://example.com/commit/abcdef1" + ], + "cvss_v3_severity": { + "level": "CRITICAL", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "cvss_v4_severity": {"base_level": "CRITICAL", "base_score": 9.3, "vector": "CVSS:4.0/AV:N"}, + "epss_score": {"probability_score": 0.04231, "percentile_score": 0.91}, + "disputed": false, + "malicious": false + } + } + } + } + }, + { + "uuid": "00000000-0000-4000-8000-000000000002", + "meta": {"name": ""}, + "spec": { + "level": "FINDING_LEVEL_HIGH", + "finding_categories": ["FINDING_CATEGORY_SCA"], + "finding_tags": ["FINDING_TAGS_UNREACHABLE_DEPENDENCY", "FINDING_TAGS_TRANSITIVE"], + "summary": "generic-parser is vulnerable to denial of service.", + "target_dependency_name": "", + "target_dependency_package_name": "pypi://generic-parser", + "target_dependency_version": "3.1.0", + "finding_metadata": { + "vulnerability": { + "meta": {"name": "GHSA-0000-0000-0002"}, + "spec": { + "aliases": [], + "summary": "Unbounded recursion.", + "references": [], + "cvss_v4_severity": {"base_level": "HIGH", "base_score": 7.7, "vector": "CVSS:4.0/AV:N"}, + "epss_score": {"probability_score": 0} + } + } + } + } + }, + { + "uuid": "", + "meta": {"name": ""}, + "spec": { + "level": "FINDING_LEVEL_MEDIUM", + "finding_categories": [], + "finding_tags": ["FINDING_TAGS_POTENTIALLY_REACHABLE_FUNCTION"], + "target_dependency_name": "generic-widget", + "target_dependency_version": "4.0.0", + "finding_metadata": { + "vulnerability": {"meta": {"name": "CVE-2000-0003"}, "spec": {}} + } + } + }, + { + "uuid": "00000000-0000-4000-8000-000000000004", + "meta": {"name": "Secret committed in generic-app"}, + "spec": { + "level": "NOT_A_LEVEL", + "finding_categories": ["FINDING_CATEGORY_SECRETS", "FINDING_CATEGORY_UNSPECIFIED"], + "finding_tags": [], + "summary": "A credential was committed to the repository.", + "target_dependency_name": "", + "target_dependency_version": "", + "finding_metadata": {} + } + } + ], + "response": {"next_page_id": "", "next_page_token": 0} + } +} diff --git a/unittests/scans/endorlabs/endorlabs_no_vuln.json b/unittests/scans/endorlabs/endorlabs_no_vuln.json new file mode 100644 index 00000000000..f8769342976 --- /dev/null +++ b/unittests/scans/endorlabs/endorlabs_no_vuln.json @@ -0,0 +1,8 @@ +{ + "list": { + "objects": [], + "response": { + "next_page_id": "" + } + } +} diff --git a/unittests/scans/endorlabs/endorlabs_one_vuln.json b/unittests/scans/endorlabs/endorlabs_one_vuln.json new file mode 100644 index 00000000000..7ba83192be1 --- /dev/null +++ b/unittests/scans/endorlabs/endorlabs_one_vuln.json @@ -0,0 +1,76 @@ +{ + "list": { + "objects": [ + { + "uuid": "00000000-0000-4000-8000-000000000001", + "meta": { + "name": "CVE-2000-0001 in generic-lib", + "kind": "Finding", + "create_time": "2026-06-01T12:00:00.000Z" + }, + "tenant_meta": { + "namespace": "example-tenant" + }, + "spec": { + "level": "FINDING_LEVEL_CRITICAL", + "finding_categories": [ + "FINDING_CATEGORY_VULNERABILITY", + "FINDING_CATEGORY_SCA" + ], + "finding_tags": [ + "FINDING_TAGS_REACHABLE_FUNCTION", + "FINDING_TAGS_DIRECT", + "FINDING_TAGS_FIX_AVAILABLE", + "FINDING_TAGS_UNSPECIFIED" + ], + "summary": "generic-lib is vulnerable to remote code execution.", + "explanation": "<p>The <b>parse()</b> helper evaluates untrusted input.</p><script>alert('x')</script>", + "remediation": "Upgrade generic-lib to 1.2.4 or later.", + "ecosystem": "ECOSYSTEM_NPM", + "project_uuid": "00000000-0000-4000-8000-0000000000aa", + "target_dependency_name": "generic-lib", + "target_dependency_version": "1.2.3", + "target_dependency_package_name": "npm://generic-lib", + "finding_metadata": { + "vulnerability": { + "uuid": "00000000-0000-4000-8000-0000000000bb", + "meta": { + "name": "CVE-2000-0001" + }, + "spec": { + "aliases": [ + "GHSA-0000-0000-0001", + "CVE-2000-0001" + ], + "summary": "Remote code execution in generic-lib.", + "references": [ + "https://example.com/advisories/cve-2000-0001", + "https://example.com/commit/abcdef1" + ], + "cvss_v3_severity": { + "level": "CRITICAL", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "cvss_v4_severity": { + "base_level": "CRITICAL", + "base_score": 9.3, + "vector": "CVSS:4.0/AV:N" + }, + "epss_score": { + "probability_score": 0.04231, + "percentile_score": 0.91 + }, + "disputed": false, + "malicious": false + } + } + } + } + } + ], + "response": { + "next_page_id": "" + } + } +} diff --git a/unittests/scans/escape/escape_many_vuln.json b/unittests/scans/escape/escape_many_vuln.json new file mode 100644 index 00000000000..b2928a44eaa --- /dev/null +++ b/unittests/scans/escape/escape_many_vuln.json @@ -0,0 +1,48 @@ +{ + "id": "scan-0001", + "status": "SUCCESS", + "issues": [ + { + "id": "issue-0001", + "name": "SQL injection in the reports query", + "severity": "high", + "url": "https://api.example.com/v1/reports", + "method": "post", + "description": "A request parameter is concatenated into a database query.", + "remediation": "Use parameterised queries.", + "owasp": "API8:2023", + "cwe": "CWE-89" + }, + { + "id": "issue-0002", + "name": "Missing rate limiting", + "severity": "medium", + "url": "https://api.example.com/v1/login", + "method": "post", + "description": "The endpoint accepted 500 requests in ten seconds.", + "owasp": "API4:2023", + "cwe": "770" + }, + { + "id": "issue-0003", + "name": "Verbose error message", + "severity": "low", + "url": "https://api.example.com:8443/v1/search?q=1", + "description": "The response body contained a stack trace.", + "cwe": "not a cwe" + }, + { + "id": "issue-0004", + "severity": "not a label", + "url": "https://api.example.com/v1/health", + "method": "get" + }, + { + "id": "issue-0005", + "name": "An issue with no endpoint", + "severity": "info", + "description": "Reported against the application as a whole.", + "owasp": "API9:2023" + } + ] +} diff --git a/unittests/scans/escape/escape_no_vuln.json b/unittests/scans/escape/escape_no_vuln.json new file mode 100644 index 00000000000..7b154beb496 --- /dev/null +++ b/unittests/scans/escape/escape_no_vuln.json @@ -0,0 +1,5 @@ +{ + "id": "scan-0002", + "status": "SUCCESS", + "issues": [] +} diff --git a/unittests/scans/escape/escape_one_vuln.json b/unittests/scans/escape/escape_one_vuln.json new file mode 100644 index 00000000000..cea100da65d --- /dev/null +++ b/unittests/scans/escape/escape_one_vuln.json @@ -0,0 +1,21 @@ +{ + "id": "app-0001", + "name": "generic-api", + "lastScan": { + "id": "scan-0001", + "status": "SUCCESS", + "issues": [ + { + "id": "issue-0001", + "name": "SQL injection in the reports query", + "severity": "high", + "url": "https://api.example.com/v1/reports", + "method": "post", + "description": "A request parameter is concatenated into a database query.", + "remediation": "Use parameterised queries.", + "owasp": "API8:2023", + "cwe": "CWE-89" + } + ] + } +} diff --git a/unittests/scans/fairwinds/fairwinds_many_vuln.json b/unittests/scans/fairwinds/fairwinds_many_vuln.json new file mode 100644 index 00000000000..c3a908a2392 --- /dev/null +++ b/unittests/scans/fairwinds/fairwinds_many_vuln.json @@ -0,0 +1,106 @@ +[ + { + "ID": 5001, + "Title": "CVE-2000-0001 in openssl", + "Severity": 0.95, + "Category": "Security", + "ReportType": "trivy", + "EventType": "", + "Cluster": "generic-prod", + "ResourceKind": "Deployment", + "ResourceName": "generic-api", + "ResourceNamespace": "generic-app", + "ResourceContainer": "api", + "Description": "<p>The image ships a vulnerable openssl.</p>", + "Remediation": "Rebuild the image on a patched base.", + "Notes": "Raised by the platform team.", + "Fixed": false, + "FirstSeen": "2026-07-20T09:00:00Z", + "LastReportedAt": "2026-07-31T09:00:00Z", + "ImageName": "registry.example.com/generic-api", + "ImageTag": "1.4.0", + "ImageSHA": "sha256:aaaa1111", + "Tags": ["owner/platform"], + "TicketLink": "", + "DeletedAt": null + }, + { + "ID": 5002, + "Title": "Container has no CPU limit", + "Severity": 0.75, + "Category": "Reliability", + "ReportType": "polaris", + "Cluster": "generic-prod", + "ResourceKind": "Deployment", + "ResourceName": "generic-worker", + "ResourceNamespace": "generic-app", + "ResourceContainer": "", + "Description": "No CPU limit is set.", + "Remediation": "Set resources.limits.cpu.", + "Notes": "", + "Fixed": false, + "ImageName": "", + "ImageTag": "", + "Tags": [] + }, + { + "ID": 5003, + "Title": "Privileged container", + "Severity": 0.5, + "Category": "Security", + "ReportType": "opa", + "EventType": "admission", + "Cluster": "generic-staging", + "ResourceKind": "Pod", + "ResourceName": "debug-pod", + "ResourceNamespace": "", + "Description": "The pod runs privileged.", + "Remediation": "", + "Fixed": true, + "ImageName": "", + "Tags": ["reviewed"] + }, + { + "ID": 5004, + "Title": "", + "Severity": 0.2, + "Category": "", + "ReportType": "", + "Cluster": "", + "ResourceKind": "", + "ResourceName": "", + "ResourceNamespace": "", + "Description": "", + "Fixed": false, + "ImageName": "", + "Tags": [] + }, + { + "ID": 5005, + "Title": "Informational note", + "Severity": 0.05, + "Category": "Efficiency", + "ReportType": "goldilocks", + "Cluster": "generic-prod", + "ResourceKind": "Deployment", + "ResourceName": "generic-api", + "ResourceNamespace": "generic-app", + "Description": "Requests are oversized.", + "Fixed": false, + "ImageName": "", + "Tags": [] + }, + { + "ID": 5006, + "Title": "Malformed severity", + "Severity": "not-a-number", + "Category": "Security", + "ReportType": "trivy", + "Cluster": "generic-prod", + "ResourceName": "generic-api", + "Description": "", + "Fixed": false, + "ImageName": "", + "Tags": [] + } +] diff --git a/unittests/scans/fairwinds/fairwinds_no_vuln.json b/unittests/scans/fairwinds/fairwinds_no_vuln.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/unittests/scans/fairwinds/fairwinds_no_vuln.json @@ -0,0 +1 @@ +[] diff --git a/unittests/scans/fairwinds/fairwinds_one_vuln.json b/unittests/scans/fairwinds/fairwinds_one_vuln.json new file mode 100644 index 00000000000..038df059fa4 --- /dev/null +++ b/unittests/scans/fairwinds/fairwinds_one_vuln.json @@ -0,0 +1,29 @@ +[ + { + "ID": 5001, + "Title": "CVE-2000-0001 in openssl", + "Severity": 0.95, + "Category": "Security", + "ReportType": "trivy", + "EventType": "", + "Cluster": "generic-prod", + "ResourceKind": "Deployment", + "ResourceName": "generic-api", + "ResourceNamespace": "generic-app", + "ResourceContainer": "api", + "Description": "<p>The image ships a vulnerable openssl.</p>", + "Remediation": "Rebuild the image on a patched base.", + "Notes": "Raised by the platform team.", + "Fixed": false, + "FirstSeen": "2026-07-20T09:00:00Z", + "LastReportedAt": "2026-07-31T09:00:00Z", + "ImageName": "registry.example.com/generic-api", + "ImageTag": "1.4.0", + "ImageSHA": "sha256:aaaa1111", + "Tags": [ + "owner/platform" + ], + "TicketLink": "", + "DeletedAt": null + } +] diff --git a/unittests/scans/finitestate/finitestate_findings_only.json b/unittests/scans/finitestate/finitestate_findings_only.json new file mode 100644 index 00000000000..eb914a28787 --- /dev/null +++ b/unittests/scans/finitestate/finitestate_findings_only.json @@ -0,0 +1,14 @@ +[ + { + "id": "finding-0010", + "title": "A finding exported with no build context", + "date": "2024-06-02T10:05:00Z", + "severity": "high", + "cvssScore": 7.5, + "vulnIdFromTool": "FS-2024-0010", + "description": "Exported from the findings query alone.", + "category": "SBOM", + "affects": [{"name": "example-lib", "version": "1.0.0"}], + "cves": [{"cveId": "CVE-2000-0011"}] + } +] diff --git a/unittests/scans/finitestate/finitestate_many_vuln.json b/unittests/scans/finitestate/finitestate_many_vuln.json new file mode 100644 index 00000000000..4ece8566e66 --- /dev/null +++ b/unittests/scans/finitestate/finitestate_many_vuln.json @@ -0,0 +1,89 @@ +{ + "asset": {"id": "asset-0001", "name": "Generic Router"}, + "assetVersion": {"id": "ver-0001", "name": "1.4.0", "relativeRiskScore": 2.5}, + "data": { + "allFindings": [ + { + "id": "finding-0001", + "title": "Outdated TLS library in the firmware image", + "date": "2024-06-02T10:05:00Z", + "severity": "critical", + "cvssScore": 9.8, + "riskScore": 87.5, + "vulnIdFromTool": "FS-2024-0001", + "description": "The image bundles a TLS library with known flaws.", + "category": "SBOM", + "subcategory": "Known vulnerability", + "sourceTypes": ["BINARY_ANALYSIS"], + "affects": [{"id": "comp-1", "name": "example-tls", "version": "1.0.2k"}], + "currentStatus": {"status": "AFFECTED", "updatedAt": "2024-06-03T11:00:00Z"}, + "cwes": [{"cweId": "CWE-787"}], + "cves": [ + {"cveId": "CVE-2000-0001", "epss": {"epssScore": 0.11, "epssPercentile": 0.80}, + "cvssBaseMetricV3": {"cvssv3": {"baseScore": 9.8, "vectorString": "CVSS:3.1/AV:N"}}, + "exploitsInfo": {"weaponized": false, "reportedInTheWild": false}}, + {"cveId": "CVE-2000-0002", "epss": {"epssScore": 0.62, "epssPercentile": 0.99}, + "cvssBaseMetricV3": {"cvssv3": {"baseScore": 7.5, "vectorString": "CVSS:3.1/AV:L"}}, + "exploitsInfo": {"weaponized": true, "reportedInTheWild": true}} + ], + "origin": "FIRMWARE", + "test": {"tools": [{"name": "Binary Analysis"}, {"name": "SBOM"}]} + }, + { + "id": "finding-0002", + "title": "A vulnerability the product team has ruled out", + "createdAt": "2024-05-20T08:00:00Z", + "severity": "high", + "cvssScore": 0, + "cvssSeverity": "medium", + "description": "", + "category": "SBOM", + "affects": [{"name": "example-parser", "version": "2.0.0"}], + "currentStatus": { + "status": "NOT_AFFECTED", + "justification": "VULNERABLE_CODE_NOT_PRESENT", + "comment": "The vulnerable routine is compiled out of this build.", + "updatedAt": "2024-06-04T09:00:00Z" + }, + "cwes": [{"cweId": "not a cwe"}, {"cweId": "89"}], + "cves": [{"cveId": "CVE-2000-0003"}] + }, + { + "id": "finding-0003", + "title": "A vulnerability ruled out for a reason that is not absence", + "createdAt": "2024-05-21T08:00:00Z", + "severity": "medium", + "currentStatus": { + "status": "NOT_AFFECTED", + "justification": "INLINE_MITIGATIONS_ALREADY_EXIST", + "updatedAt": "2024-06-04T09:00:00Z" + }, + "affects": [{"name": "example-utils", "version": "1.1.0"}] + }, + { + "id": "finding-0004", + "title": "A vulnerability fixed in a later build", + "createdAt": "2024-05-22T08:00:00Z", + "severity": "low", + "currentStatus": {"status": "FIXED", "updatedAt": "2024-06-05T14:30:00Z"}, + "affects": [{"name": "example-agent", "version": "3.0.0"}], + "regression": true + }, + { + "id": "finding-0005", + "title": "A vulnerability nobody has ruled on yet", + "createdAt": "2024-05-23T08:00:00Z", + "severity": "", + "cvssSeverity": "high", + "currentStatus": {"status": "UNDER_INVESTIGATION", "updatedAt": "2024-06-06T09:00:00Z"} + }, + { + "id": "finding-0006", + "title": "A finding with no status assertion at all", + "createdAt": "not a timestamp", + "severity": "a word Finite State does not use", + "cvssSeverity": "also not a level" + } + ] + } +} diff --git a/unittests/scans/finitestate/finitestate_no_vuln.json b/unittests/scans/finitestate/finitestate_no_vuln.json new file mode 100644 index 00000000000..546abef849c --- /dev/null +++ b/unittests/scans/finitestate/finitestate_no_vuln.json @@ -0,0 +1,7 @@ +{ + "asset": {"id": "asset-0001", "name": "Generic Router"}, + "assetVersion": {"id": "ver-0001", "name": "1.4.0", "relativeRiskScore": 0}, + "data": { + "allFindings": [] + } +} diff --git a/unittests/scans/finitestate/finitestate_one_vuln.json b/unittests/scans/finitestate/finitestate_one_vuln.json new file mode 100644 index 00000000000..2226977635f --- /dev/null +++ b/unittests/scans/finitestate/finitestate_one_vuln.json @@ -0,0 +1,56 @@ +{ + "asset": {"id": "asset-0001", "name": "Generic Router"}, + "assetVersion": {"id": "ver-0001", "name": "1.4.0", "relativeRiskScore": 2.5}, + "data": { + "allFindings": [ + { + "_cursor": "cursor-1", + "id": "finding-0001", + "title": "Outdated TLS library in the firmware image", + "date": "2024-06-02T10:05:00Z", + "createdAt": "2024-06-01T09:00:00Z", + "updatedAt": "2024-06-03T11:00:00Z", + "severity": "critical", + "cvssScore": 9.8, + "cvssSeverity": "high", + "riskScore": 87.5, + "vulnIdFromTool": "FS-2024-0001", + "description": "The image bundles a TLS library with known remote code execution flaws.", + "category": "SBOM", + "subcategory": "Known vulnerability", + "sourceTypes": ["BINARY_ANALYSIS"], + "regression": false, + "affects": [{"id": "comp-1", "name": "example-tls", "version": "1.0.2k"}], + "currentStatus": { + "id": "status-1", + "status": "AFFECTED", + "justification": "", + "comment": "Confirmed present in the shipping build.", + "updatedAt": "2024-06-03T11:00:00Z" + }, + "cwes": [{"id": "cwe-1", "cweId": "CWE-787", "name": "Out-of-bounds Write"}], + "cves": [ + { + "id": "cve-1", + "cveId": "CVE-2000-0001", + "cvssScore": 9.8, + "epss": {"epssScore": 0.42, "epssPercentile": 0.97}, + "cvssBaseMetricV3": { + "cvssv3": { + "baseScore": 9.8, + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + }, + "exploitsInfo": { + "exploitProofOfConcept": true, + "reportedInTheWild": true, + "weaponized": true + } + } + ], + "origin": "FIRMWARE", + "test": {"id": "test-1", "tools": [{"id": "tool-1", "name": "Binary Analysis"}]} + } + ] + } +} diff --git a/unittests/scans/fleet_policies/fleet_policies_many_vuln.json b/unittests/scans/fleet_policies/fleet_policies_many_vuln.json new file mode 100644 index 00000000000..8f5ff8d8787 --- /dev/null +++ b/unittests/scans/fleet_policies/fleet_policies_many_vuln.json @@ -0,0 +1,75 @@ +{ + "hosts": [ + { + "id": 11, + "hostname": "laptop-11.example.com", + "computer_name": "Laptop 11", + "display_name": "laptop-11", + "platform": "darwin", + "os_version": "macOS 14.5", + "primary_ip": "10.0.0.11", + "policies": [ + { + "id": 90, + "name": "Disk encryption enabled", + "query": "SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT NULL AND filevault_status = 'on';", + "description": "Checks that FileVault is enabled on the startup volume.", + "resolution": "Turn on FileVault in System Settings, Privacy & Security.", + "platform": "darwin", + "critical": true, + "response": "fail" + }, + { + "id": 91, + "name": "Screen lock after 15 minutes", + "query": "SELECT 1 FROM screenlock WHERE enabled = 1 AND grace_period <= 900;", + "description": "Checks that the screen locks within 15 minutes.", + "resolution": "Set the screen lock to 15 minutes or less.", + "platform": "darwin", + "critical": false, + "response": "fail" + }, + { + "id": 92, + "name": "Firewall enabled", + "query": "SELECT 1 FROM alf WHERE global_state >= 1;", + "resolution": "Turn on the firewall.", + "platform": "darwin", + "critical": true, + "response": "pass" + }, + { + "id": 93, + "name": "", + "query": "SELECT 1;", + "response": "fail" + }, + { + "id": 94, + "name": "Automatic updates enabled", + "description": "Checks that automatic updates are on.", + "response": "" + } + ], + "software": [] + }, + { + "id": 12, + "hostname": "server-12.example.com", + "platform": "ubuntu", + "os_version": "22.04.4 LTS", + "primary_ip": "10.0.0.12", + "policies": [ + { + "id": 90, + "name": "Disk encryption enabled", + "description": "Checks that the root volume is encrypted.", + "resolution": "Enable full-disk encryption.", + "platform": "linux", + "critical": true, + "response": "FAIL" + } + ] + } + ] +} diff --git a/unittests/scans/fleet_policies/fleet_policies_no_vuln.json b/unittests/scans/fleet_policies/fleet_policies_no_vuln.json new file mode 100644 index 00000000000..434101bc664 --- /dev/null +++ b/unittests/scans/fleet_policies/fleet_policies_no_vuln.json @@ -0,0 +1,18 @@ +{ + "hosts": [ + { + "id": 11, + "hostname": "laptop-11.example.com", + "platform": "darwin", + "os_version": "macOS 14.5", + "policies": [ + {"id": 90, "name": "Disk encryption enabled", "critical": true, "response": "pass"}, + {"id": 91, "name": "Screen lock after 15 minutes", "response": "pass"} + ], + "software": [ + {"id": 501, "name": "Example Browser", "version": "120.0.2", + "vulnerabilities": [{"cve": "CVE-2000-0001", "cvss_score": 9.8}]} + ] + } + ] +} diff --git a/unittests/scans/fleet_policies/fleet_policies_one_vuln.json b/unittests/scans/fleet_policies/fleet_policies_one_vuln.json new file mode 100644 index 00000000000..04f85981209 --- /dev/null +++ b/unittests/scans/fleet_policies/fleet_policies_one_vuln.json @@ -0,0 +1,23 @@ +{ + "host": { + "id": 11, + "hostname": "laptop-11.example.com", + "computer_name": "Laptop 11", + "display_name": "laptop-11", + "platform": "darwin", + "os_version": "macOS 14.5", + "primary_ip": "10.0.0.11", + "policies": [ + { + "id": 90, + "name": "Disk encryption enabled", + "query": "SELECT 1 FROM disk_encryption WHERE user_uuid IS NOT NULL AND filevault_status = 'on';", + "description": "Checks that FileVault is enabled on the startup volume.", + "resolution": "Turn on FileVault in System Settings, Privacy & Security.", + "platform": "darwin", + "critical": true, + "response": "fail" + } + ] + } +} diff --git a/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_many_vuln.json b/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_many_vuln.json new file mode 100644 index 00000000000..29f42e84c31 --- /dev/null +++ b/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_many_vuln.json @@ -0,0 +1,89 @@ +{ + "hosts": [ + { + "id": 11, + "hostname": "laptop-11.example.com", + "computer_name": "Laptop 11", + "display_name": "laptop-11", + "platform": "darwin", + "os_version": "macOS 14.5", + "primary_ip": "10.0.0.11", + "primary_mac": "00:00:5e:00:53:11", + "team_id": 1, + "team_name": "Workstations", + "status": "online", + "seen_time": "2024-07-01T12:00:00Z", + "software": [ + { + "id": 501, + "name": "Example Browser", + "version": "120.0.1", + "source": "apps", + "vendor": "Example Vendor", + "generated_cpe": "cpe:2.3:a:example:browser:120.0.1:*:*:*:*:macos:*:*", + "vulnerabilities": [ + { + "cve": "CVE-2000-0001", + "cvss_score": 9.8, + "epss_probability": 0.94, + "cisa_known_exploit": true, + "cve_published": "2024-05-01T00:00:00Z", + "cve_description": "A memory-safety flaw allows remote code execution.", + "resolved_in_version": "120.0.2" + }, + { + "cve": "CVE-2000-0002", + "cvss_score": "7.5", + "cisa_known_exploit": false, + "cve_published": "2024-04-15", + "cve_description": "An out-of-bounds read discloses process memory." + } + ] + }, + { + "id": 502, + "name": "Example Reader", + "version": "9.1", + "source": "apps", + "vulnerabilities": [ + {"cve": "CVE-2000-0003", "cvss_score": 5.0}, + {"cve": "CVE-2000-0004", "cvss_score": 3.1}, + {"cve": "CVE-2000-0005", "cvss_score": 0}, + {"cve": "CVE-2000-0006"}, + {"cve": "", "cvss_score": 9.9} + ] + }, + { + "id": 503, + "name": "Example Tool", + "version": "2.0", + "vulnerabilities": [] + } + ], + "policies": [] + }, + { + "id": 12, + "hostname": "server-12.example.com", + "platform": "ubuntu", + "os_version": "22.04.4 LTS", + "primary_ip": "10.0.0.12", + "software": [ + { + "id": 504, + "name": "openssl", + "version": "3.0.2", + "source": "deb_packages", + "vulnerabilities": [ + { + "cve": "CVE-2000-0001", + "cvss_score": 9.8, + "cve_description": "A memory-safety flaw allows remote code execution.", + "resolved_in_version": "3.0.13" + } + ] + } + ] + } + ] +} diff --git a/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_no_vuln.json b/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_no_vuln.json new file mode 100644 index 00000000000..fcb4e1f2b61 --- /dev/null +++ b/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_no_vuln.json @@ -0,0 +1,17 @@ +{ + "hosts": [ + { + "id": 11, + "hostname": "laptop-11.example.com", + "platform": "darwin", + "os_version": "macOS 14.5", + "software": [ + {"id": 501, "name": "Example Browser", "version": "120.0.2", "vulnerabilities": []}, + {"id": 502, "name": "Example Reader", "version": "9.2"} + ], + "policies": [ + {"id": 90, "name": "Disk encryption enabled", "response": "fail", "critical": true} + ] + } + ] +} diff --git a/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_one_vuln.json b/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_one_vuln.json new file mode 100644 index 00000000000..3c769fc7947 --- /dev/null +++ b/unittests/scans/fleet_vulnerabilities/fleet_vulnerabilities_one_vuln.json @@ -0,0 +1,32 @@ +{ + "host": { + "id": 11, + "hostname": "laptop-11.example.com", + "computer_name": "Laptop 11", + "display_name": "laptop-11", + "platform": "darwin", + "os_version": "macOS 14.5", + "primary_ip": "10.0.0.11", + "software": [ + { + "id": 501, + "name": "Example Browser", + "version": "120.0.1", + "source": "apps", + "vendor": "Example Vendor", + "generated_cpe": "cpe:2.3:a:example:browser:120.0.1:*:*:*:*:macos:*:*", + "vulnerabilities": [ + { + "cve": "CVE-2000-0001", + "cvss_score": 9.8, + "epss_probability": 0.94, + "cisa_known_exploit": true, + "cve_published": "2024-05-01T00:00:00Z", + "cve_description": "A memory-safety flaw allows remote code execution.", + "resolved_in_version": "120.0.2" + } + ] + } + ] + } +} diff --git a/unittests/scans/fortytwocrunch/fortytwocrunch_audit_many_vuln.json b/unittests/scans/fortytwocrunch/fortytwocrunch_audit_many_vuln.json new file mode 100644 index 00000000000..e91060b3620 --- /dev/null +++ b/unittests/scans/fortytwocrunch/fortytwocrunch_audit_many_vuln.json @@ -0,0 +1,67 @@ +{ + "apiId": "api-0001", + "report": { + "index": [ + "/paths/~1orders/get/security", + "/paths/~1orders/post/requestBody", + "/paths/~1orders~1{id}/get/responses/200" + ], + "score": 41.0, + "security": { + "issues": { + "v3-global-securityscheme-apikey-inheader": { + "criticality": 4, + "description": "The API key is passed in a header, which is exposed to intermediaries", + "issues": [ + {"score": 12.5, "pointer": 0, + "specificDescription": "The GET /orders operation accepts an API key in a request header"}, + {"score": 12.5, "pointer": 1, "specificDescription": ""} + ] + }, + "v3-operation-securityscheme-missing": { + "criticality": 5, + "description": "The operation has no security requirement", + "issues": [ + {"score": 20.0, "pointer": 99, "specificDescription": "No security on POST /orders"} + ] + } + } + }, + "data": { + "issues": { + "v3-schema-response-string-maxlength": { + "criticality": 3, + "description": "The string response has no maximum length", + "issues": [{"score": 4.0, "pointer": 2, "specificDescription": ""}] + } + } + }, + "warnings": { + "issues": { + "v3-operation-summary": { + "criticality": 1, + "description": "The operation has no summary", + "issues": [{"score": 0, "pointer": 2, "specificDescription": ""}] + } + } + }, + "semanticErrors": { + "issues": { + "v3-semantic-mismatch": { + "criticality": 2, + "description": "", + "issues": [{"score": 1.0, "pointer": 0, "specificDescription": ""}] + } + } + }, + "validationErrors": { + "issues": { + "v3-validation-broken": { + "criticality": 0, + "description": "The definition does not validate", + "issues": [{"score": 0, "pointer": -1, "specificDescription": ""}] + } + } + } + } +} diff --git a/unittests/scans/fortytwocrunch/fortytwocrunch_audit_one_vuln.json b/unittests/scans/fortytwocrunch/fortytwocrunch_audit_one_vuln.json new file mode 100644 index 00000000000..b926410f372 --- /dev/null +++ b/unittests/scans/fortytwocrunch/fortytwocrunch_audit_one_vuln.json @@ -0,0 +1,29 @@ +{ + "apiId": "api-0001", + "report": { + "index": [ + "/paths/~1orders/get/security", + "/paths/~1orders/post/requestBody" + ], + "score": 62.5, + "security": { + "issues": { + "v3-global-securityscheme-apikey-inheader": { + "criticality": 4, + "description": "The API key is passed in a header, which is exposed to intermediaries", + "issues": [ + { + "score": 12.5, + "pointer": 0, + "specificDescription": "The GET /orders operation accepts an API key in a request header" + } + ] + } + } + }, + "data": {"issues": {}}, + "warnings": {"issues": {}}, + "semanticErrors": {"issues": {}}, + "validationErrors": {"issues": {}} + } +} diff --git a/unittests/scans/fortytwocrunch/fortytwocrunch_no_vuln.json b/unittests/scans/fortytwocrunch/fortytwocrunch_no_vuln.json new file mode 100644 index 00000000000..90c33e82e13 --- /dev/null +++ b/unittests/scans/fortytwocrunch/fortytwocrunch_no_vuln.json @@ -0,0 +1,12 @@ +{ + "apiId": "api-0001", + "report": { + "index": [], + "score": 100, + "security": {"issues": {}}, + "data": {"issues": {}}, + "warnings": {"issues": {}}, + "semanticErrors": {"issues": {}}, + "validationErrors": {"issues": {}} + } +} diff --git a/unittests/scans/fortytwocrunch/fortytwocrunch_scan_many_vuln.json b/unittests/scans/fortytwocrunch/fortytwocrunch_scan_many_vuln.json new file mode 100644 index 00000000000..f4dc669f661 --- /dev/null +++ b/unittests/scans/fortytwocrunch/fortytwocrunch_scan_many_vuln.json @@ -0,0 +1,75 @@ +{ + "apiId": "api-0001", + "report": { + "data": { + "index": { + "jsonPointers": [ + "/paths/~1orders/get/responses/200", + "/paths/~1orders/post/requestBody" + ], + "injectionDescriptions": [ + "The response returned %s instead of the documented %s", + "The operation accepted a request with no authentication", + "The response body did not match the schema" + ], + "responseDescriptions": ["OK", "Unauthorized"] + }, + "summary": {"state": "finished", "criticality": 5}, + "paths": { + "/orders": { + "get": { + "issues": [ + { + "id": "3f8b0c1e-0000-4000-8000-000000000001", + "criticality": 5, + "injectionDescription": 0, + "injectionDescriptionParams": ["500", "200"], + "url": "https://api.example.com/orders?limit=1", + "curl": "curl -X GET 'https://api.example.com/orders?limit=1'", + "responseHttpStatusCode": 500, + "jsonPointer": 0 + }, + { + "id": "3f8b0c1e-0000-4000-8000-000000000002", + "criticality": 4, + "injectionDescription": 1, + "injectionDescriptionParams": [], + "url": "https://api.example.com/orders", + "curl": "curl -X GET 'https://api.example.com/orders'", + "responseHttpStatusCode": 200, + "jsonPointer": 0 + } + ] + }, + "post": { + "issues": [ + { + "id": "3f8b0c1e-0000-4000-8000-000000000003", + "criticality": 3, + "injectionDescription": 2, + "url": "https://api.example.com/orders", + "curl": "", + "responseHttpStatusCode": 0, + "jsonPointer": 1 + } + ] + } + }, + "/health": { + "get": { + "issues": [ + { + "id": "3f8b0c1e-0000-4000-8000-000000000004", + "criticality": 2, + "injectionDescription": 99, + "url": "not a url at all", + "responseHttpStatusCode": 404, + "jsonPointer": 99 + } + ] + } + } + } + } + } +} diff --git a/unittests/scans/fossa/fossa_many_vuln.json b/unittests/scans/fossa/fossa_many_vuln.json new file mode 100644 index 00000000000..3a5ee4e234f --- /dev/null +++ b/unittests/scans/fossa/fossa_many_vuln.json @@ -0,0 +1,110 @@ +{ + "issues": [ + { + "id": 1001, + "createdAt": "2026-05-11T08:14:02.000Z", + "type": "vulnerability", + "vulnId": "FOSSA-2026-0001", + "title": "Prototype pollution in generic-lib", + "cve": "CVE-2000-0001", + "cvss": 9.8, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "severity": "critical", + "details": "A crafted payload can pollute Object.prototype and lead to remote code execution.", + "cveStatus": "PUBLISHED", + "published": "2026-05-01", + "cwes": ["CWE-1321", "CWE-20"], + "cpes": ["cpe:2.3:a:example:generic-lib:1.2.3:*:*:*:*:*:*:*"], + "affectedVersionRanges": ["<1.2.4"], + "patchedVersionRanges": [">=1.2.4"], + "references": [ + {"url": "https://example.com/advisories/cve-2000-0001", "title": "Advisory"}, + "https://example.com/commit/abcdef1" + ], + "remediation": { + "partialFix": "1.2.4", + "partialFixDistance": "PATCH", + "completeFix": "2.0.0", + "completeFixDistance": "MAJOR" + }, + "license": "", + "source": { + "id": "npm+generic-lib$1.2.3", + "name": "generic-lib", + "url": "https://example.com/packages/generic-lib", + "version": "1.2.3", + "packageManager": "npm" + }, + "depths": {"direct": 1, "deep": 3}, + "statuses": {"active": 1, "ignored": 0}, + "projects": [ + {"id": "custom+1/generic-app", "title": "generic-app"}, + {"id": "custom+1/generic-service", "title": "generic-service"} + ] + }, + { + "id": 1002, + "createdAt": "2026-05-12T09:00:00.000Z", + "type": "vulnerability", + "vulnId": "FOSSA-2026-0002", + "title": "Denial of service in generic-parser", + "cve": "", + "cvss": 7.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "severity": "unknown", + "details": "Deeply nested input causes unbounded recursion.", + "cwes": ["not-a-cwe"], + "cpes": [], + "affectedVersionRanges": [], + "patchedVersionRanges": [], + "references": [], + "remediation": {"completeFix": "", "partialFix": "3.1.1", "partialFixDistance": ""}, + "source": { + "id": "pip+generic-parser$3.1.0", + "name": "generic-parser", + "version": "3.1.0", + "packageManager": "pip" + }, + "depths": {"direct": 0, "deep": 0}, + "projects": [{"id": "custom+1/generic-app"}] + }, + { + "id": 1003, + "createdAt": "2026-05-13T10:30:00.000Z", + "type": "policy_conflict", + "license": "GPL-3.0-only", + "source": { + "id": "maven+com.example:generic-widget$4.0.0", + "name": "com.example:generic-widget", + "version": "4.0.0", + "packageManager": "maven" + }, + "projects": [{"id": "custom+1/generic-app"}] + }, + { + "id": 1004, + "createdAt": "2026-05-14", + "type": "risk_empty-package", + "source": {"name": "generic-stub", "version": "0.0.1", "packageManager": "npm"}, + "projects": [{"id": "custom+1/generic-app"}] + }, + { + "id": 1005, + "createdAt": "not-a-timestamp", + "type": "some_new_type_fossa_added", + "source": {"name": "generic-thing", "version": "1.0.0"}, + "projects": [{"id": "custom+1/generic-app"}] + }, + { + "id": 1006, + "createdAt": "2026-05-15T11:00:00.000Z", + "type": "", + "cve": "CVE-2000-0006", + "cvss": 0, + "severity": "", + "details": "", + "source": {}, + "projects": [{"id": "custom+1/generic-app"}] + } + ] +} diff --git a/unittests/scans/fossa/fossa_no_vuln.json b/unittests/scans/fossa/fossa_no_vuln.json new file mode 100644 index 00000000000..8fc2e32967a --- /dev/null +++ b/unittests/scans/fossa/fossa_no_vuln.json @@ -0,0 +1,3 @@ +{ + "issues": [] +} diff --git a/unittests/scans/fossa/fossa_one_vuln.json b/unittests/scans/fossa/fossa_one_vuln.json new file mode 100644 index 00000000000..97ce6072604 --- /dev/null +++ b/unittests/scans/fossa/fossa_one_vuln.json @@ -0,0 +1,66 @@ +{ + "issues": [ + { + "id": 1001, + "createdAt": "2026-05-11T08:14:02.000Z", + "type": "vulnerability", + "vulnId": "FOSSA-2026-0001", + "title": "Prototype pollution in generic-lib", + "cve": "CVE-2000-0001", + "cvss": 9.8, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "severity": "critical", + "details": "A crafted payload can pollute Object.prototype and lead to remote code execution.", + "cveStatus": "PUBLISHED", + "published": "2026-05-01", + "cwes": [ + "CWE-1321", + "CWE-20" + ], + "cpes": [ + "cpe:2.3:a:example:generic-lib:1.2.3:*:*:*:*:*:*:*" + ], + "affectedVersionRanges": [ + "<1.2.4" + ], + "patchedVersionRanges": [ + ">=1.2.4" + ], + "references": [ + { + "url": "https://example.com/advisories/cve-2000-0001", + "title": "Advisory" + }, + "https://example.com/commit/abcdef1" + ], + "remediation": { + "partialFix": "1.2.4", + "partialFixDistance": "PATCH", + "completeFix": "2.0.0", + "completeFixDistance": "MAJOR" + }, + "license": "", + "source": { + "id": "npm+generic-lib$1.2.3", + "name": "generic-lib", + "url": "https://example.com/packages/generic-lib", + "version": "1.2.3", + "packageManager": "npm" + }, + "depths": { + "direct": 1, + "deep": 3 + }, + "statuses": { + "active": 1, + "ignored": 0 + }, + "projects": [ + { + "id": "custom+1/generic-app", + "title": "generic-app" + } + ] + } + ] +} diff --git a/unittests/scans/gitguardian/gitguardian_many_vuln.json b/unittests/scans/gitguardian/gitguardian_many_vuln.json new file mode 100644 index 00000000000..3519f142b66 --- /dev/null +++ b/unittests/scans/gitguardian/gitguardian_many_vuln.json @@ -0,0 +1,61 @@ +[ + { + "id": 5001, + "date": "2026-06-02T10:15:00Z", + "status": "TRIGGERED", + "severity": "critical", + "validity": "valid", + "occurrences_count": 3, + "secret_revoked": false, + "incident_name": "AWS IAM key exposed in generic-app", + "gitguardian_url": "https://dashboard.example.com/incidents/5001", + "detector": { + "name": "aws_iam", + "display_name": "AWS IAM Key", + "nature": "specific", + "family": "api_key", + "category": "cloud_provider" + } + }, + { + "id": 5002, + "date": "2026-06-03T11:00:00Z", + "status": "TRIGGERED", + "severity": "high", + "validity": "invalid", + "occurrences_count": 1, + "secret_revoked": true, + "incident_name": "", + "gitguardian_url": "https://dashboard.example.com/incidents/5002", + "detector": { + "name": "generic_api_key", + "display_name": "Generic API Key", + "family": "api_key", + "category": "other" + } + }, + { + "id": 5003, + "date": "2026-06-04T12:00:00Z", + "status": "TRIGGERED", + "severity": "medium", + "validity": "no_checker", + "occurrences_count": 0, + "secret_revoked": false, + "incident_name": "", + "gitguardian_url": "", + "detector": {"name": "private_key", "display_name": "", "family": "", "category": ""} + }, + { + "id": 5004, + "date": "2026-06-05T13:00:00Z", + "status": "TRIGGERED", + "severity": "not-a-severity", + "validity": "some_new_validity", + "occurrences_count": 7, + "secret_revoked": false, + "incident_name": "Database URI exposed", + "gitguardian_url": "https://dashboard.example.com/incidents/5004", + "detector": {"name": "database_uri", "display_name": "Database URI", "category": "database"} + } +] diff --git a/unittests/scans/gitguardian/gitguardian_no_vuln.json b/unittests/scans/gitguardian/gitguardian_no_vuln.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/unittests/scans/gitguardian/gitguardian_no_vuln.json @@ -0,0 +1 @@ +[] diff --git a/unittests/scans/gitguardian/gitguardian_one_vuln.json b/unittests/scans/gitguardian/gitguardian_one_vuln.json new file mode 100644 index 00000000000..22566deec61 --- /dev/null +++ b/unittests/scans/gitguardian/gitguardian_one_vuln.json @@ -0,0 +1,20 @@ +[ + { + "id": 5001, + "date": "2026-06-02T10:15:00Z", + "status": "TRIGGERED", + "severity": "critical", + "validity": "valid", + "occurrences_count": 3, + "secret_revoked": false, + "incident_name": "AWS IAM key exposed in generic-app", + "gitguardian_url": "https://dashboard.example.com/incidents/5001", + "detector": { + "name": "aws_iam", + "display_name": "AWS IAM Key", + "nature": "specific", + "family": "api_key", + "category": "cloud_provider" + } + } +] diff --git a/unittests/scans/googlescc/googlescc_many_vuln.json b/unittests/scans/googlescc/googlescc_many_vuln.json new file mode 100644 index 00000000000..76c1bee53dc --- /dev/null +++ b/unittests/scans/googlescc/googlescc_many_vuln.json @@ -0,0 +1,84 @@ +{ + "listFindingsResults": [ + { + "finding": { + "name": "organizations/000000000001/sources/000000000002/findings/aaaa1111", + "canonicalName": "projects/000000000003/sources/000000000002/findings/aaaa1111", + "category": "PUBLIC_BUCKET_ACL", + "state": "ACTIVE", + "mute": "UNDEFINED", + "severity": "HIGH", + "description": "The bucket grants access to allUsers.", + "externalUri": "https://console.example.com/storage/browser/generic-app-assets", + "eventTime": "2026-07-31T09:00:00Z", + "findingClass": "MISCONFIGURATION", + "resourceName": "//storage.googleapis.com/generic-app-assets" + }, + "resource": { + "name": "//storage.googleapis.com/generic-app-assets", + "displayName": "generic-app-assets", + "type": "google.cloud.storage.Bucket", + "service": "storage.googleapis.com", + "cloudProvider": "GOOGLE_CLOUD_PLATFORM", + "gcpMetadata": {"project": "//cloudresourcemanager.googleapis.com/projects/000000000003", + "projectDisplayName": "generic-app"} + } + }, + { + "finding": { + "name": "organizations/000000000001/sources/000000000002/findings/bbbb2222", + "category": "OS_VULNERABILITY", + "state": "ACTIVE", + "severity": "CRITICAL", + "description": "An outdated package is installed on the instance.", + "externalUri": "", + "eventTime": "2026-07-30T09:00:00Z", + "findingClass": "VULNERABILITY", + "resourceName": "//compute.googleapis.com/projects/p/zones/z/instances/generic-vm", + "vulnerability": { + "cve": {"id": "CVE-2000-0001", "cvssv3": {"baseScore": 9.8}} + } + }, + "resource": { + "name": "//compute.googleapis.com/projects/p/zones/z/instances/generic-vm", + "displayName": "generic-vm", + "type": "google.compute.Instance", + "service": "compute.googleapis.com" + } + }, + { + "finding": { + "name": "organizations/000000000001/sources/000000000002/findings/cccc3333", + "category": "OS_VULNERABILITY", + "severity": "MEDIUM", + "description": "A package with an advisory but no score.", + "findingClass": "VULNERABILITY", + "vulnerability": {"cve": {"id": "CVE-2000-0002", "cvssv3": {"baseScore": 0}}} + }, + "resource": {"name": "//compute.googleapis.com/projects/p/instances/other-vm", "type": ""} + }, + { + "finding": { + "name": "organizations/000000000001/sources/000000000002/findings/dddd4444", + "category": "", + "severity": "SEVERITY_UNSPECIFIED", + "description": "", + "findingClass": "OBSERVATION" + }, + "resource": {"name": "", "displayName": "", "type": ""} + }, + { + "finding": { + "name": "organizations/000000000001/sources/000000000002/findings/eeee5555", + "category": "MFA_NOT_ENFORCED", + "severity": "LOW", + "description": "A user has no second factor.", + "findingClass": "MISCONFIGURATION", + "vulnerability": {} + }, + "resource": {"displayName": "user@example.com", "type": "google.iam.ServiceAccount"} + } + ], + "totalSize": 5, + "readTime": "2026-07-31T10:00:00Z" +} diff --git a/unittests/scans/googlescc/googlescc_no_vuln.json b/unittests/scans/googlescc/googlescc_no_vuln.json new file mode 100644 index 00000000000..471f3282f78 --- /dev/null +++ b/unittests/scans/googlescc/googlescc_no_vuln.json @@ -0,0 +1,5 @@ +{ + "listFindingsResults": [], + "totalSize": 0, + "readTime": "2026-07-31T10:00:00Z" +} diff --git a/unittests/scans/googlescc/googlescc_one_vuln.json b/unittests/scans/googlescc/googlescc_one_vuln.json new file mode 100644 index 00000000000..c0be0a8e692 --- /dev/null +++ b/unittests/scans/googlescc/googlescc_one_vuln.json @@ -0,0 +1,32 @@ +{ + "listFindingsResults": [ + { + "finding": { + "name": "organizations/000000000001/sources/000000000002/findings/aaaa1111", + "canonicalName": "projects/000000000003/sources/000000000002/findings/aaaa1111", + "category": "PUBLIC_BUCKET_ACL", + "state": "ACTIVE", + "mute": "UNDEFINED", + "severity": "HIGH", + "description": "The bucket grants access to allUsers.", + "externalUri": "https://console.example.com/storage/browser/generic-app-assets", + "eventTime": "2026-07-31T09:00:00Z", + "findingClass": "MISCONFIGURATION", + "resourceName": "//storage.googleapis.com/generic-app-assets" + }, + "resource": { + "name": "//storage.googleapis.com/generic-app-assets", + "displayName": "generic-app-assets", + "type": "google.cloud.storage.Bucket", + "service": "storage.googleapis.com", + "cloudProvider": "GOOGLE_CLOUD_PLATFORM", + "gcpMetadata": { + "project": "//cloudresourcemanager.googleapis.com/projects/000000000003", + "projectDisplayName": "generic-app" + } + } + } + ], + "totalSize": 1, + "readTime": "2026-07-31T10:00:00Z" +} diff --git a/unittests/scans/groupib/groupib_many_vuln.json b/unittests/scans/groupib/groupib_many_vuln.json new file mode 100644 index 00000000000..67d0cdf75a2 --- /dev/null +++ b/unittests/scans/groupib/groupib_many_vuln.json @@ -0,0 +1,86 @@ +{ + "count": 6, + "items": [ + { + "id": "issue-0001", + "companyName": "Generic Org", + "companyId": "company-1", + "status": "Detected", + "firstSeen": "2024-06-02T10:00:00Z", + "lastSeen": "2024-06-05T10:00:00Z", + "body": { + "category": "Vulnerability", + "type": "Exposed administrative interface", + "status": "Critical severity", + "reason": "The management console is reachable from the internet", + "asset": "admin.example.com", + "assetId": "asset-1", + "assetDiscovered": "2024-05-01", + "assetStatus": "Confirmed", + "descriptions": "The console accepted a connection from an external address.", + "context": "Discovered during external scanning.", + "alertMitreInfo": {"T1595": "Active Scanning", "T1190": "Exploit Public-Facing Application"} + } + }, + { + "id": "issue-0002", + "status": "Solved", + "firstSeen": "2024-05-20T08:00:00Z", + "body": { + "category": "Configuration", + "type": "Weak TLS configuration", + "status": "High severity", + "asset": "10.20.0.11:8443", + "descriptions": "TLS 1.0 was offered." + } + }, + { + "id": "issue-0003", + "status": "False positive", + "firstSeen": "2024-05-21T08:00:00Z", + "body": { + "category": "Software", + "type": "Outdated software", + "status": "Medium severity", + "asset": "OpenSSL 1.0.2k", + "reason": "A version banner suggested an old release" + } + }, + { + "id": "issue-0004", + "status": "Ignored", + "firstSeen": "2024-05-22T08:00:00Z", + "body": { + "category": "Exposure", + "type": "", + "status": "Low severity", + "reason": "A login form was found on a marketing host", + "asset": "https://www.example.com/login" + } + }, + { + "id": "issue-0005", + "status": "Under review", + "firstSeen": "not a timestamp", + "body": { + "category": "", + "type": "", + "status": "a label Group-IB does not use", + "reason": "", + "asset": "" + } + }, + { + "id": "issue-0006", + "status": "Detected", + "firstSeen": "2024-05-23T08:00:00Z", + "body": { + "category": "Exposure", + "type": "Directory listing enabled", + "status": "Info severity", + "asset": "files.example.com", + "alertMitreInfo": {} + } + } + ] +} diff --git a/unittests/scans/groupib/groupib_no_vuln.json b/unittests/scans/groupib/groupib_no_vuln.json new file mode 100644 index 00000000000..c097b2a3536 --- /dev/null +++ b/unittests/scans/groupib/groupib_no_vuln.json @@ -0,0 +1,4 @@ +{ + "count": 0, + "items": [] +} diff --git a/unittests/scans/groupib/groupib_one_vuln.json b/unittests/scans/groupib/groupib_one_vuln.json new file mode 100644 index 00000000000..c58a1116943 --- /dev/null +++ b/unittests/scans/groupib/groupib_one_vuln.json @@ -0,0 +1,29 @@ +{ + "count": 1, + "items": [ + { + "id": "issue-0001", + "companyName": "Generic Org", + "companyId": "company-1", + "status": "Detected", + "firstSeen": "2024-06-02T10:00:00Z", + "lastSeen": "2024-06-05T10:00:00Z", + "body": { + "category": "Vulnerability", + "type": "Exposed administrative interface", + "status": "Critical severity", + "reason": "The management console is reachable from the internet", + "asset": "admin.example.com", + "assetId": "asset-1", + "assetDiscovered": "2024-05-01", + "assetStatus": "Confirmed", + "descriptions": "The console accepted a connection from an external address.", + "context": "Discovered during external scanning.", + "alertMitreInfo": { + "T1595": "Active Scanning", + "T1190": "Exploit Public-Facing Application" + } + } + } + ] +} diff --git a/unittests/scans/hackerone/hackerone_many_vuln.json b/unittests/scans/hackerone/hackerone_many_vuln.json new file mode 100644 index 00000000000..6c45dd839ff --- /dev/null +++ b/unittests/scans/hackerone/hackerone_many_vuln.json @@ -0,0 +1,100 @@ +{ + "data": [ + { + "id": "1000001", + "type": "report", + "attributes": { + "title": "Stored XSS in the profile page", + "state": "triaged", + "vulnerability_information": "A stored cross-site scripting issue lets an attacker run script in another user's session.", + "created_at": "2026-07-10T09:00:00.000Z" + }, + "relationships": { + "severity": { + "data": { + "id": "sev-1", + "type": "severity", + "attributes": { + "rating": "high", + "score": 7.6, + "cvss_vector_string": "CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N" + } + } + }, + "weakness": { + "data": { + "id": "weak-1", + "type": "weakness", + "attributes": {"external_id": "cwe-79", "name": "Cross-site Scripting (XSS) - Stored"} + } + }, + "reporter": { + "data": {"id": "user-1", "type": "user", "attributes": {"username": "researcher-one"}} + } + } + }, + { + "id": "1000002", + "type": "report", + "attributes": { + "title": "Rate limiting missing on the login endpoint", + "state": "new", + "vulnerability_information": "The login endpoint accepts unlimited attempts.", + "created_at": "2026-07-11T10:00:00.000Z" + }, + "relationships": { + "severity": { + "data": {"attributes": {"rating": "medium", "score": 5.3, "cvss_vector_string": ""}} + }, + "weakness": { + "data": {"attributes": {"external_id": "cwe-307", "name": "Improper Restriction of Authentication Attempts"}} + }, + "reporter": {"data": {"attributes": {"username": "researcher-two"}}} + } + }, + { + "id": "1000003", + "type": "report", + "attributes": { + "title": "Informative report with no severity", + "state": "informative", + "vulnerability_information": "", + "created_at": "2026-07-12T11:00:00.000Z" + }, + "relationships": { + "severity": {"data": {"attributes": {"rating": "none", "score": 0}}}, + "weakness": {"data": {"attributes": {"external_id": "", "name": ""}}}, + "reporter": {"data": {"attributes": {"username": ""}}} + } + }, + { + "id": "1000004", + "type": "report", + "attributes": { + "title": "Report with an unparseable weakness id", + "state": "triaged", + "vulnerability_information": "Details here.", + "created_at": "2026-07-13T12:00:00.000Z" + }, + "relationships": { + "severity": {"data": {"attributes": {"rating": "CRITICAL", "score": 9.9}}}, + "weakness": {"data": {"attributes": {"external_id": "capec-63", "name": "Some weakness"}}}, + "reporter": {"data": {"attributes": {"username": "researcher-three"}}} + } + }, + { + "id": "1000005", + "type": "report", + "attributes": { + "title": "Report with no relationships at all", + "state": "new", + "vulnerability_information": "Nothing linked.", + "created_at": "2026-07-14T13:00:00.000Z" + } + } + ], + "links": { + "self": "https://api.hackerone.com/v1/reports?page%5Bnumber%5D=1", + "next": "" + } +} diff --git a/unittests/scans/hackerone/hackerone_no_vuln.json b/unittests/scans/hackerone/hackerone_no_vuln.json new file mode 100644 index 00000000000..febdaa63aa6 --- /dev/null +++ b/unittests/scans/hackerone/hackerone_no_vuln.json @@ -0,0 +1,6 @@ +{ + "data": [], + "links": { + "next": "" + } +} diff --git a/unittests/scans/hackerone/hackerone_one_vuln.json b/unittests/scans/hackerone/hackerone_one_vuln.json new file mode 100644 index 00000000000..c0388ff18ec --- /dev/null +++ b/unittests/scans/hackerone/hackerone_one_vuln.json @@ -0,0 +1,49 @@ +{ + "data": [ + { + "id": "1000001", + "type": "report", + "attributes": { + "title": "Stored XSS in the profile page", + "state": "triaged", + "vulnerability_information": "A stored cross-site scripting issue lets an attacker run script in another user's session.", + "created_at": "2026-07-10T09:00:00.000Z" + }, + "relationships": { + "severity": { + "data": { + "id": "sev-1", + "type": "severity", + "attributes": { + "rating": "high", + "score": 7.6, + "cvss_vector_string": "CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N" + } + } + }, + "weakness": { + "data": { + "id": "weak-1", + "type": "weakness", + "attributes": { + "external_id": "cwe-79", + "name": "Cross-site Scripting (XSS) - Stored" + } + } + }, + "reporter": { + "data": { + "id": "user-1", + "type": "user", + "attributes": { + "username": "researcher-one" + } + } + } + } + } + ], + "links": { + "next": "" + } +} diff --git a/unittests/scans/halosecurity/halosecurity_many_vuln.json b/unittests/scans/halosecurity/halosecurity_many_vuln.json new file mode 100644 index 00000000000..83810b987d5 --- /dev/null +++ b/unittests/scans/halosecurity/halosecurity_many_vuln.json @@ -0,0 +1,90 @@ +{ + "pagination": {"total": 6, "start": 0, "limit": 100}, + "list": [ + { + "issue": {"issue_id": 9001, "name": "TLS certificate expires in under 14 days", "severity": 5}, + "target": {"target_id": 3001, "target": "app.example.com", "target_name": "Main app", + "ip": "10.0.0.11", "geo": "US"}, + "status": {"issue_id": 9001, "target_id": 3001, "status": "confirmed", + "assigned_to": "Platform Team", "scans_since_found": 4}, + "instances": [] + }, + { + "issue": {"issue_id": 9002, "name": "Missing HSTS header", "severity": 2}, + "target": {"target_id": 3001, "target": "app.example.com"}, + "status": {"issue_id": 9002, "target_id": 3001, "status": "new", + "assigned_to": "Nobody", "scans_since_found": 1}, + "instances": [] + }, + { + "issue": {"issue_id": 9002, "name": "Missing HSTS header", "severity": 2}, + "target": {"target_id": 3002, "target": "shop.example.com"}, + "status": {"issue_id": 9002, "target_id": 3002, "status": "fixed", + "assigned_to": "Nobody", "scans_since_found": 0}, + "instances": [] + }, + { + "issue": {"issue_id": 9003, "name": "Acknowledged false positive", "severity": 3}, + "target": {"target_id": 3002, "target": "shop.example.com"}, + "status": {"issue_id": 9003, "target_id": 3002, "status": "ack_false_positive", + "assigned_to": "Nobody", "scans_since_found": 7}, + "instances": [] + }, + { + "issue": {"issue_id": 9004, "name": "Accepted risk on a legacy host", "severity": 4}, + "target": {"target_id": 3003, "target": "legacy.example.com"}, + "status": {"issue_id": 9004, "target_id": 3003, "status": "ack_acceptable_risk", + "assigned_to": "Nobody", "scans_since_found": 12}, + "instances": [] + }, + { + "issue": {"issue_id": 9005, "name": "", "severity": 0}, + "target": {"target_id": 0, "target": ""}, + "status": {"issue_id": 9005, "target_id": 0, "status": "", "assigned_to": "", + "scans_since_found": 0}, + "instances": [] + } + ], + "details": { + "9001": { + "issue_id": 9001, + "name": "TLS certificate expires in under 14 days", + "description": "The certificate presented by the host expires shortly.", + "category": "Encryption", + "cve_ids": [], + "pci": 1, + "severity": 5, + "type": 2 + }, + "9002": { + "issue_id": 9002, + "name": "Missing HSTS header", + "description": "No Strict-Transport-Security header was returned.", + "category": "Headers", + "cve_ids": [], + "pci": 0, + "severity": 2, + "type": 1 + }, + "9004": { + "issue_id": 9004, + "name": "Accepted risk on a legacy host", + "description": "An outdated component is exposed.", + "category": "Software", + "cve_ids": ["CVE-2000-0001", "CVE-2000-0002", "CVE-2000-0001"], + "pci": 0, + "severity": 4, + "type": 3 + }, + "9005": { + "issue_id": 9005, + "name": "A detail-only name", + "description": "", + "category": "", + "cve_ids": [], + "pci": 0, + "severity": 3, + "type": 0 + } + } +} diff --git a/unittests/scans/halosecurity/halosecurity_no_vuln.json b/unittests/scans/halosecurity/halosecurity_no_vuln.json new file mode 100644 index 00000000000..40c70d99fa7 --- /dev/null +++ b/unittests/scans/halosecurity/halosecurity_no_vuln.json @@ -0,0 +1,9 @@ +{ + "pagination": { + "total": 0, + "start": 0, + "limit": 100 + }, + "list": [], + "details": {} +} diff --git a/unittests/scans/halosecurity/halosecurity_one_vuln.json b/unittests/scans/halosecurity/halosecurity_one_vuln.json new file mode 100644 index 00000000000..0f4b6bd8016 --- /dev/null +++ b/unittests/scans/halosecurity/halosecurity_one_vuln.json @@ -0,0 +1,43 @@ +{ + "pagination": { + "total": 1, + "start": 0, + "limit": 100 + }, + "list": [ + { + "issue": { + "issue_id": 9001, + "name": "TLS certificate expires in under 14 days", + "severity": 5 + }, + "target": { + "target_id": 3001, + "target": "app.example.com", + "target_name": "Main app", + "ip": "10.0.0.11", + "geo": "US" + }, + "status": { + "issue_id": 9001, + "target_id": 3001, + "status": "confirmed", + "assigned_to": "Platform Team", + "scans_since_found": 4 + }, + "instances": [] + } + ], + "details": { + "9001": { + "issue_id": 9001, + "name": "TLS certificate expires in under 14 days", + "description": "The certificate presented by the host expires shortly.", + "category": "Encryption", + "cve_ids": [], + "pci": 1, + "severity": 5, + "type": 2 + } + } +} diff --git a/unittests/scans/hiddenlayer/hiddenlayer_many_vuln.json b/unittests/scans/hiddenlayer/hiddenlayer_many_vuln.json new file mode 100644 index 00000000000..d4c7df1f6cb --- /dev/null +++ b/unittests/scans/hiddenlayer/hiddenlayer_many_vuln.json @@ -0,0 +1,96 @@ +{ + "scan_id": "scan-0001", + "sarif": { + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "HiddenLayer Model Scanner", + "version": "1.0.0", + "rules": [ + { + "id": "PICKLE_DESERIALIZATION", + "name": "UnsafePickleDeserialization", + "shortDescription": {"text": "The model file deserialises untrusted pickle data"}, + "fullDescription": {"text": "Loading this model executes arbitrary code."}, + "helpUri": "https://example.com/rules/pickle-deserialization", + "properties": {"security-severity": "9.3", + "tags": ["external/cwe/cwe-502", "model-integrity"]}, + "relationships": [{"target": {"id": "CWE-502"}}] + }, + { + "id": "SUSPICIOUS_IMPORT", + "name": "SuspiciousImport", + "shortDescription": {"text": "The model imports a module used to run commands"}, + "help": {"text": "https://example.com/rules/suspicious-import"}, + "properties": {"security-severity": "high", "tags": ["external/cwe/cwe-94"]} + }, + { + "id": "ARCHITECTURE_NOTE", + "shortDescription": {"text": "The model architecture is unusual"}, + "help": {"text": "Review the architecture by hand."} + }, + { + "id": "CVE-2000-0001", + "name": "VulnerableDependency", + "shortDescription": {"text": "The model bundles a vulnerable library"}, + "properties": {"security-severity": "not a number at all", "tags": []} + } + ] + } + }, + "results": [ + { + "ruleId": "PICKLE_DESERIALIZATION", + "level": "error", + "kind": "fail", + "message": {"text": "Unsafe pickle opcode found in the model archive"}, + "locations": [ + {"physicalLocation": {"artifactLocation": {"uri": "generic-model/pytorch_model.bin"}, + "region": {"startLine": 42}}} + ], + "fixes": [ + {"description": {"text": "Re-export the model in the safetensors format."}}, + {"description": {"text": "Load the model with weights_only=True."}} + ], + "properties": {"tags": ["supply-chain"]} + }, + { + "ruleId": "SUSPICIOUS_IMPORT", + "level": "warning", + "message": {"text": "The archive imports the subprocess module"}, + "locations": [ + {"physicalLocation": {"artifactLocation": {"uri": "generic-model/loader.py"}, + "region": {"startLine": "17"}}} + ], + "suppressions": [{"kind": "external", "justification": "Reviewed and accepted"}] + }, + { + "ruleId": "ARCHITECTURE_NOTE", + "level": "note", + "message": {"text": "The architecture is unusual"} + }, + { + "ruleId": "CVE-2000-0001", + "message": {"text": ""}, + "locations": [ + {"physicalLocation": {"artifactLocation": {"uri": "generic-model/requirements.txt"}}} + ] + }, + { + "ruleId": "PICKLE_DESERIALIZATION", + "kind": "pass", + "message": {"text": "Never imported: this result is a pass, not a failure"} + }, + { + "ruleId": "UNKNOWN_RULE", + "level": "error", + "message": {"text": "A result whose rule is not defined in the run"}, + "properties": {"tags": ["external/cwe/cwe-77"]} + } + ] + } + ] + } +} diff --git a/unittests/scans/hiddenlayer/hiddenlayer_no_vuln.json b/unittests/scans/hiddenlayer/hiddenlayer_no_vuln.json new file mode 100644 index 00000000000..34740cb09f0 --- /dev/null +++ b/unittests/scans/hiddenlayer/hiddenlayer_no_vuln.json @@ -0,0 +1,12 @@ +{ + "scan_id": "scan-0001", + "sarif": { + "version": "2.1.0", + "runs": [ + { + "tool": {"driver": {"name": "HiddenLayer Model Scanner", "version": "1.0.0", "rules": []}}, + "results": [] + } + ] + } +} diff --git a/unittests/scans/hiddenlayer/hiddenlayer_one_vuln.json b/unittests/scans/hiddenlayer/hiddenlayer_one_vuln.json new file mode 100644 index 00000000000..430e8329328 --- /dev/null +++ b/unittests/scans/hiddenlayer/hiddenlayer_one_vuln.json @@ -0,0 +1,51 @@ +{ + "scan_id": "scan-0001", + "sarif": { + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "HiddenLayer Model Scanner", + "version": "1.0.0", + "rules": [ + { + "id": "PICKLE_DESERIALIZATION", + "name": "UnsafePickleDeserialization", + "shortDescription": {"text": "The model file deserialises untrusted pickle data"}, + "fullDescription": { + "text": "Loading this model executes arbitrary code from the pickle stream." + }, + "helpUri": "https://example.com/rules/pickle-deserialization", + "help": {"text": "Convert the model to a safetensors file."}, + "properties": { + "security-severity": "9.3", + "tags": ["external/cwe/cwe-502", "model-integrity"] + }, + "relationships": [{"target": {"id": "CWE-502"}}] + } + ] + } + }, + "results": [ + { + "ruleId": "PICKLE_DESERIALIZATION", + "level": "error", + "kind": "fail", + "message": {"text": "Unsafe pickle opcode found in the model archive"}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": "generic-model/pytorch_model.bin"}, + "region": {"startLine": 42} + } + } + ], + "fixes": [{"description": {"text": "Re-export the model in the safetensors format."}}], + "properties": {"tags": ["supply-chain"]} + } + ] + } + ] + } +} diff --git a/unittests/scans/holm_security/holm_security_many_vuln.json b/unittests/scans/holm_security/holm_security_many_vuln.json new file mode 100644 index 00000000000..dc31977f79d --- /dev/null +++ b/unittests/scans/holm_security/holm_security_many_vuln.json @@ -0,0 +1,74 @@ +{ + "class": "web", + "count": 5, + "next": null, + "previous": null, + "results": [ + { + "hid": "HID-100001", + "vulnerability_name": "Cross-site scripting in the search form", + "cve_ids": ["CVE-2000-0001"], + "cvss_score": 5.4, + "cvss_base": 6.1, + "severity_level": 2, + "severity": "high", + "status": "open", + "solution": "Encode user input before rendering it.", + "detection_information": "The payload was reflected in the response body.", + "vulnerability_impact": "An attacker can run script in a visitor's session.", + "vendor_reference": "https://vendor.example.com/advisory/xss", + "asset_uuid": "asset-0001", + "detected_port": 443, + "detected_port_protocol": "tcp", + "url": "https://app.example.com/search?q=1", + "first_detected": "2024-06-01T00:00:00Z", + "last_detected": "2024-07-01T12:00:00Z" + }, + { + "hid": "HID-100002", + "vulnerability_name": "", + "cve_ids": ["CVE-2000-0002", "CVE-2000-0003"], + "cvss_score": 7.5, + "cvss_base": 0, + "severity_level": "3", + "severity": "", + "status": "open", + "solution": "Upgrade the service.", + "asset_uuid": "asset-0001", + "detected_port": 8443, + "url": "https://app.example.com/", + "first_detected": "2024-06-01T00:00:00Z" + }, + { + "hid": "HID-100003", + "vulnerability_name": "An unrecognised severity name with a usable level", + "cve_ids": [], + "cvss_base": 0, + "cvss_score": 0, + "severity_level": 4, + "severity": "severe", + "status": "open", + "asset_uuid": "asset-0002", + "detected_port": 0, + "url": "" + }, + { + "hid": "HID-100004", + "vulnerability_name": "A fixed finding", + "severity": "medium", + "severity_level": 2, + "status": "fixed", + "asset_uuid": "asset-0002", + "url": "https://app.example.com/old", + "last_detected": "2024-05-01T00:00:00Z" + }, + { + "hid": "HID-100005", + "vulnerability_name": "A finding on a label rather than a host", + "severity": "low", + "status": "open", + "asset_uuid": "asset-0003", + "url": "an internal service" + } + ] +} diff --git a/unittests/scans/holm_security/holm_security_no_vuln.json b/unittests/scans/holm_security/holm_security_no_vuln.json new file mode 100644 index 00000000000..e0cae84afb9 --- /dev/null +++ b/unittests/scans/holm_security/holm_security_no_vuln.json @@ -0,0 +1,5 @@ +{ + "class": "net", + "count": 0, + "results": [] +} diff --git a/unittests/scans/holm_security/holm_security_one_vuln.json b/unittests/scans/holm_security/holm_security_one_vuln.json new file mode 100644 index 00000000000..ac18bd12465 --- /dev/null +++ b/unittests/scans/holm_security/holm_security_one_vuln.json @@ -0,0 +1,28 @@ +{ + "class": "web", + "count": 1, + "results": [ + { + "hid": "HID-100001", + "vulnerability_name": "Cross-site scripting in the search form", + "cve_ids": [ + "CVE-2000-0001" + ], + "cvss_score": 5.4, + "cvss_base": 6.1, + "severity_level": 2, + "severity": "high", + "status": "open", + "solution": "Encode user input before rendering it.", + "detection_information": "The payload was reflected in the response body.", + "vulnerability_impact": "An attacker can run script in a visitor's session.", + "vendor_reference": "https://vendor.example.com/advisory/xss", + "asset_uuid": "asset-0001", + "detected_port": 443, + "detected_port_protocol": "tcp", + "url": "https://app.example.com/search?q=1", + "first_detected": "2024-06-01T00:00:00Z", + "last_detected": "2024-07-01T12:00:00Z" + } + ] +} diff --git a/unittests/scans/insightappsec/insightappsec_many_vuln.json b/unittests/scans/insightappsec/insightappsec_many_vuln.json new file mode 100644 index 00000000000..83d252e875c --- /dev/null +++ b/unittests/scans/insightappsec/insightappsec_many_vuln.json @@ -0,0 +1,95 @@ +{ + "data": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "app": {"id": "app-0001"}, + "root_cause": { + "url": "https://app.example.com/search", + "parameter": "q", + "method": "GET" + }, + "severity": "HIGH", + "status": "VERIFIED", + "first_discovered": "2024-07-01T12:00:00.000Z", + "last_discovered": "2024-07-15T12:00:00.000Z", + "newly_discovered": false, + "vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "vulnerability_score": 6.1, + "insight_ui_url": "https://insight.example.com/vm/app-0001/vuln/11111111", + "variances": [ + { + "id": "var-1", + "original_value": "test", + "module": {"id": "module-xss"}, + "attack": {"id": "attack-1"}, + "attack_value": "<script>alert(1)</script>", + "message": "The payload was reflected in the response.", + "proof": "<p>Reflected in the <b>body</b></p>", + "references": { + "owasp": "https://owasp.example.com/xss", + "cwe": "https://cwe.example.com/79" + } + }, + { + "id": "var-2", + "module": {"id": "module-xss"}, + "attack_value": "'\"><img src=x onerror=alert(1)>", + "message": "A second payload was reflected.", + "references": {"owasp": "https://owasp.example.com/xss"} + }, + {"id": "var-3", "module": {"id": "module-xss"}, "message": "Third."}, + {"id": "var-4", "module": {"id": "module-xss"}, "message": "Fourth."}, + {"id": "var-5", "module": {"id": "module-xss"}, "message": "Fifth."} + ] + }, + { + "id": "22222222-2222-2222-2222-222222222222", + "root_cause": {"url": "https://app.example.com/login", "method": "POST"}, + "severity": "CRITICAL", + "status": "UNREVIEWED", + "first_discovered": "2024-06-01T00:00:00.000Z", + "vector_string": "AV:N/AC:L/Au:N/C:P/I:P/A:P", + "vulnerability_score": 7.5, + "variances": [ + {"id": "var-6", "module": {"id": "module-sqli"}, "message": "A database error was returned."} + ] + }, + { + "id": "33333333-3333-3333-3333-333333333333", + "root_cause": {"url": "https://app.example.com/admin"}, + "severity": "not-an-enum", + "status": "VERIFIED", + "variances": [{"id": "var-7", "module": {"id": "module-unknown"}}] + }, + { + "id": "44444444-4444-4444-4444-444444444444", + "root_cause": {"url": "https://app.example.com/old"}, + "severity": "HIGH", + "status": "REMEDIATED", + "variances": [{"id": "var-8", "module": {"id": "module-xss"}}] + }, + { + "id": "55555555-5555-5555-5555-555555555555", + "root_cause": {"url": "https://app.example.com/dupe"}, + "severity": "MEDIUM", + "status": "DUPLICATE", + "variances": [] + }, + { + "id": "66666666-6666-6666-6666-666666666666", + "root_cause": {"url": "https://app.example.com/fp"}, + "severity": "LOW", + "status": "FALSE_POSITIVE", + "variances": [] + } + ], + "modules": [ + { + "id": "module-xss", + "name": "Cross-Site Scripting (XSS)", + "description": "<p>The application reflects input into the response without encoding.</p><script>alert('x')</script>" + }, + {"id": "module-sqli", "name": "SQL Injection", "description": "A parameter reaches a database query."} + ], + "metadata": {"index": 0, "size": 50, "total_data": 6, "total_pages": 1} +} diff --git a/unittests/scans/insightappsec/insightappsec_no_vuln.json b/unittests/scans/insightappsec/insightappsec_no_vuln.json new file mode 100644 index 00000000000..7cdabf2b774 --- /dev/null +++ b/unittests/scans/insightappsec/insightappsec_no_vuln.json @@ -0,0 +1,65 @@ +{ + "data": [ + { + "id": "44444444-4444-4444-4444-444444444444", + "root_cause": { + "url": "https://app.example.com/old" + }, + "severity": "HIGH", + "status": "REMEDIATED", + "variances": [ + { + "id": "var-8", + "module": { + "id": "module-xss" + } + } + ] + }, + { + "id": "55555555-5555-5555-5555-555555555555", + "root_cause": { + "url": "https://app.example.com/dupe" + }, + "severity": "MEDIUM", + "status": "DUPLICATE", + "variances": [] + }, + { + "id": "66666666-6666-6666-6666-666666666666", + "root_cause": { + "url": "https://app.example.com/fp" + }, + "severity": "LOW", + "status": "FALSE_POSITIVE", + "variances": [] + }, + { + "id": "77777777-7777-7777-7777-777777777777", + "root_cause": { + "url": "https://app.example.com/ignored" + }, + "severity": "HIGH", + "status": "IGNORED", + "variances": [] + } + ], + "modules": [ + { + "id": "module-xss", + "name": "Cross-Site Scripting (XSS)", + "description": "<p>The application reflects input into the response without encoding.</p><script>alert('x')</script>" + }, + { + "id": "module-sqli", + "name": "SQL Injection", + "description": "A parameter reaches a database query." + } + ], + "metadata": { + "index": 0, + "size": 50, + "total_data": 4, + "total_pages": 1 + } +} diff --git a/unittests/scans/insightappsec/insightappsec_one_vuln.json b/unittests/scans/insightappsec/insightappsec_one_vuln.json new file mode 100644 index 00000000000..0d5a4fb3e9e --- /dev/null +++ b/unittests/scans/insightappsec/insightappsec_one_vuln.json @@ -0,0 +1,87 @@ +{ + "data": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "app": { + "id": "app-0001" + }, + "root_cause": { + "url": "https://app.example.com/search", + "parameter": "q", + "method": "GET" + }, + "severity": "HIGH", + "status": "VERIFIED", + "first_discovered": "2024-07-01T12:00:00.000Z", + "last_discovered": "2024-07-15T12:00:00.000Z", + "newly_discovered": false, + "vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "vulnerability_score": 6.1, + "insight_ui_url": "https://insight.example.com/vm/app-0001/vuln/11111111", + "variances": [ + { + "id": "var-1", + "original_value": "test", + "module": { + "id": "module-xss" + }, + "attack": { + "id": "attack-1" + }, + "attack_value": "<script>alert(1)</script>", + "message": "The payload was reflected in the response.", + "proof": "<p>Reflected in the <b>body</b></p>", + "references": { + "owasp": "https://owasp.example.com/xss", + "cwe": "https://cwe.example.com/79" + } + }, + { + "id": "var-2", + "module": { + "id": "module-xss" + }, + "attack_value": "'\"><img src=x onerror=alert(1)>", + "message": "A second payload was reflected.", + "references": { + "owasp": "https://owasp.example.com/xss" + } + }, + { + "id": "var-3", + "module": { + "id": "module-xss" + }, + "message": "Third." + }, + { + "id": "var-4", + "module": { + "id": "module-xss" + }, + "message": "Fourth." + }, + { + "id": "var-5", + "module": { + "id": "module-xss" + }, + "message": "Fifth." + } + ] + } + ], + "modules": [ + { + "id": "module-xss", + "name": "Cross-Site Scripting (XSS)", + "description": "<p>The application reflects input into the response without encoding.</p><script>alert('x')</script>" + } + ], + "metadata": { + "index": 0, + "size": 50, + "total_data": 1, + "total_pages": 1 + } +} diff --git a/unittests/scans/intigriti/intigriti_many_vuln.json b/unittests/scans/intigriti/intigriti_many_vuln.json new file mode 100644 index 00000000000..baa04399e13 --- /dev/null +++ b/unittests/scans/intigriti/intigriti_many_vuln.json @@ -0,0 +1,106 @@ +{ + "records": [ + { + "code": "GENERIC-0001", + "title": "Stored XSS in the comment field", + "originators": {"programId": "prog-0001", "pentestCode": ""}, + "severity": {"id": 5, "value": "Exceptional", "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N"}, + "state": { + "status": {"id": 3, "value": "Accepted"}, + "closeReason": {"id": 0, "value": ""} + }, + "createdAt": 1784000000, + "lastUpdatedAt": 1784100000, + "tags": ["web"], + "webLinks": { + "details": "https://app.example.com/submissions/GENERIC-0001", + "submissions": "https://app.example.com/submissions" + }, + "detail": { + "code": "GENERIC-0001", + "title": "Stored XSS in the comment field", + "severity": {"id": 5, "value": "Exceptional", "vector": ""}, + "state": {"status": {"value": "Accepted"}, "closeReason": {"value": ""}}, + "webLinks": {"details": "https://app.example.com/submissions/GENERIC-0001"}, + "report": { + "originalTitle": "XSS in comments", + "type": {"name": "Cross-site Scripting", "category": "Injection", "cwe": "cwe-79"}, + "domain": {"name": "app.example.com", "description": "Main web application"}, + "endpointVulnerableComponent": "/comments", + "pocDescription": "<p>Submit <b><script></b> in the comment body.</p><script>alert(1)</script>", + "impact": "<p>An attacker can run script in another user's session.</p>", + "recommendedSolution": "<p>Encode user input before rendering.</p>", + "questions": [ + {"question": "Which browsers did you test?", "answer": "<p>Latest Chrome and Firefox.</p>"}, + {"question": "", "answer": ""} + ] + } + } + }, + { + "code": "GENERIC-0002", + "title": "Resolved header issue", + "severity": {"id": 2, "value": "Low", "vector": ""}, + "state": {"status": {"value": "Closed"}, "closeReason": {"value": "Solved"}}, + "webLinks": {"details": "https://app.example.com/submissions/GENERIC-0002"} + }, + { + "code": "GENERIC-0003", + "title": "Accepted risk submission", + "severity": {"id": 3, "value": "Medium", "vector": ""}, + "state": {"status": {"value": "Closed"}, "closeReason": {"value": "Accepted risk"}}, + "webLinks": {"details": ""} + }, + { + "code": "GENERIC-0004", + "title": "Duplicate submission", + "severity": {"id": 4, "value": "High", "vector": ""}, + "state": {"status": {"value": "Closed"}, "closeReason": {"value": "Duplicate"}}, + "webLinks": {"details": ""} + }, + { + "code": "GENERIC-0005", + "title": "Out of scope submission", + "severity": {"id": 2, "value": "Low", "vector": ""}, + "state": {"status": {"value": "Archived"}, "closeReason": {"value": "Out of scope"}}, + "webLinks": {"details": ""} + }, + { + "code": "GENERIC-0006", + "title": "Rejected submission", + "severity": {"id": 1, "value": "Low", "vector": ""}, + "state": {"status": {"value": "Closed"}, "closeReason": {"value": "Not applicable"}}, + "webLinks": {"details": ""} + }, + { + "code": "GENERIC-0007", + "title": "Terse rejection", + "severity": {"id": 1, "value": "Low", "vector": ""}, + "state": {"status": {"value": "Closed"}, "closeReason": {"value": "No"}}, + "webLinks": {"details": ""} + }, + { + "code": "GENERIC-0008", + "title": "Still in triage", + "severity": {"id": 0, "value": "not-a-severity", "vector": ""}, + "state": {"status": {"value": "Triage"}, "closeReason": {"value": ""}}, + "webLinks": {"details": ""} + }, + { + "code": "GENERIC-0009", + "title": "Detail carried on the entry itself", + "severity": {"id": 0, "value": "", "vector": ""}, + "state": {"status": {"value": "Open"}, "closeReason": {"value": ""}}, + "webLinks": {"details": ""}, + "report": { + "type": {"name": "", "category": "Access Control", "cwe": "not-a-cwe"}, + "domain": {"name": "", "description": ""}, + "endpointVulnerableComponent": "api.example.com/v1/users", + "pocDescription": "", + "impact": "", + "recommendedSolution": "", + "questions": [] + } + } + ] +} diff --git a/unittests/scans/intigriti/intigriti_no_vuln.json b/unittests/scans/intigriti/intigriti_no_vuln.json new file mode 100644 index 00000000000..aa38702b883 --- /dev/null +++ b/unittests/scans/intigriti/intigriti_no_vuln.json @@ -0,0 +1,3 @@ +{ + "records": [] +} diff --git a/unittests/scans/intigriti/intigriti_one_vuln.json b/unittests/scans/intigriti/intigriti_one_vuln.json new file mode 100644 index 00000000000..5d2f56132a1 --- /dev/null +++ b/unittests/scans/intigriti/intigriti_one_vuln.json @@ -0,0 +1,82 @@ +{ + "records": [ + { + "code": "GENERIC-0001", + "title": "Stored XSS in the comment field", + "originators": { + "programId": "prog-0001", + "pentestCode": "" + }, + "severity": { + "id": 5, + "value": "Exceptional", + "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N" + }, + "state": { + "status": { + "id": 3, + "value": "Accepted" + }, + "closeReason": { + "id": 0, + "value": "" + } + }, + "createdAt": 1784000000, + "lastUpdatedAt": 1784100000, + "tags": [ + "web" + ], + "webLinks": { + "details": "https://app.example.com/submissions/GENERIC-0001", + "submissions": "https://app.example.com/submissions" + }, + "detail": { + "code": "GENERIC-0001", + "title": "Stored XSS in the comment field", + "severity": { + "id": 5, + "value": "Exceptional", + "vector": "" + }, + "state": { + "status": { + "value": "Accepted" + }, + "closeReason": { + "value": "" + } + }, + "webLinks": { + "details": "https://app.example.com/submissions/GENERIC-0001" + }, + "report": { + "originalTitle": "XSS in comments", + "type": { + "name": "Cross-site Scripting", + "category": "Injection", + "cwe": "cwe-79" + }, + "domain": { + "name": "app.example.com", + "description": "Main web application" + }, + "endpointVulnerableComponent": "/comments", + "pocDescription": "<p>Submit <b><script></b> in the comment body.</p><script>alert(1)</script>", + "impact": "<p>An attacker can run script in another user's session.</p>", + "recommendedSolution": "<p>Encode user input before rendering.</p>", + "questions": [ + { + "question": "Which browsers did you test?", + "answer": "<p>Latest Chrome and Firefox.</p>" + }, + { + "question": "", + "answer": "" + } + ] + } + } + } + ] +} diff --git a/unittests/scans/intruder/intruder_many_vuln.json b/unittests/scans/intruder/intruder_many_vuln.json new file mode 100644 index 00000000000..44bdb932bf0 --- /dev/null +++ b/unittests/scans/intruder/intruder_many_vuln.json @@ -0,0 +1,104 @@ +{ + "count": 4, + "results": [ + { + "id": 9001, + "severity": "high", + "title": "TLS certificate expires in under 14 days", + "description": "The certificate presented by the service expires shortly. See CVE-2000-0009 for background.", + "remediation": "Renew the certificate and redeploy it to every listener.", + "cvss_score": 7.4, + "snoozed": false, + "snooze_reason": "", + "exploit_likelihood": "MEDIUM", + "occurrences": "https://api.example.com/v1/issues/9001/occurrences" + }, + { + "id": 9002, + "severity": "medium", + "title": "Outdated web server version", + "description": "The service reports a version with published vulnerabilities.", + "remediation": "Upgrade the web server.", + "cvss_score": 5.3, + "snoozed": false, + "exploit_likelihood": "LOW" + }, + { + "id": 9003, + "severity": "not a label", + "title": "An issue with an unrecognised severity", + "description": "", + "remediation": "", + "cvss_score": 0 + }, + { + "id": 9004, + "severity": "critical", + "title": "An issue with no occurrences", + "description": "Nothing is currently affected.", + "remediation": "Nothing to do.", + "cvss_score": 9.8 + } + ], + "occurrences": { + "9001": [ + { + "id": 1, + "occurrence_id": 500001, + "target": "app.example.com", + "display_address": "app.example.com (production)", + "port": "443", + "protocol": "tcp", + "first_seen_at": "2024-07-01T12:00:00Z", + "target_last_scanned_at": "2024-07-15T12:00:00Z", + "snoozed": false, + "snooze_reason": "", + "exploit_likelihood": "MEDIUM", + "cvss_score": 8.1, + "cves": ["CVE-2000-0001", "CVE-2000-0001"], + "extra_info": {"issuer": "Example CA", "expires": "2024-07-20", "chain": "complete"} + }, + { + "id": 2, + "occurrence_id": 500002, + "target": "10.0.0.11", + "port": "8443", + "protocol": "tcp", + "first_seen_at": "2024-06-01T00:00:00Z", + "snoozed": true, + "snooze_reason": "FALSE_POSITIVE", + "cvss_score": 0 + } + ], + "9002": [ + { + "id": 3, + "occurrence_id": 500003, + "target": "shop.example.com", + "port": "0", + "protocol": "tcp", + "first_seen_at": "not a timestamp", + "snoozed": true, + "snooze_reason": "ACCEPT_RISK" + }, + { + "id": 4, + "occurrence_id": 500004, + "target": "Reception Desk PC", + "port": "80", + "snoozed": true, + "snooze_reason": "MITIGATING_CONTROLS" + } + ], + "9003": [ + { + "id": 5, + "occurrence_id": 500005, + "target": "legacy.example.com", + "port": "21", + "snoozed": true, + "snooze_reason": "SOME_FUTURE_REASON" + } + ] + } +} diff --git a/unittests/scans/intruder/intruder_no_vuln.json b/unittests/scans/intruder/intruder_no_vuln.json new file mode 100644 index 00000000000..c87655f3249 --- /dev/null +++ b/unittests/scans/intruder/intruder_no_vuln.json @@ -0,0 +1,5 @@ +{ + "count": 0, + "results": [], + "occurrences": {} +} diff --git a/unittests/scans/intruder/intruder_one_vuln.json b/unittests/scans/intruder/intruder_one_vuln.json new file mode 100644 index 00000000000..ab2c7445964 --- /dev/null +++ b/unittests/scans/intruder/intruder_one_vuln.json @@ -0,0 +1,31 @@ +{ + "count": 1, + "results": [ + { + "id": 9001, + "severity": "high", + "title": "TLS certificate expires in under 14 days", + "description": "The certificate presented by the service expires shortly.", + "remediation": "Renew the certificate and redeploy it to every listener.", + "cvss_score": 7.4, + "snoozed": false, + "exploit_likelihood": "MEDIUM", + "occurrences": [ + { + "id": 1, + "occurrence_id": 500001, + "target": "app.example.com", + "display_address": "app.example.com (production)", + "port": "443", + "protocol": "tcp", + "first_seen_at": "2024-07-01T12:00:00Z", + "snoozed": false, + "exploit_likelihood": "MEDIUM", + "cvss_score": 8.1, + "cves": ["CVE-2000-0001"], + "extra_info": {"issuer": "Example CA", "expires": "2024-07-20"} + } + ] + } + ] +} diff --git a/unittests/scans/klocwork/klocwork_many_vuln.json b/unittests/scans/klocwork/klocwork_many_vuln.json new file mode 100644 index 00000000000..124c41e0c3c --- /dev/null +++ b/unittests/scans/klocwork/klocwork_many_vuln.json @@ -0,0 +1,75 @@ +[ + { + "id": 100001, + "name": "Buffer overflow in strcpy call", + "message": "Buffer 'dest' of size 64 may overflow.", + "code": "ABV.GENERAL", + "file": "src/parser/input.c", + "line": 142, + "method": "parse_input", + "severity": "Critical", + "severityCode": 1, + "state": "New", + "status": "Analyze", + "taxonomyName": "CWE", + "url": "https://klocwork.example.com/review/insight-review.html#issuedetails_goto:problemid=100001", + "dateOriginated": 1719792000000 + }, + { + "id": "100002", + "name": "Unvalidated user input reaches a system call", + "message": "Tainted data flows to system().", + "code": "SV.TAINTED.INJECTION", + "file": "src/cli/main.c", + "line": "88", + "method": "run_command", + "severity": "Severe", + "severityCode": "2", + "status": "Fix", + "taxonomyName": "CWE", + "dateOriginated": 1717200000000 + }, + { + "id": 100003, + "name": "Unused variable", + "message": "Variable 'tmp' is never read.", + "code": "UNUSED.VAR", + "file": "src/util/helpers.c", + "line": 15, + "severity": "Style", + "severityCode": 7, + "status": "Ignore", + "taxonomyName": "Style" + }, + { + "id": 100004, + "name": "", + "message": "A finding with neither name nor checker.", + "code": "", + "file": "src/other.c", + "line": 3, + "severityCode": 0, + "status": "Analyze" + }, + { + "id": 0, + "name": "A row with no id", + "code": "ABV.GENERAL", + "file": "src/skipped.c", + "line": 1, + "severityCode": 1, + "status": "Analyze" + }, + { + "id": 100005, + "name": "A deferred finding", + "message": "Deferred to a later release.", + "code": "NPD.FUNC.MUST", + "file": "src/defer.c", + "line": 20, + "severity": "Error", + "severityCode": 3, + "status": "Defer", + "taxonomyName": "CWE" + } +] diff --git a/unittests/scans/klocwork/klocwork_many_vuln.ndjson b/unittests/scans/klocwork/klocwork_many_vuln.ndjson new file mode 100644 index 00000000000..f2df3ddf38d --- /dev/null +++ b/unittests/scans/klocwork/klocwork_many_vuln.ndjson @@ -0,0 +1,7 @@ +{"id": 100001, "name": "Buffer overflow in strcpy call", "message": "Buffer 'dest' of size 64 may overflow.", "code": "ABV.GENERAL", "file": "src/parser/input.c", "line": 142, "method": "parse_input", "severity": "Critical", "severityCode": 1, "state": "New", "status": "Analyze", "taxonomyName": "CWE", "url": "https://klocwork.example.com/review/insight-review.html#issuedetails_goto:problemid=100001", "dateOriginated": 1719792000000} +{"id": "100002", "name": "Unvalidated user input reaches a system call", "message": "Tainted data flows to system().", "code": "SV.TAINTED.INJECTION", "file": "src/cli/main.c", "line": "88", "method": "run_command", "severity": "Severe", "severityCode": "2", "status": "Fix", "taxonomyName": "CWE", "dateOriginated": 1717200000000} +{"id": 100003, "name": "Unused variable", "message": "Variable 'tmp' is never read.", "code": "UNUSED.VAR", "file": "src/util/helpers.c", "line": 15, "severity": "Style", "severityCode": 7, "status": "Ignore", "taxonomyName": "Style"} +{"id": 100004, "name": "", "message": "A finding with neither name nor checker.", "code": "", "file": "src/other.c", "line": 3, "severityCode": 0, "status": "Analyze"} +{"id": 0, "name": "A row with no id", "code": "ABV.GENERAL", "file": "src/skipped.c", "line": 1, "severityCode": 1, "status": "Analyze"} +{"id": 100005, "name": "A deferred finding", "message": "Deferred to a later release.", "code": "NPD.FUNC.MUST", "file": "src/defer.c", "line": 20, "severity": "Error", "severityCode": 3, "status": "Defer", "taxonomyName": "CWE"} +{"summary": {"total": 6, "build": "last"}} diff --git a/unittests/scans/klocwork/klocwork_no_vuln.ndjson b/unittests/scans/klocwork/klocwork_no_vuln.ndjson new file mode 100644 index 00000000000..18b0bdaef5c --- /dev/null +++ b/unittests/scans/klocwork/klocwork_no_vuln.ndjson @@ -0,0 +1 @@ +{"summary": {"total": 0, "build": "last"}} diff --git a/unittests/scans/klocwork/klocwork_one_vuln.ndjson b/unittests/scans/klocwork/klocwork_one_vuln.ndjson new file mode 100644 index 00000000000..d6e334a4439 --- /dev/null +++ b/unittests/scans/klocwork/klocwork_one_vuln.ndjson @@ -0,0 +1 @@ +{"id": 100001, "name": "Buffer overflow in strcpy call", "message": "Buffer 'dest' of size 64 may overflow.", "code": "ABV.GENERAL", "file": "src/parser/input.c", "line": 142, "method": "parse_input", "severity": "Critical", "severityCode": 1, "state": "New", "status": "Analyze", "taxonomyName": "CWE", "url": "https://klocwork.example.com/review/insight-review.html#issuedetails_goto:problemid=100001", "dateOriginated": 1719792000000} diff --git a/unittests/scans/lacework/lacework_many_vuln.json b/unittests/scans/lacework/lacework_many_vuln.json new file mode 100644 index 00000000000..72c9b002087 --- /dev/null +++ b/unittests/scans/lacework/lacework_many_vuln.json @@ -0,0 +1,81 @@ +{ + "data": [ + { + "imageId": "sha256:aaaa1111", + "vulnId": "CVE-2000-0001", + "severity": "Critical", + "status": "Active", + "evalCtx": { + "image_info": { + "repo": "registry.example.com/generic-app", + "registry": "registry.example.com", + "digest": "sha256:bbbb2222" + } + }, + "featureKey": { + "name": "openssl", + "namespace": "debian:12", + "version": "3.0.11-1" + }, + "fixInfo": { + "fix_available": 1, + "fixed_version": "3.0.13-1" + } + }, + { + "mid": 4242, + "vulnId": "CVE-2000-0002", + "severity": "High", + "status": "Active", + "evalCtx": { + "hostname": "host01.example.com" + }, + "featureKey": { + "name": "curl", + "namespace": "ubuntu:22.04", + "version_installed": "7.81.0-1" + }, + "cveProps": { + "description": "A flaw in URL parsing.", + "link": "https://example.com/cve-2000-0002" + }, + "fixInfo": { + "fix_available": "1", + "fixed_version": "7.81.0-4" + } + }, + { + "imageId": "sha256:cccc3333", + "vulnId": "CVE-2000-0003", + "severity": "Medium", + "status": "Fixed", + "evalCtx": { + "image_info": { + "repo": "registry.example.com/generic-app", + "registry": "registry.example.com" + } + }, + "featureKey": { + "name": "zlib", + "version": "1.2.13" + }, + "fixInfo": { + "fix_available": 0, + "fixed_version": "" + } + }, + { + "mid": 77, + "vulnId": "", + "severity": "not-a-level", + "status": "Active", + "evalCtx": {}, + "featureKey": { + "name": "", + "version_installed": "" + }, + "cveProps": {}, + "fixInfo": {} + } + ] +} diff --git a/unittests/scans/lacework/lacework_no_vuln.json b/unittests/scans/lacework/lacework_no_vuln.json new file mode 100644 index 00000000000..268c73f0e37 --- /dev/null +++ b/unittests/scans/lacework/lacework_no_vuln.json @@ -0,0 +1,3 @@ +{ + "data": [] +} diff --git a/unittests/scans/lacework/lacework_one_vuln.json b/unittests/scans/lacework/lacework_one_vuln.json new file mode 100644 index 00000000000..7674c450c11 --- /dev/null +++ b/unittests/scans/lacework/lacework_one_vuln.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "imageId": "sha256:aaaa1111", + "vulnId": "CVE-2000-0001", + "severity": "Critical", + "status": "Active", + "evalCtx": { + "image_info": { + "repo": "registry.example.com/generic-app", + "registry": "registry.example.com", + "digest": "sha256:bbbb2222" + } + }, + "featureKey": { + "name": "openssl", + "namespace": "debian:12", + "version": "3.0.11-1" + }, + "fixInfo": { + "fix_available": 1, + "fixed_version": "3.0.13-1" + } + } + ] +} diff --git a/unittests/scans/netrise/netrise_many_vuln.json b/unittests/scans/netrise/netrise_many_vuln.json new file mode 100644 index 00000000000..0762cda582d --- /dev/null +++ b/unittests/scans/netrise/netrise_many_vuln.json @@ -0,0 +1,62 @@ +{ + "asset": { + "id": "artifact-0001", + "name": "generic-router-firmware-1.4.0.bin", + "vendor": "Generic Networks", + "product": "GN-1000", + "version": "1.4.0" + }, + "vulnerabilities": { + "edges": [ + { + "node": { + "cve": "CVE-2000-0001", + "name": "example-tls", + "severity": "critical", + "cvssScore": 9.8, + "fixVersions": ["1.1.1w", "3.0.12"], + "isReachable": true, + "inKnownExploitedVulnerabilities": true + } + }, + { + "node": { + "cve": "CVE-2000-0002", + "name": "example-compress", + "severity": "a word NetRise does not usually send", + "cvssScore": "7.5", + "fixVersions": [], + "isReachable": false, + "inKnownExploitedVulnerabilities": false + } + }, + { + "node": { + "cve": "", + "name": "example-config-weakness", + "severity": "none", + "cvssScore": 0, + "isReachable": true + } + }, + { + "node": { + "cve": "CVE-2000-0004", + "name": "", + "severity": "", + "cvssScore": 0, + "inKnownExploitedVulnerabilities": true + } + }, + { + "node": { + "cve": "", + "name": "", + "severity": "low", + "cvssScore": 2.1 + } + } + ], + "pageInfo": {"endCursor": "cursor-5", "hasNextPage": false} + } +} diff --git a/unittests/scans/netrise/netrise_no_vuln.json b/unittests/scans/netrise/netrise_no_vuln.json new file mode 100644 index 00000000000..dca63101559 --- /dev/null +++ b/unittests/scans/netrise/netrise_no_vuln.json @@ -0,0 +1,13 @@ +{ + "asset": { + "id": "artifact-0001", + "name": "generic-router-firmware-1.4.0.bin", + "vendor": "Generic Networks", + "product": "GN-1000", + "version": "1.4.0" + }, + "vulnerabilities": { + "edges": [], + "pageInfo": {"endCursor": "", "hasNextPage": false} + } +} diff --git a/unittests/scans/netrise/netrise_one_vuln.json b/unittests/scans/netrise/netrise_one_vuln.json new file mode 100644 index 00000000000..3bb48d98727 --- /dev/null +++ b/unittests/scans/netrise/netrise_one_vuln.json @@ -0,0 +1,25 @@ +{ + "asset": { + "id": "artifact-0001", + "name": "generic-router-firmware-1.4.0.bin", + "vendor": "Generic Networks", + "product": "GN-1000", + "version": "1.4.0" + }, + "vulnerabilities": { + "edges": [ + { + "node": { + "cve": "CVE-2000-0001", + "name": "example-tls", + "severity": "critical", + "cvssScore": 9.8, + "fixVersions": ["1.1.1w", "3.0.12"], + "isReachable": true, + "inKnownExploitedVulnerabilities": true + } + } + ], + "pageInfo": {"endCursor": "cursor-1", "hasNextPage": false} + } +} diff --git a/unittests/scans/nightfall/nightfall_many_vuln.json b/unittests/scans/nightfall/nightfall_many_vuln.json new file mode 100644 index 00000000000..e4f18fa40b6 --- /dev/null +++ b/unittests/scans/nightfall/nightfall_many_vuln.json @@ -0,0 +1,162 @@ +{ + "violations": [ + { + "id": "vio_slack_pending", + "integration": "SLACK", + "createdAt": 1717200000, + "state": "PENDING", + "resourceLink": "https://app.example.com/violations/vio_slack_pending", + "policyNames": ["Payment card numbers in chat", "PII in chat"], + "risk": "MEDIUM", + "riskSource": "DETECTION_RULE", + "riskScore": 5, + "metadata": { + "slackMetadata": { + "location": "#general", + "locationType": "PUBLIC_CHANNEL", + "username": "example-user", + "messagePermalink": "https://slack.example.com/archives/C0000000000/p0000000000000000", + "workspaceName": "Example Workspace" + } + }, + "userInfo": {"userEmail": "example-user@example.com"} + }, + { + "id": "vio_gdrive_resolved", + "integration": "GDRIVE", + "createdAt": 1717200000, + "state": "RESOLVED", + "policyNames": ["Confidential documents"], + "risk": "LOW", + "metadata": { + "gdriveMetadata": { + "fileName": "quarter-notes.docx", + "fileType": "document", + "fileLink": "https://drive.example.com/file/d/0000000000", + "permissionSetting": "ANYONE_WITH_LINK", + "fileOwner": "example-owner", + "drive": "Shared drive" + } + }, + "fileDetails": { + "fileName": "quarter-notes.docx", + "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "permalink": "https://drive.example.com/file/d/0000000000/view" + } + }, + { + "id": "vio_github_public", + "integration": "GITHUB", + "createdAt": 1721001600, + "state": "ACTIVE", + "policyNames": ["Cloud credentials in source code"], + "risk": "LOW", + "riskScore": 2.5, + "riskSource": "POLICY", + "metadata": { + "githubMetadata": { + "organization": "example-org", + "repository": "public-sample", + "branchName": "main", + "filePath": "config/local.env", + "githubPermalink": "https://github.example.com/example-org/public-sample/blob/main/config/local.env", + "isRepoPrivate": false + } + } + }, + { + "id": "vio_jira_expired", + "integration": "JIRA", + "createdAt": 1717200000, + "state": "EXPIRED", + "policyNames": [], + "risk": "NO_RISK", + "metadata": { + "jiraMetadata": { + "projectName": "PLATFORM", + "ticketNumber": "PLATFORM-1234", + "ticketLink": "https://jira.example.com/browse/PLATFORM-1234" + } + } + }, + { + "id": "vio_zendesk_unknown_risk", + "integration": "ZENDESK", + "createdAt": 1717200000, + "state": "ACTIVE", + "policyNames": ["Support attachments"], + "risk": "SEVERE", + "metadata": { + "zendeskMetadata": { + "ticketTitle": "Cannot sign in", + "ticketID": "4321", + "ticketStatus": "open", + "location": "comment" + } + } + }, + { + "id": "vio_notion_shared", + "integration": "NOTION", + "createdAt": 0, + "state": "", + "policyNames": ["Internal runbooks"], + "risk": "UNSPECIFIED", + "metadata": { + "notionMetadata": { + "workspaceName": "Example Workspace", + "pageTitle": "Deployment runbook", + "privatePageLink": "https://notion.example.com/Deployment-runbook-0000000000", + "sharedExternally": true + } + } + }, + { + "id": "vio_teams_no_metadata", + "integration": "M365_TEAMS", + "createdAt": 1717200000, + "state": "ACTIVE", + "policyNames": ["Chat attachments"], + "risk": "HIGH" + } + ], + "findings": { + "vio_slack_pending": [ + { + "id": "fnd_slack_1", + "confidence": "LIKELY", + "redactedSensitiveText": "4111****REDACTED****", + "subLocation": "message body", + "metadata": {"apiKeyMetaData": null} + } + ], + "vio_github_public": [ + { + "id": "fnd_github_1", + "confidence": "VERY_LIKELY", + "redactedSensitiveText": "sk_live_****REDACTED****", + "redactedLocation": {"lineRange": {"start": 7, "end": 7}}, + "metadata": { + "apiKeyMetaData": {"status": "SIGNATURE_VERIFIED", "kind": "STRIPE", "description": "Stripe secret key"} + } + }, + { + "id": "fnd_github_2", + "confidence": "POSSIBLE", + "redactedSensitiveText": "ghp_****REDACTED****", + "redactedLocation": {"lineRange": {"start": 12, "end": 12}}, + "metadata": { + "apiKeyMetaData": {"status": "UNVERIFIED", "kind": "GITHUB", "description": "GitHub token"} + } + } + ], + "vio_teams_no_metadata": [ + { + "id": "fnd_teams_1", + "confidence": "LIKELY", + "redactedSensitiveText": "****REDACTED****", + "metadata": {"apiKeyMetaData": {"status": "EXPIRED", "kind": "UNSPECIFIED"}} + } + ] + } +} diff --git a/unittests/scans/nightfall/nightfall_no_vuln.json b/unittests/scans/nightfall/nightfall_no_vuln.json new file mode 100644 index 00000000000..62a95ebc421 --- /dev/null +++ b/unittests/scans/nightfall/nightfall_no_vuln.json @@ -0,0 +1,4 @@ +{ + "violations": [], + "nextPageToken": "" +} diff --git a/unittests/scans/nightfall/nightfall_one_vuln.json b/unittests/scans/nightfall/nightfall_one_vuln.json new file mode 100644 index 00000000000..ee1576b705a --- /dev/null +++ b/unittests/scans/nightfall/nightfall_one_vuln.json @@ -0,0 +1,52 @@ +{ + "violations": [ + { + "id": "vio_00000000000000000001", + "integration": "GITHUB", + "createdAt": 1719792000, + "updatedAt": 1721001600, + "state": "ACTIVE", + "resourceLink": "https://app.example.com/violations/vio_00000000000000000001", + "policyUUIDs": ["11111111-1111-1111-1111-111111111111"], + "policyNames": ["Cloud credentials in source code"], + "detectionRuleUUIDs": ["33333333-3333-3333-3333-333333333333"], + "detectorUUIDs": ["22222222-2222-2222-2222-222222222222"], + "risk": "HIGH", + "riskSource": "POLICY", + "riskScore": 8.5, + "metadata": { + "githubMetadata": { + "organization": "example-org", + "repository": "generic-app", + "branchName": "main", + "filePath": "deploy/settings.py", + "authorUsername": "example-user", + "githubPermalink": "https://github.example.com/example-org/generic-app/blob/main/deploy/settings.py#L42", + "isRepoPrivate": true + } + }, + "userInfo": {"username": "example-user", "userEmail": "example-user@example.com"}, + "findings": [ + { + "id": "fnd_00000000000000000001", + "detectorUUID": "22222222-2222-2222-2222-222222222222", + "subDetectorUUID": "44444444-4444-4444-4444-444444444444", + "confidence": "VERY_LIKELY", + "redactedSensitiveText": "AKIA****REDACTED****", + "redactedContext": {"beforeContext": "AWS_ACCESS_KEY_ID = \"", "afterContext": "\""}, + "redactedLocation": { + "byteRange": {"start": 1200, "end": 1220}, + "lineRange": {"start": 42, "end": 42} + }, + "metadata": { + "apiKeyMetaData": { + "status": "ACTIVE", + "kind": "AWS", + "description": "AWS access key id" + } + } + } + ] + } + ] +} diff --git a/unittests/scans/nowsecure/nowsecure_many_vuln.json b/unittests/scans/nowsecure/nowsecure_many_vuln.json new file mode 100644 index 00000000000..0b5d2d4cc5c --- /dev/null +++ b/unittests/scans/nowsecure/nowsecure_many_vuln.json @@ -0,0 +1,80 @@ +{ + "assessment": { + "ref": "aaaaaaaa-0000-4000-8000-000000000001", + "task": 900001, + "application": "bbbbbbbb-0000-4000-8000-000000000001", + "account": "example-account", + "group": "example-group", + "package": "com.example.genericapp", + "platform": "android", + "created": "2024-07-01T12:00:00Z", + "updated": "2024-07-01T12:45:00Z", + "task_status": "completed", + "status": {"static": "completed", "dynamic": "completed"}, + "binary": "cccccccc-0000-4000-8000-000000000001" + }, + "findings": [ + { + "affected": true, + "hidden": false, + "analysis_type": "static", + "category": "Data Storage", + "check_id": "insecure_data_storage_world_readable", + "title": "World-readable files created by the app", + "severity": "high", + "cvss": 7.5, + "cvss_vector": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", + "description": "The app writes files that any other app on the device can read.", + "vulnerability_detail": "Observed mode 0644 on two files under the app's data directory.", + "unique_vulnerability_id": 12345, + "recommendations": {"developer": "Use MODE_PRIVATE when opening files."} + }, + { + "affected": true, + "hidden": false, + "analysis_type": "dynamic", + "category": "Network", + "check_id": "tls_certificate_validation_disabled", + "title": "Certificate validation disabled at runtime", + "severity": "critical", + "cvss": 9.1, + "cvss_vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N", + "description": "The app accepted a certificate it should have rejected. Related to CVE-2000-0002 and cve-2000-0002.", + "vulnerability_detail": "See also CVE-2000-0001.", + "unique_vulnerability_id": 12346, + "recommendations": {"developer": "Remove the permissive TrustManager."} + }, + { + "affected": true, + "hidden": false, + "analysis_type": "manual_review", + "category": "Code Quality", + "check_id": "", + "title": "Debug symbols present in the binary", + "severity": "not a label", + "cvss": 0, + "description": "", + "unique_vulnerability_id": 0 + }, + { + "affected": false, + "hidden": false, + "analysis_type": "static", + "category": "Data Storage", + "check_id": "keychain_accessibility", + "title": "A check that did not affect this app", + "severity": "medium", + "cvss": 5.3 + }, + { + "affected": true, + "hidden": true, + "analysis_type": "static", + "category": "Data Storage", + "check_id": "hidden_check", + "title": "A finding hidden in NowSecure", + "severity": "high", + "cvss": 7.1 + } + ] +} diff --git a/unittests/scans/nowsecure/nowsecure_no_vuln.json b/unittests/scans/nowsecure/nowsecure_no_vuln.json new file mode 100644 index 00000000000..26ca8f510ee --- /dev/null +++ b/unittests/scans/nowsecure/nowsecure_no_vuln.json @@ -0,0 +1,41 @@ +{ + "assessment": { + "ref": "aaaaaaaa-0000-4000-8000-000000000001", + "task": 900001, + "application": "bbbbbbbb-0000-4000-8000-000000000001", + "account": "example-account", + "group": "example-group", + "package": "com.example.genericapp", + "platform": "android", + "created": "2024-07-01T12:00:00Z", + "updated": "2024-07-01T12:45:00Z", + "task_status": "completed", + "status": { + "static": "completed", + "dynamic": "completed" + }, + "binary": "cccccccc-0000-4000-8000-000000000001" + }, + "findings": [ + { + "affected": false, + "hidden": false, + "analysis_type": "static", + "category": "Data Storage", + "check_id": "keychain_accessibility", + "title": "A check that did not affect this app", + "severity": "medium", + "cvss": 5.3 + }, + { + "affected": true, + "hidden": true, + "analysis_type": "static", + "category": "Data Storage", + "check_id": "hidden_check", + "title": "A finding hidden in NowSecure", + "severity": "high", + "cvss": 7.1 + } + ] +} diff --git a/unittests/scans/nowsecure/nowsecure_one_vuln.json b/unittests/scans/nowsecure/nowsecure_one_vuln.json new file mode 100644 index 00000000000..e508134a0e3 --- /dev/null +++ b/unittests/scans/nowsecure/nowsecure_one_vuln.json @@ -0,0 +1,38 @@ +{ + "assessment": { + "ref": "aaaaaaaa-0000-4000-8000-000000000001", + "task": 900001, + "application": "bbbbbbbb-0000-4000-8000-000000000001", + "account": "example-account", + "group": "example-group", + "package": "com.example.genericapp", + "platform": "android", + "created": "2024-07-01T12:00:00Z", + "updated": "2024-07-01T12:45:00Z", + "task_status": "completed", + "status": { + "static": "completed", + "dynamic": "completed" + }, + "binary": "cccccccc-0000-4000-8000-000000000001" + }, + "findings": [ + { + "affected": true, + "hidden": false, + "analysis_type": "static", + "category": "Data Storage", + "check_id": "insecure_data_storage_world_readable", + "title": "World-readable files created by the app", + "severity": "high", + "cvss": 7.5, + "cvss_vector": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", + "description": "The app writes files that any other app on the device can read.", + "vulnerability_detail": "Observed mode 0644 on two files under the app's data directory.", + "unique_vulnerability_id": 12345, + "recommendations": { + "developer": "Use MODE_PRIVATE when opening files." + } + } + ] +} diff --git a/unittests/scans/nozomi/nozomi_many_vuln.json b/unittests/scans/nozomi/nozomi_many_vuln.json new file mode 100644 index 00000000000..bd6a49b4135 --- /dev/null +++ b/unittests/scans/nozomi/nozomi_many_vuln.json @@ -0,0 +1,70 @@ +{ + "result": [ + { + "id": "nc-0001", + "cve": "CVE-2000-0001", + "cve_score": 9.8, + "cve_summary": "A crafted control message causes the controller to execute attacker code.", + "cve_references": ["https://example.com/advisories/CVE-2000-0001"], + "cwe_id": "CWE-787", + "cwe_name": "Out-of-bounds Write", + "asset_id": "asset-0001", + "zone": "Cell Zone A", + "node_label": "generic-plc-01", + "node_type": "PLC", + "node_vendor": "Generic Controls", + "node_product_name": "GC-9000", + "node_firmware_version": "2.4.1", + "node_os": "GCOS 4", + "latest_hotfix": "2.4.3", + "minimum_hotfix": "2.4.2", + "resolved": false + }, + { + "id": "", + "cve": "CVE-2000-0002", + "cve_score": "7.5", + "cve_summary": "", + "cve_references": [], + "cwe_id": "89", + "cwe_name": "", + "asset_id": "asset-0002", + "zone": "Cell Zone B", + "node_label": "generic-hmi-02", + "node_type": "HMI", + "node_vendor": "Generic HMI Works", + "node_product_name": "HMI-100", + "node_firmware_version": "5.0.0", + "minimum_hotfix": "5.0.1", + "resolved": false + }, + { + "id": "nc-0003", + "cve": "", + "cve_score": 0, + "cwe_id": "not a cwe", + "asset_id": "asset-0003", + "node_label": "generic-rtu-03", + "node_type": "RTU", + "resolved": false + }, + { + "id": "nc-0004", + "cve": "CVE-2000-0004", + "cve_score": 9.9, + "cve_summary": "Never imported: Nozomi has already resolved this one.", + "asset_id": "asset-0004", + "node_label": "generic-plc-04", + "resolved": true + }, + { + "id": "nc-0005", + "cve": "CVE-2000-0005", + "cve_score": 4.3, + "asset_id": "asset-0005", + "node_label": "generic-switch-05", + "node_product_name": "SW-200", + "node_firmware_version": "1.0.0" + } + ] +} diff --git a/unittests/scans/nozomi/nozomi_no_vuln.json b/unittests/scans/nozomi/nozomi_no_vuln.json new file mode 100644 index 00000000000..4677642a3ac --- /dev/null +++ b/unittests/scans/nozomi/nozomi_no_vuln.json @@ -0,0 +1,3 @@ +{ + "result": [] +} diff --git a/unittests/scans/nozomi/nozomi_one_vuln.json b/unittests/scans/nozomi/nozomi_one_vuln.json new file mode 100644 index 00000000000..14bfe9d8c5b --- /dev/null +++ b/unittests/scans/nozomi/nozomi_one_vuln.json @@ -0,0 +1,28 @@ +{ + "result": [ + { + "id": "nc-0001", + "cve": "CVE-2000-0001", + "cve_score": 9.8, + "cve_summary": "A crafted control message causes the controller to execute attacker code.", + "cve_references": [ + "https://example.com/advisories/CVE-2000-0001", + "https://example.com/vendor/advisory-2024-01" + ], + "cwe_id": "CWE-787", + "cwe_name": "Out-of-bounds Write", + "asset_id": "asset-0001", + "zone": "Cell Zone A", + "node_label": "generic-plc-01", + "node_type": "PLC", + "node_vendor": "Generic Controls", + "node_product_name": "GC-9000", + "node_firmware_version": "2.4.1", + "node_os": "GCOS 4", + "latest_hotfix": "2.4.3", + "minimum_hotfix": "2.4.2", + "likelihood": 0.8, + "resolved": false + } + ] +} diff --git a/unittests/scans/ostorlab/ostorlab_many_vuln.json b/unittests/scans/ostorlab/ostorlab_many_vuln.json new file mode 100644 index 00000000000..143dabbcb01 --- /dev/null +++ b/unittests/scans/ostorlab/ostorlab_many_vuln.json @@ -0,0 +1,79 @@ +{ + "data": { + "scan": { + "id": 4002, + "assetType": "WEB", + "progress": "DONE", + "createdTime": "2024-06-03T09:30:00Z", + "vulnerabilities": { + "pageInfo": {"hasNext": false, "numPages": 1}, + "vulnerabilities": [ + { + "id": 900010, + "technicalDetail": "The endpoint reflects the q parameter without escaping.", + "detail": { + "title": "Reflected cross-site scripting", + "shortDescription": "User input is echoed into the page.", + "description": "See CVE-2000-0002 and CVE-2000-0001 for the underlying library flaw.", + "recommendation": "Escape user input before rendering it.", + "cvssV3Vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "riskRating": "CRITICAL", + "references": [{"title": "Advisory", "url": "https://example.com/CVE-2000-0001"}] + }, + "vulnerabilityLocation": { + "asset": {"host": "app.example.com", "name": ""}, + "metadata": [{"metadataType": "URL", "metadataValue": "https://app.example.com/search?q=1"}] + } + }, + { + "id": 900011, + "technicalDetail": "", + "detail": { + "title": "Missing security header", + "riskRating": "POTENTIALLY", + "references": [] + }, + "vulnerabilityLocation": { + "asset": {"name": "app.example.com"} + } + }, + { + "id": 900012, + "detail": { + "title": "Server banner discloses a version", + "riskRating": "HARDENING" + } + }, + { + "id": 900013, + "detail": { + "title": "An important-rated finding", + "riskRating": "IMPORTANT" + } + }, + { + "id": 900014, + "detail": { + "title": "Never imported: this check passed", + "riskRating": "SECURE" + } + }, + { + "id": 900015, + "technicalDetail": "A finding with no detail block at all." + }, + { + "id": 900016, + "detail": { + "title": "A finding on a host DefectDojo cannot accept", + "riskRating": "MEDIUM" + }, + "vulnerabilityLocation": { + "asset": {"name": "not a valid host!!"} + } + } + ] + } + } + } +} diff --git a/unittests/scans/ostorlab/ostorlab_no_vuln.json b/unittests/scans/ostorlab/ostorlab_no_vuln.json new file mode 100644 index 00000000000..9a2424d18c5 --- /dev/null +++ b/unittests/scans/ostorlab/ostorlab_no_vuln.json @@ -0,0 +1,17 @@ +{ + "data": { + "scan": { + "id": 4001, + "assetType": "ANDROID_STORE", + "riskRating": "SECURE", + "packageName": "com.example.genericapp", + "version": "3.2.0", + "progress": "DONE", + "createdTime": "2024-06-01T10:00:00Z", + "vulnerabilities": { + "pageInfo": {"hasNext": false, "numPages": 1}, + "vulnerabilities": [] + } + } + } +} diff --git a/unittests/scans/ostorlab/ostorlab_one_vuln.json b/unittests/scans/ostorlab/ostorlab_one_vuln.json new file mode 100644 index 00000000000..b7b6a03f1c0 --- /dev/null +++ b/unittests/scans/ostorlab/ostorlab_one_vuln.json @@ -0,0 +1,41 @@ +{ + "data": { + "scan": { + "id": 4001, + "assetType": "ANDROID_STORE", + "packageName": "com.example.genericapp", + "version": "3.2.0", + "progress": "DONE", + "createdTime": "2024-06-02T10:00:00Z", + "vulnerabilities": { + "pageInfo": {"hasNext": false, "numPages": 1}, + "vulnerabilities": [ + { + "id": 900001, + "technicalDetail": "The application ships a debuggable manifest flag.", + "detail": { + "title": "Application is debuggable", + "shortDescription": "The release build allows a debugger to attach.", + "description": "A debuggable release build lets anyone with the device read memory and step through the application.", + "recommendation": "Set android:debuggable to false in the release manifest.", + "cvssV3Vector": "CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", + "riskRating": "HIGH", + "references": [ + {"title": "Vendor guidance", "url": "https://example.com/guidance/debuggable"}, + {"title": "", "url": "https://example.com/advisories/CVE-2000-0001"}, + {"title": "A reference with no link", "url": ""} + ] + }, + "vulnerabilityLocation": { + "asset": {"packageName": "com.example.genericapp", "bundleId": "", "host": "", "name": ""}, + "metadata": [ + {"metadataType": "File path", "metadataValue": "AndroidManifest.xml"}, + {"metadataType": "Code location", "metadataValue": "application/@android:debuggable"} + ] + } + } + ] + } + } + } +} diff --git a/unittests/scans/parasoft/parasoft_many_vuln.json b/unittests/scans/parasoft/parasoft_many_vuln.json new file mode 100644 index 00000000000..d49a18314ca --- /dev/null +++ b/unittests/scans/parasoft/parasoft_many_vuln.json @@ -0,0 +1,57 @@ +{ + "staticAnalysisViolations": [ + { + "hash": "a1b2c3d4e5f60718", + "id": "v-900001", + "rule": "CERT_C-INT30-a", + "ruleCategory": "Integers", + "message": "Unsigned integer operation may wrap around", + "severity": 1, + "locFile": "src/generic/parser.c", + "locStartLine": 128, + "analyzerId": "com.parasoft.xtest.cpp.analyzer.static.pattern", + "language": "c" + }, + { + "hash": "", + "id": "v-900002", + "rule": "CERT_C-STR31-a", + "ruleCategory": "Strings", + "message": "Buffer may overflow, see CVE-2000-0002 and CVE-2000-0001", + "severity": "2", + "locFile": "src/generic/strings.c", + "locStartLine": "64", + "analyzerId": "com.parasoft.xtest.cpp.analyzer.static.pattern", + "language": "c" + }, + { + "hash": "", + "id": "", + "rule": "PB.NUM.CLP", + "ruleCategory": "Possible Bugs", + "message": "", + "severity": 3, + "locFile": "src/generic/util.java", + "locStartLine": 12, + "language": "java" + }, + { + "rule": "", + "message": "A violation with no rule id", + "severity": 4, + "locFile": "src/generic/other.c", + "locStartLine": 1 + }, + { + "rule": "MISRA-1", + "message": "An informational violation", + "severity": 5, + "locFile": "src/generic/info.c" + }, + { + "rule": "", + "message": "", + "severity": 0 + } + ] +} diff --git a/unittests/scans/parasoft/parasoft_no_vuln.json b/unittests/scans/parasoft/parasoft_no_vuln.json new file mode 100644 index 00000000000..804029630c2 --- /dev/null +++ b/unittests/scans/parasoft/parasoft_no_vuln.json @@ -0,0 +1,3 @@ +{ + "staticAnalysisViolations": [] +} diff --git a/unittests/scans/parasoft/parasoft_one_vuln.json b/unittests/scans/parasoft/parasoft_one_vuln.json new file mode 100644 index 00000000000..da1795120a2 --- /dev/null +++ b/unittests/scans/parasoft/parasoft_one_vuln.json @@ -0,0 +1,17 @@ +{ + "staticAnalysisViolations": [ + { + "hash": "a1b2c3d4e5f60718", + "id": "v-900001", + "rule": "CERT_C-INT30-a", + "ruleCategory": "Integers", + "message": "Unsigned integer operation may wrap around", + "severity": 1, + "locFile": "src/generic/parser.c", + "locStartLine": 128, + "analyzerId": "com.parasoft.xtest.cpp.analyzer.static.pattern", + "language": "c", + "author": "build-agent" + } + ] +} diff --git a/unittests/scans/probely/probely_many_vuln.json b/unittests/scans/probely/probely_many_vuln.json new file mode 100644 index 00000000000..5d26b6293b4 --- /dev/null +++ b/unittests/scans/probely/probely_many_vuln.json @@ -0,0 +1,149 @@ +{ + "page_total": 1, + "results": [ + { + "id": 9001, + "severity": 30, + "definition": { + "id": "sql-injection", + "name": "SQL Injection", + "desc": "The application builds a database query from unvalidated input.", + "cwe_id": "CWE-89" + }, + "fix": "Use parameterised queries instead of string concatenation.", + "evidence": "A single quote in the id parameter produced a database error.", + "url": "https://app.example.com/search?q=1", + "path": "/search", + "extra": "Confirmed against the staging target only.", + "state": "notfixed", + "cvss_score": 9.1, + "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", + "requests": [ + { + "request": " `GET /search?q=1 HTTP/1.1` ", + "response": " `HTTP/1.1 500 Internal Server Error` " + } + ], + "parameter": "q", + "insertion_point": "url_query", + "method": "get" + }, + { + "id": 9002, + "severity": 20, + "definition": { + "id": "missing-csp", + "name": "Missing Content-Security-Policy", + "desc": "No Content-Security-Policy header was returned.", + "cwe_id": "CWE-693" + }, + "fix": "Add a Content-Security-Policy header.", + "evidence": "No CSP header present on the response.", + "url": "https://app.example.com/", + "path": "/", + "extra": "", + "state": "retesting", + "cvss_score": 5.3, + "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", + "requests": [], + "parameter": "", + "insertion_point": "", + "method": "" + }, + { + "id": 9003, + "severity": 10, + "definition": { + "id": "cookie-no-httponly", + "name": "Cookie without HttpOnly flag", + "desc": "A cookie is set without the HttpOnly flag.", + "cwe_id": "CWE-1004" + }, + "fix": "Set the HttpOnly flag on session cookies.", + "evidence": "Set-Cookie: session=... (no HttpOnly)", + "url": "https://app.example.com:8443/login", + "path": "/login", + "extra": "", + "state": "notfixed", + "cvss_score": 3.1, + "cvss_vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N", + "requests": [], + "parameter": "session", + "insertion_point": "json_body", + "method": "post" + }, + { + "id": 9004, + "severity": 99, + "definition": { + "id": "unknown-definition", + "name": "An unrecognised finding", + "desc": "", + "cwe_id": "not-a-cwe" + }, + "fix": "", + "evidence": "", + "url": "not a url at all", + "path": "", + "extra": "", + "state": "notfixed", + "cvss_score": 0, + "cvss_vector": "", + "requests": [], + "parameter": "", + "insertion_point": "graphql_variable", + "method": "" + }, + { + "id": 9005, + "severity": 30, + "definition": {"id": "fixed-one", "name": "Already fixed", "desc": "", "cwe_id": "CWE-79"}, + "fix": "", + "evidence": "", + "url": "https://app.example.com/old", + "path": "/old", + "extra": "", + "state": "fixed", + "cvss_score": 7.5, + "cvss_vector": "", + "requests": [], + "parameter": "", + "insertion_point": "", + "method": "" + }, + { + "id": 9006, + "severity": 20, + "definition": {"id": "accepted-one", "name": "Accepted risk", "desc": "", "cwe_id": "CWE-16"}, + "fix": "", + "evidence": "", + "url": "https://app.example.com/accepted", + "path": "/accepted", + "extra": "", + "state": "accepted", + "cvss_score": 5.0, + "cvss_vector": "", + "requests": [], + "parameter": "", + "insertion_point": "", + "method": "" + }, + { + "id": 9007, + "severity": 20, + "definition": {"id": "invalid-one", "name": "Marked invalid", "desc": "", "cwe_id": "CWE-16"}, + "fix": "", + "evidence": "", + "url": "https://app.example.com/invalid", + "path": "/invalid", + "extra": "", + "state": "invalid", + "cvss_score": 5.0, + "cvss_vector": "", + "requests": [], + "parameter": "", + "insertion_point": "", + "method": "" + } + ] +} diff --git a/unittests/scans/probely/probely_no_vuln.json b/unittests/scans/probely/probely_no_vuln.json new file mode 100644 index 00000000000..853637a3944 --- /dev/null +++ b/unittests/scans/probely/probely_no_vuln.json @@ -0,0 +1,4 @@ +{ + "page_total": 1, + "results": [] +} diff --git a/unittests/scans/probely/probely_one_vuln.json b/unittests/scans/probely/probely_one_vuln.json new file mode 100644 index 00000000000..b61b59b7d15 --- /dev/null +++ b/unittests/scans/probely/probely_one_vuln.json @@ -0,0 +1,32 @@ +{ + "page_total": 1, + "results": [ + { + "id": 9001, + "severity": 30, + "definition": { + "id": "sql-injection", + "name": "SQL Injection", + "desc": "The application builds a database query from unvalidated input.", + "cwe_id": "CWE-89" + }, + "fix": "Use parameterised queries instead of string concatenation.", + "evidence": "A single quote in the id parameter produced a database error.", + "url": "https://app.example.com/search?q=1", + "path": "/search", + "extra": "Confirmed against the staging target only.", + "state": "notfixed", + "cvss_score": 9.1, + "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", + "requests": [ + { + "request": " `GET /search?q=1 HTTP/1.1` ", + "response": " `HTTP/1.1 500 Internal Server Error` " + } + ], + "parameter": "q", + "insertion_point": "url_query", + "method": "get" + } + ] +} diff --git a/unittests/scans/quay/quay_many_vuln.json b/unittests/scans/quay/quay_many_vuln.json new file mode 100644 index 00000000000..0bb9d502460 --- /dev/null +++ b/unittests/scans/quay/quay_many_vuln.json @@ -0,0 +1,66 @@ +{ + "status": "scanned", + "tag": "1.4.0", + "data": { + "Layer": { + "Name": "sha256:aaaa1111bbbb2222cccc3333dddd4444", + "Features": [ + { + "Name": "openssl", + "Version": "3.0.11-1", + "Vulnerabilities": [ + { + "Name": "CVE-2000-0001", + "Description": "<p>A flaw in certificate verification allows an attacker to bypass validation.</p>", + "Link": "https://example.com/advisories/cve-2000-0001", + "Severity": "Critical", + "FixedBy": "3.0.13-1", + "NamespaceName": "debian:12" + }, + { + "Name": "CVE-2000-0002", + "Description": "A second flaw in the same package.", + "Link": "", + "Severity": "Defcon1", + "FixedBy": "", + "NamespaceName": "debian:12" + } + ] + }, + { + "Name": "curl", + "Version": "7.81.0-1", + "Vulnerabilities": [ + { + "Name": "CVE-2000-0003", + "Description": "A flaw in URL parsing.<script>alert('x')</script>", + "Link": "https://example.com/advisories/cve-2000-0003", + "Severity": "High", + "FixedBy": "7.81.0-4", + "NamespaceName": "debian:12" + } + ] + }, + { + "Name": "zlib", + "Version": "1.2.13", + "Vulnerabilities": [ + { + "Name": "CVE-2000-0004", + "Description": "", + "Link": "", + "Severity": "not-a-severity", + "FixedBy": "", + "NamespaceName": "" + } + ] + }, + { + "Name": "busybox", + "Version": "1.35.0", + "Vulnerabilities": [] + } + ] + } + } +} diff --git a/unittests/scans/quay/quay_no_vuln.json b/unittests/scans/quay/quay_no_vuln.json new file mode 100644 index 00000000000..ec6b5770d39 --- /dev/null +++ b/unittests/scans/quay/quay_no_vuln.json @@ -0,0 +1,31 @@ +{ + "status": "scanned", + "tag": "1.4.0", + "data": { + "Layer": { + "Name": "sha256:aaaa1111bbbb2222cccc3333dddd4444", + "Features": [ + { + "Name": "openssl", + "Version": "3.0.11-1", + "Vulnerabilities": [] + }, + { + "Name": "curl", + "Version": "7.81.0-1", + "Vulnerabilities": [] + }, + { + "Name": "zlib", + "Version": "1.2.13", + "Vulnerabilities": [] + }, + { + "Name": "busybox", + "Version": "1.35.0", + "Vulnerabilities": [] + } + ] + } + } +} diff --git a/unittests/scans/quay/quay_one_vuln.json b/unittests/scans/quay/quay_one_vuln.json new file mode 100644 index 00000000000..979ca1e690f --- /dev/null +++ b/unittests/scans/quay/quay_one_vuln.json @@ -0,0 +1,25 @@ +{ + "status": "scanned", + "tag": "1.4.0", + "data": { + "Layer": { + "Name": "sha256:aaaa1111bbbb2222cccc3333dddd4444", + "Features": [ + { + "Name": "openssl", + "Version": "3.0.11-1", + "Vulnerabilities": [ + { + "Name": "CVE-2000-0001", + "Description": "<p>A flaw in certificate verification allows an attacker to bypass validation.</p>", + "Link": "https://example.com/advisories/cve-2000-0001", + "Severity": "Critical", + "FixedBy": "3.0.13-1", + "NamespaceName": "debian:12" + } + ] + } + ] + } + } +} diff --git a/unittests/scans/qwiet/qwiet_many_vuln.json b/unittests/scans/qwiet/qwiet_many_vuln.json new file mode 100644 index 00000000000..a3aa816a288 --- /dev/null +++ b/unittests/scans/qwiet/qwiet_many_vuln.json @@ -0,0 +1,81 @@ +{ + "ok": true, + "response": [ + { + "id": "1", + "app": "generic-app", + "type": "vuln", + "category": "SQL Injection", + "owasp_category": "a03-injection", + "severity": "critical", + "title": "SQL Injection in ReportController", + "description": "A request parameter is concatenated into a database query.", + "internal_id": "sl/00000000-0000-4000-8000-000000000001", + "related_findings": [], + "details": { + "source_method": "com.example.web.ReportController.list", + "sink_method": "java.sql.Statement.executeQuery", + "file_locations": ["src/main/java/com/example/web/ReportController.java:88", + "src/main/java/com/example/db/Reports.java:120"] + }, + "tags": [ + {"key": "cwe_category", "value": "CWE-89"}, + {"key": "reachability", "value": "reachable"}, + {"key": "owasp_category", "value": "a03-injection"} + ] + }, + { + "id": "2", + "app": "generic-app", + "type": "oss_vuln", + "category": "Vulnerable dependency", + "severity": "high", + "title": "CVE-2000-0001 in lib", + "description": "The dependency is affected by a known vulnerability.", + "internal_id": "sl/00000000-0000-4000-8000-000000000002", + "related_findings": ["sl/00000000-0000-4000-8000-000000000001"], + "details": {"file_locations": ["pom.xml"]}, + "tags": [ + {"key": "cve", "value": "CVE-2000-0001"}, + {"key": "package_url", "value": "pkg:maven/org.example/lib@1.2.3"}, + {"key": "cvss_score", "value": "9.8"}, + {"key": "cwe_category", "value": "502"} + ] + }, + { + "id": "3", + "type": "oss_vuln", + "category": "Vulnerable dependency", + "severity": "medium", + "title": "CVE-2000-0002 in other-lib", + "internal_id": "sl/00000000-0000-4000-8000-000000000003", + "related_findings": [], + "tags": [ + {"key": "cve", "value": "CVE-2000-0002"}, + {"key": "package_url", "value": "pkg:npm/other-lib@4.5.6"}, + {"key": "cvss_score", "value": "not a score"}, + {"key": "reachability", "value": "unreachable"} + ] + }, + { + "id": "4", + "type": "vuln", + "category": "", + "severity": "not a label", + "title": "", + "internal_id": "", + "details": {"file_locations": ["src/main/java/com/example/Other.java"]}, + "tags": [{"key": "cwe_category", "value": "not a cwe"}] + }, + { + "id": "5", + "type": "vuln", + "category": "Hardcoded credential", + "severity": "low", + "title": "Hardcoded credential in Config", + "internal_id": "sl/00000000-0000-4000-8000-000000000005", + "details": {"file_locations": ["src/main/java/com/example/Config.java:not-a-line"]}, + "tags": [] + } + ] +} diff --git a/unittests/scans/qwiet/qwiet_no_vuln.json b/unittests/scans/qwiet/qwiet_no_vuln.json new file mode 100644 index 00000000000..2ec4e7bca76 --- /dev/null +++ b/unittests/scans/qwiet/qwiet_no_vuln.json @@ -0,0 +1,4 @@ +{ + "ok": true, + "response": [] +} diff --git a/unittests/scans/qwiet/qwiet_one_vuln.json b/unittests/scans/qwiet/qwiet_one_vuln.json new file mode 100644 index 00000000000..375973c1b1b --- /dev/null +++ b/unittests/scans/qwiet/qwiet_one_vuln.json @@ -0,0 +1,39 @@ +{ + "ok": true, + "response": [ + { + "id": "1", + "app": "generic-app", + "type": "vuln", + "category": "SQL Injection", + "owasp_category": "a03-injection", + "severity": "critical", + "title": "SQL Injection in ReportController", + "description": "A request parameter is concatenated into a database query.", + "internal_id": "sl/00000000-0000-4000-8000-000000000001", + "related_findings": [], + "details": { + "source_method": "com.example.web.ReportController.list", + "sink_method": "java.sql.Statement.executeQuery", + "file_locations": [ + "src/main/java/com/example/web/ReportController.java:88", + "src/main/java/com/example/db/Reports.java:120" + ] + }, + "tags": [ + { + "key": "cwe_category", + "value": "CWE-89" + }, + { + "key": "reachability", + "value": "reachable" + }, + { + "key": "owasp_category", + "value": "a03-injection" + } + ] + } + ] +} diff --git a/unittests/scans/socket/socket_many_vuln.json b/unittests/scans/socket/socket_many_vuln.json new file mode 100644 index 00000000000..4a0e933753d --- /dev/null +++ b/unittests/scans/socket/socket_many_vuln.json @@ -0,0 +1,69 @@ +{ + "artifacts": [ + { + "id": "art-1", + "type": "npm", + "namespace": "@example-scope", + "name": "utils", + "version": "0.2.1", + "alerts": [ + { + "key": "alert-1", + "type": "malware", + "severity": "critical", + "category": "supplyChainRisk", + "file": "package.json", + "props": { + "note": "obfuscated install script", + "confidence": "high" + } + } + ] + }, + { + "id": "art-2", + "type": "pypi", + "namespace": "", + "name": "requests", + "version": "2.0.0", + "alerts": [ + { + "key": "alert-2", + "type": "cve", + "severity": "high", + "category": "vulnerability", + "props": { + "cve": "CVE-2000-0000" + } + }, + { + "key": "alert-3", + "type": "typosquat", + "severity": "middle", + "category": "supplyChainRisk" + } + ] + }, + { + "id": "art-3", + "type": "golang", + "namespace": "github.com/example", + "name": "widget", + "version": "1.4.0", + "alerts": [ + { + "key": "alert-4", + "type": "installScripts", + "severity": "low", + "category": "supplyChainRisk" + }, + { + "key": "alert-5", + "type": "unknownThing", + "severity": "not-a-level", + "category": "" + } + ] + } + ] +} diff --git a/unittests/scans/socket/socket_no_vuln.json b/unittests/scans/socket/socket_no_vuln.json new file mode 100644 index 00000000000..08944e0f51c --- /dev/null +++ b/unittests/scans/socket/socket_no_vuln.json @@ -0,0 +1,3 @@ +{ + "artifacts": [] +} diff --git a/unittests/scans/socket/socket_one_vuln.json b/unittests/scans/socket/socket_one_vuln.json new file mode 100644 index 00000000000..37bdd45692f --- /dev/null +++ b/unittests/scans/socket/socket_one_vuln.json @@ -0,0 +1,24 @@ +{ + "artifacts": [ + { + "id": "art-1", + "type": "npm", + "namespace": "@example-scope", + "name": "utils", + "version": "0.2.1", + "alerts": [ + { + "key": "alert-1", + "type": "malware", + "severity": "critical", + "category": "supplyChainRisk", + "file": "package.json", + "props": { + "note": "obfuscated install script", + "confidence": "high" + } + } + ] + } + ] +} diff --git a/unittests/scans/soos/soos_many_vuln.json b/unittests/scans/soos/soos_many_vuln.json new file mode 100644 index 00000000000..810f7e93e37 --- /dev/null +++ b/unittests/scans/soos/soos_many_vuln.json @@ -0,0 +1,88 @@ +{ + "entries": [ + { + "id": "issue-0001", + "title": "Prototype pollution in the example utility library", + "severity": "Critical", + "description": "A crafted key allows an attacker to modify the object prototype.", + "cve": "CVE-2000-0001", + "cwe": "CWE-1321", + "packageName": "example-utils", + "packageVersion": "4.17.20", + "fixedVersion": "4.17.21", + "remediation": "", + "fileName": "package-lock.json", + "line": 0, + "url": "", + "cvssScore": 9.8, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": "https://example.com/advisories/CVE-2000-0001", + "status": "Open", + "scanType": "sca", + "firstDetected": "2024-06-02T10:00:00Z" + }, + { + "id": "issue-0002", + "title": "Reflected cross-site scripting", + "severity": "High", + "description": "", + "cve": "", + "cwe": "79", + "packageName": "", + "url": "https://app.example.com/search?q=1", + "cvssScore": 7.4, + "status": "Open", + "scanType": "dast", + "firstDetected": "2024-06-03T10:00:00Z" + }, + { + "id": "issue-0003", + "title": "Hard-coded credential pattern", + "severity": "Medium", + "cwe": "not a cwe", + "fileName": "src/generic/config.py", + "line": 42, + "remediation": "Move the value into an environment variable.", + "status": "False positive", + "scanType": "sast", + "firstDetected": "2024-06-04T10:00:00Z" + }, + { + "id": "issue-0004", + "title": "Outdated base image", + "severity": "Low", + "packageName": "example-base", + "packageVersion": "3.18", + "fixedVersion": "3.20", + "status": "Accepted", + "scanType": "csa", + "firstDetected": "2024-06-05T10:00:00Z" + }, + { + "id": "issue-0005", + "title": "Licence not on the approved list", + "severity": "Unknown", + "packageName": "example-lib", + "status": "Resolved", + "scanType": "sbom", + "firstDetected": "2024-06-06T10:00:00Z" + }, + { + "id": "issue-0006", + "title": "An issue from a scan type this parser does not know", + "severity": "a word SOOS does not use", + "status": "Something new", + "scanType": "iac", + "firstDetected": "not a timestamp" + }, + { + "id": "issue-0007", + "title": "An issue with a URL DefectDojo cannot accept", + "severity": "Low", + "url": "not a url at all", + "status": "Ignored", + "scanType": "dast" + } + ], + "total": 7 +} diff --git a/unittests/scans/soos/soos_no_vuln.json b/unittests/scans/soos/soos_no_vuln.json new file mode 100644 index 00000000000..759cb69b273 --- /dev/null +++ b/unittests/scans/soos/soos_no_vuln.json @@ -0,0 +1,4 @@ +{ + "entries": [], + "total": 0 +} diff --git a/unittests/scans/soos/soos_one_vuln.json b/unittests/scans/soos/soos_one_vuln.json new file mode 100644 index 00000000000..4a281d6592d --- /dev/null +++ b/unittests/scans/soos/soos_one_vuln.json @@ -0,0 +1,26 @@ +{ + "entries": [ + { + "id": "issue-0001", + "title": "Prototype pollution in the example utility library", + "severity": "Critical", + "description": "A crafted key allows an attacker to modify the object prototype.", + "cve": "CVE-2000-0001", + "cwe": "CWE-1321", + "packageName": "example-utils", + "packageVersion": "4.17.20", + "fixedVersion": "4.17.21", + "remediation": "", + "fileName": "package-lock.json", + "line": 0, + "url": "", + "cvssScore": 9.8, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "references": "https://example.com/advisories/CVE-2000-0001", + "status": "Open", + "scanType": "sca", + "firstDetected": "2024-06-02T10:00:00Z" + } + ], + "total": 1 +} diff --git a/unittests/scans/uptycs/uptycs_many_vuln.json b/unittests/scans/uptycs/uptycs_many_vuln.json new file mode 100644 index 00000000000..366983a929f --- /dev/null +++ b/unittests/scans/uptycs/uptycs_many_vuln.json @@ -0,0 +1,40 @@ +{ + "items": [ + { + "cve_list": ["CVE-2000-0001", "CVE-2000-0002", "CVE-2000-0003"], + "package_name": "example-tls", + "package_version": "1.0.2k-1", + "cvss_score": 9.8, + "os": "Ubuntu 22.04", + "upt_asset_id": "asset-0001", + "upt_hostname": "generic-host-01", + "upt_asset_group_name": "production-linux" + }, + { + "cve_list": "CVE-2000-0004, CVE-2000-0005", + "package_name": "example-compress", + "package_version": "1.2.3", + "cvss_score": "7.5", + "os": "Ubuntu 22.04", + "upt_asset_id": "asset-0001", + "upt_hostname": "generic-host-01", + "upt_asset_group_name": "production-linux" + }, + { + "cve_list": [], + "package_name": "example-agent", + "package_version": "3.0.0", + "cvss_score": 0, + "os": "Windows Server 2019", + "upt_asset_id": "asset-0002", + "upt_hostname": "generic-host-02" + }, + { + "cve_list": ["CVE-2000-0006"], + "package_name": "", + "package_version": "", + "cvss_score": 4.3, + "upt_asset_id": "asset-0003" + } + ] +} diff --git a/unittests/scans/uptycs/uptycs_no_vuln.json b/unittests/scans/uptycs/uptycs_no_vuln.json new file mode 100644 index 00000000000..2feb21015d1 --- /dev/null +++ b/unittests/scans/uptycs/uptycs_no_vuln.json @@ -0,0 +1,3 @@ +{ + "items": [] +} diff --git a/unittests/scans/uptycs/uptycs_one_vuln.json b/unittests/scans/uptycs/uptycs_one_vuln.json new file mode 100644 index 00000000000..157c63e4649 --- /dev/null +++ b/unittests/scans/uptycs/uptycs_one_vuln.json @@ -0,0 +1,15 @@ +{ + "items": [ + { + "cve_list": ["CVE-2000-0001"], + "package_name": "example-tls", + "package_version": "1.0.2k-1", + "cvss_score": 9.8, + "os": "Ubuntu 22.04", + "upt_asset_id": "asset-0001", + "upt_hostname": "generic-host-01", + "upt_asset_group_name": "production-linux", + "upt_asset_group_id": "group-1" + } + ] +} diff --git a/unittests/scans/vanta/vanta_many_vuln.json b/unittests/scans/vanta/vanta_many_vuln.json new file mode 100644 index 00000000000..4b0ebaef55a --- /dev/null +++ b/unittests/scans/vanta/vanta_many_vuln.json @@ -0,0 +1,86 @@ +{ + "results": { + "data": [ + { + "id": "test-mfa-enabled", + "name": "MFA is enabled for all users", + "description": "Every user with console access must have multi-factor authentication enabled.", + "failureDescription": "One or more users have no MFA device registered.", + "remediationDescription": "Register an MFA device for each user listed below.", + "category": "Access Control", + "integrations": ["aws", "okta"], + "status": "NEEDS_ATTENTION", + "lastTestRunDate": "2024-07-15T06:00:00Z", + "latestFlipDate": "2024-06-20T06:00:00Z", + "owner": {"id": "owner-1", "displayName": "Example Owner", "emailAddress": "owner@example.com"} + }, + { + "id": "test-disk-encryption", + "name": "Disk encryption is enabled on workstations", + "description": "Company workstations must encrypt the startup volume.", + "failureDescription": "A workstation reported encryption as off.", + "remediationDescription": "Enable full-disk encryption on the workstation.", + "category": "Endpoint Security", + "integrations": [], + "status": "NEEDS_ATTENTION", + "latestFlipDate": "2024-05-01T06:00:00Z" + }, + { + "id": "test-no-failing-entities", + "name": "Backups are configured", + "description": "Production data must be backed up.", + "remediationDescription": "Configure backups.", + "category": "Availability", + "status": "OK", + "latestFlipDate": "2024-01-01T06:00:00Z" + }, + { + "id": "test-unnamed", + "name": "", + "category": "", + "status": "NEEDS_ATTENTION" + } + ] + }, + "entities": { + "test-mfa-enabled": { + "results": { + "data": [ + { + "id": "user-0001", + "entityStatus": "FAILING", + "displayName": "example-user@example.com", + "responseType": "IAM_USER", + "createdDate": "2024-07-01T12:00:00Z", + "lastUpdatedDate": "2024-07-15T06:00:00Z" + }, + { + "id": "user-0002", + "entityStatus": "FAILING", + "displayName": "second-user@example.com", + "responseType": "IAM_USER", + "createdDate": "not a timestamp" + }, + { + "id": "user-0003", + "entityStatus": "OK", + "displayName": "compliant-user@example.com", + "responseType": "IAM_USER", + "createdDate": "2024-07-01T12:00:00Z" + } + ] + } + }, + "test-disk-encryption": [ + { + "id": "workstation-0001", + "entityStatus": "FAILING", + "displayName": "laptop-11", + "responseType": "WORKSTATION" + } + ], + "test-unnamed": [ + {"id": "resource-0001", "entityStatus": "FAILING", "displayName": "", "responseType": ""} + ] + } +} diff --git a/unittests/scans/vanta/vanta_no_vuln.json b/unittests/scans/vanta/vanta_no_vuln.json new file mode 100644 index 00000000000..e1f30db38a9 --- /dev/null +++ b/unittests/scans/vanta/vanta_no_vuln.json @@ -0,0 +1,16 @@ +{ + "results": { + "data": [ + { + "id": "test-no-failing-entities", + "name": "Backups are configured", + "description": "Production data must be backed up.", + "remediationDescription": "Configure backups.", + "category": "Availability", + "status": "OK", + "latestFlipDate": "2024-01-01T06:00:00Z" + } + ] + }, + "entities": {} +} diff --git a/unittests/scans/vanta/vanta_one_vuln.json b/unittests/scans/vanta/vanta_one_vuln.json new file mode 100644 index 00000000000..a5aa62016a1 --- /dev/null +++ b/unittests/scans/vanta/vanta_one_vuln.json @@ -0,0 +1,42 @@ +{ + "results": { + "data": [ + { + "id": "test-mfa-enabled", + "name": "MFA is enabled for all users", + "description": "Every user with console access must have multi-factor authentication enabled.", + "failureDescription": "One or more users have no MFA device registered.", + "remediationDescription": "Register an MFA device for each user listed below.", + "category": "Access Control", + "integrations": [ + "aws", + "okta" + ], + "status": "NEEDS_ATTENTION", + "lastTestRunDate": "2024-07-15T06:00:00Z", + "latestFlipDate": "2024-06-20T06:00:00Z", + "owner": { + "id": "owner-1", + "displayName": "Example Owner", + "emailAddress": "owner@example.com" + } + } + ] + }, + "entities": { + "test-mfa-enabled": { + "results": { + "data": [ + { + "id": "user-0001", + "entityStatus": "FAILING", + "displayName": "example-user@example.com", + "responseType": "IAM_USER", + "createdDate": "2024-07-01T12:00:00Z", + "lastUpdatedDate": "2024-07-15T06:00:00Z" + } + ] + } + } + } +} diff --git a/unittests/scans/venafi/venafi_many_vuln.json b/unittests/scans/venafi/venafi_many_vuln.json new file mode 100644 index 00000000000..aae8014d6cf --- /dev/null +++ b/unittests/scans/venafi/venafi_many_vuln.json @@ -0,0 +1,60 @@ +{ + "certificates": [ + { + "id": "cert-0002", + "fingerprint": "BB11CC22DD33EE44FF5500112233445566778899", + "subjectCN": ["legacy.example.com"], + "subjectAlternativeNamesByType": {"dNSName": ["legacy.example.com"]}, + "issuerCN": ["Generic Issuing CA"], + "keyStrength": 1024, + "encryptionType": "RSA", + "signatureHashAlgorithm": "SHA-1", + "validityEnd": "2020-03-01T12:00:00Z", + "selfSigned": true + }, + { + "id": "cert-0003", + "fingerprint": "CC11DD22EE33FF445500112233445566778899AA", + "subjectCN": [], + "subjectDN": "CN=internal.example.com,O=Generic Org", + "subjectAlternativeNamesByType": {}, + "issuerCN": [], + "issuerDN": "CN=Generic Issuing CA", + "keyStrength": "256", + "encryptionType": "EC", + "signatureHashAlgorithm": "SHA384", + "validityEnd": "2099-06-01", + "selfSigned": false + }, + { + "id": "cert-0004", + "fingerprint": "DD11EE22FF33445500112233445566778899AABB", + "subjectCN": ["healthy.example.com"], + "keyStrength": 2048, + "encryptionType": "RSA", + "signatureHashAlgorithm": "SHA256", + "validityEnd": "2099-01-01T00:00:00Z", + "selfSigned": false + }, + { + "id": "cert-0005", + "subjectCN": ["no-expiry.example.com"], + "keyStrength": 0, + "encryptionType": "", + "signatureHashAlgorithm": "", + "validityEnd": "", + "selfSigned": false + }, + { + "id": "cert-0006", + "fingerprint": "EE11FF224433445500112233445566778899AABB", + "subjectCN": ["md5.example.com"], + "keyStrength": 2048, + "encryptionType": "RSA", + "signatureAlgorithm": "md5WithRSAEncryption", + "validityEnd": "2099-01-01T00:00:00Z", + "selfSigned": false + } + ], + "count": 5 +} diff --git a/unittests/scans/venafi/venafi_no_vuln.json b/unittests/scans/venafi/venafi_no_vuln.json new file mode 100644 index 00000000000..7a7bba1284b --- /dev/null +++ b/unittests/scans/venafi/venafi_no_vuln.json @@ -0,0 +1,22 @@ +{ + "certificates": [ + { + "id": "cert-0001", + "fingerprint": "AA11BB22CC33DD44EE55FF6677889900AABBCCDD", + "subjectCN": ["app.example.com"], + "subjectDN": "CN=app.example.com,O=Generic Org", + "subjectAlternativeNamesByType": {"dNSName": ["app.example.com", "www.example.com"]}, + "issuerCN": ["Generic Issuing CA"], + "issuerDN": "CN=Generic Issuing CA,O=Generic Org", + "keyStrength": 4096, + "encryptionType": "RSA", + "signatureHashAlgorithm": "SHA256", + "signatureAlgorithm": "sha256WithRSAEncryption", + "validityEnd": "2099-01-01T00:00:00Z", + "certificateStatus": "ACTIVE", + "selfSigned": false, + "applicationIds": ["app-1"] + } + ], + "count": 1 +} diff --git a/unittests/scans/venafi/venafi_one_vuln.json b/unittests/scans/venafi/venafi_one_vuln.json new file mode 100644 index 00000000000..42878e7d658 --- /dev/null +++ b/unittests/scans/venafi/venafi_one_vuln.json @@ -0,0 +1,19 @@ +{ + "certificates": [ + { + "id": "cert-0002", + "fingerprint": "BB11CC22DD33EE44FF5500112233445566778899", + "subjectCN": ["legacy.example.com"], + "subjectDN": "CN=legacy.example.com,O=Generic Org", + "subjectAlternativeNamesByType": {"dNSName": ["legacy.example.com"]}, + "issuerCN": ["Generic Issuing CA"], + "keyStrength": 4096, + "encryptionType": "RSA", + "signatureHashAlgorithm": "SHA256", + "validityEnd": "2020-03-01T12:00:00Z", + "certificateStatus": "ACTIVE", + "selfSigned": false + } + ], + "count": 1 +} diff --git a/unittests/scans/venafi/venafi_self_hosted.json b/unittests/scans/venafi/venafi_self_hosted.json new file mode 100644 index 00000000000..414dcf4766e --- /dev/null +++ b/unittests/scans/venafi/venafi_self_hosted.json @@ -0,0 +1,32 @@ +{ + "Certificates": [ + { + "DN": "\\VED\\Policy\\Generic Org\\Certificates\\legacy.example.com", + "ParentDN": "\\VED\\Policy\\Generic Org\\Certificates", + "Guid": "{00000000-0000-4000-8000-000000000001}", + "CN": "legacy.example.com", + "Subject": "CN=legacy.example.com,O=Generic Org", + "Issuer": "CN=legacy.example.com,O=Generic Org", + "Serial": "0A0B0C0D", + "Thumbprint": "FF11EE22DD33CC44BB5500112233445566778899", + "KeyAlgorithm": "RSA", + "KeySize": 1024, + "SignatureAlgorithm": "sha1WithRSAEncryption", + "ValidTo": "2020-03-01T12:00:00" + }, + { + "DN": "\\VED\\Policy\\Generic Org\\Certificates\\healthy.example.com", + "ParentDN": "\\VED\\Policy\\Generic Org\\Certificates", + "Guid": "{00000000-0000-4000-8000-000000000002}", + "CN": "healthy.example.com", + "Subject": "CN=healthy.example.com,O=Generic Org", + "Issuer": "CN=Generic Issuing CA,O=Generic Org", + "Thumbprint": "11223344556677889900AABBCCDDEEFF00112233", + "KeyAlgorithm": "RSA", + "KeySize": 2048, + "SignatureAlgorithm": "sha256WithRSAEncryption", + "ValidTo": "2099-01-01T00:00:00" + } + ], + "TotalCount": 2 +} diff --git a/unittests/scans/vmanplus/vmanplus_many_vuln.json b/unittests/scans/vmanplus/vmanplus_many_vuln.json new file mode 100644 index 00000000000..328d5bfb321 --- /dev/null +++ b/unittests/scans/vmanplus/vmanplus_many_vuln.json @@ -0,0 +1,55 @@ +{ + "vulnerabilities": [ + { + "resource_id": "301", + "resource_name": "generic-host-01", + "ip_address": "10.20.0.11", + "vulnerabilityid": "50123", + "vulnerabilityname": "Remote code execution in the example office suite", + "cveids": "CVE-2000-0002, CVE-2000-0001", + "severity": "Important", + "cvss_2_score": 9.3, + "cvss_3_score": 8.8, + "patch_description": "Example Office Suite security update 2024-06", + "patchid": "77001", + "vulnerability_status": "Open", + "updatedtime": 1717322400000 + }, + { + "resource_id": 302, + "fqdn_name": "generic-host-02.corp.example.com", + "ip_address": "10.20.0.12", + "vulnerabilityid": 50124, + "vulnerabilityname": "Elevation of privilege in the example service", + "cveids": "CVE-2000-0003", + "severity": "Moderate", + "cvss_2_score": 6.8, + "cvss_3_score": 0, + "vulnerability_status": "Closed", + "updatedtime": "1717408800000" + }, + { + "resource_id": "303", + "ip_address": "10.20.0.13", + "vulnerabilityid": "50125", + "vulnerabilityname": "", + "cveids": "CVE-2000-0004", + "severity": "Unrated", + "cvss_2_score": 0, + "cvss_3_score": 0, + "vulnerability_status": "Mitigated", + "updatedtime": 0 + }, + { + "resource_id": "304", + "resource_name": "generic-host-04", + "vulnerabilityid": "50126", + "vulnerabilityname": "", + "cveids": "", + "severity": "critical", + "cvss_3_score": 9.9, + "vulnerability_status": "Fixed" + } + ], + "metadata": {"cursor": "c4", "isNextPageAvailable": false, "totalRecords": 4} +} diff --git a/unittests/scans/vmanplus/vmanplus_no_vuln.json b/unittests/scans/vmanplus/vmanplus_no_vuln.json new file mode 100644 index 00000000000..ec419fa5850 --- /dev/null +++ b/unittests/scans/vmanplus/vmanplus_no_vuln.json @@ -0,0 +1,4 @@ +{ + "vulnerabilities": [], + "metadata": {"cursor": "", "isNextPageAvailable": false, "totalRecords": 0} +} diff --git a/unittests/scans/vmanplus/vmanplus_one_vuln.json b/unittests/scans/vmanplus/vmanplus_one_vuln.json new file mode 100644 index 00000000000..02f360db34c --- /dev/null +++ b/unittests/scans/vmanplus/vmanplus_one_vuln.json @@ -0,0 +1,23 @@ +{ + "vulnerabilities": [ + { + "resource_id": "301", + "resource_name": "generic-host-01", + "fqdn_name": "generic-host-01.corp.example.com", + "ip_address": "10.20.0.11", + "vulnerabilityid": "50123", + "vulnerabilityname": "Remote code execution in the example office suite", + "cveids": "CVE-2000-0001", + "severity": "Important", + "cvss_2_score": 9.3, + "cvss_3_score": 8.8, + "patch_description": "Example Office Suite security update 2024-06", + "patchid": "77001", + "reference_links": "https://example.com/advisories/CVE-2000-0001", + "exploitscount": "0", + "vulnerability_status": "Open", + "updatedtime": 1717322400000 + } + ], + "metadata": {"cursor": "c1", "isNextPageAvailable": false, "totalRecords": 1} +} diff --git a/unittests/scans/wallarm/wallarm_many_vuln.json b/unittests/scans/wallarm/wallarm_many_vuln.json new file mode 100644 index 00000000000..9e52ac8f87e --- /dev/null +++ b/unittests/scans/wallarm/wallarm_many_vuln.json @@ -0,0 +1,89 @@ +{ + "status": 200, + "body": [ + { + "id": 700001, + "wid": "wid-0001", + "title": "SQL injection in the reports endpoint", + "type": "sqli", + "template": "sqli_template", + "status": "active", + "threat": 5, + "clientid": 4321, + "method": "POST", + "domain": "api.example.com", + "path": "/v1/reports", + "parameter": "post_body|json_doc|hash|filter", + "description": "A request parameter reaches a database query. Related to CVE-2000-0002 and cve-2000-0002.", + "additional": "Validated against the running service. See CVE-2000-0001.", + "exploit_example": "POST /v1/reports with filter=1' OR '1'='1", + "detection_method": "active_verification", + "validated": true, + "validate_time": 1719792000, + "invalidate_time": 0, + "filter": [{"method": "POST", "domain": "api.example.com", "path": "/v1/reports", "parameter": "filter"}] + }, + { + "id": 700002, + "title": "Weak JWT signature algorithm", + "type": "weak_jwt", + "status": "active", + "threat": "high", + "method": "GET", + "domain": "api.example.com", + "path": "/v1/profile", + "description": "The service accepts tokens signed with a weak algorithm.", + "exploit_example": "Forge a token using the none algorithm.", + "validate_time": 1717200000 + }, + { + "id": 0, + "wid": "wid-0003", + "title": "Information disclosure in an error page", + "type": "infoleak", + "status": "active", + "threat": 3, + "domain": "shop.example.com", + "path": "not-a-path", + "description": "A stack trace was returned to the client.", + "validate_time": 0 + }, + { + "id": 0, + "wid": "", + "type": "", + "title": "", + "status": "active", + "threat": null, + "domain": "legacy.example.com", + "path": "/old" + }, + { + "id": 700005, + "title": "A closed vulnerability", + "type": "sqli", + "status": "closed", + "threat": 5, + "domain": "api.example.com", + "path": "/v1/old" + }, + { + "id": 700006, + "title": "A false positive", + "type": "xss", + "status": "falsepositive", + "threat": 4, + "domain": "api.example.com", + "path": "/v1/search" + }, + { + "id": 700007, + "title": "An unrecognised threat label", + "type": "misconfig", + "status": "active", + "threat": "severe", + "domain": "api.example.com", + "path": "/v1/config" + } + ] +} diff --git a/unittests/scans/wallarm/wallarm_no_vuln.json b/unittests/scans/wallarm/wallarm_no_vuln.json new file mode 100644 index 00000000000..fb4116043c6 --- /dev/null +++ b/unittests/scans/wallarm/wallarm_no_vuln.json @@ -0,0 +1,23 @@ +{ + "status": 200, + "body": [ + { + "id": 700005, + "title": "A closed vulnerability", + "type": "sqli", + "status": "closed", + "threat": 5, + "domain": "api.example.com", + "path": "/v1/old" + }, + { + "id": 700006, + "title": "A false positive", + "type": "xss", + "status": "falsepositive", + "threat": 4, + "domain": "api.example.com", + "path": "/v1/search" + } + ] +} diff --git a/unittests/scans/wallarm/wallarm_one_vuln.json b/unittests/scans/wallarm/wallarm_one_vuln.json new file mode 100644 index 00000000000..395a70b8dfc --- /dev/null +++ b/unittests/scans/wallarm/wallarm_one_vuln.json @@ -0,0 +1,34 @@ +{ + "status": 200, + "body": [ + { + "id": 700001, + "wid": "wid-0001", + "title": "SQL injection in the reports endpoint", + "type": "sqli", + "template": "sqli_template", + "status": "active", + "threat": 5, + "clientid": 4321, + "method": "POST", + "domain": "api.example.com", + "path": "/v1/reports", + "parameter": "post_body|json_doc|hash|filter", + "description": "A request parameter reaches a database query. Related to CVE-2000-0002 and cve-2000-0002.", + "additional": "Validated against the running service. See CVE-2000-0001.", + "exploit_example": "POST /v1/reports with filter=1' OR '1'='1", + "detection_method": "active_verification", + "validated": true, + "validate_time": 1719792000, + "invalidate_time": 0, + "filter": [ + { + "method": "POST", + "domain": "api.example.com", + "path": "/v1/reports", + "parameter": "filter" + } + ] + } + ] +} diff --git a/unittests/scans/yeswehack/yeswehack_many_vuln.json b/unittests/scans/yeswehack/yeswehack_many_vuln.json new file mode 100644 index 00000000000..f978bc4b150 --- /dev/null +++ b/unittests/scans/yeswehack/yeswehack_many_vuln.json @@ -0,0 +1,94 @@ +{ + "items": [ + { + "id": 40001, + "local_id": "GENERIC-2026-0001", + "title": "SQL injection on the search endpoint", + "scope": "app.example.com", + "end_point": "https://app.example.com/search", + "cvss": { + "criticity": "critical", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "priority": {"name": "P1", "slug": "p1", "level": 1}, + "bug_type": { + "name": "SQL Injection", + "slug": "sql-injection", + "description": "", + "category": {"name": "Injection"} + }, + "status": {"workflow_state": "accepted", "title": "Accepted"}, + "description_html": "The q parameter is concatenated into a SQL query.", + "technical_information_html": "Related to CVE-2000-0002.", + "impact": "An attacker can read the whole database.", + "created_at": "2026-07-20T09:00:00+0000" + }, + { + "id": 40002, + "local_id": "GENERIC-2026-0002", + "title": "Resolved header issue", + "scope": "app.example.com", + "end_point": "", + "cvss": {"criticity": "", "score": 0, "vector": ""}, + "priority": {"name": "Medium", "slug": "medium", "level": 3}, + "bug_type": null, + "status": {"workflow_state": "resolved", "title": "Resolved"}, + "description_html": "", + "impact": "", + "created_at": "2026-07-21 10:00:00" + }, + { + "id": 40003, + "local_id": "", + "title": "", + "scope": "", + "end_point": "", + "cvss": {"criticity": "unknown-word", "score": 0, "vector": ""}, + "priority": {"name": "not-a-level", "slug": "low", "level": 5}, + "status": {"workflow_state": "wont_fix", "title": "Won't fix"}, + "description_html": "", + "impact": "", + "created_at": "2026-07-22" + }, + { + "id": 40004, + "local_id": "GENERIC-2026-0004", + "title": "Rejected report", + "scope": "shop.example.com", + "end_point": "", + "cvss": {"criticity": "low", "score": 2.0, "vector": "CVSS:3.1/AV:N"}, + "status": {"workflow_state": "rejected", "title": "Rejected"}, + "description_html": "", + "impact": "", + "created_at": "not-a-timestamp" + }, + { + "id": 40005, + "local_id": "GENERIC-2026-0005", + "title": "Duplicate report", + "scope": "shop.example.com", + "cvss": {"criticity": "medium", "score": 5.0, "vector": ""}, + "status": {"workflow_state": "duplicate", "title": "Duplicate"}, + "created_at": "2026-07-24T09:00:00+0000" + }, + { + "id": 40006, + "local_id": "GENERIC-2026-0006", + "title": "Out of scope report", + "scope": "shop.example.com", + "cvss": {"criticity": "high", "score": 7.0, "vector": ""}, + "status": {"workflow_state": "out_of_scope", "title": "Out of scope"}, + "created_at": "2026-07-25T09:00:00+0000" + }, + { + "id": 40007, + "local_id": "GENERIC-2026-0007", + "title": "A brand new state YesWeHack added", + "scope": "shop.example.com", + "cvss": {"criticity": "low", "score": 1.0, "vector": ""}, + "status": {"workflow_state": "some_new_state", "title": "New state"}, + "created_at": "2026-07-26T09:00:00+0000" + } + ] +} diff --git a/unittests/scans/yeswehack/yeswehack_no_vuln.json b/unittests/scans/yeswehack/yeswehack_no_vuln.json new file mode 100644 index 00000000000..2feb21015d1 --- /dev/null +++ b/unittests/scans/yeswehack/yeswehack_no_vuln.json @@ -0,0 +1,3 @@ +{ + "items": [] +} diff --git a/unittests/scans/yeswehack/yeswehack_one_vuln.json b/unittests/scans/yeswehack/yeswehack_one_vuln.json new file mode 100644 index 00000000000..73f680ba821 --- /dev/null +++ b/unittests/scans/yeswehack/yeswehack_one_vuln.json @@ -0,0 +1,37 @@ +{ + "items": [ + { + "id": 40001, + "local_id": "GENERIC-2026-0001", + "title": "SQL injection on the search endpoint", + "scope": "app.example.com", + "end_point": "https://app.example.com/search", + "cvss": { + "criticity": "critical", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "priority": { + "name": "P1", + "slug": "p1", + "level": 1 + }, + "bug_type": { + "name": "SQL Injection", + "slug": "sql-injection", + "description": "", + "category": { + "name": "Injection" + } + }, + "status": { + "workflow_state": "accepted", + "title": "Accepted" + }, + "description_html": "The q parameter is concatenated into a SQL query.", + "technical_information_html": "Related to CVE-2000-0002.", + "impact": "An attacker can read the whole database.", + "created_at": "2026-07-20T09:00:00+0000" + } + ] +} diff --git a/unittests/scans/zimperium/zimperium_many_vuln.json b/unittests/scans/zimperium/zimperium_many_vuln.json new file mode 100644 index 00000000000..1c13738848a --- /dev/null +++ b/unittests/scans/zimperium/zimperium_many_vuln.json @@ -0,0 +1,80 @@ +{ + "assessment": { + "id": "assess-0002", + "appVersion": "4.0.1", + "buildNumber": "500", + "buildUploadedAt": "2024-06-05T14:30:00Z" + }, + "app": { + "zdevAppId": "app-0002", + "name": "Generic Mobile App", + "bundleIdentifier": "com.example.genericapp", + "platform": "ios" + }, + "sarif": { + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "zScan", + "version": "1.0.0", + "rules": [ + { + "id": "ZS_HARDCODED_SECRET", + "name": "HardcodedSecret", + "shortDescription": {"text": "A credential-shaped string is embedded in the binary"}, + "helpUri": "https://example.com/rules/hardcoded-secret", + "properties": {"security-severity": "9.1", + "tags": ["external/cwe/cwe-798", "secrets"]} + }, + { + "id": "ZS_WEAK_CRYPTO", + "name": "WeakCryptography", + "shortDescription": {"text": "The app uses a broken cipher"}, + "help": {"text": "Use AES-GCM instead."}, + "properties": {"security-severity": "medium", "tags": ["external/cwe/cwe-327"]} + }, + { + "id": "ZS_DEBUG_SYMBOLS", + "shortDescription": {"text": "Debug symbols are present in the release build"} + } + ] + } + }, + "results": [ + { + "ruleId": "ZS_HARDCODED_SECRET", + "level": "error", + "message": {"text": "A credential-shaped string was found in the binary"}, + "locations": [ + {"physicalLocation": {"artifactLocation": {"uri": "Payload/GenericApp/Config.plist"}, + "region": {"startLine": 12}}} + ], + "properties": {"tags": ["mobile"]} + }, + { + "ruleId": "ZS_WEAK_CRYPTO", + "level": "warning", + "message": {"text": "DES is used to encrypt stored data"}, + "locations": [ + {"physicalLocation": {"artifactLocation": {"uri": "Payload/GenericApp/Crypto.m"}, + "region": {"startLine": 88}}} + ], + "suppressions": [{"kind": "external", "justification": "Reviewed and accepted"}] + }, + { + "ruleId": "ZS_DEBUG_SYMBOLS", + "level": "note", + "message": {"text": "Debug symbols are present"} + }, + { + "ruleId": "ZS_HARDCODED_SECRET", + "kind": "pass", + "message": {"text": "Never imported: this check passed"} + } + ] + } + ] + } +} diff --git a/unittests/scans/zimperium/zimperium_no_vuln.json b/unittests/scans/zimperium/zimperium_no_vuln.json new file mode 100644 index 00000000000..08a05c52928 --- /dev/null +++ b/unittests/scans/zimperium/zimperium_no_vuln.json @@ -0,0 +1,21 @@ +{ + "assessment": { + "id": "assess-0001", + "appVersion": "3.2.0", + "buildNumber": "412", + "buildUploadedAt": "2024-06-02T09:00:00Z" + }, + "app": { + "zdevAppId": "app-0001", + "name": "Generic Mobile App", + "bundleIdentifier": "com.example.genericapp", + "platform": "android", + "os": "android" + }, + "sarif": { + "version": "2.1.0", + "runs": [ + {"tool": {"driver": {"name": "zScan", "version": "1.0.0", "rules": []}}, "results": []} + ] + } +} diff --git a/unittests/scans/zimperium/zimperium_one_vuln.json b/unittests/scans/zimperium/zimperium_one_vuln.json new file mode 100644 index 00000000000..514856a23a7 --- /dev/null +++ b/unittests/scans/zimperium/zimperium_one_vuln.json @@ -0,0 +1,61 @@ +{ + "assessment": { + "id": "assess-0001", + "appVersion": "3.2.0", + "buildNumber": "412", + "buildUploadedAt": "2024-06-02T09:00:00Z" + }, + "app": { + "zdevAppId": "app-0001", + "name": "Generic Mobile App", + "bundleIdentifier": "com.example.genericapp", + "platform": "android" + }, + "sarif": { + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "zScan", + "version": "1.0.0", + "rules": [ + { + "id": "ZS_CLEARTEXT_TRAFFIC", + "name": "CleartextTrafficPermitted", + "shortDescription": {"text": "The app permits cleartext HTTP traffic"}, + "fullDescription": { + "text": "The network security configuration allows unencrypted HTTP to any host." + }, + "helpUri": "https://example.com/rules/cleartext-traffic", + "properties": { + "security-severity": "7.4", + "tags": ["external/cwe/cwe-319", "network"] + }, + "relationships": [{"target": {"id": "CWE-319"}}] + } + ] + } + }, + "results": [ + { + "ruleId": "ZS_CLEARTEXT_TRAFFIC", + "level": "error", + "kind": "fail", + "message": {"text": "Cleartext traffic is permitted for all domains"}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": "res/xml/network_security_config.xml"}, + "region": {"startLine": 3} + } + } + ], + "fixes": [{"description": {"text": "Set cleartextTrafficPermitted to false."}}], + "properties": {"tags": ["mobile"]} + } + ] + } + ] + } +} diff --git a/unittests/tools/test_accuknox_parser.py b/unittests/tools/test_accuknox_parser.py new file mode 100644 index 00000000000..eb55ee6e451 --- /dev/null +++ b/unittests/tools/test_accuknox_parser.py @@ -0,0 +1,244 @@ +import io +import json +from datetime import date + +from dojo.models import Finding, Test +from dojo.tools.accuknox.parser import AccuKnoxParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestAccuKnoxParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("accuknox") / filename).open(encoding="utf-8") as file: + return list(AccuKnoxParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the AccuKnox connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = AccuKnoxParser() + self.assertEqual(["AccuKnox - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "AccuKnox - Connectors Import", + parser.get_label_for_scan_types("AccuKnox - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("accuknox_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("accuknox_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring RowToFinding in the connector's converter.""" + findings = self.parse("accuknox_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 in openssl", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("af-0000000001", finding.unique_id_from_tool) + self.assertEqual("container_image", finding.vuln_id_from_tool) + self.assertEqual("openssl", finding.component_name) + self.assertEqual("3.0.11-1", finding.component_version) + self.assertEqual("registry.example.com/generic-api:1.4.0", finding.service) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("Rebuild the image on a patched base.", finding.mitigation) + self.assertEqual(date(2026, 7, 20), finding.date) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + def test_the_column_names_vary_by_data_type(self): + """ + AccuKnox returns container, IaC, cloud-posture and runtime rows through ONE endpoint, and the + column names differ per type. + + AccuKnox does not publish this part of its schema, so the connector probes a list of candidate + keys per field. Assuming a single set of names would silently import empty findings for every + type but one - the fixture's second row uses id/title/severity/finding_status where the first + uses finding_id/name/risk_factor/status. + """ + findings = self.by_uid("accuknox_many_vuln.json") + + first = findings["af-0000000001"] # finding_id / name / risk_factor / status + self.assertEqual("CVE-2000-0001 in openssl", first.title) + self.assertEqual("Critical", first.severity) + + second = findings["af-0000000002"] # id / title / severity / finding_status + self.assertEqual("S3 bucket is publicly readable", second.title) + self.assertEqual("High", second.severity) + self.assertEqual("generic-app-assets", second.service) + self.assertEqual("Remove the public grant.", second.mitigation) + + def test_the_vulnerability_column_prefix_is_also_tried(self): + """ + Some AccuKnox rows prefix their vulnerability columns with "vulnerability__". + + Every candidate key is tried both bare and prefixed, so a prefixed row still maps. Without + that the whole row would import as an empty Info finding. + """ + finding = self.by_uid("accuknox_many_vuln.json")["af-0000000003"] + self.assertEqual("CVE-2000-0002 in curl", finding.title) + self.assertEqual("Medium", finding.severity) + self.assertEqual("curl", finding.component_name) + self.assertEqual("7.81.0-1", finding.component_version) + self.assertEqual(["CVE-2000-0002"], finding.unsaved_vulnerability_ids) + self.assertIn("A flaw in URL parsing.", finding.description) + + def test_the_status_decides_which_findings_are_closed(self): + """ + Only fixed, accepted-risk and duplicate close a finding. + + The working states - active, in progress, waiting for 3rd party, exception requested - stay + open, so a finding somebody is mid-way through fixing is not hidden. + """ + findings = self.by_uid("accuknox_many_vuln.json") + + self.assertTrue(findings["af-0000000001"].active) # active + self.assertTrue(findings["af-0000000002"].active) # in progress + + fixed = findings["af-0000000003"] + self.assertFalse(fixed.active) + self.assertTrue(fixed.is_mitigated) + + accepted = findings["af-0000000004"] + self.assertFalse(accepted.active) + self.assertTrue(accepted.risk_accepted) + + duplicate = findings["af-0000000005"] + self.assertFalse(duplicate.active) + self.assertTrue(duplicate.duplicate) + + def test_only_potential_is_unverified_and_a_blank_status_counts_as_verified(self): + """ + The connector's rule: verified unless the status is empty or "potential". + + A row with no status at all is treated as verified, which is the opposite of what a naive + truthiness check would give. + """ + findings = self.by_uid("accuknox_many_vuln.json") + self.assertFalse(findings["af-0000000006"].verified) # potential + self.assertFalse(findings["af-0000000007"].verified) # blank status + self.assertTrue(findings["af-0000000001"].verified) # active + + def test_the_status_helper_directly(self): + parser = AccuKnoxParser() + for status, checks in ( + ("active", {"active": True, "verified": True}), + ("in progress", {"active": True, "verified": True}), + ("waiting for 3rd party", {"active": True, "verified": True}), + ("exception requested", {"active": True, "verified": True}), + ("waiting for verification", {"active": True, "verified": True}), + ("fixed", {"active": False, "is_mitigated": True}), + ("accepted risk", {"active": False, "risk_accepted": True}), + ("duplicate", {"active": False, "duplicate": True}), + ("potential", {"active": True, "verified": False}), + ("", {"active": True, "verified": False}), + ): + finding = Finding() + parser.apply_status(finding, status) + for attribute, expected in checks.items(): + self.assertEqual(expected, getattr(finding, attribute), f"{status!r}.{attribute}") + + def test_an_ignored_row_is_marked_out_of_scope_rather_than_dropped(self): + """ + AccuKnox lets a user suppress a row, and the connector records that instead of discarding it. + + The flag arrives as a string in this row, so a bare boolean check would miss it. + """ + finding = self.by_uid("accuknox_many_vuln.json")["af-0000000006"] + self.assertTrue(finding.out_of_scope) + + def test_a_not_ignored_row_is_not_out_of_scope(self): + finding = self.by_uid("accuknox_many_vuln.json")["af-0000000001"] + self.assertFalse(finding.out_of_scope) + + def test_the_ignored_flag_accepts_both_boolean_and_string_forms(self): + parser = AccuKnoxParser() + for value, expected in [ + (True, True), ("true", True), ("True", True), ("yes", True), ("1", True), + (1, True), (False, False), ("false", False), ("", False), (0, False), + ]: + self.assertEqual(expected, parser.flag({"ignored": value}, ("ignored",)), value) + self.assertFalse(parser.flag({}, ("ignored",))) + + def test_an_unrecognised_risk_factor_is_info(self): + finding = self.by_uid("accuknox_many_vuln.json")["af-0000000006"] + self.assertEqual("Info", finding.severity) + + def test_a_row_with_no_title_is_named_from_its_id(self): + """ + AccuKnox does not publish the schema for every data type, so a row can carry no recognisable + title and still has to import. + """ + finding = self.by_uid("accuknox_many_vuln.json")["af-0000000007"] + self.assertEqual("AccuKnox finding af-0000000007", finding.title) + + def test_a_row_with_neither_title_nor_id_still_imports(self): + report = io.StringIO(json.dumps({"results": [{"data_type": "runtime"}]})) + finding = list(AccuKnoxParser().get_findings(report, Test()))[0] + self.assertEqual("AccuKnox finding", finding.title) + self.assertIsNone(finding.unique_id_from_tool) + + def test_a_cve_list_column_is_scanned_and_deduplicated(self): + """ + The CVE column can hold several identifiers, and AccuKnox sometimes sends an array. + + The fixture repeats one to prove the deduplication. + """ + finding = self.by_uid("accuknox_many_vuln.json")["af-0000000007"] + self.assertEqual(["CVE-2000-0003", "CVE-2000-0004"], finding.unsaved_vulnerability_ids) + + def test_the_cve_falls_back_to_the_title(self): + """AccuKnox often carries the identifier only in the finding name.""" + report = io.StringIO(json.dumps({"results": [ + {"finding_id": "x", "name": "CVE-2000-0009 in openssl", "risk_factor": "high"}, + ]})) + finding = list(AccuKnoxParser().get_findings(report, Test()))[0] + self.assertEqual(["CVE-2000-0009"], finding.unsaved_vulnerability_ids) + + def test_a_row_with_no_cve_anywhere_has_none(self): + finding = self.by_uid("accuknox_many_vuln.json")["af-0000000004"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_the_description_carries_the_class_status_and_asset(self): + finding = self.parse("accuknox_one_vuln.json")[0] + self.assertIn("The image ships a vulnerable openssl.", finding.description) + self.assertIn("**Finding class:** container_image", finding.description) + self.assertIn("**AccuKnox status:** active", finding.description) + self.assertIn("**Asset:** registry.example.com/generic-api:1.4.0", finding.description) + self.assertIn("**Asset type:** image", finding.description) + self.assertIn("**First seen:** 2026-07-20", finding.description) + self.assertIn("**Last seen:** 2026-07-31", finding.description) + + def test_tags_are_the_data_type_and_asset_type(self): + finding = self.parse("accuknox_one_vuln.json")[0] + self.assertEqual(["container_image", "image"], finding.unsaved_tags) + + def test_the_alternative_date_formats_are_accepted(self): + findings = self.by_uid("accuknox_many_vuln.json") + self.assertEqual(date(2026, 7, 21), findings["af-0000000002"].date) # "2026-07-21 10:00:00" + self.assertEqual(date(2026, 7, 22), findings["af-0000000003"].date) # "2026-07-22" + self.assertIsNone(findings["af-0000000004"].date) # no date column + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([ + {"finding_id": "x", "name": "A finding", "risk_factor": "low"}, + ])) + self.assertEqual(1, len(list(AccuKnoxParser().get_findings(report, Test())))) + + def test_many_vuln(self): + self.assertEqual(7, len(self.parse("accuknox_many_vuln.json"))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(AccuKnoxParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("results", str(raised.exception)) diff --git a/unittests/tools/test_action1_parser.py b/unittests/tools/test_action1_parser.py new file mode 100644 index 00000000000..b3015ae2a30 --- /dev/null +++ b/unittests/tools/test_action1_parser.py @@ -0,0 +1,271 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.action1.parser import Action1Parser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestAction1Parser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("action1") / filename).open(encoding="utf-8") as file: + return list(Action1Parser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(Action1Parser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Action1 connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. Any drift and someone who uploads + an export and also syncs the API gets two un-deduplicated copies of every finding. + """ + parser = Action1Parser() + self.assertEqual(["Action1 Scan"], parser.get_scan_types()) + self.assertEqual("Action1 Scan", parser.get_label_for_scan_types("Action1 Scan")) + self.assertNotIn("Action1 - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("action1_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("action1_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("action1_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Remote code execution in Example Browser", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("action1-CVE-2000-0001-ep-0001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("Example Browser", finding.component_name) + # The endpoint's own copy of the software, not the vulnerability's. + self.assertEqual("119.0.4", finding.component_version) + self.assertEqual("Apply: Example Browser 120.0.2", finding.mitigation) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + self.assertEqual( + "**CVE:** CVE-2000-0001\n" + "**Endpoint:** workstation-01\n" + "**OS:** Windows 11 Pro 23H2\n" + "**Remediation status:** Overdue", + finding.description, + ) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("workstation-01", locations[0].host) + + def test_the_description_uses_single_newlines(self): + """ + The connector joins these lines with one newline, not a blank line. + + Mirrored rather than tidied: a gratuitous difference between the two import paths is still a + difference. + """ + finding = self.parse("action1_one_vuln.json")[0] + self.assertNotIn("\n\n", finding.description) + + def test_many_vuln(self): + """One finding per vulnerability per affected endpoint.""" + self.assertEqual(4, len(self.parse("action1_many_vuln.json"))) + + def test_one_cve_on_two_machines_is_two_findings(self): + """ + The endpoint is half of the identity, because the installed version differs per machine. + + Collapsing them would hide a machine that is still running the vulnerable build - which is + also why the component version is part of this scan type's deduplication hash. + """ + findings = self.by_uid("action1_many_vuln.json") + self.assertIn("action1-CVE-2000-0001-ep-0001", findings) + self.assertIn("action1-CVE-2000-0001-ep-0002", findings) + self.assertEqual("119.0.4", findings["action1-CVE-2000-0001-ep-0001"].component_version) + self.assertEqual("120.0.1", findings["action1-CVE-2000-0001-ep-0002"].component_version) + + def test_a_vulnerability_with_no_affected_endpoint_produces_nothing(self): + """ + Action1 reports a vulnerability catalogue separately from what is actually affected. + + The connector only writes a finding per affected endpoint, so a catalogue entry nothing is + running is not imported. + """ + findings = self.by_uid("action1_many_vuln.json") + self.assertFalse([uid for uid in findings if "CVE-2000-0004" in uid]) + + def test_an_affected_endpoint_row_with_no_id_is_skipped(self): + """The id is half the identity, so a row without one cannot be reported.""" + findings = self.by_uid("action1_many_vuln.json") + self.assertNotIn("action1-CVE-2000-0001-", findings) + + def test_severity_prefers_base_severity_then_the_score_bucket(self): + """ + Action1's "score" is a WORD, not a number - Critical/High/Medium/Low. + + Treating it as numeric would drop every finding whose base severity is missing to Info. + """ + findings = self.by_uid("action1_many_vuln.json") + self.assertEqual("Medium", findings["action1-CVE-2000-0002-ep-0001"].severity) + + for base, score, expected in (("Critical", "", "Critical"), ("High", "Low", "High"), + ("", "Medium", "Medium"), ("", "Low", "Low"), + ("Not graded", "High", "Info"), ("", "", "Info")): + with self.subTest(base=base, score=score): + parsed = self.parse_string({ + "items": [{"cve_id": "CVE-2000-0001", "base_severity": base, "score": score, + "endpoints": [{"endpoint_id": "ep-1"}]}], + }) + self.assertEqual(expected, parsed[0].severity) + + def test_an_unrecognised_bucket_is_info(self): + finding = self.by_uid("action1_many_vuln.json")["action1-CVE-2000-0003-ep-0003"] + self.assertEqual("Info", finding.severity) + + def test_title_falls_back_to_the_cve_then_to_a_constant(self): + findings = self.by_uid("action1_many_vuln.json") + self.assertEqual("CVE-2000-0003", findings["action1-CVE-2000-0003-ep-0003"].title) + + parsed = self.parse_string({"items": [{"endpoints": [{"endpoint_id": "ep-1"}]}]}) + self.assertEqual("Action1 vulnerability", parsed[0].title) + self.assertIsNone(parsed[0].vuln_id_from_tool) + + def test_an_unscored_vulnerability_lands_as_zero(self): + """ + The connector sets the CVSS score unconditionally, so an unscored vulnerability gets 0.0. + + Mirrored for parity rather than left unset; flagged in the PR as a follow-up for both sides. + """ + finding = self.by_uid("action1_many_vuln.json")["action1-CVE-2000-0003-ep-0003"] + self.assertEqual(0.0, finding.cvssv3_score) + + def test_a_score_may_arrive_as_a_string(self): + finding = self.by_uid("action1_many_vuln.json")["action1-CVE-2000-0002-ep-0001"] + self.assertEqual(6.5, finding.cvssv3_score) + + def test_mitigation_lists_every_available_update(self): + finding = self.by_uid("action1_many_vuln.json")["action1-CVE-2000-0001-ep-0001"] + self.assertEqual( + "Apply: Example Browser 120.0.2, Example Browser security update", + finding.mitigation, + ) + + def test_no_available_updates_leaves_the_mitigation_empty(self): + """ + Action1 knows of no patch, and the connector says nothing rather than inventing advice. + + Writing "update the software" here would be this parser's opinion, not Action1's. + """ + findings = self.by_uid("action1_many_vuln.json") + self.assertEqual("", findings["action1-CVE-2000-0001-ep-0002"].mitigation) + self.assertEqual("", findings["action1-CVE-2000-0002-ep-0001"].mitigation) + + def test_software_falls_back_to_the_vulnerabilitys_own_copy(self): + """The endpoint's copy is preferred; the vulnerability's is the fallback.""" + finding = self.by_uid("action1_many_vuln.json")["action1-CVE-2000-0002-ep-0001"] + self.assertEqual("Example Reader", finding.component_name) + self.assertEqual("9.1", finding.component_version) + + def test_a_vulnerability_with_no_software_has_no_component(self): + finding = self.by_uid("action1_many_vuln.json")["action1-CVE-2000-0003-ep-0003"] + self.assertIsNone(finding.component_name) + self.assertIsNone(finding.component_version) + + def test_the_endpoint_name_falls_back_to_its_id(self): + findings = self.parse_string({"items": [ + {"cve_id": "CVE-2000-0001", "endpoints": [{"endpoint_id": "ep-0009"}]}, + ]}) + self.assertIn("**Endpoint:** ep-0009", findings[0].description) + + def test_a_machine_name_that_cannot_be_a_host_is_not_recorded(self): + """ + An Action1 endpoint name is free text - "Reception Desk PC" is a normal value. + + DefectDojo's host field would reject it, and a ValidationError fails the whole import rather + than the one finding, so the endpoint is dropped. The name is still in the description. + """ + finding = self.by_uid("action1_many_vuln.json")["action1-CVE-2000-0003-ep-0003"] + self.assertEqual([], self.get_unsaved_locations(finding)) + self.assertIn("**Endpoint:** Reception Desk PC", finding.description) + + def test_affected_endpoints_may_be_nested_or_keyed_by_cve(self): + """ + Action1 needs a call per CVE to learn what is affected, and those rows carry no CVE of their + own, so an export either nests them or keys them by CVE id. Both have to work. + """ + nested = self.parse("action1_one_vuln.json")[0] + + with (get_unit_tests_scans_path("action1") / "action1_one_vuln.json").open(encoding="utf-8") as file: + export = json.load(file) + vulnerability = export["items"][0] + endpoints = vulnerability.pop("endpoints") + keyed = self.parse_string({ + "items": [vulnerability], + "endpoints": {vulnerability["cve_id"]: endpoints}, + "managed_endpoints": export["managed_endpoints"], + }) + self.assertEqual(nested.unique_id_from_tool, keyed[0].unique_id_from_tool) + self.assertEqual(nested.description, keyed[0].description) + self.assertEqual(nested.component_version, keyed[0].component_version) + + def test_the_operating_system_is_best_effort(self): + """A machine missing from the managed-endpoint list simply has no OS line.""" + findings = self.parse_string({ + "items": [{"cve_id": "CVE-2000-0001", "endpoints": [{"endpoint_id": "ep-1", "endpoint_name": "host-1"}]}], + }) + self.assertNotIn("**OS:**", findings[0].description) + self.assertIn("**Endpoint:** host-1", findings[0].description) + + def test_managed_endpoints_may_be_a_bare_list_or_a_paged_response(self): + for managed in ([{"id": "ep-1", "OS": "Windows 11 Pro 23H2"}], + {"items": [{"id": "ep-1", "OS": "Windows 11 Pro 23H2"}]}): + with self.subTest(shape=type(managed).__name__): + findings = self.parse_string({ + "items": [{"cve_id": "CVE-2000-0001", "endpoints": [{"endpoint_id": "ep-1"}]}], + "managed_endpoints": managed, + }) + self.assertIn("**OS:** Windows 11 Pro 23H2", findings[0].description) + + def test_a_bare_list_of_vulnerabilities_is_accepted(self): + findings = self.parse_string([ + {"cve_id": "CVE-2000-0001", "base_severity": "High", "endpoints": [{"endpoint_id": "ep-1"}]}, + ]) + self.assertEqual(1, len(findings)) + self.assertEqual("High", findings[0].severity) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Action1", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("items", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"items": [ + "not an object", + None, + {"cve_id": "CVE-2000-0001", "base_severity": "High", + "endpoints": ["not an object", {"endpoint_id": "ep-1"}]}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("action1_many_vuln.json", "action1_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_akto_parser.py b/unittests/tools/test_akto_parser.py new file mode 100644 index 00000000000..faeb6dc2f49 --- /dev/null +++ b/unittests/tools/test_akto_parser.py @@ -0,0 +1,221 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.akto.parser import AktoParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestAktoParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("akto") / filename).open(encoding="utf-8") as file: + return list(AktoParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(AktoParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def issue(self, **overrides): + issue = {"apiCollectionId": 1, "apiUrl": "https://api.example.com/v1/thing", "apiMethod": "GET", + "testSubCategory": "BOLA", "testName": "A test", "severity": "HIGH", "status": "OPEN"} + issue.update(overrides) + return {"issueDetails": [issue]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Akto connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = AktoParser() + self.assertEqual(["Akto Scan"], parser.get_scan_types()) + self.assertEqual("Akto Scan", parser.get_label_for_scan_types("Akto Scan")) + self.assertNotIn("Akto - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("akto_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("akto_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("akto_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Broken Object Level Authorization", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual( + "akto-1700000001-GET-https://api.example.com/v1/users/{id}-BOLA", + finding.unique_id_from_tool, + ) + self.assertEqual("BOLA", finding.vuln_id_from_tool) + self.assertEqual("GET https://api.example.com/v1/users/{id}", finding.component_name) + self.assertEqual(639, finding.cwe) + self.assertEqual("Check that the requested object belongs to the caller.", finding.mitigation) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertTrue(finding.active) + self.assertFalse(finding.false_p) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertEqual(["BOLA", "OWASP API1:2023", "Authorization"], finding.unsaved_tags) + self.assertEqual( + "https://app.example.com/dashboard/issues/1\nhttps://owasp.example.com/api1", + finding.references, + ) + + self.assertEqual( + "**Endpoint:** GET https://api.example.com/v1/users/{id}\n" + "**Description:** The endpoint returned another user's record.\n" + "**Impact:** Any authenticated user can read every user's record.\n" + "**Details:** Changing {id} to a second account's id returned that account's data.", + finding.description, + ) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("api.example.com", locations[0].host) + + def test_many_vuln(self): + self.assertEqual(5, len(self.parse("akto_many_vuln.json"))) + + def test_the_endpoint_and_the_test_are_both_in_the_identity(self): + """ + Akto runs every test against every endpoint it knows, so neither alone identifies a finding. + + Both are in the hash fields too: the same test against two paths is two findings, and two + different tests against one path are as well. + """ + findings = self.by_uid("akto_many_vuln.json") + self.assertIn("akto-1700000001-GET-https://api.example.com/v1/users/{id}-BOLA", findings) + self.assertIn("akto-1700000001-POST-/v1/reports-SSRF", findings) + self.assertEqual( + ["title", "severity", "endpoints", "vuln_id_from_tool"], + AktoParser().get_dedupe_fields(), + ) + + def test_severity_labels(self): + for label, expected in (("CRITICAL", "Critical"), ("HIGH", "High"), ("MEDIUM", "Medium"), + ("LOW", "Low"), ("high", "High"), ("INFO", "Info"), + ("not a label", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.issue(severity=label)) + self.assertEqual(expected, findings[0].severity) + + def test_status_decides_active_and_false_positive(self): + """ + Akto records triage in the status. + + IGNORED is how a reviewer marks a false positive; FIXED means it is gone. Both are inactive, + but only IGNORED sets the flag - "fixed" is not a judgement about whether it was real. + """ + for status, active, false_p in (("OPEN", True, False), ("IGNORED", False, True), + ("FIXED", False, False), ("ignored", False, True), + ("", True, False)): + with self.subTest(status=status): + findings = self.parse_string(self.issue(status=status)) + self.assertEqual(active, findings[0].active) + self.assertEqual(false_p, findings[0].false_p) + + def test_the_endpoint_is_the_component(self): + """ + Akto has no package to report, so "<METHOD> <url>" is the component. + + That is what the component slot of this scan type's deduplication means here. + """ + findings = self.by_uid("akto_many_vuln.json") + self.assertEqual( + "POST /v1/reports", + findings["akto-1700000001-POST-/v1/reports-SSRF"].component_name, + ) + + def test_a_relative_path_is_not_recorded_as_an_endpoint(self): + """ + Akto's apiUrl is often just a path, which is not an endpoint on its own. + + The connector skips those rather than inventing a host, and the path is still the component and + in the description, so nothing is lost. + """ + finding = self.by_uid("akto_many_vuln.json")["akto-1700000001-POST-/v1/reports-SSRF"] + self.assertEqual([], self.get_unsaved_locations(finding)) + self.assertIn("**Endpoint:** POST /v1/reports", finding.description) + + def test_title_falls_back_to_the_sub_category_then_a_constant(self): + by_subcategory = self.parse_string(self.issue(testName="", testSubCategory="BOLA")) + self.assertEqual("BOLA", by_subcategory[0].title) + + bare = self.by_uid("akto_many_vuln.json")["akto-1700000003---"] + self.assertEqual("Akto API-security issue", bare.title) + self.assertIsNone(bare.vuln_id_from_tool) + + def test_a_collection_id_may_be_a_string(self): + finding = self.by_uid("akto_many_vuln.json")["akto-1700000001-POST-/v1/reports-SSRF"] + self.assertIn("1700000001", finding.unique_id_from_tool) + + def test_cwe_forms(self): + for value, expected in (("CWE-639", 639), ("639", 639), ("cwe-639", 639), + ("not a cwe", 0), ("", 0)): + with self.subTest(value=value): + findings = self.parse_string(self.issue(testCwe=value)) + self.assertEqual(expected, findings[0].cwe) + + def test_identifiers_are_sorted_and_deduplicated_case_insensitively(self): + """ + Akto's CVE field is free text - an API-security test usually has none, but a + dependency-related one may name several, sometimes in mixed case. + """ + finding = self.by_uid("akto_many_vuln.json")["akto-1700000001-POST-/v1/reports-SSRF"] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_a_finding_with_no_cve_has_none(self): + finding = self.parse("akto_one_vuln.json")[0] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_dates_are_unix_seconds(self): + findings = self.by_uid("akto_many_vuln.json") + self.assertEqual( + datetime(2024, 6, 1, tzinfo=UTC).date(), + findings["akto-1700000001-POST-/v1/reports-SSRF"].date, + ) + + def test_a_zero_creation_time_keeps_the_default_date(self): + finding = self.by_uid("akto_many_vuln.json")["akto-1700000002-GET-https://api.example.com/v1/health-INFO_DISCLOSURE"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_a_bare_array_of_issues_is_accepted(self): + findings = self.parse_string([ + {"apiCollectionId": 1, "apiUrl": "https://api.example.com/v1/thing", "apiMethod": "GET", + "testSubCategory": "BOLA", "testName": "A test", "severity": "HIGH", "status": "OPEN"}, + ]) + self.assertEqual(1, len(findings)) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Akto", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("issueDetails", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"issueDetails": [ + "not an object", + None, + {"apiCollectionId": 1, "apiUrl": "https://api.example.com/v1/thing", "apiMethod": "GET", + "testSubCategory": "BOLA", "testName": "A test", "severity": "HIGH", "status": "OPEN"}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("akto_many_vuln.json", "akto_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_automox_parser.py b/unittests/tools/test_automox_parser.py new file mode 100644 index 00000000000..0b3962d2521 --- /dev/null +++ b/unittests/tools/test_automox_parser.py @@ -0,0 +1,270 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.automox.parser import AutomoxParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestAutomoxParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("automox") / filename + with path.open(encoding="utf-8") as file: + return list(AutomoxParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(AutomoxParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"id": 1, "server_id": 1001, "installed": False, "name": "example-runtime", + "display_name": "Example Runtime", "version": "1.0", "severity": "high"} + row.update(overrides) + return {"packages": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Automox connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = AutomoxParser() + self.assertEqual(["Automox Scan"], parser.get_scan_types()) + self.assertEqual("Automox Scan", parser.get_label_for_scan_types("Automox Scan")) + self.assertNotIn("Automox - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("automox_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("automox_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("automox_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Missing patch: Example Runtime", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("automox-900001", finding.unique_id_from_tool) + self.assertEqual("example-runtime", finding.component_name) + self.assertEqual("4.8.1", finding.component_version) + self.assertEqual(8.8, finding.cvssv3_score) + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + self.assertEqual(date(2024, 5, 13), finding.date) + self.assertEqual("Install the available patch (version 4.8.1).", finding.mitigation) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["Windows", "requires-reboot"], finding.unsaved_tags) + + self.assertEqual( + "**Package:** Example Runtime 4.8.1\n" + "**Repository:** Vendor Update Catalog\n" + "**Device:** generic-host-01\n" + "**OS:** Windows Server 2019\n" + "**CVEs:** CVE-2000-0001, CVE-2000-0002\n" + "**Status:** Patch available but not installed", + finding.description, + ) + + def test_many_vuln(self): + """The two rows with no usable id are dropped; four remain.""" + self.assertEqual(4, len(self.parse("automox_many_vuln.json"))) + + def test_the_device_comes_from_a_second_list_joined_on_server_id(self): + """ + Automox reports the device separately from the missing patch. + + The connector pulls both endpoints and joins them, so an export has to carry both lists for a + finding to name its device. + """ + finding = self.by_uid("automox_many_vuln.json")["automox-900002"] + self.assertIn("**Device:** generic-host-02", finding.description) + self.assertIn("**OS:** Ubuntu 22.04", finding.description) + self.assertIn("Linux", finding.unsaved_tags) + + def test_a_package_whose_device_is_absent_is_still_a_finding(self): + """ + The connector's device lookup is a map read that can miss, and it converts anyway. + + Dropping the finding would silently lose a missing patch just because the device list did + not travel with it. + """ + finding = self.by_uid("automox_many_vuln.json")["automox-900003"] + self.assertEqual("Missing patch: Example Agent", finding.title) + self.assertNotIn("**Device:**", finding.description) + self.assertNotIn("**OS:**", finding.description) + self.assertEqual([], finding.unsaved_tags) + + def test_a_packages_only_export_is_accepted(self): + """Automox answers the packages endpoint with a bare array, so a saved export is one.""" + findings = self.parse("automox_packages_only.json") + self.assertEqual(1, len(findings)) + self.assertEqual("automox-900010", findings[0].unique_id_from_tool) + self.assertNotIn("**Device:**", findings[0].description) + + def test_export_shapes(self): + row = {"id": 1, "name": "example-runtime", "display_name": "Example Runtime", + "version": "1.0", "severity": "low", "installed": False} + for payload in ([row], {"packages": [row]}, {"data": [row]}, {"results": [row]}): + with self.subTest(shape=str(payload)[:24]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_the_devices_list_may_be_named_servers(self): + """Automox serves devices from /servers, so an export may name the list either way.""" + row = {"id": 1, "server_id": 7, "name": "example", "display_name": "Example", + "version": "1.0", "severity": "low", "installed": False} + device = {"id": 7, "name": "generic-host-07", "os_family": "Linux"} + for key in ("devices", "servers"): + with self.subTest(key=key): + findings = self.parse_string({"packages": [row], key: [device]}) + self.assertIn("**Device:** generic-host-07", findings[0].description) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Automox", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("packages", str(context.exception)) + + def test_severity_labels(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("CRITICAL", "Critical"), (" high ", "High"), + ("none", "Info"), ("unknown", "Info"), ("no_known_cves", "Info"), + ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.row(severity=label)) + self.assertEqual(expected, findings[0].severity) + + def test_the_title_falls_back_to_the_package_name_then_the_id(self): + findings = self.by_uid("automox_many_vuln.json") + self.assertEqual("Missing patch: example-library", findings["automox-900002"].title) + self.assertEqual("Missing patch: Automox package 900004", findings["automox-900004"].title) + + def test_quoted_numbers_are_accepted(self): + """ + Automox's own decoder accepts a number sent as a quoted string, for ids and scores alike. + + A device id is quoted in the fixture too, so the join has to tolerate it on both sides. + """ + finding = self.by_uid("automox_many_vuln.json")["automox-900002"] + self.assertEqual(5.4, finding.cvssv3_score) + self.assertIn("**Device:** generic-host-02", finding.description) + + def test_a_row_with_no_usable_id_is_dropped(self): + """ + The id is the whole identity - every row without one would collapse onto "automox-0". + + Automox's own decoder rejects the entire page when an id is not numeric; dropping the single + row keeps the rest of the export importable. + """ + uids = self.by_uid("automox_many_vuln.json") + self.assertNotIn("automox-0", uids) + for finding in uids.values(): + self.assertNotIn("Dropped", finding.title) + + def test_a_non_numeric_score_is_zero_rather_than_an_error(self): + """The connector's decoder tolerates a non-numeric score, so an import must not fail on one.""" + finding = self.by_uid("automox_many_vuln.json")["automox-900003"] + self.assertEqual(0.0, finding.cvssv3_score) + + def test_blank_cves_are_dropped_and_no_cves_means_none(self): + findings = self.by_uid("automox_many_vuln.json") + self.assertEqual(["CVE-2000-0003", "CVE-2000-0004"], + findings["automox-900003"].unsaved_vulnerability_ids) + self.assertIsNone(findings["automox-900002"].unsaved_vulnerability_ids) + self.assertNotIn("**CVEs:**", findings["automox-900002"].description) + + def test_an_installed_package_reports_no_status_line(self): + """The status line exists to say the patch is available and NOT applied.""" + findings = self.by_uid("automox_many_vuln.json") + self.assertNotIn("**Status:**", findings["automox-900003"].description) + self.assertIn("**Status:** Patch available but not installed", + findings["automox-900001"].description) + + def test_the_mitigation_names_the_version_only_when_there_is_one(self): + findings = self.by_uid("automox_many_vuln.json") + self.assertEqual("Install the available patch.", findings["automox-900003"].mitigation) + self.assertEqual("Install the available patch (version 4.8.1).", + findings["automox-900001"].mitigation) + + def test_timestamp_formats(self): + """Automox's own offset form first, then RFC 3339 - both with and without a colon.""" + cases = ( + ("2024-05-13T18:02:45+0000", date(2024, 5, 13)), + ("2024-05-14T09:15:00Z", date(2024, 5, 14)), + ("2024-06-01T00:00:00+02:00", date(2024, 6, 1)), + ("2024-06-02T10:20:30.500000+0000", date(2024, 6, 2)), + ) + for value, expected in cases: + with self.subTest(value=value): + findings = self.parse_string(self.row(create_time=value)) + self.assertEqual(expected, findings[0].date) + + def test_an_unparseable_timestamp_leaves_the_date_alone(self): + """Losing the finding over a malformed date would lose a real missing patch.""" + finding = self.by_uid("automox_many_vuln.json")["automox-900003"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_requires_reboot_is_tagged_only_when_true(self): + findings = self.by_uid("automox_many_vuln.json") + self.assertIn("requires-reboot", findings["automox-900001"].unsaved_tags) + self.assertNotIn("requires-reboot", findings["automox-900002"].unsaved_tags) + + def test_the_severity_is_not_tagged_even_though_the_connector_comment_says_so(self): + """ + The connector's tags() comment claims the severity is tagged; its code does not tag it. + + Mirroring the code rather than the comment is what keeps a file import and an API sync + producing the same tags - flagged in the PR as a follow-up on the connector side. + """ + finding = self.by_uid("automox_many_vuln.json")["automox-900001"] + self.assertEqual(["Windows", "requires-reboot"], finding.unsaved_tags) + self.assertNotIn("CRITICAL", finding.unsaved_tags) + self.assertNotIn("Critical", finding.unsaved_tags) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"packages": [ + "not an object", + None, + {"id": 5, "name": "example", "display_name": "Example", "version": "1.0", + "severity": "low", "installed": False}, + ], "devices": ["not an object", None]}) + self.assertEqual(1, len(findings)) + self.assertEqual("automox-5", findings[0].unique_id_from_tool) + + def test_a_missing_cves_key_is_not_an_error(self): + findings = self.parse_string(self.row(cves=None)) + self.assertIsNone(findings[0].unsaved_vulnerability_ids) + + def test_the_component_is_the_package(self): + """ + The hash spans the component, so the same patch missing on two devices hashes alike. + + The package id in the identity is what keeps those two findings apart. + """ + self.assertEqual(["title", "severity", "component_name"], AutomoxParser().get_dedupe_fields()) + finding = self.by_uid("automox_many_vuln.json")["automox-900001"] + self.assertEqual("example-runtime", finding.component_name) + self.assertEqual("4.8.1", finding.component_version) + + def test_a_package_with_no_name_has_no_component(self): + findings = self.by_uid("automox_many_vuln.json") + self.assertIsNone(findings["automox-900004"].component_name) + + def test_severity_is_always_a_known_value(self): + for filename in ("automox_many_vuln.json", "automox_one_vuln.json", + "automox_packages_only.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_beagle_parser.py b/unittests/tools/test_beagle_parser.py new file mode 100644 index 00000000000..e68e125725a --- /dev/null +++ b/unittests/tools/test_beagle_parser.py @@ -0,0 +1,428 @@ +import hashlib +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.beagle.parser import BeagleParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestBeagleParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("beagle") / filename).open(encoding="utf-8") as file: + return list(BeagleParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(BeagleParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_title(self, filename): + return {finding.title: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Beagle Security connector's ScanType() verbatim. + + Any drift and a customer who uploads a report and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = BeagleParser() + self.assertEqual(["Beagle Security - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Beagle Security - Connectors Import", + parser.get_label_for_scan_types("Beagle Security - Connectors Import"), + ) + self.assertIn("Beagle", parser.get_description_for_scan_types("Beagle Security - Connectors Import")) + + def test_no_vuln(self): + """A report Beagle produced with nothing to report is not an error.""" + self.assertEqual(0, len(self.parse("beagle_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("beagle_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring finding() in the connector's converter.""" + findings = self.parse("beagle_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("SQL injection", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual(89, finding.cwe) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual( + "A request parameter is concatenated into a database query.", + finding.description.split("\n\n")[0], + ) + self.assertIn("**Method:** POST", finding.description) + self.assertIn("**URL:** https://app.example.com/api/report", finding.description) + self.assertIn("**Beagle status:** Open", finding.description) + self.assertIn("**CWE:** CWE-89", finding.description) + self.assertEqual("Use parameterised queries.", finding.mitigation) + self.assertEqual("SQL injection", finding.vuln_id_from_tool) + self.assertEqual("POST", finding.param) + self.assertTrue(finding.active) + self.assertFalse(finding.is_mitigated) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertEqual(datetime(2021, 9, 4, tzinfo=UTC).date(), finding.date) + self.assertEqual(["beagle-security", "Critical"], finding.unsaved_tags) + + def test_api_envelope_is_accepted(self): + """ + Beagle returns the report as a JSON *string* inside an envelope. + + A user can save either the envelope or the report body it carries, so both are read. + """ + from_envelope = self.parse("beagle_one_vuln.json")[0] + + with (get_unit_tests_scans_path("beagle") / "beagle_one_vuln.json").open(encoding="utf-8") as file: + envelope = json.load(file) + from_body = self.parse_string(json.loads(envelope["result"]))[0] + + self.assertEqual(from_envelope.title, from_body.title) + self.assertEqual(from_envelope.severity, from_body.severity) + self.assertEqual(from_envelope.description, from_body.description) + + def test_result_may_be_a_nested_object(self): + """An export that pretty-printed the report into the envelope instead of escaping it.""" + findings = self.parse_string({ + "code": "0", + "result": {"url": "https://app.example.com", "vulnerabilities": [{"name": "Open redirect", "severity": "Low"}]}, + }) + self.assertEqual(1, len(findings)) + self.assertEqual("Open redirect", findings[0].title) + + def test_many_vuln(self): + """One finding per occurrence, so five report entries with seven occurrences is six findings.""" + self.assertEqual(6, len(self.parse("beagle_many_vuln.json"))) + + def test_one_finding_per_occurrence(self): + """ + A finding reported on two URLs is two findings. + + This is the connector's vulnerabilityFindings(): an occurrence is one place the finding was + observed, and their statuses differ, which is exactly why they are kept apart. + """ + findings = [ + finding for finding in self.parse("beagle_many_vuln.json") + if finding.title == "Information disclosure in HTTP headers" + ] + self.assertEqual(2, len(findings)) + + by_endpoint = {self.get_unsaved_locations(finding)[0].path: finding for finding in findings} + self.assertEqual({"status.asp", "login"}, set(by_endpoint)) + + self.assertTrue(by_endpoint["status.asp"].active) + self.assertFalse(by_endpoint["status.asp"].is_mitigated) + self.assertEqual("GET", by_endpoint["status.asp"].param) + + # Beagle calls this occurrence "Fixed", so it is imported as mitigated rather than open. + self.assertFalse(by_endpoint["login"].active) + self.assertTrue(by_endpoint["login"].is_mitigated) + self.assertEqual("POST", by_endpoint["login"].param) + + def test_severity_label_wins_over_score(self): + findings = self.by_title("beagle_many_vuln.json") + finding = findings["Information disclosure in HTTP headers"] + self.assertEqual("Medium", finding.severity) + # The vector was carried under "cvss", which is not a number, so no score was recorded. + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", finding.cvssv3) + self.assertIsNone(finding.cvssv3_score) + + def test_numeric_severity_is_graded_against_the_cvss_floors(self): + """ + A tenant that scores reports with CVSS sends a number under the severity key. + + The connector's floors are 9.0 Critical, 7.0 High, 4.0 Medium, anything else Low. + """ + findings = self.by_title("beagle_many_vuln.json") + self.assertEqual("High", findings["Reflected cross-site scripting"].severity) + self.assertEqual(8.2, findings["Reflected cross-site scripting"].cvssv3_score) + self.assertEqual("Low", findings["Beagle Security finding (215, 216)"].severity) + + def test_severity_floors(self): + for score, expected in ((10.0, "Critical"), (9.0, "Critical"), (8.9, "High"), (7.0, "High"), + (6.9, "Medium"), (4.0, "Medium"), (3.9, "Low"), (0.1, "Low")): + with self.subTest(score=score): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [ + {"name": "Scored finding", "severity": score}, + ]}) + self.assertEqual(expected, findings[0].severity) + + def test_unrecognised_severity_label_is_info(self): + """ + An unknown label is not a guess. + + Beagle does not publish the severity enum, so a label outside the known set is imported as + Info rather than being mapped by resemblance - but the label itself is kept as a tag so the + real value is not lost. + """ + finding = self.by_title("beagle_many_vuln.json")["Missing security headers"] + self.assertEqual("Info", finding.severity) + self.assertEqual(["beagle-security", "Somewhat risky"], finding.unsaved_tags) + + def test_no_severity_and_no_score_is_info(self): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [ + {"name": "Unscored finding"}, + ]}) + self.assertEqual("Info", findings[0].severity) + self.assertEqual(["beagle-security"], findings[0].unsaved_tags) + + def test_finding_with_no_occurrences_targets_the_application(self): + """ + A finding Beagle reported without occurrences still gets one finding. + + With no occurrence there is no method or per-occurrence URL, so it is aimed at the + application's own URL and counted as active. + """ + finding = self.by_title("beagle_many_vuln.json")["Missing security headers"] + self.assertTrue(finding.active) + self.assertFalse(finding.is_mitigated) + self.assertIsNone(finding.param) + self.assertNotIn("**Method:**", finding.description) + self.assertNotIn("**Beagle status:**", finding.description) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + + def test_occurrence_without_a_url_falls_back_to_the_application(self): + finding = self.by_title("beagle_many_vuln.json")["Beagle Security finding (215, 216)"] + self.assertEqual("HEAD", finding.param) + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual("https", locations[0].protocol) + + def test_endpoint_carries_scheme_port_and_path(self): + """ + The tested URL is recorded in full. + + This scan type's deduplication hashes the endpoints, so an unpopulated endpoint would leave + the hash computed over nothing and every rescan would reimport. + """ + finding = self.by_title("beagle_many_vuln.json")["Outdated TLS protocol offered"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("api.example.com", locations[0].host) + self.assertEqual(8443, locations[0].port) + self.assertEqual("https", locations[0].protocol) + self.assertEqual("v1/health", locations[0].path) + + def test_every_finding_records_an_endpoint(self): + for finding in self.parse("beagle_many_vuln.json"): + with self.subTest(title=finding.title): + self.assertEqual(1, len(self.get_unsaved_locations(finding))) + + def test_query_string_is_kept(self): + finding = self.by_title("beagle_many_vuln.json")["Reflected cross-site scripting"] + location = self.get_unsaved_locations(finding)[0] + self.assertEqual("search", location.path) + self.assertEqual("q=1", location.query) + + def test_field_aliases(self): + """ + Beagle publishes none of the per-finding field names. + + The connector reads each from a set of plausible aliases, case-insensitively, and this + parser has to accept the same spellings or the two disagree about the same report. + """ + findings = self.by_title("beagle_alias_keys.json") + self.assertEqual(2, len(findings)) + + cookie = findings["Session cookie missing the Secure attribute"] + self.assertEqual("Low", cookie.severity) + self.assertEqual(614, cookie.cwe) + self.assertEqual("A session cookie is set without the Secure attribute.", cookie.description.split("\n\n")[0]) + self.assertEqual("Set the Secure attribute on every session cookie.", cookie.mitigation) + + listing = findings["Directory listing enabled"] + self.assertEqual("Medium", listing.severity) + self.assertEqual(5.3, listing.cvssv3_score) + self.assertEqual("The web server returns an index of the directory contents.", listing.description) + self.assertEqual("Disable automatic directory indexes.", listing.mitigation) + + def test_finding_array_is_found_by_shape_when_its_name_is_unknown(self): + """ + Beagle's reference cuts the finding array's name out of its only sample. + + The documented-plausible names are tried first, then the first key - in sorted order, so the + choice is deterministic - whose value is an array of objects. A confirmed report key is never + mistaken for the finding list. + """ + findings = self.parse("beagle_alias_keys.json") + self.assertEqual(2, len(findings)) + + by_documented_name = self.parse_string({"url": "https://app.example.com", "signatures": [ + {"name": "Named array"}, + ]}) + self.assertEqual("Named array", by_documented_name[0].title) + + by_shape = self.parse_string({"url": "https://app.example.com", "some_future_key": [ + {"name": "Array found by shape"}, + ]}) + self.assertEqual("Array found by shape", by_shape[0].title) + + def test_occurrence_key_accepts_the_vendors_own_spelling(self): + """Beagle spells it "occurences", with one "r"; the connector leads with their spelling.""" + for key in ("occurences", "occurrences", "instances"): + with self.subTest(key=key): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [{ + "name": "Two places", + "severity": "High", + key: [ + {"status": "Open", "vulnerability": {"Method": "get", "Url": "https://app.example.com/a"}}, + {"status": "Open", "vulnerability": {"Method": "get", "Url": "https://app.example.com/b"}}, + ], + }]}) + self.assertEqual(2, len(findings)) + + def test_only_the_documented_fixed_status_mitigates(self): + """ + "Fixed" is the one status value Beagle's documentation shows. + + The rest of the enum is unpublished, so anything else counts as open rather than being + guessed at. + """ + for status, mitigated in (("Fixed", True), ("fixed", True), ("FIXED", True), ("Open", False), + ("Reopened", False), ("", False)): + with self.subTest(status=status): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [{ + "name": "Status check", + "occurences": [{"status": status, "vulnerability": {"Url": "https://app.example.com/a"}}], + }]}) + self.assertEqual(mitigated, findings[0].is_mitigated) + self.assertEqual(not mitigated, findings[0].active) + + def test_title_falls_back_to_the_cwe(self): + finding = self.by_title("beagle_many_vuln.json")["Beagle Security finding (215, 216)"] + self.assertEqual(215, finding.cwe) + self.assertIsNone(finding.vuln_id_from_tool) + + def test_title_falls_back_to_a_bare_label(self): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [{"score": 5.0}]}) + self.assertEqual("Beagle Security finding", findings[0].title) + self.assertEqual(0, findings[0].cwe) + + def test_cwe_forms(self): + """A CWE id arrives as "CWE-215", a bare number, a list, or a comma-separated string.""" + for value, expected in (("CWE-215", 215), ("215", 215), (215, 215), ("cwe-215", 215), + ("215, 216", 215), (["CWE-693", "CWE-1021"], 693), ("CWE-79 CWE-80", 79), + ("not a cwe", 0), ("", 0), ("CWE-0", 0), ("-5", 0)): + with self.subTest(value=value): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [ + {"name": "CWE check", "cwe": value}, + ]}) + self.assertEqual(expected, findings[0].cwe) + + def test_report_date_prefers_generated_then_approved(self): + """The connector stamps every finding in a report with the report's own date.""" + for report, expected in ( + ({"generated_date": "04 Sep 2021", "approved_date": "05 Sep 2021"}, datetime(2021, 9, 4, tzinfo=UTC).date()), + ({"approved_date": "05 Sep 2021"}, datetime(2021, 9, 5, tzinfo=UTC).date()), + ): + with self.subTest(report=report): + findings = self.parse_string({**report, "vulnerabilities": [{"name": "Dated"}]}) + self.assertEqual(expected, findings[0].date) + + def test_unparseable_report_date_falls_back(self): + """An unreadable generated date falls through to the approved date.""" + findings = self.by_title("beagle_alias_keys.json") + self.assertEqual( + datetime(2022, 1, 12, tzinfo=UTC).date(), + findings["Session cookie missing the Secure attribute"].date, + ) + + def test_missing_report_date_is_today(self): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [{"name": "Undated"}]}) + self.assertEqual(datetime.now(tz=UTC).date(), findings[0].date) + + def test_unique_id_matches_the_connector_when_the_token_is_present(self): + """ + The connector's identity is sha256(token|name|method|url). + + An export that carries the application token gets connector-identical ids. + """ + finding = self.parse("beagle_one_vuln.json")[0] + expected = hashlib.sha256( + b"example-application-token|SQL injection|POST|https://app.example.com/api/report", + ).hexdigest() + self.assertEqual(expected, finding.unique_id_from_tool) + + def test_no_unique_id_without_a_token(self): + """ + A report body does not carry the application token, and no id is invented. + + This scan type deduplicates on unique_id_from_tool *or* the hash code, so leaving the id + unset lets the hash over title, severity and endpoints match the API findings instead. An id + hashed over a token the connector never used would match nothing. + """ + for finding in self.parse("beagle_many_vuln.json"): + with self.subTest(title=finding.title): + self.assertIsNone(finding.unique_id_from_tool) + + def test_unique_ids_differ_per_occurrence(self): + payload = { + "applicationToken": "example-application-token", + "url": "https://app.example.com", + "vulnerabilities": [{ + "name": "Two places", + "occurences": [ + {"status": "Open", "vulnerability": {"Method": "get", "Url": "https://app.example.com/a"}}, + {"status": "Open", "vulnerability": {"Method": "get", "Url": "https://app.example.com/b"}}, + ], + }], + } + findings = self.parse_string(payload) + self.assertEqual(2, len({finding.unique_id_from_tool for finding in findings})) + + def test_severity_is_always_a_known_value(self): + for filename in ("beagle_many_vuln.json", "beagle_one_vuln.json", "beagle_alias_keys.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, title=finding.title): + self.assertIn(finding.severity, Finding.SEVERITIES) + + def test_non_object_report_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string([{"name": "A bare list of findings"}]) + self.assertIn("Beagle Security", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + """A file with neither Beagle's report keys nor a finding list is not a Beagle report.""" + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else", "count": 3}) + self.assertIn("report keys", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [ + {"name": "The one real finding"}, + "not an object", + None, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("The one real finding", findings[0].title) + + def test_an_array_that_does_not_start_with_an_object_is_not_the_finding_list(self): + """ + The shape check looks at the first element only, exactly as the connector's does. + + So an array of strings is not mistaken for the finding list - it is skipped and, with no + other candidate in the document, the report simply has no findings. + """ + self.assertEqual(0, len(self.parse_string({ + "url": "https://app.example.com", + "vulnerabilities": ["not an object", {"name": "Never reached"}], + }))) + + def test_malformed_occurrence_is_tolerated(self): + findings = self.parse_string({"url": "https://app.example.com", "vulnerabilities": [{ + "name": "Odd occurrences", + "occurences": ["not an object", {"status": "Open", "vulnerability": "not an object"}], + }]}) + self.assertEqual(1, len(findings)) + self.assertIsNone(findings[0].param) + self.assertEqual("app.example.com", self.get_unsaved_locations(findings[0])[0].host) diff --git a/unittests/tools/test_bigid_parser.py b/unittests/tools/test_bigid_parser.py new file mode 100644 index 00000000000..95b7f01c9b1 --- /dev/null +++ b/unittests/tools/test_bigid_parser.py @@ -0,0 +1,244 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.bigid.parser import BigidParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestBigidParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("bigid") / filename + with path.open(encoding="utf-8") as file: + return list(BigidParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(BigidParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"caseId": "case-1", "caseLabel": "A case", "policyName": "A policy", + "severityLevel": "high", "caseStatus": "open", "dataSourceName": "generic-source"} + row.update(overrides) + return {"cases": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the BigID connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = BigidParser() + self.assertEqual(["BigID Scan"], parser.get_scan_types()) + self.assertEqual("BigID Scan", parser.get_label_for_scan_types("BigID Scan")) + self.assertNotIn("BigID - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("bigid_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("bigid_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("bigid_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Unprotected personal data in a reporting database", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("bigid-case-0001", finding.unique_id_from_tool) + self.assertEqual("case-0001", finding.vuln_id_from_tool) + self.assertEqual("reporting-db", finding.component_name) + self.assertEqual("Move the data to an approved store and revoke the broad read grant.", + finding.mitigation) + self.assertEqual(date(2024, 6, 2), finding.date) + self.assertTrue(finding.active) + self.assertFalse(finding.is_mitigated) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["PostgreSQL", "Restricted"], finding.unsaved_tags) + + self.assertEqual( + "**Policy:** Personal data outside an approved store\n" + "**Policy description:** Personal data must only live in an approved, " + "access-controlled store.\n" + "**Data source:** reporting-db\n" + "**Data source type:** PostgreSQL\n" + "**Sensitivity:** Restricted\n" + "**Affected objects:** 412\n" + "**Status:** open\n" + "**Assignee:** data-owner", + finding.description, + ) + + def test_many_vuln(self): + """The row with no case id is dropped; four remain.""" + self.assertEqual(4, len(self.parse("bigid_many_vuln.json"))) + + def test_only_the_count_of_affected_objects_is_read_never_the_data(self): + """ + A BigID case is about sensitive data that was found, so the data itself is never carried. + + The fixture's case includes sample values, a preview and matched values. None of it may reach + the finding - the count is the only thing said about the affected objects. + """ + findings = self.parse("bigid_with_sample_values.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertIn("**Affected objects:** 9", finding.description) + + rendered = " ".join(str(value) for value in ( + finding.title, finding.description, finding.mitigation, finding.component_name, + finding.unsaved_tags, finding.vuln_id_from_tool, finding.unique_id_from_tool, + )) + self.assertNotIn("placeholder-not-real-data", rendered) + for field in ("sampleValues", "preview", "matchedValues", "objectDetails"): + self.assertNotIn(field, rendered) + + def test_export_shapes(self): + """ + BigID's own samples disagree about the shape, so its client accepts all three. + + A bare array, a {"data": {"cases": []}} object, and a top-level {"cases": []}. + """ + row = {"caseId": "case-1", "caseLabel": "A case", "severityLevel": "low", + "caseStatus": "open", "dataSourceName": "generic-source"} + for payload in ([row], {"cases": [row]}, {"data": {"cases": [row], "totalCount": 1}}): + with self.subTest(shape=str(payload)[:24]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_the_wrapped_form_wins_over_a_top_level_list(self): + """The client prefers data.cases whenever it carries anything, so the parser does too.""" + wrapped = {"caseId": "wrapped", "caseLabel": "Wrapped", "severityLevel": "low", + "caseStatus": "open"} + top = {"caseId": "top-level", "caseLabel": "Top level", "severityLevel": "low", + "caseStatus": "open"} + findings = self.parse_string({"data": {"cases": [wrapped], "totalCount": 1}, "cases": [top]}) + self.assertEqual(1, len(findings)) + self.assertEqual("bigid-wrapped", findings[0].unique_id_from_tool) + + def test_an_empty_wrapped_form_still_reports_no_findings(self): + """A wrapped envelope that is genuinely empty is an empty result, not a fall-through.""" + self.assertEqual(0, len(self.parse_string({"data": {"cases": [], "totalCount": 0}}))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("BigID", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("cases", str(context.exception)) + + def test_severity_labels(self): + """BigID has no Info tier of its own, so anything unrecognised lands there.""" + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("HIGH", "High"), (" low ", "Low"), + ("not a level", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.row(severityLevel=label)) + self.assertEqual(expected, findings[0].severity) + + def test_the_title_falls_back_to_the_policy_then_the_case_id(self): + findings = self.by_uid("bigid_many_vuln.json") + self.assertEqual("Payment data in an object store", findings["bigid-case-0002"].title) + self.assertEqual("BigID case case-0003", findings["bigid-case-0003"].title) + + def test_a_resolved_or_remediated_case_is_mitigated_and_inactive(self): + findings = self.by_uid("bigid_many_vuln.json") + for uid in ("bigid-case-0002", "bigid-case-0003"): + with self.subTest(uid=uid): + self.assertFalse(findings[uid].active) + self.assertTrue(findings[uid].is_mitigated) + + def test_case_status_matching_ignores_case(self): + """The fixture spells one status "RESOLVED"; BigID's own casing varies.""" + for status, active in (("resolved", False), ("RESOLVED", False), ("Remediated", False), + ("closed", False), ("open", True), ("in review", True), ("", True)): + with self.subTest(status=status): + findings = self.parse_string(self.row(caseStatus=status)) + self.assertEqual(active, findings[0].active) + self.assertEqual(not active, findings[0].is_mitigated) + + def test_an_unfamiliar_status_stays_active(self): + """ + Only the three states BigID uses for "dealt with" close a case. + + Treating an unfamiliar status as closed would silently hide a live exposure. + """ + finding = self.by_uid("bigid_many_vuln.json")["bigid-case-0004"] + self.assertTrue(finding.active) + self.assertFalse(finding.is_mitigated) + self.assertIn("**Status:** in review", finding.description) + + def test_a_quoted_count_is_read_and_a_zero_or_negative_one_is_omitted(self): + findings = self.by_uid("bigid_many_vuln.json") + self.assertIn("**Affected objects:** 37", findings["bigid-case-0002"].description) + self.assertNotIn("**Affected objects:**", findings["bigid-case-0003"].description) + self.assertNotIn("**Affected objects:**", findings["bigid-case-0004"].description) + + def test_a_case_with_no_id_is_dropped(self): + """The case id is the whole identity; without one every row would collapse onto "bigid-".""" + uids = self.by_uid("bigid_many_vuln.json") + self.assertNotIn("bigid-", uids) + for finding in uids.values(): + self.assertNotIn("Dropped", finding.title) + + def test_the_date_is_the_update_timestamp_then_creation(self): + findings = self.by_uid("bigid_many_vuln.json") + self.assertEqual(date(2024, 6, 2), findings["bigid-case-0001"].date) + self.assertEqual(date(2024, 4, 1), findings["bigid-case-0002"].date) + + def test_a_timestamp_that_is_not_a_date_leaves_the_date_alone(self): + """ + BigID's timestamps are snake_case while every other field is camelCase. + + A value too short to hold a date, or one that is not a date, falls through to the import + default rather than failing the whole file. + """ + findings = self.by_uid("bigid_many_vuln.json") + self.assertEqual(datetime.now(tz=UTC).date(), findings["bigid-case-0003"].date) + self.assertEqual(datetime.now(tz=UTC).date(), findings["bigid-case-0004"].date) + + def test_the_data_source_is_the_component(self): + """The same policy failing on two data sources stays two findings.""" + self.assertEqual(["title", "severity", "component_name"], BigidParser().get_dedupe_fields()) + findings = self.by_uid("bigid_many_vuln.json") + self.assertEqual("reporting-db", findings["bigid-case-0001"].component_name) + self.assertEqual("generic-bucket", findings["bigid-case-0002"].component_name) + self.assertIsNone(findings["bigid-case-0003"].component_name) + + def test_an_absent_remediation_leaves_the_mitigation_unset(self): + findings = self.by_uid("bigid_many_vuln.json") + self.assertIsNone(findings["bigid-case-0002"].mitigation) + self.assertIsNone(findings["bigid-case-0003"].mitigation) + + def test_tags_are_the_data_source_type_and_sensitivity(self): + findings = self.by_uid("bigid_many_vuln.json") + self.assertEqual(["S3", "Confidential"], findings["bigid-case-0002"].unsaved_tags) + self.assertEqual(["Elasticsearch"], findings["bigid-case-0004"].unsaved_tags) + self.assertEqual([], findings["bigid-case-0003"].unsaved_tags) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"cases": [ + "not an object", + None, + {"caseId": "case-9", "caseLabel": "A case", "severityLevel": "low", "caseStatus": "open"}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("bigid-case-9", findings[0].unique_id_from_tool) + + def test_severity_is_always_a_known_value(self): + for filename in ("bigid_many_vuln.json", "bigid_one_vuln.json", + "bigid_with_sample_values.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_bright_parser.py b/unittests/tools/test_bright_parser.py new file mode 100644 index 00000000000..00356dcb1ce --- /dev/null +++ b/unittests/tools/test_bright_parser.py @@ -0,0 +1,211 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.bright.parser import BrightParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestBrightParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("bright") / filename).open(encoding="utf-8") as file: + return list(BrightParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(BrightParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Bright connector's ScanTypeName verbatim. + + Any drift and someone who uploads an export and also syncs the API gets two un-deduplicated + copies of every issue. + """ + parser = BrightParser() + self.assertEqual(["Bright - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Bright - Connectors Import", + parser.get_label_for_scan_types("Bright - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("bright_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("bright_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring IssueToFinding in the connector's converter.""" + findings = self.parse("bright_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Reflected cross-site scripting", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("issue-0001", finding.unique_id_from_tool) + self.assertEqual(6.1, finding.cvssv3_score) + self.assertEqual(79, finding.cwe) + self.assertEqual("Encode user input before rendering it.", finding.mitigation) + self.assertEqual("https://app.example.com/search?q=1", finding.references) + self.assertTrue(finding.active) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + self.assertEqual( + "A request parameter is reflected into the response without encoding.\n\n" + "**Entry Point:** https://app.example.com/search?q=1\n" + "**Protocol:** http\n" + "**CWE:** CWE-79\n" + "\n**Request:**\n```\n" + "GET /search?q=%3Cscript%3E HTTP/1.1\nHost: app.example.com\n" + "```\n" + "\n**Response:**\n```\n" + "HTTP/1.1 200 OK\n\n<html><script>\n" + "```", + finding.description, + ) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual("search", locations[0].path) + self.assertEqual("q=1", locations[0].query) + + def test_the_request_and_response_are_fenced_not_rendered(self): + """ + Both are raw HTTP captured from the target, so they must not be read as markup. + + A reviewer also needs them verbatim to reproduce, which is why they are fenced rather than + summarised or escaped. + """ + finding = self.parse("bright_one_vuln.json")[0] + self.assertIn("**Request:**\n```\n", finding.description) + self.assertIn("**Response:**\n```\n", finding.description) + # The captured markup survives inside the fence rather than being stripped. + self.assertIn("<html><script>", finding.description) + + def test_many_vuln(self): + self.assertEqual(4, len(self.parse("bright_many_vuln.json"))) + + def test_severity_labels(self): + for label, expected in (("Critical", "Critical"), ("High", "High"), ("Medium", "Medium"), + ("Low", "Low"), ("low", "Low"), ("not a label", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string([{"id": "issue-1", "name": "An issue", "severity": label}]) + self.assertEqual(expected, findings[0].severity) + + def test_an_unrecognised_severity_is_info(self): + finding = self.by_uid("bright_many_vuln.json")["issue-0003"] + self.assertEqual("Info", finding.severity) + + def test_title_falls_back_to_the_issue_id(self): + finding = self.by_uid("bright_many_vuln.json")["issue-0003"] + self.assertEqual("Bright issue issue-0003", finding.title) + + def test_a_score_may_arrive_as_a_string(self): + finding = self.by_uid("bright_many_vuln.json")["issue-0002"] + self.assertEqual(3.1, finding.cvssv3_score) + + def test_a_zero_score_is_left_unset(self): + finding = self.by_uid("bright_many_vuln.json")["issue-0003"] + self.assertIsNone(finding.cvssv3_score) + + def test_cwe_forms(self): + for value, expected in (("CWE-79", 79), ("79", 79), ("cwe-79", 79), ("not a cwe", 0), ("", 0)): + with self.subTest(value=value): + findings = self.parse_string([ + {"id": "issue-1", "name": "An issue", "severity": "low", "cwe": value}, + ]) + self.assertEqual(expected, findings[0].cwe) + + def test_a_bare_cwe_number_is_read(self): + finding = self.by_uid("bright_many_vuln.json")["issue-0002"] + self.assertEqual(693, finding.cwe) + + def test_an_unparseable_cwe_still_appears_in_the_description(self): + finding = self.by_uid("bright_many_vuln.json")["issue-0003"] + self.assertEqual(0, finding.cwe) + self.assertIn("**CWE:** not a cwe", finding.description) + + def test_the_entry_point_is_the_endpoint(self): + """ + This scan type's deduplication hashes the endpoints, so one is always recorded. + + An unpopulated endpoint would leave the hash computed over nothing and every rescan would + reimport. + """ + finding = self.by_uid("bright_many_vuln.json")["issue-0001"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + + def test_the_resources_are_the_endpoint_fallback(self): + """ + Bright reports one issue against several resources when the same weakness is reachable from + more than one URL, so with no entry point every resource is recorded. + """ + findings = self.by_uid("bright_many_vuln.json") + locations = self.get_unsaved_locations(findings["issue-0002"]) + self.assertEqual(2, len(locations)) + self.assertEqual({"app.example.com", "api.example.com"}, {loc.host for loc in locations}) + self.assertIn(8443, {loc.port for loc in locations}) + + # An empty entry point falls back the same way. + single = self.get_unsaved_locations(findings["issue-0004"]) + self.assertEqual(1, len(single)) + self.assertEqual("api.example.com", single[0].host) + + def test_an_entry_point_that_cannot_be_a_host_is_not_recorded(self): + """ + DefectDojo's host field would reject it, and a ValidationError fails the whole import rather + than the one finding. The entry point is still in the description. + """ + finding = self.by_uid("bright_many_vuln.json")["issue-0003"] + self.assertEqual([], self.get_unsaved_locations(finding)) + self.assertIn("**Entry Point:** an internal service", finding.description) + + def test_references_are_the_resources_one_per_line(self): + finding = self.by_uid("bright_many_vuln.json")["issue-0002"] + self.assertEqual( + "https://app.example.com/\nhttps://api.example.com:8443/v1/health", + finding.references, + ) + + def test_no_remediation_leaves_the_mitigation_unset(self): + finding = self.by_uid("bright_many_vuln.json")["issue-0002"] + self.assertIsNone(finding.mitigation) + + def test_export_shapes(self): + """Bright's issues endpoint answers with a bare array; a scan object carries it nested.""" + issue = {"id": "issue-1", "name": "An issue", "severity": "low"} + for payload in ([issue], {"issues": [issue]}, {"items": [issue]}, + {"scan": {"issues": [issue]}}, {"data": {"issues": [issue]}}): + with self.subTest(shape=type(payload).__name__ + str(list(payload)[:1])): + findings = self.parse_string(payload) + self.assertEqual(1, len(findings)) + self.assertEqual("An issue", findings[0].title) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Bright", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("issues", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string(["not an object", None, + {"id": "issue-1", "name": "An issue", "severity": "low"}]) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("bright_many_vuln.json", "bright_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_calicocloud_parser.py b/unittests/tools/test_calicocloud_parser.py new file mode 100644 index 00000000000..902def2a6b3 --- /dev/null +++ b/unittests/tools/test_calicocloud_parser.py @@ -0,0 +1,304 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.calicocloud.parser import CalicocloudParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestCalicocloudParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("calicocloud") / filename + with path.open(encoding="utf-8") as file: + return list(CalicocloudParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(CalicocloudParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + vuln = {"id": "CVE-2000-0001", "name": "A finding", "severity": "high", + "package_name": "example-lib", "version": "1.0.0"} + vuln.update(overrides) + return {"images": [{"imageID": "img-1", "repository": "generic-app", "tag": "1.0", + "scan_result": "Fail", "result": "Fail", "vulnerabilities": [vuln]}]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Calico Cloud connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = CalicocloudParser() + self.assertEqual(["Calico Cloud Image Assurance Scan"], parser.get_scan_types()) + self.assertEqual("Calico Cloud Image Assurance Scan", + parser.get_label_for_scan_types("Calico Cloud Image Assurance Scan")) + self.assertNotIn("Calico Cloud - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("calicocloud_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("calicocloud_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("calicocloud_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001: Heap overflow in the example compression library", + finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("calico-cloud-img-0001-CVE-2000-0001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(9.1, finding.cvssv3_score) + self.assertEqual("example-compress", finding.component_name) + self.assertEqual("1.2.3", finding.component_version) + self.assertEqual("https://example.com/advisories/CVE-2000-0001", finding.references) + self.assertEqual("Upgrade example-compress to 1.2.4, 1.3.0.", finding.mitigation) + self.assertEqual(date(2024, 6, 2), finding.date) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + self.assertEqual( + "**Description:** A crafted archive can overflow a heap buffer during decompression.\n" + "**Image:** registry.example.com/generic-app:1.4.0\n" + "**Digest:** sha256:11111111111111111111111111111111111111111111111111111111" + "11111111\n" + "**Package:** example-compress\n" + "**Installed version:** 1.2.3\n" + "**Fixed in:** 1.2.4, 1.3.0", + finding.description, + ) + + def test_many_vuln(self): + """Six vulnerabilities across five images, but the pending image's one is never imported.""" + self.assertEqual(6, len(self.parse("calicocloud_many_vuln.json"))) + + def test_an_image_whose_scan_is_still_unknown_is_skipped_entirely(self): + """ + Calico says "Unknown" while a registry scan is still being processed. + + Importing those results would record a partial scan as a complete one, so the connector skips + the image and this does too - even though the vulnerability on it is Critical. + """ + findings = self.by_uid("calicocloud_many_vuln.json") + self.assertNotIn("calico-cloud-img-0002-CVE-2000-0099", findings) + for finding in findings.values(): + self.assertNotIn("Never imported", finding.title) + + def test_unknown_is_matched_case_insensitively_in_either_status_field(self): + for key in ("scan_result", "result"): + for value in ("Unknown", "unknown", " UNKNOWN "): + with self.subTest(key=key, value=value): + payload = self.row() + payload["images"][0][key] = value + self.assertEqual(0, len(self.parse_string(payload))) + + def test_the_cvss_score_decides_the_severity_not_calicos_word(self): + """ + The fixture's first finding is scored 9.1 but labelled "low". + + Calico's Pass/Warn/Fail verdict and its severity word are per-tenant configuration; scoring + from CVSS is what keeps the same CVE at the same severity in every tenant. + """ + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0001-CVE-2000-0001"] + self.assertEqual("Critical", finding.severity) + self.assertEqual(9.1, finding.cvssv3_score) + + def test_the_nested_cvss_base_score_is_the_fallback_and_may_be_quoted(self): + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0001-CVE-2000-0002"] + self.assertEqual(7.5, finding.cvssv3_score) + self.assertEqual("High", finding.severity) + + def test_the_severity_word_is_used_only_when_there_is_no_score(self): + findings = self.by_uid("calicocloud_many_vuln.json") + unscored = findings["calico-cloud-img-0001-CALICO-2024-0003"] + self.assertEqual(0.0, unscored.cvssv3_score) + self.assertEqual("Medium", unscored.severity) + + def test_negligible_is_info(self): + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0001-CVE-2000-0004"] + self.assertEqual("Info", finding.severity) + + def test_cvss_score_bands(self): + cases = ((9.0, "Critical"), (9.8, "Critical"), (7.0, "High"), (8.9, "High"), + (4.0, "Medium"), (6.9, "Medium"), (0.1, "Low"), (3.9, "Low")) + for score, expected in cases: + with self.subTest(score=score): + findings = self.parse_string(self.row(cvss3Score=score, severity="negligible")) + self.assertEqual(expected, findings[0].severity) + + def test_severity_words(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("CRITICAL", "Critical"), ("negligible", "Info"), + ("unknown", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.row(severity=label, cvss3Score=0)) + self.assertEqual(expected, findings[0].severity) + + def test_only_a_cve_becomes_a_vulnerability_id(self): + """Calico issues its own advisory ids too, and those are not CVEs.""" + findings = self.by_uid("calicocloud_many_vuln.json") + self.assertEqual(["CVE-2000-0001"], + findings["calico-cloud-img-0001-CVE-2000-0001"].unsaved_vulnerability_ids) + advisory = findings["calico-cloud-img-0001-CALICO-2024-0003"] + self.assertIsNone(advisory.unsaved_vulnerability_ids) + self.assertEqual("CALICO-2024-0003", advisory.vuln_id_from_tool) + + def test_a_title_repeating_the_id_is_not_doubled(self): + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0001-CVE-2000-0002"] + self.assertEqual("CVE-2000-0002", finding.title) + + def test_a_vulnerability_with_neither_id_nor_name_still_has_a_title(self): + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0003-"] + self.assertEqual("Calico Cloud image vulnerability", finding.title) + self.assertIsNone(finding.vuln_id_from_tool) + + def test_the_package_name_key_wins_over_the_package_key(self): + findings = self.parse_string(self.row(package="example-outer", package_name="example-inner")) + self.assertEqual("example-inner", findings[0].component_name) + + def test_the_package_key_is_used_when_there_is_no_package_name(self): + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0001-CVE-2000-0002"] + self.assertEqual("example-tls", finding.component_name) + + def test_a_single_fix_string_is_accepted_as_well_as_a_list(self): + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0001-CVE-2000-0002"] + self.assertEqual("Upgrade example-tls to 3.0.2.", finding.mitigation) + self.assertIn("**Fixed in:** 3.0.2", finding.description) + + def test_no_fix_means_no_mitigation(self): + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0001-CALICO-2024-0003"] + self.assertIsNone(finding.mitigation) + self.assertNotIn("**Fixed in:**", finding.description) + + def test_the_image_reference_falls_back_to_the_digest_then_the_id(self): + findings = self.by_uid("calicocloud_many_vuln.json") + digest_only = findings["calico-cloud-img-0003-"] + self.assertIn("**Image:** sha256:3333", digest_only.description) + id_only = findings["calico-cloud-img-0004-CVE-2000-0005"] + self.assertIn("**Image:** img-0004", id_only.description) + + def test_a_trailing_slash_on_the_registry_is_not_doubled(self): + """The fixture's registry ends in "/" and the reference must not carry "//".""" + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0001-CVE-2000-0001"] + self.assertIn("**Image:** registry.example.com/generic-app:1.4.0", finding.description) + self.assertNotIn("//generic-app", finding.description) + + def test_the_registry_is_only_prefixed_when_there_is_a_repository(self): + payload = self.row() + payload["images"][0]["registry"] = "registry.example.com" + payload["images"][0]["repository"] = "" + payload["images"][0]["digest"] = "sha256:abc" + findings = self.parse_string(payload) + self.assertIn("**Image:** sha256:abc", findings[0].description) + + def test_vulnerabilities_may_be_keyed_by_image_id_instead_of_nested(self): + """ + Calico serves an image's vulnerabilities from a per-image endpoint. + + A saved export of both calls can nest them or key them by image id, so both work. + """ + findings = self.by_uid("calicocloud_keyed_vulnerabilities.json") + self.assertEqual(2, len(findings)) + self.assertIn("calico-cloud-img-0001-CVE-2000-0001", findings) + self.assertIn("calico-cloud-img-0002-CVE-2000-0006", findings) + self.assertIn("**Image:** registry.example.com/generic-worker:2.0.0", + findings["calico-cloud-img-0002-CVE-2000-0006"].description) + + def test_export_shapes(self): + image = {"imageID": "img-1", "repository": "generic-app", "scan_result": "Fail", + "result": "Fail", + "vulnerabilities": [{"id": "CVE-2000-0001", "name": "A finding", + "severity": "high", "package_name": "example-lib"}]} + for payload in ([image], {"images": [image]}, {"data": [image]}, {"results": [image]}): + with self.subTest(shape=str(payload)[:20]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_a_bare_vulnerability_list_is_accepted_with_no_image_context(self): + """ + A file holding only the vulnerability call still imports. + + The identity then carries an empty image id, which is exactly what the connector would build + for an image it knows nothing about - the finding is not silently dropped. + """ + vulns = [{"id": "CVE-2000-0001", "name": "A finding", "severity": "high", + "package_name": "example-lib", "version": "1.0.0", "cvss3Score": 7.5}] + for payload in (vulns, {"vulnerabilities": vulns}): + with self.subTest(shape=type(payload).__name__): + findings = self.parse_string(payload) + self.assertEqual(1, len(findings)) + self.assertEqual("calico-cloud--CVE-2000-0001", findings[0].unique_id_from_tool) + self.assertNotIn("**Image:**", findings[0].description) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Calico Cloud", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("images", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + payload = {"images": ["not an object", None, + {"imageID": "img-9", "repository": "generic-app", + "scan_result": "Fail", "result": "Fail", + "vulnerabilities": ["not an object", None, + {"id": "CVE-2000-0007", "name": "A finding", + "severity": "low", + "package_name": "example-lib"}]}]} + findings = self.parse_string(payload) + self.assertEqual(1, len(findings)) + self.assertEqual("calico-cloud-img-9-CVE-2000-0007", findings[0].unique_id_from_tool) + + def test_an_image_with_no_vulnerabilities_contributes_nothing(self): + self.assertEqual(0, len(self.parse_string({"images": [ + {"imageID": "img-1", "repository": "generic-app", "scan_result": "Pass", + "result": "Pass"}, + ]}))) + + def test_the_identity_spans_the_image_and_the_vulnerability(self): + """ + The same CVE in two images is two findings - two things to fix. + + The hash spans the component and its version, so the identity is what separates them. + """ + self.assertEqual(["title", "severity", "component_name", "component_version"], + CalicocloudParser().get_dedupe_fields()) + image = {"repository": "generic-app", "scan_result": "Fail", "result": "Fail", + "vulnerabilities": [{"id": "CVE-2000-0001", "name": "A finding", + "severity": "high", "package_name": "example-lib", + "version": "1.0.0"}]} + findings = self.parse_string({"images": [ + {**image, "imageID": "img-a"}, + {**image, "imageID": "img-b"}, + ]}) + self.assertEqual(["calico-cloud-img-a-CVE-2000-0001", "calico-cloud-img-b-CVE-2000-0001"], + [finding.unique_id_from_tool for finding in findings]) + + def test_the_date_is_the_result_time_then_when_it_was_scanned(self): + findings = self.by_uid("calicocloud_many_vuln.json") + self.assertEqual(date(2024, 6, 2), findings["calico-cloud-img-0001-CVE-2000-0001"].date) + self.assertEqual(date(2024, 6, 4), findings["calico-cloud-img-0003-"].date) + + def test_an_image_with_no_timestamp_leaves_the_date_alone(self): + finding = self.by_uid("calicocloud_many_vuln.json")["calico-cloud-img-0004-CVE-2000-0005"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_severity_is_always_a_known_value(self): + for filename in ("calicocloud_many_vuln.json", "calicocloud_one_vuln.json", + "calicocloud_keyed_vulnerabilities.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_codacy_parser.py b/unittests/tools/test_codacy_parser.py new file mode 100644 index 00000000000..474f9ace89a --- /dev/null +++ b/unittests/tools/test_codacy_parser.py @@ -0,0 +1,266 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.codacy.parser import CodacyParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestCodacyParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("codacy") / filename).open(encoding="utf-8") as file: + return list(CodacyParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(CodacyParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Codacy connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = CodacyParser() + self.assertEqual(["Codacy - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Codacy - Connectors Import", + parser.get_label_for_scan_types("Codacy - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("codacy_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("codacy_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ItemToFinding in the connector's converter.""" + findings = self.parse("codacy_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 in generic-lib", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("00000000-0000-4000-8000-000000000001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(502, finding.cwe) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + self.assertEqual("https://app.example.com/items/1", finding.references) + self.assertEqual(date(2026, 6, 10), finding.date) + self.assertTrue(finding.active) + self.assertFalse(finding.false_p) + + def test_the_component_is_the_last_entry_of_the_dependency_chain(self): + """ + The vulnerable package is the deepest entry, not the project at the head of the chain. + + Taking the first entry would name the application as the vulnerable component on every SCA + finding. + """ + finding = self.parse("codacy_one_vuln.json")[0] + self.assertEqual("generic-lib", finding.component_name) + self.assertEqual("1.2.3", finding.component_version) + self.assertIn( + "**Dependency path:** generic-app → generic-framework → generic-lib", + finding.description, + ) + + def test_the_first_non_empty_chain_is_used(self): + """A leading empty chain must not make the parser conclude there is no package.""" + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + self.assertEqual("generic-openssl", finding.component_name) + + def test_a_dast_item_is_dynamic_and_everything_else_is_static(self): + """ + Codacy reports SCA, container and DAST items through one endpoint. + + Only DAST looked at something running, so flagging every item static - or every item dynamic - + would misreport most of them. + """ + findings = self.by_uid("codacy_many_vuln.json") + sca = findings["00000000-0000-4000-8000-000000000001"] + self.assertTrue(sca.static_finding) + self.assertFalse(sca.dynamic_finding) + + dast = findings["00000000-0000-4000-8000-000000000002"] + self.assertFalse(dast.static_finding) + self.assertTrue(dast.dynamic_finding) + + container = findings["00000000-0000-4000-8000-000000000003"] + self.assertTrue(container.static_finding) + self.assertFalse(container.dynamic_finding) + + def test_the_scanned_target_is_recorded(self): + """ + A DAST item names the application it scanned; a container item names the image. + + Asserted through get_unsaved_locations so this passes with V3_FEATURE_LOCATIONS either way. + """ + findings = self.by_uid("codacy_many_vuln.json") + dast = self.get_unsaved_locations(findings["00000000-0000-4000-8000-000000000002"]) + self.assertEqual(1, len(dast)) + self.assertEqual("app.example.com", dast[0].host) + + # No application, so the converter falls back to affectedTargets - an image reference, whose + # repository is a path rather than part of the host. Leaving it in the host field fails + # Endpoint.clean(), and that fails the whole import rather than this one finding. + container = self.get_unsaved_locations(findings["00000000-0000-4000-8000-000000000003"]) + self.assertEqual(1, len(container)) + self.assertEqual("registry.example.com", container[0].host) + self.assertEqual("generic-app", container[0].path) + + def test_a_target_that_cannot_be_a_host_records_no_endpoint(self): + """ + DefectDojo's host field accepts letters, digits, dot, hyphen, underscore, plus, or an IP. + + An image reference with a tag has nowhere sensible to go - the tag is neither a port nor part + of a path - so no endpoint is recorded rather than one that fails validation and takes the + whole import down with it. The value is still in the description. + """ + findings = self.parse_string({"items": [ + {"itemId": "1", "affectedTargets": "generic-app:1.2", "priority": "high"}, + ]}) + self.assertEqual([], self.get_unsaved_locations(findings[0])) + + def test_an_application_url_is_split_into_its_parts(self): + findings = self.parse_string({"items": [ + {"itemId": "1", "application": "https://app.example.com:8443/login", "priority": "high"}, + ]}) + location = self.get_unsaved_locations(findings[0])[0] + self.assertEqual("app.example.com", location.host) + self.assertEqual(8443, location.port) + self.assertEqual("https", location.protocol) + self.assertEqual("login", location.path) + + def test_an_item_with_no_target_records_none(self): + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000001"] + self.assertEqual([], self.get_unsaved_locations(finding)) + + def test_an_item_ignored_as_a_false_positive_is_flagged(self): + """ + Codacy can ignore an item, and the reason says whether a human judged it a false positive. + + The comparison strips spaces, so Codacy's "False Positive" matches. Importing it as an + ordinary active finding would put triaged noise back in front of the team. + """ + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + self.assertTrue(finding.false_p) + + def test_any_other_ignore_reason_is_not_a_false_positive(self): + """"Acceptable risk" is a real finding somebody accepted, not a mistake.""" + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000004"] + self.assertFalse(finding.false_p) + + def test_the_false_positive_check_directly(self): + parser = CodacyParser() + for reason, expected in [ + ("falsepositive", True), ("False Positive", True), ("FALSE POSITIVE", True), + ("Acceptable risk", False), ("", False), + ]: + self.assertEqual(expected, parser.is_false_positive({"ignored": {"reason": reason}}), reason) + self.assertFalse(parser.is_false_positive({"ignored": None})) + self.assertFalse(parser.is_false_positive({})) + + def test_several_cves_in_one_field_are_all_imported_and_deduplicated(self): + """ + Codacy's "cve" is a typed string documented as possibly holding several identifiers. + + Reading it as a single value would drop the rest; the fixture repeats one to prove the + deduplication. + """ + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + self.assertEqual(["CVE-2000-0003", "CVE-2000-0004"], finding.unsaved_vulnerability_ids) + # vuln_id_from_tool takes the first CVE found. + self.assertEqual("CVE-2000-0003", finding.vuln_id_from_tool) + + def test_an_item_with_no_cve_falls_back_to_the_codacy_source_id(self): + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertEqual("ZAP-40012", finding.vuln_id_from_tool) + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_an_item_with_no_title_is_named_from_the_scan_type_and_category(self): + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertEqual("Codacy DAST - Injection item", finding.title) + + def test_an_item_with_neither_title_nor_scan_type_names_the_item_id(self): + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000004"] + self.assertEqual( + "Codacy security item 00000000-0000-4000-8000-000000000004", finding.title, + ) + + def test_an_unparseable_cwe_leaves_the_cwe_at_zero(self): + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + self.assertEqual(0, finding.cwe) + + def test_an_unrecognised_priority_is_info(self): + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000004"] + self.assertEqual("Info", finding.severity) + + def test_an_unparseable_timestamp_dates_the_finding_today(self): + """ + The converter falls back to now() so that a finding always carries a date. + + Mirrored rather than corrected; asserted as a range so the test cannot flake on a date + rollover between the parse and the assertion. + """ + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000004"] + self.assertIsNotNone(finding.date) + today = datetime.now(tz=UTC).date() + self.assertLessEqual(abs((finding.date - today).days), 1) + + def test_the_container_image_is_reported_with_its_tag(self): + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + self.assertIn( + "**Container image:** registry.example.com/generic-app:1.4.0", finding.description, + ) + + def test_an_image_with_no_tag_is_reported_bare(self): + parser = CodacyParser() + self.assertEqual("generic-app", parser.container_image({"imageName": "generic-app"})) + self.assertEqual("", parser.container_image({"imageName": "", "imageTag": "1.0"})) + + def test_mitigation_carries_the_remediation_and_the_fixed_versions(self): + finding = self.parse("codacy_one_vuln.json")[0] + self.assertEqual( + "Upgrade generic-lib to 1.2.4 or later.\n\nFixed in: 1.2.4, 2.0.0", + finding.mitigation, + ) + + def test_an_item_with_neither_remediation_nor_fixed_versions_has_no_mitigation(self): + finding = self.by_uid("codacy_many_vuln.json")["00000000-0000-4000-8000-000000000004"] + self.assertIsNone(finding.mitigation) + + def test_tags_mirror_the_converter(self): + finding = self.parse("codacy_one_vuln.json")[0] + self.assertEqual(["SCA", "Vulnerability", "Trivy"], finding.unsaved_tags) + + def test_the_description_carries_the_detector_and_repository(self): + finding = self.parse("codacy_one_vuln.json")[0] + self.assertIn("generic-lib is vulnerable to remote code execution.", finding.description) + self.assertIn("The library evaluates untrusted input", finding.description) + self.assertIn("**Detected by:** Trivy", finding.description) + self.assertIn("**Repository:** generic-app", finding.description) + self.assertIn("**Likelihood:** High", finding.description) + self.assertIn("**Effort to fix:** Low", finding.description) + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([{"id": "i1", "priority": "Low", "title": "An item"}])) + self.assertEqual(1, len(list(CodacyParser().get_findings(report, Test())))) + + def test_a_repeated_item_id_collapses(self): + item = {"id": "same", "priority": "Low", "title": "An item"} + report = io.StringIO(json.dumps({"data": [item, item]})) + self.assertEqual(1, len(list(CodacyParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(CodacyParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("data", str(raised.exception)) diff --git a/unittests/tools/test_crowdstrike_spotlight_parser.py b/unittests/tools/test_crowdstrike_spotlight_parser.py new file mode 100644 index 00000000000..7bdc2a1080f --- /dev/null +++ b/unittests/tools/test_crowdstrike_spotlight_parser.py @@ -0,0 +1,208 @@ +import io +import json +from datetime import date + +from dojo.models import Finding, Test +from dojo.tools.crowdstrike_spotlight.parser import CrowdstrikeSpotlightParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestCrowdstrikeSpotlightParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("crowdstrike_spotlight") / filename + with path.open(encoding="utf-8") as file: + return list(CrowdstrikeSpotlightParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal ScanTypeSpotlight in the CrowdStrike connector verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding, because the two land in different test types. Note + the connector also defines a separate "CrowdStrike:Detections - Connectors Import" scan type, + which this parser must not claim. + """ + parser = CrowdstrikeSpotlightParser() + self.assertEqual(["CrowdStrike:Spotlight - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "CrowdStrike:Spotlight - Connectors Import", + parser.get_label_for_scan_types("CrowdStrike:Spotlight - Connectors Import"), + ) + self.assertNotIn( + "CrowdStrike:Detections - Connectors Import", parser.get_scan_types(), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("crowdstrike_spotlight_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("crowdstrike_spotlight_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring VulnConverter.Convert in the connector.""" + findings = self.parse("crowdstrike_spotlight_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + # converter title(): "<CVE>: <product_name_version>" + self.assertEqual("CVE-2000-0001: OpenSSL 3.0.11", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("spotlight-0000000000000000000000000000001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("OpenSSL", finding.component_name) + # converter componentVersion(): product_name_version minus the normalized name. + self.assertEqual("3.0.11", finding.component_version) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + # converter firstCWE(): the first parseable CWE-NNN. + self.assertEqual(295, finding.cwe) + self.assertEqual(date(2026, 1, 15), finding.date) + + # The converter marks Spotlight findings NEITHER static NOR dynamic. + self.assertFalse(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + def test_the_description_mirrors_the_converters_block(self): + finding = self.parse("crowdstrike_spotlight_one_vuln.json")[0] + self.assertIn("A flaw in certificate verification", finding.description) + self.assertIn("**Host:** host01.example.com", finding.description) + self.assertIn("**OS:** Ubuntu 22.04", finding.description) + self.assertIn("**Affected product:** OpenSSL 3.0.11", finding.description) + self.assertIn("**ExPRT rating:** HIGH", finding.description) + self.assertIn( + "**CISA KEV:** listed in the CISA Known Exploited Vulnerabilities catalog", + finding.description, + ) + + def test_the_severity_justification_mirrors_the_converters_sentence(self): + """The converter's severityJustification(): markdown emphasis and a one-decimal score.""" + finding = self.parse("crowdstrike_spotlight_one_vuln.json")[0] + self.assertEqual( + "CrowdStrike severity of **CRITICAL** from a base CVSS score of **9.8** " + "(ExPRT rating: HIGH)", + finding.severity_justification, + ) + + def test_mitigation_and_references_mirror_the_converter(self): + finding = self.parse("crowdstrike_spotlight_one_vuln.json")[0] + self.assertEqual( + "**Update OpenSSL**\nUpgrade OpenSSL to 3.0.13 or later.\n" + "Reference: VENDOR-SA-2026-0001", + finding.mitigation, + ) + # converter references(): CVE references, then vendor advisories, then entity links. + self.assertEqual( + "https://example.com/advisories/cve-2000-0001\n" + "https://vendor.example.com/security/2000-0001\n" + "https://example.com/remediation/openssl\n" + "https://vendor.example.com/downloads/openssl", + finding.references, + ) + + def test_tags_mirror_the_converter(self): + finding = self.parse("crowdstrike_spotlight_one_vuln.json")[0] + self.assertEqual( + ["exprt:high", "cisa-kev", "env/production", "team/platform"], + finding.unsaved_tags, + ) + + def test_the_affected_host_is_recorded(self): + """ + The connector emits "//<host>" so DefectDojo's URI parser treats it as a host. + + Asserted through get_unsaved_locations so this passes with V3_FEATURE_LOCATIONS either way - + Finding.__init__ creates unsaved_locations or unsaved_endpoints, never both. + """ + finding = self.parse("crowdstrike_spotlight_one_vuln.json")[0] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("host01.example.com", locations[0].host) + + def test_the_host_falls_back_to_the_local_ip(self): + """The converter's hostEndpoint(): hostname first, then local_ip.""" + finding = self.by_uid("crowdstrike_spotlight_many_vuln.json")["spotlight-3"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("10.0.0.33", locations[0].host) + + def test_the_severity_ladder_is_the_connectors(self): + """ + The converter's mapCVESeverity(): upper-cased comparison, anything unrecognised is Info. + + Spotlight reports severity on the CVE, not the vulnerability, so an unrecognised value must + not silently inherit a neighbouring finding's grade. + """ + findings = self.by_uid("crowdstrike_spotlight_many_vuln.json") + self.assertEqual("Critical", findings["spotlight-0000000000000000000000000000001"].severity) + self.assertEqual("High", findings["spotlight-2"].severity) + self.assertEqual("Medium", findings["spotlight-3"].severity) + self.assertEqual("Low", findings["spotlight-4"].severity) + self.assertEqual("Info", findings["spotlight-5"].severity) + + def test_a_vulnerability_with_no_apps_has_no_component(self): + finding = self.by_uid("crowdstrike_spotlight_many_vuln.json")["spotlight-4"] + self.assertEqual("CVE-2000-0004", finding.title) + self.assertIsNone(finding.component_name) + self.assertIsNone(finding.component_version) + self.assertIsNone(finding.mitigation) + + def test_an_unparseable_cwe_list_yields_zero_not_a_crash(self): + """ + The converter's firstCWE(): entries that do not split into two parts, or whose tail is not a + number, are skipped; zero is returned when none parse. + + Finding.cwe is an IntegerField with default 0, so "no CWE" reads as 0 rather than None. + """ + finding = self.by_uid("crowdstrike_spotlight_many_vuln.json")["spotlight-4"] + self.assertEqual(0, finding.cwe) + + def test_a_vulnerability_with_neither_cve_nor_product_uses_the_constant_title(self): + finding = self.by_uid("crowdstrike_spotlight_many_vuln.json")["spotlight-5"] + self.assertEqual("CrowdStrike Spotlight Vulnerability", finding.title) + self.assertIsNone(finding.vuln_id_from_tool) + # Finding.__init__ leaves this None; "no CVE" is None, not an empty list. + self.assertIsNone(finding.unsaved_vulnerability_ids) + self.assertIsNone(finding.references) + + def test_an_unparseable_timestamp_leaves_the_date_unset(self): + """The converter's formatDate() returns "" when the RFC3339 parse fails.""" + finding = self.by_uid("crowdstrike_spotlight_many_vuln.json")["spotlight-5"] + self.assertIsNone(finding.date) + + def test_a_zero_base_score_is_left_out_of_the_justification(self): + finding = self.by_uid("crowdstrike_spotlight_many_vuln.json")["spotlight-5"] + self.assertEqual( + "CrowdStrike severity of **NOT-A-SEVERITY**", finding.severity_justification, + ) + self.assertIsNone(finding.cvssv3_score) + + def test_several_remediation_entities_are_separated_by_a_blank_line(self): + finding = self.by_uid("crowdstrike_spotlight_many_vuln.json")["spotlight-6"] + self.assertEqual( + "**Apply patch**\nInstall KB0000001.\nReference: KB0000001\n\n" + "**Workaround**\nDisable the affected feature.", + finding.mitigation, + ) + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([{ + "id": "v1", "cve": {"id": "CVE-2000-0001", "severity": "HIGH"}, + }])) + findings = list(CrowdstrikeSpotlightParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("CVE-2000-0001", findings[0].title) + + def test_a_repeated_vulnerability_id_collapses(self): + row = {"id": "same", "cve": {"id": "CVE-2000-0001", "severity": "HIGH"}} + report = io.StringIO(json.dumps({"resources": [row, row]})) + self.assertEqual(1, len(list(CrowdstrikeSpotlightParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(CrowdstrikeSpotlightParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("resources", str(raised.exception)) diff --git a/unittests/tools/test_datadog_parser.py b/unittests/tools/test_datadog_parser.py new file mode 100644 index 00000000000..acb7d70ad2a --- /dev/null +++ b/unittests/tools/test_datadog_parser.py @@ -0,0 +1,291 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.datadog.parser import DatadogParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestDatadogParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("datadog") / filename).open(encoding="utf-8") as file: + return list(DatadogParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(DatadogParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, attributes, tags=None, identifier="row-1"): + return {"data": [{"id": identifier, "attributes": {"tags": tags or [], "attributes": attributes}}]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Datadog connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = DatadogParser() + self.assertEqual(["Datadog Cloud Security"], parser.get_scan_types()) + self.assertEqual("Datadog Cloud Security", parser.get_label_for_scan_types("Datadog Cloud Security")) + self.assertNotIn("Datadog - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """ + Every row in this sample is one Datadog has already dealt with. + + Muted by status, muted through the workflow, a compliance rule that passed, resolved and + auto-closed - five different ways of saying "not actionable", and all five are honoured or a + triaged queue comes straight back. + """ + self.assertEqual(0, len(self.parse("datadog_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("datadog_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("datadog_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("openssl 3.0.2 is affected by CVE-2000-0001", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("AAAAA-BBBB-CCCC-0001", finding.unique_id_from_tool) + self.assertEqual("rule-0001", finding.vuln_id_from_tool) + self.assertEqual("openssl", finding.component_name) + self.assertEqual("3.0.2", finding.component_version) + self.assertEqual("payments-api", finding.service) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + # The base severity details win over the adjusted ones, and both values come from the same block. + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["CVE-2000-0001", "GHSA-aaaa-bbbb-cccc"], finding.unsaved_vulnerability_ids) + + self.assertEqual( + "## Overview\n\n" + "The installed openssl build is affected by a memory-safety flaw.\n" + "* **Rule:** Vulnerable library detected\n" + "* **Finding type:** vulnerability\n" + "* **Resource:** web-node-1 (aws_ec2_instance)\n" + "* **Advisory:** CVE-2000-0001\n" + "* **Advisory summary:** Memory-safety flaw in openssl.\n", + finding.description, + ) + self.assertEqual( + ["datadog", "finding_type:vulnerability", "cloud_provider:aws", "region:us-east-1", + "account:acct-1234", "resource_type:aws_ec2_instance", "service:payments-api", + "env:prod", "team:platform"], + finding.unsaved_tags, + ) + + def test_many_vuln(self): + """Seven rows, three of them already dealt with.""" + self.assertEqual(4, len(self.parse("datadog_many_vuln.json"))) + + def test_each_way_of_saying_not_actionable(self): + for status in ("muted", "resolved", "auto_closed", "MUTED", " resolved "): + with self.subTest(status=status): + self.assertEqual(0, len(self.parse_string(self.row({ + "finding_type": "misconfiguration", "title": "A finding", "severity": "high", + "status": status, + })))) + + self.assertEqual(0, len(self.parse_string(self.row({ + "finding_type": "misconfiguration", "title": "A finding", "severity": "high", + "status": "open", "workflow": {"mute": {"is_muted": True}}, + })))) + self.assertEqual(0, len(self.parse_string(self.row({ + "finding_type": "misconfiguration", "title": "A finding", "severity": "high", + "status": "open", "compliance": {"evaluation": "PASS"}, + })))) + + def test_a_failing_compliance_rule_is_imported(self): + """Only a passing evaluation is silence; a failing one is the finding.""" + findings = self.by_uid("datadog_many_vuln.json") + self.assertIn("AAAAA-BBBB-CCCC-0002", findings) + self.assertIn("* **Compliance evaluation:** fail", findings["AAAAA-BBBB-CCCC-0002"].description) + + def test_severity_labels(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("CRITICAL", "Critical"), ("unknown", "Info"), + ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.row({ + "finding_type": "misconfiguration", "title": "A finding", "severity": label, + "status": "open", + })) + self.assertEqual(expected, findings[0].severity) + + def test_base_severity_is_not_used_for_the_grade(self): + """ + Datadog's base_severity is the rule's default before it adjusts for the environment. + + The adjusted `severity` is the one worth importing, so a row whose base says high and whose + severity says critical is Critical. + """ + finding = self.parse("datadog_one_vuln.json")[0] + self.assertEqual("Critical", finding.severity) + + def test_runtime_finding_types_are_dynamic(self): + """ + Datadog returns everything through one endpoint, so static-versus-dynamic is per row. + + A runtime or API-security finding is something Datadog watched happen; a misconfiguration or a + library vulnerability is something it read. + """ + for finding_type, static in (("api_security", False), ("attack_path", False), + ("runtime_code_vulnerability", False), ("workload_activity", False), + ("identity_risk", False), ("misconfiguration", True), + ("vulnerability", True), ("", True)): + with self.subTest(finding_type=finding_type): + findings = self.parse_string(self.row({ + "finding_type": finding_type, "title": "A finding", "severity": "high", + "status": "open", + })) + self.assertEqual(static, findings[0].static_finding) + self.assertEqual(not static, findings[0].dynamic_finding) + + def test_title_falls_back_to_the_rule_then_the_finding_type(self): + findings = self.by_uid("datadog_many_vuln.json") + self.assertEqual("S3 bucket should not allow public reads", findings["AAAAA-BBBB-CCCC-0002"].title) + self.assertEqual("Datadog finding: attack_path", findings["AAAAA-BBBB-CCCC-0007"].title) + + bare = self.parse_string(self.row({"severity": "low", "status": "open"})) + self.assertEqual("Datadog security finding", bare[0].title) + + def test_the_unique_id_falls_back_to_the_finding_id(self): + """Datadog repeats the id inside the attributes, which is the fallback when the row has none.""" + findings = self.by_uid("datadog_many_vuln.json") + self.assertIn("finding-0003", findings) + self.assertEqual("Unauthenticated endpoint accepts writes", findings["finding-0003"].title) + + def test_dates_are_unix_milliseconds(self): + """ + Datadog timestamps in milliseconds, not seconds. + + Reading them as seconds would date every finding in 1970. + """ + findings = self.by_uid("datadog_many_vuln.json") + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), findings["AAAAA-BBBB-CCCC-0001"].date) + self.assertEqual(datetime(2024, 6, 1, tzinfo=UTC).date(), findings["AAAAA-BBBB-CCCC-0002"].date) + # No first_seen_at, so the detection-changed timestamp stands in. + self.assertEqual(datetime(2024, 7, 15, tzinfo=UTC).date(), findings["finding-0003"].date) + + def test_a_row_with_no_timestamp_keeps_the_default_date(self): + findings = self.parse_string(self.row({ + "finding_type": "misconfiguration", "title": "A finding", "severity": "low", "status": "open", + })) + self.assertEqual(datetime.now(tz=UTC).date(), findings[0].date) + + def test_cvss_prefers_the_base_block_and_takes_both_values_together(self): + """ + Mixing a vector from one block with a score from the other would describe a scoring that never + existed, so the first block carrying either value supplies both. + """ + findings = self.parse_string(self.row({ + "finding_type": "vulnerability", "title": "A finding", "severity": "high", "status": "open", + "severity_details": {"adjusted": {"score": 7.2, "vector": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}}, + })) + self.assertEqual(7.2, findings[0].cvssv3_score) + self.assertEqual("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", findings[0].cvssv3) + + def test_no_severity_details_leaves_the_cvss_unset(self): + findings = self.by_uid("datadog_many_vuln.json") + self.assertIsNone(findings["AAAAA-BBBB-CCCC-0002"].cvssv3) + self.assertIsNone(findings["AAAAA-BBBB-CCCC-0002"].cvssv3_score) + + def test_vulnerability_ids_come_from_the_advisory_and_the_prose(self): + """ + Datadog names the CVE in the advisory for some finding types and only in the text for others. + + Both are read, duplicates dropped, order preserved - and the identifier set is the + connector's: CVE, GHSA, Go and RHSA. + """ + finding = self.parse("datadog_one_vuln.json")[0] + self.assertEqual(["CVE-2000-0001", "GHSA-aaaa-bbbb-cccc"], finding.unsaved_vulnerability_ids) + + findings = self.parse_string(self.row({ + "finding_type": "vulnerability", "status": "open", "severity": "high", + "title": "GO-2024-1234 in a module", + "description": "See also RHSA-2024:1234 and GHSA-dddd-eeee-ffff.", + })) + self.assertEqual( + ["GO-2024-1234", "RHSA-2024:1234", "GHSA-dddd-eeee-ffff"], + findings[0].unsaved_vulnerability_ids, + ) + + def test_a_finding_with_no_identifiers_has_none(self): + findings = self.by_uid("datadog_many_vuln.json") + self.assertIsNone(findings["AAAAA-BBBB-CCCC-0002"].unsaved_vulnerability_ids) + + def test_the_resource_label_falls_back_to_the_resource_id(self): + findings = self.by_uid("datadog_many_vuln.json") + self.assertIn( + "* **Resource:** arn:aws:s3:::example-bucket (aws_s3_bucket)", + findings["AAAAA-BBBB-CCCC-0002"].description, + ) + + def test_tags_are_deduplicated_but_not_sorted(self): + """ + The connector preserves the order it built the tags in. + + Sorting them would be tidier and wrong: a tag list that reorders on every sync reads as a + change to the finding. + """ + finding = self.parse("datadog_one_vuln.json")[0] + self.assertEqual("datadog", finding.unsaved_tags[0]) + self.assertEqual("finding_type:vulnerability", finding.unsaved_tags[1]) + self.assertEqual(len(set(finding.unsaved_tags)), len(finding.unsaved_tags)) + self.assertNotEqual(sorted(finding.unsaved_tags), finding.unsaved_tags) + + def test_the_service_comes_out_of_datadogs_own_tag(self): + finding = self.parse("datadog_one_vuln.json")[0] + self.assertEqual("payments-api", finding.service) + + findings = self.parse_string(self.row({ + "finding_type": "misconfiguration", "title": "A finding", "severity": "low", "status": "open", + }, tags=["env:prod"])) + self.assertIsNone(findings[0].service) + + def test_export_shapes(self): + attributes = {"finding_type": "misconfiguration", "title": "A finding", "severity": "low", + "status": "open"} + row = {"id": "row-1", "attributes": {"tags": [], "attributes": attributes}} + for payload in ({"data": [row]}, [row], row): + with self.subTest(shape=type(payload).__name__): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Datadog", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("data", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"data": [ + "not an object", + None, + {"id": "no-attributes"}, + {"id": "empty-attributes", "attributes": {}}, + {"id": "row-1", "attributes": {"attributes": {"title": "A finding", "severity": "low", + "status": "open"}}}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("A finding", findings[0].title) + + def test_severity_is_always_a_known_value(self): + for filename in ("datadog_many_vuln.json", "datadog_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_deepsource_parser.py b/unittests/tools/test_deepsource_parser.py new file mode 100644 index 00000000000..b00c3d4f592 --- /dev/null +++ b/unittests/tools/test_deepsource_parser.py @@ -0,0 +1,313 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.deepsource.parser import DeepSourceParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestDeepSourceParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("deepsource") / filename).open(encoding="utf-8") as file: + return list(DeepSourceParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the DeepSource connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = DeepSourceParser() + self.assertEqual(["DeepSource - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "DeepSource - Connectors Import", + parser.get_label_for_scan_types("DeepSource - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("deepsource_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("deepsource_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring OccurrenceToFinding in the connector's converter.""" + findings = self.parse("deepsource_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Possible SQL injection through string formatting", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("src/generic_app/views.py", finding.file_path) + self.assertEqual(42, finding.line) + self.assertEqual("occ-0001", finding.unique_id_from_tool) + self.assertEqual("PY-A6006", finding.vuln_id_from_tool) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + # The run's finish time dates the finding, not its creation time. + self.assertEqual(date(2026, 6, 20), finding.date) + + self.assertIn("Query is built with string formatting", finding.description) + self.assertIn("**Issue:** PY-A6006", finding.description) + self.assertIn("**Analyzer:** Python (python)", finding.description) + self.assertIn("**Category:** SECURITY", finding.description) + self.assertIn("**DeepSource severity:** CRITICAL", finding.description) + # A genuine multi-line span is rendered as a range. + self.assertIn("**Location:** src/generic_app/views.py:42-47", finding.description) + self.assertEqual(["python", "SECURITY", "CRITICAL"], finding.unsaved_tags) + + def test_a_security_issue_keeps_its_grade_but_a_bug_risk_issue_drops_a_step(self): + """ + DeepSource grades everything CRITICAL / MAJOR / MINOR whatever the issue actually is. + + So the category has to decide which ladder applies. A CRITICAL security issue is Critical; a + CRITICAL bug-risk issue is High, because it describes a defect rather than a weakness. + Applying one ladder to both would either inflate every lint finding or bury real ones. + """ + findings = self.by_uid("deepsource_many_vuln.json") + self.assertEqual("Critical", findings["occ-0001"].severity) # SECURITY + CRITICAL + self.assertEqual("High", findings["occ-0002"].severity) # BUG_RISK + CRITICAL + + def test_both_severity_ladders_directly(self): + parser = DeepSourceParser() + for category, pairs in ( + ("SECURITY", [("CRITICAL", "Critical"), ("MAJOR", "High"), ("MINOR", "Medium")]), + ("BUG_RISK", [("CRITICAL", "High"), ("MAJOR", "Medium"), ("MINOR", "Low")]), + ("PERFORMANCE", [("CRITICAL", "High"), ("MINOR", "Low")]), + ("TYPECHECK", [("MAJOR", "Medium")]), + ("ANTI_PATTERN", [("MAJOR", "Medium")]), + ): + for severity, expected in pairs: + self.assertEqual( + expected, + parser.severity_for_issue({"category": category, "severity": severity}), + f"{category}/{severity}", + ) + + def test_style_documentation_and_coverage_are_info(self): + """ + These categories are not weaknesses, and the connector grades them Info. + + Mirrored rather than corrected, since parity with the connector is what stops findings + duplicating. Raised in the PR as a follow-up worth discussing against the connector. + """ + parser = DeepSourceParser() + for category in ("STYLE", "DOCUMENTATION", "COVERAGE"): + self.assertEqual( + "Info", parser.severity_for_issue({"category": category, "severity": "CRITICAL"}), + category, + ) + finding = self.by_uid("deepsource_many_vuln.json")["occ-0003"] + self.assertEqual("Info", finding.severity) + + def test_a_secrets_analyzer_hit_is_critical_whatever_deepsource_graded_it(self): + """ + A committed credential is a committed credential. + + The fixture grades it MINOR, which on the security ladder would be Medium; the analyzer + override is what makes it Critical. + """ + finding = self.by_uid("deepsource_many_vuln.json")["occ-0004"] + self.assertEqual("Critical", finding.severity) + self.assertIn("secrets", finding.unsaved_tags) + self.assertIn("MINOR", finding.unsaved_tags) + + def test_an_unrecognised_category_is_info(self): + finding = self.by_uid("deepsource_many_vuln.json")["occ-0005"] + self.assertEqual("Info", finding.severity) + + def test_an_occurrence_with_no_title_falls_back_through_the_issue(self): + findings = self.by_uid("deepsource_many_vuln.json") + # No occurrence title, so the issue title is used. + self.assertEqual("Mutable default argument", findings["occ-0002"].title) + # No occurrence or issue title, and no shortcode either. + self.assertEqual("DeepSource issue occ-0005", findings["occ-0005"].title) + + def test_a_single_line_occurrence_is_not_rendered_as_a_range(self): + """An endLine equal to beginLine is one line, not a range.""" + finding = self.by_uid("deepsource_many_vuln.json")["occ-0002"] + self.assertIn("**Location:** src/generic_app/utils.py:10", finding.description) + self.assertNotIn(":10-10", finding.description) + + def test_an_occurrence_with_no_line_has_no_location(self): + finding = self.by_uid("deepsource_many_vuln.json")["occ-0005"] + self.assertIsNone(finding.line) + self.assertIsNone(finding.file_path) + self.assertNotIn("**Location:**", finding.description) + + def test_a_dependency_vulnerability_is_mapped_separately(self): + """ + DeepSource reports analysis issues and dependency advisories, converted differently. + + Treating one shape as the other would lose the CVE, component and CVSS score entirely. + """ + finding = self.by_uid("deepsource_many_vuln.json")["dep-0001"] + self.assertEqual("CVE-2000-0001 - generic-lib (1.2.3)", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertEqual("generic-lib", finding.component_name) + self.assertEqual("1.2.3", finding.component_version) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual(0.0431, finding.epss_score) + self.assertEqual(date(2026, 5, 1), finding.date) + self.assertEqual( + "https://example.com/advisories/cve-2000-0001\nhttps://example.com/commit/abcdef1", + finding.references, + ) + self.assertEqual(["sca", "PYPI", "REACHABLE"], finding.unsaved_tags) + + self.assertIn("**Package:** generic-lib 1.2.3", finding.description) + self.assertIn("**Ecosystem:** PYPI", finding.description) + self.assertIn("**Reachability:** REACHABLE", finding.description) + self.assertIn("**Fixability:** FIXABLE", finding.description) + self.assertIn("**Aliases:** GHSA-0000-0000-0001, cve-2000-0001", finding.description) + + def test_advisory_identifiers_are_upper_cased_and_deduplicated(self): + """The fixture repeats the identifier in lower case as an alias, to prove both.""" + finding = self.by_uid("deepsource_many_vuln.json")["dep-0001"] + self.assertEqual( + ["CVE-2000-0001", "GHSA-0000-0000-0001"], finding.unsaved_vulnerability_ids, + ) + + def test_an_unscored_advisory_is_graded_by_its_severity_word(self): + """GitHub spells medium "MODERATE", so both have to map.""" + finding = self.by_uid("deepsource_many_vuln.json")["dep-0002"] + self.assertEqual("Medium", finding.severity) + self.assertIsNone(finding.cvssv3_score) + + def test_the_advisory_severity_resolution_directly(self): + parser = DeepSourceParser() + # A score always wins over the words. + self.assertEqual("Critical", parser.severity_for_vulnerability( + {"cvssV3BaseScore": 9.1, "severity": "LOW"})) + # cvssV3Severity is consulted before the plain severity. + self.assertEqual("High", parser.severity_for_vulnerability( + {"cvssV3Severity": "HIGH", "severity": "LOW"})) + self.assertEqual("Medium", parser.severity_for_vulnerability({"severity": "MODERATE"})) + self.assertEqual("Info", parser.severity_for_vulnerability({"severity": "nonsense"})) + + def test_the_cvss_bands_bottom_out_at_low_not_info(self): + """A scored advisory is never Info; the converter's lowest band is Low.""" + parser = DeepSourceParser() + for score, expected in [(10.0, "Critical"), (9.0, "Critical"), (7.0, "High"), + (4.0, "Medium"), (0.1, "Low"), (2.1, "Low")]: + self.assertEqual(expected, parser.cvss_band(score), score) + finding = self.by_uid("deepsource_many_vuln.json")["dep-0004"] + self.assertEqual("Low", finding.severity) + + def test_an_advisory_with_no_fix_says_so_explicitly(self): + """ + "No fix published" is itself useful triage information. + + Leaving the mitigation empty would read as "nobody filled this in". + """ + finding = self.by_uid("deepsource_many_vuln.json")["dep-0002"] + self.assertEqual( + "No fixed version has been published for this advisory.", finding.mitigation, + ) + + def test_several_fixed_versions_are_offered_as_alternatives(self): + finding = self.by_uid("deepsource_many_vuln.json")["dep-0001"] + self.assertEqual("Upgrade generic-lib to 1.2.4 or 2.0.0.", finding.mitigation) + + def test_an_advisory_with_no_package_name_still_gets_a_mitigation(self): + parser = DeepSourceParser() + self.assertEqual( + "Upgrade the affected package to 1.0.1.", + parser.vulnerability_mitigation({"fixedVersions": ["1.0.1"]}, {}), + ) + + def test_an_advisory_with_no_identifier_falls_back_to_its_summary(self): + finding = self.by_uid("deepsource_many_vuln.json")["dep-0003"] + self.assertEqual("An advisory with no identifier at all.", finding.title) + self.assertIsNone(finding.vuln_id_from_tool) + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_an_unparseable_published_date_falls_back_to_today(self): + """The converter always dates a finding; asserted as a range so it cannot flake.""" + finding = self.by_uid("deepsource_many_vuln.json")["dep-0002"] + self.assertLessEqual(abs((finding.date - datetime.now(tz=UTC).date()).days), 1) + + def test_both_shapes_import_together(self): + findings = self.parse("deepsource_many_vuln.json") + self.assertEqual(9, len(findings)) + self.assertEqual(5, sum(1 for f in findings if f.unique_id_from_tool.startswith("occ-"))) + self.assertEqual(4, sum(1 for f in findings if f.unique_id_from_tool.startswith("dep-"))) + + def test_a_bare_array_is_classified_per_entry(self): + """ + A bare array can hold either shape, so each entry is judged on whether it has an advisory. + + Assuming a whole file is one shape would silently mis-map a mixed export. + """ + report = io.StringIO(json.dumps([ + {"id": "occ-1", "path": "a.py", "beginLine": 1, + "issue": {"shortcode": "PY-1", "title": "An issue", + "category": "SECURITY", "severity": "MAJOR"}}, + {"id": "dep-1", "vulnerability": {"identifier": "CVE-2000-0001", "cvssV3BaseScore": 5.0}, + "package": {"name": "p"}, "packageVersion": {"version": "1"}}, + ])) + findings = {f.unique_id_from_tool: f for f in DeepSourceParser().get_findings(report, Test())} + self.assertEqual("High", findings["occ-1"].severity) + self.assertEqual("Medium", findings["dep-1"].severity) + self.assertEqual("CVE-2000-0001 - p (1)", findings["dep-1"].title) + + def test_a_repeated_id_collapses(self): + occurrence = {"id": "same", "path": "a.py", "beginLine": 1, + "issue": {"shortcode": "PY-1", "category": "SECURITY", "severity": "MAJOR"}} + report = io.StringIO(json.dumps({"occurrences": [occurrence, occurrence]})) + self.assertEqual(1, len(list(DeepSourceParser().get_findings(report, Test())))) + + def test_an_export_with_no_run_still_dates_the_findings(self): + report = io.StringIO(json.dumps({"occurrences": [ + {"id": "o1", "path": "a.py", "beginLine": 1, + "issue": {"shortcode": "PY-1", "category": "SECURITY", "severity": "MAJOR"}}, + ]})) + finding = list(DeepSourceParser().get_findings(report, Test()))[0] + self.assertLessEqual(abs((finding.date - datetime.now(tz=UTC).date()).days), 1) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(DeepSourceParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("GraphQL", str(raised.exception)) + self.assertIn("issueOccurrences", str(raised.exception)) + + def test_the_input_is_a_saved_graphql_response(self): + """ + DeepSource has NO REST API - everything goes through POST /graphql/. + + So the only file a user can actually produce is a saved GraphQL response, wrapped in + data.repository with each connection under edges[].node. An earlier version of this parser + read an invented {"occurrences": [...]} envelope, which no DeepSource user could have + generated; this test pins the real shape so that cannot regress. + """ + raw = json.loads((get_unit_tests_scans_path("deepsource") + / "deepsource_many_vuln.json").read_text(encoding="utf-8")) + repository = raw["data"]["repository"] + self.assertIn("issueOccurrences", repository) + self.assertIn("dependencyVulnerabilityOccurrences", repository) + self.assertIn("analysisRuns", repository) + # The findings are behind edges/node, not directly in a list. + self.assertNotIsInstance(repository["issueOccurrences"], list) + self.assertIn("node", repository["issueOccurrences"]["edges"][0]) + # And the invented envelope is genuinely absent. + self.assertNotIn("occurrences", raw) + self.assertNotIn("vulnerabilities", raw) + + # Both connections still import, so the unwrapping works. + findings = self.parse("deepsource_many_vuln.json") + self.assertEqual(9, len(findings)) + + def test_the_data_wrapper_may_be_omitted(self): + """Someone may save just the repository object; both forms are accepted.""" + raw = json.loads((get_unit_tests_scans_path("deepsource") + / "deepsource_one_vuln.json").read_text(encoding="utf-8")) + report = io.StringIO(json.dumps({"repository": raw["data"]["repository"]})) + self.assertEqual(1, len(list(DeepSourceParser().get_findings(report, Test())))) diff --git a/unittests/tools/test_defender_for_cloud_parser.py b/unittests/tools/test_defender_for_cloud_parser.py new file mode 100644 index 00000000000..cd547b03c21 --- /dev/null +++ b/unittests/tools/test_defender_for_cloud_parser.py @@ -0,0 +1,298 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.defender_for_cloud.parser import DefenderForCloudParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestDefenderForCloudParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("defender_for_cloud") / filename + with path.open(encoding="utf-8") as file: + return list(DefenderForCloudParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(DefenderForCloudParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, properties, identifier="sub-1"): + return {"value": [{"id": identifier, "properties": properties}]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Defender for Cloud connector's ScanTypeName verbatim. + + Note this is a different product from Microsoft Defender for Endpoint, which DefectDojo already + parses as `ms_defender` - a separate scan type, a separate parser, and not to be conflated. + """ + parser = DefenderForCloudParser() + self.assertEqual(["Microsoft Defender for Cloud - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Microsoft Defender for Cloud - Connectors Import", + parser.get_label_for_scan_types("Microsoft Defender for Cloud - Connectors Import"), + ) + + def test_the_dedupe_hash_is_the_arm_id_alone(self): + """ + The ARM sub-assessment id already encodes the subscription, the resource and the finding. + + It is therefore the whole identity, and adding a volatile field would split a finding that had + merely been regraded. + """ + self.assertEqual(["unique_id_from_tool"], DefenderForCloudParser().get_dedupe_fields()) + + def test_no_vuln(self): + """ + Posture recommendations and healthy findings are not vulnerabilities. + + Healthy ones are left out so a reimport closes them rather than resurrecting them. + """ + self.assertEqual(0, len(self.parse("defender_for_cloud_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("defender_for_cloud_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("defender_for_cloud_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 in openssl", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual( + "Defender for Cloud assigned severity **High**. CVSS v3 base score 9.8.", + finding.severity_justification, + ) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("openssl", finding.component_name) + self.assertEqual("3.0.2", finding.component_version) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertEqual( + "An attacker who reaches the service can run code as the service account.", + finding.impact, + ) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertIn("sub-0001", finding.unique_id_from_tool) + + self.assertEqual( + "**Assessed resource:** web-node-1\n" + "**Package:** openssl 3.0.2\n" + "**Fixed version:** 3.0.13\n" + "\nA memory-safety flaw allows remote code execution.", + finding.description, + ) + self.assertEqual( + "Update openssl to 3.0.13 or later.\n\nApply the vendor update through your patching process.", + finding.mitigation, + ) + self.assertEqual( + "https://nvd.example.com/vuln/detail/CVE-2000-0001\nhttps://vendor.example.com/advisory/1", + finding.references, + ) + + def test_many_vuln(self): + """Six sub-assessments; three are not open vulnerabilities.""" + self.assertEqual(3, len(self.parse("defender_for_cloud_many_vuln.json"))) + + def test_only_unhealthy_sub_assessments_are_imported(self): + for code, imported in (("Unhealthy", 1), ("Healthy", 0), ("NotApplicable", 0), ("", 0)): + with self.subTest(code=code): + findings = self.parse_string(self.row({ + "displayName": "CVE-2000-0001", "status": {"code": code, "severity": "High"}, + "additionalData": {"assessedResourceType": "ServerVulnerabilityTvm"}, + })) + self.assertEqual(imported, len(findings)) + + def test_posture_sub_assessments_are_not_vulnerabilities(self): + """SQL baselines and posture checks carry no CVEs, so they are excluded by resource type.""" + for resource_type in ("SqlServerVulnerability", "GeneralVulnerability"): + with self.subTest(resource_type=resource_type): + findings = self.parse_string(self.row({ + "displayName": "A recommendation", "status": {"code": "Unhealthy", "severity": "High"}, + "additionalData": {"assessedResourceType": resource_type}, + })) + self.assertEqual(0, len(findings)) + + def test_an_unfamiliar_resource_type_is_decided_by_whether_it_has_a_cve(self): + """ + A new Defender scanner should not be dropped silently, and a configuration baseline should not + arrive as a vulnerability. The presence of a CVE is what tells them apart. + """ + findings = self.by_uid("defender_for_cloud_many_vuln.json") + self.assertIn("sub-0005", findings) + self.assertNotIn("sub-0006", findings) + + def test_the_tvm_cve_field_may_be_a_list_an_object_or_a_string(self): + """The connector's decoder accepts all three, so a file carrying any of them reads the same.""" + for cve in ("CVE-2000-0005", + {"title": "CVE-2000-0005"}, + [{"title": "CVE-2000-0005"}], + ["CVE-2000-0005"]): + with self.subTest(shape=type(cve).__name__): + findings = self.parse_string(self.row({ + "displayName": "A finding", "status": {"code": "Unhealthy", "severity": "High"}, + "additionalData": {"assessedResourceType": "SomeFutureType", "cve": cve}, + })) + self.assertEqual(1, len(findings)) + self.assertEqual(["CVE-2000-0005"], findings[0].unsaved_vulnerability_ids) + + def test_severity_labels(self): + for label, expected in (("Critical", "Critical"), ("High", "High"), ("medium", "Medium"), + ("Low", "Low"), ("not a label", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.row({ + "displayName": "CVE-2000-0001", "status": {"code": "Unhealthy", "severity": label}, + "additionalData": {"assessedResourceType": "ServerVulnerabilityTvm"}, + })) + self.assertEqual(expected, findings[0].severity) + + def test_the_raw_label_is_kept_as_the_justification(self): + finding = self.by_uid("defender_for_cloud_many_vuln.json")["sub-0005"] + self.assertEqual("Info", finding.severity) + self.assertEqual( + "Defender for Cloud assigned severity **not a label**.", + finding.severity_justification, + ) + + def test_the_package_is_read_from_either_finding_shape(self): + """ + A container finding puts the package under softwareDetails; a server finding flattens it. + + Reading only one shape would leave every finding of the other kind with no component - and the + component is what a reviewer patches. + """ + findings = self.by_uid("defender_for_cloud_many_vuln.json") + server = findings[[uid for uid in findings if "sub-0001" in uid][0]] + self.assertEqual("openssl", server.component_name) + self.assertEqual("3.0.2", server.component_version) + + container = findings[[uid for uid in findings if "sub-0002" in uid][0]] + self.assertEqual("libxml2", container.component_name) + self.assertEqual("2.9.13", container.component_version) + + def test_a_container_finding_names_the_image_and_digest(self): + findings = self.by_uid("defender_for_cloud_many_vuln.json") + container = findings[[uid for uid in findings if "sub-0002" in uid][0]] + self.assertIn("**Image:** exampleacr.azurecr.example.com/generic-app", container.description) + self.assertIn("**Digest:** sha256:", container.description) + + def test_a_server_finding_has_no_image_lines(self): + findings = self.by_uid("defender_for_cloud_many_vuln.json") + server = findings[[uid for uid in findings if "sub-0001" in uid][0]] + self.assertNotIn("**Image:**", server.description) + self.assertNotIn("**Digest:**", server.description) + + def test_the_highest_cvss_wins_and_only_v3_reaches_the_v3_field(self): + """ + Defender reports a flat v3.0 score on some shapes and a version-keyed map on others. + + The highest wins, and a v2 base must not land in a v3 field - the same number means different + things on the two scales. The justification still records which version it was. + """ + findings = self.by_uid("defender_for_cloud_many_vuln.json") + server = findings[[uid for uid in findings if "sub-0001" in uid][0]] + self.assertEqual(9.8, server.cvssv3_score) + + container = findings[[uid for uid in findings if "sub-0002" in uid][0]] + self.assertIsNone(container.cvssv3_score) + self.assertIn("CVSS v2 base score 5.0.", container.severity_justification) + + def test_the_title_only_appends_the_package_for_a_bare_cve(self): + """ + "CVE-2000-0001" alone says nothing about what is affected, and one CVE usually appears against + several packages on the same host. A descriptive name is left as it is. + """ + findings = self.by_uid("defender_for_cloud_many_vuln.json") + self.assertEqual("A future scanner's finding", findings["sub-0005"].title) + + named = self.parse_string(self.row({ + "displayName": "Vulnerable OpenSSL detected", "status": {"code": "Unhealthy", "severity": "High"}, + "additionalData": {"assessedResourceType": "ServerVulnerabilityTvm", "softwareName": "openssl"}, + })) + self.assertEqual("Vulnerable OpenSSL detected", named[0].title) + + def test_the_resource_label_falls_back_to_the_last_segment_of_an_arm_id(self): + """ + An ARM id is a path, and its last segment is the resource. + + Printing the whole path would bury the one part a reader needs. + """ + findings = self.by_uid("defender_for_cloud_many_vuln.json") + container = findings[[uid for uid in findings if "sub-0002" in uid][0]] + self.assertIn("**Assessed resource:** exampleacr", container.description) + + def test_cve_ids_are_matched_anchored_not_as_substrings(self): + """ + A reference title is either a CVE id or it is prose. + + A substring match would pull an id out of a sentence like "supersedes CVE-2000-0009", which is + a different finding's identifier. + """ + findings = self.parse_string(self.row({ + "displayName": "CVE-2000-0001", "status": {"code": "Unhealthy", "severity": "High"}, + "additionalData": { + "assessedResourceType": "ServerVulnerabilityTvm", + "vulnerabilityDetails": { + "cveId": "CVE-2000-0001", + "references": [{"title": "supersedes CVE-2000-0009", "link": "https://example.com/a"}], + }, + }, + })) + self.assertEqual(["CVE-2000-0001"], findings[0].unsaved_vulnerability_ids) + + def test_mitigation_keeps_both_the_version_and_defenders_text(self): + """They answer different questions - what to do and how - and neither is always present.""" + findings = self.by_uid("defender_for_cloud_many_vuln.json") + container = findings[[uid for uid in findings if "sub-0002" in uid][0]] + self.assertEqual("Update libxml2 to 2.9.14 or later.", container.mitigation) + + only_text = self.parse_string(self.row({ + "displayName": "CVE-2000-0001", "remediation": "Follow the vendor advisory.", + "status": {"code": "Unhealthy", "severity": "High"}, + "additionalData": {"assessedResourceType": "ServerVulnerabilityTvm"}, + })) + self.assertEqual("Follow the vendor advisory.", only_text[0].mitigation) + + def test_export_shapes(self): + properties = {"displayName": "CVE-2000-0001", "status": {"code": "Unhealthy", "severity": "High"}, + "additionalData": {"assessedResourceType": "ServerVulnerabilityTvm"}} + row = {"id": "sub-1", "properties": properties} + for payload in ({"value": [row]}, [row], row): + with self.subTest(shape=type(payload).__name__): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Defender for Cloud", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("value", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"value": [ + "not an object", + None, + {"id": "no-properties"}, + {"id": "sub-1", "properties": {"displayName": "CVE-2000-0001", + "status": {"code": "Unhealthy", "severity": "High"}, + "additionalData": {"assessedResourceType": "ServerVulnerabilityTvm"}}}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("defender_for_cloud_many_vuln.json", "defender_for_cloud_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_detectify_parser.py b/unittests/tools/test_detectify_parser.py new file mode 100644 index 00000000000..b0c463c322e --- /dev/null +++ b/unittests/tools/test_detectify_parser.py @@ -0,0 +1,257 @@ +import io +import json +from datetime import date + +from dojo.models import Finding, Test +from dojo.tools.detectify.parser import DetectifyParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestDetectifyParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("detectify") / filename).open(encoding="utf-8") as file: + return list(DetectifyParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Detectify connector's ScanType() verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. Any drift and a customer who + uploads an export and also syncs the API gets two un-deduplicated copies of every finding. + """ + parser = DetectifyParser() + self.assertEqual(["Detectify Scan"], parser.get_scan_types()) + self.assertEqual("Detectify Scan", parser.get_label_for_scan_types("Detectify Scan")) + self.assertNotIn("Detectify - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("detectify_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("detectify_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("detectify_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Remote code execution via CVE-2000-0001", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("00000000-0000-4000-8000-000000000001", finding.unique_id_from_tool) + # vuln_id_from_tool is the definition's title, Detectify's stable rule name. + self.assertEqual("Insecure Deserialization", finding.vuln_id_from_tool) + self.assertEqual(502, finding.cwe) + self.assertEqual(date(2026, 7, 1), finding.date) + self.assertTrue(finding.active) + # Detectify is EASM/DAST. + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + self.assertIn("**Host:** app.example.com", finding.description) + self.assertIn("**Location:** /api/upload", finding.description) + self.assertIn("**Scan source:** surface-monitoring", finding.description) + self.assertIn("**Status:** active", finding.description) + self.assertIn("The endpoint deserializes untrusted input.", finding.description) + # The definition's risk text becomes the impact. + self.assertEqual("An attacker can execute arbitrary code on the host.", finding.impact) + self.assertEqual(["production", "external", "surface-monitoring"], finding.unsaved_tags) + + def test_the_cvss_31_block_is_preferred_over_30_and_20_is_ignored(self): + """ + Detectify reports 2.0, 3.0 and 3.1 blocks. Finding.cvssv3 is a v3 field. + + Taking 2.0 would put a v2 vector in a v3 field, and taking 3.0 over 3.1 would report a stale + score - the fixture deliberately gives all three different values. + """ + finding = self.parse("detectify_one_vuln.json")[0] + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + + def test_a_block_with_only_a_vector_still_counts_as_present(self): + """A zero score with a vector is real data; requiring a score would discard the vector.""" + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertEqual("CVSS:3.0/AV:N/AC:H", finding.cvssv3) + self.assertIsNone(finding.cvssv3_score) + + def test_a_block_with_only_a_score_still_counts_as_present(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000006"] + self.assertEqual(3.1, finding.cvssv3_score) + self.assertIsNone(finding.cvssv3) + + def test_the_cvss_preference_directly(self): + parser = DetectifyParser() + self.assertEqual((9.8, "v31"), parser.cvss({ + "cvss_2_0": {"score": 9.0, "vector": "v20"}, + "cvss_3_0": {"score": 9.5, "vector": "v30"}, + "cvss_3_1": {"score": 9.8, "vector": "v31"}, + })) + self.assertEqual((9.5, "v30"), parser.cvss({"cvss_3_0": {"score": 9.5, "vector": "v30"}})) + # A 2.0-only report yields nothing, because it cannot go in a v3 field. + self.assertEqual((0, ""), parser.cvss({"cvss_2_0": {"score": 9.0, "vector": "v20"}})) + self.assertEqual((0, ""), parser.cvss(None)) + + def test_patched_and_false_positive_findings_are_not_imported(self): + """Importing resolved and dismissed findings would put closed work back in front of the team.""" + uids = set(self.by_uid("detectify_many_vuln.json")) + self.assertNotIn("00000000-0000-4000-8000-000000000004", uids) # patched + self.assertNotIn("00000000-0000-4000-8000-000000000005", uids) # false_positive + self.assertEqual(4, len(uids)) + + def test_an_accepted_risk_is_imported_and_flagged_rather_than_skipped(self): + """ + The connector deliberately keeps accepted risks, unlike patched and false-positive. + + Discarding them would lose the record that somebody accepted the risk. + """ + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertTrue(finding.risk_accepted) + self.assertTrue(finding.active) + + def test_an_ordinary_finding_is_not_flagged_risk_accepted(self): + finding = self.parse("detectify_one_vuln.json")[0] + self.assertFalse(finding.risk_accepted) + + def test_the_ignored_status_check_directly(self): + parser = DetectifyParser() + for status in ("patched", "false_positive", "PATCHED", " false_positive "): + self.assertTrue(parser.is_ignored({"status": status}), status) + for status in ("active", "accepted_risk", "", None): + self.assertFalse(parser.is_ignored({"status": status}), status) + + def test_information_is_one_of_detectifys_severity_spellings(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertEqual("Info", finding.severity) + + def test_the_severity_mapping_directly(self): + parser = DetectifyParser() + for raw, expected in [ + ("critical", "Critical"), ("high", "High"), ("medium", "Medium"), ("low", "Low"), + ("information", "Info"), ("info", "Info"), ("informational", "Info"), + ("CRITICAL", "Critical"), ("not-a-severity", "Info"), ("", "Info"), + ]: + finding = parser.build_finding({"uuid": "u", "severity": raw}, Test()) + self.assertEqual(expected, finding.severity, raw) + + def test_cves_are_extracted_from_prose_including_the_references(self): + """ + Detectify has no dedicated CVE field, so the connector scans several text fields. + + The fixture puts one CVE in the title and a different one in a reference name, to prove both + are picked up and that they are deduplicated in order. + """ + finding = self.parse("detectify_one_vuln.json")[0] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_a_finding_with_no_cve_anywhere_has_none(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000006"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_references_render_name_and_link_and_either_alone(self): + finding = self.parse("detectify_one_vuln.json")[0] + self.assertEqual( + "- Advisory CVE-2000-0002: https://example.com/advisories/cve-2000-0001\n" + "- https://example.com/guidance\n" + "- Vendor bulletin", + finding.references, + ) + + def test_the_mitigation_points_at_the_reference_links(self): + """Detectify supplies no remediation text, only links, so the connector points at them.""" + finding = self.parse("detectify_one_vuln.json")[0] + self.assertEqual( + "See references:\n" + "https://example.com/advisories/cve-2000-0001\n" + "https://example.com/guidance", + finding.mitigation, + ) + + def test_a_finding_with_no_references_has_no_mitigation(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertIsNone(finding.mitigation) + self.assertIsNone(finding.references) + + def test_the_request_url_is_preferred_for_the_endpoint(self): + """ + Preference order: the request URL, then host plus path, then the location alone. + + Asserted through get_unsaved_locations so this passes in both V3_FEATURE_LOCATIONS modes. + """ + finding = self.parse("detectify_one_vuln.json")[0] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual("https", locations[0].protocol) + + def test_the_host_and_path_are_combined_when_there_is_no_request(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("shop.example.com", locations[0].host) + self.assertIn("checkout", locations[0].path) + + def test_a_location_that_is_not_a_path_is_not_appended_to_the_host(self): + """ + The location is only appended when it starts with "/". + + Otherwise it is not a path and concatenating it would produce a nonsense host. + + Asserted as falsey rather than None: an unset path is "" on the URL location model + (CharField(blank=True)) and None on Endpoint, so asserting either one specifically passes + under one value of V3_FEATURE_LOCATIONS and fails under the other. + """ + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000006"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("api.example.com", locations[0].host) + self.assertFalse(locations[0].path) + + def test_a_full_url_location_is_used_when_there_is_no_host(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("legacy.example.com", locations[0].host) + + def test_a_finding_with_no_title_or_definition_is_named_from_its_uuid(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + self.assertEqual( + "Detectify finding 00000000-0000-4000-8000-000000000003", finding.title, + ) + self.assertIsNone(finding.vuln_id_from_tool) + self.assertIsNone(finding.impact) + + def test_a_finding_with_no_title_falls_back_to_the_definition_title(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertEqual("Information Disclosure", finding.title) + + def test_an_unparseable_timestamp_leaves_the_date_unset(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + self.assertIsNone(finding.date) + + def test_an_empty_tag_name_is_dropped(self): + finding = self.by_uid("detectify_many_vuln.json")["00000000-0000-4000-8000-000000000003"] + self.assertEqual([], finding.unsaved_tags) + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([{ + "uuid": "u1", "title": "A finding", "severity": "high", "status": "active", + "host": "app.example.com", + }])) + findings = list(DetectifyParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("High", findings[0].severity) + + def test_a_repeated_uuid_collapses(self): + row = {"uuid": "same", "title": "A finding", "severity": "high", "status": "active"} + report = io.StringIO(json.dumps({"vulnerabilities": [row, row]})) + self.assertEqual(1, len(list(DetectifyParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(DetectifyParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("vulnerabilities", str(raised.exception)) diff --git a/unittests/tools/test_dragos_parser.py b/unittests/tools/test_dragos_parser.py new file mode 100644 index 00000000000..7baed319014 --- /dev/null +++ b/unittests/tools/test_dragos_parser.py @@ -0,0 +1,306 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.dragos.parser import DragosParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestDragosParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("dragos") / filename + with path.open(encoding="utf-8") as file: + return list(DragosParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(DragosParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, host=None, **overrides): + vuln = {"id": "vuln-1", "title": "A finding", "severity": 3, "score": {"base": 5.0}, + "intel": {}} + vuln.update(overrides) + return {"content": [{"host": host or {"id": "asset-1", "name": "generic-plc-01"}, + "vulnerability": vuln}]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Dragos connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = DragosParser() + self.assertEqual(["Dragos Scan"], parser.get_scan_types()) + self.assertEqual("Dragos Scan", parser.get_label_for_scan_types("Dragos Scan")) + self.assertNotIn("Dragos - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("dragos_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("dragos_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("dragos_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Unauthenticated command execution on the controller", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("dragos-vuln-0001-asset-0001", finding.unique_id_from_tool) + self.assertEqual("DRA-2024-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("generic-plc-01", finding.component_name) + self.assertEqual("2.4.1", finding.component_version) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual( + ["Generic Controls", "GC-9000", "Cell Zone A", "PLC", "ot-asset", "active-exploit"], + finding.unsaved_tags, + ) + self.assertEqual( + "Restrict the control protocol to the engineering workstation segment.\n" + "Apply vendor firmware 2.4.3 at the next maintenance window.", + finding.mitigation, + ) + self.assertEqual( + "Dragos OT context: actively exploited; public proof of concept exists; " + "remotely exploitable; Dragos risk score 8.5.", + finding.severity_justification, + ) + self.assertEqual( + "**Summary:** The controller accepts control commands without authentication.\n" + "**Description:** A device on the same network segment can issue control commands.\n" + "**Dragos advisory:** DRA-2024-0001\n" + "**Asset:** generic-plc-01\n" + "**Vendor:** Generic Controls\n" + "**Model:** GC-9000\n" + "**Firmware:** 2.4.1\n" + "**Zone:** Cell Zone A\n" + "**Purdue level:** 1\n" + "**IP:** 10.10.0.11, 10.10.0.12", + finding.description, + ) + + def test_many_vuln(self): + self.assertEqual(4, len(self.parse("dragos_many_vuln.json"))) + + def test_the_cvss_score_decides_the_severity_not_dragos_own_scale(self): + """ + The fixture's first finding is scored 9.8 but carries Dragos severity 2. + + Dragos's own scale runs the other way from a score - 5 is the most severe - so reading one as + the other would inverte the whole ladder. CVSS is the portable signal, so it wins. + """ + finding = self.by_uid("dragos_many_vuln.json")["dragos-vuln-0001-asset-0001"] + self.assertEqual("Critical", finding.severity) + self.assertEqual(9.8, finding.cvssv3_score) + + def test_the_dragos_scale_is_used_only_when_there_is_no_cvss_score(self): + """Severity "4" on the 0-5 scale is High - and it arrives quoted, with a quoted "0" score.""" + finding = self.by_uid("dragos_many_vuln.json")["dragos-vuln-0002-asset-0002"] + self.assertEqual("High", finding.severity) + self.assertEqual(0.0, finding.cvssv3_score) + + def test_dragos_severity_levels(self): + """5 is the most severe on Dragos's own scale, which is the inverse of a score.""" + for level, expected in ((5, "Critical"), (5.5, "Critical"), (4, "High"), (3, "Medium"), + (2, "Low"), (1, "Info"), (0, "Info"), ("", "Info")): + with self.subTest(level=level): + findings = self.parse_string(self.row(severity=level, score={"base": 0})) + self.assertEqual(expected, findings[0].severity) + + def test_cvss_score_bands(self): + cases = ((9.0, "Critical"), (9.8, "Critical"), (7.0, "High"), (8.9, "High"), + (4.0, "Medium"), (6.9, "Medium"), (0.1, "Low"), (3.9, "Low")) + for score, expected in cases: + with self.subTest(score=score): + findings = self.parse_string(self.row(severity=0, score={"base": score})) + self.assertEqual(expected, findings[0].severity) + + def test_purdue_level_zero_is_a_real_level_and_an_absent_one_is_not_reported(self): + """ + Level 0 is the physical process layer - a real level, and the most sensitive one. + + Dragos leaves the field out when it does not know the level, so an absent level and level 0 + must not render alike. + """ + findings = self.by_uid("dragos_many_vuln.json") + self.assertIn("**Purdue level:** 0", findings["dragos-vuln-0002-asset-0002"].description) + self.assertNotIn("**Purdue level:**", findings["dragos-vuln-0003-asset-0003"].description) + + def test_the_asset_falls_back_to_hostname_then_address_then_id(self): + findings = self.by_uid("dragos_many_vuln.json") + self.assertEqual("generic-hmi-02.plant.example.com", + findings["dragos-vuln-0002-asset-0002"].component_name) + # A blank hostname is skipped, so the address is used. + self.assertEqual("10.10.0.31", findings["dragos-vuln-0003-asset-0003"].component_name) + self.assertEqual("asset-0004", findings["dragos-vuln-0004-asset-0004"].component_name) + + def test_the_hardware_vendor_is_the_fallback_for_the_asset_vendor(self): + findings = self.by_uid("dragos_many_vuln.json") + self.assertIn("**Vendor:** Generic HMI Works", + findings["dragos-vuln-0002-asset-0002"].description) + self.assertIn("Generic HMI Works", findings["dragos-vuln-0002-asset-0002"].unsaved_tags) + + def test_the_title_falls_back_to_the_advisory_then_the_internal_id(self): + findings = self.parse_string(self.row(title="", report_id="DRA-2024-0009")) + self.assertEqual("DRA-2024-0009", findings[0].title) + findings = self.parse_string(self.row(title="", report_id="")) + self.assertEqual("Dragos vulnerability vuln-1", findings[0].title) + + def test_the_vuln_id_prefers_the_dragos_advisory(self): + """The advisory is what an OT engineer looks up; the internal id is the last resort.""" + findings = self.by_uid("dragos_many_vuln.json") + self.assertEqual("DRA-2024-0001", findings["dragos-vuln-0001-asset-0001"].vuln_id_from_tool) + # No advisory and no enumeration, so the reference is used. + self.assertEqual("GHSA-aaaa-bbbb-cccc", + findings["dragos-vuln-0003-asset-0003"].vuln_id_from_tool) + self.assertEqual("vuln-0004", findings["dragos-vuln-0004-asset-0004"].vuln_id_from_tool) + + def test_advisory_ids_are_sorted_not_kept_in_the_order_they_appear(self): + """ + The connector's shared extractor SORTS its results, unlike the order-preserving call. + + The fixture's reference names CVE-2000-0002 before CVE-2000-0001, and the enumeration repeats + the second - so the result is both sorted and deduplicated. + """ + finding = self.by_uid("dragos_many_vuln.json")["dragos-vuln-0001-asset-0001"] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_identifiers_are_read_from_the_reference_enumeration_and_title(self): + for field in ("reference", "enumeration", "title"): + with self.subTest(field=field): + findings = self.parse_string(self.row(**{field: "see CVE-2000-0005 for details"})) + self.assertEqual(["CVE-2000-0005"], findings[0].unsaved_vulnerability_ids) + + def test_non_cve_advisory_formats_are_recognised(self): + for identifier in ("GHSA-aaaa-bbbb-cccc", "GO-2024-1234", "RHSA-2024:1234"): + with self.subTest(identifier=identifier): + findings = self.parse_string(self.row(reference=identifier)) + self.assertEqual([identifier], findings[0].unsaved_vulnerability_ids) + + def test_a_finding_with_no_identifier_has_none(self): + findings = self.by_uid("dragos_many_vuln.json") + self.assertIsNone(findings["dragos-vuln-0004-asset-0004"].unsaved_vulnerability_ids) + + def test_the_ot_context_is_a_justification_not_a_regrade(self): + """ + Exploitability decides whether a flaw waits for the next outage or the next window. + + It is recorded as the justification: moving the severity would make the same CVE a different + severity here than in an API sync. + """ + findings = self.by_uid("dragos_many_vuln.json") + exploited = findings["dragos-vuln-0001-asset-0001"] + self.assertEqual( + "Dragos OT context: actively exploited; remotely exploitable; Dragos risk score 8.5.", + exploited.severity_justification, + ) + self.assertEqual("Critical", exploited.severity) + + poc_only = findings["dragos-vuln-0002-asset-0002"] + self.assertEqual("Dragos OT context: public proof of concept exists; Dragos risk score 6.", + poc_only.severity_justification) + + def test_no_ot_context_leaves_the_justification_unset(self): + findings = self.by_uid("dragos_many_vuln.json") + self.assertIsNone(findings["dragos-vuln-0003-asset-0003"].severity_justification) + + def test_the_risk_score_renders_without_a_trailing_zero(self): + """The connector formats it in its shortest round-tripping form, so 6.0 is "6".""" + for score, expected in ((8.5, "8.5"), (6, "6"), (6.0, "6"), (7.25, "7.25")): + with self.subTest(score=score): + findings = self.parse_string(self.row(dragos_score=score, + intel={"active_exploit": False})) + self.assertEqual(f"Dragos OT context: Dragos risk score {expected}.", + findings[0].severity_justification) + + def test_active_exploit_is_tagged_but_the_other_flags_are_not(self): + findings = self.by_uid("dragos_many_vuln.json") + self.assertIn("active-exploit", findings["dragos-vuln-0001-asset-0001"].unsaved_tags) + self.assertNotIn("active-exploit", findings["dragos-vuln-0002-asset-0002"].unsaved_tags) + + def test_only_an_ot_asset_is_tagged_as_one(self): + findings = self.by_uid("dragos_many_vuln.json") + self.assertIn("ot-asset", findings["dragos-vuln-0001-asset-0001"].unsaved_tags) + self.assertNotIn("ot-asset", findings["dragos-vuln-0003-asset-0003"].unsaved_tags) + + def test_an_empty_mitigation_list_leaves_the_mitigation_unset(self): + findings = self.by_uid("dragos_many_vuln.json") + self.assertIsNone(findings["dragos-vuln-0002-asset-0002"].mitigation) + self.assertEqual("Segment the control network.", + findings["dragos-vuln-0001-asset-0001"].mitigation) + + def test_export_shapes(self): + detection = {"host": {"id": "asset-1", "name": "generic-plc-01"}, + "vulnerability": {"id": "vuln-1", "title": "A finding", "severity": 3, + "score": {"base": 5.0}}} + for payload in ([detection], {"content": [detection]}, {"detections": [detection]}, + {"data": [detection]}, {"results": [detection]}): + with self.subTest(shape=str(payload)[:20]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Dragos", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("content", str(context.exception)) + + def test_a_detection_missing_its_blocks_is_still_a_finding(self): + """ + A detection with no host or no vulnerability block is degenerate but not malformed. + + The connector reads both as values rather than pointers, so an absent block is an empty one + and the finding is still produced. + """ + findings = self.parse_string({"content": [{}]}) + self.assertEqual(1, len(findings)) + self.assertEqual("dragos--", findings[0].unique_id_from_tool) + self.assertEqual("Dragos vulnerability ", findings[0].title) + self.assertEqual("Info", findings[0].severity) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"content": [ + "not an object", + None, + {"host": {"id": "asset-9"}, "vulnerability": {"id": "vuln-9", "title": "A finding", + "severity": 3}}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("dragos-vuln-9-asset-9", findings[0].unique_id_from_tool) + + def test_the_identity_spans_the_flaw_and_the_asset(self): + """ + The same advisory on two devices is two findings - two devices to patch. + + In an OT estate those two may sit at different Purdue levels, which is exactly why they must + not collapse into one. + """ + self.assertEqual(["title", "severity", "component_name"], DragosParser().get_dedupe_fields()) + vuln = {"id": "vuln-1", "title": "A finding", "severity": 4, "score": {"base": 7.5}} + findings = self.parse_string({"content": [ + {"host": {"id": "asset-a", "name": "generic-plc-a"}, "vulnerability": vuln}, + {"host": {"id": "asset-b", "name": "generic-plc-b"}, "vulnerability": vuln}, + ]}) + self.assertEqual(["dragos-vuln-1-asset-a", "dragos-vuln-1-asset-b"], + [finding.unique_id_from_tool for finding in findings]) + self.assertEqual(["generic-plc-a", "generic-plc-b"], + [finding.component_name for finding in findings]) + + def test_severity_is_always_a_known_value(self): + for filename in ("dragos_many_vuln.json", "dragos_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_elastic_security_cnvm_parser.py b/unittests/tools/test_elastic_security_cnvm_parser.py new file mode 100644 index 00000000000..6ad83c78b94 --- /dev/null +++ b/unittests/tools/test_elastic_security_cnvm_parser.py @@ -0,0 +1,260 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.elastic_security_cnvm.parser import ElasticSecurityCnvmParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestElasticSecurityCnvmParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("elastic_security_cnvm") / filename + with path.open(encoding="utf-8") as file: + return list(ElasticSecurityCnvmParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(ElasticSecurityCnvmParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def document(self, source, document_id="doc-1"): + return {"hits": {"hits": [{"_id": document_id, "_source": source}]}} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Elastic Security connector's ScanTypeVulnerabilities verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = ElasticSecurityCnvmParser() + self.assertEqual(["Elastic Security:CNVM - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Elastic Security:CNVM - Connectors Import", + parser.get_label_for_scan_types("Elastic Security:CNVM - Connectors Import"), + ) + self.assertNotIn("Elastic Security:Posture - Connectors Import", parser.get_scan_types()) + self.assertNotIn("Elastic Security:Detections - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """ + An export of only posture and detection documents produces nothing here. + + Elastic returns all three kinds from the same search API, so each parser has to claim only its + own documents - otherwise a posture evaluation would be imported three times under three scan + types with three different deduplication keys. + """ + self.assertEqual(0, len(self.parse("elastic_security_cnvm_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("elastic_security_cnvm_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring vulnerabilityFinding in the connector's converter.""" + findings = self.parse("elastic_security_cnvm_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 - openssl 3.0.2 on web-node-1", finding.title) + # Elastic's own label wins over the CVSS score, which would say Critical. + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("openssl", finding.component_name) + self.assertEqual("3.0.2", finding.component_version) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("doc-cnvm-1", finding.unique_id_from_tool) + self.assertEqual("https://nvd.example.com/vuln/detail/CVE-2000-0001", finding.references) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertEqual(datetime(2024, 5, 1, tzinfo=UTC).date(), finding.publish_date) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual( + "Upgrade openssl to 3.0.13 or later, then rebuild and redeploy the affected workload image.", + finding.mitigation, + ) + self.assertEqual(["aws", "cnvm", "us-east-1", "vulnerability"], finding.unsaved_tags) + + sections = finding.description.split("\n\n") + self.assertEqual("A memory-safety flaw allows remote code execution.", sections[0]) + self.assertEqual("**Package:** openssl 3.0.2 (deb)", sections[1]) + self.assertEqual("**Resource:** web-node-1 (instance/ec2)", sections[2]) + self.assertEqual("**Host:** web-node-1", sections[3]) + self.assertEqual("**OS:** Ubuntu 22.04.4 LTS", sections[4]) + self.assertEqual("**Cloud:** aws, account example-account, us-east-1", sections[5]) + self.assertEqual("**CVSS:** 9.8 (v3.1)", sections[6]) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("web-node-1", locations[0].host) + + def test_many_vuln(self): + """Only CNVM documents, and only those Elastic attached to a CVE.""" + self.assertEqual(5, len(self.parse("elastic_security_cnvm_many_vuln.json"))) + + def test_a_document_with_no_cve_id_is_skipped(self): + findings = self.by_uid("elastic_security_cnvm_many_vuln.json") + self.assertNotIn("doc-cnvm-not-a-vulnerability", findings) + + def test_an_unrecognised_label_falls_back_to_the_cvss_score(self): + """ + A CNVM document has a score to fall back on, unlike the other two scan types. + + Elastic's vulnerability severity is free text in practice, so an unknown label is graded from + the score rather than guessed at from the wording. + """ + findings = self.by_uid("elastic_security_cnvm_many_vuln.json") + self.assertEqual("High", findings["doc-cnvm-2"].severity) + self.assertEqual("Medium", findings["doc-cnvm-3"].severity) + + def test_an_unrecognised_label_with_no_score_is_info(self): + findings = self.by_uid("elastic_security_cnvm_many_vuln.json") + self.assertEqual("Info", findings["doc-cnvm-4"].severity) + self.assertIsNone(findings["doc-cnvm-4"].cvssv3_score) + + def test_severity_labels(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("informational", "Info"), ("info", "Info"), + ("none", "Info"), ("unknown", "Info"), ("CRITICAL", "Critical")): + with self.subTest(label=label): + findings = self.parse_string(self.document({ + "vulnerability": {"id": "CVE-2000-0001", "severity": label}, + })) + self.assertEqual(expected, findings[0].severity) + + def test_severity_floors_when_the_label_is_unusable(self): + for base, expected in ((10.0, "Critical"), (9.0, "Critical"), (8.9, "High"), (7.0, "High"), + (6.9, "Medium"), (4.0, "Medium"), (3.9, "Low"), (0.1, "Low"), + (0, "Info")): + with self.subTest(base=base): + findings = self.parse_string(self.document({ + "vulnerability": {"id": "CVE-2000-0001", "severity": "", "score": {"base": base, "version": "3.1"}}, + })) + self.assertEqual(expected, findings[0].severity) + + def test_only_a_v3_score_reaches_the_cvssv3_field(self): + """ + Elastic also reports CVSS v2 bases, which do not belong in a v3 field. + + The score still appears in the description with its version, so nothing is lost. + """ + findings = self.by_uid("elastic_security_cnvm_many_vuln.json") + self.assertIsNone(findings["doc-cnvm-3"].cvssv3_score) + self.assertIn("**CVSS:** 5 (v2.0)", findings["doc-cnvm-3"].description) + self.assertEqual(7.5, findings["doc-cnvm-2"].cvssv3_score) + self.assertIn("**CVSS:** 7.5 (v3.0)", findings["doc-cnvm-2"].description) + + def test_a_score_may_arrive_as_a_string(self): + findings = self.parse_string(self.document({ + "vulnerability": {"id": "CVE-2000-0001", "severity": "", "score": {"base": "8.1", "version": "3.1"}}, + })) + self.assertEqual("High", findings[0].severity) + self.assertEqual(8.1, findings[0].cvssv3_score) + + def test_asset_name_falls_back_from_resource_to_pod_to_host(self): + cases = ( + ({"resource": {"name": "resource"}, "kubernetes": {"pod": {"name": "pod"}}, + "host": {"name": "host"}}, "resource"), + ({"kubernetes": {"pod": {"name": "pod"}}, "host": {"name": "host"}}, "pod"), + ({"host": {"name": "host"}}, "host"), + ({"host": {"hostname": "hostname"}}, "hostname"), + ({}, ""), + ) + for source, expected in cases: + with self.subTest(expected=expected): + self.assertEqual(expected, ElasticSecurityCnvmParser().asset_name(source)) + + def test_an_asset_name_that_cannot_be_a_host_records_no_endpoint(self): + """ + A cloud resource name is not always host-shaped - a bucket path or an ARN is not. + + DefectDojo's host field would reject it, and a ValidationError fails the whole import rather + than the one finding, so the endpoint is dropped. The name is still in the description's + Resource line. + """ + findings = self.parse_string(self.document({ + "vulnerability": {"id": "CVE-2000-0001", "severity": "low"}, + "resource": {"name": "projects/example/buckets/example-bucket", "type": "gcs"}, + })) + self.assertEqual([], self.get_unsaved_locations(findings[0])) + self.assertIn("**Resource:** projects/example/buckets/example-bucket (gcs)", findings[0].description) + + def test_the_endpoint_prefers_the_host_over_the_resource(self): + """ + The finding is about a machine, so the endpoint is the host identity. + + Only when there is no host does the resource or pod name stand in - a bucket has no hostname + but is still worth recording. + """ + findings = self.by_uid("elastic_security_cnvm_many_vuln.json") + self.assertEqual("web-node-1", self.get_unsaved_locations(findings["doc-cnvm-1"])[0].host) + self.assertEqual("api-7c9f-abcde", self.get_unsaved_locations(findings["doc-cnvm-4"])[0].host) + + def test_kubernetes_and_cluster_context(self): + finding = self.by_uid("elastic_security_cnvm_many_vuln.json")["doc-cnvm-4"] + self.assertIn("**Cluster:** example-cluster, namespace payments", finding.description) + self.assertIn("cluster:example-cluster", finding.unsaved_tags) + + def test_the_document_id_is_the_identity(self): + """ + Elasticsearch document ids are stable across syncs, so they are the identity. + + Only a hand-assembled export lacks one; then the asset, CVE and package stand in, which still + keeps the same CVE on two assets apart. + """ + findings = self.parse("elastic_security_cnvm_many_vuln.json") + by_uid = {finding.unique_id_from_tool for finding in findings} + self.assertIn("doc-cnvm-1", by_uid) + self.assertIn("batch-node-2:CVE-2000-0005:curl:7.81.0", by_uid) + + def test_mitigation_without_a_fixed_version(self): + finding = self.by_uid("elastic_security_cnvm_many_vuln.json")["doc-cnvm-2"] + self.assertIn("No fixed version is published for this CVE yet", finding.mitigation) + + def test_tags_are_sorted_and_deduplicated(self): + """ + The connector sorts and deduplicates its tags, so a reimport does not look like a change. + + Worth asserting rather than assuming: an unordered tag list is a diff on every sync. + """ + for finding in self.parse("elastic_security_cnvm_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertEqual(sorted(set(finding.unsaved_tags)), finding.unsaved_tags) + self.assertIn("cnvm", finding.unsaved_tags) + self.assertIn("vulnerability", finding.unsaved_tags) + + def test_export_shapes(self): + """A search response, a bare array of documents and a single document are all accepted.""" + doc = {"_id": "doc-1", "_source": {"vulnerability": {"id": "CVE-2000-0001", "severity": "low"}}} + for payload in ({"hits": {"hits": [doc]}}, [doc], doc, {"hits": [doc]}): + with self.subTest(shape=type(payload).__name__ + str(list(payload)[:1])): + findings = self.parse_string(payload) + self.assertEqual(1, len(findings)) + self.assertEqual("CVE-2000-0001", findings[0].title) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Elastic Security", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("hits", str(context.exception)) + + def test_malformed_documents_are_skipped(self): + findings = self.parse_string({"hits": {"hits": [ + "not an object", + None, + {"_id": "no-source"}, + {"_id": "doc-1", "_source": {"vulnerability": {"id": "CVE-2000-0001", "severity": "low"}}}, + ]}}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for finding in self.parse("elastic_security_cnvm_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_elastic_security_detections_parser.py b/unittests/tools/test_elastic_security_detections_parser.py new file mode 100644 index 00000000000..33a69f233a5 --- /dev/null +++ b/unittests/tools/test_elastic_security_detections_parser.py @@ -0,0 +1,212 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.elastic_security_detections.parser import ElasticSecurityDetectionsParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestElasticSecurityDetectionsParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("elastic_security_detections") / filename + with path.open(encoding="utf-8") as file: + return list(ElasticSecurityDetectionsParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(ElasticSecurityDetectionsParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def document(self, source, document_id="doc-1"): + return {"hits": {"hits": [{"_id": document_id, "_source": source}]}} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Elastic Security connector's ScanTypeDetections verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every alert. + """ + parser = ElasticSecurityDetectionsParser() + self.assertEqual(["Elastic Security:Detections - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Elastic Security:Detections - Connectors Import", + parser.get_label_for_scan_types("Elastic Security:Detections - Connectors Import"), + ) + self.assertNotIn("Elastic Security:CNVM - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """ + An export of only CNVM and posture documents produces nothing here. + + Elastic returns all three kinds from the same search API, so each parser has to claim only its + own documents - otherwise one document would be imported three times under three scan types + with three different deduplication keys. + """ + self.assertEqual(0, len(self.parse("elastic_security_detections_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("elastic_security_detections_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring detectionFinding in the connector's converter.""" + findings = self.parse("elastic_security_detections_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Malware Detection Alert", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("doc-detection-1", finding.unique_id_from_tool) + self.assertEqual("rule-uuid-0001", finding.vuln_id_from_tool) + self.assertEqual(datetime(2024, 7, 2, tzinfo=UTC).date(), finding.date) + self.assertEqual( + "https://docs.example.com/rules/malware\nhttps://attack.example.com/techniques/T0000", + finding.references, + ) + # A detection is observed activity: neither a static nor a dynamic test found it. + self.assertFalse(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual( + ["Elastic", "Endpoint Security", "alert", "aws", "detection", "endpoint", "malware", + "process", "us-east-1"], + finding.unsaved_tags, + ) + + sections = finding.description.split("\n\n") + self.assertEqual("malware detected on web-node-1", sections[0]) + self.assertEqual("Detects known malware signatures on an endpoint.", sections[1]) + self.assertEqual("**Message:** Suspicious process started", sections[2]) + self.assertEqual("**Host:** web-node-1", sections[3]) + self.assertEqual("**OS:** Ubuntu 22.04.4 LTS", sections[4]) + self.assertEqual("**Cloud:** aws, account example-account, us-east-1", sections[5]) + self.assertEqual("**Risk score:** 99 (Elastic's 0-100 scale)", sections[6]) + self.assertEqual("**Workflow status in Elastic:** open", sections[7]) + self.assertEqual("**Event category:** process, malware", sections[8]) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("web-node-1", locations[0].host) + + def test_mitigation_is_a_triage_instruction(self): + """ + A detection is not a defect with a patch. + + Saying so keeps a triage queue from being read as a remediation backlog, which is why the + connector writes the same instruction on every detection. + """ + finding = self.parse("elastic_security_detections_one_vuln.json")[0] + self.assertIn("Triage this detection in Elastic Security.", finding.mitigation) + self.assertIn("rather than a fixable defect", finding.mitigation) + self.assertIn("not shipping a patch", finding.mitigation) + + def test_many_vuln(self): + """Only alerts with something to say.""" + self.assertEqual(2, len(self.parse("elastic_security_detections_many_vuln.json"))) + + def test_an_alert_with_no_rule_name_or_reason_is_skipped(self): + findings = self.by_uid("elastic_security_detections_many_vuln.json") + self.assertNotIn("doc-detection-3", findings) + + def test_the_legacy_signal_object_is_accepted(self): + """ + Current Elastic nests the alert under kibana.alert; the older engine wrote a top-level signal. + + An export taken from an existing index may still carry either, and the connector reads both. + """ + finding = self.by_uid("elastic_security_detections_many_vuln.json")["doc-detection-2"] + self.assertEqual("unusual outbound connection from batch-node-2", finding.title) + self.assertEqual("rule-uuid-0002", finding.vuln_id_from_tool) + + def test_title_falls_back_to_the_alert_reason(self): + findings = self.parse_string(self.document({ + "kibana": {"alert": {"uuid": "a1", "reason": "something happened", "severity": "low"}}, + })) + self.assertEqual("something happened", findings[0].title) + + def test_severity_comes_from_the_alert_then_the_rule(self): + cases = ( + ({"severity": "high", "rule": {"severity": "low"}}, "High"), + ({"rule": {"severity": "low"}}, "Low"), + ({"severity": "critical"}, "Critical"), + ) + for alert, expected in cases: + with self.subTest(alert=alert): + findings = self.parse_string(self.document({ + "kibana": {"alert": {"uuid": "a1", "reason": "an alert", **alert}}, + })) + self.assertEqual(expected, findings[0].severity) + + def test_an_unrecognised_label_is_medium(self): + """ + A detection has no score to fall back on, so the connector defaults to Medium. + + Elastic's risk score is a 0-100 scale, not a severity, so it is reported in the description + rather than being converted into one. + """ + findings = self.by_uid("elastic_security_detections_many_vuln.json") + self.assertEqual("Medium", findings["doc-detection-2"].severity) + + for label in ("", "not a label", None): + with self.subTest(label=label): + parsed = self.parse_string(self.document({ + "kibana": {"alert": {"uuid": "a1", "reason": "an alert", "severity": label}}, + })) + self.assertEqual("Medium", parsed[0].severity) + + def test_risk_score_comes_from_the_alert_then_the_rule(self): + finding = self.by_uid("elastic_security_detections_many_vuln.json")["doc-detection-2"] + self.assertIn("**Risk score:** 47 (Elastic's 0-100 scale)", finding.description) + + findings = self.parse_string(self.document({ + "kibana": {"alert": {"uuid": "a1", "reason": "an alert", "risk_score": 21.5, + "rule": {"risk_score": 99}}}, + })) + self.assertIn("**Risk score:** 21.5 (Elastic's 0-100 scale)", findings[0].description) + + def test_no_risk_score_line_when_there_is_no_score(self): + findings = self.parse_string(self.document({ + "kibana": {"alert": {"uuid": "a1", "reason": "an alert", "severity": "low"}}, + })) + self.assertNotIn("**Risk score:**", findings[0].description) + + def test_the_document_id_is_the_identity_and_the_alert_uuid_is_the_fallback(self): + findings = self.parse_string({"hits": {"hits": [ + {"_source": {"kibana": {"alert": {"uuid": "alert-9", "reason": "an alert"}}}}, + ]}}) + self.assertEqual("alert-9", findings[0].unique_id_from_tool) + + def test_tags_are_sorted_and_deduplicated(self): + for finding in self.parse("elastic_security_detections_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertEqual(sorted(set(finding.unsaved_tags)), finding.unsaved_tags) + self.assertIn("detection", finding.unsaved_tags) + self.assertIn("alert", finding.unsaved_tags) + + def test_export_shapes(self): + doc = {"_id": "doc-1", "_source": {"kibana": {"alert": {"uuid": "a1", "reason": "an alert"}}}} + for payload in ({"hits": {"hits": [doc]}}, [doc], doc): + with self.subTest(shape=type(payload).__name__): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Elastic Security", str(context.exception)) + + def test_malformed_documents_are_skipped(self): + findings = self.parse_string({"hits": {"hits": [ + "not an object", + None, + {"_id": "no-source"}, + {"_id": "no-alert", "_source": {"message": "just a log line"}}, + {"_id": "doc-1", "_source": {"kibana": {"alert": {"uuid": "a1", "reason": "an alert"}}}}, + ]}}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for finding in self.parse("elastic_security_detections_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_elastic_security_posture_parser.py b/unittests/tools/test_elastic_security_posture_parser.py new file mode 100644 index 00000000000..606f2753f0a --- /dev/null +++ b/unittests/tools/test_elastic_security_posture_parser.py @@ -0,0 +1,240 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.elastic_security_posture.parser import ElasticSecurityPostureParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestElasticSecurityPostureParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("elastic_security_posture") / filename + with path.open(encoding="utf-8") as file: + return list(ElasticSecurityPostureParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(ElasticSecurityPostureParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def document(self, source, document_id="doc-1"): + return {"hits": {"hits": [{"_id": document_id, "_source": source}]}} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Elastic Security connector's ScanTypePosture verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every failing rule. + """ + parser = ElasticSecurityPostureParser() + self.assertEqual(["Elastic Security:Posture - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Elastic Security:Posture - Connectors Import", + parser.get_label_for_scan_types("Elastic Security:Posture - Connectors Import"), + ) + self.assertNotIn("Elastic Security:CNVM - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """ + An export of only CNVM and detection documents produces nothing here. + + Elastic returns all three kinds from the same search API, so each parser has to claim only its + own documents - otherwise one document would be imported three times under three scan types + with three different deduplication keys. + """ + self.assertEqual(0, len(self.parse("elastic_security_posture_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("elastic_security_posture_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring postureFinding in the connector's converter.""" + findings = self.parse("elastic_security_posture_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Ensure the audit log is enabled", finding.title) + self.assertEqual("Medium", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("doc-posture-1", finding.unique_id_from_tool) + self.assertEqual("rule-0001", finding.vuln_id_from_tool) + self.assertEqual("CIS Kubernetes V1.23", finding.component_name) + self.assertEqual("v1.0.1", finding.component_version) + self.assertEqual("Enable the audit log in the cluster configuration.", finding.mitigation) + self.assertEqual("https://docs.example.com/cis/3.2.1", finding.references) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual( + ["CIS", "CIS 3.2.1", "CIS Kubernetes V1.23", "Kubernetes", "cluster:example-cluster", + "compliance", "configuration", "kspm", "posture"], + finding.unsaved_tags, + ) + + sections = finding.description.split("\n\n") + self.assertEqual("An audit log records administrative actions for later review.", sections[0]) + self.assertEqual("Checks the API server's audit-log configuration.", sections[1]) + self.assertEqual("This benchmark rule **failed** evaluation.", sections[2]) + self.assertEqual("**Benchmark:** CIS Kubernetes V1.23 v1.0.1, rule 3.2.1", sections[3]) + self.assertEqual("**Section:** Logging", sections[4]) + self.assertEqual("**Host:** control-plane-1", sections[5]) + self.assertEqual("**OS:** Ubuntu 22.04.4 LTS", sections[6]) + self.assertEqual("**Cluster:** example-cluster, namespace kube-system", sections[7]) + self.assertEqual("**Impact of remediation:** Log volume increases.", sections[8]) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("control-plane-1", locations[0].host) + + def test_many_vuln(self): + """Only failing, named posture rules.""" + self.assertEqual(2, len(self.parse("elastic_security_posture_many_vuln.json"))) + + def test_only_failed_evaluations_are_imported(self): + """ + Elastic writes a document for every evaluation, passed or failed. + + A passing rule is not a finding, and a rule with no name has nothing to report. + """ + findings = self.by_uid("elastic_security_posture_many_vuln.json") + self.assertIn("doc-posture-1", findings) + self.assertIn("doc-posture-2", findings) + self.assertNotIn("doc-posture-passed", findings) + self.assertNotIn("doc-posture-unnamed", findings) + + def test_evaluation_is_read_case_insensitively(self): + """The second document reports "FAILED".""" + self.assertIn("doc-posture-2", self.by_uid("elastic_security_posture_many_vuln.json")) + + def test_severity_labels(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("informational", "Info"), ("unknown", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.document({ + "rule": {"id": "r1", "name": "A rule", "severity": label}, + "result": {"evaluation": "failed"}, + })) + self.assertEqual(expected, findings[0].severity) + + def test_an_unrecognised_label_is_medium(self): + """ + A posture document has no score to fall back on, so the connector defaults to Medium. + + Info would be wrong - a failing benchmark rule is a real finding whatever Elastic called its + severity. + """ + findings = self.by_uid("elastic_security_posture_many_vuln.json") + self.assertEqual("Medium", findings["doc-posture-2"].severity) + + for label in ("", "not a label", None): + with self.subTest(label=label): + parsed = self.parse_string(self.document({ + "rule": {"id": "r1", "name": "A rule", "severity": label}, + "result": {"evaluation": "failed"}, + })) + self.assertEqual("Medium", parsed[0].severity) + + def test_a_repeated_rationale_is_not_printed_twice(self): + """ + Elastic often copies the rationale into the description. + + Printing the same paragraph twice reads as a rendering error, so the connector drops the + duplicate and so does this parser. + """ + finding = self.by_uid("elastic_security_posture_many_vuln.json")["doc-posture-2"] + sections = finding.description.split("\n\n") + self.assertEqual("The same text as the rationale.", sections[0]) + self.assertEqual("This benchmark rule **failed** evaluation.", sections[1]) + self.assertEqual(1, finding.description.count("The same text as the rationale.")) + + def test_rule_id_falls_back_to_the_benchmark_numbering(self): + """ + The rule id is what this scan type's deduplication hash keys on. + + Elastic's cloud benchmarks do not always carry one, so the benchmark's own numbering stands in + before the rule name does. + """ + findings = self.by_uid("elastic_security_posture_many_vuln.json") + self.assertEqual("cis_aws:2.1.1", findings["doc-posture-2"].vuln_id_from_tool) + + by_name = self.parse_string(self.document({ + "rule": {"name": "A rule with nothing else", "severity": "low"}, + "result": {"evaluation": "failed"}, + })) + self.assertEqual("A rule with nothing else", by_name[0].vuln_id_from_tool) + + def test_a_cloud_resource_is_the_asset_when_there_is_no_host(self): + finding = self.by_uid("elastic_security_posture_many_vuln.json")["doc-posture-2"] + self.assertIn("**Resource:** example-bucket (s3/bucket)", finding.description) + self.assertIn("**Cloud:** aws, account example-account, us-east-1", finding.description) + self.assertEqual("example-bucket", self.get_unsaved_locations(finding)[0].host) + + def test_the_benchmark_is_the_component(self): + findings = self.by_uid("elastic_security_posture_many_vuln.json") + self.assertEqual("CIS AWS Foundations", findings["doc-posture-2"].component_name) + self.assertEqual("v1.5.0", findings["doc-posture-2"].component_version) + + def test_a_rule_with_no_benchmark(self): + findings = self.parse_string(self.document({ + "rule": {"id": "r1", "name": "A rule", "severity": "high"}, + "result": {"evaluation": "failed"}, + })) + self.assertIsNone(findings[0].component_name) + self.assertNotIn("**Benchmark:**", findings[0].description) + self.assertEqual(["compliance", "configuration", "posture"], findings[0].unsaved_tags) + + def test_the_document_id_is_the_identity(self): + """ + Elasticsearch document ids are stable across syncs, so they are the identity. + + Only a hand-assembled export lacks one; then the asset and the rule stand in, which keeps the + same rule failing on two assets apart. + """ + findings = self.parse_string({"hits": {"hits": [ + {"_source": {"rule": {"id": "r1", "name": "A rule"}, "result": {"evaluation": "failed"}, + "host": {"name": "host-a"}}}, + {"_source": {"rule": {"id": "r1", "name": "A rule"}, "result": {"evaluation": "failed"}, + "host": {"name": "host-b"}}}, + ]}}) + self.assertEqual( + {"host-a:r1:A rule", "host-b:r1:A rule"}, + {finding.unique_id_from_tool for finding in findings}, + ) + + def test_tags_are_sorted_and_deduplicated(self): + for finding in self.parse("elastic_security_posture_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertEqual(sorted(set(finding.unsaved_tags)), finding.unsaved_tags) + self.assertIn("posture", finding.unsaved_tags) + self.assertIn("compliance", finding.unsaved_tags) + + def test_export_shapes(self): + doc = {"_id": "doc-1", "_source": {"rule": {"id": "r1", "name": "A rule", "severity": "low"}, + "result": {"evaluation": "failed"}}} + for payload in ({"hits": {"hits": [doc]}}, [doc], doc): + with self.subTest(shape=type(payload).__name__): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Elastic Security", str(context.exception)) + + def test_malformed_documents_are_skipped(self): + findings = self.parse_string({"hits": {"hits": [ + "not an object", + None, + {"_id": "no-source"}, + {"_id": "doc-1", "_source": {"rule": {"id": "r1", "name": "A rule"}, + "result": {"evaluation": "failed"}}}, + ]}}) + self.assertEqual(1, len(findings)) + + def test_a_document_with_no_result_block_is_skipped(self): + """No evaluation means Elastic has not scored the rule against this asset.""" + self.assertEqual(0, len(self.parse_string(self.document({ + "rule": {"id": "r1", "name": "A rule", "severity": "high"}, + })))) diff --git a/unittests/tools/test_endorlabs_parser.py b/unittests/tools/test_endorlabs_parser.py new file mode 100644 index 00000000000..72b2d11fd1f --- /dev/null +++ b/unittests/tools/test_endorlabs_parser.py @@ -0,0 +1,230 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.endorlabs.parser import EndorLabsParser, inert_text +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestEndorLabsParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("endorlabs") / filename).open(encoding="utf-8") as file: + return list(EndorLabsParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Endor Labs connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = EndorLabsParser() + self.assertEqual(["Endor Labs - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Endor Labs - Connectors Import", + parser.get_label_for_scan_types("Endor Labs - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("endorlabs_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("endorlabs_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring FindingConverter.Convert in the connector.""" + findings = self.parse("endorlabs_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 in generic-lib", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("generic-lib", finding.component_name) + self.assertEqual("1.2.3", finding.component_version) + self.assertEqual("00000000-0000-4000-8000-000000000001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + def test_reachability_is_the_finding_impact(self): + """ + Reachability is Endor's distinguishing signal, so the connector promotes it to impact. + + Leaving it only in tags would hide the one thing that separates Endor from any other SCA + tool: whether the vulnerable code is actually called. + """ + finding = self.parse("endorlabs_one_vuln.json")[0] + self.assertEqual("Reachable (vulnerable function is called)", finding.impact) + self.assertIn("**Reachability**: Reachable (vulnerable function is called)", + finding.description) + + def test_the_reachability_precedence_is_the_connectors(self): + """ + A function verdict outranks a dependency verdict, and a definite verdict outranks a maybe. + + Getting the order wrong would report "potentially reachable" for a finding Endor confirmed + reachable. + """ + parser = EndorLabsParser() + self.assertEqual( + "Reachable (vulnerable function is called)", + parser.reachability_summary([ + "FINDING_TAGS_POTENTIALLY_REACHABLE_FUNCTION", + "FINDING_TAGS_REACHABLE_DEPENDENCY", + "FINDING_TAGS_REACHABLE_FUNCTION", + ]), + ) + self.assertEqual( + "Unreachable (vulnerable function is not called)", + parser.reachability_summary([ + "FINDING_TAGS_UNREACHABLE_FUNCTION", "FINDING_TAGS_REACHABLE_DEPENDENCY", + ]), + ) + self.assertEqual( + "Reachable (dependency is used)", + parser.reachability_summary(["FINDING_TAGS_REACHABLE_DEPENDENCY"]), + ) + self.assertEqual("", parser.reachability_summary(["FINDING_TAGS_DIRECT"])) + + def test_all_vulnerability_ids_are_imported_with_the_primary_first(self): + """The primary identifier then Endor's aliases, deduplicated - the alias repeats the CVE.""" + finding = self.parse("endorlabs_one_vuln.json")[0] + self.assertEqual( + ["CVE-2000-0001", "GHSA-0000-0000-0001"], finding.unsaved_vulnerability_ids, + ) + + def test_the_description_is_assembled_in_the_converters_order(self): + finding = self.parse("endorlabs_one_vuln.json")[0] + self.assertIn("generic-lib is vulnerable to remote code execution.", finding.description) + self.assertIn("**Explanation**:", finding.description) + self.assertIn("**Vulnerability**: Remote code execution in generic-lib.", + finding.description) + self.assertIn("**EPSS probability**: 0.0423", finding.description) + self.assertIn("**References**:\n- https://example.com/advisories/cve-2000-0001", + finding.description) + self.assertLess( + finding.description.index("**Explanation**:"), + finding.description.index("**Reachability**:"), + ) + self.assertLess( + finding.description.index("**EPSS probability**:"), + finding.description.index("**References**:"), + ) + + def test_html_in_advisory_text_is_flattened_not_rendered(self): + """ + Endor advisory text arrives as HTML, sourced from upstream advisories. + + The connector's InertText drops script and style content, turns block tags into newlines and + escapes the result, so nothing can be injected into a rendered finding. + """ + finding = self.parse("endorlabs_one_vuln.json")[0] + self.assertIn("The parse() helper evaluates untrusted input.", finding.description) + # The script content is dropped entirely, and no raw markup survives. + self.assertNotIn("alert(", finding.description) + self.assertNotIn("<script", finding.description) + self.assertNotIn("<b>", finding.description) + self.assertNotIn("<p>", finding.description) + + def test_the_inert_text_helper_directly(self): + """Mirrors the connector's InertText, including Go's apostrophe entity.""" + self.assertEqual("plain", inert_text("plain")) + # Both the open and close tag emit a newline, so a paragraph break survives as a blank line. + self.assertEqual("a\n\nb", inert_text("<p>a</p><p>b</p>")) + # A single block boundary leaves one newline. + self.assertEqual("a\nb", inert_text("a<br>b")) + self.assertEqual("kept", inert_text("<script>dropped()</script>kept")) + self.assertEqual("kept", inert_text("<style>.x{}</style>kept")) + # Go's html.EscapeString spells these entities this way. + self.assertEqual("<b>", inert_text("<b>")) + self.assertEqual("a & b", inert_text("a & b")) + self.assertEqual("'quoted'", inert_text("'quoted'")) + self.assertEqual(""quoted"", inert_text('"quoted"')) + # Runs of blank lines collapse, and trailing blanks are dropped. + self.assertEqual("a\n\nb", inert_text("a<p></p><p></p>b<br><br>")) + self.assertEqual("", inert_text("")) + + def test_the_cvss_score_falls_back_to_the_v4_base_score(self): + """ + Endor publishes both v3 and v4, and the connector prefers v3. + + A v4-only advisory would otherwise import with no score at all. + """ + finding = self.by_uid("endorlabs_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertEqual(7.7, finding.cvssv3_score) + # No v3 block, so no vector. + self.assertIsNone(finding.cvssv3) + + def test_the_component_falls_back_to_the_package_name(self): + finding = self.by_uid("endorlabs_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertEqual("pypi://generic-parser", finding.component_name) + self.assertEqual("3.1.0", finding.component_version) + + def test_a_finding_with_no_name_is_titled_from_the_vulnerability_and_component(self): + finding = self.by_uid("endorlabs_many_vuln.json")["00000000-0000-4000-8000-000000000002"] + self.assertEqual("GHSA-0000-0000-0002 in pypi://generic-parser:3.1.0", finding.title) + + def test_a_finding_with_no_uuid_falls_back_to_a_composite_id(self): + """The converter's uniqueID(): "<vuln id>|<component>:<version>" when Endor sends no UUID.""" + uids = set(self.by_uid("endorlabs_many_vuln.json")) + self.assertIn("CVE-2000-0003|generic-widget:4.0.0", uids) + + def test_an_unrecognised_level_is_clamped_to_info(self): + finding = self.by_uid("endorlabs_many_vuln.json")["00000000-0000-4000-8000-000000000004"] + self.assertEqual("Info", finding.severity) + + def test_tags_are_humanised_and_the_unspecified_placeholders_dropped(self): + """ + The enum prefix is stripped, the rest lower-cased and hyphenated. + + A tag ending _UNSPECIFIED only says Endor did not determine it, so it carries no information + and is dropped rather than imported as a meaningless label. + """ + finding = self.parse("endorlabs_one_vuln.json")[0] + self.assertEqual( + ["reachable-function", "direct", "fix-available", "vulnerability", "sca"], + finding.unsaved_tags, + ) + self.assertNotIn("unspecified", finding.unsaved_tags) + + other = self.by_uid("endorlabs_many_vuln.json")["00000000-0000-4000-8000-000000000004"] + self.assertEqual(["secrets"], other.unsaved_tags) + + def test_a_finding_with_no_vulnerability_still_imports(self): + """Endor reports secrets and other non-CVE findings with no vulnerability block at all.""" + finding = self.by_uid("endorlabs_many_vuln.json")["00000000-0000-4000-8000-000000000004"] + self.assertEqual("Secret committed in generic-app", finding.title) + self.assertIsNone(finding.vuln_id_from_tool) + self.assertIsNone(finding.unsaved_vulnerability_ids) + self.assertIsNone(finding.cvssv3_score) + self.assertIsNone(finding.impact) + + def test_remediation_becomes_the_mitigation(self): + finding = self.parse("endorlabs_one_vuln.json")[0] + self.assertEqual("Upgrade generic-lib to 1.2.4 or later.", finding.mitigation) + + def test_a_bare_objects_envelope_and_a_bare_array_are_accepted(self): + row = {"uuid": "u1", "meta": {"name": "A finding"}, + "spec": {"level": "FINDING_LEVEL_LOW"}} + for payload in ({"objects": [row]}, [row]): + report = io.StringIO(json.dumps(payload)) + findings = list(EndorLabsParser().get_findings(report, Test())) + self.assertEqual(1, len(findings), payload) + self.assertEqual("Low", findings[0].severity) + + def test_a_repeated_uuid_collapses(self): + row = {"uuid": "same", "meta": {"name": "A finding"}, + "spec": {"level": "FINDING_LEVEL_LOW"}} + report = io.StringIO(json.dumps({"list": {"objects": [row, row]}})) + self.assertEqual(1, len(list(EndorLabsParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(EndorLabsParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("objects", str(raised.exception)) diff --git a/unittests/tools/test_escape_parser.py b/unittests/tools/test_escape_parser.py new file mode 100644 index 00000000000..fd199adf012 --- /dev/null +++ b/unittests/tools/test_escape_parser.py @@ -0,0 +1,209 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.escape.parser import EscapeParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestEscapeParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("escape") / filename).open(encoding="utf-8") as file: + return list(EscapeParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(EscapeParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Escape connector's ScanTypeName verbatim. + + Any drift and someone who uploads an export and also syncs the API gets two un-deduplicated + copies of every issue. + """ + parser = EscapeParser() + self.assertEqual(["Escape - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Escape - Connectors Import", + parser.get_label_for_scan_types("Escape - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("escape_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("escape_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring IssueToFinding in the connector's converter.""" + findings = self.parse("escape_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("SQL injection in the reports query", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("issue-0001", finding.unique_id_from_tool) + self.assertEqual(89, finding.cwe) + self.assertEqual("Use parameterised queries.", finding.mitigation) + self.assertTrue(finding.active) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertEqual(["owasp:API8:2023", "method:POST"], finding.unsaved_tags) + + self.assertEqual( + "A request parameter is concatenated into a database query.\n\n" + "**Endpoint:** post https://api.example.com/v1/reports\n" + "**OWASP:** API8:2023\n" + "**CWE:** CWE-89", + finding.description, + ) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("api.example.com", locations[0].host) + self.assertEqual("v1/reports", locations[0].path) + self.assertEqual("https", locations[0].protocol) + + def test_the_application_shape_is_accepted(self): + """ + The connector reads an application's latest scan, so an export is often the application. + + A bare scan, an application carrying one, an applications list and the issue list itself all + have to work. + """ + issue = {"id": "issue-1", "name": "An issue", "severity": "low", + "url": "https://api.example.com/v1/thing"} + shapes = ( + {"issues": [issue]}, + {"lastScan": {"issues": [issue]}}, + {"scan": {"issues": [issue]}}, + {"applications": [{"id": "app-1", "lastScan": {"issues": [issue]}}]}, + [issue], + ) + for payload in shapes: + with self.subTest(shape=type(payload).__name__ + str(list(payload)[:1])): + findings = self.parse_string(payload) + self.assertEqual(1, len(findings)) + self.assertEqual("An issue", findings[0].title) + + def test_many_vuln(self): + self.assertEqual(5, len(self.parse("escape_many_vuln.json"))) + + def test_severity_labels(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("info", "Info"), ("HIGH", "High"), + ("not a label", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string({"issues": [ + {"id": "issue-1", "name": "An issue", "severity": label}, + ]}) + self.assertEqual(expected, findings[0].severity) + + def test_an_unrecognised_label_is_info(self): + finding = self.by_uid("escape_many_vuln.json")["issue-0004"] + self.assertEqual("Info", finding.severity) + + def test_title_falls_back_to_the_issue_id(self): + finding = self.by_uid("escape_many_vuln.json")["issue-0004"] + self.assertEqual("Escape issue issue-0004", finding.title) + + def test_the_endpoint_line_carries_the_method(self): + """ + The same URL behaves differently per verb, which is the whole point of an API scanner. + + When Escape reports no method the line is the URL alone. + """ + findings = self.by_uid("escape_many_vuln.json") + self.assertIn( + "**Endpoint:** post https://api.example.com/v1/reports", + findings["issue-0001"].description, + ) + self.assertIn( + "**Endpoint:** https://api.example.com:8443/v1/search?q=1", + findings["issue-0003"].description, + ) + + def test_cwe_forms(self): + for value, expected in (("CWE-89", 89), ("89", 89), ("cwe-89", 89), ("not a cwe", 0), + ("", 0), (None, 0)): + with self.subTest(value=value): + findings = self.parse_string({"issues": [ + {"id": "issue-1", "name": "An issue", "severity": "low", "cwe": value}, + ]}) + self.assertEqual(expected, findings[0].cwe) + + def test_a_bare_cwe_number_is_read(self): + finding = self.by_uid("escape_many_vuln.json")["issue-0002"] + self.assertEqual(770, finding.cwe) + + def test_an_unparseable_cwe_is_left_unset(self): + finding = self.by_uid("escape_many_vuln.json")["issue-0003"] + self.assertEqual(0, finding.cwe) + # The raw value is still reported, so nothing is lost. + self.assertIn("**CWE:** not a cwe", finding.description) + + def test_no_remediation_leaves_the_mitigation_unset(self): + """Escape has no advice for this issue, and the connector does not invent any.""" + finding = self.by_uid("escape_many_vuln.json")["issue-0003"] + self.assertIsNone(finding.mitigation) + + def test_the_endpoint_records_scheme_port_path_and_query(self): + """ + This scan type's deduplication hashes the endpoints, so the tested URL is always recorded. + + An unpopulated endpoint would leave the hash computed over nothing and every rescan would + reimport. + """ + finding = self.by_uid("escape_many_vuln.json")["issue-0003"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("api.example.com", locations[0].host) + self.assertEqual(8443, locations[0].port) + self.assertEqual("v1/search", locations[0].path) + self.assertEqual("q=1", locations[0].query) + + def test_an_issue_with_no_url_records_no_endpoint(self): + finding = self.by_uid("escape_many_vuln.json")["issue-0005"] + self.assertEqual([], self.get_unsaved_locations(finding)) + self.assertNotIn("**Endpoint:**", finding.description) + + def test_tags_carry_the_owasp_category_and_the_method(self): + findings = self.by_uid("escape_many_vuln.json") + self.assertEqual(["owasp:API4:2023", "method:POST"], findings["issue-0002"].unsaved_tags) + self.assertEqual(["method:GET"], findings["issue-0004"].unsaved_tags) + self.assertEqual(["owasp:API9:2023"], findings["issue-0005"].unsaved_tags) + self.assertEqual([], findings["issue-0003"].unsaved_tags) + + def test_the_method_tag_is_uppercased(self): + finding = self.by_uid("escape_many_vuln.json")["issue-0001"] + self.assertIn("method:POST", finding.unsaved_tags) + # The description keeps Escape's own casing, as the connector does. + self.assertIn("**Endpoint:** post ", finding.description) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Escape", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("issues", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"issues": [ + "not an object", + None, + {"id": "issue-1", "name": "An issue", "severity": "low"}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("escape_many_vuln.json", "escape_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_fairwinds_parser.py b/unittests/tools/test_fairwinds_parser.py new file mode 100644 index 00000000000..939adb9434a --- /dev/null +++ b/unittests/tools/test_fairwinds_parser.py @@ -0,0 +1,199 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.fairwinds.parser import FairwindsParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestFairwindsParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("fairwinds") / filename).open(encoding="utf-8") as file: + return list(FairwindsParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Fairwinds Insights connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = FairwindsParser() + self.assertEqual(["Fairwinds Insights - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Fairwinds Insights - Connectors Import", + parser.get_label_for_scan_types("Fairwinds Insights - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("fairwinds_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("fairwinds_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ActionItemToFinding in the connector's converter.""" + findings = self.parse("fairwinds_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 in openssl", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("5001", finding.unique_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("Rebuild the image on a patched base.", finding.mitigation) + self.assertTrue(finding.active) + self.assertFalse(finding.is_mitigated) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + def test_severity_is_a_float_score_not_a_word(self): + """ + Fairwinds scores severity as a normalised 0.0-1.0 FLOAT. + + It is neither a severity word nor a CVSS score, so the breakpoints are Fairwinds' own: + 0.9 Critical, 0.7 High, 0.4 Medium, 0.1 Low, below that Info. Treating the number as CVSS + would put every finding at Info. + """ + parser = FairwindsParser() + for score, expected in [ + (1.0, "Critical"), (0.9, "Critical"), (0.89, "High"), (0.7, "High"), + (0.69, "Medium"), (0.4, "Medium"), (0.39, "Low"), (0.1, "Low"), + (0.09, "Info"), (0.0, "Info"), + ]: + self.assertEqual(expected, parser.severity(score), score) + + def test_the_severity_ladder_against_the_fixture(self): + findings = self.by_uid("fairwinds_many_vuln.json") + self.assertEqual("Critical", findings["5001"].severity) # 0.95 + self.assertEqual("High", findings["5002"].severity) # 0.75 + self.assertEqual("Medium", findings["5003"].severity) # 0.5 + self.assertEqual("Low", findings["5004"].severity) # 0.2 + self.assertEqual("Info", findings["5005"].severity) # 0.05 + + def test_a_malformed_severity_is_info_rather_than_an_error(self): + finding = self.by_uid("fairwinds_many_vuln.json")["5006"] + self.assertEqual("Info", finding.severity) + + def test_a_fixed_item_is_imported_closed(self): + """ + Fairwinds tracks whether an item has been fixed. + + Importing a fixed item as active would put resolved work back in the open queue. + """ + finding = self.by_uid("fairwinds_many_vuln.json")["5003"] + self.assertFalse(finding.active) + self.assertTrue(finding.is_mitigated) + + def test_the_component_is_the_image_when_there_is_one(self): + """ + Fairwinds aggregates several scanners, so an item may be about an image or a manifest. + + A Trivy image finding's component is the image; a Polaris manifest finding has no image at + all and falls back to the Kubernetes resource name. + """ + findings = self.by_uid("fairwinds_many_vuln.json") + image_item = findings["5001"] + self.assertEqual("registry.example.com/generic-api", image_item.component_name) + self.assertEqual("1.4.0", image_item.component_version) + + manifest_item = findings["5002"] + self.assertEqual("generic-worker", manifest_item.component_name) + self.assertIsNone(manifest_item.component_version) + + def test_the_resource_line_joins_the_kubernetes_coordinates(self): + finding = self.parse("fairwinds_one_vuln.json")[0] + self.assertIn( + "**Resource:** generic-app/Deployment/generic-api (container: api)", + finding.description, + ) + + def test_the_container_qualifier_is_omitted_when_there_is_none(self): + finding = self.by_uid("fairwinds_many_vuln.json")["5002"] + self.assertIn("**Resource:** generic-app/Deployment/generic-worker", finding.description) + self.assertNotIn("container:", finding.description) + + def test_a_missing_coordinate_segment_is_skipped(self): + """The namespace is empty on this item, so the line is just kind/name.""" + finding = self.by_uid("fairwinds_many_vuln.json")["5003"] + self.assertIn("**Resource:** Pod/debug-pod", finding.description) + + def test_an_item_with_no_coordinates_has_no_resource_line(self): + finding = self.by_uid("fairwinds_many_vuln.json")["5004"] + self.assertNotIn("**Resource:**", finding.description) + + def test_the_description_carries_the_image_event_type_and_notes(self): + findings = self.by_uid("fairwinds_many_vuln.json") + first = findings["5001"] + self.assertIn("The image ships a vulnerable openssl.", first.description) + self.assertIn("**Image:** registry.example.com/generic-api:1.4.0", first.description) + self.assertIn("**Notes:** Raised by the platform team.", first.description) + # Only the OPA item has an event type. + self.assertNotIn("**Event type:**", first.description) + self.assertIn("**Event type:** admission", findings["5003"].description) + + def test_html_in_the_description_is_flattened(self): + finding = self.parse("fairwinds_one_vuln.json")[0] + self.assertIn("The image ships a vulnerable openssl.", finding.description) + self.assertNotIn("<p>", finding.description) + + def test_an_item_with_no_title_is_named_from_its_id(self): + finding = self.by_uid("fairwinds_many_vuln.json")["5004"] + self.assertEqual("Fairwinds action item 5004", finding.title) + + def test_tags_mirror_the_connector(self): + finding = self.parse("fairwinds_one_vuln.json")[0] + self.assertEqual( + ["tool:trivy", "category:security", "cluster:generic-prod", + "namespace:generic-app", "owner/platform"], + finding.unsaved_tags, + ) + + def test_the_cluster_tag_is_added_even_when_empty(self): + """ + The connector adds it unconditionally, so an item with no cluster gets a bare "cluster:" tag. + + Reproduced rather than tidied - tidying it here would be a difference between a file import + and an API sync for the same item. + """ + finding = self.by_uid("fairwinds_many_vuln.json")["5004"] + self.assertEqual(["cluster:"], finding.unsaved_tags) + + def test_the_event_type_becomes_a_tag_too(self): + finding = self.by_uid("fairwinds_many_vuln.json")["5003"] + self.assertIn("event:admission", finding.unsaved_tags) + + def test_cves_are_extracted_from_the_title_and_description(self): + """Fairwinds has no CVE field, so the connector scans the prose.""" + finding = self.parse("fairwinds_one_vuln.json")[0] + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + # And an item with no CVE anywhere has none. + self.assertIsNone( + self.by_uid("fairwinds_many_vuln.json")["5002"].unsaved_vulnerability_ids, + ) + + def test_no_mitigation_when_fairwinds_gives_no_remediation(self): + finding = self.by_uid("fairwinds_many_vuln.json")["5003"] + self.assertIsNone(finding.mitigation) + + def test_an_envelope_is_accepted(self): + item = {"ID": 1, "Title": "An item", "Severity": 0.5, "Cluster": "c"} + for payload in ({"ActionItems": [item]}, {"items": [item]}, {"data": [item]}): + report = io.StringIO(json.dumps(payload)) + self.assertEqual( + 1, len(list(FairwindsParser().get_findings(report, Test()))), payload, + ) + + def test_a_repeated_item_id_collapses(self): + item = {"ID": 1, "Title": "An item", "Severity": 0.5} + report = io.StringIO(json.dumps([item, item])) + self.assertEqual(1, len(list(FairwindsParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(FairwindsParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("ActionItems", str(raised.exception)) diff --git a/unittests/tools/test_finitestate_parser.py b/unittests/tools/test_finitestate_parser.py new file mode 100644 index 00000000000..e3e0acb7b31 --- /dev/null +++ b/unittests/tools/test_finitestate_parser.py @@ -0,0 +1,379 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.finitestate.parser import FinitestateParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestFinitestateParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("finitestate") / filename + with path.open(encoding="utf-8") as file: + return list(FinitestateParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(FinitestateParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"id": "finding-1", "title": "A finding", "severity": "high"} + row.update(overrides) + return {"data": {"allFindings": [row]}} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """Must equal the Finite State connector's ScanTypeName verbatim.""" + parser = FinitestateParser() + self.assertEqual(["Finite State - Connectors Import"], parser.get_scan_types()) + self.assertEqual("Finite State - Connectors Import", + parser.get_label_for_scan_types("Finite State - Connectors Import")) + + def test_this_scan_type_has_no_curated_dedupe_fields(self): + """ + Finite State has no hash-field list to copy, so it uses DefectDojo's default algorithm. + + That is what the connector's own findings already do. Choosing hash fields here would change + how those findings deduplicate too, which is not this parser's decision to make. + """ + self.assertFalse(hasattr(FinitestateParser(), "get_dedupe_fields")) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("finitestate_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("finitestate_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ConvertFinding in the connector's finding_converter.""" + findings = self.parse("finitestate_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Outdated TLS library in the firmware image", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("finding-0001", finding.unique_id_from_tool) + self.assertEqual("FS-2024-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(787, finding.cwe) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + self.assertEqual(0.42, finding.epss_score) + self.assertEqual(0.97, finding.epss_percentile) + self.assertEqual("example-tls", finding.component_name) + self.assertEqual("1.0.2k", finding.component_version) + self.assertEqual(date(2024, 6, 2), finding.date) + self.assertTrue(finding.active) + self.assertFalse(finding.out_of_scope) + self.assertFalse(finding.false_p) + self.assertFalse(finding.is_mitigated) + self.assertFalse(finding.under_review) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual( + ["firmware-build:1.4.0", "SBOM", "Known vulnerability", "BINARY_ANALYSIS", + "Binary Analysis", "weaponized", "exploited-in-the-wild"], + finding.unsaved_tags, + ) + self.assertEqual( + "The image bundles a TLS library with known remote code execution flaws.\n\n" + "**Asset:** Generic Router\n" + "**Firmware build:** 1.4.0\n" + "**Build relative risk score:** 2.5\n" + "**Category:** SBOM\n" + "**Origin:** FIRMWARE\n" + "**VEX status:** AFFECTED\n" + "**VEX comment:** Confirmed present in the shipping build.\n" + "**Risk score:** 87.5\n", + finding.description, + ) + + def test_the_description_keeps_its_trailing_newline(self): + """ + The connector does not trim the description it builds. + + Reproduced rather than tidied, so a file import and an API sync render identically. + """ + finding = self.parse("finitestate_one_vuln.json")[0] + self.assertTrue(finding.description.endswith("\n")) + + def test_many_vuln(self): + self.assertEqual(6, len(self.parse("finitestate_many_vuln.json"))) + + def test_a_not_affected_finding_is_out_of_scope_and_not_active(self): + """ + NOT_AFFECTED is a product team asserting the vulnerability does not apply to this build. + + Leaving it active would put an answered question back in the queue on every import. + """ + finding = self.by_uid("finitestate_many_vuln.json")["finding-0002"] + self.assertFalse(finding.active) + self.assertTrue(finding.out_of_scope) + + def test_a_not_affected_finding_is_a_false_positive_only_when_the_code_is_absent(self): + """ + "Vulnerable code not present" means there was never anything to fix - a false positive. + + "Inline mitigations already exist" means the flaw is real but handled, which is out of scope + and NOT a false positive. The distinction matters for metrics. + """ + findings = self.by_uid("finitestate_many_vuln.json") + absent = findings["finding-0002"] + self.assertTrue(absent.false_p) + self.assertTrue(absent.out_of_scope) + + mitigated_in_place = findings["finding-0003"] + self.assertTrue(mitigated_in_place.out_of_scope) + self.assertFalse(mitigated_in_place.false_p) + + def test_every_absence_justification_is_a_false_positive(self): + for justification in ("COMPONENT_NOT_PRESENT", "VULNERABLE_CODE_NOT_PRESENT", + "VULNERABLE_CODE_NOT_IN_EXECUTE_PATH"): + with self.subTest(justification=justification): + findings = self.parse_string(self.row(currentStatus={ + "status": "NOT_AFFECTED", "justification": justification})) + self.assertTrue(findings[0].false_p) + + def test_a_fixed_finding_is_mitigated_and_records_when(self): + finding = self.by_uid("finitestate_many_vuln.json")["finding-0004"] + self.assertFalse(finding.active) + self.assertTrue(finding.is_mitigated) + self.assertEqual(datetime(2024, 6, 5, 14, 30), finding.mitigated.replace(tzinfo=None)) + + def test_an_under_investigation_finding_stays_active_and_is_under_review(self): + """Nobody has ruled it out yet, so it stays in the queue - flagged as being looked at.""" + finding = self.by_uid("finitestate_many_vuln.json")["finding-0005"] + self.assertTrue(finding.active) + self.assertTrue(finding.under_review) + self.assertFalse(finding.out_of_scope) + + def test_affected_and_unrecognised_statuses_stay_active(self): + """ + Staying active is the safe direction to be wrong in. + + A finding wrongly left active gets triaged; one wrongly closed is never seen again. + """ + for status in ("AFFECTED", "SOMETHING_NEW", "", "affected"): + with self.subTest(status=status): + findings = self.parse_string(self.row(currentStatus={"status": status})) + self.assertTrue(findings[0].active) + self.assertFalse(findings[0].out_of_scope) + self.assertFalse(findings[0].is_mitigated) + + def test_a_finding_with_no_status_block_stands_as_reported(self): + finding = self.by_uid("finitestate_many_vuln.json")["finding-0006"] + self.assertTrue(finding.active) + self.assertFalse(finding.out_of_scope) + self.assertNotIn("**VEX status:**", finding.description) + + def test_the_highest_epss_across_the_findings_cves_is_used(self): + """ + EPSS is per-CVE, and the finding's real exploitation likelihood is the highest of them. + + The percentile travels with the score it belongs to rather than being mixed in from another + CVE - the fixture's lower-scoring CVE has a different percentile. + """ + finding = self.by_uid("finitestate_many_vuln.json")["finding-0001"] + self.assertEqual(0.62, finding.epss_score) + self.assertEqual(0.99, finding.epss_percentile) + + def test_the_first_cve_vector_is_used_and_the_finding_score_wins(self): + """The finding carries 9.8 while its second CVE scores 7.5; the finding's own score wins.""" + finding = self.by_uid("finitestate_many_vuln.json")["finding-0001"] + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N", finding.cvssv3) + + def test_a_cve_base_score_is_the_fallback_when_the_finding_has_none(self): + findings = self.parse_string(self.row(cvssScore=0, cves=[ + {"cveId": "CVE-2000-0001", + "cvssBaseMetricV3": {"cvssv3": {"baseScore": 6.1, "vectorString": "CVSS:3.1/AV:N"}}}, + ])) + self.assertEqual(6.1, findings[0].cvssv3_score) + + def test_severity_prefers_the_platform_severity_over_the_cvss_one(self): + """The fixture's second finding is "high" with cvssSeverity "medium".""" + finding = self.by_uid("finitestate_many_vuln.json")["finding-0002"] + self.assertEqual("High", finding.severity) + + def test_the_cvss_severity_is_used_when_the_platform_severity_is_missing(self): + finding = self.by_uid("finitestate_many_vuln.json")["finding-0005"] + self.assertEqual("High", finding.severity) + + def test_unknown_is_a_recognised_word_and_does_not_fall_through(self): + """ + "unknown" and "none" are values Finite State actually uses, and both mean Info. + + So a finding graded "unknown" is Info even when its CVSS severity says Critical - the + fall-through only happens for a word the platform does not use at all. Treating "unknown" as + missing would silently upgrade every unscored finding. + """ + findings = self.parse_string(self.row(severity="unknown", cvssSeverity="critical")) + self.assertEqual("Info", findings[0].severity) + findings = self.parse_string(self.row(severity="none", cvssSeverity="critical")) + self.assertEqual("Info", findings[0].severity) + findings = self.parse_string(self.row(severity="a word it does not use", + cvssSeverity="critical")) + self.assertEqual("Critical", findings[0].severity) + + def test_severity_words(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("info", "Info"), ("none", "Info"), + ("unknown", "Info"), ("CRITICAL", "Critical")): + with self.subTest(label=label): + findings = self.parse_string(self.row(severity=label)) + self.assertEqual(expected, findings[0].severity) + + def test_an_unrecognised_severity_in_both_fields_is_info(self): + """ + Inventing a grade for a word the platform does not use would be worse than under-reporting. + + The connector logs a warning and falls back to Info; this does the same silently. + """ + finding = self.by_uid("finitestate_many_vuln.json")["finding-0006"] + self.assertEqual("Info", finding.severity) + + def test_the_first_parseable_cwe_wins_and_a_bare_number_is_accepted(self): + """The fixture's first CWE is not a number at all, so the second is used.""" + finding = self.by_uid("finitestate_many_vuln.json")["finding-0002"] + self.assertEqual(89, finding.cwe) + + def test_cwe_forms(self): + for value, expected in (("CWE-79", 79), ("79", 79), ("cwe-79", 79), ("not a cwe", 0), + ("", 0)): + with self.subTest(value=value): + findings = self.parse_string(self.row(cwes=[{"cweId": value}])) + self.assertEqual(expected, findings[0].cwe) + + def test_a_finding_with_no_cwe_has_zero(self): + finding = self.by_uid("finitestate_many_vuln.json")["finding-0004"] + self.assertEqual(0, finding.cwe) + + def test_the_first_affected_component_is_the_component(self): + findings = self.by_uid("finitestate_many_vuln.json") + self.assertEqual("example-tls", findings["finding-0001"].component_name) + self.assertEqual("1.0.2k", findings["finding-0001"].component_version) + self.assertIsNone(findings["finding-0005"].component_name) + + def test_the_build_tag_names_the_firmware_the_finding_belongs_to(self): + """ + Several builds of one product land in the same DefectDojo product. + + The tag is what lets a reader tell which firmware a finding is from without opening it. + """ + finding = self.by_uid("finitestate_many_vuln.json")["finding-0001"] + self.assertIn("firmware-build:1.4.0", finding.unsaved_tags) + + def test_the_build_tag_falls_back_to_the_build_id(self): + payload = self.row() + payload["assetVersion"] = {"id": "ver-0009", "name": ""} + findings = self.parse_string(payload) + self.assertIn("firmware-build:ver-0009", findings[0].unsaved_tags) + + def test_a_regression_is_tagged(self): + findings = self.by_uid("finitestate_many_vuln.json") + self.assertIn("regression", findings["finding-0004"].unsaved_tags) + self.assertNotIn("regression", findings["finding-0001"].unsaved_tags) + + def test_exploit_intel_is_tagged_from_any_of_the_findings_cves(self): + """The fixture's first CVE has neither flag; its second has both.""" + finding = self.by_uid("finitestate_many_vuln.json")["finding-0001"] + self.assertIn("weaponized", finding.unsaved_tags) + self.assertIn("exploited-in-the-wild", finding.unsaved_tags) + + def test_tags_are_deduplicated(self): + payload = self.row(category="SBOM", subcategory="SBOM", sourceTypes=["SBOM"], + test={"tools": [{"name": "SBOM"}, {"name": "SBOM"}]}) + findings = self.parse_string(payload) + self.assertEqual(["SBOM"], findings[0].unsaved_tags) + + def test_the_build_context_may_be_stated_once_for_the_file_or_per_row(self): + """ + One export is one firmware build, so the context is normally stated once. + + A row carrying its own overrides it, for an export that repeats the context per finding. + """ + payload = {"asset": {"name": "Generic Router"}, + "assetVersion": {"name": "1.4.0"}, + "data": {"allFindings": [ + {"id": "a", "title": "Uses the file context", "severity": "low"}, + {"id": "b", "title": "Carries its own", "severity": "low", + "asset": {"name": "Generic Switch"}, "assetVersion": {"name": "9.9.9"}}, + ]}} + findings = {finding.unique_id_from_tool: finding for finding in self.parse_string(payload)} + self.assertIn("**Asset:** Generic Router", findings["a"].description) + self.assertIn("firmware-build:1.4.0", findings["a"].unsaved_tags) + self.assertIn("**Asset:** Generic Switch", findings["b"].description) + self.assertIn("firmware-build:9.9.9", findings["b"].unsaved_tags) + + def test_an_export_with_no_build_context_still_imports(self): + findings = self.parse("finitestate_findings_only.json") + self.assertEqual(1, len(findings)) + self.assertEqual("finding-0010", findings[0].unique_id_from_tool) + self.assertNotIn("**Asset:**", findings[0].description) + self.assertNotIn("**Firmware build:**", findings[0].description) + # The finding's own category still tags it; only the build tag is missing. + self.assertEqual(["SBOM"], findings[0].unsaved_tags) + self.assertFalse([tag for tag in findings[0].unsaved_tags if tag.startswith("firmware-build:")]) + + def test_export_shapes(self): + row = {"id": "finding-1", "title": "A finding", "severity": "low"} + for payload in ([row], {"data": {"allFindings": [row]}}, {"allFindings": [row]}, + {"findings": [row]}): + with self.subTest(shape=str(payload)[:24]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Finite State", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("allFindings", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"data": {"allFindings": [ + "not an object", + None, + {"id": "finding-9", "title": "A finding", "severity": "low", + "cwes": ["not an object", None], "cves": ["not an object", None], + "affects": ["not an object"]}, + ]}}) + self.assertEqual(1, len(findings)) + self.assertEqual("finding-9", findings[0].unique_id_from_tool) + self.assertEqual(0, findings[0].cwe) + self.assertIsNone(findings[0].unsaved_vulnerability_ids) + self.assertIsNone(findings[0].component_name) + + def test_a_risk_score_of_zero_is_not_reported(self): + findings = self.by_uid("finitestate_many_vuln.json") + self.assertIn("**Risk score:** 87.5", findings["finding-0001"].description) + self.assertNotIn("**Risk score:**", findings["finding-0004"].description) + + def test_scores_render_without_a_trailing_zero(self): + """The connector formats them in their shortest round-tripping form, so 3.0 is "3".""" + payload = self.row(riskScore=3.0) + payload["assetVersion"] = {"name": "1.0", "relativeRiskScore": 2.0} + findings = self.parse_string(payload) + self.assertIn("**Risk score:** 3\n", findings[0].description) + self.assertIn("**Build relative risk score:** 2\n", findings[0].description) + + def test_an_unparseable_date_leaves_the_date_alone(self): + finding = self.by_uid("finitestate_many_vuln.json")["finding-0006"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_the_date_falls_back_to_when_the_finding_was_created(self): + findings = self.by_uid("finitestate_many_vuln.json") + self.assertEqual(date(2024, 6, 2), findings["finding-0001"].date) + self.assertEqual(date(2024, 5, 20), findings["finding-0002"].date) + + def test_severity_is_always_a_known_value(self): + for filename in ("finitestate_many_vuln.json", "finitestate_one_vuln.json", + "finitestate_findings_only.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_fleet_policies_parser.py b/unittests/tools/test_fleet_policies_parser.py new file mode 100644 index 00000000000..e50ca077dbc --- /dev/null +++ b/unittests/tools/test_fleet_policies_parser.py @@ -0,0 +1,200 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.fleet_policies.parser import FleetPoliciesParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestFleetPoliciesParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("fleet_policies") / filename + with path.open(encoding="utf-8") as file: + return list(FleetPoliciesParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(FleetPoliciesParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Fleet connector's ScanTypePolicies verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every failing policy. + """ + parser = FleetPoliciesParser() + self.assertEqual(["Fleet:Policies - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Fleet:Policies - Connectors Import", + parser.get_label_for_scan_types("Fleet:Policies - Connectors Import"), + ) + self.assertNotIn("Fleet:Vulnerabilities - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """ + Policies that all pass produce nothing. + + The sample also carries a software CVE, which this parser must ignore: vulnerabilities are a + separate scan type with their own deduplication key. + """ + self.assertEqual(0, len(self.parse("fleet_policies_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("fleet_policies_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring policyFinding in the connector's converter.""" + findings = self.parse("fleet_policies_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Disk encryption enabled", finding.title) + # Fleet marks this policy critical. + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("11:policy:90", finding.unique_id_from_tool) + self.assertEqual("fleet-policy-90", finding.vuln_id_from_tool) + self.assertEqual("Turn on FileVault in System Settings, Privacy & Security.", finding.mitigation) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["compliance", "critical-policy", "darwin", "endpoint", "policy"], finding.unsaved_tags) + + sections = finding.description.split("\n\n") + self.assertEqual("Checks that FileVault is enabled on the startup volume.", sections[0]) + self.assertEqual("This Fleet policy is **failing** on this host.", sections[1]) + self.assertEqual("**Host:** laptop-11 (10.0.0.11)", sections[2]) + self.assertEqual("**OS:** darwin macOS 14.5", sections[3]) + self.assertEqual("**Policy query**", sections[4]) + self.assertIn("```sql", finding.description) + self.assertIn("SELECT 1 FROM disk_encryption", finding.description) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("laptop-11", locations[0].host) + + def test_many_vuln(self): + """Two failing policies on one host and one on another; the rest are skipped.""" + self.assertEqual(3, len(self.parse("fleet_policies_many_vuln.json"))) + + def test_only_failing_policies_are_imported(self): + """ + Fleet reports every policy's outcome per host, not just the failures. + + A passing policy is not a finding, and neither is one Fleet has no result for - an empty + response means the query has not run on that host yet. + """ + findings = self.by_uid("fleet_policies_many_vuln.json") + self.assertIn("11:policy:90", findings) + self.assertIn("11:policy:91", findings) + # 92 passes, 93 has no name, 94 has no result yet. + self.assertNotIn("11:policy:92", findings) + self.assertNotIn("11:policy:93", findings) + self.assertNotIn("11:policy:94", findings) + + def test_response_is_read_case_insensitively(self): + """The second host reports the same policy as "FAIL".""" + self.assertIn("12:policy:90", self.by_uid("fleet_policies_many_vuln.json")) + + def test_a_critical_policy_is_high_and_the_rest_are_medium(self): + findings = self.by_uid("fleet_policies_many_vuln.json") + self.assertEqual("High", findings["11:policy:90"].severity) + self.assertEqual("Medium", findings["11:policy:91"].severity) + self.assertIn("critical-policy", findings["11:policy:90"].unsaved_tags) + self.assertNotIn("critical-policy", findings["11:policy:91"].unsaved_tags) + + def test_the_same_policy_on_two_hosts_is_two_findings(self): + """ + A policy failing on two machines is two findings. + + The host is part of the identity because remediating one machine does not fix the other. + """ + findings = [ + finding for finding in self.parse("fleet_policies_many_vuln.json") + if finding.title == "Disk encryption enabled" + ] + self.assertEqual(2, len(findings)) + self.assertEqual({"11:policy:90", "12:policy:90"}, {f.unique_id_from_tool for f in findings}) + # Both point at the same policy, which is what the deduplication hash keys on. + self.assertEqual({"fleet-policy-90"}, {f.vuln_id_from_tool for f in findings}) + + def test_the_platform_of_both_the_policy_and_the_host_is_tagged(self): + findings = self.by_uid("fleet_policies_many_vuln.json") + self.assertEqual( + ["compliance", "critical-policy", "endpoint", "linux", "policy", "ubuntu"], + findings["12:policy:90"].unsaved_tags, + ) + + def test_tags_are_sorted_and_deduplicated(self): + """ + The connector sorts and deduplicates its tags, so a reimport does not look like a change. + + On the first host the policy platform and the host platform are both "darwin", which is + exactly the case the deduplication is for. + """ + for finding in self.parse("fleet_policies_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertEqual(sorted(set(finding.unsaved_tags)), finding.unsaved_tags) + self.assertEqual( + ["compliance", "critical-policy", "darwin", "endpoint", "policy"], + self.by_uid("fleet_policies_many_vuln.json")["11:policy:90"].unsaved_tags, + ) + + def test_a_policy_without_a_description_or_query(self): + findings = self.parse_string({"hosts": [{"id": 1, "hostname": "h.example.com", "policies": [ + {"id": 5, "name": "Bare policy", "response": "fail"}, + ]}]}) + self.assertEqual( + "This Fleet policy is **failing** on this host.\n\n**Host:** h.example.com", + findings[0].description, + ) + self.assertEqual("", findings[0].mitigation) + + def test_ids_may_be_strings(self): + findings = self.parse_string({"hosts": [{"id": "42", "hostname": "h.example.com", "policies": [ + {"id": "7", "name": "String ids", "response": "fail"}, + ]}]}) + self.assertEqual("42:policy:7", findings[0].unique_id_from_tool) + self.assertEqual("fleet-policy-7", findings[0].vuln_id_from_tool) + + def test_single_host_and_bare_list_shapes(self): + host = {"id": 1, "hostname": "h.example.com", "policies": [ + {"id": 5, "name": "A policy", "response": "fail"}, + ]} + for payload in ({"hosts": [host]}, {"host": host}, [host], host): + with self.subTest(shape=next(iter(payload)) if isinstance(payload, dict) else "list"): + findings = self.parse_string(payload) + self.assertEqual(1, len(findings)) + self.assertEqual("A policy", findings[0].title) + + def test_a_host_with_no_name_falls_back_to_its_address(self): + findings = self.parse_string({"hosts": [{"id": 1, "primary_ip": "10.0.0.9", "policies": [ + {"id": 5, "name": "A policy", "response": "fail"}, + ]}]}) + self.assertEqual("10.0.0.9", self.get_unsaved_locations(findings[0])[0].host) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Fleet", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("host", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"hosts": [ + "not an object", + None, + {"id": 1, "hostname": "h.example.com", "policies": [ + "not an object", + {"id": 5, "name": "A policy", "response": "fail"}, + ]}, + ]}) + self.assertEqual(1, len(findings)) + + def test_a_host_with_no_policies_is_not_an_error(self): + self.assertEqual(0, len(self.parse_string({"hosts": [{"id": 1, "hostname": "h.example.com"}]}))) diff --git a/unittests/tools/test_fleet_vulnerabilities_parser.py b/unittests/tools/test_fleet_vulnerabilities_parser.py new file mode 100644 index 00000000000..9b046d03330 --- /dev/null +++ b/unittests/tools/test_fleet_vulnerabilities_parser.py @@ -0,0 +1,287 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.fleet_vulnerabilities.parser import FleetVulnerabilitiesParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestFleetVulnerabilitiesParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("fleet_vulnerabilities") / filename + with path.open(encoding="utf-8") as file: + return list(FleetVulnerabilitiesParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(FleetVulnerabilitiesParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Fleet connector's ScanTypeVulnerabilities verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every CVE. + """ + parser = FleetVulnerabilitiesParser() + self.assertEqual(["Fleet:Vulnerabilities - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Fleet:Vulnerabilities - Connectors Import", + parser.get_label_for_scan_types("Fleet:Vulnerabilities - Connectors Import"), + ) + self.assertNotIn("Fleet:Policies - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """ + Software with no CVEs produces nothing. + + The sample also carries a *failing* policy, which this parser must ignore: policies are a + separate scan type with their own deduplication key. + """ + self.assertEqual(0, len(self.parse("fleet_vulnerabilities_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("fleet_vulnerabilities_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring vulnerabilityFinding in the connector's converter.""" + findings = self.parse("fleet_vulnerabilities_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 - Example Browser 120.0.1 on laptop-11", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("Example Browser", finding.component_name) + self.assertEqual("120.0.1", finding.component_version) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("11:Example Browser:120.0.1:CVE-2000-0001", finding.unique_id_from_tool) + self.assertEqual(datetime(2024, 5, 1, tzinfo=UTC).date(), finding.publish_date) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual( + "Upgrade Example Browser to 120.0.2 or later, which resolves this CVE.", + finding.mitigation, + ) + self.assertEqual(["cisa-known-exploited", "darwin", "endpoint", "vulnerability"], finding.unsaved_tags) + + sections = finding.description.split("\n\n") + self.assertEqual("A memory-safety flaw allows remote code execution.", sections[0]) + self.assertEqual("**Software:** Example Browser 120.0.1 (apps, Example Vendor)", sections[1]) + self.assertEqual("**Host:** laptop-11 (10.0.0.11)", sections[2]) + self.assertEqual("**OS:** darwin macOS 14.5", sections[3]) + self.assertIn("**CPE:** cpe:2.3:a:example:browser", sections[4]) + self.assertEqual("**CVSS:** 9.8", sections[5]) + self.assertEqual( + "**EPSS:** 0.94 (probability of exploitation in the next 30 days)", + sections[6], + ) + self.assertIn("**CISA KEV:**", sections[7]) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("laptop-11", locations[0].host) + + def test_many_vuln(self): + """One finding per host per software per CVE, and the CVE-less row is dropped.""" + self.assertEqual(7, len(self.parse("fleet_vulnerabilities_many_vuln.json"))) + + def test_the_same_cve_on_two_hosts_is_two_findings(self): + """ + Fleet reports software per host, so the host is part of the identity. + + Collapsing them would hide a machine that is still running the vulnerable version. + """ + findings = self.by_uid("fleet_vulnerabilities_many_vuln.json") + self.assertIn("11:Example Browser:120.0.1:CVE-2000-0001", findings) + self.assertIn("12:openssl:3.0.2:CVE-2000-0001", findings) + self.assertEqual("Example Browser", findings["11:Example Browser:120.0.1:CVE-2000-0001"].component_name) + self.assertEqual("openssl", findings["12:openssl:3.0.2:CVE-2000-0001"].component_name) + + def test_severity_floors(self): + for score, expected in ((10.0, "Critical"), (9.0, "Critical"), (8.9, "High"), (7.0, "High"), + (6.9, "Medium"), (4.0, "Medium"), (3.9, "Low"), (0.1, "Low"), + (0, "Info")): + with self.subTest(score=score): + findings = self.parse_string({"hosts": [{"id": 1, "hostname": "h.example.com", "software": [ + {"name": "pkg", "version": "1.0", "vulnerabilities": [ + {"cve": "CVE-2000-0001", "cvss_score": score}, + ]}, + ]}]}) + self.assertEqual(expected, findings[0].severity) + + def test_an_unscored_cve_is_medium(self): + """ + A CVE Fleet has not scored is Medium, not Info. + + Fleet enriches from the NVD, so a missing score means "not scored yet" rather than "no risk", + and the connector deliberately grades it as an unknown. An explicit zero is still Info. + """ + findings = self.by_uid("fleet_vulnerabilities_many_vuln.json") + self.assertEqual("Medium", findings["11:Example Reader:9.1:CVE-2000-0006"].severity) + self.assertIsNone(findings["11:Example Reader:9.1:CVE-2000-0006"].cvssv3_score) + self.assertEqual("Info", findings["11:Example Reader:9.1:CVE-2000-0005"].severity) + self.assertEqual(0, findings["11:Example Reader:9.1:CVE-2000-0005"].cvssv3_score) + + def test_a_null_score_is_unscored_but_an_empty_string_is_zero(self): + """This is the connector's own FlexFloat decoding, which distinguishes the two.""" + findings = self.parse_string({"hosts": [{"id": 1, "hostname": "h.example.com", "software": [ + {"name": "pkg", "version": "1.0", "vulnerabilities": [ + {"cve": "CVE-2000-0001", "cvss_score": None}, + {"cve": "CVE-2000-0002", "cvss_score": ""}, + ]}, + ]}]}) + self.assertEqual("Medium", findings[0].severity) + self.assertEqual("Info", findings[1].severity) + + def test_numbers_may_arrive_as_strings(self): + """Fleet sends these as either a number or a numeric string.""" + finding = self.by_uid("fleet_vulnerabilities_many_vuln.json")["11:Example Browser:120.0.1:CVE-2000-0002"] + self.assertEqual("High", finding.severity) + self.assertEqual(7.5, finding.cvssv3_score) + self.assertIn("**CVSS:** 7.5", finding.description) + + def test_a_host_id_may_be_a_string(self): + findings = self.parse_string({"hosts": [{"id": "42", "hostname": "h.example.com", "software": [ + {"name": "pkg", "version": "1.0", "vulnerabilities": [{"cve": "CVE-2000-0001"}]}, + ]}]}) + self.assertEqual("42:pkg:1.0:CVE-2000-0001", findings[0].unique_id_from_tool) + + def test_cisa_known_exploited_is_called_out(self): + """ + A CVE on CISA's list is flagged in the description and tagged. + + The connector does not raise the severity for it - the CVSS score still decides - so neither + does this parser; the flag is what makes it findable. + """ + findings = self.by_uid("fleet_vulnerabilities_many_vuln.json") + flagged = findings["11:Example Browser:120.0.1:CVE-2000-0001"] + self.assertIn("cisa-known-exploited", flagged.unsaved_tags) + self.assertIn("CISA's Known Exploited Vulnerabilities list", flagged.description) + + not_flagged = findings["11:Example Browser:120.0.1:CVE-2000-0002"] + self.assertNotIn("cisa-known-exploited", not_flagged.unsaved_tags) + self.assertNotIn("CISA", not_flagged.description) + + def test_mitigation_without_a_fixed_version(self): + finding = self.by_uid("fleet_vulnerabilities_many_vuln.json")["11:Example Reader:9.1:CVE-2000-0003"] + self.assertIn("following the vendor's security advisory", finding.mitigation) + self.assertIn("Fleet did not report a fixed version", finding.mitigation) + + def test_publish_date_accepts_a_date_or_a_timestamp(self): + findings = self.by_uid("fleet_vulnerabilities_many_vuln.json") + self.assertEqual( + datetime(2024, 5, 1, tzinfo=UTC).date(), + findings["11:Example Browser:120.0.1:CVE-2000-0001"].publish_date, + ) + self.assertEqual( + datetime(2024, 4, 15, tzinfo=UTC).date(), + findings["11:Example Browser:120.0.1:CVE-2000-0002"].publish_date, + ) + self.assertIsNone(findings["11:Example Reader:9.1:CVE-2000-0003"].publish_date) + + def test_tags_are_sorted_and_deduplicated(self): + """ + The connector sorts and deduplicates its tags, so a reimport does not look like a change. + + Worth asserting rather than assuming: an unordered tag list is a diff on every sync. + """ + for finding in self.parse("fleet_vulnerabilities_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertEqual(sorted(set(finding.unsaved_tags)), finding.unsaved_tags) + self.assertIn("vulnerability", finding.unsaved_tags) + self.assertIn("endpoint", finding.unsaved_tags) + + def test_host_name_falls_back_through_fleets_three_names(self): + cases = ( + ({"display_name": "display", "computer_name": "computer", "hostname": "host"}, "display"), + ({"computer_name": "computer", "hostname": "host"}, "computer"), + ({"hostname": "host"}, "host"), + ({"primary_ip": "10.0.0.9"}, ""), + ) + for host, expected in cases: + with self.subTest(host=host): + self.assertEqual(expected, FleetVulnerabilitiesParser().host_name(host)) + + def test_a_host_with_no_name_falls_back_to_its_address(self): + findings = self.parse_string({"hosts": [{"id": 1, "primary_ip": "10.0.0.9", "software": [ + {"name": "pkg", "version": "1.0", "vulnerabilities": [{"cve": "CVE-2000-0001"}]}, + ]}]}) + self.assertEqual("CVE-2000-0001 - pkg 1.0", findings[0].title) + self.assertEqual("10.0.0.9", self.get_unsaved_locations(findings[0])[0].host) + + def test_a_host_with_no_name_or_address_records_no_endpoint(self): + findings = self.parse_string({"hosts": [{"id": 1, "software": [ + {"name": "pkg", "version": "1.0", "vulnerabilities": [{"cve": "CVE-2000-0001"}]}, + ]}]}) + self.assertEqual(0, len(self.get_unsaved_locations(findings[0]))) + + def test_a_host_name_that_cannot_be_a_host_records_no_endpoint(self): + """ + Fleet's display name is free text - "Jane's MacBook" is a normal value. + + DefectDojo's host field would reject it, and a ValidationError fails the whole import rather + than the one finding, so the endpoint is dropped instead. The name is still in the + description's Host line. + """ + findings = self.parse_string({"hosts": [{ + "id": 1, "display_name": "Someone's MacBook Pro", "software": [ + {"name": "pkg", "version": "1.0", "vulnerabilities": [{"cve": "CVE-2000-0001"}]}, + ], + }]}) + self.assertEqual([], self.get_unsaved_locations(findings[0])) + self.assertIn("**Host:** Someone's MacBook Pro", findings[0].description) + + def test_an_ipv6_address_is_accepted_as_a_host(self): + findings = self.parse_string({"hosts": [{ + "id": 1, "primary_ip": "2001:db8::1", "software": [ + {"name": "pkg", "version": "1.0", "vulnerabilities": [{"cve": "CVE-2000-0001"}]}, + ], + }]}) + self.assertEqual("2001:db8::1", self.get_unsaved_locations(findings[0])[0].host) + + def test_single_host_and_bare_list_shapes(self): + """Fleet's list response nests hosts under "hosts" and its single-host response under "host".""" + host = {"id": 1, "hostname": "h.example.com", "software": [ + {"name": "pkg", "version": "1.0", "vulnerabilities": [{"cve": "CVE-2000-0001"}]}, + ]} + for payload in ({"hosts": [host]}, {"host": host}, [host], host): + with self.subTest(shape=next(iter(payload)) if isinstance(payload, dict) else "list"): + findings = self.parse_string(payload) + self.assertEqual(1, len(findings)) + self.assertEqual("CVE-2000-0001 - pkg 1.0 on h.example.com", findings[0].title) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Fleet", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("host", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"hosts": [ + "not an object", + None, + {"id": 1, "hostname": "h.example.com", "software": [ + "not an object", + {"name": "pkg", "version": "1.0", "vulnerabilities": ["not an object", {"cve": "CVE-2000-0001"}]}, + ]}, + ]}) + self.assertEqual(1, len(findings)) + + def test_a_host_with_no_software_is_not_an_error(self): + self.assertEqual(0, len(self.parse_string({"hosts": [{"id": 1, "hostname": "h.example.com"}]}))) + + def test_severity_is_always_a_known_value(self): + for filename in ("fleet_vulnerabilities_many_vuln.json", "fleet_vulnerabilities_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_fortytwocrunch_parser.py b/unittests/tools/test_fortytwocrunch_parser.py new file mode 100644 index 00000000000..98a8837af95 --- /dev/null +++ b/unittests/tools/test_fortytwocrunch_parser.py @@ -0,0 +1,355 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.fortytwocrunch.parser import FortytwocrunchParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestFortytwocrunchParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("fortytwocrunch") / filename + with path.open(encoding="utf-8") as file: + return list(FortytwocrunchParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(FortytwocrunchParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def audit(self, index=None, **group): + row = {"criticality": 4, "description": "An issue", "issues": [{"pointer": 0}]} + row.update(group) + return {"apiId": "api-1", + "report": {"index": index if index is not None else ["/paths/~1x/get"], + "security": {"issues": {"issue-1": row}}}} + + def scan(self, issue=None, templates=None, pointers=None): + return {"apiId": "api-1", "report": {"data": { + "index": {"jsonPointers": pointers if pointers is not None else ["/paths/~1x/get"], + "injectionDescriptions": templates if templates is not None else ["A problem"]}, + "paths": {"/x": {"get": {"issues": [issue or {"criticality": 4, + "injectionDescription": 0}]}}}}}} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """Must equal the 42Crunch connector's ScanTypeName verbatim.""" + parser = FortytwocrunchParser() + self.assertEqual(["42Crunch - Connectors Import"], parser.get_scan_types()) + self.assertEqual("42Crunch - Connectors Import", + parser.get_label_for_scan_types("42Crunch - Connectors Import")) + + def test_this_scan_type_has_no_curated_dedupe_fields(self): + """No hash-field list to copy, so it uses DefectDojo's default algorithm - as the connector does.""" + self.assertFalse(hasattr(FortytwocrunchParser(), "get_dedupe_fields")) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("fortytwocrunch_no_vuln.json"))) + + def test_audit_one_vuln(self): + self.assertEqual(1, len(self.parse("fortytwocrunch_audit_one_vuln.json"))) + + def test_audit_one_vuln_field_mapping(self): + """Full field mapping, mirroring auditFinding in the connector's converter.""" + findings = self.parse("fortytwocrunch_audit_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("The API key is passed in a header, which is exposed to intermediaries", + finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("api-0001/audit/v3-global-securityscheme-apikey-inheader/" + "/paths/~1orders/get/security", finding.unique_id_from_tool) + self.assertEqual("v3-global-securityscheme-apikey-inheader", finding.vuln_id_from_tool) + self.assertEqual("/paths/~1orders/get/security", finding.file_path) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["42crunch", "audit", "security"], finding.unsaved_tags) + self.assertEqual( + "The GET /orders operation accepts an API key in a request header\n\n" + "* **Category:** security\n" + "* **OpenAPI location:** /paths/~1orders/get/security\n", + finding.description, + ) + self.assertEqual(0, len(self.get_unsaved_locations(finding))) + + def test_the_title_and_the_description_paragraph_use_opposite_precedence(self): + """ + The title prefers the SHARED issue description; the paragraph prefers the SPECIFIC one. + + That is deliberate in the connector: the title groups occurrences of one issue type under one + name, while the body says what is wrong at this particular location. + """ + finding = self.parse("fortytwocrunch_audit_one_vuln.json")[0] + self.assertEqual("The API key is passed in a header, which is exposed to intermediaries", + finding.title) + self.assertTrue(finding.description.startswith( + "The GET /orders operation accepts an API key in a request header")) + + def test_the_title_falls_back_to_the_specific_description_then_the_issue_id(self): + findings = self.parse_string(self.audit(description="", + issues=[{"pointer": 0, + "specificDescription": "Specific"}])) + self.assertEqual("Specific", findings[0].title) + findings = self.parse_string(self.audit(description="", issues=[{"pointer": 0}])) + self.assertEqual("issue-1", findings[0].title) + + def test_audit_many_vuln(self): + """ + Every category contributes, and one issue type with two occurrences is two findings. + + Six issue types across the five sections, one of them firing twice. + """ + self.assertEqual(7, len(self.parse("fortytwocrunch_audit_many_vuln.json"))) + + def test_every_audit_category_is_read_with_its_own_label(self): + findings = self.parse("fortytwocrunch_audit_many_vuln.json") + categories = {tag for finding in findings for tag in finding.unsaved_tags} + for expected in ("security", "data-validation", "warning", "semantic-error", + "validation-error"): + self.assertIn(expected, categories) + + def test_two_occurrences_of_one_issue_type_are_two_findings(self): + """ + One audit issue can fire at several places in the definition, and each is its own fix. + + Their identities differ by the resolved OpenAPI location. + """ + uids = set(self.by_uid("fortytwocrunch_audit_many_vuln.json")) + self.assertIn("api-0001/audit/v3-global-securityscheme-apikey-inheader/" + "/paths/~1orders/get/security", uids) + self.assertIn("api-0001/audit/v3-global-securityscheme-apikey-inheader/" + "/paths/~1orders/post/requestBody", uids) + + def test_an_unresolvable_pointer_keeps_the_raw_index_in_the_identity(self): + """ + Without it, two occurrences of one issue with no location would collapse into one finding. + + The location is out of range here, so there is nothing to resolve - but the occurrences are + still distinct things. + """ + findings = self.by_uid("fortytwocrunch_audit_many_vuln.json") + self.assertIn("api-0001/audit/v3-operation-securityscheme-missing/#99", findings) + self.assertIsNone(findings["api-0001/audit/v3-operation-securityscheme-missing/#99"].file_path) + + def test_a_negative_pointer_is_also_unresolvable(self): + findings = self.by_uid("fortytwocrunch_audit_many_vuln.json") + self.assertIn("api-0001/audit/v3-validation-broken/#-1", findings) + + def test_criticality_five_is_the_most_severe(self): + """42Crunch grades 1 as informational and 5 as critical - a scale, not an inverse one.""" + for criticality, expected in ((5, "Critical"), (4, "High"), (3, "Medium"), (2, "Low"), + (1, "Info"), (0, "Info"), (9, "Info"), (None, "Info")): + with self.subTest(criticality=criticality): + findings = self.parse_string(self.audit(criticality=criticality)) + self.assertEqual(expected, findings[0].severity) + + def test_the_openapi_location_is_the_file_path(self): + """An audit finding is a place in a definition, so the JSON Pointer is its path.""" + finding = self.by_uid("fortytwocrunch_audit_many_vuln.json")[ + "api-0001/audit/v3-schema-response-string-maxlength//paths/~1orders~1{id}/get/responses/200"] + self.assertEqual("/paths/~1orders~1{id}/get/responses/200", finding.file_path) + + def test_scan_many_vuln(self): + self.assertEqual(4, len(self.parse("fortytwocrunch_scan_many_vuln.json"))) + + def test_scan_field_mapping(self): + """Full field mapping, mirroring scanFinding in the connector's converter.""" + finding = self.by_uid("fortytwocrunch_scan_many_vuln.json")["api-0001/scan/GET /orders/0"] + + self.assertEqual("The response returned 500 instead of the documented 200", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertEqual("curl -X GET 'https://api.example.com/orders?limit=1'", + finding.steps_to_reproduce) + self.assertTrue(finding.active) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertEqual(["42crunch", "scan", "GET"], finding.unsaved_tags) + self.assertEqual( + "The response returned 500 instead of the documented 200\n\n" + "* **Operation:** GET /orders\n" + "* **URL:** https://api.example.com/orders?limit=1\n" + "* **Response status:** 500\n" + "* **OpenAPI location:** /paths/~1orders/get/responses/200\n", + finding.description, + ) + + def test_the_scan_description_is_a_template_filled_from_a_separate_parameter_list(self): + """ + The description is referenced by integer and its parameters arrive separately. + + Each "%s" is filled in turn, one substitution per parameter. Reading the integer alone would + leave the finding with no description at all. + """ + findings = self.parse_string(self.scan( + templates=["saw %s, expected %s, at %s"], + issue={"criticality": 3, "injectionDescription": 0, + "injectionDescriptionParams": ["500", "200"]})) + self.assertEqual("saw 500, expected 200, at %s", findings[0].title) + + def test_a_template_with_no_parameters_is_used_as_it_is(self): + finding = self.by_uid("fortytwocrunch_scan_many_vuln.json")["api-0001/scan/GET /orders/1"] + self.assertEqual("The operation accepted a request with no authentication", finding.title) + + def test_an_unresolvable_description_falls_back_to_the_operation(self): + finding = self.by_uid("fortytwocrunch_scan_many_vuln.json")["api-0001/scan/GET /health/99"] + self.assertEqual("GET /health", finding.title) + self.assertNotIn("* **OpenAPI location:**", finding.description) + + def test_the_scan_identity_avoids_the_per_scan_uuid(self): + """ + A scan issue's own id is a per-scan UUID, so it is not stable across scans. + + The identity is the operation plus the check index instead - the same for the same issue - so + rescanning updates a finding rather than creating a new one every time. + """ + findings = self.parse("fortytwocrunch_scan_many_vuln.json") + for finding in findings: + self.assertNotIn("3f8b0c1e", finding.unique_id_from_tool) + self.assertEqual( + ["api-0001/scan/GET /health/99", "api-0001/scan/GET /orders/0", + "api-0001/scan/GET /orders/1", "api-0001/scan/POST /orders/2"], + sorted(finding.unique_id_from_tool for finding in findings), + ) + + def test_the_method_is_uppercased_in_the_operation_and_the_tag(self): + findings = self.parse_string(self.scan()) + self.assertIn("* **Operation:** GET /x", findings[0].description) + self.assertIn("GET", findings[0].unsaved_tags) + + def test_a_response_status_of_zero_is_not_reported(self): + finding = self.by_uid("fortytwocrunch_scan_many_vuln.json")["api-0001/scan/POST /orders/2"] + self.assertNotIn("* **Response status:**", finding.description) + self.assertIsNone(finding.steps_to_reproduce) + + def test_the_endpoint_is_the_url_origin_only(self): + """ + A conformance scan hits many paths on one host, and the operation is already in the identity. + + So the endpoint carries the scheme and host, with no path - which is what the connector + records. + """ + finding = self.by_uid("fortytwocrunch_scan_many_vuln.json")["api-0001/scan/GET /orders/0"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("api.example.com", locations[0].host) + self.assertEqual("https", locations[0].protocol) + self.assertFalse(locations[0].path) + self.assertFalse(locations[0].query) + + def test_a_url_that_is_not_a_url_adds_no_endpoint(self): + """ + A host DefectDojo rejects makes Endpoint.clean() raise, which fails the WHOLE import. + + The URL is still in the description, so nothing is lost by declining to build an endpoint. + """ + finding = self.by_uid("fortytwocrunch_scan_many_vuln.json")["api-0001/scan/GET /health/99"] + self.assertEqual(0, len(self.get_unsaved_locations(finding))) + self.assertIn("* **URL:** not a url at all", finding.description) + + def test_a_url_with_no_scheme_adds_no_endpoint(self): + """The connector requires both a scheme and a host before it records an origin.""" + findings = self.parse_string(self.scan(issue={"criticality": 3, "injectionDescription": 0, + "url": "api.example.com/orders"})) + self.assertEqual(0, len(self.get_unsaved_locations(findings[0]))) + + def test_a_url_naming_a_port_keeps_it(self): + findings = self.parse_string(self.scan(issue={"criticality": 3, "injectionDescription": 0, + "url": "https://api.example.com:8443/orders"})) + locations = self.get_unsaved_locations(findings[0]) + self.assertEqual(8443, locations[0].port) + + def test_the_two_report_types_are_told_apart_by_shape(self): + """ + 42Crunch produces an audit of the definition and a scan of the running API. + + Both convert under one scan type, and a file is one or the other - so the shape decides: a + scan report has a per-path issue tree, an audit report has an index table and its sections. + """ + audit = self.parse("fortytwocrunch_audit_one_vuln.json")[0] + self.assertTrue(audit.static_finding) + self.assertFalse(audit.dynamic_finding) + self.assertIn("audit", audit.unsaved_tags) + + scan = self.parse("fortytwocrunch_scan_many_vuln.json")[0] + self.assertFalse(scan.static_finding) + self.assertTrue(scan.dynamic_finding) + self.assertIn("scan", scan.unsaved_tags) + + def test_an_unwrapped_report_is_accepted(self): + """A report downloaded as-is has no wrapper, and then it carries no API id either.""" + payload = {"index": ["/paths/~1x/get"], + "security": {"issues": {"issue-1": {"criticality": 4, "description": "An issue", + "issues": [{"pointer": 0}]}}}} + findings = self.parse_string(payload) + self.assertEqual(1, len(findings)) + self.assertEqual("/audit/issue-1//paths/~1x/get", findings[0].unique_id_from_tool) + + def test_the_api_id_may_be_spelled_three_ways(self): + """ + The API id is part of every identity the connector builds and a report does not carry it. + + Without it a file import will not deduplicate against synced findings, so all the spellings a + user might reach for are accepted. + """ + for key in ("apiId", "api_id", "apiID"): + with self.subTest(key=key): + payload = self.audit() + payload.pop("apiId") + payload[key] = "api-9" + findings = self.parse_string(payload) + self.assertTrue(findings[0].unique_id_from_tool.startswith("api-9/audit/")) + + def test_a_long_title_is_truncated_with_an_ellipsis(self): + long_description = "x" * 300 + findings = self.parse_string(self.audit(description=long_description)) + self.assertEqual(250, len(findings[0].title)) + self.assertTrue(findings[0].title.endswith("...")) + # The full text is still in the body. + self.assertIn(long_description, findings[0].description) + + def test_a_title_at_the_limit_is_not_truncated(self): + exact = "x" * 250 + findings = self.parse_string(self.audit(description=exact)) + self.assertEqual(exact, findings[0].title) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string(["not a report"]) + self.assertIn("42Crunch", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("Conformance Scan", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + audit = {"apiId": "api-1", "report": {"index": ["/paths/~1x/get"], "security": {"issues": { + "issue-bad": "not an object", + "issue-no-list": {"criticality": 4, "issues": "not a list"}, + "issue-1": {"criticality": 4, "description": "An issue", + "issues": ["not an object", None, {"pointer": 0}]}, + }}}} + findings = self.parse_string(audit) + self.assertEqual(1, len(findings)) + self.assertEqual("api-1/audit/issue-1//paths/~1x/get", findings[0].unique_id_from_tool) + + def test_malformed_scan_rows_are_skipped(self): + scan = {"apiId": "api-1", "report": {"data": { + "index": {"jsonPointers": [], "injectionDescriptions": ["A problem"]}, + "paths": {"/x": {"get": {"issues": ["not an object", None, + {"criticality": 3, "injectionDescription": 0}]}, + "post": "not an object"}, + "/y": "not an object"}}}} + findings = self.parse_string(scan) + self.assertEqual(1, len(findings)) + self.assertEqual("api-1/scan/GET /x/0", findings[0].unique_id_from_tool) + + def test_severity_is_always_a_known_value(self): + for filename in ("fortytwocrunch_audit_many_vuln.json", "fortytwocrunch_audit_one_vuln.json", + "fortytwocrunch_scan_many_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_fossa_parser.py b/unittests/tools/test_fossa_parser.py new file mode 100644 index 00000000000..ac6accab44b --- /dev/null +++ b/unittests/tools/test_fossa_parser.py @@ -0,0 +1,258 @@ +import io +import json +from datetime import date + +from dojo.models import Finding, Test +from dojo.tools.fossa.parser import FossaParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestFossaParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("fossa") / filename).open(encoding="utf-8") as file: + return list(FossaParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the FOSSA connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = FossaParser() + self.assertEqual(["FOSSA - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "FOSSA - Connectors Import", + parser.get_label_for_scan_types("FOSSA - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("fossa_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("fossa_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring IssueToFinding in the connector's converter.""" + findings = self.parse("fossa_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + # converter vulnerabilityTitle(): "<CVE> - <package> (<version>)" + self.assertEqual("CVE-2000-0001 - generic-lib (1.2.3)", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("generic-lib", finding.component_name) + self.assertEqual("1.2.3", finding.component_version) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + # converter cweNumber(): first parseable, "CWE-1321" -> 1321. + self.assertEqual(1321, finding.cwe) + self.assertEqual(date(2026, 5, 11), finding.date) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + # converter: "<issue id>:<project locator>" + self.assertEqual("1001:custom+1/generic-app", finding.unique_id_from_tool) + + def test_the_unique_id_is_suffixed_with_the_project_locator(self): + """ + One FOSSA issue can affect several projects, and the connector emits one finding per project. + + Without the locator suffix those findings would share a tool id, and DefectDojo would treat + the same dependency issue in two products as one finding. + """ + findings = self.parse("fossa_many_vuln.json") + shared = [f for f in findings if f.unique_id_from_tool.startswith("1001:")] + self.assertEqual(2, len(shared)) + self.assertEqual( + {"1001:custom+1/generic-app", "1001:custom+1/generic-service"}, + {f.unique_id_from_tool for f in shared}, + ) + # Same issue, so the same title and severity - only the tool id differs. + self.assertEqual({"CVE-2000-0001 - generic-lib (1.2.3)"}, {f.title for f in shared}) + + def test_an_export_with_no_project_context_uses_the_issue_id_alone(self): + """An export carrying no projects cannot reproduce the connector's suffix.""" + report = io.StringIO(json.dumps({"issues": [ + {"id": 42, "type": "vulnerability", "cve": "CVE-2000-0001", "severity": "high", + "source": {"name": "generic-lib", "version": "1.0.0"}}, + ]})) + finding = list(FossaParser().get_findings(report, Test()))[0] + self.assertEqual("42", finding.unique_id_from_tool) + + def test_the_description_mirrors_the_converters_order(self): + finding = self.parse("fossa_one_vuln.json")[0] + self.assertIn("A crafted payload can pollute", finding.description) + self.assertIn("**Package:** npm+generic-lib$1.2.3", finding.description) + self.assertIn("**Dependency:** generic-lib@1.2.3", finding.description) + self.assertIn("**Package manager:** npm", finding.description) + self.assertIn("**Dependency depths:** direct 1, transitive 3", finding.description) + self.assertIn("**FOSSA issue ID:** 1001", finding.description) + self.assertIn("**Affected versions:** <1.2.4", finding.description) + self.assertIn("**Patched versions:** >=1.2.4", finding.description) + self.assertIn("**CWEs:** CWE-1321, CWE-20", finding.description) + self.assertIn("**Published:** 2026-05-01", finding.description) + self.assertIn("**CVE status:** PUBLISHED", finding.description) + # The converter writes the package block before the version-range lines. + self.assertLess( + finding.description.index("**FOSSA issue ID:**"), + finding.description.index("**Affected versions:**"), + ) + + def test_mitigation_puts_the_complete_fix_first(self): + """The converter's mitigation(): the fix that resolves the issue leads, each with its semver distance.""" + finding = self.parse("fossa_one_vuln.json")[0] + self.assertEqual( + "**Complete fix:** upgrade to 2.0.0 (MAJOR version bump)\n" + "**Partial fix:** upgrade to 1.2.4 (PATCH version bump)", + finding.mitigation, + ) + + def test_a_partial_fix_with_no_distance_omits_the_parenthetical(self): + finding = self.by_uid("fossa_many_vuln.json")["1002:custom+1/generic-app"] + self.assertEqual("**Partial fix:** upgrade to 3.1.1", finding.mitigation) + + def test_references_accept_both_a_link_object_and_a_bare_string(self): + """ + FOSSA sends a reference either way, and the connector's Reference.Link() handles both. + + Rejecting the bare-string form would silently drop references. + """ + finding = self.parse("fossa_one_vuln.json")[0] + self.assertEqual( + "https://example.com/advisories/cve-2000-0001\nhttps://example.com/commit/abcdef1", + finding.references, + ) + + def test_a_reference_object_with_only_a_title_falls_back_to_it(self): + report = io.StringIO(json.dumps({"issues": [ + {"id": 7, "cve": "CVE-2000-0001", "severity": "low", + "references": [{"url": "", "title": "Vendor bulletin 7"}]}, + ]})) + finding = list(FossaParser().get_findings(report, Test()))[0] + self.assertEqual("Vendor bulletin 7", finding.references) + + def test_severity_falls_back_to_the_cvss_bands_when_fossa_says_unknown(self): + """ + The converter's vulnerabilitySeverity(): FOSSA reports "unknown" often enough for this to matter. + + A 7.5 with severity "unknown" must become High, not Info. + """ + finding = self.by_uid("fossa_many_vuln.json")["1002:custom+1/generic-app"] + self.assertEqual("High", finding.severity) + + def test_the_cvss_bands_directly(self): + parser = FossaParser() + for score, expected in [ + (10.0, "Critical"), (9.0, "Critical"), (8.9, "High"), (7.0, "High"), + (6.9, "Medium"), (4.0, "Medium"), (3.9, "Low"), (0.1, "Low"), (0, "Info"), + ]: + self.assertEqual(expected, parser.severity_from_cvss(score), score) + + def test_an_issue_is_treated_as_a_vulnerability_when_it_carries_cve_fields(self): + """ + The converter's isVulnerability(): the type check is backed up by the vulnerability-only fields. + + A missing or renamed type value must not silently downgrade a CVE to a licensing finding, + which would also change how it is severity-graded. + """ + finding = self.by_uid("fossa_many_vuln.json")["1006:custom+1/generic-app"] + self.assertEqual("CVE-2000-0006", finding.title) + self.assertEqual(["CVE-2000-0006"], finding.unsaved_vulnerability_ids) + self.assertIn("category:vulnerability", finding.unsaved_tags) + # cvss 0 and no severity, so the bands give Info rather than a licensing grade. + self.assertEqual("Info", finding.severity) + + def test_a_licensing_issue_is_titled_and_graded_from_the_type_table(self): + finding = self.by_uid("fossa_many_vuln.json")["1003:custom+1/generic-app"] + self.assertEqual( + "policy_conflict - GPL-3.0-only in com.example:generic-widget", finding.title, + ) + self.assertEqual("High", finding.severity) + self.assertIn("**Issue type:** policy_conflict", finding.description) + self.assertIn("**License:** GPL-3.0-only", finding.description) + # Licensing issues carry none of the vulnerability-only fields. + self.assertIsNone(finding.mitigation) + self.assertIsNone(finding.references) + self.assertIsNone(finding.cvssv3_score) + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_both_spellings_of_the_risk_types_are_graded(self): + """ + FOSSA's docs table hyphenates (risk_empty-package); fossa-cli's wire format uses underscores. + + Mapping only one spelling would drop the other to Info. + """ + parser = FossaParser() + for issue_type in ("risk_empty-package", "risk_empty_package", + "risk_native-code", "risk_native_code"): + self.assertEqual("Low", parser.severity({"type": issue_type}), issue_type) + # Confirmed against the fixture too, not just the table. + finding = self.by_uid("fossa_many_vuln.json")["1004:custom+1/generic-app"] + self.assertEqual("Low", finding.severity) + + def test_an_unrecognised_risk_type_is_still_low_and_anything_else_is_info(self): + """The converter's severity(): an unknown risk_* signal is quality noise; other types are unknown.""" + parser = FossaParser() + self.assertEqual("Low", parser.severity({"type": "risk_something_new"})) + self.assertEqual("Info", parser.severity({"type": "some_new_type_fossa_added"})) + finding = self.by_uid("fossa_many_vuln.json")["1005:custom+1/generic-app"] + self.assertEqual("Info", finding.severity) + + def test_an_unparseable_cwe_list_leaves_the_cwe_at_zero(self): + """ + The converter's cweNumber() returns ok=false, so the converter never assigns Cwe. + + Finding.cwe is an IntegerField with default 0, so this reads as 0 rather than None. + """ + finding = self.by_uid("fossa_many_vuln.json")["1002:custom+1/generic-app"] + self.assertEqual(0, finding.cwe) + + def test_a_date_only_timestamp_is_accepted(self): + """The converter's findingDate() falls back to the leading date portion.""" + finding = self.by_uid("fossa_many_vuln.json")["1004:custom+1/generic-app"] + self.assertEqual(date(2026, 5, 14), finding.date) + + def test_an_unparseable_timestamp_leaves_the_date_unset(self): + finding = self.by_uid("fossa_many_vuln.json")["1005:custom+1/generic-app"] + self.assertIsNone(finding.date) + + def test_a_vulnerability_with_no_identifier_falls_back_to_the_issue_title(self): + report = io.StringIO(json.dumps({"issues": [ + {"id": 9, "type": "vulnerability", "title": "Unnamed advisory", "severity": "medium"}, + ]})) + self.assertEqual( + "Unnamed advisory", list(FossaParser().get_findings(report, Test()))[0].title, + ) + + def test_a_vulnerability_with_neither_identifier_nor_title_names_the_issue_id(self): + report = io.StringIO(json.dumps({"issues": [ + {"id": 9, "type": "vulnerability", "severity": "medium"}, + ]})) + self.assertEqual( + "FOSSA vulnerability 9", list(FossaParser().get_findings(report, Test()))[0].title, + ) + + def test_tags_mirror_the_converter(self): + finding = self.parse("fossa_one_vuln.json")[0] + self.assertEqual( + ["fossa:vulnerability", "category:vulnerability", "package-manager:npm", + "npm+generic-lib$1.2.3"], + finding.unsaved_tags, + ) + + def test_a_bare_issues_array_is_accepted(self): + report = io.StringIO(json.dumps([ + {"id": 1, "type": "vulnerability", "cve": "CVE-2000-0001", "severity": "low"}, + ])) + self.assertEqual(1, len(list(FossaParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(FossaParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("issues", str(raised.exception)) diff --git a/unittests/tools/test_gitguardian_parser.py b/unittests/tools/test_gitguardian_parser.py new file mode 100644 index 00000000000..253865848b5 --- /dev/null +++ b/unittests/tools/test_gitguardian_parser.py @@ -0,0 +1,164 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.gitguardian.parser import GitGuardianParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestGitGuardianParser(DojoTestCase): + def scan(self, filename): + return get_unit_tests_scans_path("gitguardian") / filename + + def parse(self, filename): + with self.scan(filename).open(encoding="utf-8") as file: + return list(GitGuardianParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal ScanTypeName in the GitGuardian connector verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = GitGuardianParser() + self.assertEqual(["GitGuardian - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "GitGuardian - Connectors Import", + parser.get_label_for_scan_types("GitGuardian - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("gitguardian_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("gitguardian_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring toFinding in the connector's converter.""" + findings = self.parse("gitguardian_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("AWS IAM key exposed in generic-app", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("aws_iam", finding.vuln_id_from_tool) + self.assertEqual("gitguardian-incident-5001", finding.unique_id_from_tool) + self.assertEqual("https://dashboard.example.com/incidents/5001", finding.url) + + self.assertIn("GitGuardian detected an exposed **AWS IAM Key**.", finding.description) + self.assertIn("**Detector:** family api_key, category cloud_provider", finding.description) + self.assertIn("**Occurrences:** 3", finding.description) + self.assertIn("**Details:** https://dashboard.example.com/incidents/5001", + finding.description) + + def test_a_confirmed_live_credential_is_marked_verified(self): + """ + GitGuardian actively checks whether a discovered credential still authenticates. + + The connector marks only "valid" as verified: an unchecked credential is not evidence either + way, and marking it verified would overstate what GitGuardian knows. + """ + finding = self.parse("gitguardian_one_vuln.json")[0] + self.assertTrue(finding.verified) + self.assertIn("still live and actively exploitable", finding.description) + + def test_an_invalid_or_unchecked_credential_is_not_marked_verified(self): + findings = self.by_uid("gitguardian_many_vuln.json") + for uid in ("gitguardian-incident-5002", "gitguardian-incident-5003", + "gitguardian-incident-5004"): + self.assertFalse(findings[uid].verified, uid) + + def test_the_validity_verdict_is_spelled_out(self): + """ + The converter's validityNarrative(): the three unverified states share one narrative. + + A bare enum value such as "no_checker" tells a triager nothing. + """ + findings = self.by_uid("gitguardian_many_vuln.json") + self.assertIn("no longer authenticates", + findings["gitguardian-incident-5002"].description) + self.assertIn("could not automatically check", + findings["gitguardian-incident-5003"].description) + # An unrecognised validity contributes no narrative line at all. + self.assertNotIn("**Validity:**", findings["gitguardian-incident-5004"].description) + + def test_all_three_unverified_states_share_the_narrative(self): + parser = GitGuardianParser() + for validity in ("no_checker", "not_checked", "failed_to_check"): + finding = parser.build_finding( + {"id": 1, "validity": validity, "detector": {}}, Test(), + ) + self.assertIn("could not automatically check", finding.description, validity) + + def test_a_revoked_secret_is_called_out(self): + finding = self.by_uid("gitguardian_many_vuln.json")["gitguardian-incident-5002"] + self.assertIn("**Revoked:** the secret has been marked revoked in GitGuardian.", + finding.description) + + def test_an_incident_with_no_name_is_titled_from_the_detector(self): + finding = self.by_uid("gitguardian_many_vuln.json")["gitguardian-incident-5002"] + self.assertEqual("Generic API Key detected", finding.title) + + def test_an_incident_with_no_detector_display_name_falls_back_to_secret(self): + """The converter's own fallbacks: "Secret detected", and "secret" in the description.""" + finding = self.by_uid("gitguardian_many_vuln.json")["gitguardian-incident-5003"] + self.assertEqual("Secret detected", finding.title) + self.assertIn("GitGuardian detected an exposed **secret**.", finding.description) + # An empty family and category contribute no detector line. + self.assertNotIn("**Detector:**", finding.description) + # A zero occurrence count is omitted rather than printed as zero. + self.assertNotIn("**Occurrences:**", finding.description) + + def test_an_incident_with_no_url_leaves_the_url_unset(self): + finding = self.by_uid("gitguardian_many_vuln.json")["gitguardian-incident-5003"] + self.assertIsNone(finding.url) + self.assertNotIn("**Details:**", finding.description) + + def test_an_unrecognised_severity_is_info(self): + finding = self.by_uid("gitguardian_many_vuln.json")["gitguardian-incident-5004"] + self.assertEqual("Info", finding.severity) + + def test_every_finding_carries_the_revoke_and_purge_mitigation(self): + """ + Every incident here is an exposed credential, so the remediation is always the same. + + The connector hardcodes it, and leaving it off would ship findings with no guidance. + """ + for finding in self.parse("gitguardian_many_vuln.json"): + self.assertIn("Revoke and rotate the exposed credential", finding.mitigation) + self.assertIn("purge it from version-control history", finding.mitigation) + + def test_no_secret_value_is_imported(self): + """ + GitGuardian's incidents endpoint does not return the matched secret. + + Asserted so a future change that starts pulling occurrences cannot quietly begin copying + credentials into the database. + """ + raw = self.scan("gitguardian_many_vuln.json").read_text(encoding="utf-8") + self.assertNotIn("secret_value", raw) + for finding in self.parse("gitguardian_many_vuln.json"): + self.assertNotIn("secret_value", finding.description) + + def test_an_envelope_is_accepted(self): + incident = {"id": 1, "severity": "low", "detector": {"name": "d", "display_name": "D"}} + for payload in ({"incidents": [incident]}, {"results": [incident]}, {"data": [incident]}): + report = io.StringIO(json.dumps(payload)) + self.assertEqual( + 1, len(list(GitGuardianParser().get_findings(report, Test()))), payload, + ) + + def test_a_repeated_incident_id_collapses(self): + incident = {"id": 1, "severity": "low", "detector": {"name": "d"}} + report = io.StringIO(json.dumps([incident, incident])) + self.assertEqual(1, len(list(GitGuardianParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(GitGuardianParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("incidents", str(raised.exception)) diff --git a/unittests/tools/test_googlescc_parser.py b/unittests/tools/test_googlescc_parser.py new file mode 100644 index 00000000000..90431d8c91f --- /dev/null +++ b/unittests/tools/test_googlescc_parser.py @@ -0,0 +1,205 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.googlescc.parser import GoogleSCCParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestGoogleSCCParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("googlescc") / filename).open(encoding="utf-8") as file: + return list(GoogleSCCParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal ScanTypeName in the Google Cloud SCC connector verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = GoogleSCCParser() + self.assertEqual(["Google Cloud SCC - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Google Cloud SCC - Connectors Import", + parser.get_label_for_scan_types("Google Cloud SCC - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("googlescc_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("googlescc_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring toFinding in the connector's converter.""" + findings = self.parse("googlescc_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("PUBLIC_BUCKET_ACL - generic-app-assets", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + # The SCC category is its rule identifier. + self.assertEqual("PUBLIC_BUCKET_ACL", finding.vuln_id_from_tool) + self.assertEqual( + "organizations/000000000001/sources/000000000002/findings/aaaa1111", + finding.unique_id_from_tool, + ) + self.assertEqual( + "https://console.example.com/storage/browser/generic-app-assets", finding.url, + ) + + self.assertIn("The bucket grants access to allUsers.", finding.description) + self.assertIn("**Finding class:** MISCONFIGURATION", finding.description) + self.assertIn( + "**Resource:** google.cloud.storage.Bucket //storage.googleapis.com/generic-app-assets", + finding.description, + ) + self.assertIn("**Reference:** https://console.example.com/", finding.description) + + def test_the_finding_and_the_resource_are_siblings_not_nested(self): + """ + SCC's ListFindings pairs each finding with the resource it was found on, side by side. + + The resource carries the display name and type that make the finding readable, so reading only + the finding half would lose them - and reading the result as if it were the finding would find + nothing at all. + """ + raw = json.loads((get_unit_tests_scans_path("googlescc") + / "googlescc_one_vuln.json").read_text(encoding="utf-8")) + result = raw["listFindingsResults"][0] + self.assertIn("finding", result) + self.assertIn("resource", result) + self.assertNotIn("resource", result["finding"]) + self.assertNotIn("displayName", result["finding"]) + + finding = self.parse("googlescc_one_vuln.json")[0] + # The display name comes from the resource half, the category from the finding half. + self.assertEqual("PUBLIC_BUCKET_ACL - generic-app-assets", finding.title) + + def test_the_title_is_the_category_alone_when_the_resource_has_no_display_name(self): + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/cccc3333" + ] + self.assertEqual("OS_VULNERABILITY", finding.title) + + def test_a_finding_with_no_category_is_named_by_the_connectors_fallback(self): + """SCC does not always set a category, and an empty title would be useless in the finding list.""" + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/dddd4444" + ] + self.assertEqual("Security Command Center finding", finding.title) + self.assertIsNone(finding.vuln_id_from_tool) + + def test_severity_unspecified_is_info(self): + """SCC's own SEVERITY_UNSPECIFIED is not a DefectDojo severity and falls through.""" + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/dddd4444" + ] + self.assertEqual("Info", finding.severity) + + def test_the_severity_ladder(self): + findings = self.by_uid("googlescc_many_vuln.json") + base = "organizations/000000000001/sources/000000000002/findings/" + self.assertEqual("High", findings[base + "aaaa1111"].severity) + self.assertEqual("Critical", findings[base + "bbbb2222"].severity) + self.assertEqual("Medium", findings[base + "cccc3333"].severity) + self.assertEqual("Low", findings[base + "eeee5555"].severity) + + def test_a_vulnerability_class_finding_carries_its_cve_and_score(self): + """ + SCC reports several finding classes and only some carry a CVE, nested two objects deep. + + A misconfiguration finding has no vulnerability block at all, so the lookup has to tolerate + that rather than assuming every finding has one. + """ + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/bbbb2222" + ] + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(9.8, finding.cvssv3_score) + + def test_a_misconfiguration_finding_has_no_cve(self): + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/aaaa1111" + ] + self.assertIsNone(finding.unsaved_vulnerability_ids) + self.assertIsNone(finding.cvssv3_score) + + def test_a_cve_with_a_zero_score_records_the_cve_but_no_score(self): + """The connector only sets the score when it is above zero.""" + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/cccc3333" + ] + self.assertEqual(["CVE-2000-0002"], finding.unsaved_vulnerability_ids) + self.assertIsNone(finding.cvssv3_score) + + def test_an_empty_vulnerability_block_is_tolerated(self): + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/eeee5555" + ] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_a_finding_with_no_external_uri_has_no_url_or_reference_line(self): + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/bbbb2222" + ] + self.assertIsNone(finding.url) + self.assertNotIn("**Reference:**", finding.description) + + def test_the_resource_line_is_omitted_when_there_is_nothing_to_show(self): + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/dddd4444" + ] + self.assertNotIn("**Resource:**", finding.description) + + def test_the_resource_line_shows_whichever_parts_exist(self): + """The type and name are space-joined, and either may be missing.""" + finding = self.by_uid("googlescc_many_vuln.json")[ + "organizations/000000000001/sources/000000000002/findings/cccc3333" + ] + self.assertIn( + "**Resource:** //compute.googleapis.com/projects/p/instances/other-vm", + finding.description, + ) + + def test_a_flattened_export_is_accepted(self): + report = io.StringIO(json.dumps({"listFindingsResults": [{ + "name": "organizations/1/sources/2/findings/f1", + "category": "PUBLIC_BUCKET_ACL", + "severity": "HIGH", + "description": "Flattened.", + "resource": {"displayName": "a-bucket", "type": "google.cloud.storage.Bucket"}, + }]})) + finding = list(GoogleSCCParser().get_findings(report, Test()))[0] + self.assertEqual("PUBLIC_BUCKET_ACL - a-bucket", finding.title) + self.assertEqual("High", finding.severity) + + def test_a_result_with_no_finding_half_is_skipped(self): + report = io.StringIO(json.dumps({"listFindingsResults": [ + {"resource": {"displayName": "orphan"}}, + {"finding": {"name": "n1", "category": "C", "severity": "LOW"}}, + ]})) + findings = list(GoogleSCCParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("C", findings[0].title) + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([ + {"finding": {"name": "n1", "category": "C", "severity": "LOW"}}, + ])) + self.assertEqual(1, len(list(GoogleSCCParser().get_findings(report, Test())))) + + def test_a_repeated_finding_name_collapses(self): + row = {"finding": {"name": "same", "category": "C", "severity": "LOW"}} + report = io.StringIO(json.dumps({"listFindingsResults": [row, row]})) + self.assertEqual(1, len(list(GoogleSCCParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(GoogleSCCParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("listFindingsResults", str(raised.exception)) diff --git a/unittests/tools/test_groupib_parser.py b/unittests/tools/test_groupib_parser.py new file mode 100644 index 00000000000..b91b710f6f5 --- /dev/null +++ b/unittests/tools/test_groupib_parser.py @@ -0,0 +1,280 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.groupib.parser import GroupibParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestGroupibParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("groupib") / filename + with path.open(encoding="utf-8") as file: + return list(GroupibParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(GroupibParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def issue(self, lifecycle="Detected", **body): + """ + One issue. Note the two different "status" fields Group-IB uses. + + The LIFECYCLE status is the issue's own; the severity label is the one inside its body. The + helper names them apart because a single keyword cannot mean both - which is the same confusion + the parser exists to resolve. + """ + row = {"category": "Vulnerability", "type": "An issue", "status": "High severity"} + row.update(body) + return {"items": [{"id": "issue-1", "status": lifecycle, "body": row}]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """Must equal the Group-IB connector's ScanTypeName verbatim.""" + parser = GroupibParser() + self.assertEqual(["Group-IB ASM - Connectors Import"], parser.get_scan_types()) + self.assertEqual("Group-IB ASM - Connectors Import", + parser.get_label_for_scan_types("Group-IB ASM - Connectors Import")) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("groupib_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("groupib_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ConvertIssue in the connector's issue_converter.""" + findings = self.parse("groupib_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Exposed administrative interface", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("issue-0001", finding.unique_id_from_tool) + self.assertEqual("Exposed administrative interface", finding.vuln_id_from_tool) + self.assertEqual(date(2024, 6, 2), finding.date) + self.assertTrue(finding.active) + self.assertFalse(finding.is_mitigated) + self.assertFalse(finding.false_p) + self.assertFalse(finding.out_of_scope) + # ASM findings come from external scanning. + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertEqual(["mitre-attack:T1190", "mitre-attack:T1595"], finding.unsaved_tags) + self.assertEqual( + "**Category:** Vulnerability\n" + "**Type:** Exposed administrative interface\n" + "**Asset:** admin.example.com\n" + "**Asset status:** Confirmed\n" + "**Asset discovered:** 2024-05-01\n" + "**Reason:** The management console is reachable from the internet\n" + "**Details:** The console accepted a connection from an external address.\n" + "**Context:** Discovered during external scanning.", + finding.description, + ) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("admin.example.com", locations[0].host) + + def test_many_vuln(self): + self.assertEqual(6, len(self.parse("groupib_many_vuln.json"))) + + def test_the_severity_is_the_body_status_not_the_issue_status(self): + """ + Two fields are called "status" and mean different things. + + The issue's own status is its lifecycle state; the one in its body is the SEVERITY label. + Reading the lifecycle status as a severity would grade every finding Info. + """ + finding = self.by_uid("groupib_many_vuln.json")["issue-0001"] + self.assertEqual("Critical", finding.severity) + self.assertTrue(finding.active) + + def test_severity_labels_are_matched_by_containment(self): + """Group-IB writes the severity as a phrase, so equality would never match.""" + for label, expected in (("Critical severity", "Critical"), ("High severity", "High"), + ("Medium severity", "Medium"), ("Low severity", "Low"), + ("Info severity", "Info"), ("critical", "Critical"), + ("a label it does not use", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.issue(status=label)) + self.assertEqual(expected, findings[0].severity) + + def test_the_worse_keyword_wins_when_a_label_names_two(self): + findings = self.parse_string(self.issue(status="Critical, was High severity")) + self.assertEqual("Critical", findings[0].severity) + + def test_the_lifecycle_status_sets_the_right_flag(self): + """ + The three closing states mean different things and are kept apart. + + A solved issue was fixed, an ignored one was accepted, and a false positive was never real. + """ + findings = self.by_uid("groupib_many_vuln.json") + + solved = findings["issue-0002"] + self.assertFalse(solved.active) + self.assertTrue(solved.is_mitigated) + self.assertFalse(solved.false_p) + + false_positive = findings["issue-0003"] + self.assertFalse(false_positive.active) + self.assertTrue(false_positive.false_p) + self.assertFalse(false_positive.is_mitigated) + + ignored = findings["issue-0004"] + self.assertFalse(ignored.active) + self.assertTrue(ignored.out_of_scope) + self.assertFalse(ignored.false_p) + + def test_open_and_unrecognised_statuses_stay_active(self): + """ + "Detected" and "Under review" are open, and so is anything unrecognised. + + Staying active is the safe direction to be wrong in. + """ + for status in ("Detected", "Under review", "Something new", "", "detected"): + with self.subTest(status=status): + findings = self.parse_string(self.issue(lifecycle=status)) + self.assertTrue(findings[0].active) + self.assertFalse(findings[0].is_mitigated) + self.assertFalse(findings[0].out_of_scope) + + def test_status_matching_ignores_case(self): + for status, flag in (("solved", "is_mitigated"), ("SOLVED", "is_mitigated"), + ("false positive", "false_p"), ("Ignored", "out_of_scope")): + with self.subTest(status=status): + findings = self.parse_string(self.issue(lifecycle=status)) + self.assertFalse(findings[0].active) + self.assertTrue(getattr(findings[0], flag)) + + def test_a_host_shaped_asset_becomes_an_endpoint(self): + finding = self.by_uid("groupib_many_vuln.json")["issue-0006"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("files.example.com", locations[0].host) + self.assertIsNone(finding.component_name) + + def test_an_address_with_a_port_becomes_an_endpoint_with_that_port(self): + """ + Group-IB sends a bare host or address with no scheme. + + The connector prefixes "//" so DefectDojo reads it as an authority rather than a path; building + the endpoint from its parts reaches the same result without the string trick. + """ + finding = self.by_uid("groupib_many_vuln.json")["issue-0002"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("10.20.0.11", locations[0].host) + self.assertEqual(8443, locations[0].port) + + def test_a_url_asset_keeps_its_scheme_and_path_is_not_the_host(self): + finding = self.by_uid("groupib_many_vuln.json")["issue-0004"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("www.example.com", locations[0].host) + self.assertEqual("https", locations[0].protocol) + + def test_an_asset_that_is_not_host_shaped_becomes_the_component(self): + """ + Group-IB reports software names and SSL descriptors in the same field as hosts. + + Recording a software name as an endpoint would make Endpoint.clean() raise and fail the whole + import, so it becomes the component instead - and is not lost. + """ + finding = self.by_uid("groupib_many_vuln.json")["issue-0003"] + self.assertEqual("OpenSSL 1.0.2k", finding.component_name) + self.assertEqual(0, len(self.get_unsaved_locations(finding))) + + def test_asset_shapes(self): + host_shaped = ("app.example.com", "10.20.0.11", "10.20.0.11:8443", + "https://app.example.com/x", "//app.example.com") + not_host_shaped = ("OpenSSL 1.0.2", "SSL certificate expiry", "no-dot-here", + "path/like/value", "1.2.3.999999") + for asset in host_shaped: + with self.subTest(asset=asset, shape="host"): + findings = self.parse_string(self.issue(asset=asset)) + self.assertEqual(1, len(self.get_unsaved_locations(findings[0])), + f"{asset} should be an endpoint") + for asset in not_host_shaped: + with self.subTest(asset=asset, shape="component"): + findings = self.parse_string(self.issue(asset=asset)) + self.assertEqual(0, len(self.get_unsaved_locations(findings[0])), + f"{asset} should not be an endpoint") + self.assertEqual(asset, findings[0].component_name) + + def test_an_empty_asset_records_neither(self): + finding = self.by_uid("groupib_many_vuln.json")["issue-0005"] + self.assertIsNone(finding.component_name) + self.assertEqual(0, len(self.get_unsaved_locations(finding))) + + def test_the_title_falls_back_through_the_reason_and_the_category(self): + findings = self.by_uid("groupib_many_vuln.json") + self.assertEqual("A login form was found on a marketing host", findings["issue-0004"].title) + self.assertEqual("Group-IB ASM issue issue-0005", findings["issue-0005"].title) + + def test_an_issue_with_no_details_says_so(self): + """An empty description would read as though the data had been lost in transit.""" + finding = self.by_uid("groupib_many_vuln.json")["issue-0005"] + self.assertEqual("No additional details were provided by Group-IB ASM.", finding.description) + + def test_mitre_techniques_become_sorted_tags(self): + """Group-IB sends them as a MAP keyed by technique, so the sort is what makes them stable.""" + finding = self.by_uid("groupib_many_vuln.json")["issue-0001"] + self.assertEqual(["mitre-attack:T1190", "mitre-attack:T1595"], finding.unsaved_tags) + + def test_an_empty_mitre_map_produces_no_tags(self): + findings = self.by_uid("groupib_many_vuln.json") + self.assertIsNone(findings["issue-0006"].unsaved_tags) + self.assertIsNone(findings["issue-0002"].unsaved_tags) + + def test_an_unparseable_first_seen_leaves_the_date_alone(self): + finding = self.by_uid("groupib_many_vuln.json")["issue-0005"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_export_shapes(self): + issue = {"id": "issue-1", "status": "Detected", + "body": {"type": "An issue", "status": "Low severity"}} + for payload in ([issue], {"items": [issue]}, {"data": [issue]}, {"results": [issue]}, + {"issues": [issue]}): + with self.subTest(shape=str(payload)[:20]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Group-IB", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("items", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"items": [ + "not an object", + None, + {"id": "issue-9", "status": "Detected", "body": "not an object"}, + {"id": "issue-8", "status": "Detected", + "body": {"type": "An issue", "status": "Low severity", + "alertMitreInfo": "not a map"}}, + ]}) + self.assertEqual(2, len(findings)) + by_uid = {finding.unique_id_from_tool: finding for finding in findings} + # A row with no usable body still becomes a finding, titled after its id. + self.assertEqual("Group-IB ASM issue issue-9", by_uid["issue-9"].title) + self.assertIsNone(by_uid["issue-8"].unsaved_tags) + + def test_the_hash_is_only_the_title_and_severity(self): + """An ASM issue has neither a file nor a package to hash.""" + self.assertEqual(["title", "severity"], GroupibParser().get_dedupe_fields()) + + def test_severity_is_always_a_known_value(self): + for filename in ("groupib_many_vuln.json", "groupib_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_hackerone_parser.py b/unittests/tools/test_hackerone_parser.py new file mode 100644 index 00000000000..43058f773bb --- /dev/null +++ b/unittests/tools/test_hackerone_parser.py @@ -0,0 +1,170 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.hackerone.parser import HackerOneParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestHackerOneParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("hackerone") / filename).open(encoding="utf-8") as file: + return list(HackerOneParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal ScanTypeName in the HackerOne connector verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = HackerOneParser() + self.assertEqual(["HackerOne - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "HackerOne - Connectors Import", + parser.get_label_for_scan_types("HackerOne - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("hackerone_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("hackerone_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring toFinding in the connector's converter.""" + findings = self.parse("hackerone_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Stored XSS in the profile page", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("1000001", finding.unique_id_from_tool) + # The converter puts the report id in BOTH id fields. + self.assertEqual("1000001", finding.vuln_id_from_tool) + self.assertEqual(79, finding.cwe) + self.assertEqual(7.6, finding.cvssv3_score) + self.assertEqual("https://hackerone.com/reports/1000001", finding.url) + + self.assertIn("stored cross-site scripting issue", finding.description) + self.assertIn("**Weakness:** Cross-site Scripting (XSS) - Stored", finding.description) + self.assertIn("**Reported by:** researcher-one", finding.description) + self.assertIn("**Report:** https://hackerone.com/reports/1000001", finding.description) + + def test_severity_weakness_and_reporter_are_json_api_relationships(self): + """ + These three are relationships on a HackerOne report, not attributes. + + Reading them off the top level - or off "attributes" - would leave every finding at Info with + no CWE and no reporter, because the API never puts them there. + """ + raw = json.loads((get_unit_tests_scans_path("hackerone") + / "hackerone_one_vuln.json").read_text(encoding="utf-8")) + report = raw["data"][0] + self.assertNotIn("severity", report["attributes"]) + self.assertNotIn("severity", report) + self.assertIn("severity", report["relationships"]) + + finding = self.parse("hackerone_one_vuln.json")[0] + self.assertEqual("High", finding.severity) + self.assertEqual(79, finding.cwe) + + def test_an_already_flattened_export_is_also_accepted(self): + """ + Someone exporting through a script may flatten the envelope. + + The connector reads JSON:API, but accepting the flattened shape costs nothing and avoids a + confusing empty import. + """ + report = io.StringIO(json.dumps({"data": [{ + "id": "42", + "title": "A flattened report", + "vulnerability_information": "Details.", + "severity": {"rating": "critical", "score": 9.1}, + "weakness": {"external_id": "cwe-89", "name": "SQL Injection"}, + "reporter": {"username": "researcher"}, + }]})) + finding = list(HackerOneParser().get_findings(report, Test()))[0] + self.assertEqual("A flattened report", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertEqual(89, finding.cwe) + self.assertEqual(9.1, finding.cvssv3_score) + self.assertIn("**Reported by:** researcher", finding.description) + + def test_the_severity_ladder_is_the_connectors(self): + findings = self.by_uid("hackerone_many_vuln.json") + self.assertEqual("High", findings["1000001"].severity) + self.assertEqual("Medium", findings["1000002"].severity) + # HackerOne's "none" rating is not a DefectDojo severity, so it falls through to Info. + self.assertEqual("Info", findings["1000003"].severity) + # The comparison is case-insensitive. + self.assertEqual("Critical", findings["1000004"].severity) + + def test_a_report_with_no_severity_score_gets_none(self): + """The converter only sets a score when HackerOne attached one above zero.""" + finding = self.by_uid("hackerone_many_vuln.json")["1000003"] + self.assertIsNone(finding.cvssv3_score) + + def test_a_weakness_id_that_is_not_a_cwe_leaves_the_cwe_at_zero(self): + """ + HackerOne weakness ids are lower-cased "cwe-<n>", but not every weakness maps to a CWE. + + Finding.cwe is an IntegerField with default 0, so this reads as 0 rather than None. + """ + finding = self.by_uid("hackerone_many_vuln.json")["1000004"] + self.assertEqual(0, finding.cwe) + + def test_the_cwe_parse_directly(self): + parser = HackerOneParser() + self.assertEqual(79, parser.cwe("cwe-79")) + self.assertEqual(79, parser.cwe("CWE-79")) + self.assertEqual(307, parser.cwe("cwe-307")) + self.assertEqual(0, parser.cwe("capec-63")) + self.assertEqual(0, parser.cwe("cwe-none")) + self.assertEqual(0, parser.cwe("")) + self.assertEqual(0, parser.cwe(None)) + + def test_a_report_with_no_relationships_still_imports(self): + """A report can arrive with no severity, weakness or reporter linked at all.""" + finding = self.by_uid("hackerone_many_vuln.json")["1000005"] + self.assertEqual("Report with no relationships at all", finding.title) + self.assertEqual("Info", finding.severity) + self.assertEqual(0, finding.cwe) + self.assertIsNone(finding.cvssv3_score) + self.assertNotIn("**Weakness:**", finding.description) + self.assertNotIn("**Reported by:**", finding.description) + + def test_the_report_link_is_always_appended(self): + """ + Every finding gets the link, even one with no prose at all. + + A bug-bounty finding is not actionable without a way back to the report and its comments. + """ + finding = self.by_uid("hackerone_many_vuln.json")["1000003"] + self.assertEqual( + "**Report:** https://hackerone.com/reports/1000003", finding.description, + ) + self.assertEqual("https://hackerone.com/reports/1000003", finding.url) + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([{ + "id": "7", "attributes": {"title": "A report"}, + "relationships": {"severity": {"data": {"attributes": {"rating": "low"}}}}, + }])) + findings = list(HackerOneParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("Low", findings[0].severity) + + def test_a_repeated_report_id_collapses(self): + report = {"id": "same", "attributes": {"title": "A report"}} + payload = io.StringIO(json.dumps({"data": [report, report]})) + self.assertEqual(1, len(list(HackerOneParser().get_findings(payload, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(HackerOneParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("data", str(raised.exception)) diff --git a/unittests/tools/test_halosecurity_parser.py b/unittests/tools/test_halosecurity_parser.py new file mode 100644 index 00000000000..42e86de5491 --- /dev/null +++ b/unittests/tools/test_halosecurity_parser.py @@ -0,0 +1,300 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.halosecurity.parser import HaloSecurityParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestHaloSecurityParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("halosecurity") / filename + with path.open(encoding="utf-8") as file: + return list(HaloSecurityParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(HaloSecurityParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Halo Security connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = HaloSecurityParser() + self.assertEqual(["Halo Security - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Halo Security - Connectors Import", + parser.get_label_for_scan_types("Halo Security - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("halosecurity_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("halosecurity_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring RowToFinding in the connector's converter.""" + findings = self.parse("halosecurity_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("TLS certificate expires in under 14 days", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("9001:3001", finding.unique_id_from_tool) + self.assertEqual("9001", finding.vuln_id_from_tool) + self.assertTrue(finding.active) + self.assertTrue(finding.verified) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + # The description text only exists on the separately-fetched detail. + self.assertIn("The certificate presented by the host expires shortly.", finding.description) + self.assertIn("**Target:** app.example.com", finding.description) + self.assertIn("**Halo status:** confirmed", finding.description) + self.assertIn("**Category:** Encryption", finding.description) + self.assertIn("**PCI:** this issue affects PCI compliance", finding.description) + self.assertIn("**Assigned to:** Platform Team", finding.description) + self.assertIn("**Scans since found:** 4", finding.description) + + def test_severity_is_an_integer_level_with_five_highest(self): + """ + Halo Security grades severity as an INTEGER, 5 being the most severe - the inverse of a + priority number. + + Treating it as a score, or assuming 1 is worst, would invert the whole ladder. + """ + parser = HaloSecurityParser() + for level, expected in [ + (5, "Critical"), (4, "High"), (3, "Medium"), (2, "Low"), (1, "Info"), (0, "Info"), + (9, "Info"), + ]: + self.assertEqual(expected, parser.severity({"severity": level}, {}), level) + + def test_the_severity_falls_back_to_the_detail(self): + """ + The list response sometimes omits the level, and the detail carries it. + + The fixture's last row has severity 0 on the row and 3 on the detail, so a row-only read would + grade it Info instead of Medium. + """ + finding = self.by_uid("halosecurity_many_vuln.json")["9005:0"] + self.assertEqual("Medium", finding.severity) + + def test_the_detail_is_merged_from_a_separate_lookup(self): + """ + Halo splits an issue across two calls: the list row, and a per-issue detail. + + The description, category, CVEs and PCI flag exist ONLY on the detail, so a row-only import + would produce findings with no prose at all. The export carries the details keyed by issue id. + """ + raw = json.loads((get_unit_tests_scans_path("halosecurity") + / "halosecurity_one_vuln.json").read_text(encoding="utf-8")) + row = raw["list"][0] + self.assertNotIn("description", row["issue"]) + self.assertIn("9001", raw["details"]) + self.assertIn("description", raw["details"]["9001"]) + + finding = self.parse("halosecurity_one_vuln.json")[0] + self.assertIn("The certificate presented by the host expires shortly.", finding.description) + + def test_a_row_with_no_detail_still_imports(self): + """Issue 9003 has no entry in the details map.""" + finding = self.by_uid("halosecurity_many_vuln.json")["9003:3002"] + self.assertEqual("Acknowledged false positive", finding.title) + self.assertNotIn("**Category:**", finding.description) + self.assertIn("**Target:** shop.example.com", finding.description) + + def test_a_detail_nested_on_the_row_is_also_accepted(self): + report = io.StringIO(json.dumps({"list": [{ + "issue": {"issue_id": 1, "name": "An issue", "severity": 4}, + "target": {"target_id": 2, "target": "app.example.com"}, + "status": {"target_id": 2, "status": "new"}, + "detail": {"issue_id": 1, "description": "Nested detail.", "category": "Headers"}, + }]})) + finding = list(HaloSecurityParser().get_findings(report, Test()))[0] + self.assertIn("Nested detail.", finding.description) + self.assertIn("**Category:** Headers", finding.description) + + def test_a_details_array_is_indexed_by_issue_id(self): + report = io.StringIO(json.dumps({ + "list": [{ + "issue": {"issue_id": 1, "name": "An issue", "severity": 4}, + "target": {"target_id": 2, "target": "app.example.com"}, + "status": {"target_id": 2, "status": "new"}, + }], + "details": [{"issue_id": 1, "description": "From an array.", "category": "Headers"}], + })) + finding = list(HaloSecurityParser().get_findings(report, Test()))[0] + self.assertIn("From an array.", finding.description) + + def test_the_same_issue_on_two_hosts_is_two_findings(self): + """ + Halo reports an issue once per affected host, and the target is part of the identity. + + Keying on the issue id alone would collapse them into one finding and lose a host. + """ + findings = self.by_uid("halosecurity_many_vuln.json") + self.assertIn("9002:3001", findings) + self.assertIn("9002:3002", findings) + self.assertEqual( + {"Missing HSTS header"}, + {findings["9002:3001"].title, findings["9002:3002"].title}, + ) + # And their states differ, which is the point of keeping them separate. + self.assertTrue(findings["9002:3001"].active) + self.assertFalse(findings["9002:3002"].active) + + def test_the_status_decides_the_defectdojo_state(self): + findings = self.by_uid("halosecurity_many_vuln.json") + + fixed = findings["9002:3002"] + self.assertFalse(fixed.active) + self.assertTrue(fixed.is_mitigated) + + false_positive = findings["9003:3002"] + self.assertFalse(false_positive.active) + self.assertTrue(false_positive.false_p) + + risk_accepted = findings["9004:3003"] + self.assertFalse(risk_accepted.active) + self.assertTrue(risk_accepted.risk_accepted) + + new = findings["9002:3001"] + self.assertTrue(new.active) + + def test_only_confirmed_fixing_and_fixed_count_as_verified(self): + """ + A new or investigating issue has not been confirmed by anyone yet. + + Marking those verified would overstate what Halo knows. + """ + findings = self.by_uid("halosecurity_many_vuln.json") + self.assertTrue(findings["9001:3001"].verified) # confirmed + self.assertTrue(findings["9002:3002"].verified) # fixed + self.assertFalse(findings["9002:3001"].verified) # new + self.assertFalse(findings["9003:3002"].verified) # ack_false_positive + + def test_the_unassigned_placeholder_is_not_reported(self): + """Halo writes "Nobody" to mean unassigned, so reporting it would be noise.""" + finding = self.by_uid("halosecurity_many_vuln.json")["9002:3001"] + self.assertNotIn("**Assigned to:**", finding.description) + self.assertNotIn("Nobody", finding.description) + + def test_the_pci_flag_becomes_a_line_and_a_tag(self): + finding = self.parse("halosecurity_one_vuln.json")[0] + self.assertIn("**PCI:** this issue affects PCI compliance", finding.description) + self.assertIn("pci", finding.unsaved_tags) + + def test_a_non_pci_issue_has_neither(self): + finding = self.by_uid("halosecurity_many_vuln.json")["9002:3001"] + self.assertNotIn("**PCI:**", finding.description) + self.assertNotIn("pci", finding.unsaved_tags) + + def test_tags_are_the_category_pci_flag_and_status(self): + finding = self.parse("halosecurity_one_vuln.json")[0] + self.assertEqual(["Encryption", "pci", "confirmed"], finding.unsaved_tags) + + def test_cves_come_from_the_detail_and_are_deduplicated(self): + """The fixture repeats one identifier to prove the deduplication.""" + finding = self.by_uid("halosecurity_many_vuln.json")["9004:3003"] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_an_issue_with_no_cves_has_none(self): + finding = self.parse("halosecurity_one_vuln.json")[0] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_the_scanned_host_is_recorded(self): + """ + This scan type's deduplication hashes the ENDPOINTS. + + An unpopulated endpoint would leave the hash computed over nothing and every rescan would + reimport. Asserted through get_unsaved_locations so it passes in both + V3_FEATURE_LOCATIONS modes. + """ + finding = self.parse("halosecurity_one_vuln.json")[0] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + + def test_endpoints_is_in_the_dedupe_fields_so_it_must_be_populated(self): + self.assertIn("endpoints", HaloSecurityParser().get_dedupe_fields()) + + def test_a_row_with_no_target_records_no_location(self): + finding = self.by_uid("halosecurity_many_vuln.json")["9005:0"] + self.assertEqual([], self.get_unsaved_locations(finding)) + + def test_a_row_with_no_name_anywhere_is_named_from_its_identity(self): + report = io.StringIO(json.dumps({"list": [{ + "issue": {"issue_id": 7, "name": "", "severity": 2}, + "status": {"target_id": 8, "status": "new"}, + }]})) + finding = list(HaloSecurityParser().get_findings(report, Test()))[0] + self.assertEqual("Halo Security issue 7:8", finding.title) + + def test_a_row_with_no_issue_block_is_skipped(self): + report = io.StringIO(json.dumps({"list": [ + {"target": {"target_id": 1, "target": "app.example.com"}}, + {"issue": {"issue_id": 1, "name": "An issue", "severity": 3}}, + ]})) + findings = list(HaloSecurityParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("An issue", findings[0].title) + + def test_the_finding_is_dated_today(self): + """ + Halo's list response carries no discovery date, so the connector stamps today. + + Asserted as a range so it cannot flake on a date rollover. + """ + finding = self.parse("halosecurity_one_vuln.json")[0] + self.assertLessEqual(abs((finding.date - datetime.now(tz=UTC).date()).days), 1) + + def test_a_bare_array_of_rows_is_accepted(self): + report = io.StringIO(json.dumps([{ + "issue": {"issue_id": 1, "name": "An issue", "severity": 4}, + "status": {"target_id": 2, "status": "new"}, + }])) + self.assertEqual(1, len(list(HaloSecurityParser().get_findings(report, Test())))) + + def test_a_repeated_issue_and_target_pair_collapses(self): + row = {"issue": {"issue_id": 1, "name": "An issue", "severity": 3}, + "status": {"target_id": 2, "status": "new"}} + report = io.StringIO(json.dumps({"list": [row, row]})) + self.assertEqual(1, len(list(HaloSecurityParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(HaloSecurityParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("list", str(raised.exception)) + + def test_a_target_with_a_port_is_split_rather_than_kept_whole(self): + """ + Halo scans a host and port, so its target can carry one. + + Keeping "host:port" in the host field fails DefectDojo's validation, and that fails the whole + import rather than the one finding. + """ + findings = self.parse_string({"list": [{ + "issue": {"issue_id": 1, "name": "An issue", "severity": 3}, + "target": {"target_id": 1, "target": "https://app.example.com:8443/login"}, + "status": {"issue_id": 1, "target_id": 1, "status": "new"}, + }]}) + location = self.get_unsaved_locations(findings[0])[0] + self.assertEqual("app.example.com", location.host) + self.assertEqual(8443, location.port) + + def test_a_target_that_cannot_be_a_host_is_dropped(self): + findings = self.parse_string({"list": [{ + "issue": {"issue_id": 1, "name": "An issue", "severity": 3}, + "target": {"target_id": 1, "target": "an internal service"}, + "status": {"issue_id": 1, "target_id": 1, "status": "new"}, + }]}) + self.assertEqual([], self.get_unsaved_locations(findings[0])) diff --git a/unittests/tools/test_hiddenlayer_parser.py b/unittests/tools/test_hiddenlayer_parser.py new file mode 100644 index 00000000000..67b7fa6e0d0 --- /dev/null +++ b/unittests/tools/test_hiddenlayer_parser.py @@ -0,0 +1,371 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.hiddenlayer.parser import HiddenlayerParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestHiddenlayerParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("hiddenlayer") / filename + with path.open(encoding="utf-8") as file: + return list(HiddenlayerParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(HiddenlayerParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def log(self, result=None, rule=None, scan_id="scan-1"): + rules = [rule] if rule else [] + results = [result or {"ruleId": "RULE_1", "level": "error", + "message": {"text": "A finding"}}] + return {"scan_id": scan_id, "sarif": {"runs": [ + {"tool": {"driver": {"rules": rules}}, "results": results}]}} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the HiddenLayer connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = HiddenlayerParser() + self.assertEqual(["HiddenLayer Model Scan"], parser.get_scan_types()) + self.assertEqual("HiddenLayer Model Scan", + parser.get_label_for_scan_types("HiddenLayer Model Scan")) + self.assertNotIn("HiddenLayer - Connectors Import", parser.get_scan_types()) + + def test_this_is_not_the_generic_sarif_scan_type(self): + """ + DefectDojo already parses SARIF, but under the "SARIF" scan type. + + Findings imported there would not deduplicate against the HiddenLayer connector's, which is + the whole reason this parser exists. + """ + self.assertNotIn("SARIF", HiddenlayerParser().get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("hiddenlayer_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("hiddenlayer_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring convertResult in the connector's finding_converter.""" + findings = self.parse("hiddenlayer_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Unsafe pickle opcode found in the model archive", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("hiddenlayer-scan-0001-PICKLE_DESERIALIZATION-" + "generic-model/pytorch_model.bin:42", finding.unique_id_from_tool) + self.assertEqual("PICKLE_DESERIALIZATION", finding.vuln_id_from_tool) + self.assertEqual(9.3, finding.cvssv3_score) + self.assertEqual(502, finding.cwe) + self.assertEqual("generic-model/pytorch_model.bin", finding.file_path) + self.assertEqual(42, finding.line) + self.assertEqual("https://example.com/rules/pickle-deserialization", finding.references) + self.assertEqual("Re-export the model in the safetensors format.", finding.mitigation) + self.assertTrue(finding.active) + self.assertFalse(finding.false_p) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["cwe-502", "model-integrity", "supply-chain"], finding.unsaved_tags) + self.assertEqual( + "**Result message:** Unsafe pickle opcode found in the model archive\n" + "**Rule name:** UnsafePickleDeserialization\n" + "**Rule short description:** The model file deserialises untrusted pickle data\n" + "**Rule full description:** Loading this model executes arbitrary code from the " + "pickle stream.", + finding.description, + ) + + def test_many_vuln(self): + """Six results, but the "pass" result is never imported.""" + self.assertEqual(5, len(self.parse("hiddenlayer_many_vuln.json"))) + + def test_a_result_that_is_not_a_failure_is_skipped(self): + """ + SARIF uses kind for results that are not failures - "pass", "open", "informational". + + Importing those would fill the product with non-findings. + """ + for kind in ("pass", "open", "informational", "notApplicable", "review"): + with self.subTest(kind=kind): + self.assertEqual(0, len(self.parse_string(self.log( + result={"ruleId": "RULE_1", "kind": kind, "message": {"text": "A finding"}})))) + + def test_an_absent_kind_means_a_failure(self): + """SARIF makes kind optional and defaults it to "fail", so an absent kind is imported.""" + findings = self.parse_string(self.log( + result={"ruleId": "RULE_1", "level": "error", "message": {"text": "A finding"}})) + self.assertEqual(1, len(findings)) + + def test_a_suppressed_result_is_inactive_and_a_false_positive(self): + """ + SARIF suppression is a reviewer saying this one does not count. + + Recording it as inactive alone would leave it in the open-findings count; recording it as a + false positive is what its metrics should say. + """ + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-SUSPICIOUS_IMPORT-generic-model/loader.py:17"] + self.assertFalse(finding.active) + self.assertTrue(finding.false_p) + + def test_the_security_severity_property_beats_the_result_level(self): + """The fixture's first result is level "error" (High) but scores 9.3, which is Critical.""" + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-PICKLE_DESERIALIZATION-generic-model/pytorch_model.bin:42"] + self.assertEqual("Critical", finding.severity) + self.assertEqual(9.3, finding.cvssv3_score) + + def test_a_security_severity_word_is_accepted_but_scores_nothing(self): + """ + The property is documented as a CVSS number, and some tools put a word there instead. + + The word still grades the finding, but there is no score to record - so cvssv3_score stays 0. + """ + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-SUSPICIOUS_IMPORT-generic-model/loader.py:17"] + self.assertEqual("High", finding.severity) + self.assertEqual(0.0, finding.cvssv3_score) + + def test_an_unparseable_security_severity_falls_through_to_the_level(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-CVE-2000-0001-generic-model/requirements.txt:0"] + self.assertEqual("Medium", finding.severity) + self.assertEqual(0.0, finding.cvssv3_score) + + def test_result_levels(self): + for level, expected in (("note", "Info"), ("warning", "Medium"), ("error", "High")): + with self.subTest(level=level): + findings = self.parse_string(self.log( + result={"ruleId": "RULE_1", "level": level, "message": {"text": "A finding"}})) + self.assertEqual(expected, findings[0].severity) + + def test_a_result_with_no_level_is_medium_not_info(self): + """ + SARIF makes level optional, and a tool that omits it is not saying the result is harmless. + + Defaulting to Info would silently bury it. + """ + findings = self.parse_string(self.log( + result={"ruleId": "RULE_1", "message": {"text": "A finding"}})) + self.assertEqual("Medium", findings[0].severity) + + def test_cvss_score_bands(self): + for score, expected in (("9", "Critical"), ("9.9", "Critical"), ("7", "High"), + ("4", "Medium"), ("0.1", "Low"), ("0", "Info")): + with self.subTest(score=score): + findings = self.parse_string(self.log( + rule={"id": "RULE_1", "properties": {"security-severity": score}})) + self.assertEqual(expected, findings[0].severity) + + def test_the_cwe_comes_from_the_rule_relationship_first(self): + """SARIF has no CWE field; a tool states the taxonomy as a relationship or as a tag.""" + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-PICKLE_DESERIALIZATION-generic-model/pytorch_model.bin:42"] + self.assertEqual(502, finding.cwe) + + def test_the_cwe_falls_back_to_the_rule_tags_then_the_result_tags(self): + findings = self.by_uid("hiddenlayer_many_vuln.json") + from_rule_tag = findings[ + "hiddenlayer-scan-0001-SUSPICIOUS_IMPORT-generic-model/loader.py:17"] + self.assertEqual(94, from_rule_tag.cwe) + # This result's rule is not defined in the run at all, so only its own tags are available. + from_result_tag = findings["hiddenlayer-scan-0001-UNKNOWN_RULE-:0"] + self.assertEqual(77, from_result_tag.cwe) + + def test_cwe_forms(self): + for value, expected in (("CWE-502", 502), ("cwe-502", 502), ("external/cwe/cwe-502", 502), + ("not a cwe", 0)): + with self.subTest(value=value): + findings = self.parse_string(self.log( + rule={"id": "RULE_1", "properties": {"tags": [value]}})) + self.assertEqual(expected, findings[0].cwe) + + def test_a_rule_id_that_is_a_cve_becomes_a_vulnerability_id(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-CVE-2000-0001-generic-model/requirements.txt:0"] + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + + def test_a_rule_id_that_is_not_a_cve_has_no_vulnerability_id(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-PICKLE_DESERIALIZATION-generic-model/pytorch_model.bin:42"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_a_lowercase_cve_rule_id_is_uppercased(self): + findings = self.parse_string(self.log( + result={"ruleId": "cve-2000-0002", "level": "error", "message": {"text": "A finding"}})) + self.assertEqual(["CVE-2000-0002"], findings[0].unsaved_vulnerability_ids) + + def test_the_title_falls_back_through_the_rule_then_the_rule_id(self): + cases = ( + ({"id": "RULE_1", "shortDescription": {"text": "Short"}, + "fullDescription": {"text": "Full"}}, "Short"), + ({"id": "RULE_1", "fullDescription": {"text": "Full"}}, "Full"), + ({"id": "RULE_1", "name": "TheName"}, "TheName"), + ({"id": "RULE_1"}, "RULE_1"), + ) + for rule, expected in cases: + with self.subTest(expected=expected): + findings = self.parse_string(self.log( + result={"ruleId": "RULE_1", "level": "error", "message": {"text": ""}}, + rule=rule)) + self.assertEqual(expected, findings[0].title) + + def test_the_title_falls_back_to_the_result_rule_id_when_the_rule_is_missing(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")["hiddenlayer-scan-0001-UNKNOWN_RULE-:0"] + self.assertEqual("A result whose rule is not defined in the run", finding.title) + + def test_a_long_title_is_shortened_with_an_ellipsis(self): + long_message = "x" * 200 + findings = self.parse_string(self.log( + result={"ruleId": "RULE_1", "level": "error", "message": {"text": long_message}})) + self.assertEqual(150, len(findings[0].title)) + self.assertTrue(findings[0].title.endswith("...")) + # The full text is still in the body. + self.assertIn(long_message, findings[0].description) + + def test_a_description_does_not_repeat_itself(self): + """ + A rule whose short description merely repeats the result message is not printed twice. + + Nor is a full description that repeats the short one. + """ + findings = self.parse_string(self.log( + result={"ruleId": "RULE_1", "level": "error", "message": {"text": "The same text"}}, + rule={"id": "RULE_1", "shortDescription": {"text": "The same text"}, + "fullDescription": {"text": "The same text"}})) + self.assertEqual("**Result message:** The same text", findings[0].description) + + def test_the_references_fall_back_to_a_help_text_that_is_a_link(self): + findings = self.by_uid("hiddenlayer_many_vuln.json") + self.assertEqual("https://example.com/rules/pickle-deserialization", + findings["hiddenlayer-scan-0001-PICKLE_DESERIALIZATION-" + "generic-model/pytorch_model.bin:42"].references) + self.assertEqual("https://example.com/rules/suspicious-import", + findings["hiddenlayer-scan-0001-SUSPICIOUS_IMPORT-" + "generic-model/loader.py:17"].references) + + def test_help_text_that_is_not_a_link_is_not_a_reference(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")["hiddenlayer-scan-0001-ARCHITECTURE_NOTE-:0"] + self.assertIsNone(finding.references) + + def test_every_fix_description_becomes_the_mitigation_one_per_line(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-PICKLE_DESERIALIZATION-generic-model/pytorch_model.bin:42"] + self.assertEqual("Re-export the model in the safetensors format.\n" + "Load the model with weights_only=True.", finding.mitigation) + + def test_no_fixes_leaves_the_mitigation_unset(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")["hiddenlayer-scan-0001-ARCHITECTURE_NOTE-:0"] + self.assertIsNone(finding.mitigation) + + def test_a_quoted_start_line_is_accepted(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-SUSPICIOUS_IMPORT-generic-model/loader.py:17"] + self.assertEqual(17, finding.line) + + def test_a_location_with_no_region_has_no_line(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-CVE-2000-0001-generic-model/requirements.txt:0"] + self.assertEqual("generic-model/requirements.txt", finding.file_path) + self.assertIsNone(finding.line) + + def test_a_result_with_no_location_has_no_file_path(self): + finding = self.by_uid("hiddenlayer_many_vuln.json")["hiddenlayer-scan-0001-ARCHITECTURE_NOTE-:0"] + self.assertIsNone(finding.file_path) + self.assertIsNone(finding.line) + + def test_the_cwe_taxonomy_prefix_is_stripped_from_tags(self): + """"external/cwe/cwe-502" reads as "cwe-502", matching DefectDojo's own SARIF parser.""" + finding = self.by_uid("hiddenlayer_many_vuln.json")[ + "hiddenlayer-scan-0001-PICKLE_DESERIALIZATION-generic-model/pytorch_model.bin:42"] + self.assertIn("cwe-502", finding.unsaved_tags) + self.assertNotIn("external/cwe/cwe-502", finding.unsaved_tags) + + def test_tags_are_deduplicated_across_the_rule_and_the_result(self): + findings = self.parse_string(self.log( + result={"ruleId": "RULE_1", "level": "error", "message": {"text": "A finding"}, + "properties": {"tags": ["shared", "result-only"]}}, + rule={"id": "RULE_1", "properties": {"tags": ["shared", "rule-only"]}})) + self.assertEqual(["shared", "rule-only", "result-only"], findings[0].unsaved_tags) + + def test_the_scan_id_may_be_spelled_three_ways(self): + """ + The scan id is part of every identity the connector builds, and a SARIF log lacks it. + + Without it a file import will not deduplicate against synced findings. + """ + for key in ("scan_id", "scanId", "scanID"): + with self.subTest(key=key): + payload = self.log() + payload.pop("scan_id") + payload[key] = "scan-9" + findings = self.parse_string(payload) + self.assertTrue(findings[0].unique_id_from_tool.startswith("hiddenlayer-scan-9-")) + + def test_a_bare_sarif_log_is_accepted(self): + """A log downloaded as-is has no wrapper, and then it carries no scan id either.""" + findings = self.parse_string({"runs": [ + {"tool": {"driver": {"rules": []}}, + "results": [{"ruleId": "RULE_1", "level": "error", "message": {"text": "A finding"}}]}]}) + self.assertEqual(1, len(findings)) + self.assertEqual("hiddenlayer--RULE_1-:0", findings[0].unique_id_from_tool) + + def test_the_log_may_be_wrapped_under_several_keys(self): + for key in ("sarif", "log", "report"): + with self.subTest(key=key): + payload = {"scan_id": "scan-1", key: {"runs": [ + {"tool": {"driver": {"rules": []}}, + "results": [{"ruleId": "RULE_1", "level": "error", + "message": {"text": "A finding"}}]}]}} + self.assertEqual(1, len(self.parse_string(payload))) + + def test_several_runs_all_contribute(self): + run = {"tool": {"driver": {"rules": []}}, + "results": [{"ruleId": "RULE_1", "level": "error", "message": {"text": "A finding"}}]} + findings = self.parse_string({"scan_id": "scan-1", "sarif": {"runs": [run, run]}}) + self.assertEqual(2, len(findings)) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string(["not a log"]) + self.assertIn("SARIF", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("runs", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"scan_id": "scan-1", "sarif": {"runs": [ + "not an object", + None, + {"tool": {"driver": {"rules": ["not an object", None]}}, + "results": ["not an object", None, + {"ruleId": "RULE_1", "level": "error", "message": {"text": "A finding"}, + "locations": ["not an object", None], + "fixes": ["not an object", None]}]}, + ]}}) + self.assertEqual(1, len(findings)) + self.assertEqual("hiddenlayer-scan-1-RULE_1-:0", findings[0].unique_id_from_tool) + self.assertIsNone(findings[0].mitigation) + + def test_the_file_path_is_in_the_hash(self): + """One rule firing on two files in a model archive is two findings.""" + self.assertEqual(["title", "severity", "file_path"], + HiddenlayerParser().get_dedupe_fields()) + + def test_severity_is_always_a_known_value(self): + for filename in ("hiddenlayer_many_vuln.json", "hiddenlayer_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_holm_security_parser.py b/unittests/tools/test_holm_security_parser.py new file mode 100644 index 00000000000..5c71a72ebdb --- /dev/null +++ b/unittests/tools/test_holm_security_parser.py @@ -0,0 +1,264 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.holm_security.parser import HolmSecurityParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestHolmSecurityParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("holm_security") / filename + with path.open(encoding="utf-8") as file: + return list(HolmSecurityParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(HolmSecurityParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"hid": "HID-1", "vulnerability_name": "A finding", "severity": "high", + "severity_level": 3, "status": "open", "asset_uuid": "asset-1"} + row.update(overrides) + return {"class": "web", "results": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Holm Security connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = HolmSecurityParser() + self.assertEqual(["Holm Security Scan"], parser.get_scan_types()) + self.assertEqual("Holm Security Scan", parser.get_label_for_scan_types("Holm Security Scan")) + self.assertNotIn("Holm Security - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("holm_security_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("holm_security_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("holm_security_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Cross-site scripting in the search form", finding.title) + # The severity NAME wins over the numeric level, which here says Medium. + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("holm-HID-100001-asset-0001-443", finding.unique_id_from_tool) + self.assertEqual("HID-100001", finding.vuln_id_from_tool) + # The CVSS base wins over the score. + self.assertEqual(6.1, finding.cvssv3_score) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("Encode user input before rendering it.", finding.mitigation) + self.assertEqual("An attacker can run script in a visitor's session.", finding.impact) + self.assertEqual("https://vendor.example.com/advisory/xss", finding.references) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertTrue(finding.active) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertEqual(["web-scan"], finding.unsaved_tags) + + self.assertEqual( + "**Detection:** The payload was reflected in the response body.\n" + "**Holm ID:** HID-100001\n" + "**CVEs:** CVE-2000-0001\n" + "**URL:** https://app.example.com/search?q=1\n" + "**Port:** 443/tcp\n" + "**Status:** open", + finding.description, + ) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual("search", locations[0].path) + + def test_the_endpoint_is_the_url_alone_not_the_detected_port(self): + """ + The connector puts only Holm's URL in the endpoint. + + The separately-reported detected_port is in the identity and the description instead, so an + endpoint carries a port only when the URL itself names one. Adding it here would invent an + endpoint the API path never produces. + + This asserts against a detected_port of 8443 on a URL that names no port, because a port + matching the scheme's own default could not tell the two sources apart: the locations model + fills an unspecified port in from the protocol, so an https URL is port 443 there either way. + """ + finding = self.by_uid("holm_security_many_vuln.json")["holm-HID-100002-asset-0001-8443"] + location = self.get_unsaved_locations(finding)[0] + self.assertEqual("app.example.com", location.host) + self.assertNotEqual(8443, location.port) + self.assertIn("**Port:** 8443", finding.description) + + def test_a_scheme_default_port_is_not_evidence_either_way(self): + """Holm's own 443 and the https default are the same number; only the URL decides.""" + finding = self.parse("holm_security_one_vuln.json")[0] + self.assertIn("**Port:** 443/tcp", finding.description) + self.assertIn("-443", finding.unique_id_from_tool) + self.assertEqual("app.example.com", self.get_unsaved_locations(finding)[0].host) + + def test_a_url_naming_its_own_port_keeps_it(self): + findings = self.parse_string(self.row(url="https://app.example.com:8443/thing")) + location = self.get_unsaved_locations(findings[0])[0] + self.assertEqual(8443, location.port) + + def test_the_asset_class_decides_static_versus_dynamic(self): + """ + Holm scans two ways, and only the web class exercises a running application. + + The class is a property of the scan rather than the row, so an export states it - and without + it the findings are static, which is the connector's own default for anything that is not the + web class. + """ + for asset_class, static, tags in (("web", False, ["web-scan"]), + ("net", True, ["net-scan"]), + ("", True, [])): + with self.subTest(asset_class=asset_class): + payload = {"results": [{"hid": "HID-1", "vulnerability_name": "A finding", + "severity": "high", "status": "open"}]} + if asset_class: + payload["class"] = asset_class + findings = self.parse_string(payload) + self.assertEqual(static, findings[0].static_finding) + self.assertEqual(not static, findings[0].dynamic_finding) + self.assertEqual(tags, findings[0].unsaved_tags) + + def test_many_vuln(self): + self.assertEqual(5, len(self.parse("holm_security_many_vuln.json"))) + + def test_severity_names(self): + for name, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("info", "Info"), ("HIGH", "High")): + with self.subTest(name=name): + findings = self.parse_string(self.row(severity=name, severity_level=0)) + self.assertEqual(expected, findings[0].severity) + + def test_the_numeric_level_is_the_fallback_and_four_is_the_most_severe(self): + """ + Holm's numeric scale runs 0-4 with 4 the most severe - the inverse of a priority number. + + It is only consulted when the name is missing or unrecognised, so an unfamiliar name does not + silently become Info while a usable level sits next to it. + """ + for level, expected in ((4, "Critical"), (3, "High"), (2, "Medium"), (1, "Low"), + (0, "Info"), (5, "Info")): + with self.subTest(level=level): + findings = self.parse_string(self.row(severity="", severity_level=level)) + self.assertEqual(expected, findings[0].severity) + + findings = self.by_uid("holm_security_many_vuln.json") + self.assertEqual("Critical", findings["holm-HID-100003-asset-0002"].severity) + self.assertEqual("High", findings["holm-HID-100002-asset-0001-8443"].severity) + + def test_a_level_may_arrive_as_a_string(self): + finding = self.by_uid("holm_security_many_vuln.json")["holm-HID-100002-asset-0001-8443"] + self.assertEqual("High", finding.severity) + + def test_the_asset_and_port_are_part_of_the_identity(self): + """ + Holm reports the same weakness once per host and once per listening port. + + Collapsing them would hide a second exposed service. + """ + findings = self.by_uid("holm_security_many_vuln.json") + self.assertIn("holm-HID-100001-asset-0001-443", findings) + self.assertIn("holm-HID-100002-asset-0001-8443", findings) + # No port reported, so it is left out of the identity rather than recorded as zero. + self.assertIn("holm-HID-100003-asset-0002", findings) + + def test_the_cvss_base_wins_over_the_score(self): + findings = self.by_uid("holm_security_many_vuln.json") + self.assertEqual(6.1, findings["holm-HID-100001-asset-0001-443"].cvssv3_score) + # No base, so the score stands in. + self.assertEqual(7.5, findings["holm-HID-100002-asset-0001-8443"].cvssv3_score) + self.assertEqual(0.0, findings["holm-HID-100003-asset-0002"].cvssv3_score) + + def test_title_falls_back_to_the_first_cve_then_the_holm_id(self): + findings = self.by_uid("holm_security_many_vuln.json") + self.assertEqual("CVE-2000-0002", findings["holm-HID-100002-asset-0001-8443"].title) + + bare = self.parse_string(self.row(vulnerability_name="", cve_ids=[])) + self.assertEqual("Holm Security finding HID-1", bare[0].title) + + def test_closed_statuses_are_inactive(self): + for status, active in (("open", True), ("fixed", False), ("closed", False), + ("resolved", False), ("FIXED", False), ("", True)): + with self.subTest(status=status): + findings = self.parse_string(self.row(status=status)) + self.assertEqual(active, findings[0].active) + + def test_the_port_label_carries_the_protocol(self): + findings = self.by_uid("holm_security_many_vuln.json") + self.assertIn("**Port:** 443/tcp", findings["holm-HID-100001-asset-0001-443"].description) + # No protocol reported, so the port stands alone. + self.assertIn("**Port:** 8443", findings["holm-HID-100002-asset-0001-8443"].description) + self.assertNotIn("8443/", findings["holm-HID-100002-asset-0001-8443"].description) + + def test_a_finding_with_no_url_records_no_endpoint(self): + """A network finding often has no URL, and the host stays in the description.""" + finding = self.by_uid("holm_security_many_vuln.json")["holm-HID-100003-asset-0002"] + self.assertEqual([], self.get_unsaved_locations(finding)) + self.assertNotIn("**URL:**", finding.description) + + def test_a_url_that_cannot_be_a_host_is_not_recorded(self): + finding = self.by_uid("holm_security_many_vuln.json")["holm-HID-100005-asset-0003"] + self.assertEqual([], self.get_unsaved_locations(finding)) + self.assertIn("**URL:** an internal service", finding.description) + + def test_the_date_prefers_the_last_detection(self): + findings = self.by_uid("holm_security_many_vuln.json") + self.assertEqual( + datetime(2024, 7, 1, tzinfo=UTC).date(), + findings["holm-HID-100001-asset-0001-443"].date, + ) + # No last_detected, so the first detection stands in. + self.assertEqual( + datetime(2024, 6, 1, tzinfo=UTC).date(), + findings["holm-HID-100002-asset-0001-8443"].date, + ) + + def test_no_detection_dates_keeps_the_default(self): + finding = self.by_uid("holm_security_many_vuln.json")["holm-HID-100003-asset-0002"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_a_bare_list_of_vulnerabilities_is_accepted(self): + findings = self.parse_string([ + {"hid": "HID-1", "vulnerability_name": "A finding", "severity": "high", "status": "open"}, + ]) + self.assertEqual(1, len(findings)) + # No class stated, so static, matching the connector's default. + self.assertTrue(findings[0].static_finding) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Holm Security", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("results", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"class": "web", "results": [ + "not an object", + None, + {"hid": "HID-1", "vulnerability_name": "A finding", "severity": "high", "status": "open"}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("holm_security_many_vuln.json", "holm_security_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_insightappsec_parser.py b/unittests/tools/test_insightappsec_parser.py new file mode 100644 index 00000000000..64f779b79c8 --- /dev/null +++ b/unittests/tools/test_insightappsec_parser.py @@ -0,0 +1,282 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.insightappsec.parser import InsightAppSecParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestInsightAppSecParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("insightappsec") / filename + with path.open(encoding="utf-8") as file: + return list(InsightAppSecParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(InsightAppSecParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the InsightAppSec connector's ScanTypeName verbatim. + + Any drift and someone who uploads an export and also syncs the API gets two un-deduplicated + copies of every finding. + """ + parser = InsightAppSecParser() + self.assertEqual(["Rapid7 InsightAppSec - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Rapid7 InsightAppSec - Connectors Import", + parser.get_label_for_scan_types("Rapid7 InsightAppSec - Connectors Import"), + ) + + def test_the_dedupe_hash_is_the_unique_id_alone(self): + """ + This scan type hashes the unique id and NOTHING else - no title, no severity. + + InsightAppSec's vulnerability id is stable across scans, so it is the whole identity, and + adding a volatile field would split a finding that had merely been regraded. + """ + self.assertEqual(["unique_id_from_tool"], InsightAppSecParser().get_dedupe_fields()) + + def test_no_vuln(self): + """ + Every row here is one Rapid7 has closed out: remediated, duplicate, false positive, ignored. + + Importing them would resurrect findings a reimport is supposed to close. + """ + self.assertEqual(0, len(self.parse("insightappsec_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("insightappsec_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("insightappsec_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual('Cross-Site Scripting (XSS) in "q" parameter', finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("InsightAppSec assigned severity **HIGH**.", finding.severity_justification) + self.assertEqual("11111111-1111-1111-1111-111111111111", finding.unique_id_from_tool) + self.assertEqual("Cross-Site Scripting (XSS)", finding.vuln_id_from_tool) + self.assertEqual(6.1, finding.cvssv3_score) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual("search", locations[0].path) + + def test_status_is_matched_case_sensitively(self): + """ + The connector compares against its own uppercase constants, so casing matters. + + A lowercase "verified" is not a status InsightAppSec sends, and treating it as one would be + this parser inventing tolerance the API path does not have. + """ + for status, imported in (("VERIFIED", 1), ("UNREVIEWED", 1), ("verified", 0), + ("REMEDIATED", 0), ("", 0)): + with self.subTest(status=status): + findings = self.parse_string({"data": [ + {"id": "vuln-1", "severity": "HIGH", "status": status, "root_cause": {}}, + ]}) + self.assertEqual(imported, len(findings)) + + def test_severity_enum_is_matched_case_sensitively(self): + for label, expected in (("CRITICAL", "Critical"), ("HIGH", "High"), ("MEDIUM", "Medium"), + ("LOW", "Low"), ("INFORMATIONAL", "Info"), ("SAFE", "Info"), + ("critical", "Info"), ("not-an-enum", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string({"data": [ + {"id": "vuln-1", "severity": label, "status": "VERIFIED", "root_cause": {}}, + ]}) + self.assertEqual(expected, findings[0].severity) + + def test_the_raw_label_is_kept_as_the_justification(self): + """An unrecognised label lands as Info, but the original is recorded so a regrade is auditable.""" + finding = self.by_uid("insightappsec_many_vuln.json")["33333333-3333-3333-3333-333333333333"] + self.assertEqual("Info", finding.severity) + self.assertEqual("InsightAppSec assigned severity **not-an-enum**.", finding.severity_justification) + + def test_many_vuln(self): + """Six rows, three of them closed out by Rapid7.""" + self.assertEqual(3, len(self.parse("insightappsec_many_vuln.json"))) + + def test_the_title_comes_from_the_attack_module(self): + """ + InsightAppSec names a vulnerability only by its module id, so the readable name is a second + call. Without it there is nothing to call the finding but a constant. + """ + findings = self.by_uid("insightappsec_many_vuln.json") + self.assertEqual("SQL Injection", findings["22222222-2222-2222-2222-222222222222"].title) + + without_modules = self.parse_string({"data": [ + {"id": "vuln-1", "severity": "HIGH", "status": "VERIFIED", "root_cause": {}, + "variances": [{"module": {"id": "module-xss"}}]}, + ]}) + self.assertEqual("InsightAppSec finding", without_modules[0].title) + + def test_the_parameter_is_quoted_in_the_title(self): + """A parameter called "id" is otherwise indistinguishable from prose.""" + findings = self.parse_string({ + "data": [{"id": "vuln-1", "severity": "HIGH", "status": "VERIFIED", + "root_cause": {"parameter": "id"}, "variances": [{"module": {"id": "m1"}}]}], + "modules": [{"id": "m1", "name": "SQL Injection"}], + }) + self.assertEqual('SQL Injection in "id" parameter', findings[0].title) + + def test_the_rule_id_falls_back_from_name_to_module_id_to_vulnerability_id(self): + findings = self.by_uid("insightappsec_many_vuln.json") + self.assertEqual("Cross-Site Scripting (XSS)", findings["11111111-1111-1111-1111-111111111111"].vuln_id_from_tool) + # No module metadata for this one, so the module id stands in. + self.assertEqual("module-unknown", findings["33333333-3333-3333-3333-333333333333"].vuln_id_from_tool) + + no_variances = self.parse_string({"data": [ + {"id": "vuln-9", "severity": "HIGH", "status": "VERIFIED", "root_cause": {}}, + ]}) + self.assertEqual("vuln-9", no_variances[0].vuln_id_from_tool) + + def test_only_a_v3_vector_yields_a_v3_score(self): + """ + InsightAppSec also reports CVSS v2 vectors, and the same number means different things on the + two scales, so a v2 base must not land in the v3 field. + """ + findings = self.by_uid("insightappsec_many_vuln.json") + self.assertEqual(6.1, findings["11111111-1111-1111-1111-111111111111"].cvssv3_score) + self.assertIsNone(findings["22222222-2222-2222-2222-222222222222"].cvssv3_score) + + def test_a_zero_score_is_not_recorded(self): + findings = self.parse_string({"data": [ + {"id": "vuln-1", "severity": "HIGH", "status": "VERIFIED", "root_cause": {}, + "vector_string": "CVSS:3.1/AV:N", "vulnerability_score": 0}, + ]}) + self.assertIsNone(findings[0].cvssv3_score) + + def test_the_description_flattens_the_evidence(self): + """ + The evidence is the application's own response to an attack payload. + + It is the least trustworthy text in the export, so every value is flattened to inert text - + script content dropped, tags removed, the result HTML-escaped the way Go's EscapeString does. + """ + finding = self.parse("insightappsec_one_vuln.json")[0] + self.assertIn("**URL:** GET https://app.example.com/search", finding.description) + self.assertIn("**Parameter:** q", finding.description) + # The module prose is flattened and its script content dropped. + self.assertIn("The application reflects input into the response without encoding.", finding.description) + self.assertNotIn("alert('x')", finding.description) + self.assertNotIn("<p>", finding.description) + # Whatever survives flattening is escaped, never rendered. Go's EscapeString spells the + # apostrophe ', not Python's '. + self.assertIn("- Attack value: '">\n", finding.description) + self.assertNotIn("'", finding.description) + self.assertNotIn("<img", finding.description) + # Proof is flattened too. + self.assertIn("- Proof: Reflected in the body", finding.description) + + def test_a_markup_only_attack_payload_flattens_to_nothing(self): + """ + The connector's sanitiser drops tags and the contents of script elements. + + So an attack value that is *only* markup - "<script>alert(1)</script>" - leaves an empty + Attack value line: the label is written because the raw value was non-empty, but nothing + survives flattening. Mirrored rather than corrected, and raised in the PR as a follow-up for + both sides, since the payload is the part of the evidence a reviewer most wants to see. + """ + finding = self.parse("insightappsec_one_vuln.json")[0] + self.assertIn("- Attack value: \n", finding.description) + self.assertNotIn("alert(1)", finding.description) + + def test_only_three_evidence_entries_are_printed(self): + """ + A vulnerability can carry hundreds of variances, and the connector prints three then counts + the rest - the description is context, not an evidence archive. + """ + finding = self.parse("insightappsec_one_vuln.json")[0] + self.assertEqual(3, finding.description.count("**Evidence:**")) + self.assertIn("_(2 further evidence entries omitted)_", finding.description) + self.assertNotIn("Fifth.", finding.description) + + def test_no_omission_note_when_there_are_three_or_fewer(self): + finding = self.by_uid("insightappsec_many_vuln.json")["22222222-2222-2222-2222-222222222222"] + self.assertEqual(1, finding.description.count("**Evidence:**")) + self.assertNotIn("further evidence entries omitted", finding.description) + + def test_references_are_the_ui_link_then_the_module_links_in_key_order(self): + """ + The connector sorts the reference keys because a Go map has no order. + + Matching that keeps the two import paths byte-identical rather than differing by whatever + order the JSON happened to use. + """ + finding = self.parse("insightappsec_one_vuln.json")[0] + self.assertEqual( + "https://insight.example.com/vm/app-0001/vuln/11111111\n" + "https://cwe.example.com/79\n" + "https://owasp.example.com/xss", + finding.references, + ) + + def test_a_finding_with_no_links_has_empty_references(self): + finding = self.by_uid("insightappsec_many_vuln.json")["33333333-3333-3333-3333-333333333333"] + self.assertEqual("", finding.references) + + def test_module_metadata_may_be_a_list_or_a_map(self): + """The metadata comes from a call per module, so an export keys it either way.""" + vulnerability = {"id": "vuln-1", "severity": "HIGH", "status": "VERIFIED", + "root_cause": {"parameter": "q"}, "variances": [{"module": {"id": "m1"}}]} + for modules in ([{"id": "m1", "name": "SQL Injection"}], + {"m1": {"id": "m1", "name": "SQL Injection"}}, + {"data": [{"id": "m1", "name": "SQL Injection"}]}): + with self.subTest(shape=type(modules).__name__): + findings = self.parse_string({"data": [vulnerability], "modules": modules}) + self.assertEqual('SQL Injection in "q" parameter', findings[0].title) + + def test_the_date_is_the_first_ten_characters_of_the_discovery_timestamp(self): + findings = self.by_uid("insightappsec_many_vuln.json") + self.assertEqual(datetime(2024, 6, 1, tzinfo=UTC).date(), findings["22222222-2222-2222-2222-222222222222"].date) + + def test_a_row_with_no_discovery_date_keeps_the_default(self): + findings = self.parse_string({"data": [ + {"id": "vuln-1", "severity": "HIGH", "status": "VERIFIED", "root_cause": {}}, + ]}) + self.assertEqual(datetime.now(tz=UTC).date(), findings[0].date) + + def test_a_bare_list_of_vulnerabilities_is_accepted(self): + findings = self.parse_string([ + {"id": "vuln-1", "severity": "HIGH", "status": "VERIFIED", "root_cause": {}}, + ]) + self.assertEqual(1, len(findings)) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("InsightAppSec", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("data", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"data": [ + "not an object", + None, + {"id": "vuln-1", "severity": "HIGH", "status": "VERIFIED", "root_cause": {}, + "variances": ["not an object", {"module": {"id": "m1"}}]}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("insightappsec_many_vuln.json", "insightappsec_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_intigriti_parser.py b/unittests/tools/test_intigriti_parser.py new file mode 100644 index 00000000000..a2595015e20 --- /dev/null +++ b/unittests/tools/test_intigriti_parser.py @@ -0,0 +1,243 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.intigriti.parser import IntigritiParser, inert_text +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestIntigritiParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("intigriti") / filename).open(encoding="utf-8") as file: + return list(IntigritiParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Intigriti connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = IntigritiParser() + self.assertEqual(["Intigriti - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Intigriti - Connectors Import", + parser.get_label_for_scan_types("Intigriti - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("intigriti_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("intigriti_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("intigriti_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Stored XSS in the comment field", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("GENERIC-0001", finding.unique_id_from_tool) + self.assertEqual("GENERIC-0001", finding.vuln_id_from_tool) + self.assertEqual(79, finding.cwe) + self.assertEqual( + "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N", finding.cvssv3, + ) + self.assertEqual("https://app.example.com/submissions/GENERIC-0001", finding.url) + self.assertEqual( + "Intigriti submission: https://app.example.com/submissions/GENERIC-0001", + finding.references, + ) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + def test_intigritis_top_tier_is_exceptional_not_critical(self): + """ + Intigriti grades its most severe submissions "Exceptional". + + Mapping only "critical" would silently drop every top-tier submission to Info. + """ + parser = IntigritiParser() + self.assertEqual("Critical", parser.build_finding( + {"code": "X", "severity": {"value": "Exceptional"}}, Test()).severity) + self.assertEqual("Critical", parser.build_finding( + {"code": "X", "severity": {"value": "critical"}}, Test()).severity) + for value, expected in [("High", "High"), ("Medium", "Medium"), ("Low", "Low"), + ("not-a-severity", "Info"), ("", "Info")]: + self.assertEqual(expected, parser.build_finding( + {"code": "X", "severity": {"value": value}}, Test()).severity, value) + + def test_the_status_and_close_reason_become_the_defectdojo_state(self): + """ + For a closed submission the CLOSE REASON is what distinguishes a fix from a rejection. + + Treating every closed submission the same way would mark rejected and duplicate submissions + as mitigated, which reads as work completed. + """ + findings = self.by_uid("intigriti_many_vuln.json") + + accepted = findings["GENERIC-0001"] + self.assertTrue(accepted.active) + self.assertTrue(accepted.verified) + + solved = findings["GENERIC-0002"] + self.assertFalse(solved.active) + self.assertTrue(solved.is_mitigated) + self.assertTrue(solved.verified) + + risk = findings["GENERIC-0003"] + self.assertFalse(risk.active) + self.assertTrue(risk.risk_accepted) + self.assertFalse(risk.is_mitigated) + + duplicate = findings["GENERIC-0004"] + self.assertFalse(duplicate.active) + self.assertTrue(duplicate.duplicate) + + out_of_scope = findings["GENERIC-0005"] + self.assertFalse(out_of_scope.active) + self.assertTrue(out_of_scope.out_of_scope) + + rejected = findings["GENERIC-0006"] + self.assertFalse(rejected.active) + self.assertTrue(rejected.false_p) + + def test_intigritis_terse_rejection_reason_is_a_false_positive(self): + """The close reason "No" is Intigriti's shortest rejection, and it must not read as fixed.""" + finding = self.by_uid("intigriti_many_vuln.json")["GENERIC-0007"] + self.assertFalse(finding.active) + self.assertTrue(finding.false_p) + self.assertFalse(finding.is_mitigated) + + def test_an_archived_submission_is_treated_as_closed(self): + """Both "closed" and "archived" route through the close-reason branch.""" + finding = self.by_uid("intigriti_many_vuln.json")["GENERIC-0005"] + self.assertFalse(finding.active) + + def test_an_open_submission_is_active_but_not_verified(self): + finding = self.by_uid("intigriti_many_vuln.json")["GENERIC-0008"] + self.assertTrue(finding.active) + self.assertFalse(finding.verified) + + def test_a_closed_submission_with_an_unknown_reason_is_treated_as_fixed(self): + parser = IntigritiParser() + finding = Finding() + parser.apply_state(finding, "Closed", "some new reason") + self.assertFalse(finding.active) + self.assertTrue(finding.is_mitigated) + self.assertTrue(finding.verified) + + def test_researcher_html_is_flattened_not_rendered(self): + """ + Intigriti submissions are written by external researchers. + + Their prose is flattened and escaped, so nothing in a submission can be injected into a + rendered finding. + """ + finding = self.parse("intigriti_one_vuln.json")[0] + self.assertIn("**Proof of concept:**", finding.description) + self.assertIn("Submit", finding.description) + # The script content is dropped and no raw markup survives anywhere. + self.assertNotIn("alert(1)", finding.description) + self.assertNotIn("<script", finding.description) + self.assertNotIn("<b>", finding.description) + self.assertNotIn("<p>", finding.impact) + self.assertNotIn("<p>", finding.mitigation) + + def test_the_inert_text_helper_matches_gos_entities(self): + self.assertEqual("plain", inert_text("plain")) + self.assertEqual("kept", inert_text("<script>dropped()</script>kept")) + self.assertEqual("a\n\nb", inert_text("<p>a</p><p>b</p>")) + self.assertEqual("'q'", inert_text("'q'")) + self.assertEqual(""q"", inert_text('"q"')) + self.assertEqual("", inert_text("")) + + def test_the_impact_and_solution_become_impact_and_mitigation(self): + finding = self.parse("intigriti_one_vuln.json")[0] + self.assertEqual( + "An attacker can run script in another user's session.", finding.impact, + ) + self.assertEqual("Encode user input before rendering.", finding.mitigation) + + def test_the_description_carries_the_type_asset_and_question_answers(self): + finding = self.parse("intigriti_one_vuln.json")[0] + self.assertIn("**Type:** Cross-site Scripting (Injection)", finding.description) + self.assertIn("**Asset:** app.example.com", finding.description) + self.assertIn("**Which browsers did you test?**", finding.description) + self.assertIn("Latest Chrome and Firefox.", finding.description) + self.assertIn("**Submission:** GENERIC-0001", finding.description) + + def test_an_empty_question_and_answer_pair_is_skipped(self): + """The fixture carries one, and it must not produce a heading with nothing under it.""" + finding = self.parse("intigriti_one_vuln.json")[0] + self.assertNotIn("****\n", finding.description) + + def test_the_asset_falls_back_to_the_vulnerable_component(self): + finding = self.by_uid("intigriti_many_vuln.json")["GENERIC-0009"] + self.assertIn("**Asset:** api.example.com/v1/users", finding.description) + + def test_a_report_type_with_only_a_category_still_renders(self): + finding = self.by_uid("intigriti_many_vuln.json")["GENERIC-0009"] + self.assertIn("**Type:** Access Control", finding.description) + + def test_a_detail_carried_on_the_entry_itself_is_recognised(self): + """ + The connector fetches the detail separately, so an export may nest it or merge it. + + A merged export is recognised by the presence of "report"; missing that would lose the CWE, + impact, solution and the whole description body. + """ + finding = self.by_uid("intigriti_many_vuln.json")["GENERIC-0009"] + self.assertIn("**Asset:**", finding.description) + + def test_a_cwe_that_is_not_prefixed_leaves_the_cwe_at_zero(self): + finding = self.by_uid("intigriti_many_vuln.json")["GENERIC-0009"] + self.assertEqual(0, finding.cwe) + + def test_the_cwe_parse_directly(self): + parser = IntigritiParser() + self.assertEqual(79, parser.cwe("cwe-79")) + self.assertEqual(79, parser.cwe("CWE-79")) + self.assertEqual(0, parser.cwe("not-a-cwe")) + self.assertEqual(0, parser.cwe("79")) + self.assertEqual(0, parser.cwe("")) + + def test_a_submission_with_no_portal_link_has_no_url_or_references(self): + finding = self.by_uid("intigriti_many_vuln.json")["GENERIC-0003"] + self.assertIsNone(finding.url) + self.assertIsNone(finding.references) + + def test_the_overview_wins_when_both_objects_carry_a_field(self): + """ + The converter prefers the overview for any field both carry. + + The fixture's detail has an empty severity vector while the overview has one, so a + detail-first reading would lose it. + """ + finding = self.parse("intigriti_one_vuln.json")[0] + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N", finding.cvssv3) + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([{ + "code": "X-1", "title": "A submission", "severity": {"value": "High"}, + "state": {"status": {"value": "Open"}}, + }])) + findings = list(IntigritiParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("High", findings[0].severity) + + def test_a_repeated_submission_code_collapses(self): + row = {"code": "same", "title": "A submission", "severity": {"value": "Low"}} + report = io.StringIO(json.dumps({"records": [row, row]})) + self.assertEqual(1, len(list(IntigritiParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(IntigritiParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("records", str(raised.exception)) diff --git a/unittests/tools/test_intruder_parser.py b/unittests/tools/test_intruder_parser.py new file mode 100644 index 00000000000..d091f2b79b7 --- /dev/null +++ b/unittests/tools/test_intruder_parser.py @@ -0,0 +1,273 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.intruder.parser import IntruderParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestIntruderParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("intruder") / filename).open(encoding="utf-8") as file: + return list(IntruderParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(IntruderParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Intruder connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = IntruderParser() + self.assertEqual(["Intruder API Import"], parser.get_scan_types()) + self.assertEqual("Intruder API Import", parser.get_label_for_scan_types("Intruder API Import")) + self.assertNotIn("Intruder - Connectors Import", parser.get_scan_types()) + + def test_the_occurrence_id_is_part_of_the_hash_not_the_algorithm(self): + """ + Intruder is the one connector scan type using the PLAIN hash_code algorithm. + + The occurrence id sits inside the hash fields instead of being paired with them, with title + and severity guarding against id reuse. Copied from the connector's settings, not chosen. + """ + self.assertEqual( + ["unique_id_from_tool", "title", "severity"], + IntruderParser().get_dedupe_fields(), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("intruder_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("intruder_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ConvertOccurrence in the connector's finding_converter.""" + findings = self.parse("intruder_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("TLS certificate expires in under 14 days", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("500001", finding.unique_id_from_tool) + self.assertEqual("9001", finding.vuln_id_from_tool) + # The occurrence's own score wins over the issue's. + self.assertEqual(8.1, finding.cvssv3_score) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertEqual("Renew the certificate and redeploy it to every listener.", finding.mitigation) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(["intruder", "target:app.example.com"], finding.unsaved_tags) + self.assertTrue(finding.active) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + self.assertEqual( + "* **Target:** app.example.com (production)\n" + "* **Port:** 443\n" + "* **Protocol:** tcp\n" + "* **First seen:** 2024-07-01T12:00:00Z\n" + "* **Exploit likelihood:** MEDIUM\n" + "* **expires:** 2024-07-20\n" + "* **issuer:** Example CA\n" + "\n### Description\n\n" + "The certificate presented by the service expires shortly.\n", + finding.description, + ) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual(443, locations[0].port) + + def test_many_vuln(self): + """One finding per occurrence: five occurrences across four issues.""" + self.assertEqual(5, len(self.parse("intruder_many_vuln.json"))) + + def test_an_issue_with_no_occurrences_produces_nothing(self): + """ + The occurrence is the finding: it is what says where the weakness actually is. + + An issue nothing is currently affected by is not imported. + """ + titles = {finding.title for finding in self.parse("intruder_many_vuln.json")} + self.assertNotIn("An issue with no occurrences", titles) + + def test_occurrences_may_be_nested_or_keyed_by_issue_id(self): + """ + Intruder's own issue object carries "occurrences" as a URL string, not a list. + + That second call is what an export has to include, keyed by issue id - or nested as a list, as + the one-issue sample does. Both have to work. + """ + nested = self.parse("intruder_one_vuln.json")[0] + keyed = self.by_uid("intruder_many_vuln.json")["500001"] + self.assertEqual(nested.unique_id_from_tool, keyed.unique_id_from_tool) + self.assertEqual(nested.title, keyed.title) + + def test_a_url_string_in_the_occurrences_field_is_not_mistaken_for_data(self): + """The unexpanded API link must not be read as an occurrence list.""" + findings = self.parse_string({"results": [ + {"id": 1, "title": "An issue", "severity": "high", + "occurrences": "https://api.example.com/v1/issues/1/occurrences"}, + ]}) + self.assertEqual(0, len(findings)) + + def test_severity_labels(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("CRITICAL", "Critical"), ("not a label", "Info"), + ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string({"results": [ + {"id": 1, "title": "An issue", "severity": label, + "occurrences": [{"occurrence_id": 1, "target": "app.example.com"}]}, + ]}) + self.assertEqual(expected, findings[0].severity) + + def test_an_unrecognised_severity_is_info(self): + finding = self.by_uid("intruder_many_vuln.json")["500005"] + self.assertEqual("Info", finding.severity) + + def test_the_occurrence_score_wins_over_the_issue_score(self): + """ + The same weakness scores differently per target - internet-facing is not the same risk as + firewalled - so the occurrence's own score is preferred. + """ + findings = self.by_uid("intruder_many_vuln.json") + self.assertEqual(8.1, findings["500001"].cvssv3_score) + # This occurrence has no score of its own, so the issue's stands in. + self.assertEqual(7.4, findings["500002"].cvssv3_score) + + def test_snoozing_is_how_intruder_records_triage(self): + """ + A snoozed occurrence is inactive, and the reason decides which DefectDojo flag it sets. + + An unrecognised reason leaves it inactive with neither flag: it is still triaged, just not in + a way DefectDojo has a field for - and guessing would misreport the reviewer's decision. + """ + findings = self.by_uid("intruder_many_vuln.json") + + self.assertTrue(findings["500001"].active) + self.assertFalse(findings["500001"].false_p) + self.assertFalse(findings["500001"].risk_accepted) + + self.assertFalse(findings["500002"].active) + self.assertTrue(findings["500002"].false_p) + + self.assertFalse(findings["500003"].active) + self.assertTrue(findings["500003"].risk_accepted) + + # A mitigating control is an accepted risk too. + self.assertFalse(findings["500004"].active) + self.assertTrue(findings["500004"].risk_accepted) + + self.assertFalse(findings["500005"].active) + self.assertFalse(findings["500005"].false_p) + self.assertFalse(findings["500005"].risk_accepted) + + def test_extra_information_is_listed_in_sorted_key_order(self): + """ + The connector sorts because a Go map has no order. + + Matching that keeps the two import paths byte-identical rather than differing by whatever + order the JSON happened to use. + """ + finding = self.by_uid("intruder_many_vuln.json")["500001"] + self.assertIn( + "* **chain:** complete\n* **expires:** 2024-07-20\n* **issuer:** Example CA", + finding.description, + ) + + def test_the_description_prefers_the_display_address(self): + """ + The display address is what a person recognises; the target is what was scanned. + + So the description shows the display address while the endpoint records the target. + """ + finding = self.by_uid("intruder_many_vuln.json")["500001"] + self.assertIn("* **Target:** app.example.com (production)", finding.description) + self.assertEqual("app.example.com", self.get_unsaved_locations(finding)[0].host) + + def test_an_issue_with_no_description_has_no_description_section(self): + finding = self.by_uid("intruder_many_vuln.json")["500005"] + self.assertNotIn("### Description", finding.description) + self.assertIn("* **Target:** legacy.example.com", finding.description) + + def test_cves_come_from_the_occurrence_then_the_issue_prose(self): + findings = self.by_uid("intruder_many_vuln.json") + # The occurrence lists the CVE twice and the issue text names another. + self.assertEqual(["CVE-2000-0001", "CVE-2000-0009"], findings["500001"].unsaved_vulnerability_ids) + self.assertEqual(["CVE-2000-0009"], findings["500002"].unsaved_vulnerability_ids) + + def test_a_finding_with_no_identifiers_has_none(self): + finding = self.by_uid("intruder_many_vuln.json")["500003"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_a_port_of_zero_is_not_recorded(self): + """Intruder writes "0" when it has no port, and port zero is not a real port.""" + finding = self.by_uid("intruder_many_vuln.json")["500003"] + locations = self.get_unsaved_locations(finding) + self.assertEqual("shop.example.com", locations[0].host) + self.assertIsNone(locations[0].port) + + def test_an_ip_target_is_accepted_as_a_host(self): + finding = self.by_uid("intruder_many_vuln.json")["500002"] + locations = self.get_unsaved_locations(finding) + self.assertEqual("10.0.0.11", locations[0].host) + self.assertEqual(8443, locations[0].port) + + def test_a_target_that_cannot_be_a_host_is_not_recorded(self): + """ + An Intruder target can be a label rather than an address. + + DefectDojo's host field would reject it, and a ValidationError fails the whole import rather + than the one finding, so the endpoint is dropped. The target is still in the description and + the tags. + """ + finding = self.by_uid("intruder_many_vuln.json")["500004"] + self.assertEqual([], self.get_unsaved_locations(finding)) + self.assertIn("* **Target:** Reception Desk PC", finding.description) + self.assertIn("target:Reception Desk PC", finding.unsaved_tags) + + def test_an_unparseable_first_seen_keeps_the_default_date(self): + finding = self.by_uid("intruder_many_vuln.json")["500003"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_a_bare_list_of_issues_is_accepted(self): + findings = self.parse_string([ + {"id": 1, "title": "An issue", "severity": "high", + "occurrences": [{"occurrence_id": 1, "target": "app.example.com"}]}, + ]) + self.assertEqual(1, len(findings)) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Intruder", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("results", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"results": [ + "not an object", + None, + {"id": 1, "title": "An issue", "severity": "high", + "occurrences": ["not an object", {"occurrence_id": 1, "target": "app.example.com"}]}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("intruder_many_vuln.json", "intruder_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_klocwork_parser.py b/unittests/tools/test_klocwork_parser.py new file mode 100644 index 00000000000..323f6b59d2f --- /dev/null +++ b/unittests/tools/test_klocwork_parser.py @@ -0,0 +1,230 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.klocwork.parser import KlocworkParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestKlocworkParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("klocwork") / filename).open(encoding="utf-8") as file: + return list(KlocworkParser().get_findings(file, Test())) + + def parse_text(self, text): + return list(KlocworkParser().get_findings(io.StringIO(text), Test())) + + def parse_lines(self, *rows): + return self.parse_text("\n".join(json.dumps(row) for row in rows) + "\n") + + def row(self, **overrides): + row = {"id": 1, "name": "A finding", "code": "ABV.GENERAL", "file": "src/a.c", "line": 10, + "severityCode": 1, "status": "Analyze"} + row.update(overrides) + return row + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Klocwork connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = KlocworkParser() + self.assertEqual(["Klocwork Scan"], parser.get_scan_types()) + self.assertEqual("Klocwork Scan", parser.get_label_for_scan_types("Klocwork Scan")) + self.assertNotIn("Klocwork - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """A response carrying only the run summary has no issues in it.""" + self.assertEqual(0, len(self.parse("klocwork_no_vuln.ndjson"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("klocwork_one_vuln.ndjson"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("klocwork_one_vuln.ndjson") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("ABV.GENERAL: Buffer overflow in strcpy call", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("klocwork-100001", finding.unique_id_from_tool) + self.assertEqual("ABV.GENERAL", finding.vuln_id_from_tool) + self.assertEqual("src/parser/input.c", finding.file_path) + self.assertEqual(142, finding.line) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertTrue(finding.active) + self.assertFalse(finding.false_p) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["CWE", "ABV.GENERAL", "Critical"], finding.unsaved_tags) + self.assertIn("klocwork.example.com", finding.references) + + self.assertEqual( + "**Message:** Buffer 'dest' of size 64 may overflow.\n" + "**Checker:** ABV.GENERAL\n" + "**Method:** parse_input\n" + "**Taxonomy:** CWE\n" + "**Status:** Analyze", + finding.description, + ) + + def test_ndjson_is_the_wire_shape(self): + """ + Klocwork's search endpoint answers with one JSON object per line, not an array. + + That is what a saved export looks like, so it is what the parser reads first - calling + json.load on it would fail on the second line. + """ + self.assertEqual(5, len(self.parse("klocwork_many_vuln.ndjson"))) + + def test_a_reshaped_json_array_is_also_accepted(self): + """Somebody may have wrapped the lines into an array before saving them.""" + from_ndjson = self.by_uid("klocwork_many_vuln.ndjson") + from_array = self.by_uid("klocwork_many_vuln.json") + self.assertEqual(set(from_ndjson), set(from_array)) + self.assertEqual( + from_ndjson["klocwork-100001"].description, + from_array["klocwork-100001"].description, + ) + + def test_the_summary_line_is_not_an_issue(self): + """ + The response ends with a line describing the run rather than a finding. + + The connector skips it by testing for the "summary" key rather than by parsing it, and so does + this parser. + """ + findings = self.parse_text( + json.dumps({"summary": {"total": 1, "build": "last"}}) + "\n" + + json.dumps(self.row()) + "\n", + ) + self.assertEqual(1, len(findings)) + self.assertEqual("klocwork-1", findings[0].unique_id_from_tool) + + def test_blank_and_unparseable_lines_are_skipped(self): + findings = self.parse_text( + "\n" + "not json at all\n" + "{ this line does not parse\n" + + json.dumps(self.row()) + "\n" + "\n", + ) + self.assertEqual(1, len(findings)) + + def test_a_row_with_no_id_is_skipped(self): + """The id is the whole identity, so a row without one cannot be reported.""" + findings = self.by_uid("klocwork_many_vuln.ndjson") + self.assertNotIn("klocwork-0", findings) + + def test_severity_code_one_is_the_most_severe(self): + """ + Klocwork's severity code is the inverse of a score - 1 is Critical, not trivial. + + Reading it as a score would invert the entire ladder. Codes 5-10 are its informational tiers. + """ + for code, expected in ((1, "Critical"), (2, "High"), (3, "Medium"), (4, "Low"), + (5, "Info"), (7, "Info"), (10, "Info"), (0, "Info")): + with self.subTest(code=code): + findings = self.parse_lines(self.row(severityCode=code)) + self.assertEqual(expected, findings[0].severity) + + def test_numbers_may_arrive_quoted(self): + """ + Klocwork's numeric typing is modelled rather than confirmed, and the connector's own decoder + silently skips a line it cannot parse - so a server quoting its numerics would produce a clean, + empty sync. Both forms are accepted here for the same reason. + """ + finding = self.by_uid("klocwork_many_vuln.ndjson")["klocwork-100002"] + self.assertEqual("High", finding.severity) + self.assertEqual(88, finding.line) + + def test_triage_statuses_are_false_positives(self): + """ + "Ignore", "Not a problem" and "Filter" are all a reviewer saying it is not real. + + The deferred states the connector's query selects stay active - a deferred finding is still a + finding. + """ + for status, false_p in (("Ignore", True), ("Not a problem", True), ("Filter", True), + ("ignore", True), ("Analyze", False), ("Fix", False), + ("Defer", False), ("Fix in Next Release", False), ("", False)): + with self.subTest(status=status): + findings = self.parse_lines(self.row(status=status)) + self.assertEqual(false_p, findings[0].false_p) + self.assertEqual(not false_p, findings[0].active) + + def test_a_deferred_finding_stays_active(self): + finding = self.by_uid("klocwork_many_vuln.ndjson")["klocwork-100005"] + self.assertTrue(finding.active) + self.assertFalse(finding.false_p) + + def test_the_file_path_and_checker_are_both_in_the_hash(self): + """The same checker firing in two files is two findings.""" + self.assertEqual( + ["title", "severity", "file_path", "vuln_id_from_tool"], + KlocworkParser().get_dedupe_fields(), + ) + findings = self.by_uid("klocwork_many_vuln.ndjson") + self.assertEqual("src/parser/input.c", findings["klocwork-100001"].file_path) + self.assertEqual("src/cli/main.c", findings["klocwork-100002"].file_path) + + def test_title_falls_back_through_the_checker_then_the_id(self): + by_name = self.parse_lines(self.row(code="")) + self.assertEqual("A finding", by_name[0].title) + + by_code = self.parse_lines(self.row(name="")) + self.assertEqual("ABV.GENERAL", by_code[0].title) + + bare = self.by_uid("klocwork_many_vuln.ndjson")["klocwork-100004"] + self.assertEqual("Klocwork issue 100004", bare.title) + self.assertIsNone(bare.vuln_id_from_tool) + + def test_dates_are_unix_milliseconds(self): + """Reading them as seconds would date every finding in 1970.""" + findings = self.by_uid("klocwork_many_vuln.ndjson") + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), findings["klocwork-100001"].date) + self.assertEqual(datetime(2024, 6, 1, tzinfo=UTC).date(), findings["klocwork-100002"].date) + + def test_a_row_with_no_date_keeps_the_default(self): + finding = self.by_uid("klocwork_many_vuln.ndjson")["klocwork-100003"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_a_row_with_no_url_has_no_references(self): + finding = self.by_uid("klocwork_many_vuln.ndjson")["klocwork-100002"] + self.assertIsNone(finding.references) + + def test_a_single_issue_object_is_accepted(self): + findings = self.parse_text(json.dumps(self.row())) + self.assertEqual(1, len(findings)) + + def test_an_object_with_an_issues_list_is_accepted(self): + findings = self.parse_text(json.dumps({"issues": [self.row()]})) + self.assertEqual(1, len(findings)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_text(json.dumps({"scanner": "something else"})) + self.assertIn("Klocwork", str(context.exception)) + + def test_text_with_no_issue_line_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_text("this file is not a Klocwork export at all\n") + self.assertIn("Klocwork", str(context.exception)) + + def test_an_empty_file_is_not_an_error(self): + """An export written before the search ran is empty rather than malformed.""" + self.assertEqual(0, len(self.parse_text(""))) + + def test_severity_is_always_a_known_value(self): + for filename in ("klocwork_many_vuln.ndjson", "klocwork_one_vuln.ndjson"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_lacework_parser.py b/unittests/tools/test_lacework_parser.py new file mode 100644 index 00000000000..12cb6e61bfe --- /dev/null +++ b/unittests/tools/test_lacework_parser.py @@ -0,0 +1,149 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.lacework.parser import LaceworkParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestLaceworkParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("lacework") / filename).open(encoding="utf-8") as file: + return list(LaceworkParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Lacework connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = LaceworkParser() + self.assertEqual(["Lacework - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Lacework - Connectors Import", + parser.get_label_for_scan_types("Lacework - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("lacework_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("lacework_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ContainerToFinding in the connector's converter.""" + findings = self.parse("lacework_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + # converter title(): "<CVE> - <package> (<version>)" + self.assertEqual("CVE-2000-0001 - openssl (3.0.11-1)", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("openssl", finding.component_name) + self.assertEqual("3.0.11-1", finding.component_version) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("Upgrade openssl to 3.0.13-1.", finding.mitigation) + # converter: imageId|vulnId|package|version + self.assertEqual( + "sha256:aaaa1111|CVE-2000-0001|openssl|3.0.11-1", finding.unique_id_from_tool, + ) + + self.assertIn("**Image:** registry.example.com/generic-app", finding.description) + self.assertIn("**Registry:** registry.example.com", finding.description) + self.assertIn("**Image Digest:** sha256:bbbb2222", finding.description) + self.assertIn("**Namespace:** debian:12", finding.description) + + def test_a_container_row_is_static_and_a_host_row_is_dynamic(self): + """ + The connector has two mappings and flags them oppositely. + + An image scan reads a built artifact (static); a host scan looks at a running machine + (dynamic). Collapsing the two would misreport half the findings. + """ + findings = self.by_uid("lacework_many_vuln.json") + + container = findings["sha256:aaaa1111|CVE-2000-0001|openssl|3.0.11-1"] + self.assertTrue(container.static_finding) + self.assertFalse(container.dynamic_finding) + self.assertEqual( + ["image:registry.example.com/generic-app", + "registry:registry.example.com", + "source:container"], + container.unsaved_tags, + ) + + host = findings["host01.example.com|CVE-2000-0002|curl|7.81.0-1"] + self.assertFalse(host.static_finding) + self.assertTrue(host.dynamic_finding) + self.assertEqual(["host:host01.example.com", "source:host"], host.unsaved_tags) + # Only host rows carry the CVE link as a reference. + self.assertEqual("https://example.com/cve-2000-0002", host.references) + self.assertIn("**Description:** A flaw in URL parsing.", host.description) + + def test_the_unique_id_is_keyed_by_image_for_containers_and_host_for_hosts(self): + """The connector composes different identities for the two shapes.""" + uids = set(self.by_uid("lacework_many_vuln.json")) + self.assertIn("sha256:aaaa1111|CVE-2000-0001|openssl|3.0.11-1", uids) + self.assertIn("host01.example.com|CVE-2000-0002|curl|7.81.0-1", uids) + + def test_a_fixed_row_is_imported_but_not_active(self): + """Converter applyStatus(): Lacework's own status decides, and "Fixed" is not active.""" + finding = self.by_uid("lacework_many_vuln.json")["sha256:cccc3333|CVE-2000-0003|zlib|1.2.13"] + self.assertFalse(finding.active) + self.assertTrue(finding.is_mitigated) + self.assertEqual("Medium", finding.severity) + + def test_no_mitigation_without_both_a_fix_flag_and_a_fixed_version(self): + finding = self.by_uid("lacework_many_vuln.json")["sha256:cccc3333|CVE-2000-0003|zlib|1.2.13"] + self.assertIsNone(finding.mitigation) + + def test_a_host_row_reports_fix_available_as_a_string(self): + """ + Container rows use an integer fix_available, host rows a string. + + Testing one shape's type against the other would silently drop every host mitigation. + """ + finding = self.by_uid("lacework_many_vuln.json")["host01.example.com|CVE-2000-0002|curl|7.81.0-1"] + self.assertEqual("Upgrade curl to 7.81.0-4.", finding.mitigation) + + def test_a_host_row_with_no_hostname_falls_back_to_the_machine_id(self): + """converter: host = fmt.Sprintf("mid-%d", vuln.Mid) when the hostname is empty.""" + finding = self.by_uid("lacework_many_vuln.json")["mid-77|||"] + self.assertEqual(["host:mid-77", "source:host"], finding.unsaved_tags) + + def test_a_row_with_no_cve_or_package_still_gets_a_title(self): + """Converter title(): the identifier falls back to "Vulnerability".""" + finding = self.by_uid("lacework_many_vuln.json")["mid-77|||"] + self.assertEqual("Vulnerability", finding.title) + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_an_unrecognised_severity_is_info(self): + finding = self.by_uid("lacework_many_vuln.json")["mid-77|||"] + self.assertEqual("Info", finding.severity) + + def test_a_bare_row_array_is_accepted(self): + report = io.StringIO(json.dumps([{ + "imageId": "img", "vulnId": "CVE-1", "severity": "Low", + "evalCtx": {"image_info": {"repo": "r", "registry": "reg"}}, + "featureKey": {"name": "p", "version": "1"}, + }])) + self.assertEqual(1, len(list(LaceworkParser().get_findings(report, Test())))) + + def test_a_repeated_row_collapses_on_the_unique_id(self): + row = { + "imageId": "img", "vulnId": "CVE-1", "severity": "Low", + "evalCtx": {"image_info": {"repo": "r", "registry": "reg"}}, + "featureKey": {"name": "p", "version": "1"}, + } + report = io.StringIO(json.dumps({"data": [row, row]})) + self.assertEqual(1, len(list(LaceworkParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(LaceworkParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("vulnerability rows", str(raised.exception)) diff --git a/unittests/tools/test_netrise_parser.py b/unittests/tools/test_netrise_parser.py new file mode 100644 index 00000000000..c3445010468 --- /dev/null +++ b/unittests/tools/test_netrise_parser.py @@ -0,0 +1,254 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.netrise.parser import NetriseParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestNetriseParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("netrise") / filename + with path.open(encoding="utf-8") as file: + return list(NetriseParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(NetriseParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + node = {"cve": "CVE-2000-0001", "name": "example-lib", "severity": "high", "cvssScore": 7.5} + node.update(overrides) + return {"asset": {"id": "artifact-1", "name": "generic-firmware.bin"}, + "vulnerabilities": {"edges": [{"node": node}]}} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the NetRise connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = NetriseParser() + self.assertEqual(["NetRise Scan"], parser.get_scan_types()) + self.assertEqual("NetRise Scan", parser.get_label_for_scan_types("NetRise Scan")) + self.assertNotIn("NetRise - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("netrise_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("netrise_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("netrise_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 in example-tls", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("netrise-artifact-0001-CVE-2000-0001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("example-tls", finding.component_name) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("Upgrade to a fixed version: 1.1.1w, 3.0.12.", finding.mitigation) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["Generic Networks", "GN-1000", "reachable", "cisa-kev"], + finding.unsaved_tags) + self.assertEqual( + "NetRise marks this vulnerability as reachable in the firmware. " + "Listed in CISA's Known Exploited Vulnerabilities catalog.", + finding.severity_justification, + ) + self.assertEqual( + "**Component:** example-tls\n" + "**Artifact:** generic-router-firmware-1.4.0.bin\n" + "**Vendor:** Generic Networks\n" + "**Product:** GN-1000\n" + "**Firmware version:** 1.4.0\n" + "**Reachable:** yes\n" + "**CISA KEV:** yes", + finding.description, + ) + + def test_many_vuln(self): + self.assertEqual(5, len(self.parse("netrise_many_vuln.json"))) + + def test_an_unrecognised_severity_word_falls_through_to_the_score(self): + """ + Falling back to Info would bury a finding NetRise graded with a word this does not know. + + The score is the safer fallback: 7.5 is High whatever the word says. + """ + finding = self.by_uid("netrise_many_vuln.json")["netrise-artifact-0001-CVE-2000-0002"] + self.assertEqual("High", finding.severity) + self.assertEqual(7.5, finding.cvssv3_score) + + def test_severity_words(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("info", "Info"), ("informational", "Info"), + ("none", "Info"), ("CRITICAL", "Critical")): + with self.subTest(label=label): + # A high score would win if the word were not recognised, so it proves the word did. + findings = self.parse_string(self.row(severity=label, cvssScore=9.9)) + self.assertEqual(expected, findings[0].severity) + + def test_cvss_score_bands(self): + for score, expected in ((9.0, "Critical"), (7.0, "High"), (4.0, "Medium"), (0.1, "Low"), + (0, "Info")): + with self.subTest(score=score): + findings = self.parse_string(self.row(severity="", cvssScore=score)) + self.assertEqual(expected, findings[0].severity) + + def test_a_quoted_score_is_accepted(self): + finding = self.by_uid("netrise_many_vuln.json")["netrise-artifact-0001-CVE-2000-0002"] + self.assertEqual(7.5, finding.cvssv3_score) + + def test_the_identity_is_scoped_to_the_firmware_artifact(self): + """ + The same CVE in two firmware builds is two findings - two builds to re-release. + + Merging them would hide that one of the two is still shipping. + """ + node = {"cve": "CVE-2000-0001", "name": "example-lib", "severity": "high"} + first = self.parse_string({"asset": {"id": "artifact-a"}, + "vulnerabilities": {"edges": [{"node": node}]}}) + second = self.parse_string({"asset": {"id": "artifact-b"}, + "vulnerabilities": {"edges": [{"node": node}]}}) + self.assertEqual("netrise-artifact-a-CVE-2000-0001", first[0].unique_id_from_tool) + self.assertEqual("netrise-artifact-b-CVE-2000-0001", second[0].unique_id_from_tool) + + def test_the_identity_falls_back_to_the_component_when_there_is_no_cve(self): + finding = self.by_uid("netrise_many_vuln.json")[ + "netrise-artifact-0001-example-config-weakness"] + self.assertEqual("example-config-weakness", finding.title) + self.assertIsNone(finding.vuln_id_from_tool) + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_the_title_falls_back_through_the_cve_and_the_component(self): + findings = self.by_uid("netrise_many_vuln.json") + self.assertEqual("CVE-2000-0004", findings["netrise-artifact-0001-CVE-2000-0004"].title) + self.assertEqual("NetRise vulnerability", findings["netrise-artifact-0001-"].title) + + def test_reachability_and_kev_are_justifications_not_regrades(self): + """ + A reachable, actively-exploited flaw in firmware is more urgent than its score says. + + It is recorded as the justification so a reviewer sees why - changing the grade would make it + disagree with an API sync of the same finding. + """ + findings = self.by_uid("netrise_many_vuln.json") + reachable_only = findings["netrise-artifact-0001-example-config-weakness"] + self.assertEqual("NetRise marks this vulnerability as reachable in the firmware.", + reachable_only.severity_justification) + self.assertEqual("Info", reachable_only.severity) + + kev_only = findings["netrise-artifact-0001-CVE-2000-0004"] + self.assertEqual("Listed in CISA's Known Exploited Vulnerabilities catalog.", + kev_only.severity_justification) + + def test_neither_marker_leaves_the_justification_unset(self): + finding = self.by_uid("netrise_many_vuln.json")["netrise-artifact-0001-CVE-2000-0002"] + self.assertIsNone(finding.severity_justification) + self.assertEqual(["Generic Networks", "GN-1000"], finding.unsaved_tags) + + def test_the_markers_are_also_tags(self): + findings = self.by_uid("netrise_many_vuln.json") + self.assertIn("reachable", findings["netrise-artifact-0001-example-config-weakness"].unsaved_tags) + self.assertIn("cisa-kev", findings["netrise-artifact-0001-CVE-2000-0004"].unsaved_tags) + + def test_no_fix_versions_leaves_the_mitigation_unset(self): + findings = self.by_uid("netrise_many_vuln.json") + self.assertIsNone(findings["netrise-artifact-0001-CVE-2000-0002"].mitigation) + self.assertEqual("Upgrade to a fixed version: 1.1.1w, 3.0.12.", + findings["netrise-artifact-0001-CVE-2000-0001"].mitigation) + + def test_relay_edges_are_unwrapped_and_a_flat_list_is_accepted(self): + """ + NetRise answers GraphQL Relay, so each row arrives wrapped in a "node". + + A file somebody has already flattened still imports - the row is used as-is when there is no + node to unwrap. + """ + node = {"cve": "CVE-2000-0001", "name": "example-lib", "severity": "high"} + for vulnerabilities in ({"edges": [{"node": node}]}, [{"node": node}], [node]): + with self.subTest(shape=str(vulnerabilities)[:20]): + findings = self.parse_string({"asset": {"id": "artifact-1"}, + "vulnerabilities": vulnerabilities}) + self.assertEqual(1, len(findings)) + self.assertEqual("netrise-artifact-1-CVE-2000-0001", + findings[0].unique_id_from_tool) + + def test_the_graphql_data_envelope_is_accepted(self): + node = {"cve": "CVE-2000-0001", "name": "example-lib", "severity": "high"} + findings = self.parse_string({"data": {"asset": {"id": "artifact-1"}, + "vulnerabilities": {"edges": [{"node": node}]}}}) + self.assertEqual(1, len(findings)) + self.assertEqual("netrise-artifact-1-CVE-2000-0001", findings[0].unique_id_from_tool) + + def test_the_artifact_may_come_from_the_assets_relay_query(self): + """A saved export of both queries carries the artifact in its own Relay envelope.""" + node = {"cve": "CVE-2000-0001", "name": "example-lib", "severity": "high"} + findings = self.parse_string({ + "assetsRelay": {"edges": [{"node": {"id": "artifact-7", "name": "generic-firmware.bin", + "vendor": "Generic Networks"}}]}, + "vulnerabilities": {"edges": [{"node": node}]}}) + self.assertEqual("netrise-artifact-7-CVE-2000-0001", findings[0].unique_id_from_tool) + self.assertIn("**Artifact:** generic-firmware.bin", findings[0].description) + + def test_a_row_may_carry_its_own_artifact(self): + node = {"cve": "CVE-2000-0001", "name": "example-lib", "severity": "high", + "asset": {"id": "artifact-own", "name": "own-firmware.bin"}} + findings = self.parse_string({"asset": {"id": "artifact-file"}, + "vulnerabilities": {"edges": [{"node": node}]}}) + self.assertEqual("netrise-artifact-own-CVE-2000-0001", findings[0].unique_id_from_tool) + + def test_an_export_with_no_artifact_still_imports(self): + """ + The connector always has an artifact; a file might not. + + The finding is still produced rather than dropped, with an empty artifact in the identity. + """ + findings = self.parse_string({"vulnerabilities": {"edges": [ + {"node": {"cve": "CVE-2000-0001", "name": "example-lib", "severity": "high"}}]}}) + self.assertEqual(1, len(findings)) + self.assertEqual("netrise--CVE-2000-0001", findings[0].unique_id_from_tool) + self.assertNotIn("**Artifact:**", findings[0].description) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("NetRise", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("vulnerabilities", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"asset": {"id": "artifact-1"}, "vulnerabilities": {"edges": [ + "not an object", + None, + {"node": "not an object"}, + {"node": {"cve": "CVE-2000-0009", "name": "example-lib", "severity": "low"}}, + ]}}) + # A row whose node is not an object falls back to the row itself, which has no cve or name. + self.assertEqual(2, len(findings)) + self.assertIn("netrise-artifact-1-CVE-2000-0009", + [finding.unique_id_from_tool for finding in findings]) + + def test_the_component_is_in_the_hash(self): + self.assertEqual(["title", "severity", "component_name"], NetriseParser().get_dedupe_fields()) + + def test_severity_is_always_a_known_value(self): + for filename in ("netrise_many_vuln.json", "netrise_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_nightfall_parser.py b/unittests/tools/test_nightfall_parser.py new file mode 100644 index 00000000000..9c4ba602111 --- /dev/null +++ b/unittests/tools/test_nightfall_parser.py @@ -0,0 +1,422 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.nightfall.parser import NightfallParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestNightfallParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("nightfall") / filename).open(encoding="utf-8") as file: + return list(NightfallParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(NightfallParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Nightfall AI connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every violation. + """ + parser = NightfallParser() + self.assertEqual(["Nightfall AI - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Nightfall AI - Connectors Import", + parser.get_label_for_scan_types("Nightfall AI - Connectors Import"), + ) + self.assertIn("Nightfall", parser.get_description_for_scan_types("Nightfall AI - Connectors Import")) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("nightfall_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("nightfall_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ViolationToFinding in the connector's converter.""" + findings = self.parse("nightfall_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual( + "Verified live AWS credential exposed in GITHUB (example-org/generic-app:deploy/settings.py)", + finding.title, + ) + # The policy says High, but Nightfall verified the key works. + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertEqual("vio_00000000000000000001", finding.unique_id_from_tool) + self.assertEqual("Cloud credentials in source code", finding.vuln_id_from_tool) + self.assertEqual("GITHUB", finding.service) + self.assertEqual("deploy/settings.py", finding.file_path) + self.assertEqual(42, finding.line) + self.assertEqual("Nightfall risk score: 8.5 (source: POLICY)", finding.severity_justification) + self.assertTrue(finding.active) + self.assertFalse(finding.is_mitigated) + self.assertFalse(finding.out_of_scope) + self.assertTrue(finding.verified) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["dlp", "GITHUB", "HIGH", "AWS"], finding.unsaved_tags) + + self.assertIn("**Integration:** GITHUB", finding.description) + self.assertIn("**Location:** example-org/generic-app:deploy/settings.py", finding.description) + self.assertIn("**Policies:** Cloud credentials in source code", finding.description) + self.assertIn("**Nightfall state:** ACTIVE", finding.description) + self.assertIn("**Resource owner:** example-user", finding.description) + self.assertIn("**Redacted detections**", finding.description) + self.assertIn("- AWS key (ACTIVE), redacted value `AKIA****REDACTED****`, confidence VERY_LIKELY, line 42", + finding.description) + # The repository is private, so there is no exposure note. + self.assertNotIn("**Exposure:**", finding.description) + + self.assertEqual( + "https://app.example.com/violations/vio_00000000000000000001\n" + "https://github.example.com/example-org/generic-app/blob/main/deploy/settings.py#L42", + finding.references, + ) + + def test_mitigation_is_the_connectors_three_steps(self): + finding = self.parse("nightfall_one_vuln.json")[0] + steps = finding.mitigation.split("\n") + self.assertEqual(3, len(steps)) + self.assertEqual("Remove or redact the sensitive data from the GITHUB resource.", steps[0]) + self.assertIn("Rotate any credential that was exposed", steps[1]) + self.assertIn("assume it is compromised", steps[1]) + self.assertIn("Review who had access", steps[2]) + + def test_many_vuln(self): + self.assertEqual(7, len(self.parse("nightfall_many_vuln.json"))) + + def test_detections_may_be_indexed_by_violation_id(self): + """ + Nightfall needs two calls per violation, and a detection carries no violation id. + + So an export either nests the detections on the violation - as the one-violation sample does - + or keys them by violation id, which is what this sample does. Both have to work or the + evidence, the credential verdict and the severity are all lost. + """ + findings = self.by_uid("nightfall_many_vuln.json") + github = findings["vio_github_public"] + self.assertIn("redacted value `sk_live_****REDACTED****`", github.description) + self.assertIn("redacted value `ghp_****REDACTED****`", github.description) + self.assertEqual(7, github.line) + + def test_nested_and_indexed_detections_agree(self): + with (get_unit_tests_scans_path("nightfall") / "nightfall_one_vuln.json").open(encoding="utf-8") as file: + export = json.load(file) + violation = export["violations"][0] + detections = violation.pop("findings") + + indexed = self.parse_string({"violations": [violation], "findings": {violation["id"]: detections}}) + nested = self.parse("nightfall_one_vuln.json") + self.assertEqual(nested[0].title, indexed[0].title) + self.assertEqual(nested[0].severity, indexed[0].severity) + self.assertEqual(nested[0].description, indexed[0].description) + + def test_a_verified_live_credential_is_always_critical(self): + """ + A working secret outranks the policy's own risk label. + + Nightfall marks a key ACTIVE when it authenticated with it and SIGNATURE_VERIFIED when it + verified the signature; either means the credential is live. + """ + for status, expected in (("ACTIVE", "Critical"), ("SIGNATURE_VERIFIED", "Critical"), + ("UNVERIFIED", "Low"), ("EXPIRED", "Low"), ("INVALID_SIGNATURE", "Low"), + ("", "Low")): + with self.subTest(status=status): + findings = self.parse_string({"violations": [{ + "id": "vio_1", "integration": "GITHUB", "risk": "LOW", "state": "ACTIVE", + "findings": [{"metadata": {"apiKeyMetaData": {"status": status, "kind": "AWS"}}}], + }]}) + self.assertEqual(expected, findings[0].severity) + + def test_risk_labels(self): + for risk, expected in (("CRITICAL", "Critical"), ("HIGH", "High"), ("MEDIUM", "Medium"), + ("LOW", "Low"), ("NO_RISK", "Info"), ("UNSPECIFIED", "Info"), + ("SEVERE", "Info"), ("", "Info")): + with self.subTest(risk=risk): + findings = self.parse_string({"violations": [ + {"id": "vio_1", "integration": "SLACK", "risk": risk, "state": "ACTIVE"}, + ]}) + self.assertEqual(expected, findings[0].severity) + + def test_unrecognised_risk_label_is_info(self): + """An unknown label is Info rather than a guess, and is kept as a tag so it is not lost.""" + finding = self.by_uid("nightfall_many_vuln.json")["vio_zendesk_unknown_risk"] + self.assertEqual("Info", finding.severity) + self.assertIn("SEVERE", finding.unsaved_tags) + + def test_violation_states(self): + """ + A pending violation is open but not verified - nobody has triaged it yet. + + An expired one is out of scope: Nightfall can no longer see the resource, so it can neither + confirm the data is gone nor that it is still there. + """ + states = { + "ACTIVE": (True, False, False, True), + "PENDING": (True, False, False, False), + "RESOLVED": (False, True, False, True), + "EXPIRED": (False, False, True, True), + "": (False, False, False, False), + } + for state, (active, mitigated, out_of_scope, verified) in states.items(): + with self.subTest(state=state): + findings = self.parse_string({"violations": [ + {"id": "vio_1", "integration": "SLACK", "risk": "LOW", "state": state}, + ]}) + self.assertEqual(active, findings[0].active) + self.assertEqual(mitigated, findings[0].is_mitigated) + self.assertEqual(out_of_scope, findings[0].out_of_scope) + self.assertEqual(verified, findings[0].verified) + + def test_state_is_read_case_insensitively(self): + findings = self.parse_string({"violations": [ + {"id": "vio_1", "integration": "SLACK", "risk": "LOW", "state": "resolved"}, + ]}) + self.assertTrue(findings[0].is_mitigated) + self.assertFalse(findings[0].active) + + def test_location_per_integration(self): + """ + Every integration nests its own metadata block and describes a location differently. + + This is the connector's Metadata.Location(), which is a table rather than a formula. + """ + cases = ( + ({"slackMetadata": {"workspaceName": "Example Workspace", "location": "#general"}}, + "Example Workspace / #general"), + ({"githubMetadata": {"organization": "example-org", "repository": "generic-app", + "filePath": "app/main.py"}}, "example-org/generic-app:app/main.py"), + ({"githubMetadata": {"organization": "example-org", "repository": "generic-app"}}, + "example-org/generic-app"), + ({"gdriveMetadata": {"drive": "Shared drive", "fileName": "notes.docx"}}, + "Shared drive / notes.docx"), + ({"jiraMetadata": {"projectName": "PLATFORM", "ticketNumber": "PLATFORM-1"}}, "PLATFORM PLATFORM-1"), + ({"confluenceMetadata": {"spaceName": "Engineering", "itemName": "Runbook"}}, "Engineering / Runbook"), + ({"salesforceMetadata": {"orgName": "Example", "objectName": "Account"}}, "Example / Account"), + ({"zendeskMetadata": {"ticketTitle": "Cannot sign in", "ticketID": "4321"}}, "Cannot sign in #4321"), + ({"zendeskMetadata": {"ticketTitle": "Cannot sign in", "ticketID": "REQ-1"}}, "Cannot sign in REQ-1"), + ({"notionMetadata": {"workspaceName": "Example", "pageTitle": "Runbook"}}, "Example / Runbook"), + ({"m365TeamsMetadata": {"teamName": "Platform", "channelName": "General"}}, "Platform / General"), + ({"m365OnedriveMetadata": {"driveOwnerName": "example-user", "driveItemName": "notes.docx"}}, + "example-user / notes.docx"), + ({"browserMetadata": {"browserName": "Chrome", "location": "https://app.example.com"}}, + "Chrome / https://app.example.com"), + ({"inlineEmailMetadata": {"domain": "example.com", "subject": "Invoice"}}, "example.com / Invoice"), + ({}, ""), + ) + for metadata, expected in cases: + with self.subTest(metadata=next(iter(metadata), "none")): + self.assertEqual(expected, NightfallParser().location({"metadata": metadata})) + + def test_title_without_a_location_or_integration(self): + """A violation with no metadata block names the integration but no location.""" + findings = self.parse_string({"violations": [ + {"id": "vio_1", "integration": "M365_TEAMS", "policyNames": ["Chat attachments"]}, + ]}) + self.assertEqual("Chat attachments exposed in M365 TEAMS", findings[0].title) + + findings = self.parse_string({"violations": [{"id": "vio_1", "policyNames": ["A policy"]}]}) + self.assertEqual("A policy exposed", findings[0].title) + + def test_title_falls_back_to_the_policy_then_to_a_generic_label(self): + findings = self.by_uid("nightfall_many_vuln.json") + self.assertEqual( + "Payment card numbers in chat exposed in SLACK (Example Workspace / #general)", + findings["vio_slack_pending"].title, + ) + self.assertEqual( + "Sensitive data exposed in JIRA (PLATFORM PLATFORM-1234)", + findings["vio_jira_expired"].title, + ) + + def test_an_unspecified_credential_kind_is_named_api(self): + """ + The credential still leads the title even when Nightfall cannot say what kind it is. + + A detection that identified *some* key is more specific than the policy name, so the + connector prefers it and calls the kind "API". + """ + finding = self.by_uid("nightfall_many_vuln.json")["vio_teams_no_metadata"] + self.assertEqual("API credential exposed in M365 TEAMS", finding.title) + self.assertIn("UNSPECIFIED key (EXPIRED)", finding.description) + + def test_integration_label_replaces_underscores(self): + self.assertEqual("M365 TEAMS", NightfallParser().integration_label("M365_TEAMS")) + self.assertEqual("SLACK", NightfallParser().integration_label("SLACK")) + + def test_exposure_notes(self): + """Only three integrations report a sharing state that makes the exposure external.""" + findings = self.by_uid("nightfall_many_vuln.json") + self.assertIn("**Exposure:** Drive permission ANYONE_WITH_LINK", findings["vio_gdrive_resolved"].description) + self.assertIn("**Exposure:** the Notion page is shared externally", findings["vio_notion_shared"].description) + self.assertIn("**Exposure:** the GitHub repository is public", findings["vio_github_public"].description) + self.assertNotIn("**Exposure:**", findings["vio_jira_expired"].description) + + def test_absent_is_repo_private_reads_as_public(self): + """ + Matching the connector: a repository Nightfall did not call private is treated as public. + + The alternative - staying quiet - would understate a violation that may well be world + readable, which is the whole point of the note. + """ + findings = self.parse_string({"violations": [{ + "id": "vio_1", "integration": "GITHUB", "risk": "LOW", "state": "ACTIVE", + "metadata": {"githubMetadata": {"organization": "example-org", "repository": "generic-app"}}, + }]}) + self.assertIn("**Exposure:** the GitHub repository is public", findings[0].description) + + def test_references_are_deduplicated(self): + findings = self.parse_string({"violations": [{ + "id": "vio_1", "integration": "GDRIVE", "risk": "LOW", "state": "ACTIVE", + "resourceLink": "https://drive.example.com/file/d/1", + "metadata": {"gdriveMetadata": {"fileLink": "https://drive.example.com/file/d/1"}}, + "fileDetails": {"permalink": "https://drive.example.com/file/d/1/view"}, + }]}) + self.assertEqual( + "https://drive.example.com/file/d/1\nhttps://drive.example.com/file/d/1/view", + findings[0].references, + ) + + def test_integrations_without_a_link_field_have_no_references(self): + """Zendesk, browser and inline-email violations carry no permalink in the connector.""" + finding = self.by_uid("nightfall_many_vuln.json")["vio_zendesk_unknown_risk"] + self.assertEqual("", finding.references) + + def test_file_path_and_line_are_github_only(self): + """ + Only a GitHub violation has a code location. + + A Slack message or a Drive file has no path in a repository, so file_path stays unset rather + than being filled with something that is not a path. + """ + findings = self.by_uid("nightfall_many_vuln.json") + self.assertEqual("config/local.env", findings["vio_github_public"].file_path) + self.assertFalse(findings["vio_slack_pending"].file_path) + self.assertFalse(findings["vio_gdrive_resolved"].file_path) + self.assertIsNone(findings["vio_slack_pending"].line) + + def test_line_comes_from_the_first_detection_that_has_one(self): + findings = self.parse_string({"violations": [{ + "id": "vio_1", "integration": "GITHUB", "risk": "LOW", "state": "ACTIVE", + "findings": [ + {"redactedSensitiveText": "****"}, + {"redactedLocation": {"lineRange": {"start": 0, "end": 0}}}, + {"redactedLocation": {"lineRange": {"start": 19, "end": 19}}}, + ], + }]}) + self.assertEqual(19, findings[0].line) + + def test_detection_location_prefers_the_sub_location(self): + finding = self.by_uid("nightfall_many_vuln.json")["vio_slack_pending"] + self.assertIn("confidence LIKELY, message body", finding.description) + self.assertNotIn("line ", finding.description) + + def test_only_redacted_detection_text_is_imported(self): + """ + Nightfall's API only ever returns redacted detection text. + + This parser reads that field and no other, so a sensitive value cannot reach a finding even + if a hand-edited export carried one. + """ + findings = self.parse_string({"violations": [{ + "id": "vio_1", "integration": "GITHUB", "risk": "LOW", "state": "ACTIVE", + "findings": [{ + "redactedSensitiveText": "AKIA****REDACTED****", + "sensitiveText": "a value no Nightfall response contains", + "redactedContext": {"beforeContext": 'KEY = "', "afterContext": '"'}, + }], + }]}) + self.assertIn("AKIA****REDACTED****", findings[0].description) + self.assertNotIn("a value no Nightfall response contains", findings[0].description) + self.assertNotIn("KEY = ", findings[0].description) + + def test_severity_justification_only_when_scored(self): + for score, expected in ((8.5, "Nightfall risk score: 8.5 (source: POLICY)"), + (9.0, "Nightfall risk score: 9 (source: POLICY)"), + (5, "Nightfall risk score: 5 (source: POLICY)"), + (0, None), (None, None)): + with self.subTest(score=score): + violation = {"id": "vio_1", "integration": "SLACK", "risk": "LOW", "state": "ACTIVE", + "riskSource": "POLICY"} + if score is not None: + violation["riskScore"] = score + findings = self.parse_string({"violations": [violation]}) + self.assertEqual(expected, findings[0].severity_justification) + + def test_severity_justification_without_a_source(self): + findings = self.parse_string({"violations": [ + {"id": "vio_1", "integration": "SLACK", "risk": "LOW", "state": "ACTIVE", "riskScore": 4.25}, + ]}) + self.assertEqual("Nightfall risk score: 4.25", findings[0].severity_justification) + + def test_date_is_the_violations_creation_time(self): + findings = self.by_uid("nightfall_many_vuln.json") + self.assertEqual(datetime(2024, 6, 1, tzinfo=UTC).date(), findings["vio_slack_pending"].date) + self.assertEqual(datetime(2024, 7, 15, tzinfo=UTC).date(), findings["vio_github_public"].date) + + def test_missing_creation_time_is_today(self): + """Nightfall sends the creation time as unix seconds; zero means it sent none.""" + finding = self.by_uid("nightfall_many_vuln.json")["vio_notion_shared"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_policies_are_listed_and_the_first_is_the_rule_id(self): + finding = self.by_uid("nightfall_many_vuln.json")["vio_slack_pending"] + self.assertIn("**Policies:** Payment card numbers in chat, PII in chat", finding.description) + self.assertEqual("Payment card numbers in chat", finding.vuln_id_from_tool) + + def test_no_policies_leaves_the_rule_id_unset(self): + finding = self.by_uid("nightfall_many_vuln.json")["vio_jira_expired"] + self.assertIsNone(finding.vuln_id_from_tool) + + def test_credential_kinds_become_tags(self): + finding = self.by_uid("nightfall_many_vuln.json")["vio_github_public"] + # GITHUB is already there as the integration, so the GitHub-token kind is not repeated. + self.assertEqual(["dlp", "GITHUB", "LOW", "STRIPE"], finding.unsaved_tags) + + def test_a_bare_list_of_violations_is_accepted(self): + findings = self.parse_string([{"id": "vio_1", "integration": "SLACK", "risk": "HIGH", "state": "ACTIVE"}]) + self.assertEqual(1, len(findings)) + self.assertEqual("High", findings[0].severity) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Nightfall", str(context.exception)) + + def test_export_without_a_violation_list_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("violations", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"violations": [ + "not an object", + None, + {"id": "vio_1", "integration": "SLACK", "risk": "HIGH", "state": "ACTIVE"}, + ]}) + self.assertEqual(1, len(findings)) + + def test_malformed_detections_are_skipped(self): + findings = self.parse_string({"violations": [{ + "id": "vio_1", "integration": "GITHUB", "risk": "LOW", "state": "ACTIVE", + "findings": ["not an object", None, {"confidence": "LIKELY"}], + }]}) + self.assertEqual(1, len(findings)) + self.assertIn("- confidence LIKELY", findings[0].description) + + def test_severity_is_always_a_known_value(self): + for filename in ("nightfall_many_vuln.json", "nightfall_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_nowsecure_parser.py b/unittests/tools/test_nowsecure_parser.py new file mode 100644 index 00000000000..2990fb67edd --- /dev/null +++ b/unittests/tools/test_nowsecure_parser.py @@ -0,0 +1,230 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.nowsecure.parser import NowSecureParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestNowSecureParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("nowsecure") / filename).open(encoding="utf-8") as file: + return list(NowSecureParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(NowSecureParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"affected": True, "hidden": False, "title": "A finding", "severity": "high", + "check_id": "a_check", "analysis_type": "static"} + row.update(overrides) + return {"findings": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the NowSecure connector's ScanTypeName verbatim - just "NowSecure". + + It does NOT follow the "<Vendor> - Connectors Import" pattern, so it cannot be derived; any + drift and someone who uploads an export and also syncs the API gets two un-deduplicated + copies of every finding. + """ + parser = NowSecureParser() + self.assertEqual(["NowSecure"], parser.get_scan_types()) + self.assertEqual("NowSecure", parser.get_label_for_scan_types("NowSecure")) + self.assertNotIn("NowSecure - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """ + NowSecure reports every check it ran, including the ones that found nothing. + + A check that does not affect the app is not a finding, and one hidden in NowSecure has been + suppressed there - importing either would put noise in front of the team. + """ + self.assertEqual(0, len(self.parse("nowsecure_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("nowsecure_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("nowsecure_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("World-readable files created by the app", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("nowsecure-insecure_data_storage_world_readable-12345", finding.unique_id_from_tool) + self.assertEqual("insecure_data_storage_world_readable", finding.vuln_id_from_tool) + self.assertEqual(7.5, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N", finding.cvssv3) + self.assertEqual("Use MODE_PRIVATE when opening files.", finding.mitigation) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["Data Storage", "static", "android"], finding.unsaved_tags) + + self.assertEqual( + "**Category:** Data Storage\n" + "**Check:** insecure_data_storage_world_readable\n" + "**Analysis:** static\n\n" + "**Description:**\n" + "The app writes files that any other app on the device can read.\n\n" + "**Detail:**\n" + "Observed mode 0644 on two files under the app's data directory.", + finding.description, + ) + + def test_many_vuln(self): + """Five checks reported, two of them not findings.""" + self.assertEqual(3, len(self.parse("nowsecure_many_vuln.json"))) + + def test_a_check_that_does_not_affect_the_app_is_skipped(self): + for affected, hidden, imported in ((True, False, 1), (False, False, 0), (True, True, 0), + (False, True, 0)): + with self.subTest(affected=affected, hidden=hidden): + findings = self.parse_string(self.row(affected=affected, hidden=hidden)) + self.assertEqual(imported, len(findings)) + + def test_static_and_dynamic_are_decided_per_finding(self): + """ + One assessment runs both analyses of the same app, so the file cannot decide this. + + An analysis type the connector does not recognise leaves both flags alone - which is NOT + neutral, because DefectDojo defaults static_finding to False and dynamic_finding to TRUE. Such + a finding is therefore recorded as dynamic, exactly as the connector's own findings are. + Setting two Falses instead would make a file import and an API sync disagree. + """ + findings = self.by_uid("nowsecure_many_vuln.json") + + static = findings["nowsecure-insecure_data_storage_world_readable-12345"] + self.assertTrue(static.static_finding) + self.assertFalse(static.dynamic_finding) + + dynamic = findings["nowsecure-tls_certificate_validation_disabled-12346"] + self.assertFalse(dynamic.static_finding) + self.assertTrue(dynamic.dynamic_finding) + + unknown = findings["nowsecure-debug-symbols-present-in-the-binary"] + self.assertFalse(unknown.static_finding) + self.assertTrue(unknown.dynamic_finding) + + def test_severity_labels(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("info", "Info"), ("informational", "Info"), + ("", "Info"), ("HIGH", "High"), ("not a label", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.row(severity=label)) + self.assertEqual(expected, findings[0].severity) + + def test_the_unique_id_slugs_the_title_when_there_is_no_check_id(self): + """ + Something stable is needed for the identity, and without a check id the title is all there is. + + Note the vulnerability id is omitted when it is zero, which is how NowSecure says it has none. + """ + findings = self.by_uid("nowsecure_many_vuln.json") + self.assertIn("nowsecure-debug-symbols-present-in-the-binary", findings) + + def test_the_vulnerability_id_distinguishes_two_hits_of_one_check(self): + findings = self.parse_string({"findings": [ + {"affected": True, "title": "A finding", "severity": "high", "check_id": "a_check", + "unique_vulnerability_id": 1}, + {"affected": True, "title": "A finding", "severity": "high", "check_id": "a_check", + "unique_vulnerability_id": 2}, + ]}) + self.assertEqual( + {"nowsecure-a_check-1", "nowsecure-a_check-2"}, + {finding.unique_id_from_tool for finding in findings}, + ) + + def test_title_falls_back_to_the_check_then_to_a_constant(self): + by_check = self.parse_string(self.row(title="", check_id="a_check")) + self.assertEqual("NowSecure: a_check", by_check[0].title) + + bare = self.parse_string(self.row(title="", check_id="")) + self.assertEqual("NowSecure finding", bare[0].title) + self.assertIsNone(bare[0].vuln_id_from_tool) + + def test_identifiers_are_sorted_and_deduplicated_case_insensitively(self): + """ + NowSecure's extractor sorts and drops case-insensitive duplicates, unlike the order-preserving + path other connectors use. Mirrored so the two import paths agree on the same export. + """ + finding = self.by_uid("nowsecure_many_vuln.json")["nowsecure-tls_certificate_validation_disabled-12346"] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_a_finding_with_no_identifiers_has_none(self): + finding = self.by_uid("nowsecure_many_vuln.json")["nowsecure-insecure_data_storage_world_readable-12345"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_an_unscored_finding_lands_as_zero(self): + """ + The connector sets the score unconditionally, so an unscored finding gets 0.0. + + Mirrored for parity rather than left unset; raised in the PR as a follow-up for both sides. + """ + finding = self.by_uid("nowsecure_many_vuln.json")["nowsecure-debug-symbols-present-in-the-binary"] + self.assertEqual(0.0, finding.cvssv3_score) + self.assertIsNone(finding.cvssv3) + + def test_no_recommendation_leaves_the_mitigation_empty(self): + finding = self.by_uid("nowsecure_many_vuln.json")["nowsecure-debug-symbols-present-in-the-binary"] + self.assertEqual("", finding.mitigation) + + def test_a_finding_with_no_prose_has_only_the_field_lines(self): + finding = self.by_uid("nowsecure_many_vuln.json")["nowsecure-debug-symbols-present-in-the-binary"] + self.assertEqual( + "**Category:** Code Quality\n**Analysis:** manual_review", + finding.description, + ) + + def test_the_assessment_supplies_the_date_and_the_platform(self): + """ + Neither is on the finding: NowSecure puts them on the assessment that produced it. + + Without the assessment the findings still import, just without a date or a platform tag. + """ + findings = self.parse_string({"findings": [ + {"affected": True, "title": "A finding", "severity": "high", "check_id": "a_check", + "category": "Network", "analysis_type": "static"}, + ]}) + self.assertEqual(datetime.now(tz=UTC).date(), findings[0].date) + self.assertEqual(["Network", "static"], findings[0].unsaved_tags) + + def test_a_bare_array_of_findings_is_accepted(self): + """NowSecure's findings endpoint answers with a bare array.""" + findings = self.parse_string([ + {"affected": True, "title": "A finding", "severity": "high", "check_id": "a_check"}, + ]) + self.assertEqual(1, len(findings)) + self.assertEqual("A finding", findings[0].title) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("NowSecure", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("findings", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"findings": [ + "not an object", + None, + {"affected": True, "title": "A finding", "severity": "high", "check_id": "a_check"}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("nowsecure_many_vuln.json", "nowsecure_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_nozomi_parser.py b/unittests/tools/test_nozomi_parser.py new file mode 100644 index 00000000000..44283164b70 --- /dev/null +++ b/unittests/tools/test_nozomi_parser.py @@ -0,0 +1,237 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.nozomi.parser import NozomiParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestNozomiParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("nozomi") / filename + with path.open(encoding="utf-8") as file: + return list(NozomiParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(NozomiParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"id": "nc-1", "cve": "CVE-2000-0001", "cve_score": 7.5, + "node_label": "generic-plc-01", "asset_id": "asset-1"} + row.update(overrides) + return {"result": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Nozomi connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = NozomiParser() + self.assertEqual(["Nozomi Vantage Scan"], parser.get_scan_types()) + self.assertEqual("Nozomi Vantage Scan", parser.get_label_for_scan_types("Nozomi Vantage Scan")) + self.assertNotIn("Nozomi - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("nozomi_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("nozomi_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("nozomi_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 on generic-plc-01", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("nozomi-nc-0001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual(787, finding.cwe) + self.assertEqual("GC-9000", finding.component_name) + self.assertEqual("2.4.1", finding.component_version) + self.assertEqual("Apply hotfix 2.4.3.", finding.mitigation) + self.assertEqual("https://example.com/advisories/CVE-2000-0001\n" + "https://example.com/vendor/advisory-2024-01", finding.references) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["Generic Controls", "PLC", "GC-9000", "Cell Zone A"], finding.unsaved_tags) + self.assertEqual( + "**Asset:** generic-plc-01\n" + "**Type:** PLC\n" + "**Vendor:** Generic Controls\n" + "**Product:** GC-9000\n" + "**Firmware:** 2.4.1\n" + "**OS:** GCOS 4\n" + "**Zone:** Cell Zone A\n" + "**Weakness:** Out-of-bounds Write\n\n" + "A crafted control message causes the controller to execute attacker code.", + finding.description, + ) + + def test_many_vuln(self): + """Five records, but the resolved one is never imported.""" + self.assertEqual(4, len(self.parse("nozomi_many_vuln.json"))) + + def test_a_resolved_record_is_skipped(self): + """ + The connector queries "node_cves | where resolved != true", so an API sync never sees these. + + A hand-run query can return them, and importing one would open a finding Nozomi has already + closed - so the query filter is mirrored here, not only the converter. + """ + findings = self.by_uid("nozomi_many_vuln.json") + self.assertNotIn("nozomi-nc-0004", findings) + for finding in findings.values(): + self.assertNotIn("Never imported", str(finding.description)) + + def test_only_a_true_resolved_flag_skips_a_record(self): + """An absent flag, or a false one, is an open vulnerability.""" + for resolved, expected in ((True, 0), (False, 1), (None, 1)): + with self.subTest(resolved=resolved): + row = {"id": "nc-1", "cve": "CVE-2000-0001", "cve_score": 7.5} + if resolved is not None: + row["resolved"] = resolved + self.assertEqual(expected, len(self.parse_string({"result": [row]}))) + + def test_a_record_with_no_resolved_key_is_imported(self): + finding = self.by_uid("nozomi_many_vuln.json")["nozomi-nc-0005"] + self.assertEqual("Medium", finding.severity) + + def test_cvss_score_bands(self): + """Nozomi sends no severity word, so the score is the only signal.""" + for score, expected in ((9.0, "Critical"), (9.8, "Critical"), (7.0, "High"), (4.0, "Medium"), + (0.1, "Low"), (0, "Info")): + with self.subTest(score=score): + findings = self.parse_string(self.row(cve_score=score)) + self.assertEqual(expected, findings[0].severity) + + def test_a_quoted_score_is_accepted(self): + finding = self.by_uid("nozomi_many_vuln.json")["nozomi-CVE-2000-0002-asset-0002"] + self.assertEqual(7.5, finding.cvssv3_score) + self.assertEqual("High", finding.severity) + + def test_an_unscored_record_is_info_rather_than_dropped(self): + """In an OT estate the asset context is worth recording even with no score.""" + finding = self.by_uid("nozomi_many_vuln.json")["nozomi-nc-0003"] + self.assertEqual("Info", finding.severity) + self.assertEqual(0.0, finding.cvssv3_score) + + def test_the_identity_falls_back_to_the_cve_and_the_asset(self): + """Vantage's record id is preferred; without one the CVE plus the asset keeps rows apart.""" + findings = self.by_uid("nozomi_many_vuln.json") + self.assertIn("nozomi-CVE-2000-0002-asset-0002", findings) + self.assertIn("nozomi-nc-0001", findings) + + def test_the_title_has_no_asset_only_form(self): + """ + A record with no CVE has nothing to name it by, so it falls through to a generic title. + + Titling it after the device would read as though the device itself were the finding. + """ + finding = self.by_uid("nozomi_many_vuln.json")["nozomi-nc-0003"] + self.assertEqual("Nozomi vulnerability", finding.title) + self.assertIn("**Asset:** generic-rtu-03", finding.description) + + def test_a_record_with_no_cve_carries_no_vulnerability_id(self): + finding = self.by_uid("nozomi_many_vuln.json")["nozomi-nc-0003"] + self.assertIsNone(finding.vuln_id_from_tool) + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_cwe_forms(self): + for value, expected in (("CWE-787", 787), ("787", 787), ("cwe-787", 787), + ("not a cwe", 0), ("", 0)): + with self.subTest(value=value): + findings = self.parse_string(self.row(cwe_id=value)) + self.assertEqual(expected, findings[0].cwe) + + def test_a_bare_cwe_number_is_accepted(self): + finding = self.by_uid("nozomi_many_vuln.json")["nozomi-CVE-2000-0002-asset-0002"] + self.assertEqual(89, finding.cwe) + + def test_the_mitigation_prefers_the_latest_hotfix_then_the_minimum(self): + findings = self.by_uid("nozomi_many_vuln.json") + self.assertEqual("Apply hotfix 2.4.3.", findings["nozomi-nc-0001"].mitigation) + self.assertEqual("Apply at least hotfix 5.0.1.", + findings["nozomi-CVE-2000-0002-asset-0002"].mitigation) + self.assertIsNone(findings["nozomi-nc-0003"].mitigation) + + def test_the_summary_is_separated_from_the_asset_context_by_a_blank_line(self): + finding = self.by_uid("nozomi_many_vuln.json")["nozomi-nc-0001"] + self.assertIn("**Weakness:** Out-of-bounds Write\n\nA crafted control message", + finding.description) + + def test_a_record_with_no_summary_ends_at_the_asset_context(self): + """No summary means no trailing blank line - the description simply ends at the last field.""" + finding = self.by_uid("nozomi_many_vuln.json")["nozomi-CVE-2000-0002-asset-0002"] + self.assertTrue(finding.description.endswith("**Zone:** Cell Zone B")) + self.assertNotIn("\n\n", finding.description) + # The absent OS and the empty weakness name are both left out entirely. + self.assertNotIn("**OS:**", finding.description) + self.assertNotIn("**Weakness:**", finding.description) + + def test_an_empty_reference_list_leaves_the_references_unset(self): + findings = self.by_uid("nozomi_many_vuln.json") + self.assertIsNone(findings["nozomi-CVE-2000-0002-asset-0002"].references) + self.assertIsNone(findings["nozomi-nc-0003"].references) + + def test_export_shapes(self): + row = {"id": "nc-1", "cve": "CVE-2000-0001", "cve_score": 7.5} + for payload in ([row], {"result": [row]}, {"results": [row]}, {"data": [row]}): + with self.subTest(shape=str(payload)[:20]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Nozomi", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("result", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"result": [ + "not an object", + None, + {"id": "nc-9", "cve": "CVE-2000-0009", "cve_score": 5.0, + "cve_references": "not a list"}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("nozomi-nc-9", findings[0].unique_id_from_tool) + self.assertIsNone(findings[0].references) + + def test_the_component_is_the_ot_product(self): + """The same CVE on two different devices stays two findings.""" + self.assertEqual(["title", "severity", "component_name"], NozomiParser().get_dedupe_fields()) + findings = self.by_uid("nozomi_many_vuln.json") + self.assertEqual("GC-9000", findings["nozomi-nc-0001"].component_name) + self.assertEqual("HMI-100", findings["nozomi-CVE-2000-0002-asset-0002"].component_name) + self.assertIsNone(findings["nozomi-nc-0003"].component_name) + + def test_nothing_is_recorded_as_a_dynamic_finding(self): + """ + Vantage builds its inventory passively, which is why it is used in OT at all. + + Recording a finding as dynamic would imply the device had been probed. + """ + for finding in self.parse("nozomi_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + def test_severity_is_always_a_known_value(self): + for filename in ("nozomi_many_vuln.json", "nozomi_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_ostorlab_parser.py b/unittests/tools/test_ostorlab_parser.py new file mode 100644 index 00000000000..b1047750a42 --- /dev/null +++ b/unittests/tools/test_ostorlab_parser.py @@ -0,0 +1,328 @@ +import io +import json +from datetime import date + +from dojo.models import Finding, Test +from dojo.tools.ostorlab.parser import OstorlabParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestOstorlabParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("ostorlab") / filename + with path.open(encoding="utf-8") as file: + return list(OstorlabParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(OstorlabParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def scan(self, vuln=None, asset_type="WEB", scan_id=1): + return {"data": {"scan": { + "id": scan_id, "assetType": asset_type, "createdTime": "2024-06-03T09:30:00Z", + "vulnerabilities": {"vulnerabilities": [vuln or { + "id": 1, "detail": {"title": "A finding", "riskRating": "HIGH"}}]}}}} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Ostorlab connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = OstorlabParser() + self.assertEqual(["Ostorlab Scan"], parser.get_scan_types()) + self.assertEqual("Ostorlab Scan", parser.get_label_for_scan_types("Ostorlab Scan")) + self.assertNotIn("Ostorlab - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("ostorlab_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("ostorlab_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("ostorlab_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Application is debuggable", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("ostorlab-4001-900001", finding.unique_id_from_tool) + self.assertEqual("Application is debuggable", finding.vuln_id_from_tool) + self.assertEqual("CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", finding.cvssv3) + self.assertEqual("Set android:debuggable to false in the release manifest.", + finding.mitigation) + self.assertEqual(date(2024, 6, 2), finding.date) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertTrue(finding.active) + # ANDROID_STORE is analysed statically. + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["HIGH", "ANDROID_STORE"], finding.unsaved_tags) + self.assertEqual( + "- Vendor guidance: https://example.com/guidance/debuggable\n" + "- https://example.com/advisories/CVE-2000-0001\n" + "- A reference with no link", + finding.references, + ) + self.assertEqual( + "**Description:**\n" + "A debuggable release build lets anyone with the device read memory and step through " + "the application.\n\n" + "**Summary:**\n" + "The release build allows a debugger to attach.\n\n" + "**Technical detail:**\n" + "The application ships a debuggable manifest flag.\n\n" + "**File path:**\n" + "AndroidManifest.xml\n\n" + "**Code location:**\n" + "application/@android:debuggable", + finding.description, + ) + + def test_many_vuln(self): + """Seven vulnerabilities, but the SECURE one is never imported.""" + self.assertEqual(6, len(self.parse("ostorlab_many_vuln.json"))) + + def test_a_secure_rating_is_a_passed_check_and_is_skipped(self): + """ + SECURE means the check passed. Importing it would file a passing check as a finding. + + That is what the connector's IsIgnored exists to prevent. + """ + findings = self.by_uid("ostorlab_many_vuln.json") + self.assertNotIn("ostorlab-4002-900014", findings) + for finding in findings.values(): + self.assertNotIn("Never imported", finding.title) + + def test_secure_is_matched_case_insensitively(self): + for rating in ("SECURE", "secure", " Secure "): + with self.subTest(rating=rating): + self.assertEqual(0, len(self.parse_string(self.scan( + vuln={"id": 1, "detail": {"title": "A finding", "riskRating": rating}})))) + + def test_risk_ratings(self): + for rating, expected in (("CRITICAL", "Critical"), ("HIGH", "High"), ("MEDIUM", "Medium"), + ("LOW", "Low"), ("POTENTIALLY", "Low"), ("HARDENING", "Info"), + ("IMPORTANT", "Info"), ("INFO", "Info"), ("critical", "Critical"), + ("", "Info")): + with self.subTest(rating=rating): + findings = self.parse_string(self.scan( + vuln={"id": 1, "detail": {"title": "A finding", "riskRating": rating}})) + self.assertEqual(expected, findings[0].severity) + + def test_potentially_is_low_because_ostorlab_could_not_confirm_it(self): + finding = self.by_uid("ostorlab_many_vuln.json")["ostorlab-4002-900011"] + self.assertEqual("Low", finding.severity) + self.assertIn("POTENTIALLY", finding.unsaved_tags) + + def test_an_important_rating_grades_as_info(self): + """ + Mirrored, not corrected: the connector maps IMPORTANT to Info. + + Changing it here would make a file import disagree with an API sync of the same finding. + Raised in the PR as a follow-up on the connector side. + """ + finding = self.by_uid("ostorlab_many_vuln.json")["ostorlab-4002-900013"] + self.assertEqual("Info", finding.severity) + + def test_the_asset_type_decides_static_versus_dynamic(self): + """ + Ostorlab scans mobile binaries and web targets from one platform. + + A binary is read; a web target is exercised. Deciding per scan is what keeps both honest. + """ + for asset_type, static in (("ANDROID_STORE", True), ("IOS_STORE", True), + ("ANDROID_FILE", True), ("MOBILE_APP", True), + ("WEB", False), ("NETWORK", False), ("DOMAIN_NAME", False), + ("", False)): + with self.subTest(asset_type=asset_type): + findings = self.parse_string(self.scan(asset_type=asset_type)) + self.assertEqual(static, findings[0].static_finding) + self.assertEqual(not static, findings[0].dynamic_finding) + + def test_a_web_scan_is_dynamic(self): + finding = self.by_uid("ostorlab_many_vuln.json")["ostorlab-4002-900010"] + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + def test_identifiers_are_extracted_from_the_prose_and_the_references(self): + """ + Ostorlab exposes NO CVE field, so a finding that names one names it in its text. + + The connector's shared extractor sorts its results, so the order is alphabetical rather than + the order they appear - the fixture names CVE-2000-0002 before CVE-2000-0001. + """ + finding = self.by_uid("ostorlab_many_vuln.json")["ostorlab-4002-900010"] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_a_reference_url_alone_can_supply_an_identifier(self): + finding = self.parse("ostorlab_one_vuln.json")[0] + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + + def test_a_finding_naming_no_identifier_has_none(self): + finding = self.by_uid("ostorlab_many_vuln.json")["ostorlab-4002-900012"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_non_cve_advisory_formats_are_recognised(self): + for identifier in ("GHSA-aaaa-bbbb-cccc", "GO-2024-1234", "RHSA-2024:1234"): + with self.subTest(identifier=identifier): + findings = self.parse_string(self.scan(vuln={ + "id": 1, "technicalDetail": f"see {identifier}", + "detail": {"title": "A finding", "riskRating": "HIGH"}})) + self.assertEqual([identifier], findings[0].unsaved_vulnerability_ids) + + def test_the_location_metadata_is_rendered_under_its_own_type(self): + """ + Ostorlab attaches whatever context fits the finding - a URL, a file path, a code location. + + Using the metadata type as the label means the parser does not have to know the names in + advance, so a new kind of context still reaches the reader. + """ + finding = self.by_uid("ostorlab_many_vuln.json")["ostorlab-4002-900010"] + self.assertIn("**URL:**\nhttps://app.example.com/search?q=1", finding.description) + + def test_metadata_with_no_type_is_skipped(self): + findings = self.parse_string(self.scan(vuln={ + "id": 1, "detail": {"title": "A finding", "riskRating": "HIGH"}, + "vulnerabilityLocation": {"metadata": [ + {"metadataType": "", "metadataValue": "orphaned"}, + "not an object", None, + {"metadataType": "Kept", "metadataValue": "value"}]}})) + self.assertIn("**Kept:**\nvalue", findings[0].description) + self.assertNotIn("orphaned", findings[0].description) + + def test_a_finding_with_no_detail_block_still_imports(self): + """ + The detail is a pointer in the connector, so it can be absent. + + The finding falls back to a generated title and grades as Info rather than being dropped. + """ + finding = self.by_uid("ostorlab_many_vuln.json")["ostorlab-4002-900015"] + self.assertEqual("Ostorlab finding 900015", finding.title) + self.assertEqual("Info", finding.severity) + self.assertIsNone(finding.vuln_id_from_tool) + self.assertIsNone(finding.mitigation) + self.assertIsNone(finding.cvssv3) + self.assertIsNone(finding.references) + self.assertIn("**Technical detail:**", finding.description) + + def test_the_endpoint_prefers_the_asset_name_then_its_host(self): + findings = self.by_uid("ostorlab_many_vuln.json") + from_host = self.get_unsaved_locations(findings["ostorlab-4002-900010"]) + self.assertEqual(1, len(from_host)) + self.assertEqual("app.example.com", from_host[0].host) + + from_name = self.get_unsaved_locations(findings["ostorlab-4002-900011"]) + self.assertEqual(1, len(from_name)) + self.assertEqual("app.example.com", from_name[0].host) + + def test_a_mobile_finding_has_no_endpoint(self): + """A mobile scan names a package, not a host, so there is nothing to record.""" + finding = self.parse("ostorlab_one_vuln.json")[0] + self.assertEqual(0, len(self.get_unsaved_locations(finding))) + + def test_a_host_defectdojo_would_reject_adds_no_endpoint(self): + """ + A bad host makes Endpoint.clean() raise, which fails the WHOLE import. + + Declining to build the endpoint keeps the rest of the file importable. + """ + finding = self.by_uid("ostorlab_many_vuln.json")["ostorlab-4002-900016"] + self.assertEqual(0, len(self.get_unsaved_locations(finding))) + self.assertEqual("Medium", finding.severity) + + def test_the_cvss_vector_is_a_vector_not_a_score(self): + """Ostorlab publishes the vector string and no score, so cvssv3_score stays unset.""" + finding = self.parse("ostorlab_one_vuln.json")[0] + self.assertTrue(finding.cvssv3.startswith("CVSS:3.1/")) + self.assertIsNone(finding.cvssv3_score) + + def test_the_identity_spans_the_scan_and_the_vulnerability(self): + """The same finding in two scans of one app is two records, one per scan.""" + vuln = {"id": 900010, "detail": {"title": "A finding", "riskRating": "HIGH"}} + first = self.parse_string(self.scan(vuln=vuln, scan_id=4001)) + second = self.parse_string(self.scan(vuln=vuln, scan_id=4002)) + self.assertEqual("ostorlab-4001-900010", first[0].unique_id_from_tool) + self.assertEqual("ostorlab-4002-900010", second[0].unique_id_from_tool) + + def test_quoted_ids_are_accepted(self): + findings = self.parse_string({"data": {"scan": { + "id": "4001", "assetType": "WEB", + "vulnerabilities": {"vulnerabilities": [ + {"id": "900010", "detail": {"title": "A finding", "riskRating": "HIGH"}}]}}}}) + self.assertEqual("ostorlab-4001-900010", findings[0].unique_id_from_tool) + + def test_export_shapes(self): + """ + Ostorlab's own shape doubles the key: the outer one is the connection, the inner one the list. + + The unwrapped forms and a bare array are accepted too. + """ + vuln = {"id": 1, "detail": {"title": "A finding", "riskRating": "HIGH"}} + payloads = ( + {"data": {"scan": {"id": 1, "vulnerabilities": {"vulnerabilities": [vuln]}}}}, + {"scan": {"id": 1, "vulnerabilities": {"vulnerabilities": [vuln]}}}, + {"vulnerabilities": {"vulnerabilities": [vuln]}}, + {"vulnerabilities": [vuln]}, + [vuln], + ) + for payload in payloads: + with self.subTest(shape=str(payload)[:26]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_an_export_with_no_scan_context_still_imports(self): + """ + Without the scan, the identity carries scan 0 and every finding is dynamic. + + The findings are still produced rather than dropped, and the docs say what is lost. + """ + findings = self.parse_string([{"id": 900010, + "detail": {"title": "A finding", "riskRating": "HIGH"}}]) + self.assertEqual(1, len(findings)) + self.assertEqual("ostorlab-0-900010", findings[0].unique_id_from_tool) + self.assertFalse(findings[0].static_finding) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Ostorlab", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("vulnerabilities", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"data": {"scan": {"id": 1, "vulnerabilities": { + "vulnerabilities": ["not an object", None, + {"id": 9, "detail": {"title": "A finding", "riskRating": "LOW", + "references": ["not an object", None]}}]}}}}) + self.assertEqual(1, len(findings)) + self.assertEqual("ostorlab-1-9", findings[0].unique_id_from_tool) + self.assertIsNone(findings[0].references) + + def test_the_hash_includes_a_component_ostorlab_never_reports(self): + """ + The copied hash field list names component_name, and Ostorlab reports no component. + + So that field hashes as empty and the hash is effectively title plus severity. Copied as it + stands rather than trimmed, because changing it would change how the connector's own findings + hash - raised in the PR as a follow-up. + """ + self.assertEqual(["title", "severity", "component_name"], + OstorlabParser().get_dedupe_fields()) + for finding in self.parse("ostorlab_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertIsNone(finding.component_name) + + def test_severity_is_always_a_known_value(self): + for filename in ("ostorlab_many_vuln.json", "ostorlab_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_parasoft_parser.py b/unittests/tools/test_parasoft_parser.py new file mode 100644 index 00000000000..419439afc20 --- /dev/null +++ b/unittests/tools/test_parasoft_parser.py @@ -0,0 +1,193 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.parasoft.parser import ParasoftParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestParasoftParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("parasoft") / filename + with path.open(encoding="utf-8") as file: + return list(ParasoftParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(ParasoftParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"hash": "h1", "rule": "RULE-1", "message": "A violation", "severity": 2, + "locFile": "src/generic/a.c", "locStartLine": 10} + row.update(overrides) + return {"staticAnalysisViolations": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Parasoft connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = ParasoftParser() + self.assertEqual(["Parasoft DTP Scan"], parser.get_scan_types()) + self.assertEqual("Parasoft DTP Scan", parser.get_label_for_scan_types("Parasoft DTP Scan")) + self.assertNotIn("Parasoft - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("parasoft_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("parasoft_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("parasoft_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CERT_C-INT30-a: Unsigned integer operation may wrap around", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("parasoft-a1b2c3d4e5f60718", finding.unique_id_from_tool) + self.assertEqual("CERT_C-INT30-a", finding.vuln_id_from_tool) + self.assertEqual("src/generic/parser.c", finding.file_path) + self.assertEqual(128, finding.line) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["CERT_C-INT30-a", "Integers", + "com.parasoft.xtest.cpp.analyzer.static.pattern", "c"], + finding.unsaved_tags) + self.assertEqual( + "**Message:** Unsigned integer operation may wrap around\n" + "**Rule:** CERT_C-INT30-a\n" + "**Category:** Integers\n" + "**Analyzer:** com.parasoft.xtest.cpp.analyzer.static.pattern\n" + "**Language:** c", + finding.description, + ) + + def test_many_vuln(self): + self.assertEqual(6, len(self.parse("parasoft_many_vuln.json"))) + + def test_severity_one_is_the_most_severe(self): + """ + DTP's numeric severity is the inverse of a score: 1 is critical, 5 is informational. + + Reading it as a score would invert the entire ladder. + """ + for code, expected in ((1, "Critical"), (2, "High"), (3, "Medium"), (4, "Low"), (5, "Info"), + (0, "Info"), (9, "Info"), (None, "Info")): + with self.subTest(code=code): + findings = self.parse_string(self.row(severity=code)) + self.assertEqual(expected, findings[0].severity) + + def test_a_quoted_severity_and_line_are_accepted(self): + finding = self.by_uid("parasoft_many_vuln.json")["parasoft-v-900002"] + self.assertEqual("High", finding.severity) + self.assertEqual(64, finding.line) + + def test_the_identity_prefers_the_violation_hash(self): + """ + DTP's hash is what stays stable as a file is edited around the violation. + + The rule-plus-file fallback would merge two violations of one rule in one file, so the hash + and then the id are preferred. + """ + findings = self.by_uid("parasoft_many_vuln.json") + self.assertIn("parasoft-a1b2c3d4e5f60718", findings) + # No hash, so the violation id is used. + self.assertIn("parasoft-v-900002", findings) + # Neither, so the rule and the file. + self.assertIn("parasoft-PB.NUM.CLP-src/generic/util.java", findings) + + def test_the_title_falls_back_through_the_message_and_the_rule(self): + findings = self.by_uid("parasoft_many_vuln.json") + self.assertEqual("PB.NUM.CLP", findings["parasoft-PB.NUM.CLP-src/generic/util.java"].title) + self.assertEqual("A violation with no rule id", + findings["parasoft--src/generic/other.c"].title) + self.assertEqual("Parasoft DTP violation", findings["parasoft--"].title) + + def test_a_violation_with_no_rule_has_no_rule_id(self): + finding = self.by_uid("parasoft_many_vuln.json")["parasoft--src/generic/other.c"] + self.assertIsNone(finding.vuln_id_from_tool) + + def test_identifiers_named_in_the_message_are_extracted_and_sorted(self): + """ + Rare for static analysis, but a rule that names a CVE is worth linking. + + The connector's shared extractor sorts its results, so the order is alphabetical rather than + the order they appear - the fixture names CVE-2000-0002 first. + """ + finding = self.by_uid("parasoft_many_vuln.json")["parasoft-v-900002"] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_an_identifier_in_the_rule_id_is_also_read(self): + findings = self.parse_string(self.row(rule="CVE-2000-0005", message="A violation")) + self.assertEqual(["CVE-2000-0005"], findings[0].unsaved_vulnerability_ids) + + def test_non_cve_advisory_formats_are_recognised(self): + for identifier in ("GHSA-aaaa-bbbb-cccc", "GO-2024-1234", "RHSA-2024:1234"): + with self.subTest(identifier=identifier): + findings = self.parse_string(self.row(message=f"see {identifier}")) + self.assertEqual([identifier], findings[0].unsaved_vulnerability_ids) + + def test_a_violation_naming_no_identifier_has_none(self): + finding = self.by_uid("parasoft_many_vuln.json")["parasoft-a1b2c3d4e5f60718"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_a_violation_with_no_line_has_none(self): + finding = self.by_uid("parasoft_many_vuln.json")["parasoft-MISRA-1-src/generic/info.c"] + self.assertIsNone(finding.line) + self.assertEqual("src/generic/info.c", finding.file_path) + + def test_a_violation_with_no_file_has_no_path(self): + finding = self.by_uid("parasoft_many_vuln.json")["parasoft--"] + self.assertIsNone(finding.file_path) + + def test_absent_description_fields_are_left_out(self): + finding = self.by_uid("parasoft_many_vuln.json")["parasoft-PB.NUM.CLP-src/generic/util.java"] + self.assertNotIn("**Message:**", finding.description) + self.assertNotIn("**Analyzer:**", finding.description) + self.assertIn("**Rule:** PB.NUM.CLP", finding.description) + self.assertIn("**Language:** java", finding.description) + + def test_export_shapes(self): + row = {"hash": "h1", "rule": "RULE-1", "message": "A violation", "severity": 2} + for payload in ([row], {"staticAnalysisViolations": [row]}, {"violations": [row]}, + {"data": [row]}, {"results": [row]}): + with self.subTest(shape=str(payload)[:26]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Parasoft", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("staticAnalysisViolations", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"staticAnalysisViolations": [ + "not an object", + None, + {"hash": "h9", "rule": "RULE-9", "message": "A violation", "severity": 3}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("parasoft-h9", findings[0].unique_id_from_tool) + + def test_the_file_path_and_the_rule_are_both_in_the_hash(self): + """The same rule firing in two files is two violations to fix.""" + self.assertEqual(["title", "severity", "file_path", "vuln_id_from_tool"], + ParasoftParser().get_dedupe_fields()) + + def test_severity_is_always_a_known_value(self): + for filename in ("parasoft_many_vuln.json", "parasoft_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_probely_parser.py b/unittests/tools/test_probely_parser.py new file mode 100644 index 00000000000..1b64cd5b340 --- /dev/null +++ b/unittests/tools/test_probely_parser.py @@ -0,0 +1,209 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.probely.parser import ProbelyParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestProbelyParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("probely") / filename).open(encoding="utf-8") as file: + return list(ProbelyParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Probely connector's ScanType() verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. Any drift and a customer who + uploads an export and also syncs the API gets two un-deduplicated copies of every finding. + """ + parser = ProbelyParser() + self.assertEqual(["Probely API Import"], parser.get_scan_types()) + self.assertEqual("Probely API Import", parser.get_label_for_scan_types("Probely API Import")) + self.assertNotIn("Probely - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("probely_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("probely_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ConvertFinding in the connector's converter.""" + findings = self.parse("probely_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("SQL Injection", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("9001", finding.unique_id_from_tool) + self.assertEqual("sql-injection", finding.vuln_id_from_tool) + self.assertEqual(9.1, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N", finding.cvssv3) + self.assertEqual(89, finding.cwe) + # Probely is DAST. + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + self.assertIn("**Path:** /search", finding.description) + self.assertIn("**Method:** GET", finding.description) + self.assertIn("The application builds a database query", finding.description) + self.assertIn("**Evidence:**", finding.description) + self.assertIn("produced a database error", finding.description) + + def test_the_severity_integers_are_the_connectors(self): + """ + Probely reports severity as an integer, and only 10, 20 and 30 exist. + + Treating the number as a CVSS-like score, or as an index, would misgrade everything. + """ + findings = self.by_uid("probely_many_vuln.json") + self.assertEqual("High", findings["9001"].severity) # 30 + self.assertEqual("Medium", findings["9002"].severity) # 20 + self.assertEqual("Low", findings["9003"].severity) # 10 + self.assertEqual("Info", findings["9004"].severity) # 99, unrecognised + + def test_the_severity_mapping_directly(self): + parser = ProbelyParser() + for raw, expected in [(10, "Low"), (20, "Medium"), (30, "High"), + (0, "Info"), (99, "Info"), (None, "Info"), ("nonsense", "Info")]: + self.assertEqual(expected, parser.severity({"severity": raw}), raw) + # Probely sends integers, but a string digit must not silently become Info. + self.assertEqual("High", parser.severity({"severity": "30"})) + + def test_closed_out_findings_are_not_imported(self): + """ + Probely records fixed, invalid and accepted findings, and the connector skips all three. + + Importing them would put resolved and triaged work back in front of the team. + """ + uids = set(self.by_uid("probely_many_vuln.json")) + self.assertNotIn("9005", uids) # fixed + self.assertNotIn("9006", uids) # accepted + self.assertNotIn("9007", uids) # invalid + self.assertEqual(4, len(uids)) + + def test_a_finding_being_retested_is_still_imported(self): + """ + The connector deliberately does NOT skip "retesting". + + A re-test means the issue is still being worked on, so it is assumed open. Skipping it would + drop live findings whenever somebody clicked re-test. + """ + findings = self.by_uid("probely_many_vuln.json") + self.assertIn("9002", findings) + self.assertEqual("Missing Content-Security-Policy", findings["9002"].title) + + def test_the_ignored_state_check_directly(self): + parser = ProbelyParser() + for state in ("fixed", "invalid", "accepted", "FIXED", " accepted "): + self.assertTrue(parser.is_ignored({"state": state}), state) + for state in ("notfixed", "retesting", "", None): + self.assertFalse(parser.is_ignored({"state": state}), state) + + def test_the_scanned_origin_is_recorded(self): + """ + This scan type's deduplication hashes the ENDPOINTS. + + So an unpopulated endpoint would leave the hash computed over nothing, and every rescan would + reimport. Asserted through get_unsaved_locations so it passes in both + V3_FEATURE_LOCATIONS modes. + """ + finding = self.parse("probely_one_vuln.json")[0] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual("https", locations[0].protocol) + + def test_endpoints_is_in_the_dedupe_fields_so_it_must_be_populated(self): + """Guards the pairing above: if endpoints leaves the field set, the assertion above matters.""" + self.assertIn("endpoints", ProbelyParser().get_dedupe_fields()) + + def test_an_explicit_port_is_carried_through(self): + finding = self.by_uid("probely_many_vuln.json")["9003"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual(8443, locations[0].port) + + def test_an_unparseable_url_records_no_endpoint_rather_than_failing(self): + finding = self.by_uid("probely_many_vuln.json")["9004"] + self.assertEqual([], self.get_unsaved_locations(finding)) + + def test_the_insertion_point_becomes_a_readable_label(self): + """ + The connector title-cases the insertion point and then fixes the acronyms. + + Naive title casing gives "Url Query" and "Json Body", which reads as a bug. + """ + findings = self.by_uid("probely_many_vuln.json") + self.assertIn("**URL Query:** q", findings["9001"].description) + self.assertIn("**JSON Body:** session", findings["9003"].description) + self.assertIn("**GraphQL Variable:**", findings["9004"].description) + + def test_a_finding_with_no_insertion_point_has_no_parameter_line(self): + finding = self.by_uid("probely_many_vuln.json")["9002"] + self.assertNotIn("**URL", finding.description) + # No method either, so no method line. + self.assertNotIn("**Method:**", finding.description) + + def test_the_severity_justification_mirrors_the_converters_sentence(self): + finding = self.parse("probely_one_vuln.json")[0] + self.assertEqual( + "Probely has issued a severity level of **High** from a base CVSS score of **9.1**.\n" + "*CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N*", + finding.severity_justification, + ) + + def test_mitigation_joins_the_fix_and_the_extra_notes(self): + """The converter concatenates them with a newline, even when one is empty.""" + finding = self.parse("probely_one_vuln.json")[0] + self.assertEqual( + "Use parameterised queries instead of string concatenation.\n" + "Confirmed against the staging target only.", + finding.mitigation, + ) + # An empty extra still leaves the trailing newline, as the converter does. + self.assertEqual( + "Add a Content-Security-Policy header.\n", + self.by_uid("probely_many_vuln.json")["9002"].mitigation, + ) + + def test_a_cwe_that_is_not_prefixed_leaves_the_cwe_at_zero(self): + finding = self.by_uid("probely_many_vuln.json")["9004"] + self.assertEqual(0, finding.cwe) + + def test_the_cwe_parse_directly(self): + parser = ProbelyParser() + self.assertEqual(89, parser.cwe({"cwe_id": "CWE-89"})) + self.assertEqual(1004, parser.cwe({"cwe_id": "CWE-1004"})) + self.assertEqual(0, parser.cwe({"cwe_id": "89"})) + self.assertEqual(0, parser.cwe({"cwe_id": "CWE-abc"})) + self.assertEqual(0, parser.cwe({})) + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([{ + "id": 1, "severity": 30, "state": "notfixed", + "definition": {"id": "d", "name": "A finding", "desc": ""}, + "url": "https://app.example.com/", "path": "/", + }])) + findings = list(ProbelyParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("A finding", findings[0].title) + + def test_a_repeated_finding_id_collapses(self): + row = {"id": 1, "severity": 30, "state": "notfixed", + "definition": {"id": "d", "name": "A finding", "desc": ""}} + report = io.StringIO(json.dumps({"results": [row, row]})) + self.assertEqual(1, len(list(ProbelyParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(ProbelyParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("results", str(raised.exception)) diff --git a/unittests/tools/test_quay_parser.py b/unittests/tools/test_quay_parser.py new file mode 100644 index 00000000000..1f89947fa7d --- /dev/null +++ b/unittests/tools/test_quay_parser.py @@ -0,0 +1,210 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.quay.parser import QuayParser, inert_text +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestQuayParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("quay") / filename + with path.open(encoding="utf-8") as file: + return list(QuayParser().get_findings(file, Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Quay connector's ScanType() verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = QuayParser() + self.assertEqual(["Quay - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Quay - Connectors Import", + parser.get_label_for_scan_types("Quay - Connectors Import"), + ) + + def test_no_vuln(self): + """A scanned image with features but no vulnerabilities on any of them.""" + self.assertEqual(0, len(self.parse("quay_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("quay_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's converter.""" + findings = self.parse("quay_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 - (openssl, 3.0.11-1)", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("openssl", finding.component_name) + self.assertEqual("3.0.11-1", finding.component_version) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("3.0.13-1", finding.mitigation) + self.assertEqual("https://example.com/advisories/cve-2000-0001", finding.references) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + def test_the_clair_nesting_and_capitalised_keys_are_read(self): + """ + Quay's scanner is Clair, so the report nests vulnerabilities under the features they affect + and capitalises the JSON keys - "Layer", "Features", "Name", "Severity". + + Reading lower-case keys, or expecting a flat vulnerability list, would import nothing at all + without erroring. + """ + raw = json.loads((get_unit_tests_scans_path("quay") + / "quay_one_vuln.json").read_text(encoding="utf-8")) + self.assertIn("Layer", raw["data"]) + self.assertIn("Features", raw["data"]["Layer"]) + feature = raw["data"]["Layer"]["Features"][0] + self.assertIn("Name", feature) + self.assertNotIn("name", feature) + self.assertIn("Vulnerabilities", feature) + + self.assertEqual(1, len(self.parse("quay_one_vuln.json"))) + + def test_one_finding_per_feature_and_vulnerability_pair(self): + """ + A feature can carry several vulnerabilities, and each is its own finding. + + A feature with an empty vulnerability list contributes nothing. + """ + findings = self.parse("quay_many_vuln.json") + self.assertEqual(4, len(findings)) + self.assertEqual( + {"openssl", "curl", "zlib"}, {f.component_name for f in findings}, + ) + # openssl carries two. + self.assertEqual(2, sum(1 for f in findings if f.component_name == "openssl")) + # busybox has no vulnerabilities and so no findings. + self.assertNotIn("busybox", {f.component_name for f in findings}) + + def test_clairs_defcon1_severity_is_critical(self): + """ + Clair grades "Defcon1" above Critical, and DefectDojo has nothing higher. + + Not mapping it would drop the most severe grade Clair can report to Info. + """ + finding = self.by_uid("quay_many_vuln.json")["CVE-2000-0002openssl"] + self.assertEqual("Critical", finding.severity) + + def test_the_severity_mapping_directly(self): + parser = QuayParser() + for value, expected in [ + ("Critical", "Critical"), ("Defcon1", "Critical"), ("defcon1", "Critical"), + ("High", "High"), ("Medium", "Medium"), ("Low", "Low"), + ("Negligible", "Info"), ("", "Info"), + ]: + finding = parser.build_finding({"Name": "p"}, {"Name": "V", "Severity": value}, "", Test()) + self.assertEqual(expected, finding.severity, value) + + def test_the_unique_id_concatenates_with_no_separator(self): + """ + The connector builds this as advisory id + feature name, with nothing between them. + + Reproduced exactly: inserting a separator would give every finding a different tool id from + the connector's and break the merge this parser exists for. + """ + uids = set(self.by_uid("quay_many_vuln.json")) + self.assertIn("CVE-2000-0001openssl", uids) + self.assertIn("CVE-2000-0003curl", uids) + + def test_every_finding_says_no_impact_provided(self): + """Clair supplies no impact assessment, and the connector states that rather than blanking it.""" + for finding in self.parse("quay_many_vuln.json"): + self.assertEqual("No impact provided", finding.impact) + + def test_the_description_block_is_the_connectors(self): + finding = self.parse("quay_one_vuln.json")[0] + self.assertIn("A flaw in certificate verification", finding.description) + self.assertIn("**Vulnerable feature:** openssl", finding.description) + self.assertIn("**Vulnerable version:** 3.0.11-1", finding.description) + self.assertIn("**Fixed by:** 3.0.13-1", finding.description) + self.assertIn("**Namespace:** debian:12", finding.description) + self.assertIn("**CVE:** CVE-2000-0001", finding.description) + self.assertIn("**Image tag:** 1.4.0", finding.description) + + def test_the_fix_namespace_and_cve_lines_appear_even_when_empty(self): + """ + The connector writes these unconditionally. + + Omitting the empty ones would make a file import read differently from an API sync for the + same finding, which is the whole thing this parser is trying to avoid. + """ + finding = self.by_uid("quay_many_vuln.json")["CVE-2000-0004zlib"] + self.assertIn("**Fixed by:** \n", finding.description + "\n") + self.assertIn("**Namespace:** \n", finding.description + "\n") + self.assertIn("**CVE:** CVE-2000-0004", finding.description) + + def test_advisory_html_is_flattened_not_rendered(self): + """ + Clair advisory text comes from upstream distro trackers. + + It is flattened and escaped, so nothing from an upstream tracker can be injected into a + rendered finding. + """ + finding = self.by_uid("quay_many_vuln.json")["CVE-2000-0003curl"] + self.assertIn("A flaw in URL parsing.", finding.description) + self.assertNotIn("alert(", finding.description) + self.assertNotIn("<script", finding.description) + # And the paragraph markup on the one_vuln advisory does not survive either. + first = self.parse("quay_one_vuln.json")[0] + self.assertNotIn("<p>", first.description) + + def test_the_inert_text_helper_matches_gos_entities(self): + self.assertEqual("plain", inert_text("plain")) + self.assertEqual("kept", inert_text("<script>dropped()</script>kept")) + self.assertEqual("a\n\nb", inert_text("<p>a</p><p>b</p>")) + self.assertEqual("'q'", inert_text("'q'")) + self.assertEqual("", inert_text("")) + + def test_no_mitigation_or_references_when_clair_has_none(self): + finding = self.by_uid("quay_many_vuln.json")["CVE-2000-0004zlib"] + self.assertIsNone(finding.mitigation) + self.assertIsNone(finding.references) + + def test_the_image_tag_is_omitted_when_the_export_does_not_carry_it(self): + """ + The tag is not in Clair's output - the connector supplies it from the tag it scanned. + + An export without one still imports; the tag line is simply absent. + """ + report = io.StringIO(json.dumps({"data": {"Layer": {"Features": [ + {"Name": "openssl", "Version": "3.0.11-1", "Vulnerabilities": [ + {"Name": "CVE-2000-0001", "Severity": "High"}, + ]}, + ]}}})) + finding = list(QuayParser().get_findings(report, Test()))[0] + self.assertNotIn("**Image tag:**", finding.description) + + def test_a_bare_layer_or_features_list_is_accepted(self): + features = [{"Name": "p", "Version": "1", "Vulnerabilities": [ + {"Name": "CVE-2000-0001", "Severity": "Low"}, + ]}] + for payload in ({"Layer": {"Features": features}}, {"Features": features}, features): + report = io.StringIO(json.dumps(payload)) + self.assertEqual( + 1, len(list(QuayParser().get_findings(report, Test()))), payload, + ) + + def test_a_repeated_pair_collapses(self): + vuln = {"Name": "CVE-2000-0001", "Severity": "Low"} + report = io.StringIO(json.dumps({"data": {"Layer": {"Features": [ + {"Name": "p", "Version": "1", "Vulnerabilities": [vuln, vuln]}, + ]}}})) + self.assertEqual(1, len(list(QuayParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(QuayParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("Layer", str(raised.exception)) diff --git a/unittests/tools/test_qwiet_parser.py b/unittests/tools/test_qwiet_parser.py new file mode 100644 index 00000000000..b3f6c72163c --- /dev/null +++ b/unittests/tools/test_qwiet_parser.py @@ -0,0 +1,231 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.qwiet.parser import QwietParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestQwietParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("qwiet") / filename).open(encoding="utf-8") as file: + return list(QwietParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(QwietParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"id": "1", "type": "vuln", "category": "SQL Injection", "severity": "critical", + "title": "A finding", "internal_id": "sl/1", "tags": []} + row.update(overrides) + return {"ok": True, "response": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Qwiet connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = QwietParser() + self.assertEqual(["Qwiet Scan"], parser.get_scan_types()) + self.assertEqual("Qwiet Scan", parser.get_label_for_scan_types("Qwiet Scan")) + self.assertNotIn("Qwiet - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("qwiet_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("qwiet_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("qwiet_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("SQL Injection in ReportController", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("qwiet-sl/00000000-0000-4000-8000-000000000001", finding.unique_id_from_tool) + self.assertEqual("sl/00000000-0000-4000-8000-000000000001", finding.vuln_id_from_tool) + self.assertEqual(89, finding.cwe) + self.assertEqual("src/main/java/com/example/web/ReportController.java", finding.file_path) + self.assertEqual(88, finding.line) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["vuln", "a03-injection", "reachability:reachable"], finding.unsaved_tags) + self.assertIn("reachable", finding.severity_justification) + + self.assertEqual( + "**Type:** vuln\n" + "**Category:** SQL Injection\n" + "**OWASP:** a03-injection\n" + "**Source:** com.example.web.ReportController.list\n" + "**Sink:** java.sql.Statement.executeQuery\n" + "**Locations:** src/main/java/com/example/web/ReportController.java:88, " + "src/main/java/com/example/db/Reports.java:120\n\n" + "A request parameter is concatenated into a database query.", + finding.description, + ) + + def test_most_of_the_data_is_in_tags_not_fields(self): + """ + Qwiet carries the CVE, package URL, CVSS score, CWE and reachability as key/value TAGS. + + They are a list of {"key", "value"} objects rather than a map, so each is read by key - looking + for fields of those names would find nothing at all. + """ + finding = self.by_uid("qwiet_many_vuln.json")["qwiet-sl/00000000-0000-4000-8000-000000000002"] + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("lib", finding.component_name) + self.assertEqual("1.2.3", finding.component_version) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual(502, finding.cwe) + + def test_many_vuln(self): + self.assertEqual(5, len(self.parse("qwiet_many_vuln.json"))) + + def test_the_package_url_is_reduced_to_the_artefact(self): + """ + "pkg:maven/org.example/lib@1.2.3" is lib 1.2.3. + + Only the last path segment matters: the namespace before it is the group, not the artefact + DefectDojo matches a component on. + """ + parser = QwietParser() + cases = ( + ("pkg:maven/org.example/lib@1.2.3", ("lib", "1.2.3")), + ("pkg:npm/other-lib@4.5.6", ("other-lib", "4.5.6")), + ("pkg:npm/@scope/thing@1.0.0", ("thing", "1.0.0")), + ("lib@1.0.0", ("lib", "1.0.0")), + ("lib", ("lib", "")), + ("", ("", "")), + ) + for purl, expected in cases: + with self.subTest(purl=purl): + self.assertEqual(expected, parser.component(purl)) + + def test_severity_labels(self): + for label, expected in (("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("CRITICAL", "Critical"), ("info", "Info"), + ("not a label", "Info"), ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.row(severity=label)) + self.assertEqual(expected, findings[0].severity) + + def test_a_dependency_finding_with_related_findings_is_reachable_without_the_tag(self): + """ + The related findings ARE the path Qwiet traced through the application. + + So a dependency finding that has them is reachable even when no reachability tag is present - + and reachability is the whole reason to use this tool. + """ + findings = self.by_uid("qwiet_many_vuln.json") + reachable = findings["qwiet-sl/00000000-0000-4000-8000-000000000002"] + self.assertIn("reachability:reachable", reachable.unsaved_tags) + self.assertIn("attacker-controlled data-flow path", reachable.severity_justification) + + def test_an_unreachable_tag_is_recorded_as_it_is(self): + finding = self.by_uid("qwiet_many_vuln.json")["qwiet-sl/00000000-0000-4000-8000-000000000003"] + self.assertIn("reachability:unreachable", finding.unsaved_tags) + self.assertEqual("Qwiet marks this finding as unreachable.", finding.severity_justification) + # Reachability does not change the grade - it is context, not a regrade. + self.assertEqual("Medium", finding.severity) + + def test_no_reachability_information_leaves_the_justification_unset(self): + finding = self.by_uid("qwiet_many_vuln.json")["qwiet-sl/00000000-0000-4000-8000-000000000005"] + self.assertIsNone(finding.severity_justification) + self.assertNotIn("reachability:reachable", finding.unsaved_tags) + + def test_a_location_with_no_line_number_is_still_a_path(self): + bare_path = self.parse_string(self.row(details={"file_locations": ["src/a.java"]})) + self.assertEqual("src/a.java", bare_path[0].file_path) + self.assertIsNone(bare_path[0].line) + + def test_an_unparseable_line_number_keeps_the_path(self): + """Losing the path because the line was malformed would lose the finding's location.""" + finding = self.by_uid("qwiet_many_vuln.json")["qwiet-sl/00000000-0000-4000-8000-000000000005"] + self.assertEqual("src/main/java/com/example/Config.java", finding.file_path) + self.assertIsNone(finding.line) + + def test_only_the_first_location_becomes_the_file_path(self): + """ + A data-flow finding spans several files and DefectDojo has one file_path. + + The whole list stays in the description so the rest of the path is not lost. + """ + finding = self.parse("qwiet_one_vuln.json")[0] + self.assertEqual("src/main/java/com/example/web/ReportController.java", finding.file_path) + self.assertIn("src/main/java/com/example/db/Reports.java:120", finding.description) + + def test_the_hash_spans_a_file_path_a_cwe_and_a_component(self): + """ + Qwiet reports both code findings and dependency findings. + + A given finding has a file path or a component, not usually both, so the hash spans all three + and the unused half hashes as empty. + """ + self.assertEqual( + ["title", "severity", "file_path", "cwe", "component_name"], + QwietParser().get_dedupe_fields(), + ) + findings = self.by_uid("qwiet_many_vuln.json") + code = findings["qwiet-sl/00000000-0000-4000-8000-000000000001"] + self.assertIsNotNone(code.file_path) + self.assertIsNone(code.component_name) + + dependency = findings["qwiet-sl/00000000-0000-4000-8000-000000000003"] + self.assertIsNone(dependency.file_path) + self.assertIsNotNone(dependency.component_name) + + def test_an_unparseable_score_or_cwe_is_left_unset(self): + findings = self.by_uid("qwiet_many_vuln.json") + self.assertEqual(0.0, findings["qwiet-sl/00000000-0000-4000-8000-000000000003"].cvssv3_score) + self.assertEqual(0, findings["qwiet-4"].cwe) + + def test_identity_falls_back_to_the_display_id(self): + """Qwiet's internal id is stable across scans; the display id is the fallback.""" + finding = self.by_uid("qwiet_many_vuln.json")["qwiet-4"] + self.assertEqual("Qwiet finding 4", finding.title) + self.assertIsNone(finding.vuln_id_from_tool) + + def test_title_falls_back_to_the_category(self): + findings = self.parse_string(self.row(title="", category="SQL Injection")) + self.assertEqual("SQL Injection", findings[0].title) + + def test_export_shapes(self): + row = {"id": "1", "type": "vuln", "severity": "low", "title": "A finding", "tags": []} + for payload in ({"ok": True, "response": [row]}, {"findings": [row]}, [row]): + with self.subTest(shape=type(payload).__name__): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Qwiet", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("response", str(context.exception)) + + def test_malformed_rows_and_tags_are_skipped(self): + findings = self.parse_string({"response": [ + "not an object", + None, + {"id": "1", "type": "vuln", "severity": "low", "title": "A finding", + "tags": ["not an object", None, {"key": "cwe_category", "value": "CWE-79"}]}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual(79, findings[0].cwe) + + def test_severity_is_always_a_known_value(self): + for filename in ("qwiet_many_vuln.json", "qwiet_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_socket_parser.py b/unittests/tools/test_socket_parser.py new file mode 100644 index 00000000000..6405e5ee8b7 --- /dev/null +++ b/unittests/tools/test_socket_parser.py @@ -0,0 +1,147 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.socket.parser import SocketParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestSocketParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("socket") / filename).open(encoding="utf-8") as file: + return list(SocketParser().get_findings(file, Test())) + + def test_scan_type_matches_the_connector_exactly(self): + """ + The scan type must equal the Socket connector's ScanType() verbatim. + + If it drifts, a customer who both uploads an export and syncs the API gets two + un-deduplicated copies of every finding, because the two land in different test types. + """ + parser = SocketParser() + self.assertEqual(["Socket - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "Socket - Connectors Import", + parser.get_label_for_scan_types("Socket - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("socket_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("socket_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring AlertToFinding in the connector's converter.""" + findings = self.parse("socket_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + # converter: fmt.Sprintf("%s in %s", alert.Type, component) + self.assertEqual("malware in @example-scope/utils", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + # converter: namespace + "/" + name + self.assertEqual("@example-scope/utils", finding.component_name) + self.assertEqual("0.2.1", finding.component_version) + # converter: UniqueIDFromTool = alert.Key + self.assertEqual("alert-1", finding.unique_id_from_tool) + self.assertEqual("package.json", finding.file_path) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + + self.assertIn("**Socket alert:** malware", finding.description) + self.assertIn("**Category:** supplyChainRisk", finding.description) + self.assertIn("**Package:** pkg:npm/@example-scope/utils@0.2.1", finding.description) + self.assertIn("**Ecosystem:** npm", finding.description) + # The converter renders the free-form props as sorted key/value lines. + self.assertIn("**confidence:** high", finding.description) + self.assertIn("**note:** obfuscated install script", finding.description) + + def test_tags_match_the_converter(self): + finding = self.parse("socket_one_vuln.json")[0] + self.assertEqual( + [ + "socket:malware", + "category:supplyChainRisk", + "ecosystem:npm", + "pkg:npm/@example-scope/utils@0.2.1", + ], + finding.unsaved_tags, + ) + + def test_many_vuln(self): + """One artifact can carry several alerts, and each alert is its own finding.""" + findings = self.parse("socket_many_vuln.json") + self.assertEqual(5, len(findings)) + self.assertEqual( + {"@example-scope/utils", "requests", "github.com/example/widget"}, + {f.component_name for f in findings}, + ) + + def test_the_severity_ladder_is_the_connectors(self): + """ + Socket grades alerts low | middle | high | critical - "middle", not "medium". + + The connector maps anything it does not recognise to Info, so this does too. Guessing + "medium" here would silently downgrade every middle-severity alert to Info. + """ + findings = {f.unique_id_from_tool: f.severity for f in self.parse("socket_many_vuln.json")} + self.assertEqual("Critical", findings["alert-1"]) + self.assertEqual("High", findings["alert-2"]) + self.assertEqual("Medium", findings["alert-3"]) # severity "middle" + self.assertEqual("Low", findings["alert-4"]) + self.assertEqual("Info", findings["alert-5"]) # severity "not-a-level" + + def test_an_artifact_with_no_namespace_uses_the_bare_name(self): + finding = next( + f for f in self.parse("socket_many_vuln.json") if f.unique_id_from_tool == "alert-2" + ) + self.assertEqual("requests", finding.component_name) + self.assertIn("**Package:** pkg:pypi/requests@2.0.0", finding.description) + + def test_an_alert_with_no_file_leaves_file_path_unset(self): + finding = next( + f for f in self.parse("socket_many_vuln.json") if f.unique_id_from_tool == "alert-3" + ) + self.assertIsNone(finding.file_path) + + def test_a_bare_artifact_array_is_accepted(self): + """What people save varies; a plain JSON array of artifacts is the commonest form.""" + report = io.StringIO(json.dumps([{ + "type": "npm", "name": "x", "version": "1.0.0", + "alerts": [{"key": "k", "type": "cve", "severity": "high"}], + }])) + findings = list(SocketParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("cve in x", findings[0].title) + + def test_a_results_envelope_is_accepted(self): + report = io.StringIO(json.dumps({"results": [{ + "type": "npm", "name": "x", "version": "1.0.0", + "alerts": [{"key": "k", "type": "cve", "severity": "low"}], + }]})) + self.assertEqual(1, len(list(SocketParser().get_findings(report, Test())))) + + def test_a_repeated_alert_key_collapses(self): + """The connector's identity is the alert key, so the same key is the same finding.""" + alert = {"key": "same", "type": "cve", "severity": "high"} + report = io.StringIO(json.dumps([ + {"type": "npm", "name": "x", "version": "1.0.0", "alerts": [alert]}, + {"type": "npm", "name": "x", "version": "1.0.0", "alerts": [alert]}, + ])) + self.assertEqual(1, len(list(SocketParser().get_findings(report, Test())))) + + def test_a_package_url_needs_both_type_and_name(self): + report = io.StringIO(json.dumps([{ + "name": "x", "version": "1.0.0", + "alerts": [{"key": "k", "type": "cve", "severity": "low"}], + }])) + finding = list(SocketParser().get_findings(report, Test()))[0] + self.assertNotIn("**Package:**", finding.description) + self.assertEqual(["socket:cve"], finding.unsaved_tags) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(SocketParser().get_findings(io.StringIO('"a string"'), Test())) + self.assertIn("artifacts", str(raised.exception)) diff --git a/unittests/tools/test_soos_parser.py b/unittests/tools/test_soos_parser.py new file mode 100644 index 00000000000..4d20e7a2940 --- /dev/null +++ b/unittests/tools/test_soos_parser.py @@ -0,0 +1,282 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.soos.parser import SoosParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestSoosParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("soos") / filename + with path.open(encoding="utf-8") as file: + return list(SoosParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(SoosParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def issue(self, **overrides): + row = {"id": "issue-1", "title": "An issue", "severity": "High", "scanType": "sca", + "status": "Open"} + row.update(overrides) + return {"entries": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """Must equal the SOOS connector's ScanTypeName verbatim.""" + parser = SoosParser() + self.assertEqual(["SOOS - Connectors Import"], parser.get_scan_types()) + self.assertEqual("SOOS - Connectors Import", + parser.get_label_for_scan_types("SOOS - Connectors Import")) + + def test_this_scan_type_has_no_curated_dedupe_fields(self): + """No hash-field list to copy, so it uses DefectDojo's default algorithm - as the connector does.""" + self.assertFalse(hasattr(SoosParser(), "get_dedupe_fields")) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("soos_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("soos_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ConvertFinding in the connector's finding_converter.""" + findings = self.parse("soos_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Prototype pollution in the example utility library", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("issue-0001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(1321, finding.cwe) + self.assertEqual("example-utils", finding.component_name) + self.assertEqual("4.17.20", finding.component_version) + self.assertEqual("package-lock.json", finding.file_path) + self.assertIsNone(finding.line) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + self.assertEqual("https://example.com/advisories/CVE-2000-0001", finding.references) + self.assertEqual("Upgrade example-utils to 4.17.21 or later.", finding.mitigation) + self.assertEqual(date(2024, 6, 2), finding.date) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["sca"], finding.unsaved_tags) + self.assertEqual("A crafted key allows an attacker to modify the object prototype.", + finding.description) + + def test_many_vuln(self): + self.assertEqual(7, len(self.parse("soos_many_vuln.json"))) + + def test_the_scan_type_decides_static_versus_dynamic_per_issue(self): + """ + SOOS puts SCA, SAST, container, SBOM and DAST behind ONE API and stamps each issue. + + So this is decided per issue rather than for the file - only DAST exercises anything. + """ + findings = self.by_uid("soos_many_vuln.json") + for uid, static in (("issue-0001", True), ("issue-0002", False), ("issue-0003", True), + ("issue-0004", True), ("issue-0005", True), ("issue-0007", False)): + with self.subTest(uid=uid): + self.assertEqual(static, findings[uid].static_finding) + self.assertEqual(not static, findings[uid].dynamic_finding) + + def test_scan_types(self): + for scan_type, static in (("sca", True), ("sast", True), ("csa", True), ("sbom", True), + ("dast", False), ("SCA", True), ("", False)): + with self.subTest(scan_type=scan_type): + findings = self.parse_string(self.issue(scanType=scan_type)) + self.assertEqual(static, findings[0].static_finding) + + def test_an_unrecognised_scan_type_arrives_as_dynamic(self): + """ + The connector reads its lookup table with a Go map access, which yields false for a missing key. + + Mirrored rather than corrected - a new SOOS scan type would arrive as dynamic, which is worth + raising on the connector side rather than diverging here. + """ + finding = self.by_uid("soos_many_vuln.json")["issue-0006"] + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertEqual(["iac"], finding.unsaved_tags) + + def test_unknown_is_a_real_soos_severity_and_grades_as_info(self): + """A finding SOOS could not grade is still a finding, so it is not dropped.""" + finding = self.by_uid("soos_many_vuln.json")["issue-0005"] + self.assertEqual("Info", finding.severity) + + def test_severity_words(self): + for label, expected in (("Critical", "Critical"), ("High", "High"), ("Medium", "Medium"), + ("Low", "Low"), ("Info", "Info"), ("Unknown", "Info"), + ("critical", "Critical"), ("a word it does not use", "Info"), + ("", "Info")): + with self.subTest(label=label): + findings = self.parse_string(self.issue(severity=label)) + self.assertEqual(expected, findings[0].severity) + + def test_the_three_kinds_of_dismissal_are_kept_apart(self): + """ + A false positive was never real, an accepted risk is real and signed off, and a resolved issue + is dealt with. + + Collapsing them would lose the distinction a reviewer already made on the SOOS side. + """ + findings = self.by_uid("soos_many_vuln.json") + + false_positive = findings["issue-0003"] + self.assertFalse(false_positive.active) + self.assertTrue(false_positive.false_p) + self.assertFalse(false_positive.risk_accepted) + self.assertFalse(false_positive.is_mitigated) + + accepted = findings["issue-0004"] + self.assertFalse(accepted.active) + self.assertTrue(accepted.risk_accepted) + self.assertFalse(accepted.false_p) + + resolved = findings["issue-0005"] + self.assertFalse(resolved.active) + self.assertTrue(resolved.is_mitigated) + self.assertFalse(resolved.risk_accepted) + + def test_statuses(self): + cases = ( + ("Open", "active"), ("", "active"), ("Something new", "active"), + ("False positive", "false_p"), ("falsepositive", "false_p"), + ("false_positive", "false_p"), + ("Accepted", "risk_accepted"), + ("Ignored", "is_mitigated"), ("Dismissed", "is_mitigated"), + ("Resolved", "is_mitigated"), ("Fixed", "is_mitigated"), + ) + for status, expected in cases: + with self.subTest(status=status): + finding = self.parse_string(self.issue(status=status))[0] + if expected == "active": + self.assertTrue(finding.active) + else: + self.assertFalse(finding.active) + self.assertTrue(getattr(finding, expected)) + + def test_a_status_with_spaces_is_normalised(self): + """SOOS writes "False positive"; the connector strips the spaces before matching.""" + for status in ("False positive", "FALSE POSITIVE", " false positive "): + with self.subTest(status=status): + findings = self.parse_string(self.issue(status=status)) + self.assertTrue(findings[0].false_p) + + def test_a_dast_url_becomes_an_endpoint(self): + finding = self.by_uid("soos_many_vuln.json")["issue-0002"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + self.assertEqual("https", locations[0].protocol) + self.assertEqual("search", locations[0].path) + self.assertEqual("q=1", locations[0].query) + + def test_a_source_finding_has_a_file_path_and_no_endpoint(self): + finding = self.by_uid("soos_many_vuln.json")["issue-0003"] + self.assertEqual("src/generic/config.py", finding.file_path) + self.assertEqual(42, finding.line) + self.assertEqual(0, len(self.get_unsaved_locations(finding))) + + def test_a_url_defectdojo_would_reject_adds_no_endpoint(self): + """ + A bad host makes Endpoint.clean() raise, which fails the WHOLE import. + + Declining to build the endpoint keeps the rest of the file importable. + """ + finding = self.by_uid("soos_many_vuln.json")["issue-0007"] + self.assertEqual(0, len(self.get_unsaved_locations(finding))) + self.assertFalse(finding.active) + + def test_the_mitigation_prefers_the_remediation_text(self): + findings = self.by_uid("soos_many_vuln.json") + self.assertEqual("Move the value into an environment variable.", + findings["issue-0003"].mitigation) + + def test_the_mitigation_names_the_package_and_the_fixed_version(self): + findings = self.by_uid("soos_many_vuln.json") + self.assertEqual("Upgrade example-base to 3.20 or later.", findings["issue-0004"].mitigation) + + def test_the_mitigation_omits_the_package_when_soos_did_not_name_one(self): + findings = self.parse_string(self.issue(packageName="", fixedVersion="2.0.0")) + self.assertEqual("Upgrade to 2.0.0 or later.", findings[0].mitigation) + + def test_no_remediation_and_no_fixed_version_leaves_the_mitigation_unset(self): + findings = self.by_uid("soos_many_vuln.json") + self.assertIsNone(findings["issue-0002"].mitigation) + + def test_an_issue_with_no_prose_says_which_scan_reported_it(self): + """An empty body would read as though the data had been lost in transit.""" + finding = self.by_uid("soos_many_vuln.json")["issue-0002"] + self.assertEqual("Reported by the SOOS dast scan.", finding.description) + + def test_an_issue_with_no_prose_and_no_scan_type_still_says_something(self): + findings = self.parse_string(self.issue(description="", scanType="")) + self.assertEqual("Reported by SOOS.", findings[0].description) + + def test_cwe_forms(self): + for value, expected in (("CWE-79", 79), ("79", 79), ("cwe-79", 79), ("not a cwe", 0), + ("", 0)): + with self.subTest(value=value): + findings = self.parse_string(self.issue(cwe=value)) + self.assertEqual(expected, findings[0].cwe) + + def test_a_bare_cwe_number_and_an_unparseable_one(self): + findings = self.by_uid("soos_many_vuln.json") + self.assertEqual(79, findings["issue-0002"].cwe) + self.assertEqual(0, findings["issue-0003"].cwe) + + def test_an_issue_with_no_cve_carries_no_vulnerability_id(self): + findings = self.by_uid("soos_many_vuln.json") + self.assertIsNone(findings["issue-0002"].vuln_id_from_tool) + self.assertIsNone(findings["issue-0002"].unsaved_vulnerability_ids) + + def test_a_line_of_zero_is_not_recorded(self): + finding = self.by_uid("soos_many_vuln.json")["issue-0001"] + self.assertIsNone(finding.line) + + def test_an_unparseable_first_detected_leaves_the_date_alone(self): + finding = self.by_uid("soos_many_vuln.json")["issue-0006"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_export_shapes(self): + row = {"id": "issue-1", "title": "An issue", "severity": "Low", "scanType": "sca"} + for payload in ([row], {"entries": [row]}, {"items": [row]}, {"issues": [row]}, + {"data": [row]}, {"results": [row]}): + with self.subTest(shape=str(payload)[:22]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("SOOS", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("entries", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"entries": [ + "not an object", + None, + {"id": "issue-9", "title": "An issue", "severity": "Low", "scanType": "sca", + "cvssScore": "not a number", "line": "not a line"}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("issue-9", findings[0].unique_id_from_tool) + self.assertEqual(0.0, findings[0].cvssv3_score) + self.assertIsNone(findings[0].line) + + def test_severity_is_always_a_known_value(self): + for filename in ("soos_many_vuln.json", "soos_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_uptycs_parser.py b/unittests/tools/test_uptycs_parser.py new file mode 100644 index 00000000000..cc592cae0b0 --- /dev/null +++ b/unittests/tools/test_uptycs_parser.py @@ -0,0 +1,225 @@ +import io +import json + +from dojo.models import Finding, Test +from dojo.tools.uptycs.parser import UptycsParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestUptycsParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("uptycs") / filename + with path.open(encoding="utf-8") as file: + return list(UptycsParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(UptycsParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"cve_list": ["CVE-2000-0001"], "package_name": "example-lib", + "package_version": "1.0.0", "cvss_score": 7.5, "upt_asset_id": "asset-1"} + row.update(overrides) + return {"items": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Uptycs connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = UptycsParser() + self.assertEqual(["Uptycs Scan"], parser.get_scan_types()) + self.assertEqual("Uptycs Scan", parser.get_label_for_scan_types("Uptycs Scan")) + self.assertNotIn("Uptycs - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("uptycs_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("uptycs_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("uptycs_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("CVE-2000-0001 in example-tls", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("uptycs-asset-0001-example-tls-CVE-2000-0001", finding.unique_id_from_tool) + self.assertEqual("CVE-2000-0001", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual("example-tls", finding.component_name) + self.assertEqual("1.0.2k-1", finding.component_version) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["Ubuntu 22.04", "production-linux"], finding.unsaved_tags) + self.assertEqual( + "**Package:** example-tls 1.0.2k-1\n" + "**Host:** generic-host-01\n" + "**OS:** Ubuntu 22.04\n" + "**Asset group:** production-linux", + finding.description, + ) + + def test_many_vuln(self): + """Four rows fan out to seven findings: 3 + 2 + 1 (no CVE) + 1.""" + self.assertEqual(7, len(self.parse("uptycs_many_vuln.json"))) + + def test_one_row_becomes_one_finding_per_cve(self): + """ + Uptycs reports one row per vulnerable package listing every CVE against it. + + Each CVE is separately fixable and separately triaged, so each becomes its own finding rather + than one finding titled after all of them. + """ + findings = self.by_uid("uptycs_many_vuln.json") + for cve in ("CVE-2000-0001", "CVE-2000-0002", "CVE-2000-0003"): + with self.subTest(cve=cve): + self.assertIn(f"uptycs-asset-0001-example-tls-{cve}", findings) + self.assertEqual(f"{cve} in example-tls", + findings[f"uptycs-asset-0001-example-tls-{cve}"].title) + + def test_a_comma_separated_cve_list_is_split(self): + """ + Uptycs sends the list either as an array or as a comma-separated STRING. + + Reading the string whole would make one finding titled after every CVE at once. + """ + findings = self.by_uid("uptycs_many_vuln.json") + self.assertIn("uptycs-asset-0001-example-compress-CVE-2000-0004", findings) + self.assertIn("uptycs-asset-0001-example-compress-CVE-2000-0005", findings) + + def test_a_comma_separated_list_is_trimmed(self): + findings = self.parse_string(self.row(cve_list=" CVE-2000-0001 , CVE-2000-0002 ,, ")) + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], + [finding.vuln_id_from_tool for finding in findings]) + + def test_a_row_with_no_cve_is_still_one_finding(self): + """A vulnerable package is worth recording even when Uptycs attached no identifier.""" + finding = self.by_uid("uptycs_many_vuln.json")["uptycs-asset-0002-example-agent"] + self.assertEqual("Vulnerable package example-agent", finding.title) + self.assertIsNone(finding.vuln_id_from_tool) + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_a_missing_cve_list_is_not_an_error(self): + for value in (None, "", [], "not a list but a string with no comma"): + with self.subTest(value=value): + findings = self.parse_string(self.row(cve_list=value)) + self.assertEqual(1, len(findings)) + + def test_a_row_with_no_package_name_says_package(self): + finding = self.by_uid("uptycs_many_vuln.json")["uptycs-asset-0003--CVE-2000-0006"] + self.assertEqual("CVE-2000-0006 in package", finding.title) + self.assertIsNone(finding.component_name) + + def test_every_cve_from_one_row_shares_the_rows_severity(self): + """ + The row carries one CVSS score for the package rather than one per CVE. + + So the fan-out cannot grade them apart - which is worth knowing when reading the result. + """ + findings = self.by_uid("uptycs_many_vuln.json") + for cve in ("CVE-2000-0001", "CVE-2000-0002", "CVE-2000-0003"): + with self.subTest(cve=cve): + finding = findings[f"uptycs-asset-0001-example-tls-{cve}"] + self.assertEqual("Critical", finding.severity) + self.assertEqual(9.8, finding.cvssv3_score) + + def test_cvss_score_bands(self): + """Uptycs sends no severity word, so the score is the only signal.""" + for score, expected in ((9.0, "Critical"), (7.0, "High"), (4.0, "Medium"), (0.1, "Low"), + (0, "Info")): + with self.subTest(score=score): + findings = self.parse_string(self.row(cvss_score=score)) + self.assertEqual(expected, findings[0].severity) + + def test_a_quoted_score_is_accepted(self): + finding = self.by_uid("uptycs_many_vuln.json")[ + "uptycs-asset-0001-example-compress-CVE-2000-0004"] + self.assertEqual(7.5, finding.cvssv3_score) + self.assertEqual("High", finding.severity) + + def test_the_other_cves_are_listed_only_when_the_row_names_more_than_one(self): + """ + With a single CVE the title already says it, so repeating it in the body adds nothing. + + With several, the list tells a reader that the other findings exist. + """ + findings = self.by_uid("uptycs_many_vuln.json") + several = findings["uptycs-asset-0001-example-tls-CVE-2000-0001"] + self.assertIn("**CVEs:** CVE-2000-0001, CVE-2000-0002, CVE-2000-0003", several.description) + + single = findings["uptycs-asset-0003--CVE-2000-0006"] + self.assertNotIn("**CVEs:**", single.description) + + none = findings["uptycs-asset-0002-example-agent"] + self.assertNotIn("**CVEs:**", none.description) + + def test_the_identity_spans_the_asset_and_the_package(self): + """ + The same CVE on two hosts is two findings - two machines to patch. + + The component is the package, so the hash alone would merge them; the asset id in the identity + is what keeps them apart. + """ + self.assertEqual(["title", "severity", "component_name"], UptycsParser().get_dedupe_fields()) + row = {"cve_list": ["CVE-2000-0001"], "package_name": "example-lib", "cvss_score": 7.5} + findings = self.parse_string({"items": [ + {**row, "upt_asset_id": "asset-a"}, + {**row, "upt_asset_id": "asset-b"}, + ]}) + self.assertEqual(["uptycs-asset-a-example-lib-CVE-2000-0001", + "uptycs-asset-b-example-lib-CVE-2000-0001"], + [finding.unique_id_from_tool for finding in findings]) + + def test_absent_description_fields_are_left_out(self): + finding = self.by_uid("uptycs_many_vuln.json")["uptycs-asset-0003--CVE-2000-0006"] + self.assertNotIn("**Host:**", finding.description) + self.assertNotIn("**OS:**", finding.description) + self.assertNotIn("**Asset group:**", finding.description) + + def test_a_row_with_no_asset_group_tags_only_the_os(self): + finding = self.by_uid("uptycs_many_vuln.json")["uptycs-asset-0002-example-agent"] + self.assertEqual(["Windows Server 2019"], finding.unsaved_tags) + + def test_export_shapes(self): + row = {"cve_list": ["CVE-2000-0001"], "package_name": "example-lib", "cvss_score": 7.5} + for payload in ([row], {"items": [row]}, {"rows": [row]}, {"data": [row]}, + {"results": [row]}): + with self.subTest(shape=str(payload)[:22]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Uptycs", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("items", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"items": [ + "not an object", + None, + {"cve_list": ["CVE-2000-0009", "", " "], "package_name": "example-lib", + "cvss_score": 5.0, "upt_asset_id": "asset-9"}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("uptycs-asset-9-example-lib-CVE-2000-0009", + findings[0].unique_id_from_tool) + + def test_severity_is_always_a_known_value(self): + for filename in ("uptycs_many_vuln.json", "uptycs_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_vanta_parser.py b/unittests/tools/test_vanta_parser.py new file mode 100644 index 00000000000..57b1d9117a7 --- /dev/null +++ b/unittests/tools/test_vanta_parser.py @@ -0,0 +1,213 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.vanta.parser import VantaParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestVantaParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("vanta") / filename).open(encoding="utf-8") as file: + return list(VantaParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(VantaParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Vanta connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = VantaParser() + self.assertEqual(["Vanta Compliance"], parser.get_scan_types()) + self.assertEqual("Vanta Compliance", parser.get_label_for_scan_types("Vanta Compliance")) + self.assertNotIn("Vanta - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """ + A test with no failing entity is the control working. + + Vanta has no finding of its own to import in that case - the pair is what makes a finding. + """ + self.assertEqual(0, len(self.parse("vanta_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("vanta_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("vanta_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("MFA is enabled for all users", finding.title) + self.assertEqual("Medium", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("vanta-test-mfa-enabled-user-0001", finding.unique_id_from_tool) + self.assertEqual("test-mfa-enabled", finding.vuln_id_from_tool) + self.assertEqual("example-user@example.com", finding.component_name) + self.assertEqual("Register an MFA device for each user listed below.", finding.mitigation) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["compliance", "Access Control", "aws", "okta", "IAM_USER"], finding.unsaved_tags) + + self.assertEqual( + "**Failing resource:** example-user@example.com\n" + "**Resource type:** IAM_USER\n" + "**Category:** Access Control\n" + "**Integrations:** aws, okta\n\n" + "**Description:**\n" + "Every user with console access must have multi-factor authentication enabled.\n\n" + "**Why this failed:**\n" + "One or more users have no MFA device registered.", + finding.description, + ) + + def test_every_finding_is_medium(self): + """ + Vanta has no severity scale - a compliance test passes or fails. + + The connector grades every failing entity Medium rather than inventing a ladder, and Info + would read as non-actionable when a failing control is actionable by definition. + """ + severities = {finding.severity for finding in self.parse("vanta_many_vuln.json")} + self.assertEqual({"Medium"}, severities) + + def test_many_vuln(self): + """One finding per failing entity: four across three tests, and one passing entity skipped.""" + self.assertEqual(4, len(self.parse("vanta_many_vuln.json"))) + + def test_one_control_failing_on_two_resources_is_two_findings(self): + """ + The resource is half the identity, and the component keeps them apart in the hash. + + That matters more here than usual: every Vanta finding shares the same severity, so without + the component two resources failing one control would merge. + """ + findings = self.by_uid("vanta_many_vuln.json") + self.assertIn("vanta-test-mfa-enabled-user-0001", findings) + self.assertIn("vanta-test-mfa-enabled-user-0002", findings) + self.assertEqual("example-user@example.com", findings["vanta-test-mfa-enabled-user-0001"].component_name) + self.assertEqual("second-user@example.com", findings["vanta-test-mfa-enabled-user-0002"].component_name) + # Both point at the same control. + self.assertEqual("test-mfa-enabled", findings["vanta-test-mfa-enabled-user-0001"].vuln_id_from_tool) + self.assertEqual("test-mfa-enabled", findings["vanta-test-mfa-enabled-user-0002"].vuln_id_from_tool) + + def test_a_passing_entity_is_not_a_finding(self): + """ + The connector asks Vanta for the FAILING entities specifically. + + An export that carries every entity is filtered here instead, so a compliant resource does not + arrive as a finding. + """ + findings = self.by_uid("vanta_many_vuln.json") + self.assertNotIn("vanta-test-mfa-enabled-user-0003", findings) + + def test_an_entity_with_no_status_is_treated_as_failing(self): + """ + The connector only ever receives failing entities, so it does not check the status. + + An export listing entities without one is therefore taken at its word rather than dropped. + """ + findings = self.parse_string({ + "results": {"data": [{"id": "t1", "name": "A control"}]}, + "entities": {"t1": [{"id": "e1", "displayName": "a-resource"}]}, + }) + self.assertEqual(1, len(findings)) + + def test_a_test_with_no_failing_entities_produces_nothing(self): + titles = {finding.title for finding in self.parse("vanta_many_vuln.json")} + self.assertNotIn("Backups are configured", titles) + + def test_failing_entities_may_be_nested_or_keyed_by_test_id(self): + """ + Vanta needs a call per test to learn what is failing, and those rows carry no test id. + + So an export either nests them or keys them by test id, and both the paged + results.data wrapper and a bare list have to work. + """ + findings = self.by_uid("vanta_many_vuln.json") + # test-mfa-enabled uses the paged wrapper; test-disk-encryption uses a bare list. + self.assertIn("vanta-test-mfa-enabled-user-0001", findings) + self.assertIn("vanta-test-disk-encryption-workstation-0001", findings) + + nested = self.parse_string({"results": {"data": [ + {"id": "t1", "name": "A control", "entities": [{"id": "e1", "displayName": "a-resource"}]}, + ]}}) + self.assertEqual("vanta-t1-e1", nested[0].unique_id_from_tool) + + def test_title_falls_back_to_the_test_id(self): + finding = self.by_uid("vanta_many_vuln.json")["vanta-test-unnamed-resource-0001"] + self.assertEqual("Vanta test test-unnamed", finding.title) + + def test_the_entity_date_wins_over_the_tests_flip_date(self): + """ + One control can have been failing for a year while a resource added last week has only just + started failing it, so the entity's own date is preferred. + """ + findings = self.by_uid("vanta_many_vuln.json") + self.assertEqual( + datetime(2024, 7, 1, tzinfo=UTC).date(), + findings["vanta-test-mfa-enabled-user-0001"].date, + ) + # No entity date here, so the test's flip date stands in. + self.assertEqual( + datetime(2024, 5, 1, tzinfo=UTC).date(), + findings["vanta-test-disk-encryption-workstation-0001"].date, + ) + + def test_an_unparseable_entity_date_falls_back_to_the_flip_date(self): + findings = self.by_uid("vanta_many_vuln.json") + self.assertEqual( + datetime(2024, 6, 20, tzinfo=UTC).date(), + findings["vanta-test-mfa-enabled-user-0002"].date, + ) + + def test_no_date_at_all_keeps_the_default(self): + finding = self.by_uid("vanta_many_vuln.json")["vanta-test-unnamed-resource-0001"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_a_test_with_no_integrations_has_no_integrations_line(self): + finding = self.by_uid("vanta_many_vuln.json")["vanta-test-disk-encryption-workstation-0001"] + self.assertNotIn("**Integrations:**", finding.description) + self.assertEqual(["compliance", "Endpoint Security", "WORKSTATION"], finding.unsaved_tags) + + def test_a_bare_list_of_tests_is_accepted(self): + findings = self.parse_string([ + {"id": "t1", "name": "A control", "entities": [{"id": "e1", "displayName": "a-resource"}]}, + ]) + self.assertEqual(1, len(findings)) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Vanta", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("results.data", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"results": {"data": [ + "not an object", + None, + {"id": "t1", "name": "A control", + "entities": ["not an object", {"id": "e1", "displayName": "a-resource"}]}, + ]}}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("vanta_many_vuln.json", "vanta_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_venafi_parser.py b/unittests/tools/test_venafi_parser.py new file mode 100644 index 00000000000..053cc11dd74 --- /dev/null +++ b/unittests/tools/test_venafi_parser.py @@ -0,0 +1,327 @@ +import io +import json +from datetime import UTC, datetime, timedelta + +from dojo.models import Finding, Test +from dojo.tools.venafi.parser import VenafiParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestVenafiParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("venafi") / filename + with path.open(encoding="utf-8") as file: + return list(VenafiParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(VenafiParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def cert(self, **overrides): + row = {"id": "cert-1", "fingerprint": "FP1", "subjectCN": ["app.example.com"], + "keyStrength": 2048, "encryptionType": "RSA", "signatureHashAlgorithm": "SHA256", + "validityEnd": "2099-01-01T00:00:00Z", "selfSigned": False} + row.update(overrides) + return {"certificates": [row]} + + def in_days(self, days): + """A timestamp the given number of days from now, so expiry tests do not go stale.""" + return (datetime.now(tz=UTC) + timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ") + + def kinds(self, findings): + return sorted(finding.vuln_id_from_tool for finding in findings) + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the connector's ScanTypeName verbatim. + + Note the scan type names CyberArk, the product's current owner, while the directory and the + connector package still say venafi - so it cannot be derived from either and has to be copied. + """ + parser = VenafiParser() + self.assertEqual(["CyberArk Certificate Manager Scan"], parser.get_scan_types()) + self.assertEqual("CyberArk Certificate Manager Scan", + parser.get_label_for_scan_types("CyberArk Certificate Manager Scan")) + self.assertNotIn("Venafi Scan", parser.get_scan_types()) + + def test_no_vuln(self): + """A healthy certificate breaks no rule and produces no findings at all.""" + self.assertEqual(0, len(self.parse("venafi_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("venafi_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("venafi_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Certificate has expired (legacy.example.com)", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("venafi-BB11CC22DD33EE44FF5500112233445566778899-expired", + finding.unique_id_from_tool) + self.assertEqual("expired", finding.vuln_id_from_tool) + self.assertEqual("legacy.example.com", finding.component_name) + self.assertEqual("Renew the certificate and replace it on every deployment.", + finding.mitigation) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual(["expired", "RSA"], finding.unsaved_tags) + self.assertEqual( + "**Finding:** The certificate expired on 2020-03-01T12:00:00Z.\n" + "**Common name:** legacy.example.com\n" + "**Subject alternative names:** legacy.example.com\n" + "**Issuer:** Generic Issuing CA\n" + "**Key:** RSA 4096 bits\n" + "**Signature algorithm:** SHA256\n" + "**Expires:** 2020-03-01T12:00:00Z\n" + "**Fingerprint:** BB11CC22DD33EE44FF5500112233445566778899", + finding.description, + ) + + def test_many_vuln(self): + """ + Neither edition returns a compliance verdict, so the posture rules are computed. + + The fixture's first certificate breaks FOUR rules at once and the md5-signed one breaks a + fifth; the elliptic-curve, healthy and attribute-less certificates break none. + """ + findings = self.parse("venafi_many_vuln.json") + self.assertEqual(5, len(findings)) + self.assertEqual(["expired", "self-signed", "weak-key", "weak-signature", "weak-signature"], + self.kinds(findings)) + + def test_one_certificate_can_break_several_rules(self): + findings = [f for f in self.parse("venafi_many_vuln.json") + if f.component_name == "legacy.example.com"] + self.assertEqual(["expired", "self-signed", "weak-key", "weak-signature"], + self.kinds(findings)) + + def test_severity_is_fixed_per_rule(self): + by_kind = {f.vuln_id_from_tool: f for f in self.parse("venafi_many_vuln.json")} + self.assertEqual("Critical", by_kind["expired"].severity) + self.assertEqual("High", by_kind["weak-key"].severity) + self.assertEqual("High", by_kind["weak-signature"].severity) + self.assertEqual("Medium", by_kind["self-signed"].severity) + + def test_an_expiry_inside_thirty_days_is_reported_as_expiring_soon(self): + findings = self.parse_string(self.cert(validityEnd=self.in_days(10))) + self.assertEqual(1, len(findings)) + self.assertEqual("expiring-soon", findings[0].vuln_id_from_tool) + self.assertEqual("High", findings[0].severity) + self.assertEqual("Certificate expires within 30 days (app.example.com)", findings[0].title) + self.assertEqual("Schedule renewal before the expiry date.", findings[0].mitigation) + + def test_an_expiry_beyond_thirty_days_is_not_reported(self): + self.assertEqual(0, len(self.parse_string(self.cert(validityEnd=self.in_days(60))))) + + def test_an_expiry_in_the_past_is_expired_not_expiring(self): + findings = self.parse_string(self.cert(validityEnd=self.in_days(-1))) + self.assertEqual(["expired"], self.kinds(findings)) + + def test_a_certificate_with_no_expiry_is_not_reported_either_way(self): + """ + Guessing would either raise a false alarm or hide a real lapse. + + A rule is skipped when the attribute it needs is absent. + """ + findings = [f for f in self.parse("venafi_many_vuln.json") + if f.component_name == "no-expiry.example.com"] + self.assertEqual([], findings) + + def test_the_expiring_soon_detail_counts_the_days_remaining(self): + findings = self.parse_string(self.cert(validityEnd=self.in_days(10))) + self.assertIn("(in 9 days)", findings[0].description) + + def test_only_an_rsa_key_is_measured_against_the_2048_bit_floor(self): + """ + An elliptic-curve key is much shorter by design. + + Applying the RSA floor to it would report every EC certificate as weak. + """ + weak_rsa = self.parse_string(self.cert(encryptionType="RSA", keyStrength=1024)) + self.assertEqual(["weak-key"], self.kinds(weak_rsa)) + + short_ec = self.parse_string(self.cert(encryptionType="EC", keyStrength=256)) + self.assertEqual([], self.kinds(short_ec)) + + def test_the_ec_certificate_in_the_fixture_is_not_flagged(self): + findings = [f for f in self.parse("venafi_many_vuln.json") + if f.component_name == "CN=internal.example.com,O=Generic Org"] + self.assertEqual([], findings) + + def test_key_sizes(self): + for size, kinds in ((512, ["weak-key"]), (1024, ["weak-key"]), (2047, ["weak-key"]), + (2048, []), (4096, []), (0, [])): + with self.subTest(size=size): + findings = self.parse_string(self.cert(keyStrength=size)) + self.assertEqual(kinds, self.kinds(findings)) + + def test_a_quoted_key_size_is_accepted(self): + findings = self.parse_string(self.cert(keyStrength="1024")) + self.assertEqual(["weak-key"], self.kinds(findings)) + + def test_weak_signature_hashes(self): + """Hyphens are stripped first, so "SHA-1" and "SHA1" are both recognised.""" + for algorithm, weak in (("SHA1", True), ("SHA-1", True), ("sha1WithRSAEncryption", True), + ("MD5", True), ("md5WithRSAEncryption", True), ("MD2", True), + ("SHA256", False), ("SHA-256", False), ("SHA384", False), + ("", False)): + with self.subTest(algorithm=algorithm): + findings = self.parse_string(self.cert(signatureHashAlgorithm=algorithm)) + self.assertEqual(["weak-signature"] if weak else [], self.kinds(findings)) + + def test_the_signature_algorithm_field_is_the_fallback_for_the_hash_field(self): + """The fixture's md5 certificate names it only in signatureAlgorithm.""" + findings = [f for f in self.parse("venafi_many_vuln.json") + if f.component_name == "md5.example.com"] + self.assertEqual(["weak-signature"], self.kinds(findings)) + self.assertIn("md5WithRSAEncryption", findings[0].title) + + def test_a_self_signed_certificate_is_reported(self): + findings = self.parse_string(self.cert(selfSigned=True)) + self.assertEqual(["self-signed"], self.kinds(findings)) + self.assertEqual("Medium", findings[0].severity) + + def test_the_self_hosted_edition_is_read_too(self): + """ + The self-hosted edition capitalises every field name, and sends no self-signed flag. + + Reading only the SaaS names would silently produce no findings at all against it, because every + rule's attribute would look absent. + """ + findings = self.parse("venafi_self_hosted.json") + self.assertEqual(["expired", "self-signed", "weak-key", "weak-signature"], + self.kinds(findings)) + for finding in findings: + with self.subTest(kind=finding.vuln_id_from_tool): + self.assertEqual("legacy.example.com", finding.component_name) + self.assertTrue(finding.unique_id_from_tool.startswith( + "venafi-FF11EE22DD33CC44BB5500112233445566778899-")) + + def test_the_self_hosted_edition_infers_self_signed_from_subject_and_issuer(self): + """It has no flag, so a subject matching the issuer is what self-signed means.""" + matching = self.parse_string({"Certificates": [{ + "Thumbprint": "T1", "CN": "a.example.com", "Subject": "CN=a.example.com", + "Issuer": "cn=a.example.com", "KeySize": 2048, "KeyAlgorithm": "RSA", + "SignatureAlgorithm": "SHA256", "ValidTo": "2099-01-01T00:00:00"}]}) + self.assertEqual(["self-signed"], self.kinds(matching)) + + differing = self.parse_string({"Certificates": [{ + "Thumbprint": "T2", "CN": "b.example.com", "Subject": "CN=b.example.com", + "Issuer": "CN=Generic Issuing CA", "KeySize": 2048, "KeyAlgorithm": "RSA", + "SignatureAlgorithm": "SHA256", "ValidTo": "2099-01-01T00:00:00"}]}) + self.assertEqual([], self.kinds(differing)) + + def test_a_self_hosted_certificate_with_no_subject_is_not_self_signed(self): + findings = self.parse_string({"Certificates": [{ + "Thumbprint": "T3", "CN": "c.example.com", "Subject": "", "Issuer": "", + "KeySize": 2048, "KeyAlgorithm": "RSA", "SignatureAlgorithm": "SHA256", + "ValidTo": "2099-01-01T00:00:00"}]}) + self.assertEqual([], self.kinds(findings)) + + def test_the_saas_edition_sends_the_common_name_as_a_list(self): + findings = self.parse_string(self.cert(subjectCN=["first.example.com", "second.example.com"], + validityEnd=self.in_days(-1))) + self.assertEqual("first.example.com", findings[0].component_name) + + def test_the_common_name_falls_back_to_the_subject_dn_then_the_fingerprint(self): + findings = self.parse_string(self.cert(subjectCN=[], subjectDN="CN=dn.example.com", + validityEnd=self.in_days(-1))) + self.assertEqual("CN=dn.example.com", findings[0].component_name) + + findings = self.parse_string(self.cert(subjectCN=[], subjectDN="", + validityEnd=self.in_days(-1))) + self.assertEqual("FP1", findings[0].component_name) + + def test_the_identity_prefers_the_fingerprint_over_the_id(self): + findings = self.parse_string(self.cert(validityEnd=self.in_days(-1))) + self.assertEqual("venafi-FP1-expired", findings[0].unique_id_from_tool) + + findings = self.parse_string(self.cert(fingerprint="", validityEnd=self.in_days(-1))) + self.assertEqual("venafi-cert-1-expired", findings[0].unique_id_from_tool) + + def test_subject_alternative_names_are_flattened_across_types_and_sorted(self): + """ + Sorted, unlike the connector: it iterates a Go map, whose order is randomised. + + Sorting keeps a file import stable and readable. The line is not in the deduplication hash, so + the two still match. + """ + findings = self.parse_string(self.cert( + subjectAlternativeNamesByType={ + "iPAddress": ["10.0.0.1"], + "dNSName": ["zeta.example.com", "alpha.example.com"], + }, + validityEnd=self.in_days(-1))) + self.assertIn("**Subject alternative names:** 10.0.0.1, alpha.example.com, zeta.example.com", + findings[0].description) + + def test_an_empty_san_map_leaves_the_line_out(self): + findings = self.parse_string(self.cert(subjectAlternativeNamesByType={}, + validityEnd=self.in_days(-1))) + self.assertNotIn("**Subject alternative names:**", findings[0].description) + + def test_the_key_line_renders_whichever_half_is_known(self): + cases = ( + ({"encryptionType": "RSA", "keyStrength": 4096}, "**Key:** RSA 4096 bits"), + ({"encryptionType": "", "keyStrength": 4096}, "**Key:** 4096 bits"), + ({"encryptionType": "RSA", "keyStrength": 0}, "**Key:** RSA"), + ) + for overrides, expected in cases: + with self.subTest(expected=expected): + findings = self.parse_string(self.cert(validityEnd=self.in_days(-1), **overrides)) + self.assertIn(expected, findings[0].description) + + def test_timestamp_formats(self): + """Every layout the connector accepts, including a date with no time at all.""" + for value in ("2020-03-01T12:00:00Z", "2020-03-01T12:00:00.000+0000", + "2020-03-01T12:00:00", "2020-03-01"): + with self.subTest(value=value): + findings = self.parse_string(self.cert(validityEnd=value)) + self.assertEqual(["expired"], self.kinds(findings)) + + def test_an_unparseable_timestamp_reports_no_expiry_rule(self): + findings = self.parse_string(self.cert(validityEnd="not a timestamp")) + self.assertEqual([], self.kinds(findings)) + + def test_export_shapes(self): + row = {"fingerprint": "FP1", "subjectCN": ["app.example.com"], + "validityEnd": "2020-01-01T00:00:00Z"} + for payload in ([row], {"certificates": [row]}, {"data": [row]}, {"results": [row]}): + with self.subTest(shape=str(payload)[:24]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("CyberArk Certificate Manager", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("certificates", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"certificates": [ + "not an object", + None, + {"fingerprint": "FP9", "subjectCN": ["app.example.com"], + "subjectAlternativeNamesByType": {"dNSName": "not a list"}, + "validityEnd": "2020-01-01T00:00:00Z"}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("venafi-FP9-expired", findings[0].unique_id_from_tool) + self.assertNotIn("**Subject alternative names:**", findings[0].description) + + def test_the_certificate_is_the_component(self): + """The same problem on two certificates stays two findings.""" + self.assertEqual(["title", "severity", "component_name"], VenafiParser().get_dedupe_fields()) + + def test_severity_is_always_a_known_value(self): + for filename in ("venafi_many_vuln.json", "venafi_one_vuln.json", "venafi_self_hosted.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_vmanplus_parser.py b/unittests/tools/test_vmanplus_parser.py new file mode 100644 index 00000000000..6bf4941273c --- /dev/null +++ b/unittests/tools/test_vmanplus_parser.py @@ -0,0 +1,226 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.vmanplus.parser import VmanplusParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestVmanplusParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("vmanplus") / filename + with path.open(encoding="utf-8") as file: + return list(VmanplusParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(VmanplusParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"resource_id": "301", "resource_name": "generic-host-01", + "vulnerabilityid": "50123", "vulnerabilityname": "A vulnerability", + "severity": "Important", "vulnerability_status": "Open"} + row.update(overrides) + return {"vulnerabilities": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the VMP connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = VmanplusParser() + self.assertEqual(["ManageEngine Vulnerability Manager Plus Scan"], parser.get_scan_types()) + self.assertEqual("ManageEngine Vulnerability Manager Plus Scan", + parser.get_label_for_scan_types("ManageEngine Vulnerability Manager Plus Scan")) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("vmanplus_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("vmanplus_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's finding_converter.""" + findings = self.parse("vmanplus_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Remote code execution in the example office suite", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("vmanplus-301-50123", finding.unique_id_from_tool) + self.assertEqual("50123", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2000-0001"], finding.unsaved_vulnerability_ids) + self.assertEqual(8.8, finding.cvssv3_score) + self.assertEqual("generic-host-01", finding.component_name) + self.assertEqual("https://example.com/advisories/CVE-2000-0001", finding.references) + self.assertEqual(date(2024, 6, 2), finding.date) + self.assertTrue(finding.active) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertEqual( + "**Patch:** Example Office Suite security update 2024-06\n**Patch ID:** 77001", + finding.mitigation, + ) + self.assertEqual( + "**Vulnerability:** Remote code execution in the example office suite\n" + "**CVE IDs:** CVE-2000-0001\n" + "**Host:** generic-host-01\n" + "**IP address:** 10.20.0.11\n" + "**Status:** Open", + finding.description, + ) + + def test_many_vuln(self): + self.assertEqual(4, len(self.parse("vmanplus_many_vuln.json"))) + + def test_the_msrc_severity_names_do_not_mean_what_they_say(self): + """ + VMP grades on Microsoft's scale: "Important" is High and "Moderate" is Medium. + + DefectDojo has neither name, so reading them literally would fall through to Info and drop + both a tier. + """ + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertEqual("High", findings["vmanplus-301-50123"].severity) + self.assertEqual("Medium", findings["vmanplus-302-50124"].severity) + + def test_severity_words(self): + for label, expected in (("Critical", "Critical"), ("Important", "High"), ("High", "High"), + ("Moderate", "Medium"), ("Medium", "Medium"), ("Low", "Low"), + ("Unrated", "Info"), ("", "Info"), ("IMPORTANT", "High")): + with self.subTest(label=label): + findings = self.parse_string(self.row(severity=label)) + self.assertEqual(expected, findings[0].severity) + + def test_the_cvss_v3_score_wins_and_v2_is_the_fallback(self): + """VMP reports both for older advisories, and v3 is the one to prefer.""" + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertEqual(8.8, findings["vmanplus-301-50123"].cvssv3_score) + # v3 is zero here, so the v2 score is used. + self.assertEqual(6.8, findings["vmanplus-302-50124"].cvssv3_score) + self.assertEqual(0.0, findings["vmanplus-303-50125"].cvssv3_score) + + def test_a_closed_or_fixed_vulnerability_is_inactive(self): + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertFalse(findings["vmanplus-302-50124"].active) + self.assertFalse(findings["vmanplus-304-50126"].active) + self.assertTrue(findings["vmanplus-301-50123"].active) + + def test_status_matching_ignores_case(self): + for status, active in (("Closed", False), ("close", False), ("FIXED", False), + ("Remediated", False), ("Open", True), ("Mitigated", True), + ("", True)): + with self.subTest(status=status): + findings = self.parse_string(self.row(vulnerability_status=status)) + self.assertEqual(active, findings[0].active) + + def test_an_unfamiliar_status_stays_active(self): + """ + "Mitigated" is not one of the four closing states, so it stays open. + + Treating an unfamiliar status as closed would silently hide a live vulnerability. + """ + finding = self.by_uid("vmanplus_many_vuln.json")["vmanplus-303-50125"] + self.assertTrue(finding.active) + self.assertIn("**Status:** Mitigated", finding.description) + + def test_the_host_is_the_component_not_a_package(self): + """The same vulnerability on two machines stays two findings - two machines to patch.""" + self.assertEqual(["title", "severity", "component_name"], + VmanplusParser().get_dedupe_fields()) + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertEqual("generic-host-01", findings["vmanplus-301-50123"].component_name) + + def test_the_host_falls_back_to_the_fqdn_then_the_address(self): + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertEqual("generic-host-02.corp.example.com", + findings["vmanplus-302-50124"].component_name) + self.assertEqual("10.20.0.13", findings["vmanplus-303-50125"].component_name) + + def test_the_title_falls_back_to_the_cve_ids_then_the_vulnerability_id(self): + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertEqual("CVE-2000-0004", findings["vmanplus-303-50125"].title) + self.assertEqual("ManageEngine VMP vulnerability 50126", + findings["vmanplus-304-50126"].title) + + def test_several_cve_ids_arrive_in_one_string_and_are_extracted_and_sorted(self): + """ + VMP sends every CVE for a vulnerability in ONE field, so they are extracted rather than used + whole. + + The connector's shared extractor sorts its results, so the order is alphabetical rather than + the order they appear - the fixture names CVE-2000-0002 first. + """ + finding = self.by_uid("vmanplus_many_vuln.json")["vmanplus-301-50123"] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + # The raw field is still shown as VMP wrote it. + self.assertIn("**CVE IDs:** CVE-2000-0002, CVE-2000-0001", finding.description) + + def test_a_row_with_no_cve_ids_has_none(self): + finding = self.by_uid("vmanplus_many_vuln.json")["vmanplus-304-50126"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_ids_sent_as_numbers_render_without_a_decimal_point(self): + """ + VMP's ids are strings that its own decoder also accepts as numbers. + + An id read as a float would render as "50124.0" and never match the API's "50124". + """ + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertIn("vmanplus-302-50124", findings) + self.assertEqual("50124", findings["vmanplus-302-50124"].vuln_id_from_tool) + + def test_timestamps_are_epoch_milliseconds(self): + """Reading them as seconds would date every finding to 1970.""" + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertEqual(date(2024, 6, 2), findings["vmanplus-301-50123"].date) + self.assertEqual(date(2024, 6, 3), findings["vmanplus-302-50124"].date) + + def test_a_zero_or_absent_timestamp_leaves_the_date_alone(self): + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertEqual(datetime.now(tz=UTC).date(), findings["vmanplus-303-50125"].date) + self.assertEqual(datetime.now(tz=UTC).date(), findings["vmanplus-304-50126"].date) + + def test_a_row_with_no_patch_leaves_the_mitigation_unset(self): + findings = self.by_uid("vmanplus_many_vuln.json") + self.assertIsNone(findings["vmanplus-302-50124"].mitigation) + self.assertIn("**Patch ID:** 77001", findings["vmanplus-301-50123"].mitigation) + + def test_export_shapes(self): + row = {"resource_id": "301", "vulnerabilityid": "50123", + "vulnerabilityname": "A vulnerability", "severity": "Low"} + for payload in ([row], {"vulnerabilities": [row]}, {"data": [row]}, {"results": [row]}): + with self.subTest(shape=str(payload)[:24]): + self.assertEqual(1, len(self.parse_string(payload))) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("ManageEngine", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("vulnerabilities", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"vulnerabilities": [ + "not an object", + None, + {"resource_id": "309", "vulnerabilityid": "50129", + "vulnerabilityname": "A vulnerability", "severity": "Low"}, + ]}) + self.assertEqual(1, len(findings)) + self.assertEqual("vmanplus-309-50129", findings[0].unique_id_from_tool) + + def test_severity_is_always_a_known_value(self): + for filename in ("vmanplus_many_vuln.json", "vmanplus_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_wallarm_parser.py b/unittests/tools/test_wallarm_parser.py new file mode 100644 index 00000000000..b1b45e72246 --- /dev/null +++ b/unittests/tools/test_wallarm_parser.py @@ -0,0 +1,246 @@ +import io +import json +from datetime import UTC, datetime + +from dojo.models import Finding, Test +from dojo.tools.wallarm.parser import WallarmParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestWallarmParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("wallarm") / filename).open(encoding="utf-8") as file: + return list(WallarmParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(WallarmParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def row(self, **overrides): + row = {"id": 1, "title": "A vulnerability", "type": "sqli", "status": "active", + "threat": "high", "domain": "api.example.com", "path": "/v1/thing"} + row.update(overrides) + return {"status": 200, "body": [row]} + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Wallarm connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = WallarmParser() + self.assertEqual(["Wallarm API Security"], parser.get_scan_types()) + self.assertEqual("Wallarm API Security", parser.get_label_for_scan_types("Wallarm API Security")) + self.assertNotIn("Wallarm - Connectors Import", parser.get_scan_types()) + + def test_no_vuln(self): + """Closed and false-positive vulnerabilities have already been dealt with in Wallarm.""" + self.assertEqual(0, len(self.parse("wallarm_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("wallarm_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring Convert in the connector's converter.""" + findings = self.parse("wallarm_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("SQL injection in the reports endpoint", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("wallarm-700001", finding.unique_id_from_tool) + self.assertEqual("sqli", finding.vuln_id_from_tool) + self.assertEqual("POST /v1/reports with filter=1' OR '1'='1", finding.mitigation) + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), finding.date) + self.assertTrue(finding.active) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + self.assertEqual(["sqli", "active"], finding.unsaved_tags) + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + self.assertEqual( + "**Type:** sqli\n" + "**Domain:** api.example.com\n" + "**Method:** POST\n" + "**Path:** /v1/reports\n" + "**Parameter:** post_body|json_doc|hash|filter\n" + "**Detection method:** active_verification\n\n" + "**Description:**\n" + "A request parameter reaches a database query. Related to CVE-2000-0002 and cve-2000-0002.\n\n" + "**Additional:**\n" + "Validated against the running service. See CVE-2000-0001.", + finding.description, + ) + + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("api.example.com", locations[0].host) + self.assertEqual("v1/reports", locations[0].path) + + def test_many_vuln(self): + """Seven rows, two of them already dealt with.""" + self.assertEqual(5, len(self.parse("wallarm_many_vuln.json"))) + + def test_closed_and_false_positive_are_skipped(self): + for status, imported in (("active", 1), ("closed", 0), ("falsepositive", 0), + ("CLOSED", 0), ("", 1)): + with self.subTest(status=status): + self.assertEqual(imported, len(self.parse_string(self.row(status=status)))) + + def test_the_threat_level_may_be_a_number_or_a_word(self): + """ + Wallarm sends the threat level in one field as either form, depending on which API answered. + + The two need different ladders, and reading a number as a label - or the other way round - + would drop everything to Info. + """ + for threat, expected in ((5, "Critical"), (6, "Critical"), (4, "High"), (3, "Medium"), + (2, "Low"), (1, "Info"), (0, "Info"), (5.0, "Critical"), + ("critical", "Critical"), ("high", "High"), ("medium", "Medium"), + ("low", "Low"), ("info", "Info"), ("information", "Info"), + ("informational", "Info"), ("", "Info"), ("severe", "Info"), + (None, "Info")): + with self.subTest(threat=threat): + findings = self.parse_string(self.row(threat=threat)) + self.assertEqual(expected, findings[0].severity) + + def test_the_numeric_scale_runs_to_five(self): + """ + Five is the most severe, not the least - the inverse of a priority number. + + This is the one part of the mapping the vendor's documentation does not pin down, so it is + copied from the connector rather than inferred, and flagged in the PR as worth confirming + against a live tenant. + """ + findings = self.by_uid("wallarm_many_vuln.json") + self.assertEqual("Critical", findings["wallarm-700001"].severity) + self.assertEqual("Medium", findings["wallarm-wid-0003"].severity) + + def test_an_unrecognised_label_is_info(self): + findings = self.by_uid("wallarm_many_vuln.json") + self.assertEqual("Info", findings["wallarm-700007"].severity) + + def test_the_unique_id_falls_back_to_the_wid_then_the_location(self): + """ + The location fallback is last because it is the only one that is not an id. + + Two vulnerabilities of different types on one path would collide, but something stable is + better than nothing. + """ + findings = self.by_uid("wallarm_many_vuln.json") + self.assertIn("wallarm-700001", findings) + self.assertIn("wallarm-wid-0003", findings) + self.assertIn("wallarm-legacy.example.com/old", findings) + + def test_title_falls_back_to_the_type_then_the_id(self): + by_type = self.parse_string(self.row(title="", type="xss")) + self.assertEqual("Wallarm: xss", by_type[0].title) + + bare = self.parse_string(self.row(title="", type="", id=42)) + self.assertEqual("Wallarm vulnerability 42", bare[0].title) + + def test_identifiers_are_sorted_and_deduplicated_case_insensitively(self): + """ + Wallarm's extractor sorts and drops case-insensitive duplicates. + + The sample names the same CVE twice in different cases and another in the additional text. + """ + finding = self.by_uid("wallarm_many_vuln.json")["wallarm-700001"] + self.assertEqual(["CVE-2000-0001", "CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_a_finding_with_no_identifiers_has_none(self): + finding = self.by_uid("wallarm_many_vuln.json")["wallarm-700002"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_only_an_absolute_path_is_appended_to_the_endpoint(self): + """ + Wallarm uses the path field for a parameter location on some vulnerability types. + + The connector only appends a value beginning with "/", because anything else is not a path. + """ + findings = self.by_uid("wallarm_many_vuln.json") + with_path = self.get_unsaved_locations(findings["wallarm-700001"])[0] + self.assertEqual("v1/reports", with_path.path) + + without = self.get_unsaved_locations(findings["wallarm-wid-0003"])[0] + self.assertEqual("shop.example.com", without.host) + self.assertFalse(without.path) + # The raw value is still reported, so nothing is lost. + self.assertIn("**Path:** not-a-path", findings["wallarm-wid-0003"].description) + + def test_a_row_with_no_domain_records_no_endpoint(self): + findings = self.parse_string(self.row(domain="")) + self.assertEqual([], self.get_unsaved_locations(findings[0])) + + def test_dates_are_unix_seconds(self): + findings = self.by_uid("wallarm_many_vuln.json") + self.assertEqual(datetime(2024, 7, 1, tzinfo=UTC).date(), findings["wallarm-700001"].date) + self.assertEqual(datetime(2024, 6, 1, tzinfo=UTC).date(), findings["wallarm-700002"].date) + + def test_a_row_with_no_validation_time_keeps_the_default_date(self): + finding = self.by_uid("wallarm_many_vuln.json")["wallarm-wid-0003"] + self.assertEqual(datetime.now(tz=UTC).date(), finding.date) + + def test_the_exploit_example_is_the_mitigation(self): + """ + Wallarm offers an exploit example rather than advice, and the connector puts it here. + + Mirrored rather than left out: it is the only remediation-shaped field Wallarm has, and a + reviewer can act on a reproduction. Flagged in the PR as a follow-up for both sides. + """ + finding = self.by_uid("wallarm_many_vuln.json")["wallarm-700002"] + self.assertEqual("Forge a token using the none algorithm.", finding.mitigation) + + def test_a_row_with_no_prose_has_only_the_field_lines(self): + findings = self.parse_string(self.row(method="POST", description="", additional="", + parameter="", detection_method="")) + self.assertEqual( + "**Type:** sqli\n" + "**Domain:** api.example.com\n" + "**Method:** POST\n" + "**Path:** /v1/thing", + findings[0].description, + ) + + def test_absent_location_fields_are_omitted_rather_than_left_blank(self): + findings = self.parse_string(self.row(description="", additional="")) + self.assertNotIn("**Method:**", findings[0].description) + self.assertNotIn("**Parameter:**", findings[0].description) + self.assertNotIn("**Detection method:**", findings[0].description) + + def test_a_bare_list_of_vulnerabilities_is_accepted(self): + findings = self.parse_string([ + {"id": 1, "title": "A vulnerability", "type": "sqli", "status": "active", "threat": 4, + "domain": "api.example.com"}, + ]) + self.assertEqual(1, len(findings)) + self.assertEqual("High", findings[0].severity) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string("not an export") + self.assertIn("Wallarm", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("body", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"body": [ + "not an object", + None, + {"id": 1, "title": "A vulnerability", "type": "sqli", "status": "active", "threat": 4, + "domain": "api.example.com"}, + ]}) + self.assertEqual(1, len(findings)) + + def test_severity_is_always_a_known_value(self): + for filename in ("wallarm_many_vuln.json", "wallarm_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) diff --git a/unittests/tools/test_yeswehack_parser.py b/unittests/tools/test_yeswehack_parser.py new file mode 100644 index 00000000000..33d54331910 --- /dev/null +++ b/unittests/tools/test_yeswehack_parser.py @@ -0,0 +1,258 @@ +import io +import json +from datetime import date + +from dojo.models import Finding, Test +from dojo.tools.yeswehack.parser import YesWeHackParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestYesWeHackParser(DojoTestCase): + def parse(self, filename): + with (get_unit_tests_scans_path("yeswehack") / filename).open(encoding="utf-8") as file: + return list(YesWeHackParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(YesWeHackParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def by_uid(self, filename): + return {f.unique_id_from_tool: f for f in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal ScanTypeName in the YesWeHack connector verbatim. + + Any drift and a customer who uploads an export and also syncs the API gets two + un-deduplicated copies of every finding. + """ + parser = YesWeHackParser() + self.assertEqual(["YesWeHack - Connectors Import"], parser.get_scan_types()) + self.assertEqual( + "YesWeHack - Connectors Import", + parser.get_label_for_scan_types("YesWeHack - Connectors Import"), + ) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("yeswehack_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("yeswehack_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """Full field mapping, mirroring ToFinding in the connector's converter.""" + findings = self.parse("yeswehack_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("SQL injection on the search endpoint", finding.title) + self.assertEqual("Critical", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("40001", finding.unique_id_from_tool) + # vuln_id_from_tool is the human-facing local id, not the numeric one. + self.assertEqual("GENERIC-2026-0001", finding.vuln_id_from_tool) + self.assertEqual(9.8, finding.cvssv3_score) + self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) + self.assertEqual(date(2026, 7, 20), finding.date) + self.assertEqual(["yeswehack"], finding.unsaved_tags) + self.assertFalse(finding.static_finding) + self.assertTrue(finding.dynamic_finding) + + def test_the_description_uses_the_shared_formatters_markdown(self): + """ + The connector builds this with the shared formatter: "* **Prefix** text" bullets and + "### Heading" sections. + + Getting the shape wrong would not break the import but would make every finding read + differently from the connector's, which is the thing this parser exists to match. + """ + finding = self.parse("yeswehack_one_vuln.json")[0] + self.assertIn("* **Report:** GENERIC-2026-0001\n", finding.description) + self.assertIn("* **Bug type:** SQL Injection\n", finding.description) + self.assertIn("* **Category:** Injection\n", finding.description) + self.assertIn("* **Scope:** app.example.com\n", finding.description) + self.assertIn("* **Endpoint:** https://app.example.com/search\n", finding.description) + self.assertIn("### Description\n\n", finding.description) + self.assertIn("### Impact\n\n", finding.description) + self.assertIn("An attacker can read the whole database.", finding.description) + + def test_severity_falls_through_criticity_then_priority_name_then_slug(self): + """ + The connector tries three sources in order. + + Falling straight to Info when the CVSS criticity is unset would throw away the priority + YesWeHack did set - the fixture's second report has exactly that shape. + """ + findings = self.by_uid("yeswehack_many_vuln.json") + # No criticity, priority name "Medium" recognised. + self.assertEqual("Medium", findings["40002"].severity) + # Criticity is an unrecognised word AND the priority name is too, so the slug decides. + self.assertEqual("Low", findings["40003"].severity) + + def test_the_severity_resolution_directly(self): + parser = YesWeHackParser() + self.assertEqual("Critical", parser.severity({}, {"criticity": "critical"})) + self.assertEqual("High", parser.severity({"priority": {"name": "High"}}, {})) + self.assertEqual("Low", parser.severity({"priority": {"name": "?", "slug": "low"}}, {})) + # YesWeHack's own Info spellings. + for word in ("info", "informative", "none"): + self.assertEqual("Info", parser.severity({}, {"criticity": word}), word) + self.assertEqual("Info", parser.severity({}, {})) + + def test_the_workflow_state_becomes_the_defectdojo_state(self): + """ + YesWeHack's state carries real triage information, and the connector translates each one. + + Importing everything as active would put resolved, rejected and duplicate reports back in + front of the team. + """ + findings = self.by_uid("yeswehack_many_vuln.json") + + accepted = findings["40001"] + self.assertTrue(accepted.active) + self.assertTrue(accepted.verified) + + resolved = findings["40002"] + self.assertFalse(resolved.active) + self.assertTrue(resolved.is_mitigated) + + wont_fix = findings["40003"] + self.assertFalse(wont_fix.active) + self.assertTrue(wont_fix.risk_accepted) + + rejected = findings["40004"] + self.assertFalse(rejected.active) + self.assertTrue(rejected.false_p) + + duplicate = findings["40005"] + self.assertFalse(duplicate.active) + self.assertTrue(duplicate.duplicate) + + out_of_scope = findings["40006"] + self.assertFalse(out_of_scope.active) + self.assertFalse(out_of_scope.is_mitigated) + self.assertFalse(out_of_scope.false_p) + + def test_an_unrecognised_state_stays_active(self): + """ + The safe side of the assumption: a state YesWeHack adds later must not silently close a + finding. + """ + finding = self.by_uid("yeswehack_many_vuln.json")["40007"] + self.assertTrue(finding.active) + self.assertFalse(finding.is_mitigated) + self.assertFalse(finding.false_p) + self.assertFalse(finding.duplicate) + self.assertFalse(finding.risk_accepted) + + def test_the_status_mapping_directly(self): + parser = YesWeHackParser() + for state, checks in ( + ("new", {"active": True}), + ("under_review", {"active": True}), + ("accepted", {"active": True, "verified": True}), + ("resolved", {"active": False, "is_mitigated": True}), + ("auto_close", {"active": False, "is_mitigated": True}), + ("wont_fix", {"active": False, "risk_accepted": True}), + ("invalid", {"active": False, "false_p": True}), + ("informative", {"active": False}), + ): + finding = Finding() + parser.apply_status(finding, state) + for attribute, expected in checks.items(): + self.assertEqual(expected, getattr(finding, attribute), f"{state}.{attribute}") + + def test_a_report_with_no_title_falls_back_to_the_local_id_then_the_numeric_id(self): + findings = self.by_uid("yeswehack_many_vuln.json") + self.assertEqual("YesWeHack report 40003", findings["40003"].title) + # And with no local id either, vuln_id_from_tool is the numeric id. + self.assertEqual("40003", findings["40003"].vuln_id_from_tool) + + def test_cves_are_extracted_from_the_prose_fields(self): + """ + YesWeHack has no CVE field; the connector scans title, description, impact and technical + information. + + The fixture puts the identifier only in the technical information, which is the field easiest + to forget. + """ + finding = self.parse("yeswehack_one_vuln.json")[0] + self.assertEqual(["CVE-2000-0002"], finding.unsaved_vulnerability_ids) + + def test_a_report_with_no_cve_has_none(self): + finding = self.by_uid("yeswehack_many_vuln.json")["40002"] + self.assertIsNone(finding.unsaved_vulnerability_ids) + + def test_the_endpoint_prefers_the_reported_endpoint_over_the_scope(self): + """Asserted through get_unsaved_locations so it passes in both V3_FEATURE_LOCATIONS modes.""" + finding = self.parse("yeswehack_one_vuln.json")[0] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + + def test_the_endpoint_falls_back_to_the_scope(self): + finding = self.by_uid("yeswehack_many_vuln.json")["40002"] + locations = self.get_unsaved_locations(finding) + self.assertEqual(1, len(locations)) + self.assertEqual("app.example.com", locations[0].host) + + def test_a_report_with_neither_endpoint_nor_scope_records_none(self): + finding = self.by_uid("yeswehack_many_vuln.json")["40003"] + self.assertEqual([], self.get_unsaved_locations(finding)) + + def test_the_alternative_timestamp_layouts_are_accepted(self): + """ + The connector tries several layouts, so a non-RFC3339 stamp still dates the finding. + + Supporting only RFC3339 would leave those findings undated. + """ + findings = self.by_uid("yeswehack_many_vuln.json") + self.assertEqual(date(2026, 7, 21), findings["40002"].date) # "2026-07-21 10:00:00" + self.assertEqual(date(2026, 7, 22), findings["40003"].date) # "2026-07-22" + self.assertIsNone(findings["40004"].date) # unparseable + + def test_a_zero_score_is_left_unset(self): + finding = self.by_uid("yeswehack_many_vuln.json")["40002"] + self.assertIsNone(finding.cvssv3_score) + + def test_a_bare_array_is_accepted(self): + report = io.StringIO(json.dumps([{ + "id": 1, "title": "A report", "cvss": {"criticity": "low"}, + "status": {"workflow_state": "new"}, + }])) + findings = list(YesWeHackParser().get_findings(report, Test())) + self.assertEqual(1, len(findings)) + self.assertEqual("Low", findings[0].severity) + + def test_a_repeated_report_id_collapses(self): + row = {"id": 1, "title": "A report", "cvss": {"criticity": "low"}, + "status": {"workflow_state": "new"}} + report = io.StringIO(json.dumps({"items": [row, row]})) + self.assertEqual(1, len(list(YesWeHackParser().get_findings(report, Test())))) + + def test_an_unexpected_shape_is_rejected_with_a_clear_message(self): + with self.assertRaises(TypeError) as raised: + list(YesWeHackParser().get_findings(io.StringIO('"nope"'), Test())) + self.assertIn("items", str(raised.exception)) + + def test_an_endpoint_with_a_port_is_split_rather_than_kept_whole(self): + """ + A researcher writes whatever the programme scope allows, so the value may be a full URL. + + Keeping "host:port" in the host field fails DefectDojo's validation, and that fails the whole + import rather than the one finding. + """ + findings = self.parse_string({"items": [{ + "id": 1, "title": "A report", "severity": "high", "status": "accepted", + "end_point": "https://app.example.com:8443/login", + }]}) + location = self.get_unsaved_locations(findings[0])[0] + self.assertEqual("app.example.com", location.host) + self.assertEqual(8443, location.port) + self.assertEqual("https", location.protocol) + + def test_an_endpoint_that_cannot_be_a_host_is_dropped(self): + findings = self.parse_string({"items": [{ + "id": 1, "title": "A report", "severity": "high", "status": "accepted", + "end_point": "the mobile app", + }]}) + self.assertEqual([], self.get_unsaved_locations(findings[0])) diff --git a/unittests/tools/test_zimperium_parser.py b/unittests/tools/test_zimperium_parser.py new file mode 100644 index 00000000000..403d245e4e6 --- /dev/null +++ b/unittests/tools/test_zimperium_parser.py @@ -0,0 +1,241 @@ +import io +import json +from datetime import UTC, date, datetime + +from dojo.models import Finding, Test +from dojo.tools.hiddenlayer.parser import SarifConnectorFindings +from dojo.tools.zimperium.parser import ZimperiumParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestZimperiumParser(DojoTestCase): + def parse(self, filename): + path = get_unit_tests_scans_path("zimperium") / filename + with path.open(encoding="utf-8") as file: + return list(ZimperiumParser().get_findings(file, Test())) + + def parse_string(self, payload): + return list(ZimperiumParser().get_findings(io.StringIO(json.dumps(payload)), Test())) + + def report(self, result=None, rule=None, **context): + payload = {"assessment": {"id": "assess-1", "appVersion": "1.0.0"}, + "app": {"name": "Generic Mobile App", "platform": "android"}, + "sarif": {"runs": [{"tool": {"driver": {"rules": [rule] if rule else []}}, + "results": [result or {"ruleId": "RULE_1", "level": "error", + "message": {"text": "A finding"}}]}]}} + payload.update(context) + return payload + + def by_uid(self, filename): + return {finding.unique_id_from_tool: finding for finding in self.parse(filename)} + + def test_scan_type_matches_the_connector_exactly(self): + """ + Must equal the Zimperium connector's ScanTypeName verbatim. + + Note it does NOT follow the "<Vendor> - Connectors Import" pattern the other connector scan + types use, so it cannot be derived - it has to be copied. + """ + parser = ZimperiumParser() + self.assertEqual(["Zimperium zScan"], parser.get_scan_types()) + self.assertEqual("Zimperium zScan", parser.get_label_for_scan_types("Zimperium zScan")) + self.assertNotIn("Zimperium - Connectors Import", parser.get_scan_types()) + + def test_the_sarif_mapping_is_shared_with_the_other_sarif_connectors(self): + """ + On the Go side these connectors call one shared utility, parameterised per vendor. + + Extending the same mixin here is what keeps them from drifting apart - the shipped invicti + parser extends netsparker the same way. Only the prefix, the static flag and the decoration + differ. + """ + self.assertTrue(issubclass(ZimperiumParser, SarifConnectorFindings)) + self.assertEqual("zimperium", ZimperiumParser.tool_prefix) + self.assertTrue(ZimperiumParser.is_static) + + def test_the_shared_mixin_is_not_itself_a_parser(self): + """ + dojo/tools/factory.py registers the class whose lowercased name matches its module. + + "SarifConnectorFindings" matches no module, so it stays invisible - as netsparker's shared + base does. + """ + self.assertFalse(hasattr(SarifConnectorFindings, "get_scan_types")) + + def test_no_vuln(self): + self.assertEqual(0, len(self.parse("zimperium_no_vuln.json"))) + + def test_one_vuln(self): + self.assertEqual(1, len(self.parse("zimperium_one_vuln.json"))) + + def test_one_vuln_field_mapping(self): + """The shared SARIF mapping plus Zimperium's own app and build decoration.""" + findings = self.parse("zimperium_one_vuln.json") + self.assertEqual(1, len(findings)) + + finding = findings[0] + self.assertEqual("Cleartext traffic is permitted for all domains", finding.title) + self.assertEqual("High", finding.severity) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("zimperium-assess-0001-ZS_CLEARTEXT_TRAFFIC-" + "res/xml/network_security_config.xml:3", finding.unique_id_from_tool) + self.assertEqual("ZS_CLEARTEXT_TRAFFIC", finding.vuln_id_from_tool) + self.assertEqual(7.4, finding.cvssv3_score) + self.assertEqual(319, finding.cwe) + self.assertEqual("res/xml/network_security_config.xml", finding.file_path) + self.assertEqual(3, finding.line) + self.assertEqual("https://example.com/rules/cleartext-traffic", finding.references) + self.assertEqual("Set cleartextTrafficPermitted to false.", finding.mitigation) + self.assertTrue(finding.active) + self.assertFalse(finding.false_p) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + # The app, its build version and the upload date all come from the decoration. + self.assertEqual("Generic Mobile App", finding.component_name) + self.assertEqual("3.2.0", finding.component_version) + self.assertEqual(date(2024, 6, 2), finding.date) + # The platform is appended AFTER the SARIF tags. + self.assertEqual(["cwe-319", "network", "mobile", "android"], finding.unsaved_tags) + + def test_many_vuln(self): + """Four results, but the "pass" result is never imported.""" + self.assertEqual(3, len(self.parse("zimperium_many_vuln.json"))) + + def test_the_app_and_build_context_decorates_every_finding(self): + """ + A SARIF document says nothing about which app or build it came from. + + Two builds of one app land in the same product, and without the version there is no telling + them apart - so the decoration is what makes a mobile finding actionable. + """ + for finding in self.parse("zimperium_many_vuln.json"): + with self.subTest(uid=finding.unique_id_from_tool): + self.assertEqual("Generic Mobile App", finding.component_name) + self.assertEqual("4.0.1", finding.component_version) + self.assertEqual(date(2024, 6, 5), finding.date) + self.assertIn("ios", finding.unsaved_tags) + + def test_the_identity_is_namespaced_by_the_assessment(self): + """ + One assessment is one scan of one build. + + The same rule firing in two builds is two findings, which is what lets a reader see that a + problem survived a release. + """ + first = self.parse_string(self.report(assessment={"id": "assess-a", "appVersion": "1.0.0"})) + second = self.parse_string(self.report(assessment={"id": "assess-b", "appVersion": "1.0.1"})) + self.assertEqual("zimperium-assess-a-RULE_1-:0", first[0].unique_id_from_tool) + self.assertEqual("zimperium-assess-b-RULE_1-:0", second[0].unique_id_from_tool) + + def test_the_assessment_id_may_be_spelled_three_ways_or_sit_at_the_top_level(self): + for key in ("assessment_id", "assessmentId", "id"): + with self.subTest(key=key): + payload = self.report() + payload.pop("assessment") + payload[key] = "assess-9" + findings = self.parse_string(payload) + self.assertTrue(findings[0].unique_id_from_tool.startswith("zimperium-assess-9-")) + + def test_a_bare_sarif_report_is_accepted(self): + """ + A report downloaded as-is has no envelope, so it carries no app or build context either. + + The findings still import; they simply have no component or version. + """ + findings = self.parse_string({"runs": [ + {"tool": {"driver": {"rules": []}}, + "results": [{"ruleId": "RULE_1", "level": "error", "message": {"text": "A finding"}}]}]}) + self.assertEqual(1, len(findings)) + self.assertEqual("zimperium--RULE_1-:0", findings[0].unique_id_from_tool) + self.assertIsNone(findings[0].component_name) + self.assertIsNone(findings[0].component_version) + self.assertEqual(datetime.now(tz=UTC).date(), findings[0].date) + + def test_the_context_may_be_stated_at_the_top_level(self): + findings = self.parse_string({ + "id": "assess-3", "name": "Generic Mobile App", "appVersion": "2.0.0", + "platform": "android", "buildUploadedAt": "2024-07-01T00:00:00Z", + "sarif": {"runs": [{"tool": {"driver": {"rules": []}}, + "results": [{"ruleId": "RULE_1", "level": "error", + "message": {"text": "A finding"}}]}]}}) + finding = findings[0] + self.assertEqual("zimperium-assess-3-RULE_1-:0", finding.unique_id_from_tool) + self.assertEqual("Generic Mobile App", finding.component_name) + self.assertEqual("2.0.0", finding.component_version) + self.assertEqual(date(2024, 7, 1), finding.date) + self.assertIn("android", finding.unsaved_tags) + + def test_a_suppressed_result_is_inactive_and_a_false_positive(self): + """Shared behaviour: SARIF suppression is a reviewer saying this one does not count.""" + finding = self.by_uid("zimperium_many_vuln.json")[ + "zimperium-assess-0002-ZS_WEAK_CRYPTO-Payload/GenericApp/Crypto.m:88"] + self.assertFalse(finding.active) + self.assertTrue(finding.false_p) + + def test_a_result_that_is_not_a_failure_is_skipped(self): + findings = self.by_uid("zimperium_many_vuln.json") + for finding in findings.values(): + self.assertNotIn("Never imported", finding.title) + + def test_a_security_severity_word_grades_but_scores_nothing(self): + finding = self.by_uid("zimperium_many_vuln.json")[ + "zimperium-assess-0002-ZS_WEAK_CRYPTO-Payload/GenericApp/Crypto.m:88"] + self.assertEqual("Medium", finding.severity) + self.assertEqual(0.0, finding.cvssv3_score) + + def test_a_note_level_result_with_no_score_is_info(self): + finding = self.by_uid("zimperium_many_vuln.json")["zimperium-assess-0002-ZS_DEBUG_SYMBOLS-:0"] + self.assertEqual("Info", finding.severity) + + def test_the_cwe_taxonomy_prefix_is_stripped_from_tags(self): + finding = self.by_uid("zimperium_many_vuln.json")[ + "zimperium-assess-0002-ZS_HARDCODED_SECRET-Payload/GenericApp/Config.plist:12"] + self.assertEqual(["cwe-798", "secrets", "mobile", "ios"], finding.unsaved_tags) + self.assertEqual(798, finding.cwe) + + def test_the_decoration_does_not_overwrite_what_sarif_already_said(self): + """Each field is only filled when the shared mapping left it empty, as the connector does.""" + findings = self.parse_string(self.report()) + self.assertEqual("Generic Mobile App", findings[0].component_name) + + def test_an_unparseable_upload_date_leaves_the_date_alone(self): + findings = self.parse_string(self.report( + assessment={"id": "assess-1", "appVersion": "1.0.0", + "buildUploadedAt": "not a timestamp"})) + self.assertEqual(datetime.now(tz=UTC).date(), findings[0].date) + + def test_no_platform_appends_no_tag(self): + findings = self.parse_string(self.report(app={"name": "Generic Mobile App"})) + self.assertEqual([], findings[0].unsaved_tags) + + def test_non_object_export_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string(["not a report"]) + self.assertIn("Zimperium", str(context.exception)) + + def test_unrelated_json_is_rejected(self): + with self.assertRaises(TypeError) as context: + self.parse_string({"scanner": "something else"}) + self.assertIn("runs", str(context.exception)) + + def test_malformed_rows_are_skipped(self): + findings = self.parse_string({"assessment": {"id": "assess-1"}, "sarif": {"runs": [ + "not an object", + None, + {"tool": {"driver": {"rules": ["not an object", None]}}, + "results": ["not an object", None, + {"ruleId": "RULE_1", "level": "error", "message": {"text": "A finding"}}]}, + ]}}) + self.assertEqual(1, len(findings)) + self.assertEqual("zimperium-assess-1-RULE_1-:0", findings[0].unique_id_from_tool) + + def test_the_file_path_and_the_rule_are_both_in_the_hash(self): + """One rule firing on two files in an app bundle is two findings.""" + self.assertEqual(["title", "severity", "file_path", "vuln_id_from_tool"], + ZimperiumParser().get_dedupe_fields()) + + def test_severity_is_always_a_known_value(self): + for filename in ("zimperium_many_vuln.json", "zimperium_one_vuln.json"): + for finding in self.parse(filename): + with self.subTest(filename=filename, uid=finding.unique_id_from_tool): + self.assertIn(finding.severity, Finding.SEVERITIES) From 44d93fc097be7274066e6713d36d142e87e4e31d Mon Sep 17 00:00:00 2001 From: Greg Anderson <greg.anderson@owasp.org> Date: Mon, 3 Aug 2026 22:36:02 -0600 Subject: [PATCH 003/110] feat(dedupe): let a false-positive-history hook supply candidates, not only narrow them (#15502) The candidate hook could already only be used to drop candidates the deduplication algorithm had resolved. A plugin that identifies a finding by something this module does not know about had no way to contribute a match, even though the call site already replaces the candidate list wholesale rather than intersecting with it. This states that as the contract and covers it with a test, so a later refactor cannot quietly turn the hook back into a filter. Adding candidates carries obligations this function cannot check without giving up its single-query fetch: stay inside the product, or a false-positive verdict replicates across a boundary the user never crossed; exclude the findings being processed, or a finding can mark itself; and have the read fields loaded, since candidates are fetched with .only(). The first two need the batch's scope, which the hook never saw -- it is handed one finding at a time -- so FalsePositiveCandidateContext now carries the product, the algorithm and the excluded ids. The context is passed only to hooks that accept it, so a hook written against the two-argument form keeps working; the two existing hook tests cover that form. Co-authored-by: devGregA <greg-agent-2@defectdojo.com> --- dojo/finding/deduplication.py | 69 +++++++++++++++++-- .../test_false_positive_history_logic.py | 66 ++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index fe314b52319..fb5c7758631 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -1,5 +1,7 @@ +import inspect import logging from collections.abc import Iterator +from dataclasses import dataclass from operator import attrgetter import hyperlink @@ -1063,6 +1065,39 @@ def _fp_candidates_qs(scope_filter, dedup_alg, findings, exclude_ids=None): return Finding.objects.none() +@dataclass(frozen=True) +class FalsePositiveCandidateContext: + + """ + Scope a false-positive-history candidate hook must respect if it adds candidates. + + Passed to hooks that accept a ``context`` argument so they can honor the product scope and + the batch exclusion without re-deriving either. See the hook comment in + ``do_false_positive_history_batch``. + """ + + product: object + algorithm: str + excluded_finding_ids: frozenset + + +def _accepts_candidate_context(hook) -> bool: + """ + Whether ``hook`` takes a ``context`` argument. + + Lets the contract grow without breaking a plugin written against the two-argument form. A + hook whose signature cannot be read (a builtin, or an object with a ``__call__`` that hides + it) is treated as not accepting it, which is the behavior that existed before. + """ + try: + parameters = inspect.signature(hook).parameters + except (TypeError, ValueError): + return False + if "context" in parameters: + return True + return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()) + + def _fetch_fp_candidates_for_batch(findings, product, dedup_alg): """ Fetch all existing findings in the product that could be FP matches for a batch, @@ -1135,12 +1170,33 @@ def do_false_positive_history_batch(findings): # Fetch all candidate existing findings with one DB query candidates = _fetch_fp_candidates_for_batch(findings, product, dedup_alg) - # Optional plugin hook: refine the per-finding candidate list after it is resolved by - # deduplication_algorithm. Lets a plugin (e.g. Pro) narrow candidates by fields that are - # excluded from the hash string but compared per pair (set-match tokens on - # vulnerability_ids / CWEs). Resolved once; a no-op when unset. See get_custom_method. + # Optional plugin hook: resolve the per-finding candidate list after deduplication_algorithm + # has produced it. A plugin may narrow the list -- e.g. by fields excluded from the hash + # string but compared per pair, such as set-match tokens on vulnerability_ids / CWEs -- and + # it may also return candidates that were not in the list, which is how a plugin can match on + # an identity this function's algorithm does not know about. The return value replaces the + # list; it is not intersected with it. Resolved once; a no-op when unset. + # + # A plugin that adds candidates owns three obligations, because this function cannot check + # them without undoing the single-query fetch above: + # * Stay inside `product`. False-positive history is product-scoped, and a candidate from + # another product would replicate a false-positive verdict across a boundary the user + # never crossed. + # * Exclude the findings being processed. They are already excluded from the fetch, and a + # finding that reaches its own candidate list can mark itself false-positive. + # * Have `id`, `false_p` and `active` loaded. Candidates are fetched with `.only(...)`, so + # a deferred field read here costs a query per candidate. + # `context` carries what a plugin needs to satisfy the first two without re-querying. It is + # passed only to hooks that accept it, so existing two-argument hooks keep working. from dojo.utils import get_custom_method # noqa: PLC0415 -- circular import fp_candidate_filter = get_custom_method("FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD") + fp_candidate_context = None + if fp_candidate_filter and _accepts_candidate_context(fp_candidate_filter): + fp_candidate_context = FalsePositiveCandidateContext( + product=product, + algorithm=dedup_alg, + excluded_finding_ids=frozenset(f.id for f in findings if f.id), + ) to_mark_as_fp_ids: set = set() @@ -1166,7 +1222,10 @@ def do_false_positive_history_batch(findings): existing = [] if fp_candidate_filter: - existing = fp_candidate_filter(finding, existing) + if fp_candidate_context is not None: + existing = fp_candidate_filter(finding, existing, context=fp_candidate_context) + else: + existing = fp_candidate_filter(finding, existing) existing_fps = [ef for ef in existing if ef.false_p] diff --git a/unittests/test_false_positive_history_logic.py b/unittests/test_false_positive_history_logic.py index 856705ec3fe..760dd4f24a0 100644 --- a/unittests/test_false_positive_history_logic.py +++ b/unittests/test_false_positive_history_logic.py @@ -144,6 +144,30 @@ def _passthrough_fp_candidates(finding, candidates): return candidates +# Candidates a hook supplies that the resolved list did not contain. Set by a test. +_fp_supplied_candidates: list = [] +# Contexts the context-aware hook was handed. Set by the hook, read by a test. +_fp_candidate_contexts: list = [] + + +def _supply_extra_fp_candidates(finding, candidates): + """ + Hook that adds a candidate the algorithm did not resolve. + + This is the shape a plugin uses to match on an identity FP history does not know about -- + the return value replaces the resolved list rather than being intersected with it. + """ + _fp_candidate_filter_calls.append((finding, list(candidates))) + return [*candidates, *_fp_supplied_candidates] + + +def _context_aware_fp_candidates(finding, candidates, context=None): + """Hook that accepts the scope context, and records it so a test can assert what it got.""" + _fp_candidate_filter_calls.append((finding, list(candidates))) + _fp_candidate_contexts.append(context) + return candidates + + @versioned_fixtures @override_settings(SETTINGS_CACHE_L1_TTL=30, SETTINGS_CACHE_L2_TTL=-1) class TestFalsePositiveHistoryLogic(DojoTestCase): @@ -235,6 +259,48 @@ def test_fp_history_hook_passthrough_matches_default(self): self.assert_finding(find_created_after_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) self.assertTrue(_fp_candidate_filter_calls) + @override_settings( + FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD="unittests.test_false_positive_history_logic._supply_extra_fp_candidates", + ) + def test_fp_history_hook_can_supply_candidates_the_algorithm_missed(self): + # The hook's return value replaces the resolved candidate list rather than being + # intersected with it, so a plugin can match on an identity this module does not know + # about. A finding whose hash_code matches nothing is marked because the hook supplied a + # false-positive candidate the algorithm never resolved. + _fp_candidate_filter_calls.clear() + _fp_supplied_candidates.clear() + + marked_fp = Finding.objects.get(id=2) + marked_fp.false_p = True + marked_fp.save() + _fp_supplied_candidates.append(marked_fp) + + unrelated, _ = self.copy_and_reset_finding(find_id=2) + unrelated.hash_code = "0" * 64 # matches no candidate the algorithm can resolve + unrelated.save() + + self.assertTrue(_fp_candidate_filter_calls, "the hook must have been invoked") + self.assert_finding(unrelated, false_p=True, not_pk=2, test_id=3, hash_code="0" * 64) + + @override_settings( + FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD="unittests.test_false_positive_history_logic._context_aware_fp_candidates", + ) + def test_fp_history_hook_receives_the_scope_context_when_it_accepts_one(self): + # A hook that adds candidates has to honor the product scope and the batch exclusion, so + # it is handed both rather than having to re-derive them. + _fp_candidate_filter_calls.clear() + _fp_candidate_contexts.clear() + + finding, _ = self.copy_and_reset_finding(find_id=2) + finding.save() + + self.assertTrue(_fp_candidate_contexts, "a context-accepting hook must be given a context") + context = _fp_candidate_contexts[0] + self.assertIsNotNone(context) + self.assertEqual(context.product, finding.test.engagement.product) + self.assertEqual(context.algorithm, finding.test.deduplication_algorithm) + self.assertIn(finding.id, context.excluded_finding_ids) + # Finding 2 in Product 2, Engagement 1, Test 3 def test_fp_history_equal_hash_code_same_test_non_retroactive(self): # Disable retroactive FP history From 32ca6e1a99a80eb6d91d51a0e544236c9930b5eb Mon Sep 17 00:00:00 2001 From: Greg Anderson <greg.anderson@owasp.org> Date: Tue, 4 Aug 2026 14:47:47 -0600 Subject: [PATCH 004/110] ci(migrations): bring the migration graph check to dev (#15504) Port of #15501, which landed on bugfix only. dev is where this has actually bitten: two changes each adding a migration on the same parent go green individually, because the fork exists only in their union, and nothing re-derives the graph after a merge. Until a release merge-back carries #15501 across, the branch that most needs the check is the one without it. Cherry-pick rather than reimplement, so the two lines stay byte-identical and the next merge-back is a no-op. Verified on this branch: the dojo graph is sound (199 migrations, 90 squashed out, single leaf 0288_backfill_vulnerability_id_entities) and all 22 tests pass. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --- .github/workflows/migration-graph.yml | 40 +++ ruff.toml | 4 + scripts/check_migration_leaves.py | 333 +++++++++++++++++++++ unittests/test_migration_leaves.py | 403 ++++++++++++++++++++++++++ 4 files changed, 780 insertions(+) create mode 100644 .github/workflows/migration-graph.yml create mode 100755 scripts/check_migration_leaves.py create mode 100644 unittests/test_migration_leaves.py diff --git a/.github/workflows/migration-graph.yml b/.github/workflows/migration-graph.yml new file mode 100644 index 00000000000..5a7a974e255 --- /dev/null +++ b/.github/workflows/migration-graph.yml @@ -0,0 +1,40 @@ +name: Migration Graph + +# Its own workflow rather than a job inside the lint workflow: this check goes +# red for a reason that has nothing to do with code style, and it goes red on a +# branch whose own commits are usually fine, so the name on the PR rollup needs +# to say what actually broke. +# +# Both triggers are load-bearing, because there are two ways to fork the graph +# and each trigger catches only one of them: +# +# pull_request - actions/checkout resolves a pull_request event to the merge +# commit, so this sees the branch *combined with* its base. That is the only +# way to catch a migration written against a base branch that had already +# moved on: the branch on its own has a single leaf and looks fine. +# +# push - catches the mirror case, where two PRs each add a migration on the +# same parent. Both are internally consistent so both go green pre-merge, +# and the fork exists only in their union. Nothing else re-derives the graph +# after a merge, since the test suite is per-PR. +# +# Without this check the failure mode is every container-based job dying at boot +# with ~120 red checks, none of which says "migration graph" in its name. + +on: + workflow_dispatch: + push: + pull_request: + +jobs: + migration-graph: + name: Migration Graph Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The script is stdlib-only (ast, no Django, no database, no settings), so + # it needs no setup beyond the checkout and finishes in well under a second. + - name: Check for multiple migration leaf nodes + run: python3 scripts/check_migration_leaves.py diff --git a/ruff.toml b/ruff.toml index 404931f9719..9d0809ebb90 100644 --- a/ruff.toml +++ b/ruff.toml @@ -129,6 +129,10 @@ preview = true "dojo/filters.py" = [ "A003", # ruff upgrade to 0.13.3 ] +"scripts/check_migration_leaves.py" = [ + "T201", # print statements are fine for console output scripts + "EXE001", # git won't commit the executable flag I don't know why +] "scripts/update_performance_test_counts.py" = [ "S603", # subprocess.run without shell=True is safe for this script "S604", # subprocess.run without shell=True is safe for this script diff --git a/scripts/check_migration_leaves.py b/scripts/check_migration_leaves.py new file mode 100755 index 00000000000..14c7081fea8 --- /dev/null +++ b/scripts/check_migration_leaves.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" +Fail when the Django migration graph has more than one leaf node in an app. + +``migrate`` refuses to apply *anything* while a graph has two leaves:: + + CommandError: Conflicting migrations detected; multiple leaf nodes in the + migration graph: (0281_fileupload_title_not_unique, + 0288_backfill_vulnerability_id_entities in dojo). + +Every fresh install and every CI database build off the branch is blocked until +someone re-parents one of them. There are two ways to arrive there, and the +workflow that calls this script watches for both: + +* Two changes each add a migration on top of the same parent. Each is + internally consistent, so both go green pre-merge; the fork exists only in + their union, and nothing re-derives the graph after a merge. +* One change adds a migration on top of a parent that is no longer the tip, + because the author's view of the base branch was stale. The branch alone + looks fine — the fork appears in the merge with the base. + +The failure is expensive out of proportion to the mistake: with no guard it +surfaces as every container-based job dying at boot, ~120 red checks whose logs +all have to be opened before one of them names the actual problem. + +The script is deliberately standalone: ``ast`` only, no Django import, no +database, no settings module, no third-party package. That is what lets it run +as a plain ``python3`` step with nothing but a checkout, in under a second. + +Usage:: + + python3 scripts/check_migration_leaves.py # checks dojo + python3 scripts/check_migration_leaves.py DIR... # checks DIRs +""" + +from __future__ import annotations + +import argparse +import ast +import sys +from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple + +if TYPE_CHECKING: + from collections.abc import Sequence + +# ``<repo>/scripts/`` -> ``<repo>/dojo/db_migrations``. ``dojo`` is the only +# Django app in this repo that ships migrations, and its migration modules live +# in ``db_migrations`` rather than the conventional ``migrations`` (see +# MIGRATION_MODULES in dojo/settings/settings.dist.py). +DEFAULT_MIGRATION_DIRS = (Path(__file__).resolve().parents[1] / "dojo" / "db_migrations",) + +# Django resolves these against another app's graph, never this one's. +CROSS_APP_SENTINELS = frozenset({"__first__", "__latest__"}) + +# ``migrations.swappable_dependency(settings.AUTH_USER_MODEL)`` resolves to whichever +# app owns the user model — never ``dojo`` — so it cannot affect an intra-app leaf +# count. It is common enough that reporting it as unreadable would be pure noise. +KNOWN_CROSS_APP_CALLS = frozenset({"swappable_dependency"}) + + +class Reference(NamedTuple): + + """An ``(app_label, migration_name)`` pair as written in a migration file.""" + + app_label: str + name: str + + +class ParsedMigration(NamedTuple): + + """The graph-relevant attributes of one migration module.""" + + name: str + path: Path + dependencies: tuple[Reference, ...] + run_before: tuple[Reference, ...] + replaces: tuple[Reference, ...] + # Entries we could not read as literal ``("app", "name")`` pairs — e.g. + # ``migrations.swappable_dependency(settings.AUTH_USER_MODEL)``, which is + # always cross-app and so never affects an intra-app leaf count. Reported + # only when the check fails, as context for an otherwise puzzling verdict. + opaque: tuple[str, ...] + + +class AppGraph(NamedTuple): + + """The intra-app migration graph derived from one migrations directory.""" + + app_label: str + directory: Path + migrations: dict[str, ParsedMigration] + children: dict[str, set[str]] + leaves: tuple[str, ...] + # ``("0281_x", "0280_missing")``: 0281 depends on a same-app migration that + # is not on disk. Django raises NodeNotFoundError for this, and it would + # also skew the leaf count here, so it is reported rather than ignored. + dangling: tuple[tuple[str, str], ...] + squashed_out: tuple[str, ...] + + +def _is_known_cross_app_call(node: ast.AST) -> bool: + """True for calls that always resolve to some other app's graph.""" + if not isinstance(node, ast.Call): + return False + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + return name in KNOWN_CROSS_APP_CALLS + + +def _literal_references(value: ast.AST) -> tuple[list[Reference], list[str]]: + """ + Read a ``[("app", "name"), ...]`` literal into references. + + Returns the pairs it could read plus a source snippet for every entry it + could not, so a dynamically built dependency never silently vanishes. + """ + if not isinstance(value, (ast.List, ast.Tuple, ast.Set)): + return [], [ast.unparse(value)] + + references: list[Reference] = [] + opaque: list[str] = [] + for element in value.elts: + if ( + isinstance(element, (ast.Tuple, ast.List)) + and len(element.elts) == 2 + and all(isinstance(part, ast.Constant) and isinstance(part.value, str) for part in element.elts) + ): + app_label, name = (part.value for part in element.elts) + references.append(Reference(app_label, name)) + elif not _is_known_cross_app_call(element): + opaque.append(ast.unparse(element)) + return references, opaque + + +def parse_migration_file(path: Path) -> ParsedMigration: + """ + Extract ``dependencies`` / ``run_before`` / ``replaces`` from one migration. + + Only assignments inside ``class Migration`` count, which is the only place + Django reads them from. Anything not spelled as a literal pair of strings is + recorded as opaque rather than guessed at. + """ + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + collected: dict[str, list[Reference]] = {"dependencies": [], "run_before": [], "replaces": []} + opaque: list[str] = [] + + for class_node in tree.body: + if not isinstance(class_node, ast.ClassDef) or class_node.name != "Migration": + continue + for statement in class_node.body: + if isinstance(statement, ast.Assign): + targets = [t.id for t in statement.targets if isinstance(t, ast.Name)] + elif isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name): + targets = [statement.target.id] + else: + continue + for target in targets: + if target not in collected or statement.value is None: + continue + references, unreadable = _literal_references(statement.value) + collected[target].extend(references) + opaque.extend(f"{target}: {snippet}" for snippet in unreadable) + + return ParsedMigration( + name=path.stem, + path=path, + dependencies=tuple(collected["dependencies"]), + run_before=tuple(collected["run_before"]), + replaces=tuple(collected["replaces"]), + opaque=tuple(opaque), + ) + + +def _same_app(references: Sequence[Reference], app_label: str) -> list[str]: + """Names from ``references`` that point at ``app_label`` itself.""" + return [ref.name for ref in references if ref.app_label == app_label and ref.name not in CROSS_APP_SENTINELS] + + +def build_app_graph(migrations_dir: Path, app_label: str | None = None) -> AppGraph: + """ + Build the intra-app graph for one migrations directory. + + Cross-app edges are dropped on purpose: they cannot change how many leaves + *this* app has, and following them would mean loading every other app. + """ + if app_label is None: + # ``.../dojo/db_migrations`` -> ``dojo``, matching how Django labels the app. + app_label = migrations_dir.resolve().parent.name + + migrations: dict[str, ParsedMigration] = {} + for path in sorted(migrations_dir.glob("*.py")): + if path.name == "__init__.py": + continue + parsed = parse_migration_file(path) + migrations[parsed.name] = parsed + + # A squashed migration takes the place of the ones it replaces, so the + # replaced nodes leave the graph and references to them land on the squash. + # This mirrors the fresh-install path, which is what the guard protects. + replaced_by: dict[str, str] = {} + for name, parsed in migrations.items(): + for superseded in _same_app(parsed.replaces, app_label): + if superseded in migrations: + replaced_by[superseded] = name + for superseded in replaced_by: + migrations.pop(superseded, None) + + def resolve(name: str) -> str: + return replaced_by.get(name, name) + + children: dict[str, set[str]] = {name: set() for name in migrations} + dangling: list[tuple[str, str]] = [] + + for name, parsed in migrations.items(): + # ``dependencies`` points at parents: the parent gains this node as a child. + for parent in map(resolve, _same_app(parsed.dependencies, app_label)): + if parent in children: + children[parent].add(name) + elif parent != name: + dangling.append((name, parent)) + # ``run_before`` points the other way: this node becomes the target's parent. + for successor in map(resolve, _same_app(parsed.run_before, app_label)): + if successor in children: + children[name].add(successor) + elif successor != name: + dangling.append((name, successor)) + + leaves = tuple(sorted(name for name, kids in children.items() if not kids)) + return AppGraph( + app_label=app_label, + directory=migrations_dir, + migrations=migrations, + children=children, + leaves=leaves, + dangling=tuple(sorted(set(dangling))), + squashed_out=tuple(sorted(replaced_by)), + ) + + +def describe_problems(graph: AppGraph) -> list[str]: + """Human-readable problems with ``graph``; empty when the graph is sound.""" + problems: list[str] = [] + + if not graph.migrations: + problems.append(f"{graph.directory}: no migrations found (wrong path?)") + return problems + + if len(graph.leaves) > 1: + joined = ", ".join(graph.leaves) + lines = [ + ( + "Conflicting migrations detected; multiple leaf nodes in the migration graph: " + f"({joined} in {graph.app_label})." + ), + "", + " `migrate` refuses to apply anything at all in this state, so every fresh", + " install and every CI database build off this branch is broken.", + "", + " Either two changes added a migration on the same parent, or one was written", + " against a base branch that had already moved on. Fix it by re-parenting the", + " later migration onto the other leaf and renumbering it, or by adding an empty", + " merge migration that depends on both leaves.", + "", + " Before renumbering, confirm what the base branch actually holds: a stale", + " remote-tracking ref is a common way to pick a number that is already taken.", + "", + " Leaves and their declared parents:", + ] + for leaf in graph.leaves: + parents = _same_app(graph.migrations[leaf].dependencies, graph.app_label) or ["(none — root)"] + lines.append(f" {leaf} <- {', '.join(parents)}") + problems.append("\n".join(lines)) + + for child, missing in graph.dangling: + problems.append( + f"{graph.app_label}.{child} depends on {graph.app_label}.{missing}, " + f"which is not in {graph.directory} (Django raises NodeNotFoundError).", + ) + + if problems: + opaque = [f" {parsed.name}: {snippet}" for parsed in graph.migrations.values() for snippet in parsed.opaque] + if opaque: + problems.append( + 'Note: these entries were not literal ("app", "name") pairs and were skipped.\n' + "They are normally cross-app (e.g. swappable_dependency) and harmless, but if a\n" + "verdict above looks wrong, start here:\n" + "\n".join(sorted(opaque)), + ) + + return problems + + +def check_migration_dirs(directories: Sequence[Path]) -> list[str]: + """Collect problems across every directory; empty means everything is sound.""" + problems: list[str] = [] + for directory in directories: + if not directory.is_dir(): + problems.append(f"{directory}: not a directory") + continue + problems.extend(describe_problems(build_app_graph(directory))) + return problems + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) + parser.add_argument( + "migrations_dirs", + nargs="*", + type=Path, + help="migrations directories to check (default: the dojo app)", + ) + args = parser.parse_args(argv) + directories = tuple(args.migrations_dirs) or DEFAULT_MIGRATION_DIRS + + problems = check_migration_dirs(directories) + if problems: + print("Migration graph check FAILED\n", file=sys.stderr) + for problem in problems: + print(problem, file=sys.stderr) + print(file=sys.stderr) + return 1 + + for directory in directories: + graph = build_app_graph(directory) + squashed = f", {len(graph.squashed_out)} squashed out" if graph.squashed_out else "" + print( + f"{graph.app_label}: {len(graph.migrations)} migrations{squashed}, single leaf {graph.leaves[0]}", + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/unittests/test_migration_leaves.py b/unittests/test_migration_leaves.py new file mode 100644 index 00000000000..df1c3e7700f --- /dev/null +++ b/unittests/test_migration_leaves.py @@ -0,0 +1,403 @@ +""" +Tests for ``scripts/check_migration_leaves.py``, the migration-graph CI guard. + +The script under test is deliberately Django-free so it can run as a bare +``python3`` step, so these tests import it by path and drive it with generated +fixture apps rather than going through the Django test machinery. Only the last +test touches the real ``dojo`` graph. +""" + +import contextlib +import importlib.util +import io +import tempfile +import unittest +from pathlib import Path + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "check_migration_leaves.py" + +_spec = importlib.util.spec_from_file_location("check_migration_leaves", SCRIPT_PATH) +check_migration_leaves = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(check_migration_leaves) + +MIGRATION_TEMPLATE = """\ +from django.db import migrations + + +class Migration(migrations.Migration): +{attributes} + operations = [] +""" + + +class MigrationGraphFixtureMixin(unittest.TestCase): + + """Builds throwaway ``<tmp>/<app_label>/db_migrations/`` trees to parse.""" + + def setUp(self): + super().setUp() + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.root = Path(self._tmp.name) + + def make_app(self, migrations_spec, app_label="dojo", dir_name="db_migrations"): + """ + Write one migration module per entry and return the migrations dir. + + ``migrations_spec`` maps a migration name to the source of the class + attributes it declares, e.g. + ``{"0002_b": 'dependencies = [("dojo", "0001_a")]'}``. + """ + migrations_dir = self.root / app_label / dir_name + migrations_dir.mkdir(parents=True, exist_ok=True) + (migrations_dir / "__init__.py").write_text("") + for name, attributes in migrations_spec.items(): + body = "\n".join(f" {line}" for line in attributes.splitlines()) + "\n" if attributes else "" + (migrations_dir / f"{name}.py").write_text(MIGRATION_TEMPLATE.format(attributes=body)) + return migrations_dir + + +class TestLeafDetection(MigrationGraphFixtureMixin): + def test_linear_chain_has_a_single_leaf(self): + migrations_dir = self.make_app( + { + "0001_initial": "", + "0002_second": 'dependencies = [("dojo", "0001_initial")]', + "0003_third": 'dependencies = [("dojo", "0002_second")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.app_label, "dojo") + self.assertEqual(graph.leaves, ("0003_third",)) + self.assertEqual(check_migration_leaves.describe_problems(graph), []) + + def test_two_changes_on_the_same_parent_report_both_leaves(self): + """One of the two ways to fork the graph: concurrent PRs off one parent.""" + migrations_dir = self.make_app( + { + "0287_vulnerability_id_entity_tables": "", + "0288_left": 'dependencies = [("dojo", "0287_vulnerability_id_entity_tables")]', + "0288_right": 'dependencies = [("dojo", "0287_vulnerability_id_entity_tables")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.leaves, ("0288_left", "0288_right")) + problems = check_migration_leaves.describe_problems(graph) + self.assertEqual(len(problems), 1) + # The offending node names must be in the message -- that is what makes + # the CI failure actionable without checking anything out. + self.assertIn("0288_left", problems[0]) + self.assertIn("0288_right", problems[0]) + self.assertIn("multiple leaf nodes", problems[0]) + + def test_migration_written_against_a_stale_base_is_caught(self): + """ + The other way to fork it, and the case the pull_request trigger exists for. + + A migration parented on what the author believed was the tip, while the + base had already advanced several migrations past it. Reproduces the real + instance from PR #15498, where 0281 was written on top of 0280 after + 0281-0288 had already landed on the base branch. + """ + spec = {"0280_vulnerability_id_upper_index": ""} + previous = "0280_vulnerability_id_upper_index" + for name in ( + "0281_vulnerability_id_type", + "0282_backfill_vulnerability_id_type", + "0283_unique_finding_vulnerability_id", + "0284_finding_cwe", + "0285_backfill_finding_cwe", + "0286_cicd_infrastructure", + "0287_vulnerability_id_entity_tables", + "0288_backfill_vulnerability_id_entities", + ): + spec[name] = f'dependencies = [("dojo", "{previous}")]' + previous = name + # The stale-base migration: numbered 0281, parented on 0280. + spec["0281_fileupload_title_not_unique"] = 'dependencies = [("dojo", "0280_vulnerability_id_upper_index")]' + + graph = check_migration_leaves.build_app_graph(self.make_app(spec)) + + self.assertEqual( + graph.leaves, + ("0281_fileupload_title_not_unique", "0288_backfill_vulnerability_id_entities"), + ) + problems = check_migration_leaves.describe_problems(graph) + self.assertEqual(len(problems), 1) + # Each leaf is printed with its declared parent, which is what makes the + # "written against a stale base" shape recognisable at a glance. + self.assertIn("0281_fileupload_title_not_unique <- 0280_vulnerability_id_upper_index", problems[0]) + + def test_re_parenting_the_later_migration_resolves_the_fork(self): + """The fix applied to #15498: renumber onto the real tip.""" + migrations_dir = self.make_app( + { + "0287_vulnerability_id_entity_tables": "", + "0288_backfill_vulnerability_id_entities": ( + 'dependencies = [("dojo", "0287_vulnerability_id_entity_tables")]' + ), + "0289_fileupload_title_not_unique": ( + 'dependencies = [("dojo", "0288_backfill_vulnerability_id_entities")]' + ), + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.leaves, ("0289_fileupload_title_not_unique",)) + self.assertEqual(check_migration_leaves.describe_problems(graph), []) + + def test_empty_merge_migration_resolves_the_fork(self): + """The other accepted fix.""" + migrations_dir = self.make_app( + { + "0001_initial": "", + "0002_left": 'dependencies = [("dojo", "0001_initial")]', + "0003_right": 'dependencies = [("dojo", "0001_initial")]', + "0004_merge": 'dependencies = [("dojo", "0002_left"), ("dojo", "0003_right")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.leaves, ("0004_merge",)) + self.assertEqual(check_migration_leaves.describe_problems(graph), []) + + def test_three_way_fork_lists_every_leaf(self): + migrations_dir = self.make_app( + { + "0001_initial": "", + "0002_a": 'dependencies = [("dojo", "0001_initial")]', + "0002_b": 'dependencies = [("dojo", "0001_initial")]', + "0002_c": 'dependencies = [("dojo", "0001_initial")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.leaves, ("0002_a", "0002_b", "0002_c")) + + +class TestGraphEdgeCases(MigrationGraphFixtureMixin): + def test_cross_app_dependencies_are_ignored(self): + """A dep on another app neither creates an edge nor counts as dangling.""" + migrations_dir = self.make_app( + { + "0001_initial": 'dependencies = [("auth", "0001_initial"), ("contenttypes", "0002_remove")]', + "0002_second": 'dependencies = [("dojo", "0001_initial"), ("pghistory", "0007_auto")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.leaves, ("0002_second",)) + self.assertEqual(graph.dangling, ()) + + def test_cross_app_sentinels_are_ignored(self): + migrations_dir = self.make_app( + { + "0001_initial": 'dependencies = [("auth", "__first__")]', + "0002_second": 'dependencies = [("dojo", "0001_initial"), ("auth", "__latest__")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.leaves, ("0002_second",)) + self.assertEqual(graph.dangling, ()) + + def test_swappable_dependency_is_not_reported_as_unreadable(self): + """It is always cross-app, and flagging it would bury the real message.""" + migrations_dir = self.make_app( + { + "0001_initial": ( + "dependencies = [\n" + ' ("contenttypes", "0002_remove"),\n' + " migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n" + " ]" + ), + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.migrations["0001_initial"].opaque, ()) + self.assertEqual(graph.leaves, ("0001_initial",)) + + def test_run_before_creates_a_forward_edge(self): + """``run_before`` points the opposite way to ``dependencies``.""" + migrations_dir = self.make_app( + { + "0001_initial": "", + "0002_early": ('dependencies = [("dojo", "0001_initial")]\nrun_before = [("dojo", "0003_late")]'), + "0003_late": 'dependencies = [("dojo", "0001_initial")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + # Without the run_before edge this would look like a two-leaf fork. + self.assertEqual(graph.leaves, ("0003_late",)) + self.assertEqual(check_migration_leaves.describe_problems(graph), []) + + def test_squashed_migrations_leave_the_graph_and_references_rewire(self): + migrations_dir = self.make_app( + { + "0001_initial": "", + "0002_old_a": 'dependencies = [("dojo", "0001_initial")]', + "0003_old_b": 'dependencies = [("dojo", "0002_old_a")]', + "0003_squashed": ( + 'dependencies = [("dojo", "0001_initial")]\n' + 'replaces = [("dojo", "0002_old_a"), ("dojo", "0003_old_b")]' + ), + "0004_after": 'dependencies = [("dojo", "0003_old_b")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.squashed_out, ("0002_old_a", "0003_old_b")) + self.assertNotIn("0002_old_a", graph.migrations) + # 0004's dep on the replaced 0003_old_b must land on the squash, not dangle. + self.assertEqual(graph.dangling, ()) + self.assertEqual(graph.leaves, ("0004_after",)) + + def test_dependency_on_a_missing_same_app_migration_is_reported(self): + migrations_dir = self.make_app( + { + "0001_initial": "", + "0002_second": 'dependencies = [("dojo", "0001_typo_never_existed")]', + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.dangling, (("0002_second", "0001_typo_never_existed"),)) + problems = check_migration_leaves.describe_problems(graph) + self.assertTrue(any("0001_typo_never_existed" in problem for problem in problems)) + + def test_dynamically_built_dependencies_are_surfaced_not_silently_dropped(self): + migrations_dir = self.make_app( + { + "0001_initial": "", + "0002_second": "dependencies = DEPENDENCIES_FROM_A_CONSTANT", + }, + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.migrations["0002_second"].opaque, ("dependencies: DEPENDENCIES_FROM_A_CONSTANT",)) + # It reads as a second root, so the guard fails loudly rather than + # quietly assuming the unparsed value was harmless. + self.assertEqual(graph.leaves, ("0001_initial", "0002_second")) + problems = check_migration_leaves.describe_problems(graph) + self.assertTrue(any("DEPENDENCIES_FROM_A_CONSTANT" in problem for problem in problems)) + + def test_attributes_outside_the_migration_class_are_ignored(self): + """Only ``class Migration`` counts -- prose and helpers must not create edges.""" + migrations_dir = self.make_app( + {"0001_initial": "", "0002_second": 'dependencies = [("dojo", "0001_initial")]'}, + ) + (migrations_dir / "0003_third.py").write_text( + '"""A docstring that mentions dependencies and run_before."""\n' + "\n" + "from django.db import migrations\n" + "\n" + 'dependencies = [("dojo", "0099_not_real")]\n' + "\n" + "\n" + "class NotTheMigration:\n" + ' dependencies = [("dojo", "0098_also_not_real")]\n' + "\n" + "\n" + "class Migration(migrations.Migration):\n" + ' dependencies = [("dojo", "0002_second")]\n' + "\n" + " operations = []\n", + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.leaves, ("0003_third",)) + self.assertEqual(graph.dangling, ()) + + def test_app_label_comes_from_the_parent_of_db_migrations(self): + """This repo keeps migrations in ``dojo/db_migrations``, not ``dojo/migrations``.""" + migrations_dir = self.make_app( + {"0001_initial": "", "0002_second": 'dependencies = [("dojo", "0001_initial")]'}, + ) + + self.assertEqual(migrations_dir.name, "db_migrations") + graph = check_migration_leaves.build_app_graph(migrations_dir) + self.assertEqual(graph.app_label, "dojo") + self.assertEqual(graph.leaves, ("0002_second",)) + + def test_app_label_is_derived_per_directory(self): + migrations_dir = self.make_app( + {"0001_initial": "", "0002_second": 'dependencies = [("otherapp", "0001_initial")]'}, + app_label="otherapp", + ) + graph = check_migration_leaves.build_app_graph(migrations_dir) + + self.assertEqual(graph.app_label, "otherapp") + self.assertEqual(graph.leaves, ("0002_second",)) + + +class TestCommandLineInterface(MigrationGraphFixtureMixin): + def run_main(self, *argv): + """Invoke ``main`` with its output captured; returns (exit_code, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + exit_code = check_migration_leaves.main(list(argv)) + return exit_code, out.getvalue(), err.getvalue() + + def test_exits_zero_on_a_sound_graph(self): + migrations_dir = self.make_app( + {"0001_initial": "", "0002_second": 'dependencies = [("dojo", "0001_initial")]'}, + ) + exit_code, stdout, _ = self.run_main(str(migrations_dir)) + + self.assertEqual(exit_code, 0) + self.assertIn("single leaf 0002_second", stdout) + + def test_exits_non_zero_on_a_forked_graph(self): + migrations_dir = self.make_app( + { + "0001_initial": "", + "0002_a": 'dependencies = [("dojo", "0001_initial")]', + "0002_b": 'dependencies = [("dojo", "0001_initial")]', + }, + ) + exit_code, _, stderr = self.run_main(str(migrations_dir)) + + self.assertEqual(exit_code, 1) + # Failure detail belongs on stderr, and must name both offenders. + self.assertIn("0002_a", stderr) + self.assertIn("0002_b", stderr) + + def test_exits_non_zero_when_the_directory_is_missing(self): + """A typo'd path must not read as a clean bill of health.""" + exit_code, _, _ = self.run_main(str(self.root / "nope")) + + self.assertEqual(exit_code, 1) + + def test_exits_non_zero_on_an_empty_migrations_directory(self): + empty = self.root / "emptyapp" / "db_migrations" + empty.mkdir(parents=True) + (empty / "__init__.py").write_text("") + exit_code, _, _ = self.run_main(str(empty)) + + self.assertEqual(exit_code, 1) + + def test_defaults_to_the_dojo_app_when_given_no_arguments(self): + """The bare invocation CI uses must resolve to dojo without a cwd assumption.""" + self.assertEqual( + check_migration_leaves.DEFAULT_MIGRATION_DIRS, + (Path(check_migration_leaves.__file__).resolve().parents[1] / "dojo" / "db_migrations",), + ) + + +class TestRealDojoGraph(unittest.TestCase): + + """The point of the whole exercise: this repo's own graph must be sound.""" + + def test_dojo_migrations_have_exactly_one_leaf(self): + graph = check_migration_leaves.build_app_graph(check_migration_leaves.DEFAULT_MIGRATION_DIRS[0]) + + self.assertEqual(graph.app_label, "dojo") + self.assertEqual( + len(graph.leaves), 1, + f"dojo has {len(graph.leaves)} migration leaves: {', '.join(graph.leaves)}", + ) + self.assertEqual(check_migration_leaves.describe_problems(graph), []) From fdd653808ff48fd8e63e8b37ed09ce3dc21f2722 Mon Sep 17 00:00:00 2001 From: Greg Anderson <greg.anderson@owasp.org> Date: Wed, 5 Aug 2026 09:31:28 -0600 Subject: [PATCH 005/110] refactor(finding): extract save()'s field derivation so batched writers can reuse it (#15489) * refactor(finding): extract save()'s field derivation so batched writers can reuse it Finding.save() derives the finding's own columns -- title casing/truncation, blank component normalization, the date default, numerical_severity, CVSS v3/v4 parsing, and the same-tool hash -- inline, mixed in with work that needs a primary key. Only the former is meaningful to a caller writing rows in bulk. bulk_create and bulk_update bypass save() and its signals entirely, so a batched writer must either reimplement that derivation or write rows that differ from every other finding: wrong casing, no numerical_severity, an unparsed CVSS vector, no hash. Reimplementing it has already cost us once. A downstream hash re-derived only the title truncation and omitted titlecase(); because titlecase() also collapses whitespace, the pre-save lookup hash and the stored hash diverged for any multi-line title, so reports with an embedded newline pair matched nothing on reimport and were closed and recreated on every run. Adds Finding.persisted_title() as the single source of truth for the title transform (reading max_length rather than hardcoding 511) and Finding.derive_persisted_fields(), which is the existing block moved verbatim and called by save() at the same point. Fields needing a PK stay in save(): a batched writer needs a set-based implementation of those, not a shared one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(finding): fold the new-finding static/dynamic derivation into the shared method The flags a new finding gets from file_path plus its parser-attached locations are derived in memory -- no row required -- so they belong with the rest of the derivation a batched writer needs, not in save()'s body where a bulk path would have to duplicate them. The equivalent branch for an existing finding queries self.locations/endpoints and stays in save(). save() passes is_new_finding through, so its behavior is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(finding): put the new docstring summaries on the second line Upstream ruff enforces D213 (multi-line-summary-second-line); both new docstrings used the summary-on-first-line form. Caught by CI rather than locally: this repo's ruff.toml pins a rule selector my local ruff rejects outright, so the config cannot be loaded here and the whole file lints as unrunnable rather than clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Greg Anderson <greg@Gregs-MacBook-Pro.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --- dojo/finding/models.py | 76 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/dojo/finding/models.py b/dojo/finding/models.py index 12287002642..4379590477c 100644 --- a/dojo/finding/models.py +++ b/dojo/finding/models.py @@ -555,21 +555,49 @@ def __init__(self, *args, **kwargs): def __str__(self): return self.title - def save(self, dedupe_option=True, rules_option=True, product_grading_option=True, # noqa: FBT002 - issue_updater_option=True, push_to_jira=False, user=None, *args, **kwargs): # noqa: FBT002 - this is bit hard to fix nice have this universally fixed - logger.debug("Start saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") - from dojo.finding import helper as finding_helper # noqa: PLC0415 -- lazy import, avoids circular dependency - - is_new_finding = self.pk is None - - # if not isinstance(self.date, (datetime, date)): - # raise ValidationError(_("The 'date' field must be a valid date or datetime object.")) - - if not user: - from dojo.utils import get_current_user # noqa: PLC0415 -- lazy import, avoids circular dependency - user = get_current_user() + @classmethod + def persisted_title(cls, title: str | None) -> str: + """ + Return a title in the exact form a persisted finding carries. + + The single source of truth for the title transform. Anything that needs to know + what a title *will* look like once stored -- notably a hash computed before the + row is written, which must match the hash computed after -- calls this instead of + repeating the transform. + + Note that ``titlecase()`` is not merely a case change: it also normalizes + whitespace, collapsing consecutive newlines and turning tabs into spaces. A + reimplementation that truncated but did not titlecase therefore diverged for any + multi-line title, which is a bug that has already been paid for once. + """ + return titlecase((title or "")[:cls._meta.get_field("title").max_length]) + + def derive_persisted_fields(self, *, dedupe_option: bool = True, is_new_finding: bool = False) -> None: + """ + Normalize and derive the fields that must hold for any persisted finding. + + This is the transform ``save()`` applies to a finding's own columns before the row + is written: title casing/truncation, blank-component normalization, the date + default, numerical severity, CVSS vector parsing, and the same-tool hash. It reads + configuration but performs no writes, touches no relations, and dispatches nothing, + so it is safe to call on an unsaved instance and on many instances in a loop. + + It exists as a separate method so that batched writers -- anything using + ``bulk_create``/``bulk_update``, which bypass ``save()`` and its signals entirely -- + can produce rows identical to the ones ``save()`` produces, by calling this rather + than reimplementing it. A second copy of this logic is not a hypothetical risk: Pro's + reimport hashing previously re-derived only the title truncation, and the missing + ``titlecase()`` (which also collapses whitespace) made the pre-save lookup hash and + the stored hash diverge for any multi-line title, so those findings were closed and + recreated on every single reimport. + + Anything requiring a primary key -- ``found_by``, location/endpoint queries, SLA + expiry, status bookkeeping, post-save dispatch -- deliberately stays in ``save()``, + because a batched writer needs a genuinely different (set-based) implementation of + those rather than a shared one. + """ # Title Casing - self.title = titlecase(self.title[:511]) + self.title = Finding.persisted_title(self.title) # Normalize blank component fields to NULL so that findings without a component # group together. An empty string is treated as a distinct value from NULL by the # database, which would otherwise produce a separate "None" component group (SC-13073). @@ -615,6 +643,10 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru self.set_hash_code(dedupe_option) if is_new_finding: + # A new finding's static/dynamic flags come from file_path plus the locations + # the parser attached, both of which are in memory -- no row required. The + # equivalent branch for an *existing* finding queries self.locations/endpoints + # and so stays in save(). if settings.V3_FEATURE_LOCATIONS: if (self.file_path is not None) and (len(self.unsaved_locations) == 0): self.static_finding = True @@ -628,6 +660,22 @@ def save(self, dedupe_option=True, rules_option=True, product_grading_option=Tru elif (self.file_path is not None): self.static_finding = True + def save(self, dedupe_option=True, rules_option=True, product_grading_option=True, # noqa: FBT002 + issue_updater_option=True, push_to_jira=False, user=None, *args, **kwargs): # noqa: FBT002 - this is bit hard to fix nice have this universally fixed + logger.debug("Start saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") + from dojo.finding import helper as finding_helper # noqa: PLC0415 -- lazy import, avoids circular dependency + + is_new_finding = self.pk is None + + # if not isinstance(self.date, (datetime, date)): + # raise ValidationError(_("The 'date' field must be a valid date or datetime object.")) + + if not user: + from dojo.utils import get_current_user # noqa: PLC0415 -- lazy import, avoids circular dependency + user = get_current_user() + self.derive_persisted_fields(dedupe_option=dedupe_option, is_new_finding=is_new_finding) + + if is_new_finding: # because we have reduced the number of (super()).save() calls, the helper is no longer called for new findings # so we call it manually finding_helper.update_finding_status(self, user, changed_fields={"id": (None, None)}) From 98c39d3ed2ff3630b096c27aa7c6c9e65f64c2b9 Mon Sep 17 00:00:00 2001 From: Greg Anderson <greg.anderson@owasp.org> Date: Wed, 5 Aug 2026 16:16:34 -0600 Subject: [PATCH 006/110] ci: port the bugfix CI stack to dev (#15549) dev still runs the CI this repository had a week ago: the 113-job per-file Selenium matrix, serial unit tests that migrate from scratch, no snapshot restore, no grouping, no tiers, no merge_group triggers, no gate context. Every pull request against dev pays roughly 565 runner-minutes and queue-bound wall clock that bugfix stopped paying days ago -- and the gap would persist until a release merge-back happens to carry the workflow files across. This ports the CI stack directly: fifteen files, each checked out verbatim from bugfix and verified blob-identical to it, so the next release merge-back reduces to a no-op for all of them. It is the union of what landed on bugfix as #15505 (parallel unit phase + in-process celery broker + tblib), #15507 (migrated-database snapshot restore + warm workflow), #15521 (merge_group triggers + the Unit Tests Complete gate), #15522 (Docker Hub pull retry), #15524 (grouped Selenium matrix + sequential-group entrypoint + the async-test product cleanup and paged finding purge), #15528 (two-tier matrices), #15529 (push-trigger scoping + the makemigrations heredoc that misdiagnosed forked graphs), and #15545 (the merge-queue documentation comment). Divergence check before copying, per file, against the merge-base: dev had not modified any of the fifteen since the branches diverged, except migration-graph.yml, whose only difference is that dev's copy (from #15504) predates the push-trigger scoping -- bugfix's copy is that file plus the scoping, so the verbatim copy is the correct merge there too. What changes for dev pull requests: the light tier (23 jobs, ~130 runner-minutes) replaces the old full fan-out on every push, and the "Unit Tests Complete" context starts reporting. What does not change: the merge queue is NOT enabled on dev -- ruleset 20466211 targets bugfix only, so merge_group stays inert here exactly as it was on bugfix before the ruleset, and merging on dev works as before. Extending the queue to dev is a one-line ruleset change to make deliberately, after it has soaked on bugfix. After this merges, dispatch "CI: Warm Caches" on the dev ref once so the snapshot exists in dev's cache scope; until then the first pull requests pay one cold migrate-and-save, which is the designed fallback. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --- .github/workflows/ci-warm-caches.yml | 142 ++++++++++++++++++++ .github/workflows/integration-tests.yml | 133 ++++++++++-------- .github/workflows/migration-graph.yml | 11 ++ .github/workflows/rest-framework-tests.yml | 83 ++++++++++++ .github/workflows/ruff.yml | 19 +++ .github/workflows/unit-tests.yml | 87 +++++++++++- docker-compose.override.unit_tests_cicd.yml | 20 ++- docker/entrypoint-integration-tests.sh | 28 +++- docker/entrypoint-unit-tests-devDocker.sh | 18 ++- docker/entrypoint-unit-tests.sh | 71 +++++++++- manage.py | 20 +++ requirements-dev.txt | 5 + tests/close_old_findings_dedupe_test.py | 39 +++--- tests/close_old_findings_test.py | 39 +++--- tests/dedupe_test.py | 14 ++ 15 files changed, 619 insertions(+), 110 deletions(-) create mode 100644 .github/workflows/ci-warm-caches.yml diff --git a/.github/workflows/ci-warm-caches.yml b/.github/workflows/ci-warm-caches.yml new file mode 100644 index 00000000000..fcc2dccec9a --- /dev/null +++ b/.github/workflows/ci-warm-caches.yml @@ -0,0 +1,142 @@ +name: "CI: Warm Caches" + +# Why this workflow exists at all. +# +# GitHub scopes every Actions cache entry to the ref that saved it. A run can +# restore entries saved by its own ref, by its base branch (pull_request events +# only), and by the repository's default branch -- nothing else. So a pull +# request cannot leave behind a cache that any *other* pull request can use: it +# saves into its own PR scope, and that scope dies with the PR. +# +# Without a workflow like this one, caching the migrated database would therefore +# do nothing at all for the first push of a new branch, which is most branches +# most of the time. Something has to save an entry into a scope pull requests are +# allowed to read, and only a run triggered from a release line can do that. +# +# Note what is deliberately absent: a `schedule:` trigger. Scheduled workflows +# only ever run against the default branch, so a cron here would run with ref +# refs/heads/master and warm the master scope -- not the release-line scopes this +# is for. Eviction is handled instead by reads: GitHub drops an entry it has not +# seen used for 7 days, and every pull request based on a release line reads this +# one, which keeps it alive without a timer. +# +# Warming the default-branch scope is worth doing too, since every run can read +# it regardless of base, but that needs a workflow file on master and is a +# separate change. + +on: + push: + branches: + - bugfix + - dev + # Exactly the paths the snapshot key hashes -- anything else leaves the key + # unchanged, so there would be nothing to warm. + paths: + - 'requirements.txt' + - 'dojo/db_migrations/**' + - 'dojo/models.py' + - 'dojo/base_models/**' + - 'dojo/pghistory_models.py' + - 'dojo/settings/settings.dist.py' + - 'docker-compose.yml' + - 'docker-compose.override.unit_tests_cicd.yml' + - 'docker/entrypoint-unit-tests.sh' + - '.github/workflows/ci-warm-caches.yml' + workflow_dispatch: + +concurrency: + # Two pushes in quick succession would warm the same key twice. + group: warm-caches-${{ github.ref }} + cancel-in-progress: true + +jobs: + warm-migrated-db: + name: Warm Migrated Database Snapshot + # A fork has its own cache scope and its own billing, so there is nothing + # here for it to warm. Same guard as release-nightly-dev.yml. + if: github.repository == 'DefectDojo/django-DefectDojo' + runs-on: ubuntu-latest + env: + DJANGO_VERSION: debian + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Computed once, before anything runs Python, and referenced by both the + # probe and the save. Not written inline in both places: hashFiles is + # evaluated per step, and `dojo/db_migrations/**` picks up the + # __pycache__/*.pyc that migrating writes into the bind-mounted workspace, + # so the save would land under a key the probe will never ask for. + # + # KEEP IN SYNC with the same block in rest-framework-tests.yml. If they + # drift, this job warms an entry nothing reads and every pull request goes + # on paying the full migrate, with nothing failing to say so. + - name: Compute migrated database snapshot key + id: db-key + run: echo "key=migrated-db-v1-${{ hashFiles('requirements.txt', 'dojo/db_migrations/**', 'dojo/models.py', 'dojo/base_models/**', 'dojo/pghistory_models.py', 'dojo/settings/settings.dist.py', 'docker-compose.yml', 'docker-compose.override.unit_tests_cicd.yml', 'docker/entrypoint-unit-tests.sh') }}" >> "$GITHUB_OUTPUT" + + - name: Probe for an existing snapshot + id: probe + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: migrated-db.dump + key: ${{ steps.db-key.outputs.key }} + # Do not download it -- whether the key exists is the whole question, + # and on most runs the answer is usually yes. + lookup-only: true + + - name: Set unit-test mode + if: steps.probe.outputs.cache-hit != 'true' + run: docker/setEnv.sh unit_tests_cicd + + # Built here rather than pulled from the build job's artifacts: this needs + # one image on one platform, and building it directly keeps the job + # self-contained instead of coupling it to that job's upload. + - name: Build the unit-test image + if: steps.probe.outputs.cache-hit != 'true' + timeout-minutes: 30 + run: docker compose build uwsgi + + - name: Start Postgres + if: steps.probe.outputs.cache-hit != 'true' + run: docker compose up --no-deps -d postgres + + - name: Wait for Postgres to accept connections + if: steps.probe.outputs.cache-hit != 'true' + run: | + for _ in $(seq 1 60); do + if docker compose exec -T postgres pg_isready -q -U defectdojo -d test_defectdojo; then + exit 0 + fi + sleep 2 + done + echo "Postgres did not become ready in 120s" >&2 + exit 1 + + - name: Migrate and dump + if: steps.probe.outputs.cache-hit != 'true' + timeout-minutes: 20 + run: | + docker compose run --rm -T --no-deps uwsgi + docker compose exec -T postgres pg_dump \ + --username defectdojo --dbname test_defectdojo --format=custom > migrated-db.dump + ls -lh migrated-db.dump + env: + DD_TEST_DB_MODE: prepare + + - name: Save the snapshot + if: steps.probe.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: migrated-db.dump + key: ${{ steps.db-key.outputs.key }} + + - name: Logs + if: failure() + run: docker compose logs --tail="2500" + + - name: Shutdown + if: always() + run: docker compose down diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index d5a3ab33144..f1305ea158d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -12,68 +12,48 @@ jobs: matrix: test-case: [ "openapi-validatator", - "tests/action_history_test.py", - "tests/alerts_test.py", - "tests/announcement_banner_test.py", - "tests/banner_test.py", - "tests/base_test_class.py", - "tests/benchmark_test.py", - "tests/calendar_test.py", - "tests/check_various_pages.py", - "tests/close_old_findings_dedupe_test.py", - "tests/close_old_findings_test.py", - "tests/dashboard_test.py", - "tests/dedupe_test.py", - "tests/endpoint_extended_test.py", + # endpoint_test.py stays alone so the v3 exclude below still matches it. "tests/endpoint_test.py", - "tests/engagement_checklist_test.py", - "tests/engagement_export_test.py", - "tests/engagement_extended_test.py", - "tests/engagement_presets_test.py", - "tests/engagement_test.py", - "tests/environment_test.py", - "tests/false_positive_history_test.py", - "tests/file_test.py", - "tests/finding_extended_test.py", - "tests/finding_group_test.py", - "tests/finding_test.py", - "tests/login_test.py", - "tests/metrics_extended_test.py", - "tests/note_type_test.py", - "tests/notes_test.py", - "tests/notification_webhook_test.py", - "tests/notifications_test.py", - "tests/object_test.py", - "tests/product_member_test.py", - "tests/product_metadata_test.py", - "tests/product_tag_metrics_test.py", - "tests/product_test.py", - "tests/product_type_member_test.py", - "tests/product_type_test.py", - "tests/questionnaire_advanced_test.py", - "tests/questionnaire_test.py", - "tests/regulations_test.py", - "tests/reimport_scan_test.py", - "tests/report_builder_test.py", - "tests/risk_acceptance_test.py", - "tests/search_test.py", - "tests/sla_configuration_test.py", - "tests/system_settings_test.py", - "tests/test_copy_test.py", - "tests/test_test.py", - "tests/test_type_test.py", - "tests/threat_model_test.py", - "tests/tool_config.py", - "tests/tool_product_test.py", - "tests/tool_type_test.py", - "tests/user_profile_test.py", - "tests/user_test.py", - # "tests/zap.py", + # Each entry below is a GROUP of test files, run sequentially in one + # compose stack by docker/entrypoint-integration-tests.sh. The groups + # are contiguous slices of the order the entrypoint's legacy no-filter + # branch has always run -- every adjacency here has years of history + # running back-to-back in ONE stack, and each group gets a FRESH stack, + # so this is strictly more isolation than the legacy path, not less. + # Do not reorder files across groups without that history in mind; + # appending a NEW file to the last group (or its own group) is safe. + # tests/zap.py stays disabled, as it was in the per-file matrix. + # group 01 + "tests/finding_test.py tests/report_builder_test.py tests/notes_test.py tests/regulations_test.py tests/product_type_test.py tests/product_test.py", + # group 02 + "tests/engagement_test.py tests/environment_test.py tests/test_test.py tests/user_test.py tests/product_member_test.py tests/product_type_member_test.py", + # group 03 + "tests/search_test.py tests/file_test.py tests/dedupe_test.py tests/announcement_banner_test.py tests/close_old_findings_dedupe_test.py tests/close_old_findings_test.py", + # group 04 + "tests/false_positive_history_test.py tests/check_various_pages.py tests/notifications_test.py tests/note_type_test.py tests/sla_configuration_test.py tests/dashboard_test.py", + # group 05 + "tests/login_test.py tests/alerts_test.py tests/system_settings_test.py tests/engagement_extended_test.py tests/finding_extended_test.py tests/test_copy_test.py", + # group 06 + "tests/endpoint_extended_test.py tests/calendar_test.py tests/finding_group_test.py tests/engagement_presets_test.py tests/questionnaire_test.py tests/benchmark_test.py", + # group 07 + "tests/notification_webhook_test.py tests/threat_model_test.py tests/product_tag_metrics_test.py tests/object_test.py tests/tool_type_test.py tests/tool_product_test.py", + # group 08 + "tests/risk_acceptance_test.py tests/product_metadata_test.py tests/test_type_test.py tests/user_profile_test.py tests/engagement_checklist_test.py tests/questionnaire_advanced_test.py", + # group 09 + "tests/engagement_export_test.py tests/action_history_test.py tests/reimport_scan_test.py tests/banner_test.py tests/metrics_extended_test.py tests/tool_config.py", + # group 10 + "tests/base_test_class.py", ] os: [debian] - v3_feature_locations: [true, false] + # Two tiers: pull requests run the UI suite once, with the flag in its + # default (false) position; the merge queue and manual dispatch run both + # values against the exact commit that will land. github.event_name is + # the CALLING workflow's event -- workflow_call inherits the caller's + # context -- so this follows unit-tests.yml's tiering automatically. + v3_feature_locations: ${{ github.event_name == 'pull_request' && fromJSON('[false]') || fromJSON('[true, false]') }} exclude: - # standalone create endpoint page is gone in v3 + # standalone create endpoint page is gone in v3. On the light tier + # v3=true is absent and this exclude simply matches nothing. - v3_feature_locations: true test-case: "tests/endpoint_test.py" fail-fast: false @@ -103,6 +83,37 @@ jobs: - name: Set integration-test mode run: ln -s docker-compose.override.integration_tests.yml docker-compose.override.yml + # mailhog and webhook.endpoint are the only two images this job fetches from + # Docker Hub; django, nginx and integration-tests all arrive as artifacts + # from the build job. Pulling those two is the single most common cause of a + # red check here -- four runs on one branch failed as + # + # mailhog Error Get "https://registry-1.docker.io/v2/": context deadline exceeded + # + # with no test failure anywhere in the log. `docker compose up` gives a pull + # exactly one attempt, so a transient timeout takes the whole job with it, + # and this matrix plays that lottery 113 times per pull request. + # + # Retrying here rather than adding a step to fetch them as artifacts: this is + # three lines and fixes the observed failure, where plumbing two more images + # through the build-and-upload path touches four workflows. If Docker Hub + # rate limits become the problem rather than timeouts, the artifact route is + # the better answer. + - name: Pull external images + run: | + for attempt in 1 2 3; do + if docker compose pull --quiet mailhog webhook.endpoint; then + exit 0 + fi + echo "::warning::pull attempt ${attempt} failed; retrying in 15s" + sleep 15 + done + echo "::error::could not pull mailhog / webhook.endpoint after 3 attempts" + exit 1 + env: + DJANGO_VERSION: ${{ matrix.os }} + NGINX_VERSION: alpine + - name: Start Dojo run: docker compose up --no-deps -d postgres nginx celerybeat celeryworker mailhog uwsgi valkey webhook.endpoint env: @@ -117,7 +128,11 @@ jobs: NGINX_VERSION: alpine - name: Integration tests - timeout-minutes: 10 + # 30 rather than 10: a grouped entry runs up to six files sequentially, + # and the largest groups sum to ~11 minutes of test time on a warm stack + # before any slowness. Single-file entries are unaffected by the higher + # ceiling -- it is a backstop, not a target. + timeout-minutes: 30 run: docker compose up --no-deps --exit-code-from integration-tests integration-tests env: DD_INTEGRATION_TEST_FILENAME: ${{ matrix.test-case }} diff --git a/.github/workflows/migration-graph.yml b/.github/workflows/migration-graph.yml index 5a7a974e255..2216912f814 100644 --- a/.github/workflows/migration-graph.yml +++ b/.github/workflows/migration-graph.yml @@ -23,7 +23,18 @@ name: Migration Graph on: workflow_dispatch: + # The push trigger exists for the post-merge case described above, and that + # case only happens on the release lines -- so it is scoped to them. Unfiltered + # it also fired on every push to every PR branch, where it duplicated the + # pull_request run of the same commit (and the pull_request run is the better + # of the two there: it sees the branch merged with its base). push: + branches: + - master + - dev + - bugfix + - release/** + - hotfix/** pull_request: jobs: diff --git a/.github/workflows/rest-framework-tests.yml b/.github/workflows/rest-framework-tests.yml index b39b1cee43a..365dc42f7c8 100644 --- a/.github/workflows/rest-framework-tests.yml +++ b/.github/workflows/rest-framework-tests.yml @@ -31,6 +31,23 @@ jobs: with: persist-credentials: false + # Computed once, immediately after checkout, and referenced by both the + # restore and the save below. + # + # It has to be computed once rather than written inline in both places, + # because hashFiles is evaluated per step and these patterns do not hash to + # the same value all job long. `dojo/db_migrations/**` matches whatever is + # on disk when it runs, and migrating imports every migration module, which + # writes __pycache__/*.pyc into the workspace -- the repository is bind + # mounted into the container, so those land here. An inline key therefore + # saves under a different key than it restored with, and the entry is one + # nothing ever looks up again. + # + # KEEP IN SYNC with the same block in ci-warm-caches.yml. + - name: Compute migrated database snapshot key + id: db-key + run: echo "key=migrated-db-v1-${{ hashFiles('requirements.txt', 'dojo/db_migrations/**', 'dojo/models.py', 'dojo/base_models/**', 'dojo/pghistory_models.py', 'dojo/settings/settings.dist.py', 'docker-compose.yml', 'docker-compose.override.unit_tests_cicd.yml', 'docker/entrypoint-unit-tests.sh') }}" >> "$GITHUB_OUTPUT" + # load docker images from build jobs - name: Load images from artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -53,13 +70,79 @@ jobs: - name: Start Postgres and webhook.endpoint run: docker compose up --no-deps -d postgres webhook.endpoint + - name: Wait for Postgres to accept connections + run: | + for _ in $(seq 1 60); do + if docker compose exec -T postgres pg_isready -q -U defectdojo -d test_defectdojo; then + exit 0 + fi + sleep 2 + done + echo "Postgres did not become ready in 120s" >&2 + exit 1 + + # Applying 264 migrations takes ~100 seconds and produces the same schema + # every time, so restore that result instead of recomputing it. The key is + # the migration state: models included, not just migrations, which is what + # makes it safe for the entrypoint to skip its makemigrations guard on a + # hit (see docker/entrypoint-unit-tests.sh). + # + # ci-warm-caches.yml is what puts an entry in the base branch's cache scope + # for pull requests to find. + - name: Restore migrated database snapshot + id: db-snapshot + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: migrated-db.dump + key: ${{ steps.db-key.outputs.key }} + + # pg_restore runs inside the postgres container because it has to match the + # server: this one is 18, and the runner's own client is not guaranteed to + # be. --single-transaction makes the restore atomic, so a failure leaves the + # database empty rather than half-populated, and the migrate below can then + # take over cleanly. That is why this step is allowed to fail. + - name: Restore snapshot into Postgres + id: db-restore + if: steps.db-snapshot.outputs.cache-hit == 'true' + continue-on-error: true + run: | + docker compose exec -T postgres pg_restore \ + --username defectdojo --dbname test_defectdojo \ + --no-owner --no-privileges --single-transaction < migrated-db.dump + + - name: Migrate and snapshot the database + id: db-prepare + if: steps.db-snapshot.outputs.cache-hit != 'true' || steps.db-restore.outcome == 'failure' + run: | + docker compose run --rm -T --no-deps uwsgi + docker compose exec -T postgres pg_dump \ + --username defectdojo --dbname test_defectdojo --format=custom > migrated-db.dump + env: + DJANGO_VERSION: ${{ matrix.os }} + DD_TEST_DB_MODE: prepare + + # Saving here rather than in a post-job step means a snapshot survives a + # test failure: it is a function of the migration state, not of whether the + # suite passed. Four matrix jobs race for the same key and three of them + # lose, which the action reports as a warning, hence continue-on-error. + - name: Save migrated database snapshot + if: steps.db-prepare.outcome == 'success' + continue-on-error: true + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: migrated-db.dump + key: ${{ steps.db-key.outputs.key }} + # no celery or initializer needed for unit tests + # The database is migrated by this point on both paths -- restored from the + # snapshot, or migrated by the step above -- so the entrypoint reuses it. - name: Unit tests timeout-minutes: 25 run: docker compose up --no-deps --exit-code-from uwsgi uwsgi env: DJANGO_VERSION: ${{ matrix.os }} DD_V3_FEATURE_LOCATIONS: ${{ inputs.v3_feature_locations }} + DD_TEST_DB_MODE: reuse - name: Logs if: failure() diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index b719615aedf..70306846501 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -4,8 +4,27 @@ on: workflow_dispatch: # called by unit-tests.yml as a gate: nothing else runs until ruff passes workflow_call: + # push is for the release lines: it is what lints the merged result after a + # pull request lands. Unfiltered it also fired on every push to every PR + # branch, doubling up with the pull_request trigger below on the same commit. push: + branches: + - master + - dev + - bugfix + - release/** + - hotfix/** pull_request: + # A merge queue tests each speculative merge commit and waits for the required + # status checks to report against it. `ruff-linting` is a required check, so it + # has to be produced on merge_group events too -- otherwise the queue waits for + # a context that is never going to appear, with no error to say why. + # + # This trigger must live here rather than relying on the nested call from + # unit-tests.yml: a called workflow's context is prefixed with the caller's job + # name, so that path reports something other than `ruff-linting` and would not + # satisfy the requirement. + merge_group: jobs: ruff-linting: runs-on: ubuntu-latest diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 13570bef66a..b3b795ce344 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -9,6 +9,30 @@ on: - bugfix - release/** - hotfix/** + # Run the suite against the speculative merge commit a merge queue builds, which + # is the only thing that tests what will actually land: two pull requests can + # each be green on their own and broken in combination, and nothing before this + # point ever evaluates their union. + # + # The queue that emits these events is the "Merge Queue" ruleset (id 20466211), + # active on bugfix: SQUASH, ALLGREEN grouping, max 3 entries building, and it + # requires `ruff-linting` and `Unit Tests Complete` on the merge group. Two + # operational notes recorded here so they survive the people who know them: + # + # - The queue applies ONE merge method (squash) to everything it lands. + # Release merge-backs must stay real merge commits, so they go around the + # queue via the ruleset's bypass actors (repo admins + the maintainer + # teams), exactly as they bypass branch protection today. Nothing in this + # repository's automation calls `gh pr merge`, and that is load-bearing: + # on a queue-enabled branch that command can exit 0 WITHOUT merging (it + # arms auto-merge and the queue lands the PR later, with the queue's + # method), so any future script must read the PR's merged state back + # rather than trusting the exit code. + # + # - Under the two-tier matrices below, pull requests run the light tier and + # the queue re-runs the full tier on the speculative merge commit, so a + # queued PR's checks taking longer than its own PR run is expected. + merge_group: jobs: # hard gate: linting must pass before we spend any time building images or running tests @@ -20,7 +44,17 @@ jobs: needs: ruff strategy: matrix: - platform: ['linux/amd64', 'linux/arm64'] + # Two tiers, one workflow. Pull requests build amd64 only; the merge + # queue (and manual dispatch) builds both platforms and tests the + # speculative merge commit with everything. The queue is the gate, the + # pull-request run is feedback -- an arm64-only breakage surfaces when + # the queue tests the exact commit that would land, where it costs one + # queue ejection instead of a bad merge. + # + # Tiering must shrink MATRICES, never skip whole jobs: the + # unit-tests-complete gate requires every job in its `needs` to report + # success, and a skipped job reports skipped. Same jobs, fewer cells. + platform: ${{ github.event_name == 'pull_request' && fromJSON('["linux/amd64"]') || fromJSON('["linux/amd64", "linux/arm64"]') }} fail-fast: false uses: ./.github/workflows/build-docker-images-for-testing.yml secrets: inherit @@ -35,7 +69,12 @@ jobs: test-rest-framework: strategy: matrix: - platform: ['linux/amd64', 'linux/arm64'] + # Light tier drops the arm64 cells, matching the build tiering above (a + # pull-request run has no arm64 image artifact to test anyway). Both + # v3_feature_locations values stay in BOTH tiers: the flag changes the + # API schema and endpoint behaviour, so the two runs are genuinely + # different suites, not a platform duplicate. + platform: ${{ github.event_name == 'pull_request' && fromJSON('["linux/amd64"]') || fromJSON('["linux/amd64", "linux/arm64"]') }} v3_feature_locations: [ false, true ] fail-fast: false needs: build-docker-containers @@ -57,3 +96,47 @@ jobs: uses: ./.github/workflows/k8s-tests.yml secrets: inherit + # One stable context for a branch ruleset or merge queue to require. + # + # The jobs above report contexts like + # "test-rest-framework (linux/amd64, true) / Rest Framework Unit Tests (debian)" + # which change whenever a matrix entry is added, removed or renamed. Requiring + # those individually means the ruleset silently stops covering a job the moment + # the matrix moves; requiring this one job cannot drift, because its `needs` + # list is checked by the workflow itself. + # + # Why this matters more under a merge queue than it does today: right now a + # human reads the checks and decides to merge, so a failing-but-not-required + # job still gets seen. A queue merges as soon as the *required* checks pass, so + # anything not required stops being a gate at all. Requiring this context is + # what keeps the suite meaningful once merging is automatic. + unit-tests-complete: + name: Unit Tests Complete + # always() so this still reports when something upstream fails -- without it + # the job would be skipped, and a skipped required check blocks the queue + # instead of failing it. + if: always() + needs: + - ruff + - build-docker-containers + - test-performance + - test-rest-framework + - test-user-interface + - test-k8s + runs-on: ubuntu-latest + steps: + - name: Verify every job succeeded + # A skipped or cancelled dependency is not a pass. Checking explicitly + # rather than relying on this job's own conclusion, because a job whose + # needs were skipped can otherwise report success by omission. + run: | + results="${{ join(needs.*.result, ' ') }}" + echo "dependency results: ${results}" + for result in ${results}; do + if [ "${result}" != "success" ]; then + echo "::error::At least one job did not succeed (${results})" + exit 1 + fi + done + echo "All jobs succeeded." + diff --git a/docker-compose.override.unit_tests_cicd.yml b/docker-compose.override.unit_tests_cicd.yml index 511f76ebcdb..862e451fd35 100644 --- a/docker-compose.override.unit_tests_cicd.yml +++ b/docker-compose.override.unit_tests_cicd.yml @@ -20,9 +20,27 @@ services: DD_DATABASE_ENGINE: ${DD_DATABASE_ENGINE:-django.db.backends.postgresql} DD_DATABASE_HOST: ${DD_DATABASE_HOST:-postgres} DD_DATABASE_PORT: ${DD_DATABASE_PORT:-5432} - DD_CELERY_BROKER_URL: 'sqla+sqlite:///dojo.celerydb.sqlite' + # An in-process broker rather than the default sqlite file. Nothing consumes + # the queue in unit tests -- celeryworker and celerybeat are both reset + # above -- so a real broker contributes nothing but contention: under + # --parallel every worker process opens the same sqlite file and they race + # to declare the queue, which fails in whichever loses with + # "UNIQUE constraint failed: kombu_queue.name". + # + # Set through the scheme, because settings.dist.py only assembles a URL from + # the parts when DD_CELERY_BROKER_URL is empty. double_slashes=True there + # turns these two into exactly "memory://". The entrypoint also unsets + # DD_CELERY_BROKER_URL, but blanking it here means the broker does not + # depend on that happening -- and it stops the base compose file's + # redis://valkey URL, which no unit test has a valkey for, showing through. + DD_CELERY_BROKER_URL: '' + DD_CELERY_BROKER_SCHEME: 'memory' + DD_CELERY_BROKER_PATH: '' DD_JIRA_EXTRA_ISSUE_TYPES: 'Vulnerability' # Shouldn't trigger a migration error DD_V3_FEATURE_LOCATIONS: ${DD_V3_FEATURE_LOCATIONS:-False} + # full | prepare | reuse -- see docker/entrypoint-unit-tests.sh. CI sets + # this to replace the migrate with restoring a snapshot of its result. + DD_TEST_DB_MODE: ${DD_TEST_DB_MODE:-full} # No Redis/valkey in unit tests -> default django cache is LocMemCache. DD_CACHE_URL: '' # In-process singleton cache (dojo/caching.py) stays ON: a singleton is read diff --git a/docker/entrypoint-integration-tests.sh b/docker/entrypoint-integration-tests.sh index 92790c35559..df065ce1dbf 100755 --- a/docker/entrypoint-integration-tests.sh +++ b/docker/entrypoint-integration-tests.sh @@ -57,13 +57,27 @@ if [[ -n "$DD_INTEGRATION_TEST_FILENAME" ]]; then fail "$test" fi else - test=$DD_INTEGRATION_TEST_FILENAME - echo "Running: $test" - if python3 "$DD_INTEGRATION_TEST_FILENAME"; then - success "$test" - else - fail "$test" - fi + # DD_INTEGRATION_TEST_FILENAME may name several files separated by + # whitespace; CI passes groups of files that share one compose stack. + # The unquoted expansion below is the split, which is safe because + # every value is a repo-relative path with no spaces in it. + # + # Files run in order and the first failure exits (fail() exits 1), + # exactly like the full-suite branch below: a test that fails can + # leave state behind that would make everything after it fail too, + # and one clean failure beats five cascading ones. The log names each + # file as it starts, so the failing file is always the last "Running:" + # line. + # shellcheck disable=SC2086 + for test_file in $DD_INTEGRATION_TEST_FILENAME; do + test=$test_file + echo "Running: $test" + if python3 "$test_file"; then + success "$test" + else + fail "$test" + fi + done fi else diff --git a/docker/entrypoint-unit-tests-devDocker.sh b/docker/entrypoint-unit-tests-devDocker.sh index 54b1f97901f..d728ff3d56b 100755 --- a/docker/entrypoint-unit-tests-devDocker.sh +++ b/docker/entrypoint-unit-tests-devDocker.sh @@ -23,9 +23,21 @@ python3 manage.py makemigrations --no-input --check --dry-run --verbosity 3 || { ******************************************************************************** -You made changes to the models without creating a DB migration for them. - -**NEVER** change existing migrations, create a new one. +The makemigrations check failed. This happens for two different reasons, +and the error Django printed above says which one you have: + +1) "Your models ... have changes that are not yet reflected in a migration" + -- you changed models without creating a migration for them. Create one + with 'python3 manage.py makemigrations'. + **NEVER** change existing migrations, create a new one. + +2) "Conflicting migrations detected; multiple leaf nodes in the migration + graph" -- the graph is forked: two migrations declare the same parent, + usually after merging or rebasing onto a base branch that had already + moved. No model change of yours is involved. Fix it by re-parenting the + later migration onto the other leaf (renumbering it if the numbers + collide), or with an empty merge migration that depends on both. The + Migration Graph Check on the pull request names the offending files. If you're not familiar with migrations in Django, please read the great documentation thoroughly: diff --git a/docker/entrypoint-unit-tests.sh b/docker/entrypoint-unit-tests.sh index c7e2d360334..35799df6efb 100755 --- a/docker/entrypoint-unit-tests.sh +++ b/docker/entrypoint-unit-tests.sh @@ -24,6 +24,33 @@ unset DD_CELERY_BROKER_URL wait_for_database_to_be_reachable +# What this script should do about the database before it runs anything. +# +# full (default) check the migration state, apply it, then run the tests. +# prepare apply migrations and exit, leaving a migrated database behind for +# the caller to snapshot. Keeps the makemigrations guard. +# reuse the database is already migrated -- restored from a snapshot by the +# caller -- so go straight to the tests. +# +# `reuse` skips the makemigrations guard along with the migrate, which is only +# safe because the snapshot is keyed on the models as well as on the migrations: +# a model change with no migration for it cannot happen without changing that +# key, and a changed key means there is no snapshot to restore and therefore no +# way to reach this branch. Do not key a snapshot on migrations alone. +DD_TEST_DB_MODE="${DD_TEST_DB_MODE:-full}" + +case "${DD_TEST_DB_MODE}" in + full|prepare|reuse) ;; + *) + echo "DD_TEST_DB_MODE must be one of full, prepare, reuse (got '${DD_TEST_DB_MODE}')" >&2 + exit 1 + ;; +esac + +# Nothing to do with the database: this checks the API schema, and it has to run +# per test job because DD_V3_FEATURE_LOCATIONS changes the schema it produces. +# Skipped only when preparing a database, which runs no tests. +if [ "${DD_TEST_DB_MODE}" != "prepare" ]; then python3 manage.py spectacular --fail-on-warn > /dev/null || { cat <<-EOF @@ -51,15 +78,32 @@ EOF python3 manage.py spectacular > /dev/null exit 1 } +fi # DD_TEST_DB_MODE != prepare +# The bodies below are deliberately left unindented: they contain `<<-EOF` +# heredocs, which strip leading tabs but not spaces, so indenting them would +# change the message text they print. +if [ "${DD_TEST_DB_MODE}" != "reuse" ]; then python3 manage.py makemigrations --no-input --check --dry-run --verbosity 3 || { cat <<-EOF ******************************************************************************** -You made changes to the models without creating a DB migration for them. +The makemigrations check failed. This happens for two different reasons, +and the error Django printed above says which one you have: -**NEVER** change existing migrations, create a new one. +1) "Your models ... have changes that are not yet reflected in a migration" + -- you changed models without creating a migration for them. Create one + with 'python3 manage.py makemigrations'. + **NEVER** change existing migrations, create a new one. + +2) "Conflicting migrations detected; multiple leaf nodes in the migration + graph" -- the graph is forked: two migrations declare the same parent, + usually after merging or rebasing onto a base branch that had already + moved. No model change of yours is involved. Fix it by re-parenting the + later migration onto the other leaf (renumbering it if the numbers + collide), or with an empty merge migration that depends on both. The + Migration Graph Check on the pull request names the offending files. If you're not familiar with migrations in Django, please read the great documentation thoroughly: @@ -72,15 +116,30 @@ EOF } python3 manage.py migrate +fi # DD_TEST_DB_MODE != reuse -# --parallel fails on GitHub Actions -#python3 manage.py test unittests -v 3 --no-input --parallel +if [ "${DD_TEST_DB_MODE}" = "prepare" ]; then + echo "Database migrated; DD_TEST_DB_MODE=prepare, so not running any tests." + exit 0 +fi echo "Unit Tests" echo "------------------------------------------------------------" -# Removing parallel and shuffle for now to maintain stability -python3 manage.py test unittests --keepdb --no-input --exclude-tag="non-parallel" --exclude-tag="transactional" --exclude-tag="performance" || { +# This phase is already defined as "everything that does not need to run on its +# own": the two tags excluded here are exactly the suites that were quarantined +# because they interfere with their neighbours. That is the same property +# --parallel needs, so it is enabled here and nowhere else. +# +# "auto" rather than a count from nproc: Django's get_max_test_processes() falls +# back to a single process if the multiprocessing start method is one its worker +# initialisation cannot handle, and only "auto" consults it. An explicit count +# bypasses that check and fails instead. See the note in manage.py. +# +# Set DD_TEST_PARALLEL=1 to go back to a single process -- useful locally when a +# failure's traceback matters more than the wall clock, and the one-line revert +# if this turns out to cost more in flakes than it saves in minutes. +python3 manage.py test unittests --keepdb --no-input --parallel="${DD_TEST_PARALLEL:-auto}" --exclude-tag="non-parallel" --exclude-tag="transactional" --exclude-tag="performance" || { exit 1; } python3 manage.py test unittests --keepdb --no-input --tag="non-parallel" --exclude-tag="performance" || { diff --git a/manage.py b/manage.py index 61bc69ff529..be6fecfb8ef 100755 --- a/manage.py +++ b/manage.py @@ -5,6 +5,26 @@ if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dojo.settings.settings") + if len(sys.argv) > 1 and sys.argv[1] == "test": + # Django's parallel test runner supports two multiprocessing start + # methods, and Python 3.14 defaults to neither of them on Linux: it + # switched from "fork" to "forkserver". Under forkserver, django/test/ + # runner.py::_init_worker calls django.setup() only when the method is + # "spawn", so a worker unpickles its subsuite with no app registry and + # dies on "AppRegistryNotReady: Apps aren't loaded yet." + # + # Choose spawn rather than restoring fork: it is the start method whose + # worker initialisation Django actually implements, and forking a + # process that may already have threads is what Python moved away from. + # + # Only for `test`, so nothing else that runs through manage.py is + # affected. Django's own get_max_test_processes() falls back to a single + # process for any other start method, so if this ever stops applying, + # `--parallel auto` degrades to serial instead of failing. + import multiprocessing + + multiprocessing.set_start_method("spawn", force=True) + from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) diff --git a/requirements-dev.txt b/requirements-dev.txt index 706b4371dae..955388f88fb 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,6 +10,11 @@ vcrpy==8.3.0 vcrpy-unittest==0.1.7 django-test-migrations==1.5.0 parameterized==0.9.0 +# Django's parallel test runner returns failures from its worker processes by +# pickling them, and a traceback is not picklable on its own. Without tblib +# installed, a failure under --parallel arrives without the traceback that +# would explain it. +tblib==3.2.2 # Development file watching (hot reload) watchdog[watchmedo]==6.0.0 diff --git a/tests/close_old_findings_dedupe_test.py b/tests/close_old_findings_dedupe_test.py index 78538a05f04..dc6a8e05d9e 100644 --- a/tests/close_old_findings_dedupe_test.py +++ b/tests/close_old_findings_dedupe_test.py @@ -69,22 +69,29 @@ def test_delete_findings(self): driver = self.driver driver.get(self.base_url + "finding?page=1") - if self.element_exists_by_id("no_findings"): - text = driver.find_element(By.ID, "no_findings").text - if "No findings found." in text: - return - - driver.find_element(By.ID, "select_all").click() - driver.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-trash").click() - try: - WebDriverWait(driver, 1).until(expected_conditions.alert_is_present(), - "Timed out waiting for finding delete confirmation popup to appear.") - driver.switch_to.alert.accept() - except TimeoutException: - self.fail("Confirmation dialogue not shown, cannot delete previous findings") - - logger.debug("page source when checking for no_findings element") - logger.debug(self.driver.page_source) + # Files sharing this stack may have left more than one page of findings + # behind, and the bulk-delete action only covers the page it is on, so + # delete page by page until the empty state appears. Bounded, so a + # delete that stops shrinking the list fails loudly instead of looping. + for _ in range(10): + if self.element_exists_by_id("no_findings"): + break + driver.find_element(By.ID, "select_all").click() + driver.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-trash").click() + try: + WebDriverWait(driver, 2).until(expected_conditions.alert_is_present(), + "Timed out waiting for finding delete confirmation popup to appear.") + driver.switch_to.alert.accept() + except TimeoutException: + self.fail("Confirmation dialogue not shown, cannot delete previous findings") + # The delete POSTs and redirects. Wait for the reloaded page to show + # either the empty state or the next page's table, rather than + # probing the old DOM with only the 1-second implicit wait -- a + # bulk delete of a full page is not done in 1 second. + WebDriverWait(driver, 30).until( + lambda d: d.find_elements(By.ID, "no_findings") or d.find_elements(By.ID, "select_all"), + "Timed out waiting for the findings list to reload after bulk delete.") + text = driver.find_element(By.ID, "no_findings").text self.assertIsNotNone(text) diff --git a/tests/close_old_findings_test.py b/tests/close_old_findings_test.py index cacf903d569..cbb11173168 100644 --- a/tests/close_old_findings_test.py +++ b/tests/close_old_findings_test.py @@ -29,22 +29,29 @@ def test_delete_findings(self): driver = self.driver driver.get(self.base_url + "finding?page=1") - if self.element_exists_by_id("no_findings"): - text = driver.find_element(By.ID, "no_findings").text - if "No findings found." in text: - return - - driver.find_element(By.ID, "select_all").click() - driver.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-trash").click() - try: - WebDriverWait(driver, 1).until(expected_conditions.alert_is_present(), - "Timed out waiting for finding delete confirmation popup to appear.") - driver.switch_to.alert.accept() - except TimeoutException: - self.fail("Confirmation dialogue not shown, cannot delete previous findings") - - logger.debug("page source when checking for no_findings element") - logger.debug(self.driver.page_source) + # Files sharing this stack may have left more than one page of findings + # behind, and the bulk-delete action only covers the page it is on, so + # delete page by page until the empty state appears. Bounded, so a + # delete that stops shrinking the list fails loudly instead of looping. + for _ in range(10): + if self.element_exists_by_id("no_findings"): + break + driver.find_element(By.ID, "select_all").click() + driver.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-trash").click() + try: + WebDriverWait(driver, 2).until(expected_conditions.alert_is_present(), + "Timed out waiting for finding delete confirmation popup to appear.") + driver.switch_to.alert.accept() + except TimeoutException: + self.fail("Confirmation dialogue not shown, cannot delete previous findings") + # The delete POSTs and redirects. Wait for the reloaded page to show + # either the empty state or the next page's table, rather than + # probing the old DOM with only the 1-second implicit wait -- a + # bulk delete of a full page is not done in 1 second. + WebDriverWait(driver, 30).until( + lambda d: d.find_elements(By.ID, "no_findings") or d.find_elements(By.ID, "select_all"), + "Timed out waiting for the findings list to reload after bulk delete.") + text = driver.find_element(By.ID, "no_findings").text self.assertIsNotNone(text) diff --git a/tests/dedupe_test.py b/tests/dedupe_test.py index 0893bf9b034..ccf71ee2081 100644 --- a/tests/dedupe_test.py +++ b/tests/dedupe_test.py @@ -583,6 +583,11 @@ def _patch(cls, path, payload): r.raise_for_status() return r.json() + @classmethod + def _delete(cls, path): + r = requests.delete(cls.api + path, headers=cls.headers, timeout=60) + r.raise_for_status() + # --- fixtures --------------------------------------------------------- def _make_engagement(self, name): """Create a product + dedup-on-engagement engagement, return its id.""" @@ -597,6 +602,15 @@ def _make_engagement(self, name): "description": "async_wait integration test", "prod_type": prod_type_id, }) + # Deleting the product cascades the engagement, its tests, and every + # imported finding. Without this the ~100 findings these two tests + # import outlive the file, and the next file to run in the same stack + # inherits them -- test_delete_findings in the close_old files bulk + # deletes one page and then asserts the list is empty, so more than a + # page of leftovers fails it. These API tests are the only part of + # this suite that runs after the suite's own delete_product cleanup. + # addCleanup rather than tearDown so a failing test still cleans up. + self.addCleanup(self._delete, f"/products/{product['id']}/") engagement = self._post("/engagements/", { "name": f"async_wait eng {suffix}", "product": product["id"], From 810c453ab66c2202edc659fcaff3afcc4ddbe275 Mon Sep 17 00:00:00 2001 From: Greg Anderson <greg.anderson@owasp.org> Date: Wed, 5 Aug 2026 21:17:42 -0600 Subject: [PATCH 007/110] fix(reimporter): always dispatch the final post-processing batch (#15548) * refactor(reimporter): hold findings, not ids, until dispatch batch_finding_ids accumulated (finding.id, push_to_jira) per finding, but those ids are only consumed in the flush block below. Holding the finding and reading .id at dispatch defers the only primary-key read in the per-finding loop body to a point where every finding is certainly written. That makes the loop primary-key free, so an importer that buffers inserts and flushes them per batch can do so by overriding process_finding_that_was_not_matched alone, rather than copying the whole of _process_findings_internal -- the same accommodation get_original_findings and get_reimport_match_candidates_for_batch already provide, and which their docstrings describe for exactly this case. No behavior change: the same (id, push_to_jira) pairs reach the same dispatch, in the same order. * docs(reimporter): claim only what the deferral achieves Self-review correction. The prior comment said a buffering importer could get by 'by overriding process_finding_that_was_not_matched alone'. That overstates it: the batch-boundary block persists locations and applies parser/inherited tags -- all of which need written rows -- before the ids are read, and none of that is behind an importer-controllable seam. Deferring the id read removes the only DIRECT primary-key read in the per-finding loop body. That is what this change does; the comment now says exactly that. * fix(reimporter): always dispatch the final post-processing batch The batch-boundary flush lived inside the per-finding loop, guarded by 'len(batch) >= size or is_final'. The matched branch's force_continue -- taken whenever an incoming finding matches an existing false positive / out of scope / risk accepted finding whose statuses agree -- skips the rest of the loop body, including that check. Findings are content-sorted before processing, so any report whose LAST sorted finding took that path never flushed its tail: for reports under IMPORT_REIMPORT_DEDUPE_BATCH_SIZE (1000), the entire report silently skipped post_process_findings_batch (deduplication, rules, issue updater, JIRA) and parser/inherited tag application. Locations and vulnerability ids were only rescued later if close_old_findings ran, which it does not when close-old is off. The same loss applied when the final iteration's finding was falsy (the 'appears to always be true' guard). Fix: extract the flush block into _flush_post_processing_batch and call it from two places -- size-triggered inside the loop, and once unconditionally after the loop as a drain. The drain replaces the is_final machinery (is_final_batch / is_final / the enumerate index existed only to feed that condition), so the final flush no longer depends on how the last iteration ended. Every step is a no-op on empty state, which close_old_findings already relies on by calling persist() unconditionally. The regression test drives the real matching path: an existing false positive, an incoming status-parity match crafted to hash-match it and sort last, and one genuinely new finding. Without the drain, the dispatch mock records no batch at all. * test(reimporter): drop the unused unpacked variable ruff (CI pins 0.16.0) flags the unused 'to_mitigate' from the empty-report drain test's tuple unpack. Renamed to _to_mitigate. Caught late because I had been running ruff --isolated: this repo's ruff.toml selects PLW0717, which only exists from 0.16.0, so a local 0.15.x cannot parse the config at all. Falling back to --isolated silently drops the repo's whole rule set. 'uvx ruff@0.16.0 check .' is the parity command. * perf(locations): skip the empty transaction when nothing is buffered LocationManager.persist() opened transaction.atomic() unconditionally. _persist_locations() and _persist_status_updates() both already short-circuit on empty accumulators, so with nothing buffered the only cost of proceeding was an empty transaction -- and inside an outer atomic block that is a SAVEPOINT/RELEASE pair, two queries to do nothing. persist() is called at every batch boundary and again unconditionally by close_old_findings, so imports that touched no location paid those pairs already. The post-loop drain added in this branch made it measurable: the performance suite moved expected_num_queries4 by exactly +2. The guard is the union of the two inner guards, so it can only skip work that both inner methods would have skipped anyway. * test: drop the V3 reimport baselines by the removed empty transaction LocationManager.persist() no longer opens transaction.atomic() when nothing is buffered, so the SAVEPOINT/RELEASE pair that used to cost two queries on the persist() call with nothing to write is gone. A reimport makes two persist() calls -- the batch boundary and close_old_findings -- and one of them has nothing to write. 94 -> 92 (no change) and 195 -> 193 (with new findings). V2 is unaffected because EndpointManager.persist() opens no transaction, which is why only the V3 constants move. --------- Co-authored-by: Greg Anderson <greg@Gregs-MacBook-Pro.local> --- dojo/importers/default_reimporter.py | 148 +++++++++++++++++-------- dojo/importers/location_manager.py | 13 +++ unittests/test_reimport_batch_flush.py | 142 ++++++++++++++++++++++++ unittests/test_tag_inheritance_perf.py | 12 +- 4 files changed, 264 insertions(+), 51 deletions(-) create mode 100644 unittests/test_reimport_batch_flush.py diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index 5efb306a9db..9ea8189d00e 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -370,7 +370,16 @@ def _process_findings_internal( # JIRA as a group, so their individual push is suppressed while ungrouped findings in the # same batch must still be pushed. The batch is partitioned by flag at dispatch time # instead of applying one finding's flag to the whole batch. - batch_finding_ids: list[tuple[int, bool]] = [] + # + # The finding is held rather than its id, and the id is read at dispatch time below. + # Nothing between the append and the dispatch needs the id, and deferring the read + # keeps this loop body free of direct primary-key reads -- one less obstacle for an + # importer that buffers inserts and writes them at batch boundaries, in the same + # spirit as get_original_findings and get_reimport_match_candidates_for_batch, which + # exist so downstream editions need not copy this method. (Not the only obstacle: + # the batch-boundary block below persists locations and applies tags, which also + # require written rows, before the ids are read.) + batch_findings_to_dispatch: list[tuple[Finding, bool]] = [] batch_findings: list[Finding] = [] # Findings that were newly created (else branch below) — pass these to # `apply_inherited_tags_for_findings` instead of `batch_findings` so @@ -388,7 +397,6 @@ def _process_findings_internal( for batch_start in range(0, len(cleaned_findings), match_batch_max_size): batch_end = min(batch_start + match_batch_max_size, len(cleaned_findings)) unsaved_findings_batch = cleaned_findings[batch_start:batch_end] - is_final_batch = batch_end == len(cleaned_findings) logger.debug(f"Processing reimport batch {batch_start}-{batch_end} of {len(cleaned_findings)} findings") @@ -400,9 +408,7 @@ def _process_findings_internal( ) # Process each finding in the batch using pre-fetched candidates - for idx, unsaved_finding in enumerate(unsaved_findings_batch): - is_final = is_final_batch and idx == len(unsaved_findings_batch) - 1 - + for unsaved_finding in unsaved_findings_batch: # Match any findings to this new one coming in using pre-fetched candidates matched_findings = self.match_finding_to_candidate_reimport( unsaved_finding, @@ -458,7 +464,7 @@ def _process_findings_internal( ) # all data is already saved on the finding, we only need to trigger post processing in batches push_to_jira = self.push_to_jira and ((not self.findings_groups_enabled or not self.group_by) or not finding_will_be_grouped) - batch_finding_ids.append((finding.id, push_to_jira)) + batch_findings_to_dispatch.append((finding, push_to_jira)) batch_findings.append(finding) # Post-processing batches (deduplication, rules, etc.) are separate from matching batches. @@ -476,49 +482,31 @@ def _process_findings_internal( # - Matching batches: optimize candidate fetching (solve 1+N query problem) # - Deduplication batches: optimize bulk operations (larger batches = fewer queries) # They don't need to be aligned since they optimize different operations. - if len(batch_finding_ids) >= dedupe_batch_max_size or is_final: - self.location_handler.persist() - self.flush_vulnerability_ids() - self.flush_burp_request_response() - # Apply parser-supplied tags for this batch before post-processing starts, - # so rules/deduplication tasks see the tags already on the findings. - bulk_apply_parser_tags(findings_with_parser_tags) - findings_with_parser_tags.clear() - # Apply import-time tags before post-processing so rules/deduplication see them. - self.apply_import_tags_for_batch(batch_findings) - # Apply inherited Product tags to NEWLY CREATED findings only - # (and their endpoints/locations) BEFORE post_process_findings_batch - # dispatches, so rules/dedup see inherited tags on .tags. - # Matched/existing findings already have inheritance applied from - # their original creation; re-running it on no-change reimports - # would be ~8 wasted queries per batch. - apply_inherited_tags_for_findings(new_findings_in_batch) - new_findings_in_batch.clear() - batch_findings.clear() - # Partition the batch by each finding's own push_to_jira flag so one - # finding's grouping state is not applied to the whole batch. Uniform - # batches (grouping disabled, or push_to_jira off) stay a single dispatch. - finding_ids_by_push: dict[bool, list[int]] = {} - for finding_id, finding_push_to_jira in batch_finding_ids: - finding_ids_by_push.setdefault(finding_push_to_jira, []).append(finding_id) - batch_finding_ids.clear() - for push_to_jira_batch, finding_ids_batch in finding_ids_by_push.items(): - result = dojo_dispatch_task( - finding_helper.post_process_findings_batch, - finding_ids_batch, - dedupe_option=True, - rules_option=True, - product_grading_option=True, - issue_updater_option=True, - push_to_jira=push_to_jira_batch, - jira_instance_id=getattr(self.jira_instance, "id", None), - # 'async_wait' joins on this dispatch via AsyncResult.get(), so its - # result must be stored despite the global CELERY_TASK_IGNORE_RESULT. - **({"ignore_result": False} if self.deduplication_execution_mode == DEDUPLICATION_EXECUTION_MODE_ASYNC_WAIT else {}), - **self.post_processing_dispatch_kwargs(**kwargs), - ) - if self.deduplication_execution_mode == DEDUPLICATION_EXECUTION_MODE_ASYNC_WAIT: - self.record_post_processing_result(result) + if len(batch_findings_to_dispatch) >= dedupe_batch_max_size: + self._flush_post_processing_batch( + batch_findings_to_dispatch, + batch_findings, + new_findings_in_batch, + findings_with_parser_tags, + **kwargs, + ) + + # A final drain instead of an is_final flag inside the loop. The matched branch's + # force_continue skips the rest of the loop body, so a report whose last sorted + # finding took that path never reached the old in-loop is_final flush -- everything + # appended since the previous size-triggered flush was silently dropped: no + # deduplication, rules, issue updater or JIRA dispatch, and no parser or inherited + # tags for those findings. Draining here runs exactly once however the last + # iteration ended. It is deliberately unconditional: matched findings can accumulate + # location status updates without appending anything to dispatch, and every step is + # a no-op on empty state (close_old_findings already calls persist() the same way). + self._flush_post_processing_batch( + batch_findings_to_dispatch, + batch_findings, + new_findings_in_batch, + findings_with_parser_tags, + **kwargs, + ) # No chord: tasks are dispatched immediately above per batch @@ -540,6 +528,68 @@ def _process_findings_internal( return self.new_items, self.reactivated_items, self.to_mitigate, self.untouched + def _flush_post_processing_batch( + self, + batch_findings_to_dispatch: list[tuple[Finding, bool]], + batch_findings: list[Finding], + new_findings_in_batch: list[Finding], + findings_with_parser_tags: list[tuple], + **kwargs: dict, + ) -> None: + """ + Persist and dispatch everything accumulated for one post-processing batch, then + clear the accumulators (the passed-in lists are mutated in place). + + Called from the processing loop each time the batch reaches + IMPORT_REIMPORT_DEDUPE_BATCH_SIZE, and once after the loop as an unconditional + drain, so a report whose final finding never reached the in-loop check (the + matched branch's force_continue, or a falsy finding) still gets its tail + deduplicated, tagged and dispatched. Every step is safe on empty state, which is + what makes the unconditional drain call cheap. + """ + self.location_handler.persist() + self.flush_vulnerability_ids() + self.flush_burp_request_response() + # Apply parser-supplied tags for this batch before post-processing starts, + # so rules/deduplication tasks see the tags already on the findings. + bulk_apply_parser_tags(findings_with_parser_tags) + findings_with_parser_tags.clear() + # Apply import-time tags before post-processing so rules/deduplication see them. + self.apply_import_tags_for_batch(batch_findings) + # Apply inherited Product tags to NEWLY CREATED findings only + # (and their endpoints/locations) BEFORE post_process_findings_batch + # dispatches, so rules/dedup see inherited tags on .tags. + # Matched/existing findings already have inheritance applied from + # their original creation; re-running it on no-change reimports + # would be ~8 wasted queries per batch. + apply_inherited_tags_for_findings(new_findings_in_batch) + new_findings_in_batch.clear() + batch_findings.clear() + # Partition the batch by each finding's own push_to_jira flag so one + # finding's grouping state is not applied to the whole batch. Uniform + # batches (grouping disabled, or push_to_jira off) stay a single dispatch. + finding_ids_by_push: dict[bool, list[int]] = {} + for dispatch_finding, finding_push_to_jira in batch_findings_to_dispatch: + finding_ids_by_push.setdefault(finding_push_to_jira, []).append(dispatch_finding.id) + batch_findings_to_dispatch.clear() + for push_to_jira_batch, finding_ids_batch in finding_ids_by_push.items(): + result = dojo_dispatch_task( + finding_helper.post_process_findings_batch, + finding_ids_batch, + dedupe_option=True, + rules_option=True, + product_grading_option=True, + issue_updater_option=True, + push_to_jira=push_to_jira_batch, + jira_instance_id=getattr(self.jira_instance, "id", None), + # 'async_wait' joins on this dispatch via AsyncResult.get(), so its + # result must be stored despite the global CELERY_TASK_IGNORE_RESULT. + **({"ignore_result": False} if self.deduplication_execution_mode == DEDUPLICATION_EXECUTION_MODE_ASYNC_WAIT else {}), + **self.post_processing_dispatch_kwargs(**kwargs), + ) + if self.deduplication_execution_mode == DEDUPLICATION_EXECUTION_MODE_ASYNC_WAIT: + self.record_post_processing_result(result) + def _sync_close_old_finding_status_fields(self, findings: list[Finding]) -> list[Finding]: """ Refresh false_p, risk_accepted, and out_of_scope from the DB for each finding. diff --git a/dojo/importers/location_manager.py b/dojo/importers/location_manager.py index a2e08e5272e..23a00e5fbee 100644 --- a/dojo/importers/location_manager.py +++ b/dojo/importers/location_manager.py @@ -118,6 +118,19 @@ def serialize_extra_locations(self, locations: list) -> dict: def persist(self) -> None: """Persist all accumulated location operations to the database.""" + # Both steps below already short-circuit when their accumulators are empty, so with + # nothing buffered the only cost of proceeding is an empty transaction -- inside an + # outer atomic block that is a SAVEPOINT/RELEASE pair, i.e. two queries to do + # nothing. persist() is called unconditionally at every batch boundary and again by + # close_old_findings, so those pairs are paid on imports that touched no location at + # all. The condition is the union of the two inner guards. + if not ( + self._product_locations + or self._status_updates + or self._refs_to_reactivate + or self._refs_to_mitigate + ): + return with transaction.atomic(): self._persist_locations() self._persist_status_updates() diff --git a/unittests/test_reimport_batch_flush.py b/unittests/test_reimport_batch_flush.py new file mode 100644 index 00000000000..668e9a9d7d5 --- /dev/null +++ b/unittests/test_reimport_batch_flush.py @@ -0,0 +1,142 @@ +from unittest import mock + +from django.utils import timezone + +from dojo.finding import helper as finding_helper +from dojo.importers.default_importer import DefaultImporter +from dojo.importers.default_reimporter import DefaultReImporter +from dojo.models import Development_Environment, Engagement, Finding, Product, Product_Type, User + +from .dojo_test_case import DojoTestCase, get_unit_tests_scans_path + +SCAN_TYPE = "Acunetix Scan" + + +class TestReimportFinalBatchFlush(DojoTestCase): + + """ + Regression: a reimport whose last finding matched an existing special-status finding + silently dropped the whole trailing batch. + + The processing loop accumulates created/updated findings and dispatches + post_process_findings_batch (deduplication, rules, issue updater, JIRA) plus parser and + inherited tag application at batch boundaries. The boundary check lived inside the loop + and was skipped by the matched branch's force_continue -- taken whenever the incoming + finding matches an existing false positive / out of scope / risk accepted finding whose + statuses agree. Findings are content-sorted before processing, so any report whose + last-sorted finding took that path lost everything accumulated since the previous + size-triggered flush: for reports smaller than IMPORT_REIMPORT_DEDUPE_BATCH_SIZE, the + entire report skipped deduplication and tagging. + + The fix drains the accumulators once, unconditionally, after the loop. + """ + + def setUp(self): + super().setUp() + self.user, _ = User.objects.get_or_create(username="admin") + product_type, _ = Product_Type.objects.get_or_create(name="reimport_batch_flush") + self.environment, _ = Development_Environment.objects.get_or_create(name="Development") + self.product, _ = Product.objects.get_or_create( + name="TestReimportFinalBatchFlush", + description="Test", + prod_type=product_type, + ) + self.engagement, _ = Engagement.objects.get_or_create( + name="Reimport Final Batch Flush", + product=self.product, + target_start=timezone.now(), + target_end=timezone.now(), + ) + with (get_unit_tests_scans_path("acunetix") / "one_finding.xml").open(encoding="utf-8") as scan: + importer = DefaultImporter(close_old_findings=False, **self._options(engagement=self.engagement)) + self.test, _, len_new_findings, _, _, _, _ = importer.process_scan(scan) + self.assertEqual(1, len_new_findings) + self.existing = Finding.objects.get(test=self.test) + + def _options(self, **overrides): + options = { + "user": self.user, + "lead": self.user, + "scan_date": None, + "environment": self.environment, + "active": True, + "verified": False, + "scan_type": SCAN_TYPE, + } + options.update(overrides) + return options + + def _reimporter(self): + return DefaultReImporter(close_old_findings=False, **self._options(test=self.test)) + + def _dispatched_finding_ids(self, dispatch_mock): + ids = [] + for call in dispatch_mock.call_args_list: + if call.args and call.args[0] is finding_helper.post_process_findings_batch: + ids.extend(call.args[1]) + return ids + + def test_final_force_continue_still_dispatches_the_batch(self): + # Make the existing finding special-status so a status-parity match force_continues. + Finding.objects.filter(pk=self.existing.pk).update(false_p=True) + self.existing.refresh_from_db() + + reimporter = self._reimporter() + + # An incoming finding with the same hash fields and the same statuses as the + # existing false positive: matches by hash, then takes the special-status parity + # path, which force_continues out of the loop iteration. + parity = Finding( + test=self.test, + title=self.existing.title, + description=self.existing.description, + severity=self.existing.severity, + false_p=True, + active=True, + verified=False, + ) + parity_hash = reimporter.calculate_unsaved_finding_hash_code(parity) + # Premise: the crafted finding really does hash-match the stored one. + self.assertEqual(self.existing.hash_code, parity_hash) + + # A genuinely new finding that must sort BEFORE the parity finding, so the parity + # match is the loop's final iteration. The reimporter sorts by a content key whose + # first element is hash_code, so vary the description until the hash orders first. + # SHA-256 is uniform; 200 attempts cannot realistically fail to find one. + fresh = None + for attempt in range(200): + candidate = Finding( + test=self.test, + title="brand new finding from the reimport", + description=f"created by the reimport under test, variant {attempt}", + severity="High", + active=True, + verified=False, + ) + if reimporter.calculate_unsaved_finding_hash_code(candidate) < parity_hash: + fresh = candidate + break + self.assertIsNotNone(fresh, "could not construct a finding sorting before the parity match") + + with mock.patch("dojo.importers.default_reimporter.dojo_dispatch_task") as dispatch: + new_items, _, _, _ = reimporter.process_findings([fresh, parity]) + + # Premise: the parity finding matched instead of being created -- the test only + # exercises the force_continue tail when exactly one new finding exists. + self.assertEqual(2, Finding.objects.filter(test=self.test).count()) + self.assertIn(self.existing, reimporter.unchanged_items) + self.assertEqual([fresh], new_items) + self.assertIsNotNone(fresh.pk) + + # The regression: with the boundary check inside the loop, the force_continue on + # the final iteration skipped it and this batch was never dispatched at all. + self.assertEqual([fresh.pk], self._dispatched_finding_ids(dispatch)) + + def test_empty_report_drain_is_harmless(self): + # The post-loop drain is unconditional; an empty report must stay a no-op. + reimporter = self._reimporter() + with mock.patch("dojo.importers.default_reimporter.dojo_dispatch_task") as dispatch: + new_items, reactivated, _to_mitigate, _ = reimporter.process_findings([]) + self.assertEqual([], new_items) + self.assertEqual([], reactivated) + self.assertEqual([], self._dispatched_finding_ids(dispatch)) diff --git a/unittests/test_tag_inheritance_perf.py b/unittests/test_tag_inheritance_perf.py index 379fb06f3bc..131ee57af15 100644 --- a/unittests/test_tag_inheritance_perf.py +++ b/unittests/test_tag_inheritance_perf.py @@ -616,9 +616,17 @@ def test_baseline_zap_scan_reimport_with_new_findings_v3(self): # for the reference and CWE buffers it shares, then flush_burp_request_response() # resolves its own for the request/response rows the ZAP parser attaches. The # no-change reimport buffers nothing, so it takes no lookup and is unchanged. + # -2 on the V3 reimport paths: LocationManager.persist() used to open + # transaction.atomic() even with nothing buffered, and inside the test's outer + # atomic block that empty transaction is a SAVEPOINT/RELEASE pair. Both steps + # inside it already short-circuit on empty accumulators, so persist() now returns + # before opening it. A reimport calls persist() at the batch boundary and again + # from close_old_findings; the one with nothing to write is the pair that goes. + # V2 is unaffected -- EndpointManager.persist() opens no transaction -- which is + # why only the V3 constants move. EXPECTED_ZAP_IMPORT_V2 = 301 EXPECTED_ZAP_IMPORT_V3 = 325 EXPECTED_ZAP_REIMPORT_NO_CHANGE_V2 = 82 - EXPECTED_ZAP_REIMPORT_NO_CHANGE_V3 = 94 + EXPECTED_ZAP_REIMPORT_NO_CHANGE_V3 = 92 EXPECTED_ZAP_REIMPORT_WITH_NEW_V2 = 166 - EXPECTED_ZAP_REIMPORT_WITH_NEW_V3 = 195 + EXPECTED_ZAP_REIMPORT_WITH_NEW_V3 = 193 From 403ed95b5a173d241006601e20bc1c5dfcfc6f33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:09:14 -0400 Subject: [PATCH 008/110] chore(deps): bump pillow from 12.2.0 to 12.3.0 (#15302) Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0) --- updated-dependencies: - dependency-name: pillow dependency-version: 12.3.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 800803059641b32db120baa1e2953d2b84511e64 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:09:48 -0400 Subject: [PATCH 009/110] chore(deps): update dependency node from 24.18.0 to v24.18.1 (.github/workflows/validate_docs_build.yml) (#15526) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/gh-pages.yml | 2 +- .github/workflows/validate_docs_build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 39f48c710d5..cbcbc39f963 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '24.18.0' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.18.1' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index e08949827a1..26592d71ca0 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '24.18.0' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.18.1' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 From 90d713c4b9e1dc10600364742eb6f7530f17a9cc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:09:54 -0400 Subject: [PATCH 010/110] chore(deps): update dependency renovatebot/renovate from 43.288.0 to v44 (.github/workflows/renovate.yaml) (#15473) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/renovate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml index 2c10ad0957c..febac2f69c5 100644 --- a/.github/workflows/renovate.yaml +++ b/.github/workflows/renovate.yaml @@ -21,4 +21,4 @@ jobs: uses: suzuki-shunsuke/github-action-renovate-config-validator@ee9f69e1f683ed0d08225086482b34fc9abe9300 # v2.1.0 with: strict: "true" - validator_version: 43.288.0 # renovate: datasource=github-releases depName=renovatebot/renovate + validator_version: 44.14.3 # renovate: datasource=github-releases depName=renovatebot/renovate From 5b96096ae9d0d10c300f527f9fc9e75e4acddbcd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:09:59 -0400 Subject: [PATCH 011/110] chore(deps): update valkey docker tag from 0.24.6 to v0.25.0 (helm/defectdojo/chart.yaml) (#15532) * chore(deps): update valkey docker tag from 0.24.6 to v0.25.0 (helm/defectdojo/chart.yaml) * update Helm documentation --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> --- helm/defectdojo/Chart.lock | 6 +++--- helm/defectdojo/Chart.yaml | 4 ++-- helm/defectdojo/README.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/helm/defectdojo/Chart.lock b/helm/defectdojo/Chart.lock index 82c0802ea9b..f20464c29f0 100644 --- a/helm/defectdojo/Chart.lock +++ b/helm/defectdojo/Chart.lock @@ -4,6 +4,6 @@ dependencies: version: 16.7.27 - name: valkey repository: oci://registry-1.docker.io/cloudpirates - version: 0.24.6 -digest: sha256:355de79e674456e0672f698883ebf3c1de0f81bb662acf9bc36a89cf5d1ac7c8 -generated: "2026-07-29T05:55:51.736094169Z" + version: 0.25.0 +digest: sha256:b8fe5478a81cce16ce41b84d31484a66c4edbdf4397eaab74e64efa094fd2903 +generated: "2026-08-05T05:05:07.787814039Z" diff --git a/helm/defectdojo/Chart.yaml b/helm/defectdojo/Chart.yaml index 970ed57945a..9b9ea9b42f8 100644 --- a/helm/defectdojo/Chart.yaml +++ b/helm/defectdojo/Chart.yaml @@ -14,7 +14,7 @@ dependencies: repository: "oci://us-docker.pkg.dev/os-public-container-registry/defectdojo" condition: postgresql.enabled - name: valkey - version: 0.24.6 + version: 0.25.0 repository: "oci://registry-1.docker.io/cloudpirates" condition: valkey.enabled # For correct syntax, check https://artifacthub.io/docs/topics/annotations/helm/ @@ -34,4 +34,4 @@ dependencies: # description: Critical bug annotations: artifacthub.io/prerelease: "true" - artifacthub.io/changes: "" + artifacthub.io/changes: "- kind: changed\n description: chore(deps)_ update valkey _ tag from 0.24.6 to v0.25.0 (_/defect_/chart.yaml)\n" diff --git a/helm/defectdojo/README.md b/helm/defectdojo/README.md index 9b28c829f4f..a350e81f97b 100644 --- a/helm/defectdojo/README.md +++ b/helm/defectdojo/README.md @@ -525,7 +525,7 @@ A Helm chart for Kubernetes to install DefectDojo | Repository | Name | Version | |------------|------|---------| -| oci://registry-1.docker.io/cloudpirates | valkey | 0.24.6 | +| oci://registry-1.docker.io/cloudpirates | valkey | 0.25.0 | | oci://us-docker.pkg.dev/os-public-container-registry/defectdojo | postgresql | 16.7.27 | ## Values From d9eed6fbca2b0b306a617706bf2480d810d8eb86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:10:04 -0400 Subject: [PATCH 012/110] chore(deps): bump redis from 8.0.1 to 8.1.0 (#15536) Bumps [redis](https://github.com/redis/redis-py) from 8.0.1 to 8.1.0. - [Release notes](https://github.com/redis/redis-py/releases) - [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES) - [Commits](https://github.com/redis/redis-py/compare/v8.0.1...v8.1.0) --- updated-dependencies: - dependency-name: redis dependency-version: 8.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 47562b4f3df..181e7378abb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ Pillow==12.3.0 # required by django-imagekit psycopg[c]==3.3.4 cryptography==49.0.0 python-dateutil==2.9.0.post0 -redis==8.0.1 +redis==8.1.0 requests==2.34.2 sqlalchemy==2.0.51 # Required by Celery broker transport urllib3==2.7.0 From 63169f659d3eca6b23675ffc5ff1eb3c55824844 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:10:08 -0400 Subject: [PATCH 013/110] chore(deps): bump django-polymorphic from 4.11.6 to 4.11.7 (#15538) Bumps [django-polymorphic](https://github.com/django-commons/django-polymorphic) from 4.11.6 to 4.11.7. - [Release notes](https://github.com/django-commons/django-polymorphic/releases) - [Commits](https://github.com/django-commons/django-polymorphic/compare/v4.11.6...v4.11.7) --- updated-dependencies: - dependency-name: django-polymorphic dependency-version: 4.11.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 181e7378abb..e5e8591d358 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ django-filter==26.1 django-htmx==1.28.0 django-imagekit==6.1.0 django-multiselectfield==1.0.1 -django-polymorphic==4.11.6 +django-polymorphic==4.11.7 django-crispy-forms==2.6 django_extensions==4.1 django-slack==5.19.0 From ba58a0dbd46b5a55d8a03d1f7d1f09f601eabfc8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:10:11 -0400 Subject: [PATCH 014/110] chore(deps): update dependency django-test-migrations from 1.5.0 to v1.6.0 (requirements-dev.txt) (#15544) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 955388f88fb..222c816779c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,7 @@ django-debug-toolbar-request-history==0.1.4 # Testing dependencies vcrpy==8.3.0 vcrpy-unittest==0.1.7 -django-test-migrations==1.5.0 +django-test-migrations==1.6.0 parameterized==0.9.0 # Django's parallel test runner returns failures from its worker processes by # pickling them, and a traceback is not picklable on its own. Without tblib From 7149fab0892719eff861e45b4da0e59780a9ffc8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:10:16 -0400 Subject: [PATCH 015/110] chore(deps): update python docker tag from 3.14.6 to v3.14.7 (dockerfile.nginx-alpine) (#15546) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile.django-alpine | 2 +- Dockerfile.django-debian | 2 +- Dockerfile.integration-tests-debian | 2 +- Dockerfile.nginx-alpine | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile.django-alpine b/Dockerfile.django-alpine index e17e82ad557..570521d0891 100644 --- a/Dockerfile.django-alpine +++ b/Dockerfile.django-alpine @@ -5,7 +5,7 @@ # Dockerfile.nginx to use the caching mechanism of Docker. # Ref: https://devguide.python.org/#branchstatus -FROM python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4 AS base +FROM python:3.14.7-alpine3.23@sha256:3dd78139a25399653106981c08f9ba5469bd8568c254d304368dd54ca57b1b46 AS base FROM base AS build WORKDIR /app RUN \ diff --git a/Dockerfile.django-debian b/Dockerfile.django-debian index b023717df50..4ebabd69c18 100644 --- a/Dockerfile.django-debian +++ b/Dockerfile.django-debian @@ -5,7 +5,7 @@ # Dockerfile.nginx to use the caching mechanism of Docker. # Ref: https://devguide.python.org/#branchstatus -FROM python:3.14.6-slim-trixie@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 AS base +FROM python:3.14.7-slim-trixie@sha256:83c1cebb322d099ac9e3a3a532ba74b0146d702838b25e4c75c02fa81ffeb910 AS base FROM base AS build WORKDIR /app RUN \ diff --git a/Dockerfile.integration-tests-debian b/Dockerfile.integration-tests-debian index 9f4472033e1..2fb6704bd2f 100644 --- a/Dockerfile.integration-tests-debian +++ b/Dockerfile.integration-tests-debian @@ -3,7 +3,7 @@ FROM openapitools/openapi-generator-cli:v7.24.0@sha256:5bf3dc75f764c584da8e3344c51b2f3f1e74703461d46a035b5ac1d31515cc88 AS openapitools # currently only supports x64, no arm yet due to chrome and selenium dependencies -FROM python:3.14.6-slim-trixie@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 AS build +FROM python:3.14.7-slim-trixie@sha256:83c1cebb322d099ac9e3a3a532ba74b0146d702838b25e4c75c02fa81ffeb910 AS build WORKDIR /app RUN \ apt-get -y update && \ diff --git a/Dockerfile.nginx-alpine b/Dockerfile.nginx-alpine index c6920922cac..ee91508aadd 100644 --- a/Dockerfile.nginx-alpine +++ b/Dockerfile.nginx-alpine @@ -5,7 +5,7 @@ # Dockerfile.django-alpine to use the caching mechanism of Docker. # Ref: https://devguide.python.org/#branchstatus -FROM python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4 AS base +FROM python:3.14.7-alpine3.23@sha256:3dd78139a25399653106981c08f9ba5469bd8568c254d304368dd54ca57b1b46 AS base FROM base AS build WORKDIR /app RUN \ From 7bb9cac5f17e2f88c7051aa7eedca6c286b8e3f6 Mon Sep 17 00:00:00 2001 From: Greg Anderson <greg.anderson@owasp.org> Date: Thu, 6 Aug 2026 21:10:30 -0600 Subject: [PATCH 016/110] docs(universal parser): field mappings can be edited, with identity classified and versioned (#15556) Replaces the create-only lifecycle section. Editing was blocked because a mapping edit can change what a finding's identity is built from -- true of some edits and not others -- so the page now documents the distinction the API makes, the acknowledgement an identity-relevant edit requires, the impact endpoint for checking before committing, the transition window and its two limits, and the revision history. Rolling forward to a new parser stays documented, as the right answer when a report format changes enough to yield a different set of findings. Also flags the Django admin as the one path that bypasses versioning. Co-authored-by: DefectDojo Agent <greg-agent-4@defectdojo.com> --- .../specialized_import/universal_parser.md | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/docs/content/import_data/pro/specialized_import/universal_parser.md b/docs/content/import_data/pro/specialized_import/universal_parser.md index 1b6db0a7ef4..44de1701853 100644 --- a/docs/content/import_data/pro/specialized_import/universal_parser.md +++ b/docs/content/import_data/pro/specialized_import/universal_parser.md @@ -162,26 +162,76 @@ You can edit the Test_Type associated with your Universal Parser to change: * Whether its findings should be marked "static" or "dynamic" * You can tweak the same-tool and cross-tool deduplication hash codes, as well as the reimport hash codes, for your Universal Parser under "Enterprise Settings". By default, only same-tool deduplication and reimport hash codes are populated, with the required values Title, Severity, and Description. -## Lifecycle: create, deactivate, reactivate +## Lifecycle: create, edit, deactivate, reactivate -A Universal Parser's lifecycle is **create-only**, with no in-UI edit or delete. Once a parser has been created, the field-mapping configuration cannot be modified, and the parser itself cannot be removed from the UI — this is by design, because Universal Parser configurations are tied to Test_Type records that may be referenced by existing Findings, Tests, and import history. - -What you **can** do from the UI: +What you can do from the UI: * **Deactivate** a parser to hide it from the "Scan Type" drop-down on import. Open **Import → Universal Parser** in the sidebar to see all of your Universal Parsers, and toggle "Active" off. (Alternatively, you can edit the underlying Test_Type and uncheck "active".) Deactivated parsers no longer appear as a Scan Type option on the **Add Findings** page, but existing Tests that were imported with this parser are unaffected and continue to work. * **Reactivate** a parser from the same screen by toggling "Active" back on. * **Edit the Test_Type fields** described in the section above (active/inactive, static/dynamic, deduplication hash codes). -### Recommended workflow when a scanner's report format changes +Deleting a parser configuration is still not possible, because Universal Parser configurations are tied to Test_Type records that existing Findings, Tests and import history reference. If you need one permanently removed (for example, because it contains sensitive field names), contact [DefectDojo Support](mailto:support@defectdojo.com). + +## Editing a Universal Parser's field mappings + +Field mappings can be changed after a parser has been created, through the API. Each edit is recorded as a numbered revision, so you can see what changed, when, and who changed it. + +Some mapping edits are riskier than others, and DefectDojo classifies each edit before applying it: + +* A **presentation-only** edit changes a field that does not take part in matching — `references`, `mitigation`, `impact` and similar. It applies immediately with no further consequences. +* An **identity-relevant** edit changes a field that your deduplication configuration hashes. By default that is Title, Severity and Description, so remapping any of those falls in this category, as does mapping a vendor's own identifier into `unique_id_from_tool`. These edits change what a finding's identity is built from, which is why the configuration used to be frozen. + +Which fields count as identity-relevant depends on your own deduplication settings for that scan type, not on a fixed list, so it follows any change you make under **Enterprise Settings**. + +### Checking an edit before you make it + +`POST` your proposed mappings to the `impact` endpoint to see how they will be classified, without applying anything: + +``` +POST /api/vue/universal_parser/{id}/impact/ +{"mappings": [ ... ]} +``` + +The response tells you whether the edit is identity-relevant, which fields moved, which of those reach identity, and how many findings currently exist under this parser's scan type. + +### Making the edit + +``` +PATCH /api/vue/universal_parser/{id}/ +{"mappings": [ ... ], "acknowledge_identity_change": true} +``` + +`acknowledge_identity_change` is required only for an identity-relevant edit, and the request is rejected without it. A presentation-only edit does not need it. + +### What happens to findings you have already imported + +An identity-relevant edit opens a **transition window**. During the window, an import re-derives its findings under the previous mappings as well as the current ones, and carries both identities. Findings imported before the edit are matched on the identity they were stored with, so a reimport updates them instead of closing them and creating duplicates. -Because the field-mapping configuration is locked once a parser is created, the standard workflow for handling a format change in the underlying scanner is to **roll forward to a new parser** rather than try to edit the old one: +Two limits are worth knowing: + +* Only the **immediately previous** identity generation is bridged. If you make two identity-relevant edits without importing in between, findings imported before the first edit will not be matched. +* The bridge pairs findings by position in the report. If your edit changes **how many findings the report yields** — for example by repointing the query at a different array — the pairing cannot be established and the bridge declines rather than risk attaching one finding's history to another. In that case, roll forward to a new parser instead. + +### Viewing the history + +``` +GET /api/vue/universal_parser/{id}/revisions/ +``` + +Each revision records the full mapping set in force at that point, which fields the edit changed, whether it was identity-relevant, and who made it. + +### Changing mappings through the Django admin + +Editing a `FieldMapping` directly in the Django admin bypasses all of the above: no revision is recorded, no version is bumped, and no transition window opens, so findings imported before that change become unreachable from findings imported after it. Use the API instead. + +### Rolling forward to a new parser + +Editing is not always the right answer. If your scanner's report format changes so substantially that the new report yields a different set of findings, prefer creating a second parser: 1. **Create a new Universal Parser** using a sample of the new report format (see Step 1). Give it a distinct name — e.g. append `v2` or a date to the original name. 2. **Switch new imports** in your CI/CD pipeline or UI workflow to use the new parser's scan type. 3. **Deactivate the old parser** once you've confirmed the new one is producing the findings you expect. Tests already imported under the old parser remain in DefectDojo and can still be triaged; only new imports route to the new parser. -If you need a parser configuration permanently removed (for example, because it contains sensitive field names), contact [DefectDojo Support](mailto:support@defectdojo.com). - ## A note about severity mapping The Universal Parser does **not** have a configurable severity-mapping field. Severity is mapped automatically with these rules: From 54d04b07809d1679228fe1c7024c1f6deb54ca1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:10:48 -0400 Subject: [PATCH 017/110] chore(deps): bump markdown from 3.10.2 to 3.10.3 (#15540) Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.10.2 to 3.10.3. - [Release notes](https://github.com/Python-Markdown/markdown/releases) - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md) - [Commits](https://github.com/Python-Markdown/markdown/compare/3.10.2...3.10.3) --- updated-dependencies: - dependency-name: markdown dependency-version: 3.10.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e5e8591d358..63d4276b419 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ humanize==4.16.0 jira==3.10.5 PyGithub==2.9.1 lxml==6.1.1 -Markdown==3.10.2 +Markdown==3.10.3 openpyxl==3.1.5 Pillow==12.3.0 # required by django-imagekit psycopg[c]==3.3.4 From a971f6c9af23efce8bd16181d9346d24d6217a15 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:11:07 -0400 Subject: [PATCH 018/110] chore(deps): bump ruff from 0.16.0 to 0.16.1 (#15543) Bumps [ruff](https://github.com/astral-sh/ruff) from 0.16.0 to 0.16.1. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.0...0.16.1) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements-lint.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-lint.txt b/requirements-lint.txt index 47e1ff60c72..97e48e0cc56 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1 @@ -ruff==0.16.0 +ruff==0.16.1 From fa6a3401e1f9e23497d42fa75ebc1ed7c271ac79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:11:22 -0400 Subject: [PATCH 019/110] chore(deps): bump pyopenssl from 26.3.0 to 26.4.0 (#15535) Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 26.3.0 to 26.4.0. - [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/pyopenssl/compare/26.3.0...26.4.0) --- updated-dependencies: - dependency-name: pyopenssl dependency-version: 26.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 63d4276b419..7793af7d169 100644 --- a/requirements.txt +++ b/requirements.txt @@ -65,6 +65,6 @@ netaddr==1.3.0 vulners==4.0.0 fontawesomefree==6.6.0 PyYAML==6.0.3 -pyopenssl==26.3.0 +pyopenssl==26.4.0 parameterized==0.9.0 setuptools==83.0.0 From 69201ef2d1f35a97625b201dc4bfa0abaf588d1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:11:42 -0400 Subject: [PATCH 020/110] chore(deps): bump drf-spectacular-sidecar from 2026.7.1 to 2026.8.1 (#15542) Bumps [drf-spectacular-sidecar](https://github.com/tfranzel/drf-spectacular-sidecar) from 2026.7.1 to 2026.8.1. - [Commits](https://github.com/tfranzel/drf-spectacular-sidecar/compare/2026.7.1...2026.8.1) --- updated-dependencies: - dependency-name: drf-spectacular-sidecar dependency-version: 2026.8.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7793af7d169..e136d69e7f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ cvss==3.6 django-fieldsignals==0.8.0 hyperlink==21.0.0 drf-spectacular==0.30.0 -drf-spectacular-sidecar==2026.7.1 +drf-spectacular-sidecar==2026.8.1 django-ratelimit==4.1.0 argon2-cffi==25.1.0 blackduck==1.1.3 From 4ba07fbc1fbfc1a200251d3325c1a56e16d123c9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:11:47 +0000 Subject: [PATCH 021/110] chore(deps): update dependency node from 24.18.1 to v24.19.0 (.github/workflows/validate_docs_build.yml) (#15527) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/gh-pages.yml | 2 +- .github/workflows/validate_docs_build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index cbcbc39f963..d071662d12f 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '24.18.1' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.19.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/.github/workflows/validate_docs_build.yml b/.github/workflows/validate_docs_build.yml index 26592d71ca0..81d3a383fb6 100644 --- a/.github/workflows/validate_docs_build.yml +++ b/.github/workflows/validate_docs_build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '24.18.1' # TODO: Renovate helper might not be needed here - needs to be fully tested + node-version: '24.19.0' # TODO: Renovate helper might not be needed here - needs to be fully tested - name: Cache dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 From 5c815c0c5de9f5b27b071a351aa4b9d34a1d113d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:13:37 +0000 Subject: [PATCH 022/110] chore(deps): bump django-crispy-forms from 2.6 to 2.7 (#15534) Bumps [django-crispy-forms](https://github.com/django-crispy-forms/django-crispy-forms) from 2.6 to 2.7. - [Release notes](https://github.com/django-crispy-forms/django-crispy-forms/releases) - [Changelog](https://github.com/django-crispy-forms/django-crispy-forms/blob/main/CHANGELOG.md) - [Commits](https://github.com/django-crispy-forms/django-crispy-forms/compare/2.6...2.7) --- updated-dependencies: - dependency-name: django-crispy-forms dependency-version: '2.7' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index e136d69e7f2..a52a103cdae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ django-htmx==1.28.0 django-imagekit==6.1.0 django-multiselectfield==1.0.1 django-polymorphic==4.11.7 -django-crispy-forms==2.6 +django-crispy-forms==2.7 django_extensions==4.1 django-slack==5.19.0 django-watson==1.6.3 From 0bff3b6bbb164e0a91f85972e170987744ac2f31 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:53:02 -0600 Subject: [PATCH 023/110] docs(notifications): note where notification settings live in the Pro UI (#15567) * docs(notifications): note where notification settings live in the Pro UI The Pro UI now serves notification settings as three pages -- Personal, System and Template -- instead of the classic single page with a Scope drop-down, and the webhook page moved with them. Added as a Pro callout on the two configuration pages rather than a rewrite: the Scope drop-down is still exactly how it works in open source, so the existing instructions stay correct for that audience. Pairs with the DefectDojo Pro change on the same release line. * docs(notifications): use the site's Pro-note style, not an undefined shortcode The Pro callout used `{{% alert %}}`, a Docsy shortcode this site does not define, so `hugo` failed the docs build outright: failed to extract shortcode: template for shortcode "alert" not found Those two were the only `{{% alert %}}` uses in the whole content tree, which was the tell. Replaced with the highlighted-span convention the docs already use for Pro-only notes. Verified with a local `hugo` build rather than another CI round-trip. --- docs/content/admin/notifications/configure_personal_notifs.md | 2 ++ docs/content/admin/notifications/configure_system_notifs.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/content/admin/notifications/configure_personal_notifs.md b/docs/content/admin/notifications/configure_personal_notifs.md index cae2d03b2ef..d67fe7eb661 100644 --- a/docs/content/admin/notifications/configure_personal_notifs.md +++ b/docs/content/admin/notifications/configure_personal_notifs.md @@ -4,6 +4,8 @@ description: "Configure notifications for a personal account" aliases: - /en/customize_dojo/notifications/configure_personal_notifs --- + +<span style="background-color:rgba(242, 86, 29, 0.3)">Note: In DefectDojo Pro, notification settings live in the Pro UI as three separate pages rather than one page with a Scope drop-down: **Settings \> Notifications \> Personal Notifications**, **System Notifications** and **Notification Template**. System Notifications and the Notification Template are visible to superusers only. Notification Webhooks moved alongside them, under **Settings \> Notifications \> Notification Webhooks**.</span> ## Configure Personal notifications Personal Notifications are sent in addition to System\-Wide Notifications, and will apply to any Product, Product Type or other data type that you have access to. Personal Notification preferences only apply to a single user, and can only be set on the account which is configuring them. diff --git a/docs/content/admin/notifications/configure_system_notifs.md b/docs/content/admin/notifications/configure_system_notifs.md index 24dcca2c810..9fed1aaa6da 100644 --- a/docs/content/admin/notifications/configure_system_notifs.md +++ b/docs/content/admin/notifications/configure_system_notifs.md @@ -4,6 +4,8 @@ description: "How to configure Personal & System notifications" aliases: - /en/customize_dojo/notifications/configure_system_notifs --- + +<span style="background-color:rgba(242, 86, 29, 0.3)">Note: In DefectDojo Pro, notification settings live in the Pro UI as three separate pages rather than one page with a Scope drop-down: **Settings \> Notifications \> Personal Notifications**, **System Notifications** and **Notification Template**. System Notifications and the Notification Template are visible to superusers only. Notification Webhooks moved alongside them, under **Settings \> Notifications \> Notification Webhooks**.</span> DefectDojo has two different kinds of notifications: **Personal** (sent to a single account) and **System** (which are sent to all users). Both an account’s Personal Notifications and the global System Notifications can be configured from the same page: **⚙️Configuration \> Notifications** in the sidebar. From fa6bdfb3d4db1139f4526f5e25fddc37e06519e3 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:53:17 -0600 Subject: [PATCH 024/110] fix(engagement): allow saving engagements with an empty status (#15472) An engagement whose `status` or `engagement_type` column holds NULL (or an empty string) could not be saved at all. Both fields are declared `null=True` but neither was given `blank=True`, and Django counts `None` among a field's empty values, so `full_clean()` -- which Engagement runs on every save -- rejected the row with `{'status': ['This field cannot be blank.']}`: the model refusing a value its own column permits. Every import and reimport writes its engagement back at the end of the run (`save_without_resurrecting(self.test.engagement)`), so one such row turned every subsequent scan ingest into that engagement into a hard failure. Through the API the Django ValidationError is translated to HTTP 400, so callers got a rejected import rather than any findings. Neither column offers an empty choice and both declare a default, so an empty value carries no meaning the rest of the codebase can read -- filters, reports and the UI all assume one of the listed choices. `Engagement.pre_save_logic` now fills an empty value in from the field's own default, which keeps a value outside the choice list from becoming valid (as widening to `blank=True` would) and lets each affected row heal the next time anything saves it. No data migration: `pre_save_logic` runs before `full_clean`, so the very save that used to fail is the one that repairs the row, and affected engagements recover on their next (re)import rather than needing a backfill on deploy. Tests cover both fields on the model save path, the import and reimport write-backs, and that a populated value is never overwritten. Claude-Session: https://claude.ai/code/session_01HzJ7XLk2RsabJbYyJJgxvg Co-authored-by: Claude Opus 5 <noreply@anthropic.com> --- dojo/engagement/models.py | 21 +++ unittests/test_engagement_empty_status.py | 149 ++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 unittests/test_engagement_empty_status.py diff --git a/dojo/engagement/models.py b/dojo/engagement/models.py index ee614b34053..9590bc0c684 100644 --- a/dojo/engagement/models.py +++ b/dojo/engagement/models.py @@ -115,6 +115,27 @@ def __str__(self): def get_absolute_url(self): return reverse("view_engagement", args=[str(self.id)]) + def pre_save_logic(self) -> None: + """ + Fill in an empty `status` / `engagement_type` from the field's own default. + + Both columns are nullable, but neither offers an empty choice and both declare a + default, so an empty value carries no meaning the rest of the codebase can read -- + filters, reports and the UI all assume one of the listed choices. Django counts + None among a field's empty values, so a row storing NULL in either column failed + its own validation ("This field cannot be blank.") on every save, which made rows + written before these defaults existed permanently unsavable: every (re)import + writes its engagement back at the end of a run, so one such row turned every scan + ingest into that engagement into a hard failure. + + Normalizing here rather than widening the fields to `blank=True` keeps a value + outside the choice list from becoming valid, and lets each affected row heal the + next time anything saves it. + """ + for field_name in ("status", "engagement_type"): + if not getattr(self, field_name): + setattr(self, field_name, self._meta.get_field(field_name).get_default()) + def copy(self): from dojo.models import Test, copy_model_util # noqa: PLC0415 -- lazy import, avoids circular dependency copy = copy_model_util(self) diff --git a/unittests/test_engagement_empty_status.py b/unittests/test_engagement_empty_status.py new file mode 100644 index 00000000000..299dbf35c34 --- /dev/null +++ b/unittests/test_engagement_empty_status.py @@ -0,0 +1,149 @@ +import logging + +from django.utils import timezone + +from dojo.importers.default_importer import DefaultImporter +from dojo.importers.default_reimporter import DefaultReImporter +from dojo.models import ( + Development_Environment, + Engagement, + Product, + Product_Type, + User, +) + +from .dojo_test_case import DojoTestCase, get_unit_tests_scans_path + +logger = logging.getLogger(__name__) + +SCAN_TYPE = "Acunetix Scan" +SCAN_FILE = "one_finding.xml" + + +class TestEngagementEmptyStatus(DojoTestCase): + + """ + Regression: an engagement holding a NULL/empty `status` could not be saved at all. + + `status` and `engagement_type` are declared ``null=True`` but were never given + ``blank=True``, and Django counts ``None`` among a field's empty values. Since + Engagement validates itself on every save, a row storing NULL in either column + failed its own validation with "This field cannot be blank." -- the model rejecting + a value its own column permits. + + Every (re)import writes its engagement back at the end of the run, so a single such + row turned every scheduled scan ingest into that engagement into a hard failure, and + the API surfaced it as a 400 rather than importing anything. + """ + + def setUp(self): + super().setUp() + self.user, _ = User.objects.get_or_create(username="admin") + product_type, _ = Product_Type.objects.get_or_create(name="empty_status") + self.environment, _ = Development_Environment.objects.get_or_create(name="Development") + self.product, _ = Product.objects.get_or_create( + name="TestEngagementEmptyStatus", + description="Test", + prod_type=product_type, + ) + self.engagement, _ = Engagement.objects.get_or_create( + name="Empty Status Engagement", + product=self.product, + target_start=timezone.now(), + target_end=timezone.now(), + ) + with (get_unit_tests_scans_path("acunetix") / SCAN_FILE).open(encoding="utf-8") as scan: + self.test, _, len_new_findings, _, _, _, _ = self._importer().process_scan(scan) + self.assertEqual(1, len_new_findings) + + def _options(self, **overrides): + options = { + "user": self.user, + "lead": self.user, + "scan_date": None, + "environment": self.environment, + "active": True, + "verified": False, + "scan_type": SCAN_TYPE, + } + options.update(overrides) + return options + + def _importer(self): + return DefaultImporter(close_old_findings=False, **self._options(engagement=self.engagement)) + + def _reimporter(self): + return DefaultReImporter(close_old_findings=False, **self._options(test=self.test)) + + def _store_empty(self, **columns): + """Put empty values straight into the columns, bypassing model validation.""" + Engagement.objects.filter(pk=self.engagement.pk).update(**columns) + self.engagement.refresh_from_db() + + def test_engagement_with_null_status_can_be_saved(self): + """A stored NULL status must not make the row unsavable.""" + self._store_empty(status=None) + + self.engagement.save() + + self.assertEqual( + "Not Started", + Engagement.objects.get(pk=self.engagement.pk).status, + msg="an empty status must be normalized to the field default on save", + ) + + def test_engagement_with_blank_status_can_be_saved(self): + """The empty string is the other way the column reaches an empty value.""" + self._store_empty(status="") + + self.engagement.save() + + self.assertEqual("Not Started", Engagement.objects.get(pk=self.engagement.pk).status) + + def test_engagement_with_null_engagement_type_can_be_saved(self): + """`engagement_type` carries the same declaration, so it fails the same way.""" + self._store_empty(engagement_type=None) + + self.engagement.save() + + self.assertEqual( + "Interactive", + Engagement.objects.get(pk=self.engagement.pk).engagement_type, + msg="an empty engagement_type must be normalized to the field default on save", + ) + + def test_a_populated_status_is_left_alone(self): + """Normalization must only fill in an empty value, never overwrite a real one.""" + self.engagement.status = "In Progress" + self.engagement.engagement_type = "CI/CD" + + self.engagement.save() + + persisted = Engagement.objects.get(pk=self.engagement.pk) + self.assertEqual("In Progress", persisted.status) + self.assertEqual("CI/CD", persisted.engagement_type) + + def test_reimport_into_an_engagement_with_null_status_succeeds(self): + """The reported path: the closing engagement write-back must not fail the scan.""" + self._store_empty(status=None) + + with (get_unit_tests_scans_path("acunetix") / SCAN_FILE).open(encoding="utf-8") as scan: + test, _, _, _, _, _, _ = self._reimporter().process_scan(scan) + + self.assertEqual(self.test.pk, test.pk) + self.assertEqual( + "Not Started", + Engagement.objects.get(pk=self.engagement.pk).status, + msg="the reimport write-back must persist the engagement with a valid status", + ) + + def test_import_into_an_engagement_with_null_status_succeeds(self): + """The import path shares the same write-back.""" + self._store_empty(status=None) + + with (get_unit_tests_scans_path("acunetix") / SCAN_FILE).open(encoding="utf-8") as scan: + new_test, _, len_new_findings, _, _, _, _ = self._importer().process_scan(scan) + + self.assertEqual(1, len_new_findings) + self.assertEqual(self.engagement.pk, new_test.engagement.pk) + self.assertEqual("Not Started", Engagement.objects.get(pk=self.engagement.pk).status) From b93b3f7c840fd92a19585d9ce9b83c7bded8f161 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:53:22 -0600 Subject: [PATCH 025/110] chore(deps): bump cryptography from 49.0.0 to 50.0.0 (#15541) Bumps [cryptography](https://github.com/pyca/cryptography) from 49.0.0 to 50.0.0. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/49.0.0...50.0.0) --- updated-dependencies: - dependency-name: cryptography dependency-version: 50.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a52a103cdae..66c5c256f92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ Markdown==3.10.3 openpyxl==3.1.5 Pillow==12.3.0 # required by django-imagekit psycopg[c]==3.3.4 -cryptography==49.0.0 +cryptography==50.0.0 python-dateutil==2.9.0.post0 redis==8.1.0 requests==2.34.2 From 460889bc6b9d771fe42da75dc29193ce4b804096 Mon Sep 17 00:00:00 2001 From: Greg Anderson <greg.anderson@owasp.org> Date: Fri, 7 Aug 2026 16:18:46 -0600 Subject: [PATCH 026/110] docs(import): DISA STIG checklist import, and the CCI control crosswalk (#15571) Documents the DISA STIG Checklist scan type: the two STIG Viewer formats it reads, how the four checklist statuses become finding states, how DISA categories and assessor severity overrides map to severity, what each finding carries, and how findings are identified. Two points get more than a passing mention because they change how someone organizes their imports. Findings are identified by V-number *on the assessed asset*, so the same rule failing on two hosts stays two findings and a STIG release upgrade keeps history. And closing on re-import is driven by an item's absence from the report, so a test holding several assets' checklists needs Close Old Findings turned off. On the compliance side, STIG rules cite CCIs rather than naming controls, so control coverage gains a section on the CCI crosswalk, the precedence between mapping sources, and the fact that the backfill command now runs both passes. The compliance profile's configuration-test-types note gains the STIG case, including why it is not switched on for you. Co-authored-by: devGregA <greg-agent-2@defectdojo.com> --- .../federal_compliance/compliance_profile.md | 6 +- .../federal_compliance/control_coverage.md | 39 ++++++- .../pro/specialized_import/stig_checklists.md | 109 ++++++++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 docs/content/import_data/pro/specialized_import/stig_checklists.md diff --git a/docs/content/federal_compliance/compliance_profile.md b/docs/content/federal_compliance/compliance_profile.md index 189f74020d2..74ac95ca72c 100644 --- a/docs/content/federal_compliance/compliance_profile.md +++ b/docs/content/federal_compliance/compliance_profile.md @@ -49,7 +49,11 @@ Two profile settings are not on the form and are set through the compliance API: *do* carry their own control references are mapped from those instead; see [Control Coverage](../control_coverage). * **Configuration test types** — the test types whose findings are treated as configuration items, - which is what drives CM-6 consolidation in the ledger. + which is what drives CM-6 consolidation in the ledger. Adding the **DISA STIG Checklist** test + type here rolls a checklist's failed items into the single consolidated CM-6 item rather than + filing one POA&M item per rule — see + [DISA STIG Checklists](/import_data/pro/specialized_import/stig_checklists/). Whether checklist + items are configuration items is a per-system decision, so it is not set for you. ## Auditability diff --git a/docs/content/federal_compliance/control_coverage.md b/docs/content/federal_compliance/control_coverage.md index 6b6202516ae..2dcb3211ad2 100644 --- a/docs/content/federal_compliance/control_coverage.md +++ b/docs/content/federal_compliance/control_coverage.md @@ -25,17 +25,48 @@ never produces a mapping. Findings that carry no control references of their own are attributed to the default scan controls on the Compliance Profile — see [Compliance Profile](../compliance_profile). +### DISA STIG checklists, through their CCIs + +A STIG rule does not name an 800-53 control. It cites one or more **CCIs** (Control Correlation +Identifiers), which is DISA's own index into the control catalog — `CCI-000366`, for example, is +the configuration-settings CCI and resolves to `CM-6`. + +DefectDojo crosswalks those CCIs to their controls using DISA's published CCI list, so importing a +checklist populates control coverage with no extra configuration. See +[DISA STIG Checklists](/import_data/pro/specialized_import/stig_checklists/) for the import itself. + +The crosswalk covers the NIST 800-53 Rev 5 references DISA publishes, and like reference +extraction it is grounded in the imported catalog. Checklists assessed against a control set the +bundled catalog does not cover produce no mappings rather than approximate ones. + +### When two sources disagree + +A finding can pick up a control mapping from more than one source. Where they disagree, the more +authoritative one wins, in this order: + +1. A mapping **you set by hand**. +2. A **CCI crosswalk** from a STIG checklist. +3. A control reference **extracted from the finding's own text**. +4. The profile's **default scan controls**. + +A CCI crosswalk outranks text extraction because the CCI is published by the same authority that +wrote the checklist, where an extracted reference is read out of free-form scanner output. + ### Backfilling existing findings -Extraction runs as findings arrive. To map findings that were already imported before the feature -was enabled, backfill them: +Mapping runs as findings arrive. To map findings that were already imported before the feature was +enabled, backfill them: ``` manage.py extract_control_mappings --product <id> ``` -Use `--all` to scan every active finding instead of one product. The command reports how many -mappings it created, and it leaves manual and suppressed mappings alone. +Use `--all` to scan every active finding instead of one product. Both passes — reference +extraction and the CCI crosswalk — run by default; `--skip-scanner-refs` and `--skip-crosswalk` +run one without the other. The command reports how many mappings each pass created, and it leaves +manual and suppressed mappings alone. + +Re-running it is safe: a mapping that is already correct is left untouched. ## Correcting a mapping diff --git a/docs/content/import_data/pro/specialized_import/stig_checklists.md b/docs/content/import_data/pro/specialized_import/stig_checklists.md new file mode 100644 index 00000000000..85fdf6cf972 --- /dev/null +++ b/docs/content/import_data/pro/specialized_import/stig_checklists.md @@ -0,0 +1,109 @@ +--- +title: "DISA STIG Checklists" +description: "Import .ckl and .cklb checklists and track the open items as findings" +weight: 4 +audience: pro +--- +<span style="background-color:rgba(242, 86, 29, 0.3)">Note: STIG checklist import is only available in DefectDojo Pro.</span> + +DefectDojo Pro imports DISA STIG Viewer checklists directly, so the items an assessor marked +**Open** become findings you can age against an SLA, assign, report on, and remediate alongside +everything else. + +Choose the **DISA STIG Checklist** scan type on the Add Findings page, or pass +`scan_type=DISA STIG Checklist` to the import API. + +## Supported files + +Both STIG Viewer formats are read by the same scan type: + +* **`.ckl`** — STIG Viewer 2.x, XML. +* **`.cklb`** — STIG Viewer 3.x, JSON. + +The format is detected from the file's contents rather than its name, so a checklist that was +renamed, or exported by a tool that uses a different extension, imports the same way. A checklist +that records several STIGs against one asset is fully imported: every benchmark's items are +included, and each finding names the STIG it came from. + +One checklist describes one asset. See [Importing more than one asset](#importing-more-than-one-asset) +below for how to organize them. + +## How checklist statuses map to findings + +Every status is imported, so the finding list mirrors the checklist rather than only its failures. + +| Checklist status | Finding state | Meaning | +| --- | --- | --- | +| Open | Active, Verified | Needs remediation | +| Not Reviewed | Active, not Verified | Still to be assessed | +| Not A Finding | Mitigated (inactive) | Assessed as compliant | +| Not Applicable | Out of Scope (inactive) | Does not apply to this asset | + +Importing **Not Reviewed** items as active-but-unverified is deliberate: on a freshly generated +checklist most items carry that status, and they represent assessment work that has not happened +yet. Filter the finding list on **Verified** to separate confirmed failures from items still +awaiting review. + +Re-importing an updated checklist for the same asset moves findings between these states. An item +you have since fixed (Open → Not A Finding) closes, an item that has regressed (Not A Finding → +Open) reopens, and an item you removed from the checklist entirely is closed as no longer reported. + +## Severity + +STIG severity is the rule's DISA category, and it maps to DefectDojo severity directly: + +| DISA category | Severity | +| --- | --- | +| CAT I (high) | High | +| CAT II (medium) | Medium | +| CAT III (low) | Low | + +The category itself is recorded in the finding's **Impact** field. If an assessor overrode the +severity in the checklist, the override is what the finding carries, the assessor's reason is kept +in **Severity Justification**, and Impact still shows the rule's own category — so a downgrade is +visible rather than silent. + +## What each finding contains + +| Finding field | From the checklist | +| --- | --- | +| Title | The V-number and the rule title | +| Description | Group title, rule version (STIG-ID), rule ID, the STIG itself, the discussion, and any finding details or comments the assessor recorded | +| Mitigation | The rule's fix text | +| Steps to Reproduce | The rule's check content — how to assess the item | +| References | The STIG and release, and every CCI the rule cites | +| Component | The STIG and its version and release, for example `RHEL_9_STIG` `V2R3` | +| Endpoint | The asset the checklist was run against | + +The asset is taken from the checklist's own target data, preferring its FQDN, then its host name, +then its IP address. A checklist saved without any of the three still imports; its findings simply +carry no endpoint. + +## Deduplication + +A finding is identified by its **V-number on the asset it was assessed against**. Two consequences +worth knowing: + +* The same rule failing on two different assets stays **two findings**, so per-asset remediation + is tracked separately. +* Upgrading to a newer release of the same STIG **keeps finding history**. The rule ID carries a + revision suffix that changes with every STIG release, so it is recorded for reference only and + never used to identify a finding. + +## Importing more than one asset + +Because closing on re-import is driven by an item's absence from the report, give **each asset its +own test** and re-import that asset's newer checklist into it. That is the arrangement the import +defaults assume. + +If you would rather collect several assets' checklists in a single test, turn **Close Old Findings** +off when you import, otherwise each upload will close the previous asset's items. + +## Compliance control coverage + +STIG rules cite **CCIs** (Control Correlation Identifiers), DISA's index into the NIST control +catalog, and every CCI on a rule is recorded in the finding's references. If the +[Federal Compliance](/federal_compliance/) feature is enabled, those CCIs are crosswalked to their +NIST 800-53 controls automatically, so a checklist import populates +[Control Coverage](/federal_compliance/control_coverage/) and carries control attribution into the +POA&M ledger without any further configuration. From 4c7d8da752e35396cfa5660ff0f7ee142e20b9d9 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:44:12 -0600 Subject: [PATCH 027/110] refactor(ui): remove the classic Bootstrap UI (#15565) * refactor(ui): remove the classic Bootstrap UI The deprecation banner announced that the classic UI is retired and the redesigned UI becomes the default in 3.3.0. This removes the classic tree and the machinery that chose between the two. Removed: - dojo/templates_classic/ (the Bootstrap 3 / SB Admin 2 tree) - dojo/template_loaders.py: UIPreferenceLoader picked a tree per request - UserContactInfo.ui_use_tailwind and the opt-in banner it gated - dojo/static/dojo/{css,js}/classic/ and the vendored Bootswatch build - 16 npm dependencies that only the classic UI used TEMPLATES is now a plain filesystem + app-dirs chain over dojo/templates, wrapped in the cached loader outside debug mode. The per-tree caching that UIPreferenceLoader did internally is preserved that way. Three templates were shadowed rather than superseded: UIPreferenceLoader searched both trees ahead of the filesystem loader, so the classic copy won for every user and the consolidated app-dir copies underneath had gone stale. Deleting the tree naively would have regressed them, so the live versions are ported across: - notifications/mail/scan_added.tpl and webhooks/scan_added.tpl kept the duplicate-findings sections added in #15007 - dojo/action_history.html kept has_active_filters from #15082 Two exceptions keep the app-dir copy deliberately: alert/other.tpl, whose |safe removal (344c913128) the classic copy never received, so escaping in alert notifications is restored; and the GitHub form templates, which the Tailwind rebuild edited on purpose. DataTables no longer loads the Bootstrap styling integrations; markup is styled against DataTables' own dt-* classes, which datatables-dd.css was already written for. Two visual regressions this surfaced are fixed here: the control row is inlined again (the integration supplied form-inline), and report_base.html states its heading scale and opts out of the app's uppercase h6 label styling, both of which the vendored Bootstrap build used to provide. Verified against a running instance, before and after, plus 163 unit tests covering report rendering, search, notifications, and audit log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEcRPkdyRP6wtEnqVkSB9a * chore(deps): drop three unused npm packages and fix report icons Follow-up audit of the Dockerfiles and package.json. The Dockerfiles referenced none of the removed paths, so they needed no change. collectstatic copies all of components/node_modules into the nginx image, so an unused dependency is shipped, not just installed: - alpinejs and htmx.org: the app loads committed vendored copies from dojo/static/dojo/js/vendor/, and nothing references the npm paths - font-awesome: only report_base.html loaded it, and it was the v4 build Dropping font-awesome means reports use the v6 build that the rest of the app already uses, which ships with the fontawesomefree pip package rather than npm. That also explains a comment repeated across seven report templates -- "for some reason the font-awesome icons don't work with the report template". Two causes, both fixed here: the templates use v6 class names (fa-solid) that the v4 build does not define, and report_base.html sets font-family on `*` with !important, which overrode the icon font and rendered every icon as a missing glyph. Verified in the browser: icons resolve to Font Awesome 6 Free and render at full width instead of a fallback box, with body font and heading scale unchanged. Dependencies are down to 19, from 37 before this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEcRPkdyRP6wtEnqVkSB9a * fix(ui): restore the handles the UI suite navigates by, and the jQuery dropdown API With the classic tree gone the admin user renders the redesigned templates, and the Selenium suite lost three things it had been navigating by. None of the failures were assertion failures -- every one was a NoSuchElementException. The sidebar repeats every product tab label. "Engagements", "Findings", "Endpoints", "Components" and "Metrics" all appear in the sidebar, which renders ~60 anchors before the product tab bar, so By.LINK_TEXT / By.PARTIAL_LINK_TEXT resolved to the sidebar entry. Those entries are bound to Alpine with @click.prevent, so the click never navigated -- it expanded a submenu, left the tab's dropdown closed, and the failure surfaced one step later on a menu item that was in the DOM but not rendered, so link text could not match it. The tab bar now carries data-testid handles and tests go through a new BaseTestCase.open_product_tab() helper that documents the trap. The classic tree carried ids the redesigned tree never picked up: simple_search_submit on the search button, product_component_view and id_user_menu and menu_configuration in the sidebar. They are restored on the equivalent elements, under the same permission gates. The Configuration section expands on click rather than hover, so the one test that hovered it now clicks. $.fn.dropdown was missing. index.js replaces bootstrap.min.js by delegating clicks, but never registered the jQuery plugin, so the inline onclick handlers in view_test, findings_list_snippet, finding_related_actions and view_objects died with "$(...).dropdown is not a function". It is now backed by the same open/close logic, resolving the toggle, the .dropdown container and the .dropdown-menu alike. This one was user-visible, not just a test problem. The eight reported failures were only the first failure in each of the nine CI groups: the runner uses failfast=True and the entrypoint stops a group at its first failing file, so most of the suite never ran. Fixing only those eight would have surfaced the next one a CI cycle later, so the same breakage is fixed at all ~30 affected call sites, and all 41 files in the UI matrix were run locally. Also adds data-testid="report-link" to the engagement, test and organization report menu items, which By.PARTIAL_LINK_TEXT "Report" could no longer reach past the sidebar's "Reports" section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rpv46XWjWXCWTQSkawKSHp * test(ui): stop the footer eating submit clicks on the long forms The engagement and add-test forms run long enough that their button row sits next to the page footer, and the footer wins the click. Selenium sometimes says so (ElementClickInterceptedException naming #footer-wrapper) and sometimes the click simply lands on the footer: the form is never submitted, the page never changes, and the test fails much later asserting a success banner that was never going to appear. A page-source dump taken at one such failure shows the browser still sitting on a pristine "New Interactive Engagement" form -- no success alert, no error alert, no errorlist. click_submit() centres the button in the viewport before clicking it, which takes it out from under the footer. Measured on threat_model_test.py, the file that flaked most readily: 4 failures in 12 runs before, 0 in 12 after. is_success_message_present() now waits for the banner instead of sampling once. It only renders after the redirect that follows a POST, so the assertion was riding on the 1s implicit wait -- fine locally, not always enough on a loaded runner. Every caller asserts the banner is present and nothing asserts its absence, so the wait costs nothing on the happy path and only delays a genuine failure. Both are applied to the two forms where interception was actually observed rather than to every submit in the suite. Two things deliberately NOT done. Targeting the "Done" button instead of the first input.btn.btn-primary looks like the obvious tightening -- new_eng.html renders "Import Scan Results", "Add Tests" then "Done", so the bare selector submits the first one while the comment says Done -- but "Done" is the bottom-right button, nearest the footer, and switching to it made threat_model_test and calendar_test fail outright. That ordering is also byte-identical in the template the classic UI used, so it is long-standing and not a regression. And the footer overlap itself is left alone: #footer-wrapper is position:static in normal flow and does not overlap the buttons at rest, so this is a layout shift during load, not a stylesheet bug with an obvious fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rpv46XWjWXCWTQSkawKSHp * test(ui): route the settings-form saves through click_submit too The previous commit hardened the engagement and add-test forms against the footer swallowing a submit click, and CI then failed in the one place it had not covered: enable_false_positive_history -> change_system_setting AssertionError: False is not true # assertTrue(is_enabled) after the save Same mechanism, on the system settings form -- also long enough to put its save button next to the footer. This one matters more than most: set_suite_settings() calls change_system_setting() at the head of nearly every file in the suite, so a click that lands on the footer there takes the whole file down before its first real test. set_block_execution() and set_deduplication_execution_mode() submit the same way on the profile form and get the same treatment. The notification helpers submit that form too and are deliberately left alone. notifications_test.py fails roughly one run in three locally with or without the change -- on a different test each time -- so there is no signal to act on, and it has passed every CI run so far. Patching it would have been guesswork. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rpv46XWjWXCWTQSkawKSHp * test(ui): stop toggling false positive history while deduplication is on The settings view refuses this combination outright: "Settings cannot be saved: Deduplicate findings and False positive history can not be set at the same time." test_toggle_deduplication runs immediately before test_toggle_false_positive_history and deliberately ends with deduplication ON, so the very next test asks for a save the view will not perform, and change_system_setting()'s assertion fails. This has been latent, not new: the save was already being refused, but the click that submitted it was landing on the footer often enough that deduplication never actually persisted, so the next test found it off and passed. Fixing the swallowed click made the earlier test's effect stick, which is what surfaced this. Confirmed against a running instance -- setting deduplication on and then enabling false positive history through the UI produces exactly that warning and no save. So turn deduplication off around the false-positive-history toggle and restore it afterwards, which keeps both contracts: the file still leaves deduplication enabled for dedupe_test.py, and false positive history disabled as its default. Verified with deduplication pre-set to on, the state CI reaches: all six files in this group pass, and the settings land at deduplication on / false positive history off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rpv46XWjWXCWTQSkawKSHp * test(ui): centre the checkbox clicks too, not just the submits A lost click on a checkbox is worse than a lost click on a submit button, and until now only the submits were protected. When the footer eats a submit, the form is simply never sent. When it eats a checkbox, the submit still happens and the form saves with the checkbox in its old state, so the setting silently does not change and the failure lands somewhere that looks unrelated. That is what CI was showing. Once the submits stopped being lost, the failures moved onto the checkboxes further down the same long form: enable_false_positive_history -> assertTrue(is_enabled) (earlier) enable_retroactive_false_positive_history -> assertTrue(is_enabled) The settings view re-renders the bound form after a POST, so the checkbox in the response reflects what was submitted, not what was saved. change_system_setting() asserting False can therefore only mean the checkbox click never registered -- the save itself would have re-rendered it checked either way. click_submit() is now a thin wrapper over a general click_centered(), used for the setting toggles in change_system_setting() and set_block_execution() as well. notifications_test.py gets the same treatment, and this reverses an earlier call. I had left it alone after a single run suggested the change made things worse, but that run was against a database dirtied by dozens of earlier files. On a clean database -- what CI actually gives each group -- the unpatched file fails at test_enable_personal_notification with the mail setting simply not applied, which is the lost-click signature exactly. Patched, it passes 22/22. Verified on a fresh database: all of groups 04 and 05 pass, plus product_test, dedupe_test, close_old_findings_test and threat_model_test as the heaviest users of the changed helper -- 290 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rpv46XWjWXCWTQSkawKSHp * test(ui): route every form submit through click_submit() Fixing these one at a time has cost a CI cycle per site. Each run surfaced the next lost click a little further along -- the engagement submit, then the settings save, then the false positive history toggle, then the retroactive toggle, then the CI/CD engagement's Done button -- always the same cause, the footer taking a click on a long form, only ever a different button. So convert the whole class at once: all 89 form-submit clicks across 29 files. The change is mechanical and semantically identical -- same selector, same element, centred in the viewport before the click. The one time centring made things worse was when it was paired with changing WHICH button was clicked (targeting Done instead of the first primary submit, which moved the click nearer the footer rather than away from it); that is not what this does. The last CI run left group 02 failing on exactly this, in engagement_test.py, where the submit is input[value='Done'] -- the bottom-right button and so the most footer-exposed of the three. Verified over the first 34 files of the UI matrix on a clean database, 0 failures, covering every group that has failed at any point (01, 02, 04, 05) including engagement_test.py itself. The remaining files were still running when this went out; they are all the same transformation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rpv46XWjWXCWTQSkawKSHp --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- components/package.json | 22 +- .../templates/dojo/action_history.html | 5 +- dojo/context_processors.py | 26 - ..._remove_usercontactinfo_ui_use_tailwind.py | 15 + .../notifications/mail/scan_added.tpl | 33 + .../notifications/webhooks/scan_added.tpl | 8 +- dojo/settings/settings.dist.py | 46 +- .../css/bootswatch/yeti/bootstrap.min.css | 12 - dojo/static/dojo/css/classic/dojo.css | 1994 ------------- dojo/static/dojo/css/datatables-dd.css | 46 +- dojo/static/dojo/js/classic/index.js | 367 --- dojo/static/dojo/js/classic/metrics.js | 2465 ----------------- dojo/static/dojo/js/index.js | 32 + dojo/template_loaders.py | 82 - dojo/templates/base.html | 29 +- dojo/templates/dojo/product_type.html | 2 +- dojo/templates/dojo/view_eng.html | 2 +- dojo/templates/dojo/view_test.html | 2 +- dojo/templates/report_base.html | 40 +- dojo/templates_classic/400.html | 17 - dojo/templates_classic/403.html | 17 - dojo/templates_classic/404.html | 17 - dojo/templates_classic/500.html | 17 - dojo/templates_classic/base.html | 1268 --------- .../add_choices.html | 18 - .../add_engagement.html | 14 - .../add_survey.html | 26 - .../add_surveys.html | 3 - .../answer_survey.html | 25 - .../assign_survey.html | 14 - .../create_questionnaire.html | 27 - .../create_related_question.html | 88 - .../delete_questionnaire.html | 46 - .../edit_question.html | 30 - .../edit_survey_questions.html | 30 - .../existing_engagement.html | 15 - .../list_questions.html | 85 - .../list_surveys.html | 170 -- .../survey_fields.html | 95 - .../defectDojo-engagement-survey/surveys.html | 97 - .../view_survey.html | 23 - dojo/templates_classic/disabled.html | 9 - .../dojo/action_history.html | 194 -- .../dojo/ad_hoc_findings.html | 92 - dojo/templates_classic/dojo/add_endpoint.html | 31 - dojo/templates_classic/dojo/add_findings.html | 159 -- .../dojo/add_findings_as_accepted.html | 56 - .../templates_classic/dojo/add_note_type.html | 13 - .../dojo/add_notification_webhook.html | 13 - .../add_product_api_scan_configuration.html | 35 - dojo/templates_classic/dojo/add_related.html | 100 - .../dojo/add_risk_acceptance.html | 69 - dojo/templates_classic/dojo/add_template.html | 132 - dojo/templates_classic/dojo/add_tests.html | 32 - dojo/templates_classic/dojo/add_user.html | 35 - dojo/templates_classic/dojo/alerts.html | 71 - dojo/templates_classic/dojo/announcement.html | 16 - dojo/templates_classic/dojo/api_v2_key.html | 42 - .../dojo/apply_finding_template.html | 110 - .../apply_finding_template_form_fields.html | 111 - .../authorize_user_for_product_types.html | 19 - .../dojo/authorize_user_for_products.html | 19 - dojo/templates_classic/dojo/banner.html | 12 - dojo/templates_classic/dojo/benchmark.html | 414 --- .../dojo/breadcrumbs/custom_breadcrumb.html | 12 - .../dojo/breadcrumbs/endpoint_breadcrumb.html | 21 - .../breadcrumbs/engagement_breadcrumb.html | 16 - .../dojo/breadcrumbs/finding_breadcrumb.html | 12 - .../dojo/breadcrumbs/settings_breadcrumb.html | 10 - dojo/templates_classic/dojo/calendar.html | 91 - .../templates_classic/dojo/celery_status.html | 305 -- dojo/templates_classic/dojo/change_pwd.html | 17 - dojo/templates_classic/dojo/checklist.html | 16 - .../dojo/cicd_infrastructure.html | 81 - .../dojo/clear_finding_review.html | 25 - .../templates_classic/dojo/close_finding.html | 28 - dojo/templates_classic/dojo/components.html | 181 -- dojo/templates_classic/dojo/copy_object.html | 26 - .../dojo/custom_html_report.html | 18 - .../dojo/custom_html_report_cover_page.html | 11 - .../custom_html_report_endpoint_list.html | 200 -- .../dojo/custom_html_report_finding_list.html | 203 -- .../custom_html_report_wysiwyg_content.html | 9 - .../dojo/custom_html_toc.html | 85 - .../dojo/dashboard-metrics.html | 539 ---- dojo/templates_classic/dojo/dashboard.html | 378 --- .../dojo/defect_finding_review.html | 17 - .../templates_classic/dojo/delete_alerts.html | 36 - .../dojo/delete_benchmark.html | 25 - .../dojo/delete_cicd_infrastructure.html | 13 - .../dojo/delete_endpoint.html | 28 - .../dojo/delete_engagement.html | 29 - .../dojo/delete_finding_group.html | 29 - .../templates_classic/dojo/delete_github.html | 29 - dojo/templates_classic/dojo/delete_jira.html | 29 - .../dojo/delete_notification_webhook.html | 12 - .../templates_classic/dojo/delete_object.html | 21 - .../dojo/delete_presets.html | 29 - .../dojo/delete_product.html | 26 - ...delete_product_api_scan_configuration.html | 20 - .../dojo/delete_product_type.html | 29 - .../dojo/delete_technology.html | 12 - dojo/templates_classic/dojo/delete_test.html | 29 - .../dojo/delete_tool_product.html | 21 - dojo/templates_classic/dojo/delete_user.html | 27 - dojo/templates_classic/dojo/dev_env.html | 87 - .../dojo/disable_note_type.html | 13 - .../dojo/dismiss_announcement.html | 24 - .../dojo/edit_cicd_infrastructure.html | 13 - dojo/templates_classic/dojo/edit_dev_env.html | 18 - .../templates_classic/dojo/edit_endpoint.html | 24 - dojo/templates_classic/dojo/edit_finding.html | 235 -- dojo/templates_classic/dojo/edit_jira.html | 18 - .../templates_classic/dojo/edit_metadata.html | 23 - dojo/templates_classic/dojo/edit_note.html | 14 - .../dojo/edit_note_type.html | 13 - .../dojo/edit_notification_webhook.html | 15 - dojo/templates_classic/dojo/edit_object.html | 18 - dojo/templates_classic/dojo/edit_presets.html | 59 - dojo/templates_classic/dojo/edit_product.html | 72 - .../edit_product_api_scan_configuration.html | 33 - .../dojo/edit_product_type.html | 60 - .../dojo/edit_regulation.html | 22 - .../dojo/edit_sla_config.html | 22 - .../dojo/edit_technology.html | 13 - dojo/templates_classic/dojo/edit_test.html | 25 - .../dojo/edit_test_type.html | 13 - .../dojo/edit_tool_config.html | 18 - .../dojo/edit_tool_product.html | 18 - .../dojo/edit_tool_type.html | 19 - .../dojo/enable_note_type.html | 13 - .../dojo/endpoint_meta_importer.html | 35 - .../dojo/endpoint_pdf_report.html | 437 --- dojo/templates_classic/dojo/endpoints.html | 277 -- dojo/templates_classic/dojo/engagement.html | 244 -- .../dojo/engagement_pdf_report.html | 587 ---- .../dojo/engagements_all.html | 285 -- .../dojo/engineer_metrics.html | 76 - .../dojo/filter_js_snippet.html | 60 - .../dojo/filter_snippet.html | 163 -- .../dojo/finding_groups_list.html | 8 - .../dojo/finding_groups_list_snippet.html | 255 -- .../dojo/finding_pdf_report.html | 420 --- .../dojo/finding_related_actions.html | 38 - .../dojo/finding_related_list.html | 33 - .../dojo/finding_related_row.html | 74 - .../templates_classic/dojo/findings_list.html | 8 - .../dojo/findings_list_snippet.html | 1229 -------- dojo/templates_classic/dojo/form_fields.html | 101 - dojo/templates_classic/dojo/github.html | 78 - .../dojo/import_scan_results.html | 75 - dojo/templates_classic/dojo/jira.html | 103 - dojo/templates_classic/dojo/login.html | 46 - dojo/templates_classic/dojo/manage_files.html | 32 - .../templates_classic/dojo/manage_images.html | 29 - .../dojo/merge_findings.html | 60 - dojo/templates_classic/dojo/metrics.html | 967 ------- .../dojo/migrate_endpoints.html | 33 - .../dojo/new_cicd_infrastructure.html | 13 - dojo/templates_classic/dojo/new_dev_env.html | 13 - dojo/templates_classic/dojo/new_eng.html | 111 - dojo/templates_classic/dojo/new_github.html | 13 - dojo/templates_classic/dojo/new_jira.html | 16 - .../dojo/new_jira_advanced.html | 13 - dojo/templates_classic/dojo/new_object.html | 18 - dojo/templates_classic/dojo/new_params.html | 58 - dojo/templates_classic/dojo/new_product.html | 69 - .../dojo/new_product_authorized_users.html | 19 - .../dojo/new_product_type.html | 60 - .../new_product_type_authorized_users.html | 19 - .../dojo/new_regulation.html | 13 - .../dojo/new_sla_config.html | 13 - dojo/templates_classic/dojo/new_tech.html | 13 - .../templates_classic/dojo/new_test_type.html | 13 - .../dojo/new_tool_config.html | 13 - .../dojo/new_tool_product.html | 13 - .../templates_classic/dojo/new_tool_type.html | 15 - dojo/templates_classic/dojo/note_type.html | 135 - .../templates_classic/dojo/notifications.html | 168 -- .../dojo/paging_snippet.html | 53 - dojo/templates_classic/dojo/product.html | 437 --- .../dojo/product_components.html | 178 -- .../dojo/product_endpoint_pdf_report.html | 565 ---- .../dojo/product_metrics.html | 1300 --------- .../dojo/product_pdf_report.html | 582 ---- dojo/templates_classic/dojo/product_type.html | 158 -- .../dojo/product_type_pdf_report.html | 465 ---- dojo/templates_classic/dojo/profile.html | 42 - dojo/templates_classic/dojo/pt_counts.html | 280 -- dojo/templates_classic/dojo/regulations.html | 94 - .../dojo/regulations_config.html | 70 - .../dojo/remediation_date.html | 20 - .../dojo/report_builder.html | 407 --- .../dojo/report_cover_page.html | 20 - .../dojo/report_endpoints.html | 64 - .../dojo/report_filter_snippet.html | 34 - .../dojo/report_findings.html | 71 - .../templates_classic/dojo/report_widget.html | 20 - .../dojo/request_endpoint_report.html | 97 - .../dojo/request_report.html | 116 - .../dojo/review_finding.html | 25 - .../dojo/simple_metrics.html | 66 - .../templates_classic/dojo/simple_search.html | 562 ---- dojo/templates_classic/dojo/sla_config.html | 92 - .../dojo/snippets/comments.html | 85 - .../dojo/snippets/endpoints.html | 280 -- .../dojo/snippets/engagement_list.html | 367 --- .../dojo/snippets/file_images.html | 29 - .../risk_acceptance_actions_snippet.html | 54 - .../risk_acceptance_actions_snippet_js.html | 22 - .../snippets/selectpicker_in_dropdown.html | 39 - .../dojo/snippets/sonarqube_history.html | 29 - .../templates_classic/dojo/snippets/tags.html | 13 - dojo/templates_classic/dojo/support.html | 82 - .../dojo/system_settings.html | 92 - dojo/templates_classic/dojo/templates.html | 195 -- .../dojo/test_pdf_report.html | 605 ---- dojo/templates_classic/dojo/test_type.html | 86 - dojo/templates_classic/dojo/tool_config.html | 85 - dojo/templates_classic/dojo/tool_type.html | 78 - dojo/templates_classic/dojo/up_threat.html | 13 - dojo/templates_classic/dojo/url/create.html | 24 - dojo/templates_classic/dojo/url/delete.html | 38 - dojo/templates_classic/dojo/url/list.html | 276 -- dojo/templates_classic/dojo/url/update.html | 24 - dojo/templates_classic/dojo/url/view.html | 410 --- dojo/templates_classic/dojo/users.html | 159 -- .../dojo/verify_finding.html | 18 - .../templates_classic/dojo/view_endpoint.html | 418 --- dojo/templates_classic/dojo/view_eng.html | 1031 ------- .../dojo/view_engagements.html | 61 - .../templates_classic/dojo/view_engineer.html | 655 ----- dojo/templates_classic/dojo/view_finding.html | 1600 ----------- .../dojo/view_finding_group.html | 30 - .../dojo/view_note_history.html | 35 - .../dojo/view_notification_webhooks.html | 101 - dojo/templates_classic/dojo/view_objects.html | 146 - .../dojo/view_objects_eng.html | 130 - dojo/templates_classic/dojo/view_presets.html | 69 - .../view_product_api_scan_configurations.html | 68 - .../dojo/view_product_details.html | 645 ----- .../dojo/view_product_type.html | 250 -- .../dojo/view_risk_acceptance.html | 439 --- dojo/templates_classic/dojo/view_test.html | 1872 ------------- .../dojo/view_tool_product_all.html | 69 - dojo/templates_classic/dojo/view_user.html | 399 --- .../templates_classic/google_sheet_error.html | 9 - .../jira_full/jira-description.tpl | 104 - .../jira-finding-group-description.tpl | 104 - .../jira_limited/jira-description.tpl | 17 - .../jira-finding-group-description.tpl | 39 - .../login/forgot_password.tpl | 12 - .../login/forgot_username.html | 17 - .../login/forgot_username.tpl | 10 - .../login/forgot_username_done.html | 10 - .../login/forgot_username_subject.html | 3 - .../login/password_reset.html | 18 - .../login/password_reset_complete.html | 14 - .../login/password_reset_confirm.html | 23 - .../login/password_reset_done.html | 10 - .../notifications/alert/engagement_added.tpl | 3 - .../notifications/alert/engagement_closed.tpl | 3 - .../notifications/alert/other.tpl | 1 - .../notifications/alert/product_added.tpl | 3 - .../alert/product_type_added.tpl | 3 - .../notifications/alert/review_requested.tpl | 20 - .../notifications/alert/scan_added_empty.tpl | 1 - .../notifications/alert/sla_breach.tpl | 3 - .../notifications/alert/test_added.tpl | 3 - .../alert/upcoming_engagement.tpl | 3 - .../notifications/alert/user_mentioned.tpl | 4 - .../notifications/mail/engagement_added.tpl | 41 - .../notifications/mail/engagement_closed.tpl | 41 - .../notifications/mail/other.tpl | 43 - .../notifications/mail/product_added.tpl | 40 - .../notifications/mail/product_type_added.tpl | 57 - .../notifications/mail/review_requested.tpl | 45 - .../mail/risk_acceptance_expiration.tpl | 64 - .../notifications/mail/scan_added.tpl | 117 - .../notifications/mail/scan_added_empty.tpl | 1 - .../notifications/mail/sla_breach.tpl | 57 - .../mail/sla_breach_combined.tpl | 72 - .../notifications/mail/test_added.tpl | 42 - .../mail/upcoming_engagement.tpl | 40 - .../notifications/mail/user_mentioned.tpl | 43 - .../msteams/engagement_added.tpl | 103 - .../msteams/engagement_closed.tpl | 103 - .../notifications/msteams/other.tpl | 81 - .../notifications/msteams/product_added.tpl | 107 - .../msteams/product_type_added.tpl | 99 - .../msteams/review_requested.tpl | 124 - .../msteams/risk_acceptance_expiration.tpl | 107 - .../notifications/msteams/scan_added.tpl | 123 - .../msteams/scan_added_empty.tpl | 1 - .../notifications/msteams/sla_breach.tpl | 107 - .../notifications/msteams/test_added.tpl | 103 - .../msteams/upcoming_engagement.tpl | 103 - .../notifications/msteams/user_mentioned.tpl | 107 - .../notifications/slack/engagement_added.tpl | 10 - .../notifications/slack/engagement_closed.tpl | 10 - .../notifications/slack/other.tpl | 13 - .../notifications/slack/product_added.tpl | 10 - .../slack/product_type_added.tpl | 10 - .../notifications/slack/report_created.tpl | 10 - .../notifications/slack/review_requested.tpl | 21 - .../slack/risk_acceptance_expiration.tpl | 18 - .../notifications/slack/scan_added.tpl | 15 - .../notifications/slack/scan_added_empty.tpl | 1 - .../notifications/slack/sla_breach.tpl | 13 - .../notifications/slack/test_added.tpl | 13 - .../slack/upcoming_engagement.tpl | 8 - .../notifications/slack/user_mentioned.tpl | 12 - .../webhooks/engagement_added.tpl | 2 - .../notifications/webhooks/other.tpl | 1 - .../notifications/webhooks/product_added.tpl | 2 - .../webhooks/product_type_added.tpl | 2 - .../notifications/webhooks/scan_added.tpl | 18 - .../webhooks/scan_added_empty.tpl | 1 - .../webhooks/subtemplates/base.tpl | 15 - .../webhooks/subtemplates/engagement.tpl | 14 - .../webhooks/subtemplates/findings_list.tpl | 13 - .../webhooks/subtemplates/product.tpl | 14 - .../webhooks/subtemplates/product_type.tpl | 9 - .../webhooks/subtemplates/test.tpl | 14 - .../webhooks/subtemplates/user.tpl | 16 - .../notifications/webhooks/test_added.tpl | 2 - dojo/templates_classic/pt_nav_items.html | 3 - dojo/templates_classic/report_base.html | 226 -- dojo/user/models.py | 1 - dojo/user/ui/forms.py | 2 +- dojo/user/ui/views.py | 8 +- tests/announcement_banner_test.py | 6 +- tests/banner_test.py | 4 +- tests/base_test_class.py | 74 +- tests/calendar_test.py | 4 +- tests/check_various_pages.py | 3 +- tests/close_old_findings_dedupe_test.py | 2 +- tests/dedupe_test.py | 14 +- tests/endpoint_extended_test.py | 4 +- tests/endpoint_test.py | 16 +- tests/engagement_checklist_test.py | 2 +- tests/engagement_extended_test.py | 16 +- tests/engagement_presets_test.py | 4 +- tests/engagement_test.py | 8 +- tests/environment_test.py | 6 +- tests/false_positive_history_test.py | 4 +- tests/finding_extended_test.py | 6 +- tests/finding_test.py | 6 +- tests/ibm_appscan_test.py | 2 +- tests/note_type_test.py | 8 +- tests/notification_webhook_test.py | 10 +- tests/notifications_test.py | 20 +- tests/object_test.py | 4 +- tests/product_member_test.py | 4 +- tests/product_test.py | 38 +- tests/product_type_member_test.py | 4 +- tests/product_type_test.py | 6 +- tests/questionnaire_advanced_test.py | 6 +- tests/questionnaire_test.py | 4 +- tests/report_builder_test.py | 12 +- tests/risk_acceptance_test.py | 4 +- tests/sla_configuration_test.py | 2 +- tests/system_settings_test.py | 15 +- tests/test_copy_test.py | 10 +- tests/test_test.py | 6 +- tests/test_type_test.py | 4 +- tests/threat_model_test.py | 6 +- tests/tool_config.py | 6 +- tests/tool_product_test.py | 8 +- tests/tool_type_test.py | 4 +- tests/user_profile_test.py | 2 +- tests/user_test.py | 10 +- unittests/test_os_message.py | 3 +- unittests/test_simple_search_tab_panes.py | 31 +- 374 files changed, 438 insertions(+), 39216 deletions(-) create mode 100644 dojo/db_migrations/0289_remove_usercontactinfo_ui_use_tailwind.py delete mode 100644 dojo/static/dojo/css/bootswatch/yeti/bootstrap.min.css delete mode 100644 dojo/static/dojo/css/classic/dojo.css delete mode 100644 dojo/static/dojo/js/classic/index.js delete mode 100644 dojo/static/dojo/js/classic/metrics.js delete mode 100644 dojo/template_loaders.py delete mode 100644 dojo/templates_classic/400.html delete mode 100644 dojo/templates_classic/403.html delete mode 100644 dojo/templates_classic/404.html delete mode 100644 dojo/templates_classic/500.html delete mode 100644 dojo/templates_classic/base.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/add_choices.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/add_engagement.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/add_survey.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/add_surveys.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/answer_survey.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/assign_survey.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/create_questionnaire.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/create_related_question.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/delete_questionnaire.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/edit_question.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/edit_survey_questions.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/existing_engagement.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/list_questions.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/list_surveys.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/survey_fields.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/surveys.html delete mode 100644 dojo/templates_classic/defectDojo-engagement-survey/view_survey.html delete mode 100644 dojo/templates_classic/disabled.html delete mode 100644 dojo/templates_classic/dojo/action_history.html delete mode 100644 dojo/templates_classic/dojo/ad_hoc_findings.html delete mode 100644 dojo/templates_classic/dojo/add_endpoint.html delete mode 100644 dojo/templates_classic/dojo/add_findings.html delete mode 100644 dojo/templates_classic/dojo/add_findings_as_accepted.html delete mode 100644 dojo/templates_classic/dojo/add_note_type.html delete mode 100644 dojo/templates_classic/dojo/add_notification_webhook.html delete mode 100644 dojo/templates_classic/dojo/add_product_api_scan_configuration.html delete mode 100644 dojo/templates_classic/dojo/add_related.html delete mode 100644 dojo/templates_classic/dojo/add_risk_acceptance.html delete mode 100644 dojo/templates_classic/dojo/add_template.html delete mode 100644 dojo/templates_classic/dojo/add_tests.html delete mode 100644 dojo/templates_classic/dojo/add_user.html delete mode 100644 dojo/templates_classic/dojo/alerts.html delete mode 100644 dojo/templates_classic/dojo/announcement.html delete mode 100644 dojo/templates_classic/dojo/api_v2_key.html delete mode 100755 dojo/templates_classic/dojo/apply_finding_template.html delete mode 100755 dojo/templates_classic/dojo/apply_finding_template_form_fields.html delete mode 100644 dojo/templates_classic/dojo/authorize_user_for_product_types.html delete mode 100644 dojo/templates_classic/dojo/authorize_user_for_products.html delete mode 100644 dojo/templates_classic/dojo/banner.html delete mode 100644 dojo/templates_classic/dojo/benchmark.html delete mode 100644 dojo/templates_classic/dojo/breadcrumbs/custom_breadcrumb.html delete mode 100644 dojo/templates_classic/dojo/breadcrumbs/endpoint_breadcrumb.html delete mode 100644 dojo/templates_classic/dojo/breadcrumbs/engagement_breadcrumb.html delete mode 100644 dojo/templates_classic/dojo/breadcrumbs/finding_breadcrumb.html delete mode 100644 dojo/templates_classic/dojo/breadcrumbs/settings_breadcrumb.html delete mode 100644 dojo/templates_classic/dojo/calendar.html delete mode 100644 dojo/templates_classic/dojo/celery_status.html delete mode 100644 dojo/templates_classic/dojo/change_pwd.html delete mode 100644 dojo/templates_classic/dojo/checklist.html delete mode 100644 dojo/templates_classic/dojo/cicd_infrastructure.html delete mode 100644 dojo/templates_classic/dojo/clear_finding_review.html delete mode 100644 dojo/templates_classic/dojo/close_finding.html delete mode 100644 dojo/templates_classic/dojo/components.html delete mode 100644 dojo/templates_classic/dojo/copy_object.html delete mode 100644 dojo/templates_classic/dojo/custom_html_report.html delete mode 100644 dojo/templates_classic/dojo/custom_html_report_cover_page.html delete mode 100644 dojo/templates_classic/dojo/custom_html_report_endpoint_list.html delete mode 100644 dojo/templates_classic/dojo/custom_html_report_finding_list.html delete mode 100644 dojo/templates_classic/dojo/custom_html_report_wysiwyg_content.html delete mode 100644 dojo/templates_classic/dojo/custom_html_toc.html delete mode 100644 dojo/templates_classic/dojo/dashboard-metrics.html delete mode 100644 dojo/templates_classic/dojo/dashboard.html delete mode 100644 dojo/templates_classic/dojo/defect_finding_review.html delete mode 100644 dojo/templates_classic/dojo/delete_alerts.html delete mode 100644 dojo/templates_classic/dojo/delete_benchmark.html delete mode 100644 dojo/templates_classic/dojo/delete_cicd_infrastructure.html delete mode 100644 dojo/templates_classic/dojo/delete_endpoint.html delete mode 100644 dojo/templates_classic/dojo/delete_engagement.html delete mode 100644 dojo/templates_classic/dojo/delete_finding_group.html delete mode 100644 dojo/templates_classic/dojo/delete_github.html delete mode 100644 dojo/templates_classic/dojo/delete_jira.html delete mode 100644 dojo/templates_classic/dojo/delete_notification_webhook.html delete mode 100644 dojo/templates_classic/dojo/delete_object.html delete mode 100644 dojo/templates_classic/dojo/delete_presets.html delete mode 100644 dojo/templates_classic/dojo/delete_product.html delete mode 100644 dojo/templates_classic/dojo/delete_product_api_scan_configuration.html delete mode 100644 dojo/templates_classic/dojo/delete_product_type.html delete mode 100644 dojo/templates_classic/dojo/delete_technology.html delete mode 100644 dojo/templates_classic/dojo/delete_test.html delete mode 100644 dojo/templates_classic/dojo/delete_tool_product.html delete mode 100644 dojo/templates_classic/dojo/delete_user.html delete mode 100644 dojo/templates_classic/dojo/dev_env.html delete mode 100644 dojo/templates_classic/dojo/disable_note_type.html delete mode 100644 dojo/templates_classic/dojo/dismiss_announcement.html delete mode 100644 dojo/templates_classic/dojo/edit_cicd_infrastructure.html delete mode 100644 dojo/templates_classic/dojo/edit_dev_env.html delete mode 100644 dojo/templates_classic/dojo/edit_endpoint.html delete mode 100644 dojo/templates_classic/dojo/edit_finding.html delete mode 100644 dojo/templates_classic/dojo/edit_jira.html delete mode 100644 dojo/templates_classic/dojo/edit_metadata.html delete mode 100644 dojo/templates_classic/dojo/edit_note.html delete mode 100644 dojo/templates_classic/dojo/edit_note_type.html delete mode 100644 dojo/templates_classic/dojo/edit_notification_webhook.html delete mode 100644 dojo/templates_classic/dojo/edit_object.html delete mode 100644 dojo/templates_classic/dojo/edit_presets.html delete mode 100644 dojo/templates_classic/dojo/edit_product.html delete mode 100644 dojo/templates_classic/dojo/edit_product_api_scan_configuration.html delete mode 100644 dojo/templates_classic/dojo/edit_product_type.html delete mode 100644 dojo/templates_classic/dojo/edit_regulation.html delete mode 100644 dojo/templates_classic/dojo/edit_sla_config.html delete mode 100644 dojo/templates_classic/dojo/edit_technology.html delete mode 100644 dojo/templates_classic/dojo/edit_test.html delete mode 100644 dojo/templates_classic/dojo/edit_test_type.html delete mode 100644 dojo/templates_classic/dojo/edit_tool_config.html delete mode 100644 dojo/templates_classic/dojo/edit_tool_product.html delete mode 100644 dojo/templates_classic/dojo/edit_tool_type.html delete mode 100644 dojo/templates_classic/dojo/enable_note_type.html delete mode 100644 dojo/templates_classic/dojo/endpoint_meta_importer.html delete mode 100644 dojo/templates_classic/dojo/endpoint_pdf_report.html delete mode 100644 dojo/templates_classic/dojo/endpoints.html delete mode 100644 dojo/templates_classic/dojo/engagement.html delete mode 100644 dojo/templates_classic/dojo/engagement_pdf_report.html delete mode 100644 dojo/templates_classic/dojo/engagements_all.html delete mode 100644 dojo/templates_classic/dojo/engineer_metrics.html delete mode 100644 dojo/templates_classic/dojo/filter_js_snippet.html delete mode 100644 dojo/templates_classic/dojo/filter_snippet.html delete mode 100644 dojo/templates_classic/dojo/finding_groups_list.html delete mode 100644 dojo/templates_classic/dojo/finding_groups_list_snippet.html delete mode 100644 dojo/templates_classic/dojo/finding_pdf_report.html delete mode 100644 dojo/templates_classic/dojo/finding_related_actions.html delete mode 100644 dojo/templates_classic/dojo/finding_related_list.html delete mode 100644 dojo/templates_classic/dojo/finding_related_row.html delete mode 100644 dojo/templates_classic/dojo/findings_list.html delete mode 100644 dojo/templates_classic/dojo/findings_list_snippet.html delete mode 100644 dojo/templates_classic/dojo/form_fields.html delete mode 100644 dojo/templates_classic/dojo/github.html delete mode 100755 dojo/templates_classic/dojo/import_scan_results.html delete mode 100644 dojo/templates_classic/dojo/jira.html delete mode 100644 dojo/templates_classic/dojo/login.html delete mode 100644 dojo/templates_classic/dojo/manage_files.html delete mode 100644 dojo/templates_classic/dojo/manage_images.html delete mode 100644 dojo/templates_classic/dojo/merge_findings.html delete mode 100644 dojo/templates_classic/dojo/metrics.html delete mode 100644 dojo/templates_classic/dojo/migrate_endpoints.html delete mode 100644 dojo/templates_classic/dojo/new_cicd_infrastructure.html delete mode 100644 dojo/templates_classic/dojo/new_dev_env.html delete mode 100644 dojo/templates_classic/dojo/new_eng.html delete mode 100644 dojo/templates_classic/dojo/new_github.html delete mode 100644 dojo/templates_classic/dojo/new_jira.html delete mode 100644 dojo/templates_classic/dojo/new_jira_advanced.html delete mode 100644 dojo/templates_classic/dojo/new_object.html delete mode 100644 dojo/templates_classic/dojo/new_params.html delete mode 100644 dojo/templates_classic/dojo/new_product.html delete mode 100644 dojo/templates_classic/dojo/new_product_authorized_users.html delete mode 100644 dojo/templates_classic/dojo/new_product_type.html delete mode 100644 dojo/templates_classic/dojo/new_product_type_authorized_users.html delete mode 100644 dojo/templates_classic/dojo/new_regulation.html delete mode 100644 dojo/templates_classic/dojo/new_sla_config.html delete mode 100644 dojo/templates_classic/dojo/new_tech.html delete mode 100644 dojo/templates_classic/dojo/new_test_type.html delete mode 100644 dojo/templates_classic/dojo/new_tool_config.html delete mode 100644 dojo/templates_classic/dojo/new_tool_product.html delete mode 100644 dojo/templates_classic/dojo/new_tool_type.html delete mode 100644 dojo/templates_classic/dojo/note_type.html delete mode 100644 dojo/templates_classic/dojo/notifications.html delete mode 100644 dojo/templates_classic/dojo/paging_snippet.html delete mode 100644 dojo/templates_classic/dojo/product.html delete mode 100644 dojo/templates_classic/dojo/product_components.html delete mode 100644 dojo/templates_classic/dojo/product_endpoint_pdf_report.html delete mode 100644 dojo/templates_classic/dojo/product_metrics.html delete mode 100644 dojo/templates_classic/dojo/product_pdf_report.html delete mode 100644 dojo/templates_classic/dojo/product_type.html delete mode 100644 dojo/templates_classic/dojo/product_type_pdf_report.html delete mode 100644 dojo/templates_classic/dojo/profile.html delete mode 100644 dojo/templates_classic/dojo/pt_counts.html delete mode 100644 dojo/templates_classic/dojo/regulations.html delete mode 100644 dojo/templates_classic/dojo/regulations_config.html delete mode 100644 dojo/templates_classic/dojo/remediation_date.html delete mode 100644 dojo/templates_classic/dojo/report_builder.html delete mode 100644 dojo/templates_classic/dojo/report_cover_page.html delete mode 100644 dojo/templates_classic/dojo/report_endpoints.html delete mode 100644 dojo/templates_classic/dojo/report_filter_snippet.html delete mode 100644 dojo/templates_classic/dojo/report_findings.html delete mode 100644 dojo/templates_classic/dojo/report_widget.html delete mode 100644 dojo/templates_classic/dojo/request_endpoint_report.html delete mode 100644 dojo/templates_classic/dojo/request_report.html delete mode 100644 dojo/templates_classic/dojo/review_finding.html delete mode 100644 dojo/templates_classic/dojo/simple_metrics.html delete mode 100644 dojo/templates_classic/dojo/simple_search.html delete mode 100644 dojo/templates_classic/dojo/sla_config.html delete mode 100644 dojo/templates_classic/dojo/snippets/comments.html delete mode 100644 dojo/templates_classic/dojo/snippets/endpoints.html delete mode 100644 dojo/templates_classic/dojo/snippets/engagement_list.html delete mode 100644 dojo/templates_classic/dojo/snippets/file_images.html delete mode 100644 dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet.html delete mode 100644 dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet_js.html delete mode 100644 dojo/templates_classic/dojo/snippets/selectpicker_in_dropdown.html delete mode 100644 dojo/templates_classic/dojo/snippets/sonarqube_history.html delete mode 100644 dojo/templates_classic/dojo/snippets/tags.html delete mode 100644 dojo/templates_classic/dojo/support.html delete mode 100644 dojo/templates_classic/dojo/system_settings.html delete mode 100644 dojo/templates_classic/dojo/templates.html delete mode 100644 dojo/templates_classic/dojo/test_pdf_report.html delete mode 100644 dojo/templates_classic/dojo/test_type.html delete mode 100644 dojo/templates_classic/dojo/tool_config.html delete mode 100644 dojo/templates_classic/dojo/tool_type.html delete mode 100644 dojo/templates_classic/dojo/up_threat.html delete mode 100644 dojo/templates_classic/dojo/url/create.html delete mode 100644 dojo/templates_classic/dojo/url/delete.html delete mode 100644 dojo/templates_classic/dojo/url/list.html delete mode 100644 dojo/templates_classic/dojo/url/update.html delete mode 100644 dojo/templates_classic/dojo/url/view.html delete mode 100644 dojo/templates_classic/dojo/users.html delete mode 100644 dojo/templates_classic/dojo/verify_finding.html delete mode 100644 dojo/templates_classic/dojo/view_endpoint.html delete mode 100644 dojo/templates_classic/dojo/view_eng.html delete mode 100644 dojo/templates_classic/dojo/view_engagements.html delete mode 100644 dojo/templates_classic/dojo/view_engineer.html delete mode 100755 dojo/templates_classic/dojo/view_finding.html delete mode 100644 dojo/templates_classic/dojo/view_finding_group.html delete mode 100644 dojo/templates_classic/dojo/view_note_history.html delete mode 100644 dojo/templates_classic/dojo/view_notification_webhooks.html delete mode 100644 dojo/templates_classic/dojo/view_objects.html delete mode 100644 dojo/templates_classic/dojo/view_objects_eng.html delete mode 100644 dojo/templates_classic/dojo/view_presets.html delete mode 100644 dojo/templates_classic/dojo/view_product_api_scan_configurations.html delete mode 100644 dojo/templates_classic/dojo/view_product_details.html delete mode 100644 dojo/templates_classic/dojo/view_product_type.html delete mode 100644 dojo/templates_classic/dojo/view_risk_acceptance.html delete mode 100644 dojo/templates_classic/dojo/view_test.html delete mode 100644 dojo/templates_classic/dojo/view_tool_product_all.html delete mode 100644 dojo/templates_classic/dojo/view_user.html delete mode 100644 dojo/templates_classic/google_sheet_error.html delete mode 100644 dojo/templates_classic/issue-trackers/jira_full/jira-description.tpl delete mode 100644 dojo/templates_classic/issue-trackers/jira_full/jira-finding-group-description.tpl delete mode 100644 dojo/templates_classic/issue-trackers/jira_limited/jira-description.tpl delete mode 100644 dojo/templates_classic/issue-trackers/jira_limited/jira-finding-group-description.tpl delete mode 100644 dojo/templates_classic/login/forgot_password.tpl delete mode 100644 dojo/templates_classic/login/forgot_username.html delete mode 100644 dojo/templates_classic/login/forgot_username.tpl delete mode 100644 dojo/templates_classic/login/forgot_username_done.html delete mode 100644 dojo/templates_classic/login/forgot_username_subject.html delete mode 100644 dojo/templates_classic/login/password_reset.html delete mode 100644 dojo/templates_classic/login/password_reset_complete.html delete mode 100644 dojo/templates_classic/login/password_reset_confirm.html delete mode 100644 dojo/templates_classic/login/password_reset_done.html delete mode 100644 dojo/templates_classic/notifications/alert/engagement_added.tpl delete mode 100644 dojo/templates_classic/notifications/alert/engagement_closed.tpl delete mode 100644 dojo/templates_classic/notifications/alert/other.tpl delete mode 100644 dojo/templates_classic/notifications/alert/product_added.tpl delete mode 100644 dojo/templates_classic/notifications/alert/product_type_added.tpl delete mode 100644 dojo/templates_classic/notifications/alert/review_requested.tpl delete mode 100644 dojo/templates_classic/notifications/alert/scan_added_empty.tpl delete mode 100644 dojo/templates_classic/notifications/alert/sla_breach.tpl delete mode 100644 dojo/templates_classic/notifications/alert/test_added.tpl delete mode 100644 dojo/templates_classic/notifications/alert/upcoming_engagement.tpl delete mode 100644 dojo/templates_classic/notifications/alert/user_mentioned.tpl delete mode 100644 dojo/templates_classic/notifications/mail/engagement_added.tpl delete mode 100644 dojo/templates_classic/notifications/mail/engagement_closed.tpl delete mode 100644 dojo/templates_classic/notifications/mail/other.tpl delete mode 100644 dojo/templates_classic/notifications/mail/product_added.tpl delete mode 100644 dojo/templates_classic/notifications/mail/product_type_added.tpl delete mode 100644 dojo/templates_classic/notifications/mail/review_requested.tpl delete mode 100644 dojo/templates_classic/notifications/mail/risk_acceptance_expiration.tpl delete mode 100644 dojo/templates_classic/notifications/mail/scan_added.tpl delete mode 120000 dojo/templates_classic/notifications/mail/scan_added_empty.tpl delete mode 100644 dojo/templates_classic/notifications/mail/sla_breach.tpl delete mode 100644 dojo/templates_classic/notifications/mail/sla_breach_combined.tpl delete mode 100644 dojo/templates_classic/notifications/mail/test_added.tpl delete mode 100644 dojo/templates_classic/notifications/mail/upcoming_engagement.tpl delete mode 100644 dojo/templates_classic/notifications/mail/user_mentioned.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/engagement_added.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/engagement_closed.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/other.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/product_added.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/product_type_added.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/review_requested.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/risk_acceptance_expiration.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/scan_added.tpl delete mode 120000 dojo/templates_classic/notifications/msteams/scan_added_empty.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/sla_breach.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/test_added.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/upcoming_engagement.tpl delete mode 100644 dojo/templates_classic/notifications/msteams/user_mentioned.tpl delete mode 100644 dojo/templates_classic/notifications/slack/engagement_added.tpl delete mode 100644 dojo/templates_classic/notifications/slack/engagement_closed.tpl delete mode 100644 dojo/templates_classic/notifications/slack/other.tpl delete mode 100644 dojo/templates_classic/notifications/slack/product_added.tpl delete mode 100644 dojo/templates_classic/notifications/slack/product_type_added.tpl delete mode 100644 dojo/templates_classic/notifications/slack/report_created.tpl delete mode 100644 dojo/templates_classic/notifications/slack/review_requested.tpl delete mode 100644 dojo/templates_classic/notifications/slack/risk_acceptance_expiration.tpl delete mode 100644 dojo/templates_classic/notifications/slack/scan_added.tpl delete mode 120000 dojo/templates_classic/notifications/slack/scan_added_empty.tpl delete mode 100644 dojo/templates_classic/notifications/slack/sla_breach.tpl delete mode 100644 dojo/templates_classic/notifications/slack/test_added.tpl delete mode 100644 dojo/templates_classic/notifications/slack/upcoming_engagement.tpl delete mode 100644 dojo/templates_classic/notifications/slack/user_mentioned.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/engagement_added.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/other.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/product_added.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/product_type_added.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/scan_added.tpl delete mode 120000 dojo/templates_classic/notifications/webhooks/scan_added_empty.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/base.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/engagement.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/findings_list.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/product.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/product_type.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/test.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/subtemplates/user.tpl delete mode 100644 dojo/templates_classic/notifications/webhooks/test_added.tpl delete mode 100644 dojo/templates_classic/pt_nav_items.html delete mode 100644 dojo/templates_classic/report_base.html diff --git a/components/package.json b/components/package.json index c2b83f57d86..23f185dc627 100644 --- a/components/package.json +++ b/components/package.json @@ -5,11 +5,6 @@ "private": true, "dependencies": { "@fontsource-variable/work-sans": "^5.3", - "JUMFlot": "jumjum123/JUMFlot#*", - "alpinejs": "^3.15", - "bootstrap": "^3.4.1", - "bootstrap-select": "^1.13.18", - "bootstrap-social": "^4.0.0", "bootstrap-wysiwyg": "^2.0.0", "chart.js": "^4.4", "chartjs-adapter-moment": "^1.0", @@ -17,30 +12,17 @@ "chosen-js": "^1.8.7", "clipboard": "^2.0.11", "datatables.net": "^2.3.8", - "datatables.net-buttons-bs": "^3.2.6", + "datatables.net-buttons": "^3.2.6", "datatables.net-colreorder": "^2.1.2", - "drmonty-datatables-plugins": "^1.0.0", - "drmonty-datatables-responsive": "^1.0.0", "easymde": "^2.21.0", "flatpickr": "^4.6", - "flot": "flot/flot#~0.8.3", - "font-awesome": "^4.0.0", "fullcalendar": "^3.10.2", - "google-code-prettify": "^1.0.0", - "htmx.org": "^2.0", "jquery": "^3.7.1", - "jquery-highlight": "3.5.0", "jquery-ui": "1.14.2", - "jquery.cookie": "1.4.1", - "jquery.flot.tooltip": "^0.9.0", "jquery.hotkeys": "jeresig/jquery.hotkeys#master", "jszip": "^3.10.1", - "justgage": "^1.7.0", - "metismenu": "~3.0.7", "moment": "^2.30.1", - "morris.js": "morrisjs/morris.js", - "pdfmake": "^0.3.11", - "startbootstrap-sb-admin-2": "1.0.7" + "pdfmake": "^0.3.11" }, "devDependencies": { "@tailwindcss/cli": "^4.3", diff --git a/dojo/auditlog/templates/dojo/action_history.html b/dojo/auditlog/templates/dojo/action_history.html index 3a980234d55..0f16cebb181 100644 --- a/dojo/auditlog/templates/dojo/action_history.html +++ b/dojo/auditlog/templates/dojo/action_history.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load filter_tags %} {% load display_tags %} {% block content %} {{ block.super }} @@ -14,7 +15,7 @@ <h4> </div> </h4> </div> - <div id="pghistory-filters" class="is-filters panel-body collapse {% if pghistory_filter.form.has_changed %}in{% endif %}"> + <div id="pghistory-filters" class="is-filters panel-body collapse {% if pghistory_filter.form|has_active_filters %}in{% endif %}"> {% include "dojo/filter_snippet.html" with form=pghistory_filter.form %} </div> <div class="clearfix"> @@ -135,7 +136,7 @@ <h4> </div> </h4> </div> - <div id="the-filters" class="is-filters panel-body collapse {% if log_entry_filter.form.has_changed %}in{% endif %}"> + <div id="the-filters" class="is-filters panel-body collapse {% if log_entry_filter.form|has_active_filters %}in{% endif %}"> {% include "dojo/filter_snippet.html" with form=log_entry_filter.form %} </div> <div class="clearfix"> diff --git a/dojo/context_processors.py b/dojo/context_processors.py index cf9b154cf7d..aa3af4c2a57 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -3,7 +3,6 @@ # import the settings file from django.conf import settings from django.contrib import messages -from django.urls import NoReverseMatch, reverse from dojo.announcement.os_message import OS_MESSAGE_DISMISSED_KEY, get_os_banner from dojo.labels import get_labels @@ -47,37 +46,12 @@ def globalize_vars(request): for banner in request.session.pop("_product_banners", []): additional_banners.append(banner) - if _should_show_ui_toggle_banner(request): - try: - profile_url = reverse("view_profile") - except NoReverseMatch: - profile_url = "" - additional_banners.append({ - "source": "ui_toggle", - "message": "A redesigned UI is available as a beta opt-in. It will become the default on September 8th in the 3.3.0 release.", - "style": "info", - "url": profile_url, - "link_text": "Enable it in your profile.", - "expanded_html": None, - }) - if additional_banners: context["additional_banners"] = additional_banners return context -def _should_show_ui_toggle_banner(request): - user = getattr(request, "user", None) - if user is None or not getattr(user, "is_authenticated", False): - return False - contact = getattr(user, "usercontactinfo", None) - # Show the banner whenever the authenticated user has not opted into the - # Tailwind UI — that includes users without a contact info row at all - # (those users get the classic UI by default in UIPreferenceLoader). - return not (contact is not None and getattr(contact, "ui_use_tailwind", False)) - - def _os_message_dismissed(user, token): contact = getattr(user, "usercontactinfo", None) if contact is None: diff --git a/dojo/db_migrations/0289_remove_usercontactinfo_ui_use_tailwind.py b/dojo/db_migrations/0289_remove_usercontactinfo_ui_use_tailwind.py new file mode 100644 index 00000000000..03aab54e98d --- /dev/null +++ b/dojo/db_migrations/0289_remove_usercontactinfo_ui_use_tailwind.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dojo', '0288_backfill_vulnerability_id_entities'), + ] + + operations = [ + migrations.RemoveField( + model_name='usercontactinfo', + name='ui_use_tailwind', + ), + ] diff --git a/dojo/notifications/templates/notifications/mail/scan_added.tpl b/dojo/notifications/templates/notifications/mail/scan_added.tpl index 263585246e0..567d80ee47e 100644 --- a/dojo/notifications/templates/notifications/mail/scan_added.tpl +++ b/dojo/notifications/templates/notifications/mail/scan_added.tpl @@ -26,6 +26,17 @@ {% endfor %} </details> </p> + {% if findings_new_duplicate %} + <p> + <details> + <summary>{% blocktranslate %}New findings detected as duplicates{% endblocktranslate %} ({{ findings_new_duplicate | length }})</summary><br/> + {% for finding in findings_new_duplicate %} + {% url 'view_finding' finding.id as finding_url %} + <a href="{{ finding_url|full_url }}">{{ finding.title }}</a> ({{ finding.severity }})<br/> + {% endfor %} + </details> + </p> + {% endif %} <p> <details> <summary>{% blocktranslate %}Reactivated findings{% endblocktranslate %} ({{ findings_reactivated | length }})</summary><br/> @@ -37,6 +48,17 @@ {% endfor %} </details> </p> + {% if findings_reactivated_duplicate %} + <p> + <details> + <summary>{% blocktranslate %}Reactivated findings detected as duplicates{% endblocktranslate %} ({{ findings_reactivated_duplicate | length }})</summary><br/> + {% for finding in findings_reactivated_duplicate %} + {% url 'view_finding' finding.id as finding_url %} + <a href="{{ finding_url|full_url }}">{{ finding.title }}</a> ({{ finding.severity }})<br/> + {% endfor %} + </details> + </p> + {% endif %} <p> <details> <summary>{% blocktranslate %}Closed findings{% endblocktranslate %} ({{ findings_mitigated | length }})</summary><br/> @@ -59,6 +81,17 @@ {% endfor %} </details> </p> + {% if findings_untouched_duplicate %} + <p> + <details> + <summary>{% blocktranslate %}Existing findings detected as duplicates{% endblocktranslate %} ({{ findings_untouched_duplicate | length }})</summary><br/> + {% for finding in findings_untouched_duplicate %} + {% url 'view_finding' finding.id as finding_url %} + <a href="{{ finding_url|full_url }}">{{ finding.title }}</a> ({{ finding.severity }})<br/> + {% endfor %} + </details> + </p> + {% endif %} <br/><br/> {% trans "Kind regards" %}, <br/><br/> diff --git a/dojo/notifications/templates/notifications/webhooks/scan_added.tpl b/dojo/notifications/templates/notifications/webhooks/scan_added.tpl index b42096bfba2..0f68a72eb12 100644 --- a/dojo/notifications/templates/notifications/webhooks/scan_added.tpl +++ b/dojo/notifications/templates/notifications/webhooks/scan_added.tpl @@ -8,5 +8,11 @@ findings: {% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_reactivated %} mitigated: {% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_mitigated %} - untouched: + untouched: {% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_untouched %} + new_duplicate: +{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_new_duplicate %} + reactivated_duplicate: +{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_reactivated_duplicate %} + untouched_duplicate: +{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_untouched_duplicate %} diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 034696de88c..adcd4b2f2fe 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -745,44 +745,42 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # TEMPLATES # ------------------------------------------------------------------------------ -# Two parallel template trees coexist on this branch: the new Tailwind v4 UI at -# dojo/templates/ (the default Django app dir) and the classic Bootstrap 3 / SB -# Admin 2 UI at dojo/templates_classic/. Per-user resolution is handled by -# UIPreferenceLoader; see dojo/template_loaders.py. -_DOJO_TAILWIND_TEMPLATES_DIR = root("dojo/templates") -_DOJO_CLASSIC_TEMPLATES_DIR = root("dojo/templates_classic") -# Sub-package template dirs (dojo/notifications, dojo/github, ...) share a -# single list that the FilesystemLoader below reads by reference, so any +# The UI lives in a single tree at dojo/templates/, searched ahead of the +# sub-package template dirs (dojo/auditlog, dojo/notifications, dojo/github). +# The list is shared by reference with the FilesystemLoader entry below, so any # late-binding settings can append a template dir at startup and have it # picked up at render time. -_DOJO_EXTRA_TEMPLATE_DIRS = [ +_DOJO_TEMPLATE_DIRS = [ + root("dojo/templates"), root("dojo/auditlog/templates"), root("dojo/notifications/templates"), root("dojo/github/templates"), ] +# Mirrors what APP_DIRS=True would build, except that the filesystem dirs above +# are searched first and the whole chain is wrapped in the cached loader outside +# of debug mode. +_DOJO_TEMPLATE_LOADERS = [ + ("django.template.loaders.filesystem.Loader", _DOJO_TEMPLATE_DIRS), + "django.template.loaders.app_directories.Loader", +] +if not env("DD_DEBUG"): + _DOJO_TEMPLATE_LOADERS = [ + ("django.template.loaders.cached.Loader", _DOJO_TEMPLATE_LOADERS), + ] + TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - # DIRS shares the _DOJO_EXTRA_TEMPLATE_DIRS list reference with the + # DIRS shares the _DOJO_TEMPLATE_DIRS list reference with the # FilesystemLoader entry below; later append()s land in both places. - "DIRS": _DOJO_EXTRA_TEMPLATE_DIRS, - # APP_DIRS is False because dojo's templates are loaded explicitly via - # UIPreferenceLoader; the FilesystemLoader entry below picks up - # template dirs from the dojo/auditlog, dojo/notifications and - # dojo/github consolidations; other apps' templates are loaded via the - # app_directories.Loader entry. + "DIRS": _DOJO_TEMPLATE_DIRS, + # APP_DIRS must stay False whenever "loaders" is set explicitly; the + # app_directories.Loader entry below covers the same ground. "APP_DIRS": False, "OPTIONS": { "debug": env("DD_DEBUG"), - "loaders": [ - ("dojo.template_loaders.UIPreferenceLoader", - _DOJO_TAILWIND_TEMPLATES_DIR, - _DOJO_CLASSIC_TEMPLATES_DIR), - ("django.template.loaders.filesystem.Loader", - _DOJO_EXTRA_TEMPLATE_DIRS), - "django.template.loaders.app_directories.Loader", - ], + "loaders": _DOJO_TEMPLATE_LOADERS, "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", diff --git a/dojo/static/dojo/css/bootswatch/yeti/bootstrap.min.css b/dojo/static/dojo/css/bootswatch/yeti/bootstrap.min.css deleted file mode 100644 index 4e3953938da..00000000000 --- a/dojo/static/dojo/css/bootswatch/yeti/bootstrap.min.css +++ /dev/null @@ -1,12 +0,0 @@ -@charset "UTF-8";/*! - * Bootswatch v5.2.3 (https://bootswatch.com) - * Theme: yeti - * Copyright 2012-2022 Thomas Park - * Licensed under MIT - * Based on Bootstrap -*//*! - * Bootstrap v5.2.3 (https://getbootstrap.com/) - * Copyright 2011-2022 The Bootstrap Authors - * Copyright 2011-2022 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */@import url(https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap);:root{--bs-blue:#008cba;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#e83e8c;--bs-red:#f04124;--bs-orange:#fd7e14;--bs-yellow:#e99002;--bs-green:#43ac6a;--bs-teal:#20c997;--bs-cyan:#5bc0de;--bs-black:#000;--bs-white:#fff;--bs-gray:#888;--bs-gray-dark:#333;--bs-gray-100:#f8f9fa;--bs-gray-200:#eee;--bs-gray-300:#dee2e6;--bs-gray-400:#ccc;--bs-gray-500:#adb5bd;--bs-gray-600:#888;--bs-gray-700:#495057;--bs-gray-800:#333;--bs-gray-900:#222;--bs-primary:#008cba;--bs-secondary:#eee;--bs-success:#43ac6a;--bs-info:#5bc0de;--bs-warning:#e99002;--bs-danger:#f04124;--bs-light:#eee;--bs-dark:#222;--bs-primary-rgb:0,140,186;--bs-secondary-rgb:238,238,238;--bs-success-rgb:67,172,106;--bs-info-rgb:91,192,222;--bs-warning-rgb:233,144,2;--bs-danger-rgb:240,65,36;--bs-light-rgb:238,238,238;--bs-dark-rgb:34,34,34;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:34,34,34;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:"Open Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#222;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0;--bs-border-radius-sm:0;--bs-border-radius-lg:0;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#008cba;--bs-link-hover-color:#007095;--bs-code-color:#e83e8c;--bs-highlight-bg:#fbe9cc}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:1px solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:300;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:var(--bs-link-color);text-decoration:underline}a:hover{color:var(--bs-link-hover-color)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:0}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#888;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#888}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid var(--bs-border-color);border-radius:0;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#888}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color:var(--bs-body-color);--bs-table-bg:transparent;--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-body-color);--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:var(--bs-body-color);--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:var(--bs-body-color);--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:var(--bs-table-color);vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:2px solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-striped-columns>:not(caption)>tr>:nth-child(even){--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-color:#fff;--bs-table-bg:#008cba;--bs-table-border-color:#1a98c1;--bs-table-striped-bg:#0d92bd;--bs-table-striped-color:#fff;--bs-table-active-bg:#1a98c1;--bs-table-active-color:#fff;--bs-table-hover-bg:#1395bf;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#eeeeee;--bs-table-border-color:#d6d6d6;--bs-table-striped-bg:#e2e2e2;--bs-table-striped-color:#000;--bs-table-active-bg:#d6d6d6;--bs-table-active-color:#000;--bs-table-hover-bg:gainsboro;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#fff;--bs-table-bg:#43ac6a;--bs-table-border-color:#56b479;--bs-table-striped-bg:#4cb071;--bs-table-striped-color:#fff;--bs-table-active-bg:#56b479;--bs-table-active-color:#fff;--bs-table-hover-bg:#51b275;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#fff;--bs-table-bg:#5bc0de;--bs-table-border-color:#6bc6e1;--bs-table-striped-bg:#63c3e0;--bs-table-striped-color:#fff;--bs-table-active-bg:#6bc6e1;--bs-table-active-color:#fff;--bs-table-hover-bg:#67c5e0;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#fff;--bs-table-bg:#e99002;--bs-table-border-color:#eb9b1b;--bs-table-striped-bg:#ea960f;--bs-table-striped-color:#fff;--bs-table-active-bg:#eb9b1b;--bs-table-active-color:#fff;--bs-table-hover-bg:#eb9815;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#fff;--bs-table-bg:#f04124;--bs-table-border-color:#f2543a;--bs-table-striped-bg:#f14b2f;--bs-table-striped-color:#fff;--bs-table-active-bg:#f2543a;--bs-table-active-color:#fff;--bs-table-hover-bg:#f14f34;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#eee;--bs-table-border-color:#d6d6d6;--bs-table-striped-bg:#e2e2e2;--bs-table-striped-color:#000;--bs-table-active-bg:#d6d6d6;--bs-table-active-color:#000;--bs-table-hover-bg:gainsboro;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#222;--bs-table-border-color:#383838;--bs-table-striped-bg:#2d2d2d;--bs-table-striped-color:#fff;--bs-table-active-bg:#383838;--bs-table-active-color:#fff;--bs-table-hover-bg:#333333;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#888}.form-control{display:block;width:100%;padding:.375rem .5rem;font-size:1rem;font-weight:400;line-height:1.5;color:#222;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:0;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#222;background-color:#fff;border-color:#80c6dd;outline:0;box-shadow:0 0 0 .25rem rgba(0,140,186,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#888;opacity:1}.form-control::placeholder{color:#888;opacity:1}.form-control:disabled{background-color:#eee;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .5rem;margin:-.375rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem;color:#222;background-color:#eee;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .5rem;margin:-.375rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem;color:#222;background-color:#eee;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#e2e2e2}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#e2e2e2}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#222;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:0}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:0}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:calc(1.5em + .75rem + 2px);padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:0}.form-control-color::-webkit-color-swatch{border-radius:0}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + 2px)}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + 2px)}.form-select{display:block;width:100%;padding:.375rem 1.5rem .375rem .5rem;-moz-padding-start:calc(0.5rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#222;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .5rem center;background-size:16px 12px;border:1px solid #ccc;border-radius:0;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#80c6dd;outline:0;box-shadow:0 0 0 .25rem rgba(0,140,186,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.5rem;background-image:none}.form-select:disabled{background-color:#eee}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #222}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:0}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:0}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#80c6dd;outline:0;box-shadow:0 0 0 .25rem rgba(0,140,186,.25)}.form-check-input:checked{background-color:#008cba;border-color:#008cba}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#008cba;border-color:#008cba;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2380c6dd'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(0,140,186,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(0,140,186,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#008cba;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b3ddea}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#008cba;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b3ddea}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;width:100%;height:100%;padding:1rem .5rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .5rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:1px 0}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .5rem;font-size:1rem;font-weight:400;line-height:1.5;color:#222;text-align:center;white-space:nowrap;background-color:#eee;border:1px solid #ccc;border-radius:0}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:0}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:0}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:2rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#43ac6a}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(67,172,106,.9);border-radius:0}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#43ac6a;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2343ac6a' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#43ac6a;box-shadow:0 0 0 .25rem rgba(67,172,106,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#43ac6a}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:2.75rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2343ac6a' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .5rem center,center right 1.5rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#43ac6a;box-shadow:0 0 0 .25rem rgba(67,172,106,.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#43ac6a}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#43ac6a}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(67,172,106,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#43ac6a}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#f04124}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(240,65,36,.9);border-radius:0}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#f04124;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23f04124'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f04124' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#f04124;box-shadow:0 0 0 .25rem rgba(240,65,36,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#f04124}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:2.75rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23333' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23f04124'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23f04124' stroke='none'/%3e%3c/svg%3e");background-position:right .5rem center,center right 1.5rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#f04124;box-shadow:0 0 0 .25rem rgba(240,65,36,.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#f04124}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#f04124}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(240,65,36,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#f04124}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.5rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:300;--bs-btn-line-height:1.5;--bs-btn-color:#222;--bs-btn-bg:transparent;--bs-btn-border-width:1px;--bs-btn-border-color:transparent;--bs-btn-border-radius:0;--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#008cba;--bs-btn-border-color:#008cba;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#00779e;--bs-btn-hover-border-color:#007095;--bs-btn-focus-shadow-rgb:38,157,196;--bs-btn-active-color:#fff;--bs-btn-active-bg:#007095;--bs-btn-active-border-color:#00698c;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#008cba;--bs-btn-disabled-border-color:#008cba}.btn-secondary{--bs-btn-color:#000;--bs-btn-bg:#eee;--bs-btn-border-color:#eee;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f1f1f1;--bs-btn-hover-border-color:#f0f0f0;--bs-btn-focus-shadow-rgb:202,202,202;--bs-btn-active-color:#000;--bs-btn-active-bg:#f1f1f1;--bs-btn-active-border-color:#f0f0f0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#eee;--bs-btn-disabled-border-color:#eee}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#43ac6a;--bs-btn-border-color:#43ac6a;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#39925a;--bs-btn-hover-border-color:#368a55;--bs-btn-focus-shadow-rgb:95,184,128;--bs-btn-active-color:#fff;--bs-btn-active-bg:#368a55;--bs-btn-active-border-color:#328150;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#43ac6a;--bs-btn-disabled-border-color:#43ac6a}.btn-info{--bs-btn-color:#fff;--bs-btn-bg:#5bc0de;--bs-btn-border-color:#5bc0de;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#4da3bd;--bs-btn-hover-border-color:#499ab2;--bs-btn-focus-shadow-rgb:116,201,227;--bs-btn-active-color:#fff;--bs-btn-active-bg:#499ab2;--bs-btn-active-border-color:#4490a7;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#5bc0de;--bs-btn-disabled-border-color:#5bc0de}.btn-warning{--bs-btn-color:#fff;--bs-btn-bg:#e99002;--bs-btn-border-color:#e99002;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#c67a02;--bs-btn-hover-border-color:#ba7302;--bs-btn-focus-shadow-rgb:236,161,40;--bs-btn-active-color:#fff;--bs-btn-active-bg:#ba7302;--bs-btn-active-border-color:#af6c02;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#e99002;--bs-btn-disabled-border-color:#e99002}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#f04124;--bs-btn-border-color:#f04124;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#cc371f;--bs-btn-hover-border-color:#c0341d;--bs-btn-focus-shadow-rgb:242,94,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#c0341d;--bs-btn-active-border-color:#b4311b;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#f04124;--bs-btn-disabled-border-color:#f04124}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#eee;--bs-btn-border-color:#eee;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#cacaca;--bs-btn-hover-border-color:#bebebe;--bs-btn-focus-shadow-rgb:202,202,202;--bs-btn-active-color:#000;--bs-btn-active-bg:#bebebe;--bs-btn-active-border-color:#b3b3b3;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#eee;--bs-btn-disabled-border-color:#eee}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#222;--bs-btn-border-color:#222;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#434343;--bs-btn-hover-border-color:#383838;--bs-btn-focus-shadow-rgb:67,67,67;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4e4e4e;--bs-btn-active-border-color:#383838;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#222;--bs-btn-disabled-border-color:#222}.btn-outline-primary{--bs-btn-color:#008cba;--bs-btn-border-color:#008cba;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#008cba;--bs-btn-hover-border-color:#008cba;--bs-btn-focus-shadow-rgb:0,140,186;--bs-btn-active-color:#fff;--bs-btn-active-bg:#008cba;--bs-btn-active-border-color:#008cba;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#008cba;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#008cba;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#eee;--bs-btn-border-color:#eee;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#eee;--bs-btn-hover-border-color:#eee;--bs-btn-focus-shadow-rgb:238,238,238;--bs-btn-active-color:#000;--bs-btn-active-bg:#eee;--bs-btn-active-border-color:#eee;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#eee;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#eee;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#43ac6a;--bs-btn-border-color:#43ac6a;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#43ac6a;--bs-btn-hover-border-color:#43ac6a;--bs-btn-focus-shadow-rgb:67,172,106;--bs-btn-active-color:#fff;--bs-btn-active-bg:#43ac6a;--bs-btn-active-border-color:#43ac6a;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#43ac6a;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#43ac6a;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#5bc0de;--bs-btn-border-color:#5bc0de;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5bc0de;--bs-btn-hover-border-color:#5bc0de;--bs-btn-focus-shadow-rgb:91,192,222;--bs-btn-active-color:#fff;--bs-btn-active-bg:#5bc0de;--bs-btn-active-border-color:#5bc0de;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#5bc0de;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#5bc0de;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#e99002;--bs-btn-border-color:#e99002;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#e99002;--bs-btn-hover-border-color:#e99002;--bs-btn-focus-shadow-rgb:233,144,2;--bs-btn-active-color:#fff;--bs-btn-active-bg:#e99002;--bs-btn-active-border-color:#e99002;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#e99002;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#e99002;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#f04124;--bs-btn-border-color:#f04124;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#f04124;--bs-btn-hover-border-color:#f04124;--bs-btn-focus-shadow-rgb:240,65,36;--bs-btn-active-color:#fff;--bs-btn-active-bg:#f04124;--bs-btn-active-border-color:#f04124;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f04124;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f04124;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#eee;--bs-btn-border-color:#eee;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#eee;--bs-btn-hover-border-color:#eee;--bs-btn-focus-shadow-rgb:238,238,238;--bs-btn-active-color:#000;--bs-btn-active-bg:#eee;--bs-btn-active-border-color:#eee;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#eee;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#eee;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#222;--bs-btn-border-color:#222;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#222;--bs-btn-hover-border-color:#222;--bs-btn-focus-shadow-rgb:34,34,34;--bs-btn-active-color:#fff;--bs-btn-active-bg:#222;--bs-btn-active-border-color:#222;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#222;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#222;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#888;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:none;--bs-btn-focus-shadow-rgb:38,157,196;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:0}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:0}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:#222;--bs-dropdown-bg:#fff;--bs-dropdown-border-color:rgba(0, 0, 0, 0.1);--bs-dropdown-border-radius:0;--bs-dropdown-border-width:1px;--bs-dropdown-inner-border-radius:-1px;--bs-dropdown-divider-bg:rgba(0, 0, 0, 0.1);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-dropdown-link-color:#222;--bs-dropdown-link-hover-color:#1f1f1f;--bs-dropdown-link-hover-bg:#eee;--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#008cba;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#888;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#333;--bs-dropdown-border-color:rgba(0, 0, 0, 0.1);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:rgba(0, 0, 0, 0.1);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#008cba;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:0}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:#ccc;display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link.disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:1px;--bs-nav-tabs-border-color:rgba(0, 0, 0, 0.1);--bs-nav-tabs-border-radius:0;--bs-nav-tabs-link-hover-border-color:rgba(0, 0, 0, 0.1);--bs-nav-tabs-link-active-color:#495057;--bs-nav-tabs-link-active-bg:#fff;--bs-nav-tabs-link-active-border-color:rgba(0, 0, 0, 0.1);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));background:0 0;border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.disabled,.nav-tabs .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:0;--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#008cba}.nav-pills .nav-link{background:0 0;border:0;border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(0, 0, 0, 0.55);--bs-navbar-hover-color:rgba(0, 0, 0, 0.7);--bs-navbar-disabled-color:rgba(0, 0, 0, 0.3);--bs-navbar-active-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(0, 0, 0, 0.9);--bs-navbar-brand-hover-color:rgba(0, 0, 0, 0.9);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(0, 0, 0, 0.1);--bs-navbar-toggler-border-radius:0;--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .show>.nav-link{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark{--bs-navbar-color:rgba(255, 255, 255, 0.7);--bs-navbar-hover-color:#fff;--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.7%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-border-width:1px;--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:0;--bs-card-box-shadow: ;--bs-card-inner-border-radius:0;--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(0, 0, 0, 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:#fff;--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:#222;--bs-accordion-bg:#fff;--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:1px;--bs-accordion-border-radius:0;--bs-accordion-inner-border-radius:-1px;--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:#222;--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23222'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23007ea7'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-focus-border-color:#80c6dd;--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(0, 140, 186, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:#007ea7;--bs-accordion-active-bg:#e6f4f8}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:var(--bs-accordion-btn-focus-border-color);outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button,.accordion-flush .accordion-item .accordion-button.collapsed{border-radius:0}.breadcrumb{--bs-breadcrumb-padding-x:0.75rem;--bs-breadcrumb-padding-y:0.375rem;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius:3px;--bs-breadcrumb-divider-color:#888;--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:#888;display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:#888;--bs-pagination-bg:#fff;--bs-pagination-border-width:1px;--bs-pagination-border-color:rgba(0, 0, 0, 0.1);--bs-pagination-border-radius:0;--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:#eee;--bs-pagination-hover-border-color:#dee2e6;--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:#eee;--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(0, 140, 186, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#008cba;--bs-pagination-active-border-color:#0079a1;--bs-pagination-disabled-color:#eee;--bs-pagination-disabled-bg:#fff;--bs-pagination-disabled-border-color:#dee2e6;display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:0}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:0}.badge{--bs-badge-padding-x:1rem;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:300;--bs-badge-color:#fff;--bs-badge-border-radius:0;display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:1px solid var(--bs-alert-border-color);--bs-alert-border-radius:0;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:400}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:#005470;--bs-alert-bg:#008cba;--bs-alert-border-color:#b3ddea}.alert-primary .alert-link{color:#00435a}.alert-secondary{--bs-alert-color:#8f8f8f;--bs-alert-bg:#eeeeee;--bs-alert-border-color:#fafafa}.alert-secondary .alert-link{color:#727272}.alert-success{--bs-alert-color:#286740;--bs-alert-bg:#43ac6a;--bs-alert-border-color:#c7e6d2}.alert-success .alert-link{color:#205233}.alert-info{--bs-alert-color:#377385;--bs-alert-bg:#5bc0de;--bs-alert-border-color:#ceecf5}.alert-info .alert-link{color:#2c5c6a}.alert-warning{--bs-alert-color:#8c5601;--bs-alert-bg:#e99002;--bs-alert-border-color:#f8deb3}.alert-warning .alert-link{color:#704501}.alert-danger{--bs-alert-color:#902716;--bs-alert-bg:#f04124;--bs-alert-border-color:#fbc6bd}.alert-danger .alert-link{color:#731f12}.alert-light{--bs-alert-color:#8f8f8f;--bs-alert-bg:#eeeeee;--bs-alert-border-color:#fafafa}.alert-light .alert-link{color:#727272}.alert-dark{--bs-alert-color:#a7a7a7;--bs-alert-bg:#222222;--bs-alert-border-color:#bdbdbd}.alert-dark .alert-link{color:#868686}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:#ccc;--bs-progress-border-radius:0;--bs-progress-box-shadow:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#008cba;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:#222;--bs-list-group-bg:#fff;--bs-list-group-border-color:rgba(0, 0, 0, 0.125);--bs-list-group-border-width:1px;--bs-list-group-border-radius:0;--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:#495057;--bs-list-group-action-hover-color:#495057;--bs-list-group-action-hover-bg:#f8f9fa;--bs-list-group-action-active-color:#222;--bs-list-group-action-active-bg:#eee;--bs-list-group-disabled-color:#888;--bs-list-group-disabled-bg:#eee;--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#008cba;--bs-list-group-active-border-color:#008cba;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#005470;background-color:#cce8f1}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#005470;background-color:#b8d1d9}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#005470;border-color:#005470}.list-group-item-secondary{color:#8f8f8f;background-color:#fcfcfc}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#8f8f8f;background-color:#e3e3e3}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#8f8f8f;border-color:#8f8f8f}.list-group-item-success{color:#286740;background-color:#d9eee1}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#286740;background-color:#c3d6cb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#286740;border-color:#286740}.list-group-item-info{color:#377385;background-color:#def2f8}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#377385;background-color:#c8dadf}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#377385;border-color:#377385}.list-group-item-warning{color:#8c5601;background-color:#fbe9cc}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#8c5601;background-color:#e2d2b8}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#8c5601;border-color:#8c5601}.list-group-item-danger{color:#902716;background-color:#fcd9d3}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#902716;background-color:#e3c3be}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#902716;border-color:#902716}.list-group-item-light{color:#8f8f8f;background-color:#fcfcfc}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#8f8f8f;background-color:#e3e3e3}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#8f8f8f;border-color:#8f8f8f}.list-group-item-dark{color:#141414;background-color:#d3d3d3}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141414;background-color:#bebebe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141414;border-color:#141414}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#888;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23888'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:0;opacity:.6}.btn-close:hover{color:#888;text-decoration:none;opacity:1}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(0,140,186,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(255, 255, 255, 0.85);--bs-toast-border-width:1px;--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:0;--bs-toast-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-toast-header-color:#888;--bs-toast-header-bg:rgba(255, 255, 255, 0.85);--bs-toast-header-border-color:rgba(0, 0, 0, 0.05);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:#fff;--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:1px;--bs-modal-border-radius:0;--bs-modal-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-modal-inner-border-radius:-1px;--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:1px;--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:1px;position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:#fff;--bs-tooltip-bg:#000;--bs-tooltip-border-radius:0;--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;padding:var(--bs-tooltip-arrow-height);margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:#fff;--bs-popover-border-width:1px;--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:0;--bs-popover-inner-border-radius:-1px;--bs-popover-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color: ;--bs-popover-header-bg:#f0f0f0;--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:#222;--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color: ;--bs-offcanvas-bg:#fff;--bs-offcanvas-border-width:1px;--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:575.98px){.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}}@media (max-width:575.98px){.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:767.98px){.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:767.98px){.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}}@media (max-width:767.98px){.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:991.98px){.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}}@media (max-width:991.98px){.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1199.98px){.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}}@media (max-width:1199.98px){.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}}@media (max-width:1399.98px){.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}}@media (max-width:1399.98px){.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y))}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(0,140,186,var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#000!important;background-color:RGBA(238,238,238,var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(67,172,106,var(--bs-bg-opacity,1))!important}.text-bg-info{color:#fff!important;background-color:RGBA(91,192,222,var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#fff!important;background-color:RGBA(233,144,2,var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(240,65,36,var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(238,238,238,var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(34,34,34,var(--bs-bg-opacity,1))!important}.link-primary{color:#008cba!important}.link-primary:focus,.link-primary:hover{color:#007095!important}.link-secondary{color:#eee!important}.link-secondary:focus,.link-secondary:hover{color:#f1f1f1!important}.link-success{color:#43ac6a!important}.link-success:focus,.link-success:hover{color:#368a55!important}.link-info{color:#5bc0de!important}.link-info:focus,.link-info:hover{color:#499ab2!important}.link-warning{color:#e99002!important}.link-warning:focus,.link-warning:hover{color:#ba7302!important}.link-danger{color:#f04124!important}.link-danger:focus,.link-danger:hover{color:#c0341d!important}.link-light{color:#eee!important}.link-light:focus,.link-light:hover{color:#f1f1f1!important}.link-dark{color:#222!important}.link-dark:focus,.link-dark:hover{color:#1b1b1b!important}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-1{--bs-border-width:1px}.border-2{--bs-border-width:2px}.border-3{--bs-border-width:3px}.border-4{--bs-border-width:4px}.border-5{--bs-border-width:5px}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-semibold{font-weight:600!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#888!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-2xl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}:root{color-scheme:light}.navbar{font-weight:300}.bg-primary .dropdown-menu{background-color:#008cba}.bg-primary .dropdown-menu .dropdown-item,.bg-primary .dropdown-menu .dropdown-item:focus{color:rgba(255,255,255,.7)}.bg-primary .dropdown-menu .dropdown-item.active,.bg-primary .dropdown-menu .dropdown-item:focus,.bg-primary .dropdown-menu .dropdown-item:hover{color:#fff;background-color:#007ea7}.bg-dark{background-color:#333!important}.bg-dark .dropdown-menu{background-color:#333}.bg-dark .dropdown-menu .dropdown-item,.bg-dark .dropdown-menu .dropdown-item:focus{color:rgba(255,255,255,.7)}.bg-dark .dropdown-menu .dropdown-item.active,.bg-dark .dropdown-menu .dropdown-item:focus,.bg-dark .dropdown-menu .dropdown-item:hover{color:#fff;background-color:#2e2e2e}.bg-light .dropdown-menu{background-color:#eee}.bg-light .dropdown-menu .dropdown-item,.bg-light .dropdown-menu .dropdown-item:focus{color:rgba(0,0,0,.55)}.bg-light .dropdown-menu .dropdown-item.active,.bg-light .dropdown-menu .dropdown-item:focus,.bg-light .dropdown-menu .dropdown-item:hover{color:rgba(0,0,0,.7);background-color:#f0f0f0}.btn-primary{border-color:#007ea7}.btn-secondary{border-color:#d6d6d6}.btn-success{border-color:#3c9b5f}.btn-info{color:#fff;border-color:#52adc8}.btn-danger{border-color:#d83b20}.btn-warning{color:#fff;border-color:#d28202}.btn-light{border-color:#d6d6d6}.btn-dark{border-color:#1f1f1f}.btn-outline-secondary{color:#000}.btn-group .dropdown-menu{border-top-width:0}.btn-group .dropdown-toggle.btn-primary~.dropdown-menu{background-color:#008cba;border-color:#007ea7}.btn-group .dropdown-toggle.btn-primary~.dropdown-menu .dropdown-item{color:#fff}.btn-group .dropdown-toggle.btn-primary~.dropdown-menu .dropdown-item:focus,.btn-group .dropdown-toggle.btn-primary~.dropdown-menu .dropdown-item:hover{background-color:#00769c}.btn-group .dropdown-toggle.btn-secondary~.dropdown-menu{background-color:#eee;border-color:#d6d6d6}.btn-group .dropdown-toggle.btn-secondary~.dropdown-menu .dropdown-item{color:#222}.btn-group .dropdown-toggle.btn-secondary~.dropdown-menu .dropdown-item:focus,.btn-group .dropdown-toggle.btn-secondary~.dropdown-menu .dropdown-item:hover{background-color:#c8c8c8}.btn-group .dropdown-toggle.btn-success~.dropdown-menu{background-color:#43ac6a;border-color:#3c9b5f}.btn-group .dropdown-toggle.btn-success~.dropdown-menu .dropdown-item{color:#fff}.btn-group .dropdown-toggle.btn-success~.dropdown-menu .dropdown-item:focus,.btn-group .dropdown-toggle.btn-success~.dropdown-menu .dropdown-item:hover{background-color:#389059}.btn-group .dropdown-toggle.btn-info~.dropdown-menu{background-color:#5bc0de;border-color:#52adc8}.btn-group .dropdown-toggle.btn-info~.dropdown-menu .dropdown-item{color:#fff}.btn-group .dropdown-toggle.btn-info~.dropdown-menu .dropdown-item:focus,.btn-group .dropdown-toggle.btn-info~.dropdown-menu .dropdown-item:hover{background-color:#4ca1ba}.btn-group .dropdown-toggle.btn-warning~.dropdown-menu{background-color:#e99002;border-color:#d28202}.btn-group .dropdown-toggle.btn-warning~.dropdown-menu .dropdown-item{color:#fff}.btn-group .dropdown-toggle.btn-warning~.dropdown-menu .dropdown-item:focus,.btn-group .dropdown-toggle.btn-warning~.dropdown-menu .dropdown-item:hover{background-color:#c47902}.btn-group .dropdown-toggle.btn-danger~.dropdown-menu{background-color:#f04124;border-color:#d83b20}.btn-group .dropdown-toggle.btn-danger~.dropdown-menu .dropdown-item{color:#fff}.btn-group .dropdown-toggle.btn-danger~.dropdown-menu .dropdown-item:focus,.btn-group .dropdown-toggle.btn-danger~.dropdown-menu .dropdown-item:hover{background-color:#ca371e}.text-secondary{color:#495057!important}.blockquote-footer{color:#888}table{font-size:.875rem}.checkbox,.control-label,.form-control-feedback,.help-block,.radio,label{font-size:.875rem}.dropdown-item{padding-top:.75rem;padding-bottom:.75rem;font-size:.875rem;font-weight:300}.nav-tabs .nav-link,.nav-tabs .nav-link.disabled,.nav-tabs .nav-link.disabled:focus,.nav-tabs .nav-link.disabled:hover{background-color:#eee;border-color:rgba(0,0,0,.1)}.nav-tabs .nav-item+.nav-item .nav-link{border-left:none}.nav-pills .active{border:1px solid #007ea7}.breadcrumb{font-size:.875rem;font-weight:300;text-transform:uppercase;border:1px solid rgba(0,0,0,.1)}.pagination{font-weight:300}.pagination .page-link{margin-right:.1em;margin-left:.1em;border-color:transparent;border-radius:3px}.pagination .page-link:focus,.pagination .page-link:hover{text-decoration:none}.pagination .page-item.disabled .page-link{border-color:transparent}.pagination .page-item:first-child .page-link,.pagination .page-item:last-child .page-link,.pagination-lg .page-item:first-child .page-link,.pagination-lg .page-item:last-child .page-link,.pagination-sm .page-item:first-child .page-link,.pagination-sm .page-item:last-child .page-link{border-radius:3px}.list-group{font-size:.875rem;font-weight:300}.alert{font-size:.875rem;font-weight:300;color:#fff}.alert .alert-link{color:#fff;text-decoration:underline}.alert:not(.alert-secondary):not(.alert-light) .btn-close{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e")}.alert-light,.alert-light .alert-link,.alert-light a:not(.btn),.alert-secondary,.alert-secondary .alert-link,.alert-secondary a:not(.btn){color:#222}.badge.bg-light,.badge.bg-secondary{color:#222}.progress[value]{height:22px;padding:2px;background-color:#f6f6f6;border:1px solid #ccc}.popover-header{border-top-left-radius:0;border-top-right-radius:0} \ No newline at end of file diff --git a/dojo/static/dojo/css/classic/dojo.css b/dojo/static/dojo/css/classic/dojo.css deleted file mode 100644 index 17b75a948d6..00000000000 --- a/dojo/static/dojo/css/classic/dojo.css +++ /dev/null @@ -1,1994 +0,0 @@ -.btn, -input, -textarea, -.progress, -.tag-label, -.label, -.badge, -.pagination-sm > li:first-child > a, -.pagination-sm > li:first-child > span, -.pagination-sm > li:last-child > a, -.pagination-sm > li:last-child > span, -.panel, -.panel-heading, -.panel-comments, -.nav-pills > li > a, -.breadcrumb, -.panel > table:last-child, -.panel > .table:first-child { - border-radius: 0!important; -} - -.table-condensed > tbody > tr > td, -.table-condensed > tbody > tr > th, -.table-condensed > tfoot > tr > td, -.table-condensed > tfoot > tr > th, -.table-condensed > thead > tr > td, -.table-condensed > thead > tr > th { - padding-top: 12px; -} - -.table-condensed > tbody > tr > td:first-child, -.table-condensed > tbody > tr > th:first-child, -.table-condensed > tfoot > tr > td:first-child, -.table-condensed > tfoot > tr > th:first-child, -.table-condensed > thead > tr > td:first-child, -.table-condensed > thead > tr > th:first-child { - padding-left: 15px; -} - -.btn-group .btn:not(:first-child) { - margin-left: 6px; -} - -.min, -.max { - overscroll-behavior: none!important; - overflow-x: clip!important; -} - -a { - color: #546474; -} - -.panel-default .btn-primary { - background-color: transparent!important; - border-style: none!important; - color: #fff!important; - border: 1px solid #fff!important; -} - -.panel-default .btn-primary:hover { - background-color: transparent!important; - color: #fff!important; - border: 1px solid #fff!important; -} - -.btn-primary { - color: #fff; - background-color: #546474; - /*border-style: none!important;*/ -} - -.btn-primary:hover { - background-color: #38434d!important; -} - -.btn-secondary { - color: #fff; - background-color: #546474; - border-style: none!important; -} - -.btn-secondary:hover { - color: #fff; - background-color: #38434d!important; -} - -.panel-heading .dropdown-toggle:focus { - outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px; -} - -#base-content .clearfix .pagination .dropdown-toggle:focus { - outline: 5px auto #599fd0; - outline-offset: -3px; -} -.table-responsive .dropdown-toggle:focus { - outline: 5px auto -webkit-focus-ring-color; - text-decoration: none; -} - -/* -.btn-primary.active.focus, .btn-primary.active:focus, .btn-primary.active:hover, .btn-primary:active.focus, .btn-primary:active:focus, .btn-primary:active:hover, .open > .dropdown-toggle.btn-primary.focus, .open > .dropdown-toggle.btn-primary:focus, .open > .dropdown-toggle.btn-primary:hover { - background-color: transparent!important; - border-style: none!important; -} - -.btn-primary.focus, .btn-primary:focus { - background-color: transparent!important; - border-style: none!important; -} -*/ - -.secondary-color { - background-color: #546474!important; - color: white; -} - -.h1, -.h2, -.h3, -h1, -h2 { - margin-top: 10px; - margin-bottom: 10px; -} - -h3 { - margin-top: 5px; - margin-bottom: 2px; - font-size: 18px; - line-height: 18px; -} - -a { - text-decoration: none; -} - -a:hover { - text-decoration: none; -} - -.nav.nav-second-level.nav-up { - transform: translateY(-100%); - margin-top: 41px; -} - -.nav-tabs { - border-bottom: 1px solid rgb(148, 168, 187); -} - - -.nav-tabs > li.active > a, -.nav-tabs > li.active > a:focus, -.nav-tabs > li.active > a:hover { - color: #fff; - background-color: #73879c; - border: 1px solid #73879c; - -} -.nav-tabs > li > a { - margin-right: 2px; - line-height: 1.42857143; - border: 1px solid transparent; - border-radius: 0; -} - -.nav-tabs .dropdown .badge{ - color: #fff; - background-color: #546474; -} - -.nav-tabs .active-color .badge{ - color: #72879b; - background-color: #ffffff; -} - -.navbar-brand { - padding: 5px 5px; - margin-left: 0px; -} - -.navbar-brand > img { - display: inline-block; -} - -.row { - margin-top: 5px; -} - -form.filters { - margin-top: 0px; - padding: 0px 5px 5px; -} - -.filters select, .filters textarea, .filters input[type="text"], .filters input[type="password"], .filters input[type="datetime"], .filters input[type="datetime-local"], .filters input[type="date"], .filters input[type="month"], .filters input[type="time"], .filters input[type="week"], .filters input[type="number"], .filters input[type="email"], .filters input[type="url"], .filters input[type="search"], .filters input[type="tel"], .filters input[type="color"], .filters .uneditable-input { - width: 100%; - border-radius: 4px; - background-color: #fff; - background-image: none; - border: 1px solid #ccc; -} - -.filters hr { - margin-top: 5px; - margin-bottom: 5px; -} - -input[type="file"] { - border: 0px; - width: auto; -} - -.form-control { - width: 70% !important; -} - -.bs-searchbox .form-control { - width: 100% !important; -} - -.dojo-input-group { - width: 73%; - z-index: 1; -} - -.home-icon a { - padding: 0 !important; -} - -.hilite, -.hilite1 { - background: #FF0; -} - -.hilite2 { - background: #A9DBA9; -} - -.hilite3 { - background: #F89406; -} - -.hilite4 { - background: #F8B9B7; -} - -.hilite5 { - background: #BCE8F1; -} - -#swagger-ui-container h2 { - font-size: 20px; - line-height: 20px; - padding: 0; -} - -#swagger-ui-container h3 { - font-size: 15px; - line-height: 20px; - padding: 0; -} - -#swagger-ui-container .footer { - display: none !important; -} - -div#header { - background: #FFF; -} - -.zero { - border: 0 !important; - padding: 0 !important; - width: 0 !important; -} - -body #header form#api_selector .input input#input_apiKey { - width: 500px; -} - -.prod_name { - padding-right: 15px; - white-space: nowrap; - width: 1px; -} - -.breadcrumb a.active { - color: #777 !important; -} - -.badge.badge-count { - background-color: #d9534f; -} - -.badge.badge-count.badge-count0 { - background-color: #5cb85c; -} - -.dropdown-toggle-h { - display: none; - visibility: hidden; -} - -a.eng_link { - margin-bottom: 10px; -} - -.ui-helper-reset { - font-size: 96%; -} - -#tabs.ui-widget { - font-size: .8em; -} - -form.metric_form, -form.metric_form select, -form.metric_form p, -form.metric_form ul, -form.metric_form ul li { - list-style-type: none; - font-size: 11px -} - -form.metric_form label { - font-size: 11px -} - -form.metric_form input[type='submit'] { - vertical-align: text-bottom; -} - -form.metric_form p label { - font-weight: 700; -} - -.filters .panel-body { - padding: 5px; -} - -.filters .panel-heading { - border-top: 1px solid #ddd; -} - -.filters .panel-heading:first-child { - border-top: 0px solid #ddd; -} - -.panel-blue .panel-heading, -.panel-blue { - color: #f5f5f5; - background-color: #546474!important; - border-color: #546474!important; -} - -.panel-blue a { - color: #546474; -} - -.panel-blue a:hover { - color: #38434d; -} - -.nowrap { - white-space: nowrap; -} - -.metrics-chart { - height: 300px; -} - -.metrics-half-chart { - height: 300px; - width: 460px; - display: inline-block; - margin-bottom: 40px; -} - -.table-400 { - width: 40%; -} - -.table-600 { - width: 66%; -} - -.table-full { - width: 100%; -} - -.metric-half { - display: inline-block; - vertical-align: top; - width: 38%; -} - -.field-help-text { - display: block; - font-size: 75%; - margin: -10px 1px 10px; -} - -.error-page-box { - display: inline-block; - vertical-align: middle; - padding-right: 25px; -} - -.inline-block { - display: inline-block; -} - -a.search-finding { - color: #09d; -} - -.no-bullets { - list-style: none; -} - -.inline-form, -.inline-label { - display: inline; -} - -div#upload-risk form select[multiple="multiple"] { - height: 400px; - width: auto; -} - -.controls.file label { - width: 20%; - text-align: center; -} - -.protip { - text-align: center; - margin-bottom: 10px; -} - -.control-group input[type="file"] { - display: inline-block; - width: 0; -} - -form#replace_risk_file label { - text-align: center; - width: 88px; -} - -form#replace_risk_file input[type="file"] { - display: inline-block; - width: 0; -} - -form ul { - list-style-type: none; - margin-left: 0px; - padding-left: 0px -} - -form ul li a#reset-password, -form ul li a#forgot-username { - color: rgb(51, 122, 183) -} - -form ul#id_accepted_findings { - list-style: none; - margin-left: 0; - overflow: scroll; - max-height: 500px; -} - -form ul#id_accepted_findings li { - padding: 5px; -} - -form ul#id_accepted_findings li:nth-child(odd) { - background-color: #f0f0f0; -} - -div#upload-risk .helptext, -div.show_add_findings_form .helptext { - display: block; - font-size: 13px; - font-style: italic; - margin: auto; - text-align: center; -} - -a.accept-all-findings { - cursor: pointer; -} - -div.danger-zone { - padding: 0; - text-align: center; -} - -div.danger-zone h3 { - margin: 0px; -} - -div.danger-zone p { - margin-top: 10px; -} - -.meta_profile { - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - background-color: #f0f0f0; - border: 1px solid; - border-color: #d0d0d0; - border-radius: 4px; - color: #000; - padding: 8px 35px 8px 14px; -} - -.meta_profile b { - display: inline-block; - width: 40%; -} - -ul.messages { - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - background-color: #f0f0f0; - border-radius: 4px; - margin: 0 0 10px; - padding: 5px; -} - -.messages li { - display: block; - font-size: 16px; - margin: 0 0 2px; - padding: 2px 3px; -} - -.top, -form ul#id_accepted_findings input { - vertical-align: top; -} - -.messages li.warning { - color: #c09853; -} - -.messages li.error { - color: #b94a48; -} - -.Pass, -.messages li.success { - color: #468847; -} - -.pass_fail { - -moz-border-radius: 4px; - -webkit-border-radius: 4px; - background-color: #f89406; - color: #fff; - display: inline-block; - font-size: 11.844px; - font-weight: 700; - line-height: 14px; - padding: 2px 8px; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); - vertical-align: baseline; - white-space: nowrap; -} - -.dojo-sup { - font-size: smaller; - vertical-align: super; -} -.Fail { - background-color: #b94a48; -} - -.Pass { - background-color: #468847; -} - -.severity { - font-size: 80%; -} - -.severity-Info { - background-color: #888888; -} - -.severity-Low { - background-color: #546474; -} - -.severity-Medium { - background-color: #FBE413; -} - -.severity-High { - background-color: #f09835; -} - -.severity-Critical { - background-color: #d9534f; -} - -.age-red { - background-color: #d9534f; -} - -.age-green { - background-color: green; -} - -.age-blue { - background-color: blue; -} - -.age-orange { - background-color: #EE7600; -} - -.dojo-search { - clear: right !important; - display: inline-block !important; - float: right !important; - margin: 0 !important; -} - -.pagination { - margin: 10px; -} - -.pagination>.active>a, .pagination>.active>a:focus, .pagination>.active>a:hover, .pagination>.active>span, .pagination>.active>span:focus, .pagination>.active>span:hover -{ - background-color: #546474; - border-color: #546474; -} - -.pagination-in-panel { - margin-left: 10px!important; - margin-right: 10px!important; -} - -#risk_acceptance table form { - margin: 0; -} - -#risk_acceptance table td { - vertical-align: middle; -} - -div.eng_info { - background-color: #f5f5f5; - border: 1px solid #e0e0e0; - border-radius: 4px; - margin-top: 10px; - min-height: 135px; - padding: 7px; - width: 280px; -} - -div.prod_info { - background-color: #f5f5f5; - border-radius: 4px; - margin-top: 10px; - padding: 10px 0; - border: 1px solid rgba(0, 0, 0, 0.15); - margin-bottom: 10px; -} - -br { - border: 0; - -} - -.filters h3 { - font-size: 122% !important; - line-height: 20px; - margin: 0px 0 5px 0 !important; -} - -.filters .helptext { - display: none; -} - -.table-bordered th { - background-color: #DCEDFB !important; -} - -span.report_note { - display: inline-block; - font-size: 11px !important; -} - -span.report_note * { - font-size: 11px !important; -} - -div.pagination li form { - display: inline-block; - margin-left: 3px; -} - -div.pagination li form select { - display: inline-block; - font-size: 11px; - width: auto; -} - -div.page_select { - float: left; -} - -input#simple_search { - width: 100%!important; -} - -.support-page-wrapper { - display: flex; - flex-flow: column; - align-items: center; -} - -.support-container { - display: flex; - width: 75%; - margin-bottom: 150px; -} - -.support-box { - display: flex; - flex-direction: column; - justify-content: flex-start; - flex: 1; - font-size: 16px; - font-weight: 500; -} - -.support-box.shape { - border-radius: 12px; - padding: 10px 25px 25px 25px; - margin: 0px 10px 0px 10px; - transition: transform .5s; -} - -.community { - color: #546474!important; - background-color: #e7e7e7!important; -} - -.commercial { - color: #e7e7e7!important; - background-color: #1d4565!important; -} - -.support-box.hover:hover { - transform: translateY(-10px); -} - -.btn-primary.support { - font-size: 16px; - font-weight: 500; - padding: 10px!important; - border-radius: 6px!important; - margin: 20px 10px 0px 10px!important; - border: none!important; -} - -.btn-primary.support.dark:hover { - color: #546474!important; - background-color: #f7f7f7!important; -} - -.btn-primary.support.light:hover { - color: #e7e7e7!important; - background-color: #235175!important; -} - -.divider { - border-bottom: 1px solid; - margin: 10px 25px 15px 25px; -} - -.divider.dark { - border-color: #546474!important -} - -.divider.light { - border-color: #e7e7e7!important -} - -.dojo-modals-wrapper { - display: none; - flex-direction: column; - position: fixed; - z-index: 1; - padding-top: 100px; - left: 0; - top: 0; - width: 100%; - height: 100%; - overflow: auto; -} - -.footer { - background-color: #F8F8F8; - border-top: 1px solid #dcdcdc; - padding: 30px 0; -} - -.footer img { - opacity: .7; - transition: opacity .25s ease-in-out; - -moz-transition: opacity .25s ease-in-out; - -webkit-transition: opacity .25s ease-in-out; -} - -.footer img:hover { - opacity: 1 -} - -.metric-data .row { - margin-bottom: 40px; -} - -.navbar a.brand { - margin: 0; - padding: 0; -} - -.metric-data .well { - padding: 7px; - font-size: 12px !important; -} - -.metric-data h4 { - font-size: 14px; - font-weight: bold; -} - -body { - padding-top: 50px; -} - -body.popup { - padding-top: 0; -} - -html, -body, -.container { - height: 100%; -} - -body > .container { - height: auto; - min-height: 100%; -} - -body #header form#api_selector .input a#explore:hover, -body #header form#api_selector .input a#explore { - background-color: #09d !important; -} - -.controls ul { - list-style-type: none; - margin: 0 -} - -div.dataTables_wrapper -div.dataTables_filter { - margin: 10px!important; -} - -.navbar li.search-form { - width: 170px -} - -label, -input, -button, -select, -textarea { - font-size: 14px -} - -ul.left { - text-align: left -} - -.breadcrumb.main { - margin-top: 10px; -} - -.form-read-only { - margin-left: 20px; -} - -.centered { - text-align: center; -} - -.centered table { - margin: auto; - text-align: left; -} - -table.centered th { - text-align: center; -} - -table.centered td { - text-align: center; -} - -.text-critical { - color: #d9534f -} - -.text-high { - color: #f0ad4e -} - -.text-medium { - color: #f0de28 -} - -.text-low { - color: #546474 -} - -input[type="checkbox"] { - display: inline; - width: 22px; - vertical-align: middle; - margin-top: 0px; - border: 0; - padding: 0px; - box-shadow: none -} - -.navbar-top-links .dropdown-messages, -.navbar-top-links .dropdown-tasks, -.navbar-top-links .dropdown-alerts { - width: 500px; - min-width: 0; -} - -.footer { - font-size: x-small -} - -.footer img.logo { - width: 5%; - height: 5%; -} - -td .fa-fw { - font-size: 18px; -} - -.same-height-panel-row .panel { - min-height: 150px; -} - -.view-finding .panel-body pre, .endpoints pre { - background-color: transparent; - border: 0px; - padding: 0px; - white-space: pre-wrap; - white-space: -moz-pre-wrap; - white-space: -pre-wrap; - white-space: -o-pre-wrap; - word-break: normal; -} - -h4.finding-title { - width: 90%; -} - -@media (min-width: 768px) { - .dl-horizontal dt { - width: 180px; - } - - .dl-horizontal dd { - margin-left: 200px; - } - -} - -.panel-heading span.clickable { - cursor: pointer; -} - -#test-pulldown .popover-content { - color: #000000; -} - -span.endpoint_product { - display: block; - font-size: 14px; - font-weight: normal; - margin-top: 3px; -} - -#vuln_endpoints ul { - list-style-type: square; - margin-left: 5px; - padding-left: 5px; - width: 100%; -} - -.endpoint-panel { - overflow-x: scroll; - max-height: 300px; -} - -.endpoints i.fa-circle { - font-size: 7px; - vertical-align: middle; -} - -.dropdown-menu > li > a.text-danger { - color: #a94442!important;; -} - -.dropdown-menu { - background-color: #fff; -} - -.dropdown-menu a { - text-decoration: none!important; - letter-spacing: 0.04em; - color: #000000!important;; -} - -th .fa-sort-asc { - vertical-align: -3px -} - -th .fa-sort-desc { - vertical-align: 3px -} - -th a { - color: #333; - width: 100%; - display: inline-block; -} - -@font-face { - font-family: 'icomoon'; - src: url('../../fonts/icomoon.eot?2eclin'); - src: url('../../fonts/icomoon.eot?2eclin#iefix') format('embedded-opentype'), - url('../../fonts/icomoon.ttf?2eclin') format('truetype'), - url('../../fonts/icomoon.woff?2eclin') format('woff'), - url('../../fonts/icomoon.svg?2eclin#icomoon') format('svg'); - font-weight: normal; - font-style: normal; -} - -[class^="icon-"], [class*=" icon-"] { - font-family: 'icomoon'; - speak: none; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; - - /* Better Font Rendering =========== */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.icon-grab-reorder-down:before { - content: "\e902"; -} - -.icon-grab-reorder-up:before { - content: "\e903"; -} - -.icon-grab-reorder:before { - content: "\e904"; -} - -.icon-add-template:before { - content: "\e900"; -} - -.icon-plusminus:before { - content: "\e800"; -} - -.icon-page-break:before { - content: "\e901"; -} - -.icon-user-check:before { - content: "\e975"; -} - -#side-menu ul a { - /*border-top: 1px solid #ddd;*/ - border-radius: 4px; -} - -li#minimize-menu-li { - display: none; - visibility: hidden; -} - -div.custom-search-form { - height: 21px; - width: 265px; -} - -.announcement-banner { - margin: 0px -15px; - border-radius: 0px 0px 4px 4px; - color: #000; -} - -.announcement-banner a { - color: #0645ad; - text-decoration: underline; -} - -.announcement-banner strong, -.announcement-banner b { - color: #222; -} - -.banner-toggle { - background: transparent; - border: 0; - padding: 0; - margin-left: 6px; - color: inherit; - cursor: pointer; - line-height: 1; -} - -.banner-toggle:focus, -.banner-toggle:active { - outline: none; - box-shadow: none; -} - -.banner-toggle:not(.collapsed) .fa-caret-down { - transform: rotate(180deg); -} - -.banner-expanded { - margin-top: 8px; -} - -/* OS message banner dismiss (×): inline, grouped with the headline/caret - (not floated into the empty right side). Scoped so it only affects the - OS promo banner, which is the only banner carrying these classes. */ -/* Lay out the OS promo banner as a single centered row so the dismiss button, - headline, and expand caret line up vertically. Scoped to data-source="os" - so the other banners are untouched. */ -.announcement-banner[data-source="os"] { - display: flex; - align-items: center; - flex-wrap: wrap; -} - -.announcement-banner[data-source="os"] .banner-expanded { - flex-basis: 100%; /* expanded text drops to its own row */ -} - -.announcement-banner .os-message-dismiss-form { - display: inline-flex; - margin-right: 10px; -} - -.announcement-banner .os-message-dismiss { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - padding: 0; - background: transparent; - border: 1px solid rgba(0, 0, 0, 0.3); - border-radius: 3px; - color: inherit; - cursor: pointer; - font-size: 13px; - line-height: 1; - opacity: 0.7; -} - -.announcement-banner .os-message-dismiss:hover, -.announcement-banner .os-message-dismiss:focus { - opacity: 1; - background: rgba(0, 0, 0, 0.06); - outline: none; -} - -@media (min-width: 795px) { - div.custom-search-form { - height: 21px; - width: 350px; - } -} - -@media (min-width: 945px) { - div.custom-search-form { - height: 21px; - width: 500px; - } -} - -@media (min-width: 320px) { - #page-wrapper { - margin: 75px 0 0 0; - } - - #footer-wrapper { - margin: 75px 0 0 0; - } -} - -@media (min-width: 359px) { - #page-wrapper { - margin: 100px 0 0 0; - } - - #footer-wrapper { - margin: 100px 0 0 0; - } -} - -@media (min-width: 600px) { - #page-wrapper { - margin: 50px 0 0 0; - } - - #footer-wrapper { - margin: 50px 0 0 0; - } -} - -@media (min-width: 768px) { - .sidebar { - width: 175px - } - - #page-wrapper { - margin: 0 0 0 175px; - } - - #footer-wrapper { - margin: 0 0 0 175px; - border-left: 1px solid #e7e7e7; - } - - li#minimize-menu-li { - display: inline-block; - visibility: visible; - } - - div.custom-search-form { - height: 21px; - width: 305px; - } - - #side-menu li a span.arrow { - display: none - } - - #side-menu li { - position: relative; - border-radius: 4px; - } - - #side-menu > li ul { - position: absolute; - left: 100%; - top: 0; - min-width: 200px; - height: auto !important; - display: none; - z-index: 10000; - background-color: #fff; - background-clip: padding-box; - border: 1px solid rgba(0, 0, 0, .15); - border-left-width: 0; - border-radius: 4px; - box-shadow: 0 6px 12px rgba(0, 0, 0, .175); - } - - /* #side-menu > li.mm-active ul, */ - #side-menu > li:hover ul { - display: block !important; - } - - body.min #page-wrapper { - margin: 0 0 0 50px; - border-left: 1px solid #e7e7e7; - } - - body.min #footer-wrapper { - margin: 0 0 0 50px; - } - - body.min div.navbar-default.sidebar { - width: 50px; - } - - body.min div.navbar-default.sidebar li a span { - display: none; - } - - body.min div.navbar-default.sidebar li a i { - font-size: 18px; - } - - body.min #min-menu i:before { - content: "\f0a9" !important; - } - - body.max #min-menu i:before { - content: "\f0a8" !important; - } - - .announcement-banner { - margin: 0px -30px; - } - -} - -#toTop { - position: fixed; - bottom: 95px; - right: 40px; - cursor: pointer; - display: none; - width: 40px; - height: 30px; -} - -.dojo-filter-set * { - font-size: 11px; -} - -.dojo-filter-set.form-inline .form-group { - display: inline-block; - margin-bottom: 10px; - vertical-align: middle; - margin-right: 25px; -} - -.dojo-filter-set.form-inline .filter-form-group { - display: flex; - flex-wrap: wrap; - flex-basis: auto; - flex-grow: 4; - align-content: stretch; - margin-bottom: 10px; - vertical-align: middle; - margin-right: 25px; -} - -.dojo-filter-set.form-inline .filter-form-input { - margin-right: 25px; - margin-bottom: 10px; -} - -.dojo-filter-set.form-inline .filter-form-control { - width: 100%!important; - vertical-align: middle; -} - -.dojo-filter-set.form-inline .form-control { - height: auto!important; -} - -.report-filter-set { - padding-left: 10px; -} - -.report-filter-set .form-group { - display: inline-block; - margin-bottom: 10px; - vertical-align: middle; - margin-right: 25px; -} - -.finding-list .panel-body, .finding-list table { - font-size: .95em !important; -} - -.dojo-filter-set.form-inline select[multiple=multiple] { - width: 200px; -} - -#the-filters { - background-color: #f9f9f9; -} - -#the-filters-open { - background-color: #f9f9f9; -} - -#the-filters-paused { - background-color: #f9f9f9; -} - -#the-filters-closed { - background-color: #f9f9f9; -} - -.panel-default { - border: 1px solid #dddedf; -} - -.panel-heading h2, -.panel-heading h3, -.panel-heading h4, -.panel-heading h5 { - margin-top: 0; - margin-bottom: 0; -} - -.panel-heading .btn { - padding-top: 0; - padding-bottom: 0; -} - -.panel-default > .panel-comments { - color: black; - font-size: 12px; - padding: 10px 10px; - background-color: #ECECEC; - border-color: #A1C0D7; - border: 1px solid #dddedf; -} - -.panel-default > .panel-heading { - color: white; - font-size: 16px; - background-color: rgba(32, 166, 216, 0.75); - border-color: #A1C0D7; -} - -.panel-default > .panel-heading a, -.panel-default > .panel-heading small{ - color: #fff; -} - -.panel-default > .panel-heading a{ - text-decoration: underline; -} - -.panel-default > .panel-heading a:hover{ - color: #546474; -} - -.panel-default-secondary{ - border: 1px solid #dddedf; - border-top-style: none; -} - -.panel-default-secondary > .panel-heading { - color: white; - background-color: #546474; - border-style: solid; - border-color: #536473; -} - -.panel-heading .text-muted { - color: #4b4b4b; -} - -.panel-body.product-graphs { - padding-left: 0; - padding-right: 0; - background-color: #f8f9f8; -} - -.product-graphs .panel-body { - border-right: 1px solid #dcdedf; - border-left: 1px solid #dcdedf; -} - -.graph { - min-height: 158px; -} - -.graph-loader { - min-height: 158px; - display: flex; - flex-direction: column; - justify-content: center; -} - -.panel-footer { - background-color: #dddedf; - border-top: 1px solid #ddd; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; -} - -.panel-footer small{ - color: #246c91; -} - -div.dt-buttons { - margin: 10px!important; -} - -div.dt-search { - display: inline-block!important; -} - -table#product_types .btn-success{ - background-color: #546474; - border-color: #546474; -} - -table#product_types .btn-success:hover{ - background-color: #3d4a58; - border-color: #3d4a58; -} - -table#product_types .btn-warning{ - background-color: #149b7e; - border-color: #149b7e; -} - -table#product_types .btn-warning:hover{ - background-color: #11876e; - border-color: #11876e; -} - -span.pull-right.clickable i.glyphicon-chevron-up, span.pull-right.clickable i.glyphicon-chevron-down { - color: #546474; -} - -.finding-description { - max-height: 500px; - overflow-y: scroll; -} - -.finding-description::-webkit-scrollbar { - -webkit-appearance: none; -} - -.finding-description::-webkit-scrollbar:vertical { - width: 13px; -} - -.finding-description::-webkit-scrollbar-thumb { - border-radius: 8px; - border: 2px solid white; /* should match background, can't be transparent */ - background-color: rgba(0, 0, 0, .5); -} - -.finding-description::-webkit-scrollbar-track { - background-color: #fff; - border-radius: 8px; -} - -.highlight { - background-color: rgb(255, 255, 123); -} - - -.benchmark_pass { - background-color: #dff0d8; -} -.benchmark_fail { - background-color: #f2dede; -} -.benchmark_disabled { - background-color: #eaedf2 -} - -form#quick-add-form { - display: inline-block; - width: 40%; -} - -.dojo-filter-heading { - background-color: #EBF4FB; - margin: 0px; - padding: 5px 10px; - border-radius: 0px; - border: 1px solid #A1C0D7; - margin-left: -16px; - margin-right: -16px; - margin-top: -16px; - font-size: 15px; - margin-bottom: 15px; -} - -.dojo-report-heading { - margin-top: 15px; - margin-left: -16px; - margin-right: -16px; - background-color: #EBF4FB; - border-radius: 0px; - border: 1px solid #A1C0D7; - font-size: 15px; - padding: 5px 10px; - margin-bottom: 15px; -} - -div.potential-finding, div.potential-finding div.panel-heading { - border-top-right-radius: 0px; - border-top-left-radius: 0px; -} - -a.template-popover:hover { - cursor: url(../img/zoom-in.cur), zoom-in; -} - -.popover{ - max-width: 500px; -} - -#template_findings div.popover { - width: 50% !important; - max-width: 50% !important; -} - -#template_findings div.popover .popover-content { - overflow: hidden; -} - -.panel-available-widget { - border-color: #ddd; -} - -.panel-available-widget .panel-heading { - background-color: #f5f5f5; - border-color: #ddd; -} - -.panel-available-widget .form-group * { - -} - -.panel-available-widget .form-control { - width: 100%; - height: auto; -} - -.panel-available-widget .panel-heading h5 { - margin: 0; -} - -.panel-available-widget .panel-heading hr { - margin: 7px 0; -} - -.available-widgets .panel-available-widget .panel-body { - display: none; -} - -ul#sortable1, ul#sortable2 { - padding: 10px 0 0; - list-style-type: none; -} - -ul#sortable1 li div.panel-heading:hover, ul#sortable2 li div.panel-heading:hover { - cursor: default; -} - -.available-widgets .ui-state-highlight, .in-use-widgets .ui-state-highlight { - height: 35px; - line-height: 1.2em; - border: 2px dotted #d0d0d0; - margin-top: 3px; - margin-bottom: 20px; -} - -.available-widgets .ui-state-highlight:first-child, .in-use-widgets .ui-state-highlight:first-child { - margin-top: 0px; - margin-bottom: 10px; -} - -.report-page-break { - display: flex; - align-items: center; - text-align: center; -} - -.report-page-break::before, -.report-page-break::after { - content: ''; - flex: 1; - border-bottom: 1px dashed #000; -} - -.report-page-break:not(:empty)::before { - margin-right: 0.25em; -} - -.report-page-break:not(:empty)::after { - margin-left: 0.25em; -} - -.editor * { - font-size: 1em !important; -} - -.editor ul { - list-style-type: disc; - padding: 0 40px; -} - -.editor { - overflow: scroll; - min-height: 250px; -} - -.in-use-widgets .report-filter-set .btn-sm { - padding: 2px 10px; -} - -.btn-tag { - background: #3498db; - border-radius: 3px; - border-color: #ddd; - color: #ffffff; - font-size: 90%; - padding: 2px 5px 2px 5px; - text-decoration: none; -} - -.btn-grade { - border-radius: 2px; - border-color: #ddd; - color: #ffffff; - padding: 1px 1px 1px 1px; - text-decoration: none; -} - -.tag-all { - background: #3498db; - text-decoration: none; -} - - -.A { - background: #4ec83d; -} -.B { - background: #ffa100; -} -.C { - background: #FFBD33; -} -.D { - background: #cc3300; -} -.F { - background: #ef251e; -} - -.white-color { - color: #FFFFFF; -} - -div.tags .label { - font-size: 90; - display: inline-block; - padding: .6em .3em; -} - -.tag-label { - display: inline; - padding: .2em .6em .3em; - font-size: 11px; - color: #fff; - border-radius: .25em; - text-decoration: none; - text-decoration-style: solid; -} - -.tag-color { - background: #556270; - border: 1px solid #fff!important; -} - -.tag-version { - background: rgba(32, 166, 216, 0.7); - border: 1px solid #fff!important; -} - -.happy-green-color { - color: green; -} - -.warning-color { - background: red; -} - -.amber-color { - background: #ffc107; -} - -.amber-color-with-padding { - background: #ffc107; - padding: .2em .6em .3em; -} - -.tag-label:link { - color: white; - text-decoration: none; -} - -.tag-label:hover { - color: white; - text-decoration: none; -} - -.tag-label:active { - color: white; - text-decoration: none; -} - -.tag-label:visited { - color: white; - text-decoration: none; -} - -#finding_pic, .modal_finding_pic:hover { - cursor: url(../img/zoom-in.cur), zoom-in; -} - -.product-detailed-info ol.product-detailed-info-numbers li { - display: table-cell; - width: 1%; - padding: 5px 5px; - text-align: center; - white-space: nowrap; - border-bottom: 0; - border-bottom-width: 0px; - border-bottom-style: initial; - border-bottom-color: initial; -} -.product-detailed-info ol.product-detailed-info-numbers li .language-color { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; -} -mark{ - background: orange; - color: black; -} - -.btn-overdue { - border-radius: 0px; - border-color: #ddd; - color: #ffffff; - background: #ef251e; - padding: 2px 2px 2px 2px; - font-size: 90%; -} - -.bg-vuln-critical { - background-color:#D43F3A -} -.bg-vuln-high { - background-color:#D57A1D -} -.bg-vuln-medium { - background-color:#E4AB18 -} -.bg-vuln-low { - background-color:#4CAE4C -} -.bg-vuln-info { - background-color:#357ABD -} -.bg-muted { - background-color:#777 -} -.vuln-count { - color:#fff; - font-size:18px; - font-weight: 700; -} -small { - font-size:12px; - font-weight:400; -} - -.fa-minus-square, .fa-trash { - color: red; -} - -.fa-plus-square, .fa-exchange, .fa-share-square { - color: green; -} - -.fa-asterisk { - color: blue; -} - -#jira i:hover { - cursor: pointer; -} - -.no-bottom-margin { - margin-bottom: 0px; -} - -.lightgrey { - color:lightgrey; -} - -.red { - color:red; -} - -.no-italics { - font-style: normal; -} - -.select2-container { - min-width:200px; -} - -#toggleBox { - background-color: transparent!important; - border: none!important; - font-weight: bold!important; - position: absolute!important; - left: 0!important; -} - -.EasyMDEContainer .table { - width: unset!important; -} - -/* remove spinners (up & down arrow) for integer / number fields -/* For Firefox */ - -input[type='number'] { - -moz-appearance:textfield; -} - -/* Webkit browsers like Safari and Chrome */ - -input[type=number]::-webkit-inner-spin-button, -input[type=number]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.table th, .table td { - border-right: 1px solid #ddd; -} -.table th:last-child, .table td:last-child { - border-right: none; -} - -/* Keep the color of the legend in forced color mode */ -.legend td { - forced-color-adjust: none; -} - -.legend .legendLabel { - padding-left: 4px; - forced-color-adjust: auto; -} - -.fa-caret-down, -.caret { - forced-color-adjust: none; -} - -/* Keep arrows icons in forced color mode */ -@media (forced-colors: active) { - .fa-caret-down, - .caret { - color: ButtonText; - } - } - \ No newline at end of file diff --git a/dojo/static/dojo/css/datatables-dd.css b/dojo/static/dojo/css/datatables-dd.css index ab3f14b9f58..74c3dc6cecf 100644 --- a/dojo/static/dojo/css/datatables-dd.css +++ b/dojo/static/dojo/css/datatables-dd.css @@ -1,6 +1,7 @@ /* ============================================================ DataTables — DefectDojo custom theme - Replaces datatables.net-bs Bootstrap variant with Tailwind-aligned styles. + Styles DataTables' own dt-* markup directly; no styling integration package + is loaded. Loaded AFTER tailwind-out.css so it can reference CSS custom properties. ============================================================ */ @@ -256,19 +257,15 @@ div.dt-container div.dt-search input:focus { div.dt-container div.dt-paging { margin: 0; } -div.dt-container div.dt-paging ul.pagination { +/* DataTables' own pagination markup: <div class="dt-paging"><nav><button + class="dt-paging-button">. The Bootstrap integration used to replace this + with ul.pagination > li > a; it is no longer loaded. */ +div.dt-container div.dt-paging nav { display: flex; flex-wrap: wrap; gap: 0.125rem; - list-style: none; - margin: 0; - padding: 0; -} -div.dt-container div.dt-paging ul.pagination li { - display: inline-block; } -div.dt-container div.dt-paging ul.pagination li a, -div.dt-container div.dt-paging ul.pagination li span { +div.dt-container div.dt-paging button.dt-paging-button { display: inline-flex; align-items: center; justify-content: center; @@ -284,19 +281,18 @@ div.dt-container div.dt-paging ul.pagination li span { transition: background-color 150ms, border-color 150ms; cursor: pointer; } -div.dt-container div.dt-paging ul.pagination li a:hover { +div.dt-container div.dt-paging button.dt-paging-button:hover:not(.current):not(.disabled) { background-color: var(--color-surface-2, #f8fafc); border-color: var(--color-dd-primary-300, #a5b4fc); } -div.dt-container div.dt-paging ul.pagination li.active a, -div.dt-container div.dt-paging ul.pagination li.active span { +div.dt-container div.dt-paging button.dt-paging-button.current { background-color: var(--color-dd-primary-500, #4f46e5); border-color: var(--color-dd-primary-500, #4f46e5); color: white; font-weight: 600; } -div.dt-container div.dt-paging ul.pagination li.disabled a, -div.dt-container div.dt-paging ul.pagination li.disabled span { +div.dt-container div.dt-paging button.dt-paging-button.disabled, +div.dt-container div.dt-paging button.dt-paging-button:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; @@ -336,6 +332,26 @@ div.dt-container .dt-button.active { color: white; } +/* -- Legacy `dom` control row -------------------------------- */ +/* With the legacy `dom` option (the tables here use dom: 'Bfrtip'), DataTables + emits the control divs as direct children of the container with no + dt-layout-row wrapper, so they stack vertically. The Bootstrap integration + used to inline them by putting .form-inline on the container; do it here + instead so buttons and search share one row. Must stay after the .dt-buttons + rule above, which sets display:flex at equal specificity. */ +div.dt-container > div.dt-buttons, +div.dt-container > div.dt-length, +div.dt-container > div.dt-search { + display: inline-flex; + align-items: center; + vertical-align: middle; + width: auto; +} +div.dt-container > div.dt-length, +div.dt-container > div.dt-search { + margin-left: 0.75rem; +} + /* -- DT processing spinner ----------------------------------- */ div.dt-processing { position: absolute; diff --git a/dojo/static/dojo/js/classic/index.js b/dojo/static/dojo/js/classic/index.js deleted file mode 100644 index 694f62dec1b..00000000000 --- a/dojo/static/dojo/js/classic/index.js +++ /dev/null @@ -1,367 +0,0 @@ -$(function () { - $('body').append('<a id="toTop" title="Back to Top" class="btn btn-primary btn-circle"><i class="fa-solid fa-arrow-up fa-fw"></i></a>'); - - // ---- OS promo banner dismiss: persist per-user (form carries CSRF) + hide instantly ---- - $(document).on('submit', '.os-message-dismiss-form', function (e) { - e.preventDefault(); - var form = this; - $(form).closest('.announcement-banner').fadeOut(200, function () { $(this).remove(); }); - fetch(form.action, { - method: 'POST', - body: new FormData(form), - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - credentials: 'same-origin', - }); - }); - $(window).scroll(function () { - if ($(this).scrollTop() > 300) { - $('#toTop').fadeIn(); - } else { - $('#toTop').fadeOut(); - } - }); - - $('#toTop').click(function(){ - $("html, body").animate({ scrollTop: 0 }, 600); - return false; - }); - - - $(".datepicker").datepicker({"dateFormat": "yy-mm-dd"}); - - $('form#replace_risk_file input[type="file"], div.controls.file input').change(function () { - $(this).closest("form").submit() - }); - - $('a.accept-all-findings').click(function () { - $("ul#id_accepted_findings input").attr('checked', true); - }) - - setTimeout(function () { - $('.alert-dismissible').not('.announcement-banner').slideUp('slow') - }, 20000); - - $('#side-menu').metisMenu(); - - // auto focus on first form field - $('#base-content form:first *:input[type!=hidden]:first').not('button, input[type=submit]').not('.filters :input, textarea#id_entry, input#quick_add_finding').not('input[type=checkbox]').not('.datepicker').focus(); - - $('a#minimize-menu').on('click', sidebar); - - $("ul#progress-crumbs a").on('click', function() { - var href = $(this).attr('href'); - $('html, body').animate({ - scrollTop: $(href).offset().top - 55 - }, 500); - return false; - }); - -}); - -$.fn.serializeObject = function() -{ - var o = {}; - var a = this.serializeArray(); - $.each(a, function() { - if (o[this.name] !== undefined && o[this.name] != 'csrfmiddlewaretoken') { - if (!o[this.name].push) { - o[this.name] = [o[this.name]]; - } - o[this.name].push(this.value || ''); - } else { - o[this.name] = this.value || ''; - } - }); - return o; -}; - -function sidebar() { // minimize side nav bar - var action = 'min'; - var remove = 'max'; - var speed = 250; - var width = '50'; - var fontSize = '18'; - - if (($.cookie('dojo-sidebar') == 'min') || ($('body').hasClass('min'))) { - action = 'max'; - remove = 'min'; - $.cookie('dojo-sidebar', 'max', {expires: 10000, path: '/'}); - width = '175px'; - fontSize = '14px'; - speed = 100; - } - else { - action = 'min'; - remove = 'max'; - $.cookie('dojo-sidebar', 'min', {expires: 10000, path: '/'}); - } - - $('body').switchClass(remove, action); - - return false; -} - -//methods removed in django 3.1. we copy them here to keep this popup thing working -// but this definately needs a rework, but with UI v2 in the works this is acceptable -function id_to_windowname(text) { - text = text.replace(/\./g, '__dot__'); - text = text.replace(/\-/g, '__dash__'); - return text; -} - -function windowname_to_id(text) { - text = text.replace(/__dot__/g, '.'); - text = text.replace(/__dash__/g, '-'); - return text; -} - -function emptyEndpoints(win) { - var name = windowname_to_id(win.name); - var elem = document.getElementById(name); - $(elem).empty(); -} - -function html_unescape(text) { - // Unescape a string that was escaped using django.utils.html.escape. - text = text.replace(/</g, '<'); - text = text.replace(/>/g, '>'); - text = text.replace(/"/g, '"'); - text = text.replace(/'/g, "'"); - text = text.replace(/&/g, '&'); - return text; -} - -function dismissAddAnotherPopupDojo(win, newId, newRepr) { - // newId and newRepr are expected to have previously been escaped by - // django.utils.html.escape. - newId = html_unescape(newId); - newRepr = html_unescape(newRepr); - var name = windowname_to_id(win.name); - var elem = document.getElementById(name); - var o; - if (elem) { - var elemName = elem.nodeName.toUpperCase(); - if (elemName == 'SELECT') { - var s = "#" + elem.id + " option[value='" + newId + "']"; - if ($(s).length <= 0) { - o = new Option(newRepr, newId); - elem.options[elem.options.length] = o; - o.selected = true - $(o).attr('selected', 'selected'); - } - else { - $(s).attr('selected', 'selected'); - } - } else if (elemName == 'INPUT') { - if (elem.className.indexOf('vManyToManyRawIdAdminField') != -1 && elem.value) { - elem.value += ',' + newId; - } else { - elem.value = newId; - } - } - } else { - var toId = name + "_to"; - o = new Option(newRepr, newId); - SelectBox.add_to_cache(toId, o); - SelectBox.redisplay(toId); - } -} - -function punchcard(element, data, ticks) { - var d1 = data; - var options = { - xaxis: { - ticks: ticks, - min: -.8, - max: ticks.length - .2, - tickLength: 0, - }, - series: { - bubbles: { - active: true, - debug: { - active: true - }, - show: true, - bubblelabel: { - show: false, - }, - }, - nearBy: { - distance: 5 - } - }, - yaxis: { - autoscaleMargin: 0.1, - ticks: [[6, 'Sun'], [5, 'Mon'], [4, 'Tue'], [3, 'Wed'], [2, 'Thur'], [1, 'Fri'], [0, 'Sat']], - min: -.5, - max: 6.5, - tickLength: 0, - }, - grid: { - hoverable: true, - borderWidth: {top: 0, right: 0, bottom: 1, left: 0}, - borderColor: '#e7e7e7', - clickable: true, - markings: function (axes) { - var markings = []; - - for (var x = 0; x < axes.yaxis.max; x += .5) - markings.push({yaxis: {from: x, to: x},}); - - for (var x = -0; x < axes.xaxis.max; x += 1) - markings.push({xaxis: {from: x, to: x}}); - - for (var x = -.5; x < axes.yaxis.max + 1; x += 1) - markings.push({yaxis: {from: x, to: x - .75}, color: 'white'}); - - - return markings; - - - } - }, - tooltip: true, - tooltipOpts: { - content: function (label, xval, yval, flotItem) { - for (var x = 0; x < flotItem.series.data.length; x++) { - if (xval == flotItem.series.data[x][0] && yval == flotItem.series.data[x][1]) { - yeah = flotItem.series.data[x][3]; - break; - } - } - return yeah + ' Findings'; - }, - shifts: { - y: -40, - x: -20 - } - }, - legend: { - show: false, - } - - - }; - var p4 = $.plot($(element), - [{ - data: d1, - color: "#444", - }], - options); - -} - -function togglePassVisibility() { - var passwdInput = document.getElementById("id_password"); - var toggleBox = document.getElementById("toggleBox"); - - // swap password - if (passwdInput.type === "password") { - passwdInput.type = "text"; - toggleBox.innerHTML = "<i class='fa-solid fa-eye-slash'></i>\ - <span>Hide Password</span>"; - } else { - passwdInput.type = "password"; - toggleBox.innerHTML = "<i class='fa-solid fa-eye'></i>\ - <span>Show Password</span>"; - } -} - - -// Parse a string that contains HTML to retrieve value from the HTML tag or Attribute, returning only a TEXT version. -// The htmlTagAttributValye is optional, and if supplied, then this function will look within the HTML tag attributes to -// return the value. Example htmlTagAttributValye ( data-content=****** ) -// This function is used in the product.html, view_product_details adn engagements_all for proper DataTables exports. -function getDojoExportValueFromTag(htmlString, tag, htmlTagAttribValue){ - parser = new DOMParser(); - doc = parser.parseFromString(htmlString.toString(), "text/html"); - var tags = doc.getElementsByTagName(tag.toString()); - var l = tags.length; - var tagsValueArray = []; - var exportValue = ""; - if (htmlTagAttribValue) { - for (i = 0; i < l; i++) { - var tempAttribValue = tags[i].getAttribute(htmlTagAttribValue.toString()); - // Only append values if they are not null, empty or NaN - if (tempAttribValue) { - tagsValueArray.push(tempAttribValue); - } - } - exportValue = tagsValueArray; - } - else { - if (l >= 1) { - // Iterate through all HTML tags and append the return values to the array - for (i = 0; i < l; i++) { - tagsValueArray.push(tags[i].textContent); - } - exportValue = tagsValueArray; - } - else { - exportValue = htmlString; - }} - - // Replace by a space any HTML tags that might still be in the string - return exportValue.toString().replace(/<\/?[^>]+(>|$)/g, " "); -} - -generateGUID = (typeof(window.crypto) != 'undefined' && - typeof(window.crypto.getRandomValues) != 'undefined') ? - function() { - // If we have a cryptographically secure PRNG, use that - // https://stackoverflow.com/questions/6906916/collisions-when-generating-uuids-in-javascript - var buf = new Uint16Array(8); - window.crypto.getRandomValues(buf); - var S4 = function(num) { - var ret = num.toString(16); - while(ret.length < 4){ - ret = "0"+ret; - } - return ret; - }; - return (S4(buf[0])+S4(buf[1])+"-"+S4(buf[2])+"-"+S4(buf[3])+"-"+S4(buf[4])+"-"+S4(buf[5])+S4(buf[6])+S4(buf[7])); - } - - : - - function() { - // Otherwise, just use Math.random - // https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); - return v.toString(16); - }); - }; - - var absolutePath = function(href) { - var link = document.createElement("a"); - link.href = href; - return link.href; - } - -function clear_form(form){ - $(form).find(':input').each(function() { - console.log(this.type) - switch(this.type) { - case 'number': - case 'password': - case 'select-one': - case 'text': - case 'textarea': - $(this).val(''); - break; - case 'checkbox': - case 'radio': - this.checked = false; - break; - case 'select-multiple': - // Clear all types of multiple select versions - if ($(this).hasClass('select2-hidden-accessible')) { - $(this).data('select2').$container.find(".select2-selection__choice").remove(); - } - $(this).val(null).trigger('change'); - break; - } - }); -} diff --git a/dojo/static/dojo/js/classic/metrics.js b/dojo/static/dojo/js/classic/metrics.js deleted file mode 100644 index e55ea66f198..00000000000 --- a/dojo/static/dojo/js/classic/metrics.js +++ /dev/null @@ -1,2465 +0,0 @@ -/* - dashboard.html -*/ - -function homepage_pie_chart(critical, high, medium, low, info) { - var data = [{ - label: "Critical", - color: "#d9534f", - data: critical - }, { - label: "High", - color: "#f0ad4e", - data: high - }, { - label: "Medium", - color: "#f0de28", - data: medium - }, { - label: "Low", - color: "#337ab7", - data: low - }, { - label: "Informational", - color: "#E0E0E0", - data: info - }]; - - var plotObj = $.plot($("#homepage_pie_chart"), data, { - series: { - pie: { - innerRadius: 0.5, - show: true, - radius: 1, - label: { - show: false, - radius: 2 / 3, - formatter: function (label, series) { - return '<div style="font-size:8pt;text-align:center;padding:2px;color:black;z-index:9999;">' + label + '<br/>' + series.data[0][1] + '</div>'; - - }, - - } - } - }, - grid: { - hoverable: true, - }, - tooltip:true, - tooltipOpts: { - content: function(label, xval, yval, flotItem) { - return label+"<br>"+yval - } - } - }); -} - -function homepage_severity_plot(critical, high, medium, low) { - var options = { - xaxes: [{ - mode: 'time', - minTickSize: [1, "month"] - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: true, - }; - - var plotObj = $.plot($("#homepage_severity_plot"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -} - -/* - dashboard-metrics.html -*/ - -function getTicks(critical, high, medium, low) { - return [...new Set(critical.concat(high, medium, low).map(x => x[0]))] -} - -function opened_per_month(critical, high, medium, low) { - var options = { - xaxes: [{ - mode: 'time', - timeformat: "%m/%y", - ticks: getTicks(critical, high, medium, low), - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - }; - - $.plot($("#opened_per_month"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -}; - -function accepted_per_month(critical, high, medium, low) { - var options = { - xaxes: [{ - mode: 'time', - timeformat: "%m/%y", - ticks: getTicks(critical, high, medium, low), - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - }; - - $.plot($("#accepted_per_month"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -}; - -function opened_per_week(critical, high, medium, low) { - var options = { - xaxes: [{ - mode: 'time', - timeformat: "%m/%d/%Y", - ticks: getTicks(critical, high, medium, low), - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: true, - }; - - var plotObj = $.plot($("#opened_per_week"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -} - -function accepted_per_week(critical, high, medium, low) { - var options = { - xaxes: [{ - mode: 'time', - timeformat: "%m/%d/%Y", - ticks: getTicks(critical, high, medium, low), - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - }; - - var plotObj = $.plot($("#accepted_per_week"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -} - -function top_ten_products(critical, high, medium, low, ticks) { - data1 = [ - { - data: critical, - color: "#d9534f", - bars: {fill: 1}, - label: 'Critical', - }, - { - data: high, - color: "#f0ad4e", - bars: {fill: 1}, - label: 'High', - - }, - { - data: medium, - color: "#f0de28", - bars: {fill: 1}, - label: 'Medium', - }, - { - data: low, - color: "#337ab7", - bars: {fill: 1}, - label: 'Low', - }, - ]; - - $.plot("#top-ten", data1, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - }); -} - -function severity_pie(critical, high, medium, low) { - var data = [{ - label: "Critical", - color: "#d9534f", - data: critical - }, { - label: "High", - color: "#f0ad4e", - data: high - }, { - label: "Medium", - color: "#f0de28", - data: medium - }, { - label: "Low", - color: "#337ab7", - data: low - }]; - - var plotObj = $.plot($("#opened_in_period"), data, { - series: { - pie: { - show: true, - radius: 1, - label: { - show: true, - radius: 2 / 3, - formatter: function (label, series) { - return '<div style="font-size:8pt;text-align:center;padding:2px;color:black;z-index:9999;">' + label + '<br/>' + series.data[0][1] + '</div>'; - - }, - - } - } - }, - grid: { - hoverable: false - }, - }); -} - -function total_accepted_pie(critical, high, medium, low) { - var data = [{ - label: "Critical", - color: "#d9534f", - data: critical - }, { - label: "High", - color: "#f0ad4e", - data: high - }, { - label: "Medium", - color: "#f0de28", - data: medium - }, { - label: "Low", - color: "#337ab7", - data: low - }]; - - var plotObj = $.plot($("#total_accepted_in_period"), data, { - series: { - pie: { - show: true, - radius: 1, - label: { - show: true, - radius: 2 / 3, - formatter: function (label, series) { - return '<div style="font-size:8pt;text-align:center;padding:2px;color:black;z-index:9999;">' + label + '<br/>' + series.data[0][1] + '</div>'; - - }, - - } - } - }, - grid: { - hoverable: false - }, - }); -} - -function total_closed_pie(critical, high, medium, low) { - var data = [{ - label: "Critical", - color: "#d9534f", - data: critical - }, { - label: "High", - color: "#f0ad4e", - data: high - }, { - label: "Medium", - color: "#f0de28", - data: medium - }, { - label: "Low", - color: "#337ab7", - data: low - }]; - - var plotObj = $.plot($("#total_closed_in_period"), data, { - series: { - pie: { - show: true, - radius: 1, - label: { - show: true, - radius: 2 / 3, - formatter: function (label, series) { - return '<div style="font-size:8pt;text-align:center;padding:2px;color:black;z-index:9999;">' + label + '<br/>' + series.data[0][1] + '</div>'; - - }, - - } - } - }, - grid: { - hoverable: false - }, - }); -} - -/* - metrics.html -*/ - -function opened_per_month_2(critical, high, medium, low) { - var tick_count = critical.length < 7 ? critical.length : 7; - var options = { - xaxis: { - mode: "time", - timeformat: "%m-%d-%Y", - timezone: 'browser' - }, - xaxes: [{ - ticks: tick_count - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7' - - }, - tooltip: true - }; - var plotObj = $.plot($("#opened_per_month_2"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - - }, { - data: high, - label: " High", - color: '#f0ad4e', - - }, { - data: medium, - label: " Medium", - color: '#f0de28', - - }, { - data: low, - label: " Low", - color: '#337ab7', - - }], - options); -} - -function active_per_month(critical, high, medium, low) { - var tick_count = critical.length < 7 ? critical.length : 7; - var options = { - xaxis: { - mode: "time", - timeformat: "%m-%d-%Y", - timezone: 'browser' - }, - xaxes: [{ - ticks: tick_count - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7' - - }, - tooltip: true - }; - var plotObj = $.plot($("#active_per_month"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - - }, { - data: high, - label: " High", - color: '#f0ad4e', - - }, { - data: medium, - label: " Medium", - color: '#f0de28', - - }, { - data: low, - label: " Low", - color: '#337ab7', - - }], - options); -} - -function accepted_per_month_2(critical, high, medium, low) { - var tick_count = critical.length < 7 ? critical.length : 7; - var options = { - xaxis: { - mode: "time", - timeformat: "%m-%d-%Y", - timezone: 'browser' - }, - xaxes: [{ - ticks: tick_count - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7' - - }, - tooltip: true - }; - var plotObj = $.plot($("#accepted_per_month_2"), [{ - data: critical, - label: " Critical", - color: "#d9534f" - }, { - data: high, - label: " High", - color: '#f0ad4e' - }, { - data: medium, - label: " Medium", - color: '#f0de28' - }, { - data: low, - label: " Low", - color: '#337ab7' - }], - options); -} - -function opened_per_week_2(critical, high, medium, low) { - var options = { - xaxis: { - mode: "time", - timeformat: "%m-%d-%Y", - timezone: 'browser' - }, - xaxes: [{ - ticks: 7 - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7' - - }, - tooltip: true - }; - - - var plotObj = $.plot($("#opened_per_week_2"), [{ - data: critical, - label: " Critical", - color: "#d9534f" - }, { - data: high, - label: " High", - color: '#f0ad4e' - }, { - data: medium, - label: " Medium", - color: '#f0de28' - }, { - data: low, - label: " Low", - color: '#337ab7' - }], - options); -} - -function accepted_per_week_2(critical, high, medium, low) { - var options = { - xaxis: { - mode: "time", - timeformat: "%m-%d-%Y", - timezone: 'browser' - }, - xaxes: [{ - ticks: 7 - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7' - - }, - tooltip: true - }; - - - var plotObj = $.plot($("#accepted_per_week_2"), [{ - data: critical, - label: " Critical", - color: "#d9534f" - }, { - data: high, - label: " High", - color: '#f0ad4e' - }, { - data: medium, - label: " Medium", - color: '#f0de28' - }, { - data: low, - label: " Low", - color: '#337ab7' - }], - options); -} - - -// This function is valid besides metrics.html also for the dashboard-metrics.html, -// dashboard.html, and product-metrics.html -function updatePunchcardTable(punchcardData, ticks) { - let tableBody = $("#punchcard-table tbody"); - - const daysMap = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; - let formattedData = {}; - - // No table processing in case of no data - if (punchcardData.length === 0 || ticks.length === 0) return; - - // Removing html elements from the ticks dates - let ticksMap = {}; - ticks.forEach(entry => { - let weekIndex = String(entry[0]); - let rawHtml = entry[1]; - - // Goodbye <span> + space instead of <br/> - let cleanDate = rawHtml.replace(/<\/?span[^>]*>/g, "").replace(/<br\s*\/?>/g, " "); - cleanDate = cleanDate.trim(); - ticksMap[weekIndex] = cleanDate; - }); - - let minWeekOffset = ticks[0][0]; - let maxWeekOffset = ticks[ticks.length - 1][0]; - - for (let weekOffset = minWeekOffset; weekOffset <= maxWeekOffset; weekOffset++) { - let formattedDate = ticksMap[String(weekOffset)] || "Unknown Date"; - let formattedWeek = `Week ${weekOffset - minWeekOffset + 1}, starting on ${formattedDate}`; - - formattedData[formattedWeek] = { - "Monday": 0, "Tuesday": 0, "Wednesday": 0, - "Thursday": 0, "Friday": 0, "Saturday": 0, "Sunday": 0 - }; - } - - // Populating week data - punchcardData.forEach(entry => { - let weekOffset = entry[0]; - let day = daysMap[entry[1]]; - let value = entry[3] || 0; - - let formattedDate = ticksMap[String(weekOffset)] || "Unknown Date"; - let formattedWeek = `Week ${weekOffset - minWeekOffset + 1}, starting on ${formattedDate}`; - - if (formattedData[formattedWeek]) { - formattedData[formattedWeek][day] = value; - } - }); - - // Rendering accessibility table body - Object.entries(formattedData).forEach(([week, values]) => { - let newRow = ` - <tr> - <td scope="row">${week}</td> - <td>${values.Monday || '0'}</td> - <td>${values.Tuesday || '0'}</td> - <td>${values.Wednesday || '0'}</td> - <td>${values.Thursday || '0'}</td> - <td>${values.Friday || '0'}</td> - <td>${values.Saturday || '0'}</td> - <td>${values.Sunday || '0'}</td> - </tr> - `; - tableBody.append(newRow); - }); -} - -/* - product_metrics.html -*/ - -function open_findings_burndown(critical, high, medium, low, info, y_max, y_min) { - var options = { - xaxes: [{ - mode: "time", - timeformat: "%Y/%m/%d" - }], - yaxes: [{ - max: y_max, - min: y_min - }], - series: { - lines: { - show: true - }, - points: { - show: true, - radius: 1 - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - legend: { - position: 'nw' - }, - tooltip: true, - }; - - var plotObj = $.plot($("#open_findings_burndown"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#4cae4c', - }, { - data: info, - label: " Info", - color: '#337ab7', - }], - options); -} - -function accepted_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#accepted_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -function inactive_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#inactive_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -function open_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#open_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -function false_positive_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#false_positive_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -function verified_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#verified_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - }); -} - -function out_of_scope_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#out_of_scope_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - }); -} - -function all_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#all_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -function closed_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#closed_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -function new_objs(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#new_objs", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -function open_close_weekly(opened, closed, accepted, ticks) { - var options = { - xaxes: [{ - ticks: ticks, - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7' - - }, - tooltip: true - }; - - var plotObj = $.plot($("#open_close_weekly"), [{ - data: opened, - label: " Opened", - color: "#d9534f" - }, { - data: closed, - label: " Closed", - color: '#f0ad4e' - }, { - data: accepted, - label: " Accepted", - color: '#80699B' - }], - options); -} - -function severity_weekly(critical, high, medium, low, info, ticks) { - var options = { - xaxes: [{ - ticks: ticks, - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7' - - }, - tooltip: true - }; - - var plotObj = $.plot($("#severity_weekly"), [{ - data: critical, - label: " Critical", - color: "#d9534f" - }, { - data: high, - label: " High", - color: '#f0ad4e' - }, { - data: medium, - label: " Medium", - color: '#f0de28' - }, { - data: low, - label: " Low", - color: '#337ab7' - }, { - data: info, - label: " Info", - color: '#80699B' - }], - options); - -} - -function severity_counts_weekly(critical, high, medium, ticks) { - var options = { - xaxes: [{ - ticks: ticks, - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: true, - borderWidth: 1, - borderColor: '#e7e7e7' - - }, - tooltip: true - }; - - var plotObj = $.plot($("#severity_critical"), [{ - data: critical, - label: " Critical", - color: "#d9534f" - }], - options); - var plotObj = $.plot($("#severity_high"), [{ - data: high, - label: " High", - color: "#f0ad4e" - }], - options); - var plotObj = $.plot($("#severity_medium"), [{ - data: medium, - label: " Medium", - color: "#f0de28" - }], - options); -} - -function test_type(data) { - $.plot('#test_type', [data], { - series: { - stack: true, - bars: { - show: true, - barWidth: .7, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - mode: "categories", - tickLength: 4 - }, - - }); -} - -function draw_vulnerabilities_graph(tag, data) { - $.plot(tag, [data], { - series: { - stack: true, - bars: { - show: true, - barWidth: .7, - 'align': "center" - } - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7' - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - mode: "categories", - tickLength: 4 - } - }); -} - -/* - view_endpoint.html -*/ - -/* - view_engineer.html -*/ - -function open_bug_count_by_month(critical, high, medium, low, ticks) { - var options = { - xaxis: { - tickFormatter: function (x) { - return ticks[x - 1]; - }, - }, - xaxes: [{ - ticks: ticks.length, - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - }; - var plotObj = $.plot($("#chart_div"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -} - -function accepted_bug_count_by_month(critical, high, medium, low, ticks) { - var options = { - xaxis: { - tickFormatter: function (x) { - return ticks[x - 1]; - }, - }, - xaxes: [{ - ticks: ticks.length, - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - }; - var plotObj = $.plot($("#chart_div2"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -} - -function open_bug_count_by_week(critical, high, medium, low, ticks) { - var options = { - xaxis: { - tickFormatter: function (x) { - return ticks[x - 1]; - }, - }, - xaxes: [{ - ticks: ticks.length, - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - }; - - - var plotObj = $.plot($("#chart_div3"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -} - -function accepted_bug_count_by_week(critical, high, medium, low, ticks) { - var options = { - xaxis: { - tickFormatter: function (x) { - return ticks[x - 1]; - }, - }, - xaxes: [{ - ticks: ticks.length, - }], - yaxes: [{ - min: 0 - }], - series: { - lines: { - show: true - }, - points: { - show: true - } - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - }; - - - var plotObj = $.plot($("#chart_div4"), [{ - data: critical, - label: " Critical", - color: "#d9534f", - }, { - data: high, - label: " High", - color: '#f0ad4e', - }, { - data: medium, - label: " Medium", - color: '#f0de28', - }, { - data: low, - label: " Low", - color: '#337ab7', - }], - options); -} - -/* - view_product_details.html -*/ - -function languages_pie(data) { - var plotObj = $.plot($("#donut-lang"), data, { - series: { - pie: { - innerRadius: 0.5, - show: true, - radius: 1, - } - }, - legend: { - show: true, - container: "#donut-lang-container", - }, - grid: { - hoverable: true, - } - }); -} - -/* - endpoint_pdf_report.html -*/ - -/* - engagement_pdf_report.html -*/ - -/* - finding_pdf_report.html -*/ - -/* - product_endpoint_pdf_report.html -*/ - -function accepted_findings(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#accepted_findings", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -/* - product_pdf_report.html -*/ - -/* - product_type_pdf_report.html -*/ - -function finding_age(data_1, ticks) { - var dataset = [ - {data: data_1, color: "#337ab7", fillColor: "#337ab7"} - ]; - - var options = { - series: { - bars: { - show: true, - fill: true, - fillColor: "#337ab7" - } - }, - bars: { - align: "center", - barWidth: 0.5 - }, - xaxis: { - axisLabel: "Days Open", - axisLabelUseCanvas: true, - axisLabelFontSizePixels: 12, - axisLabelFontFamily: 'Verdana, Arial', - axisLabelPadding: 10, - ticks: ticks, - }, - yaxis: { - axisLabel: "Number of Findings", - axisLabelUseCanvas: true, - axisLabelFontSizePixels: 12, - axisLabelFontFamily: 'Verdana, Arial', - axisLabelPadding: 3, - }, - legend: { - show: false, - }, - grid: { - borderWidth: 1, - borderColor: '#e7e7e7', - } - }; - - $.plot("#finding_age", dataset, options); -} - -function open_findings(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#open_findings", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -function closed_findings(d1, d2, d3, d4, d5, ticks) { - var data = [ - { - label: "Critical", - data: d1, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 1, - fillColor: "#d9534f" - }, - color: "#d9534f" - }, - { - label: "High", - data: d2, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 2, - fillColor: "#f0ad4e" - }, - color: "#f0ad4e" - }, - { - label: "Medium", - data: d3, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 3, - fillColor: "#f0de28" - }, - color: "#f0de28" - }, - { - label: "Low", - data: d4, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#337ab7" - }, - color: "#337ab7" - }, - { - label: "info", - data: d5, - bars: { - show: true, - fill: true, - lineWidth: 1, - order: 4, - fillColor: "#80699B" - }, - color: "#80699B" - } - ]; - - $.plot("#closed_findings", data, { - series: { - stack: true, - bars: { - show: true, - barWidth: .9, - 'align': "center", - }, - - }, - grid: { - hoverable: false, - borderWidth: 1, - borderColor: '#e7e7e7', - - }, - tooltip: false, - legend: { - show: false, - position: "ne" - }, - xaxis: { - ticks: ticks, - }, - - }); -} - -/* - test_pdf_report.html -*/ \ No newline at end of file diff --git a/dojo/static/dojo/js/index.js b/dojo/static/dojo/js/index.js index 6dc541de4c7..47a50d988c2 100644 --- a/dojo/static/dojo/js/index.js +++ b/dojo/static/dojo/js/index.js @@ -63,6 +63,38 @@ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAllDropdowns(null); }); + + /* jQuery plugin surface. Templates call $(el).dropdown('toggle') from inline + onclick handlers, which bootstrap.min.js used to provide; without it jQuery + raises "$(...).dropdown is not a function" and the handler dies. Route the + call through the same open/close logic as a real click. + + The receiver differs per call site: the toggle itself (#dropdownMenu2), the + .dropdown container (#test-pulldown), or the .dropdown-menu (#related_actions_N). + closest() matches the element itself as well as its ancestors, so one lookup + resolves all three to the element that carries .open. + */ + if (typeof jQuery !== 'undefined') { + jQuery.fn.dropdown = function (action) { + return this.each(function () { + var parent = this.closest('.dropdown, .btn-group, .dropup'); + if (!parent) return; + var isOpen = parent.classList.contains('open'); + var open = action === 'show' ? true : (action === 'hide' ? false : !isOpen); + if (open === isOpen) return; + if (open) { + closeAllDropdowns(parent); + parent.classList.add('open'); + fireJQueryEvent(parent, 'show.bs.dropdown'); + fireJQueryEvent(parent, 'shown.bs.dropdown'); + } else { + parent.classList.remove('open'); + fireJQueryEvent(parent, 'hide.bs.dropdown'); + fireJQueryEvent(parent, 'hidden.bs.dropdown'); + } + }); + }; + } })(); /* ---- OS promo banner dismiss ---- diff --git a/dojo/template_loaders.py b/dojo/template_loaders.py deleted file mode 100644 index 394731de76f..00000000000 --- a/dojo/template_loaders.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Template loader that selects between the classic and Tailwind UI trees per request. - -Two parallel template directories are maintained on this branch: - -- ``dojo/templates/`` — Tailwind v4 + Alpine.js / htmx UI (the new opt-in UI) -- ``dojo/templates_classic/`` — Bootstrap 3 + SB Admin 2 UI (default) - -`UIPreferenceLoader` resolves a template name from one tree first, falling back -to the other. The order is decided per request based on -`request.user.usercontactinfo.ui_use_tailwind`. Anonymous users (login pages, -error pages) and authenticated users who opted in get the Tailwind tree first. -Everyone else gets the classic tree first. - -The current request is read via `crum.get_current_request()` — `crum`'s -`CurrentRequestUserMiddleware` is already wired into the middleware chain in -`dojo/settings/settings.dist.py`. -""" - -from crum import get_current_request -from django.template import TemplateDoesNotExist -from django.template.loaders.base import Loader as BaseLoader -from django.template.loaders.cached import Loader as CachedLoader -from django.template.loaders.filesystem import Loader as FilesystemLoader - -_FILESYSTEM_LOADER_PATH = "django.template.loaders.filesystem.Loader" - - -class UIPreferenceLoader(BaseLoader): - - def __init__(self, engine, tailwind_dir, classic_dir): - super().__init__(engine) - # Inner loaders maintain their own template caches when not in debug - # mode. Caching at this outer layer would be incorrect because the - # cache key would not include the per-user UI preference. - if engine.debug: - self._tailwind_loader = FilesystemLoader(engine, [tailwind_dir]) - self._classic_loader = FilesystemLoader(engine, [classic_dir]) - else: - # ``CachedLoader`` resolves each entry in its loader list via - # ``engine.find_template_loader``, which only accepts ``str`` / - # ``(str, *args)`` configurations — passing a ``Loader`` instance - # raises ``ImproperlyConfigured: Invalid value in template - # loaders configuration``. Express the inner filesystem loader - # as a ``(loader_path, [dirs])`` tuple so ``find_template_loader`` - # can instantiate it correctly. - self._tailwind_loader = CachedLoader( - engine, [(_FILESYSTEM_LOADER_PATH, [tailwind_dir])], - ) - self._classic_loader = CachedLoader( - engine, [(_FILESYSTEM_LOADER_PATH, [classic_dir])], - ) - - def _ordered_loaders(self): - request = get_current_request() - if request is None: - return (self._tailwind_loader, self._classic_loader) - user = getattr(request, "user", None) - if user is None or not getattr(user, "is_authenticated", False): - return (self._tailwind_loader, self._classic_loader) - contact = getattr(user, "usercontactinfo", None) - if contact is not None and getattr(contact, "ui_use_tailwind", False): - return (self._tailwind_loader, self._classic_loader) - return (self._classic_loader, self._tailwind_loader) - - def get_template(self, template_name, skip=None): - tried = [] - for loader in self._ordered_loaders(): - try: - return loader.get_template(template_name, skip=skip) - except TemplateDoesNotExist as exc: - tried.extend(exc.tried) - raise TemplateDoesNotExist(template_name, tried=tried) - - def get_template_sources(self, template_name): - for loader in self._ordered_loaders(): - yield from loader.get_template_sources(template_name) - - def reset(self): - for loader in (self._tailwind_loader, self._classic_loader): - if hasattr(loader, "reset"): - loader.reset() diff --git a/dojo/templates/base.html b/dojo/templates/base.html index cfa212a7be2..532993a0cab 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -38,10 +38,11 @@ <script src="{% static "moment/min/moment.min.js" %}"></script> <script src="{% static "fullcalendar/dist/fullcalendar.min.js" %}"></script> <!-- DataTables --> + <!-- Core DataTables only — the Bootstrap styling integrations were + dropped; markup is styled by datatables-dd.css against DataTables' + own dt-* classes. --> <script src="{% static 'datatables.net/js/dataTables.min.js' %}"></script> - <script src="{% static 'datatables.net-bs/js/dataTables.bootstrap.min.js' %}"></script> <script src="{% static 'datatables.net-buttons/js/dataTables.buttons.min.js' %}"></script> - <script src="{% static 'datatables.net-buttons-bs/js/buttons.bootstrap.min.js' %}"></script> <script src="{% static 'datatables.net-buttons/js/buttons.html5.min.js' %}"></script> <script src="{% static 'datatables.net-buttons/js/buttons.colVis.min.js' %}"></script> <script src="{% static 'datatables.net-buttons/js/buttons.print.min.js' %}"></script> @@ -220,7 +221,7 @@ </div> <!-- Components --> - <a href="{% url 'components' %}" class="flex items-center gap-3 px-4 py-2 text-gray-300 hover:text-white hover:bg-white/10 transition-colors"> + <a href="{% url 'components' %}" id="product_component_view" class="flex items-center gap-3 px-4 py-2 text-gray-300 hover:text-white hover:bg-white/10 transition-colors"> <i class="fa-solid fa-table-cells-large w-5 text-center flex-shrink-0"></i> <span class="truncate">{% trans "Components" %}</span> </a> @@ -281,7 +282,7 @@ <!-- Users --> {% if "auth.view_user"|has_configuration_permission:request %} <div x-data="{ open: false }"> - <a href="{% url 'users' %}" @click.prevent="open = !open" + <a href="{% url 'users' %}" @click.prevent="open = !open" id="id_user_menu" class="flex items-center gap-3 px-4 py-2 text-gray-300 hover:text-white hover:bg-white/10 transition-colors cursor-pointer"> <i class="fa-solid fa-user w-5 text-center flex-shrink-0"></i> <span class="flex-1 truncate">{% trans "Users" %}</span> @@ -329,7 +330,7 @@ <!-- Configuration --> <div x-data="{ open: false }"> - <a href="#" @click.prevent="open = !open" + <a href="#" @click.prevent="open = !open" id="menu_configuration" class="flex items-center gap-3 px-4 py-2 text-gray-300 hover:text-white hover:bg-white/10 transition-colors cursor-pointer"> <i class="fa-solid fa-gear w-5 text-center flex-shrink-0"></i> <span class="flex-1 truncate">{% trans "Configuration" %}</span> @@ -405,7 +406,7 @@ <input id="simple_search" type="text" name="query" value="{{ clean_query }}" class="flex-1 rounded-l-md border border-r-0 border-gray-300 px-3 py-1.5 text-sm focus:border-dd-primary-400 focus:ring-dd-primary-400" placeholder="{% trans "Search" %}..." aria-label="Search"> - <button type="submit" class="btn btn-primary rounded-l-none" aria-label="Search"> + <button id="simple_search_submit" type="submit" class="btn btn-primary rounded-l-none" aria-label="Search"> <i class="fa-solid fa-magnifying-glass"></i> </button> </div> @@ -551,26 +552,26 @@ <h3 class="no-margin-top" style="padding-bottom: 5px;"> </h3> <ul class="nav nav-tabs horizontal-bar in"> <li role="presentation"{% if product_tab.tab == 'overview' %} class="active"{% endif %}> - <a href="{% url 'view_product' product_tab.product.id %}"> + <a href="{% url 'view_product' product_tab.product.id %}" data-testid="product-tab-overview"> <span class="fa-solid fa-earth-americas" aria-hidden="true"></span> <span class="hidden-xs">{% trans "Overview" %}</span> </a> </li> <li role="presentation"{% if product_tab.tab == 'components' %} class="active"{% endif %}> - <a href="{% url 'view_product_components' product_tab.product.id %}"> + <a href="{% url 'view_product_components' product_tab.product.id %}" data-testid="product-tab-components"> <span class="fa-solid fa-table-cells-large" aria-hidden="true"></span> <span class="hidden-xs">{% trans "Components" %}</span> </a> </li> <li role="presentation"{% if product_tab.tab == 'metrics' %} class="active"{% endif %}> - <a href="{% url 'view_product_metrics' product_tab.product.id %}"> + <a href="{% url 'view_product_metrics' product_tab.product.id %}" data-testid="product-tab-metrics"> <span class="fa-solid fa-chart-column" aria-hidden="true"></span> <span class="hidden-xs">{% trans "Metrics" %}</span> </a> </li> <!-- Engagements Tab --> <li role="presentation" class="dropdown{% if product_tab.tab == 'engagements' %} active active-color{% endif %}"> - <a class="dropdown-toggle" data-toggle="dropdown" href=""> + <a class="dropdown-toggle" data-toggle="dropdown" href="" data-testid="product-tab-engagements"> <span class="fa-solid fa-calendar-days" aria-hidden="true"></span> <span class="hidden-xs">{% trans "Engagements" %} {% if product_tab.engagements > 0 %} @@ -605,7 +606,7 @@ <h3 class="no-margin-top" style="padding-bottom: 5px;"> </li> <!-- Findings Tab --> <li role="presentation" class="dropdown{% if product_tab.tab == 'findings' %} active active-color{% endif %}"> - <a class="dropdown-toggle" data-toggle="dropdown" href=""> + <a class="dropdown-toggle" data-toggle="dropdown" href="" data-testid="product-tab-findings"> <span class="fa-solid fa-bug" aria-hidden="true"></span> <span class="hidden-xs"> {% trans "Findings" %} @@ -680,7 +681,7 @@ <h3 class="no-margin-top" style="padding-bottom: 5px;"> </li> <!-- Endpoints Tab --> <li role="presentation" class="dropdown{% if product_tab.tab == 'endpoints' %} active active-color{% endif %}"> - <a class="dropdown-toggle" data-toggle="dropdown" href=""> + <a class="dropdown-toggle" data-toggle="dropdown" href="" data-testid="product-tab-endpoints"> <span class="fa-solid fa-sitemap" aria-hidden="true"></span> <span class="hidden-xs"> {% trans "Hosts / Endpoints" %} @@ -743,7 +744,7 @@ <h3 class="no-margin-top" style="padding-bottom: 5px;"> {% if system_settings.enable_benchmark %} {% if product_tab.product|has_object_permission:"edit" %} <li role="presentation" class="dropdown{% if product_tab.tab == 'benchmarks' %} active{% endif %}"> - <a class="dropdown-toggle" data-toggle="dropdown" href=""> + <a class="dropdown-toggle" data-toggle="dropdown" href="" data-testid="product-tab-benchmarks"> <span class="fa-solid fa-scale-balanced" aria-hidden="true"></span> <span class="hidden-xs">{% trans "Benchmarks" %}</span> <span class="caret"></span> @@ -764,7 +765,7 @@ <h3 class="no-margin-top" style="padding-bottom: 5px;"> <!-- Product Settings Tab --> {% if product_tab.product|has_object_permission:"edit" or product_tab.product|has_object_permission:"delete" %} <li role="presentation" class="dropdown{% if product_tab.tab == 'settings' %} active{% endif %}"> - <a class="dropdown-toggle" data-toggle="dropdown" href=""> + <a class="dropdown-toggle" data-toggle="dropdown" href="" data-testid="product-tab-settings"> <span class="fa-solid fa-gears" aria-hidden="true"></span> <span class="hidden-xs">{% trans "Settings" %}</span> <span class="caret"></span></a> diff --git a/dojo/templates/dojo/product_type.html b/dojo/templates/dojo/product_type.html index be8e05ef0a1..8e09d14f54a 100644 --- a/dojo/templates/dojo/product_type.html +++ b/dojo/templates/dojo/product_type.html @@ -90,7 +90,7 @@ <h3 class="has-filters"> <i class="fa-solid fa-chart-line"></i> {% trans "Metrics" %}</a> </li> <li role="presentation"> - <a class="" href="{% url 'product_type_report' pt.id %}" role="menuitem"> + <a class="" href="{% url 'product_type_report' pt.id %}" role="menuitem" data-testid="report-link"> <i class="fa-solid fa-file-lines"></i> {{ labels.ORG_REPORT_LABEL }} </a> diff --git a/dojo/templates/dojo/view_eng.html b/dojo/templates/dojo/view_eng.html index c8fdb0f80c8..63ad0826ea6 100644 --- a/dojo/templates/dojo/view_eng.html +++ b/dojo/templates/dojo/view_eng.html @@ -52,7 +52,7 @@ <h4 class="pull-left"> </li> {% endif %} <li role="presentation"> - <a href="{% url 'engagement_report' eng.id %}?title=&active=1&verified=1&false_p=2&duplicate=2"> + <a href="{% url 'engagement_report' eng.id %}?title=&active=1&verified=1&false_p=2&duplicate=2" data-testid="report-link"> <i class="fa-solid fa-file-lines"></i> Report </a> </li> diff --git a/dojo/templates/dojo/view_test.html b/dojo/templates/dojo/view_test.html index 78244558f16..5618aafc0c8 100644 --- a/dojo/templates/dojo/view_test.html +++ b/dojo/templates/dojo/view_test.html @@ -73,7 +73,7 @@ <h3 class="pull-left"> </li> {% endif %} <li role="presentation"> - <a href="{% url 'test_report' test.id %}?title=&active=1&verified=1&false_p=2&duplicate=2"> + <a href="{% url 'test_report' test.id %}?title=&active=1&verified=1&false_p=2&duplicate=2" data-testid="report-link"> <i class="fa-solid fa-file-lines"></i> {% trans "Report" %} </a> </li> diff --git a/dojo/templates/report_base.html b/dojo/templates/report_base.html index a3328eaadba..ff4940ea0cb 100644 --- a/dojo/templates/report_base.html +++ b/dojo/templates/report_base.html @@ -4,9 +4,18 @@ <head> <meta charset="UTF-8"> <title>{{ report_name }} - + + - + + + + - - - {% block pre_wrapper %} - {% endblock pre_wrapper %} -
- {% block navigation %} - - - {% endblock %} - -
- {% if request.user.is_authenticated and announcement %} - - {% endif %} - {% for banner in additional_banners %} - - {% endfor %} -
- - {% block tab_bar %} - {% if product_tab.tab %} -
-
-

- {{ product_tab.product }} - {% if system_settings.enable_benchmark == 9 %} - {% for benchmark in tab_benchmarks%} - - {{ benchmark.desired_level }} - {% endfor %} - {% endif %} - {% with grade=product_tab.product|product_grade %} - {% if grade %} -
{{ grade }}
- {% endif %} - {% endwith %} - {% include "dojo/snippets/tags.html" with tags=product_tab.product.tags.all.all %} -

- -
-
- {% else %} - - {% endif %} - {% endblock %} - - {% if messages %} -
-
- {% for message in messages %} - - {% endfor %} -
- -
- {% endif %} - -
-
- {% include "dojo/breadcrumbs/settings_breadcrumb.html" %} - {% include "dojo/breadcrumbs/engagement_breadcrumb.html" %} - {% include "dojo/breadcrumbs/finding_breadcrumb.html" %} - {% include "dojo/breadcrumbs/endpoint_breadcrumb.html" %} - {% include "dojo/breadcrumbs/custom_breadcrumb.html" %} - {% block content %} - {% endblock %} -
- -
-
- -
- - - -
- {% block modals %} - {% endblock %} -
- - - - - - - - -
- - - - - - - - - - - - {% block extra_javascript %} - {% endblock %} - {% block postscript %} - {% endblock %} - - diff --git a/dojo/templates_classic/defectDojo-engagement-survey/add_choices.html b/dojo/templates_classic/defectDojo-engagement-survey/add_choices.html deleted file mode 100644 index aa6fc1b2062..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/add_choices.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Add Choice

- -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/add_engagement.html b/dojo/templates_classic/defectDojo-engagement-survey/add_engagement.html deleted file mode 100644 index c78ed59c066..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/add_engagement.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Link Questionnaire to New Engagement

-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/add_survey.html b/dojo/templates_classic/defectDojo-engagement-survey/add_survey.html deleted file mode 100644 index eb21802cf84..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/add_survey.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -{% if engagement %} -

Add Questionnaire to {{engagement}}

-{% else %} -

Add Unlinked Questionnaire

-{% endif %} -{% if surveys %} -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- - {% if engagement %} - - {% endif %} -
-
-
-{% else %} -

- All available questionnaires have been added to this engagement. -

-{% endif %} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/add_surveys.html b/dojo/templates_classic/defectDojo-engagement-survey/add_surveys.html deleted file mode 100644 index 1ecc4345fdf..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/add_surveys.html +++ /dev/null @@ -1,3 +0,0 @@ -{% if surveys %} - -{% endif %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/answer_survey.html b/dojo/templates_classic/defectDojo-engagement-survey/answer_survey.html deleted file mode 100644 index 3205240a08a..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/answer_survey.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -
-{% if engagement %} -

Answer {{survey}} Questionnaire for {{engagement}}

-{% else %} -

Answer {{survey}} Questionnaire

-{% endif %} -

{{ survey.survey.description }}

-
- -
{% csrf_token %} - {% for question in questions %} - {% include "defectDojo-engagement-survey/survey_fields.html" with form=question %} - {% endfor %} - -
-
- -
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/defectDojo-engagement-survey/assign_survey.html b/dojo/templates_classic/defectDojo-engagement-survey/assign_survey.html deleted file mode 100644 index ef43e25bdd1..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/assign_survey.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Assign User to Questionnaire {{ survey }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- Return - -
-
-
-{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/create_questionnaire.html b/dojo/templates_classic/defectDojo-engagement-survey/create_questionnaire.html deleted file mode 100644 index 864f7fa255d..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/create_questionnaire.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -

Create New Questionnaire

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- - {% if survey %} - - - {% if "dojo.delete_engagement_survey"|has_configuration_permission:request %} - Delete Questionnaire - {% endif %} - {% else %} - - - {% endif %} -
-
-
- -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/create_related_question.html b/dojo/templates_classic/defectDojo-engagement-survey/create_related_question.html deleted file mode 100644 index fd4d64e3a62..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/create_related_question.html +++ /dev/null @@ -1,88 +0,0 @@ -{% extends "base.html" %} - - - - - - - - - - - DefectDojo - {{ name }} - - - - - - - - - - - - - - - - - - - - - - - -{% block content %} - {{ block.super }} -
-
-
-
- {{ name }} - {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% include "dojo/form_fields.html" with form=textForm %} - {% include "dojo/form_fields.html" with form=choiceForm %} -
-
- return -
-
- -
-
-
-
-
-
-{% endblock content %} - -{% block postscript %} - {{ block.super }} - -{% endblock postscript %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/delete_questionnaire.html b/dojo/templates_classic/defectDojo-engagement-survey/delete_questionnaire.html deleted file mode 100644 index 2722990b468..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/delete_questionnaire.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete {{ survey }} Questionnaire

-

- Deleting this Questionnaire will remove any answers associated with it. These relationships are listed below: -

-
-
-

Danger Zone

-
- {% if questions and survey.completed %} -
{% csrf_token %} - {% for question in questions %} - {% include "defectDojo-engagement-survey/survey_fields.html" with form=question %} - {% endfor %} -
- {% else %} -

No answers found.

- {% endif %} - {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} -{% block add_styles %} - {{ block.super }} - .form-read-only {text-align: left !important } - .form-read-only .control-label{width: 100%; float: none; text-align: left;} - .form-read-only textarea {width: 90% !important;} - .form-read-only .controls {margin-left: 10px;} - .form-read-only .control-group {margin-bottom: 10.5px; margin-left: 10px; text-align: left;} -{% endblock %} diff --git a/dojo/templates_classic/defectDojo-engagement-survey/edit_question.html b/dojo/templates_classic/defectDojo-engagement-survey/edit_question.html deleted file mode 100644 index 798a1e208a1..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/edit_question.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "base.html" %} -{% block add_css %} - {{ block.super }} - -{% endblock %} -{% block content %} - {{ block.super }} -

Edit Question: {{ question.name }}

- -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/edit_survey_questions.html b/dojo/templates_classic/defectDojo-engagement-survey/edit_survey_questions.html deleted file mode 100644 index 9bf921b56ec..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/edit_survey_questions.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Edit Questionnaire Questions ({{ survey.name }})

- -
{% csrf_token %} -
- {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock content %} -{% block postscript %} - {{ block.super }} - -{% endblock postscript %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/existing_engagement.html b/dojo/templates_classic/defectDojo-engagement-survey/existing_engagement.html deleted file mode 100644 index 7226397d6f3..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/existing_engagement.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Link Questionnaire to Existing Engagement

-
-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/list_questions.html b/dojo/templates_classic/defectDojo-engagement-survey/list_questions.html deleted file mode 100644 index 1ca8a749e8e..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/list_questions.html +++ /dev/null @@ -1,85 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load authorization_tags %} -{% load event_tags %} -{% block content %} - {{ block.super }} -
-
-
-
- {{ name }} -
-
- {% if questions %} -
- {% include "dojo/paging_snippet.html" with page=questions page_size=True %} -
-
- - - - - - - - - - {% for question in questions %} - - - - - - {% endfor %} - -
TextOptionalType
- {% if "dojo.change_question"|has_configuration_permission:request %} - {{ question.text }} - {% else %} - {{ question.text }} - {% endif %} - {{ question.optional|yesno|capfirst }}{{ question|content_type_str|capfirst }}
-
-
- {% include "dojo/paging_snippet.html" with page=questions page_size=True %} -
- {% else %} - No Questionnaires found. - {% endif %} -
-
-
- {% if filtered.form %} - {% if "dojo.add_question"|has_configuration_permission:request %} -
Actions
- - {% endif %} -
Filters
-
- {% for field in filtered.form.visible_fields %} -
- {{ field.errors }} - - {% with placeholder="placeholder:"|add:field.label %} - {{ field|addcss:"class:form-control input-sm"|addcss:placeholder }} - {% endwith %} -
- {% endfor %} - -
- {% endif %} -
-
-
- -
-
-{% endblock %} diff --git a/dojo/templates_classic/defectDojo-engagement-survey/list_surveys.html b/dojo/templates_classic/defectDojo-engagement-survey/list_surveys.html deleted file mode 100644 index 7dd2ea10068..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/list_surveys.html +++ /dev/null @@ -1,170 +0,0 @@ -{% extends "base.html" %} -{% load authorization_tags %} -{% load event_tags %} -{% block content %} - {{ block.super }} -
-
-
-
- {{ name }} -
-
- {% if surveys %} -
- - - - - - - - - - {% for survey in surveys %} - - - - - - {% endfor %} - -
NameQuestion CountActive
- {% if "dojo.change_engagement_survey"|has_configuration_permission:request %} - {{ survey.name }} - {% else %} - {{ survey.name }} - {% endif %} - {{ survey.questions.all|length }}{{ survey.active }}
-
-
- {% include "dojo/paging_snippet.html" with page=surveys page_size=True %} -
- {% else %} - No questionnaires found. - {% endif %} - -
-
- General Questionnaires - {% if "dojo.add_engagement_survey"|has_configuration_permission:request %} - - {% endif %} -
-
- {% if general %} - - - - - - - - - - - {% for survey in general %} - - - - - - - - {% endfor %} -
NameNumber ResponsesGeneratedExpirationActions
{{ survey.survey.name }}{{ survey.num_responses }}{{ survey.generated }}{{ survey.expiration }} -
- - {% if "dojo.delete_engagement_survey"|has_configuration_permission:request %} - Delete - Questionnaire - {% endif %} -
-
- {% else %} -
-

No questionnaires found.

-
- {% endif %} -
-
-
- -
-
- {% if filtered.form %} - {% if "dojo.add_engagement_survey"|has_configuration_permission:request %} -
Actions
- - {% endif %} -
Filters
-
- {% for field in filtered.form.visible_fields %} -
- {{ field.errors }} - - {% with placeholder="placeholder:"|add:field.label %} - {{ field|addcss:"class:form-control input-sm"|addcss:placeholder }} - {% endwith %} -
- {% endfor %} - -
- {% endif %} -
-
-
- - -{% endblock %} - -{% block postscript %} - {{ block.super }} - -{% endblock postscript %} - diff --git a/dojo/templates_classic/defectDojo-engagement-survey/survey_fields.html b/dojo/templates_classic/defectDojo-engagement-survey/survey_fields.html deleted file mode 100644 index bb5bd473769..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/survey_fields.html +++ /dev/null @@ -1,95 +0,0 @@ -{% load event_tags %} -{{form.non_field_errors}} -{% for field in form.hidden_fields %} - {{ field }} -{% endfor %} - -{% for field in form.visible_fields %} -
- {% if field|is_checkbox %} - -
- {% if field.auto_id %} - - {% endif %} - {% for error in field.errors %} - {{ error }} - {% endfor %} - - {% if field.help_text %} -

- {{ field.help_text|safe }} -

- {% endif %} -
- {% elif field|is_radio %} - - {% if field.auto_id %} - - {% endif %} - - {% for choice in field %} -
- -
- {% endfor %} - - {% for error in field.errors %} - {{ error }} - {% endfor %} - - {% if field.help_text %} -

- {{ field.help_text|safe }} -

- {% endif %} - {% elif field|is_multiple_checkbox %} - - {% if field.auto_id %} - - {% endif %} - - {% for choice in field %} -
- -
- {% endfor %} - - {% for error in field.errors %} - {{ error }} - {% endfor %} - - {% if field.help_text %} -

- {{ field.help_text|safe }} -

- {% endif %} - {% else %} - {% if field.auto_id %} - - {% endif %} - -
- {{ field|addcss:"class:form-control" }} - - {% for error in field.errors %} - {{ error }} - {% endfor %} - - {% if field.help_text %} -

- {{ field.help_text|safe }} -

- {% endif %} -
- {% endif %} -
-{% endfor %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/surveys.html b/dojo/templates_classic/defectDojo-engagement-survey/surveys.html deleted file mode 100644 index ae0487c9752..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/surveys.html +++ /dev/null @@ -1,97 +0,0 @@ -{% load display_tags %} -{% load authorization_tags %} -{% if surveys %} - - - - - - - - - - - - {% for survey in surveys %} - - - - - - - - - {% endfor %} -
NameAssigneeResponderStatusCompletion Date
- - {{ survey.survey.name }} - {% if survey.assignee == None %} - Not Assigned - {% else %} - {{ survey.assignee.username }} - {% endif %} - - {% if survey.responder %} - {{ survey.responder }} - {% else %} - Anonymous - {% endif %} - - {% if survey.completed %} - Completed - {% else %} - Unanswered - {% endif %} - {{ survey.answered_on }}
-{% else %} -
-

No Questionnaires found.

-
-{% endif %} \ No newline at end of file diff --git a/dojo/templates_classic/defectDojo-engagement-survey/view_survey.html b/dojo/templates_classic/defectDojo-engagement-survey/view_survey.html deleted file mode 100644 index cbe73c9f59e..00000000000 --- a/dojo/templates_classic/defectDojo-engagement-survey/view_survey.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -
-

{{name}}

-

{{ survey.survey.description }}

-
-{% if survey.completed %} -
{% csrf_token %} - {% for question in questions %} - {% include "defectDojo-engagement-survey/survey_fields.html" with form=question %} - {% endfor %} -
-{% else %} -

Questionnaire has not been answered.

-{% endif %} -{% endblock %} -{% block add_styles %} - {{ block.super }} - .form-horizontal .control-label{width: 100%; float: none; text-align: left;} - .form-horizontal textarea {width: 90% !important;} - .form-horizontal .controls {margin-left: 10px;} -{% endblock %} diff --git a/dojo/templates_classic/disabled.html b/dojo/templates_classic/disabled.html deleted file mode 100644 index 05322d45e75..00000000000 --- a/dojo/templates_classic/disabled.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Feature Disabled

-
-

- This feature is currently down for maintenance and evaluation. We apologize for the inconvenience. -

- {% endblock %} diff --git a/dojo/templates_classic/dojo/action_history.html b/dojo/templates_classic/dojo/action_history.html deleted file mode 100644 index 0f16cebb181..00000000000 --- a/dojo/templates_classic/dojo/action_history.html +++ /dev/null @@ -1,194 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load display_tags %} -{% block content %} - {{ block.super }} -
-
- {% if pghistory_history %} -
-
-

- PostgreSQL History (pghistory) - -

-
-
- {% include "dojo/filter_snippet.html" with form=pghistory_filter.form %} -
-
- {% include "dojo/paging_snippet.html" with page=pghistory_history %} -
-
- - - - - - - - - - - - - - {% for h in pghistory_history %} - - - - - - - - - - - - - {% endfor %} -
TimestampLabelObjectUserURLIP AddressDataContextObject IDChanges
{{ h.pgh_created_at }}{{ h.pgh_label }} - {% if h.object_url %} - {{ h.object_str|default:"N/A" }} - {% else %} - {{ h.object_str|default:"N/A" }} - {% endif %} - {{ h.user|default:"N/A" }} - {% if h.url and h.url != "N/A" %} - {{ h.url|truncatechars:50 }} - {% else %} - N/A - {% endif %} - {{ h.remote_addr|default:"N/A" }} -
- - View - -
{{ h.pgh_data|pprint|default:"N/A" }}
-
-
- {% if h.pgh_context %} -
- - View - -
{{ h.pgh_context|pprint|default:"N/A" }}
-
- {% else %} - None - {% endif %} -
- {% if h.object_url and h.pgh_obj_id %} - {{ h.pgh_obj_id }} - {% else %} - {{ h.pgh_obj_id|default:"N/A" }} - {% endif %} - - {% if h.pgh_label == "initial_backfill" %} - Initial Backfill - {% elif h.pgh_diff %} -
- {% for field, values in h.pgh_diff.items %} -
- {{ field }}: - - {% if values.0 is None %} - None - {% elif values.0 == "" %} - '' - {% else %} - {{ values.0|truncatechars:50 }} - {% endif %} - - to - - {% if values.1 is None %} - None - {% elif values.1 == "" %} - '' - {% else %} - {{ values.1|truncatechars:50 }} - {% endif %} - -
- {% endfor %} -
- {% else %} - No Changes - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=pghistory_history %} -
-
- {% endif %} - - {% if auditlog_history %} -
-
-

- Audit Log History (django-auditlog) - -

-
-
- {% include "dojo/filter_snippet.html" with form=log_entry_filter.form %} -
-
- {% include "dojo/paging_snippet.html" with page=auditlog_history %} -
-
- - - - - - - - {% for h in auditlog_history %} - - - - - - - {% endfor %} -
ActionActorDate/TimeChanges
{{ h }}{{ h.actor }}{{ h.timestamp }} - {{ h.changes|action_log_entry|linebreaks}} -
-
-
- {% include "dojo/paging_snippet.html" with page=auditlog_history %} -
-
- {% endif %} - - {% if not pghistory_history and not auditlog_history %} -

No update history found for this object

- {% endif %} -
-
- -{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/ad_hoc_findings.html b/dojo/templates_classic/dojo/ad_hoc_findings.html deleted file mode 100644 index 891c556b432..00000000000 --- a/dojo/templates_classic/dojo/ad_hoc_findings.html +++ /dev/null @@ -1,92 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load display_tags %} -{% load static %} -{% block add_css %} - {{ block.super }} - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -
-

Add Findings to a Test

-
-
-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% block additional_forms %} - {% endblock additional_forms %} - {% if jform %} -

JIRA

-
-
- -
- {% if product_tab.product.has_jira_configured %} - {{ product_tab.product|jira_project_url }} - {% else %} - None - {% endif %} -
-
- {% include "dojo/form_fields.html" with form=jform %} - {% endif %} - {% if gform %} -

Github

-
- {% include "dojo/form_fields.html" with form=gform %} - {% endif %} -
-
- - -
-
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/add_endpoint.html b/dojo/templates_classic/dojo/add_endpoint.html deleted file mode 100644 index d23124186be..00000000000 --- a/dojo/templates_classic/dojo/add_endpoint.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } -{% endblock %} - -{% block content %} - {{ block.super }} -

Add Endpoint

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/add_findings.html b/dojo/templates_classic/dojo/add_findings.html deleted file mode 100644 index 2656b12070f..00000000000 --- a/dojo/templates_classic/dojo/add_findings.html +++ /dev/null @@ -1,159 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load display_tags %} -{% load static %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -
-

Add Findings to a Test

-
-
- {% if temp %} -
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% if jform %} -

JIRA

-

-
- -
- {% if product_tab.product.has_jira_configured %} - {{ product_tab.product|jira_project_url }} - {% else %} - None - {% endif %} -
-
- - {% include "dojo/form_fields.html" with form=jform %} - {% endif %} -
-
- - -
-
- - {% else %} -
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% block additional_forms %} - {% endblock additional_forms %} - {% if jform %} -

JIRA

-
-
- -
- {% if product_tab.product.has_jira_configured %} - {{ product_tab.product|jira_project_url }} - {% else %} - None - {% endif %} -
-
- {% include "dojo/form_fields.html" with form=jform %} - {% endif %} -
-
- - -
-
-
- {% endif %} -

-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/add_findings_as_accepted.html b/dojo/templates_classic/dojo/add_findings_as_accepted.html deleted file mode 100644 index c77b1613037..00000000000 --- a/dojo/templates_classic/dojo/add_findings_as_accepted.html +++ /dev/null @@ -1,56 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% load static %} - - {% for finding in findings %} - - - - - - - - - - - - - - {% endfor %} -
- {{finding.id}} - - {{ finding.severity_display }} - - - {% if finding.title %} - {{ finding.title|truncatechars:60 }} - {% else %} - {{ finding.id }} - {% endif %} - - {% if finding.cwe > 0 %} - - {{ finding.cwe|default:"" }} - - {% endif %} - - {% with finding|first_vulnerability_id as first_vulnerability_id %} - {% if first_vulnerability_id %} - {% if first_vulnerability_id|has_vulnerability_url %} - - {{ first_vulnerability_id }} - - {% else %} - {{ first_vulnerability_id }} - {% endif %} - {% endif %} - {% endwith %} - - {{ finding.epss_score|format_epss }} - - {{ finding.epss_percentile|format_epss }} - {{ finding.date }}{{ finding.active }}
- diff --git a/dojo/templates_classic/dojo/add_note_type.html b/dojo/templates_classic/dojo/add_note_type.html deleted file mode 100644 index 753da915894..00000000000 --- a/dojo/templates_classic/dojo/add_note_type.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Add a new Note Type

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/add_notification_webhook.html b/dojo/templates_classic/dojo/add_notification_webhook.html deleted file mode 100644 index 12056373af4..00000000000 --- a/dojo/templates_classic/dojo/add_notification_webhook.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Add a new Notification Webhook

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/add_product_api_scan_configuration.html b/dojo/templates_classic/dojo/add_product_api_scan_configuration.html deleted file mode 100644 index 525f4da6178..00000000000 --- a/dojo/templates_classic/dojo/add_product_api_scan_configuration.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} -

Add {{ product.name }} API Scan Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
- -
-
-
-
- API Scan Configurations are supported for these test types. - -
-{% endblock %} -{% block postscript %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/add_related.html b/dojo/templates_classic/dojo/add_related.html deleted file mode 100644 index b6007bc5525..00000000000 --- a/dojo/templates_classic/dojo/add_related.html +++ /dev/null @@ -1,100 +0,0 @@ -{% load static %} - - - - - - - - - - - - DefectDojo - {{ name }} - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- {{ name }} - {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dojo/templates_classic/dojo/add_risk_acceptance.html b/dojo/templates_classic/dojo/add_risk_acceptance.html deleted file mode 100644 index 6366868a53b..00000000000 --- a/dojo/templates_classic/dojo/add_risk_acceptance.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } -{% endblock %} -{% block add_css %} - {{ block.super }} - -{% endblock %} -{% block content %} - {{ block.super }} -

Risk acceptance

-
-

-

-

- A risk acceptance can consist of compensating control(s) and documentation. - Documentation can be in the form of an image or PDF file.
- Select the findings that apply to this acceptance. These findings will be automatically deactivated. -

-
-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-

-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/add_template.html b/dojo/templates_classic/dojo/add_template.html deleted file mode 100644 index 3953559891b..00000000000 --- a/dojo/templates_classic/dojo/add_template.html +++ /dev/null @@ -1,132 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% load authorization_tags %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

{{ name }} {{ template }}

- -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
- {% if template %} - {% if "Finding_Delete"|has_global_permission %} -
- {% csrf_token %} -
-
- - -
-
-
- {% endif %} - {% endif %} -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/add_tests.html b/dojo/templates_classic/dojo/add_tests.html deleted file mode 100644 index 158f937ad82..00000000000 --- a/dojo/templates_classic/dojo/add_tests.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block add_styles %} - {{ block.super }} -.chosen-container { - width: 70% !important; -} -{% endblock %} - -{% block content %} - {{ block.super }} - -

Add Tests

-

- Documentation -

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% include "dojo/form_fields.html" with form=cred_form %} -
-
- - - -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/add_user.html b/dojo/templates_classic/dojo/add_user.html deleted file mode 100644 index fb7c4e6efe1..00000000000 --- a/dojo/templates_classic/dojo/add_user.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load display_tags %} -{% load static %} - -{% block content %} - {{ block.super }} -

{{ name }} {% if to_edit %}- {{ to_edit.username }}{% endif %}

-
{% csrf_token %} -
- {% trans "Default Information" %} - {% include "dojo/form_fields.html" with form=form %} -
-
- {% trans "Additional Contact Information" %} - {% include "dojo/form_fields.html" with form=contact_form %} -
-
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - {% if not user.is_superuser %} - - {% endif %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/alerts.html b/dojo/templates_classic/dojo/alerts.html deleted file mode 100644 index 086bec4efbe..00000000000 --- a/dojo/templates_classic/dojo/alerts.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load static %} -{% load display_tags %} -{% block content %} - {{ block.super }} -
-
- {% if alerts %} -
{% csrf_token %} -
- - - - - - - - - - - - - {% for alert in alerts %} - - - - - - - - - {% endfor %} - -
{% trans "Type" %}{% trans "Source" %}{% trans "Title" %}{% trans "Description" %}{% trans "Timeframe" %} - -
{{ alert.source }}{%if alert.url %}{% endif %}{{ alert.title }}{% if alert.url %}{% endif %}{{ alert.description|markdown_render|linebreaks }}{{ alert.created }} - -
-
- -
-
-
-
- {% include "dojo/paging_snippet.html" with page=alerts page_size=False %} -
- {% else %} -

{% trans "No alerts found." %}

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/announcement.html b/dojo/templates_classic/dojo/announcement.html deleted file mode 100644 index 0c5572082fa..00000000000 --- a/dojo/templates_classic/dojo/announcement.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- {% if remove %} - - {% else %} - - {% endif %} -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/api_v2_key.html b/dojo/templates_classic/dojo/api_v2_key.html deleted file mode 100644 index 6b4d56e9338..00000000000 --- a/dojo/templates_classic/dojo/api_v2_key.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load event_tags %} - -{% block content %} - {{ block.super }} -

{{ name }}

-
-

{% trans "Your current API key is" %} {{ key.key }}

-

{% trans "Your current API Authorization Header value is" %} Token {{ key.key }}

-

{% trans "Has your key been exposed? Are you ready for a new one?" %}

-
- {% csrf_token %} - {{ form.as_p }} - -
-
- {% if API_TOKEN_AUTH_ENDPOINT_ENABLED %} -

{% trans "Alternatively, you can use /api/v2/api-token-auth/ to get your token. Example:" %}

-
-curl -X POST -H 'content-type: application/json' {% if request.is_secure %}https{% else %}http{% endif %}://{{ request.META.HTTP_HOST }}/api/v2/api-token-auth/ -d '{"username": "<YOURUSERNAME>", "password": "<YOURPASSWORD>"}'
- {% endif %} -

{% trans "To use your API Key you need to specify an Authorization header. Example:" %}

-
-# As a header
-# Format is ``Authorization: Token <api_key>
-Authorization: Token {{ key.key }}
-	
-

{% trans "Here is a simple python example against the /users endpoint" %}

-
-import requests
-
-url = '{% if request.is_secure %}https{% else %}http{% endif %}://{{ request.META.HTTP_HOST }}/api/v2/users/'
-headers = {'content-type': 'application/json',
-           'Authorization': 'Token {{ key.key }}'}
-r = requests.get(url, headers=headers, verify=True) # set verify to False if ssl cert is self-signed
-
-for key, value in r.__dict__.items():
-  print(f"'{key}': '{value}'")
-  print('------------------')
-	
-{% endblock %} diff --git a/dojo/templates_classic/dojo/apply_finding_template.html b/dojo/templates_classic/dojo/apply_finding_template.html deleted file mode 100755 index ac1893cd770..00000000000 --- a/dojo/templates_classic/dojo/apply_finding_template.html +++ /dev/null @@ -1,110 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load static %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

Apply template to a Finding

-
- {% csrf_token %} - {% include "dojo/apply_finding_template_form_fields.html" with form=form template=template %} - {% if jform %} - {% include "dojo/apply_finding_template_form_fields.html" with form=jform template=template %} - {% endif %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/apply_finding_template_form_fields.html b/dojo/templates_classic/dojo/apply_finding_template_form_fields.html deleted file mode 100755 index c510f8a013a..00000000000 --- a/dojo/templates_classic/dojo/apply_finding_template_form_fields.html +++ /dev/null @@ -1,111 +0,0 @@ -{% load as_json %} -{% load event_tags %} -{% load get_attribute %} -{% block css %} - {{ form.media.css }} -{% endblock %} -{% block js %} - {{ form.media.js }} -{% endblock %} -{% if form.non_field_errors %} - -{% endif %} -
- - - -
-{% for field in form.hidden_fields %} - {{ field }} -{% endfor %} - -{% for field in form.visible_fields %} - {% with template_value=template|get_attribute:field.name|default_if_none:'' %} -
- -
- {% if field.name == 'tags' %} - - {% endif %} - - - {% if field|is_text or field.name == 'tags' %} - - {% endif %} - -
-
- {% endwith %} - {% if field|is_checkbox %} -
-
- {% if field.auto_id %} - - {% endif %} - {% for error in field.errors %} - {{ error }} - {% endfor %} - - {% if field.help_text %} -

- {{ field.help_text|safe }} -

- {% endif %} -
-
- {% elif field|is_radio %} - {% if field.auto_id %} - - {% endif %} -
- {% for choice in field %} -
- -
- {% endfor %} - - {% for error in field.errors %} - {{ error }} - {% endfor %} - - {% if field.help_text %} -

- {{ field.help_text|safe }} -

- {% endif %} -
- {% else %} - {% if field.auto_id %} - - {% endif %} - -
- {{ field|addcss:"class:form-control" }} - - {% for error in field.errors %} - {{ error }} - {% endfor %} - - {% if field.help_text %} -

- {{ field.help_text|safe }} -

- {% endif %} -
- {% endif %} -
-{% endfor %} diff --git a/dojo/templates_classic/dojo/authorize_user_for_product_types.html b/dojo/templates_classic/dojo/authorize_user_for_product_types.html deleted file mode 100644 index 28d1943c2d0..00000000000 --- a/dojo/templates_classic/dojo/authorize_user_for_product_types.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block add_styles %} - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

{{ name }} - {{ user.get_full_name }} ({{ user.username }})

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/authorize_user_for_products.html b/dojo/templates_classic/dojo/authorize_user_for_products.html deleted file mode 100644 index 6715e2ebb07..00000000000 --- a/dojo/templates_classic/dojo/authorize_user_for_products.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block add_styles %} - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

{{ name }} - {{ user.get_full_name }} ({{ user.username }})

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/banner.html b/dojo/templates_classic/dojo/banner.html deleted file mode 100644 index 91ea2a34b1b..00000000000 --- a/dojo/templates_classic/dojo/banner.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/benchmark.html b/dojo/templates_classic/dojo/benchmark.html deleted file mode 100644 index 5fe3aa1a150..00000000000 --- a/dojo/templates_classic/dojo/benchmark.html +++ /dev/null @@ -1,414 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load static %} -{% load event_tags %} -{% load display_tags %} - -{% block content %} - {{ block.super }} -
-
-
- -

{{ benchmark_type.name }} {{ benchmark_type.version }} - {{ benchmark_product_summary.desired_level }}

- {{ benchmark_product_summary|asvs_level }} -
- -
-
- - {% if benchmarks %} - - - - - - - - - - - - - - - {% for benchmark in benchmarks %} - {% ifchanged %} - - - - {% endifchanged %} - - - - - - - - - - - {% endfor %} - -
#{% trans "Description" %}L1L2L3{% trans "Applicable" %}{% trans "Pass" %}{% trans "Notes" %}
{{ benchmark.control.category.name }}
{{ benchmark.control.objective_number }}{{ benchmark.control.objective | markdown_render }}{% if benchmark.control.level_1 %}✓{% endif %}{% if benchmark.control.level_2 %}✓{% endif %}{% if benchmark.control.level_3 %}✓{% endif %} - - - - - - -
- {% else %} -
-

{% trans "No benchmarks found." %}

-
- {% endif %} -
- - - {# Modal #} - - -{% endblock %} -{% block postscript %} - {{ block.super }} - -{% endblock %} -{% block add_styles %} - td p { - margin-bottom: 0px; - } - - td p a { - color: #337ab7; - } - - .table>tbody>tr>td, - .table>tbody>tr>th, - .table>tfoot>tr>td, - .table>tfoot>tr>th, - .table>thead>tr>td, - .table>thead>tr>th { - padding: 12px 10px - } - - table.level-1 tr.level-1 td:first-child, - table.level-2 tr.level-1 td:first-child, - table.level-3 tr.level-1 td:first-child, - table.level-2 tr.level-2 td:first-child, - table.level-3 tr.level-2 td:first-child, - table.level-3 tr.level-3 td:first-child { - background-color: #DCEDFB; - } - - table.level-1 tr.level-2, - table.level-1 tr.level-3, - table.level-2 tr.level-3 { - opacity: 0.55; - } -{% endblock %} diff --git a/dojo/templates_classic/dojo/breadcrumbs/custom_breadcrumb.html b/dojo/templates_classic/dojo/breadcrumbs/custom_breadcrumb.html deleted file mode 100644 index 75c62f4adcf..00000000000 --- a/dojo/templates_classic/dojo/breadcrumbs/custom_breadcrumb.html +++ /dev/null @@ -1,12 +0,0 @@ -{% if custom_breadcrumb %} - -{% endif %} diff --git a/dojo/templates_classic/dojo/breadcrumbs/endpoint_breadcrumb.html b/dojo/templates_classic/dojo/breadcrumbs/endpoint_breadcrumb.html deleted file mode 100644 index d5609f37d7d..00000000000 --- a/dojo/templates_classic/dojo/breadcrumbs/endpoint_breadcrumb.html +++ /dev/null @@ -1,21 +0,0 @@ -{% load display_tags %} -{% if product_tab.tab == "endpoints" and not custom_breadcrumb %} - -{% endif %} diff --git a/dojo/templates_classic/dojo/breadcrumbs/engagement_breadcrumb.html b/dojo/templates_classic/dojo/breadcrumbs/engagement_breadcrumb.html deleted file mode 100644 index 44c7bae073d..00000000000 --- a/dojo/templates_classic/dojo/breadcrumbs/engagement_breadcrumb.html +++ /dev/null @@ -1,16 +0,0 @@ -{% if product_tab.tab == "engagements" and not custom_breadcrumb %} - -{% endif %} diff --git a/dojo/templates_classic/dojo/breadcrumbs/finding_breadcrumb.html b/dojo/templates_classic/dojo/breadcrumbs/finding_breadcrumb.html deleted file mode 100644 index 95be65b8d45..00000000000 --- a/dojo/templates_classic/dojo/breadcrumbs/finding_breadcrumb.html +++ /dev/null @@ -1,12 +0,0 @@ -{% if finding and not custom_breadcrumb %} - -{% endif %} diff --git a/dojo/templates_classic/dojo/breadcrumbs/settings_breadcrumb.html b/dojo/templates_classic/dojo/breadcrumbs/settings_breadcrumb.html deleted file mode 100644 index d2db7058aa2..00000000000 --- a/dojo/templates_classic/dojo/breadcrumbs/settings_breadcrumb.html +++ /dev/null @@ -1,10 +0,0 @@ -{% if product_tab.tab == "settings" and not custom_breadcrumb %} - -{% endif %} diff --git a/dojo/templates_classic/dojo/calendar.html b/dojo/templates_classic/dojo/calendar.html deleted file mode 100644 index f7d96cd058e..00000000000 --- a/dojo/templates_classic/dojo/calendar.html +++ /dev/null @@ -1,91 +0,0 @@ -{% extends 'base.html' %} -{% load static %} -{% load i18n %} - -{% block content %} - {{ block.super }} -
-
-
-
- -
-
- -
-
- -
-
-
-
-

-
-

-{% endblock %} -{% block postscript %} - {{ block.super }} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/celery_status.html b/dojo/templates_classic/dojo/celery_status.html deleted file mode 100644 index 235e26f9800..00000000000 --- a/dojo/templates_classic/dojo/celery_status.html +++ /dev/null @@ -1,305 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} - {{ block.super }} -
-

Celery Status

-
-
-
-
-

Celery Status

-
-
- - - - - - - - - - - -
Redis Broker - - -
Celery Worker - - -
-
-
- -
- - - - - Inspects every message in the queue — may be slow for large queues. - -
-

- Note: Purging the queue removes pending tasks that have not yet been executed, - including deduplication tasks. If deduplication tasks were in the queue, you may need to - re-run deduplication manually using the dedupe management command: - python manage.py dedupe --dedupe_only -

-
-
- -
-
-
-
-

Celery Settings

-
-
- - - - - - - - -
SettingValue
- Read-only. Configured via environment variables: - DD_CELERY_TASK_TIME_LIMIT, - DD_CELERY_TASK_SOFT_TIME_LIMIT, - DD_CELERY_TASK_DEFAULT_EXPIRES -
-
-
-
-
-{% endblock content %} - -{% block postscript %} - {{ block.super }} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/change_pwd.html b/dojo/templates_classic/dojo/change_pwd.html deleted file mode 100644 index 1bbee0630c0..00000000000 --- a/dojo/templates_classic/dojo/change_pwd.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block content %} - {{ block.super }} -
- {% csrf_token %} -
- {% trans "Change Password" %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/checklist.html b/dojo/templates_classic/dojo/checklist.html deleted file mode 100644 index 97c927982e3..00000000000 --- a/dojo/templates_classic/dojo/checklist.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} - - - -{% block content %} - {{ block.super }} -

Checklist

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/cicd_infrastructure.html b/dojo/templates_classic/dojo/cicd_infrastructure.html deleted file mode 100644 index 2415e2492c4..00000000000 --- a/dojo/templates_classic/dojo/cicd_infrastructure.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- CI/CD Infrastructure - -

-
-
- {% if confs %} -
- - - - - - - - - - - - {% for conf in confs %} - - - - - - - - {% endfor %} - -
NameDescriptionURLTypeActions
- {% if "dojo.change_cicdinfrastructure"|has_configuration_permission:request %} - {{ conf.name }} - {% else %} - {{ conf.name }} - {% endif %} - - {% if conf.description %}{{ conf.description }}{% endif %} - - {% if conf.url %}{{ conf.url }}{% endif %} - - {{ conf.get_infrastructure_type_display }} - - {% if "dojo.delete_cicdinfrastructure"|has_configuration_permission:request %} - - - - {% endif %} -
-
- {% else %} -

No CI/CD infrastructure found.

- {% endif %} -
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/clear_finding_review.html b/dojo/templates_classic/dojo/clear_finding_review.html deleted file mode 100644 index 120d3bfa789..00000000000 --- a/dojo/templates_classic/dojo/clear_finding_review.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load static %} - -{% block add_styles %} - {{ block.super }} -.chosen-container { - width: 70% !important; -} -{% endblock %} -{% block content %} - {{ block.super }} -

Clear Finding Review

-

{{ finding.title }}

-

Set the appropriate status for this finding. Please add notes to help document.

-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/close_finding.html b/dojo/templates_classic/dojo/close_finding.html deleted file mode 100644 index 6c3c2f7c251..00000000000 --- a/dojo/templates_classic/dojo/close_finding.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% block content %} - {{ block.super }} -

Close a Finding

-

{{ finding.title }}

- {% if note_types|length <= 1 %} -

Please provide a reason why this finding is being closed.

- {% elif note_types|length > 1 %} -

Please add atleast one note from the following note type(s):

- {% for note_type in note_types %} - {{ note_type.name}}
- {% endfor %} - {% endif %} -
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- {% if note_types|length <= 1 %} - - {% elif note_types|length > 1 %} - - {% endif %} -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/components.html b/dojo/templates_classic/dojo/components.html deleted file mode 100644 index f75d53dbacc..00000000000 --- a/dojo/templates_classic/dojo/components.html +++ /dev/null @@ -1,181 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load humanize %} -{% load display_tags %} -{% load static %} -{% block content %} - {{ block.super }} - -
-
-
-
-

- Components - -

-
-
- {% include "dojo/filter_snippet.html" with form=filter.form %} -
-
-
- {% include "dojo/paging_snippet.html" with page=result page_size=True %} -
-
- - - - - - - - - - - - {% for result in result %} - - - - - - - - - {% endfor %} - -
NameVersionActiveDuplicateTotal
- {% if result.component_name == none %} - {{ result.component_name }} - {% else %} - {{ result.component_name }} - {% endif %} - {{result.component_version}} - {% if result.active and result.component_name == none %} - {{ result.active }} - {% elif result.active%} - {{ result.active }} - {% else %} - 0 - {% endif %} - - {% if result.duplicate and result.component_name == none %} - {{ result.duplicate }} - {% elif result.duplicate %} - {{ result.duplicate }} - {% else %} - 0 - {% endif %} - - {% if result.total and result.component_name == none %} - {{ result.total }} - {% elif result.total %} - {{ result.total }} - {% else %} - 0 - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=result page_size=True %} -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% include "dojo/filter_js_snippet.html" %} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/copy_object.html b/dojo/templates_classic/dojo/copy_object.html deleted file mode 100644 index e353f8d3775..00000000000 --- a/dojo/templates_classic/dojo/copy_object.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block add_css %} - {{ block.super }} - -{% endblock add_css %} -{% block content %} - {{ block.super }} - {% if product_tab.tab == "engagements" and destination_label == "Product" %} -

Copy {{ source }} in {{ product_tab.product }}

- {% else %} -

Copy {{ source }} to a new {{ destination_label }}

- - {% endif %} -
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock content %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/custom_html_report.html b/dojo/templates_classic/dojo/custom_html_report.html deleted file mode 100644 index aa091eb3ec6..00000000000 --- a/dojo/templates_classic/dojo/custom_html_report.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "report_base.html" %} -{% load display_tags %} - -{% block content %} - {{ block.super }} -
- {% if include_disclaimer %} -
- Disclaimer
-

{{ disclaimer | bleach_with_a_tags }}

-
- {% endif %} - {% for widget in widgets %} - {{ widget.get_html }} - {% endfor %} -
-{% endblock %} - diff --git a/dojo/templates_classic/dojo/custom_html_report_cover_page.html b/dojo/templates_classic/dojo/custom_html_report_cover_page.html deleted file mode 100644 index cbce276ed3b..00000000000 --- a/dojo/templates_classic/dojo/custom_html_report_cover_page.html +++ /dev/null @@ -1,11 +0,0 @@ -{% load display_tags %} - -
-
-

{{ heading }}

-

{{ sub_heading }}

-

{{ meta_info }}

-

Generated: {% display_date %}

-
-
-
Page Break
diff --git a/dojo/templates_classic/dojo/custom_html_report_endpoint_list.html b/dojo/templates_classic/dojo/custom_html_report_endpoint_list.html deleted file mode 100644 index 0e881bd6fc6..00000000000 --- a/dojo/templates_classic/dojo/custom_html_report_endpoint_list.html +++ /dev/null @@ -1,200 +0,0 @@ -{% load static %} -{% load display_tags %} -{% load humanize %} -{% load get_note_status %} -{% load get_notetype_availability %} -{% load event_tags %} - -{% if endpoints %} -

{{ title }}

-

Endpoint Findings

-

- A total of {{ endpoints|length|apnumber }} endpoint{{ endpoints|length|pluralize }} with findings - of varying severity are represented in this report. -

-{% endif %} - -
- {% for endpoint in endpoints %} -
-
-

- Endpoint: {{ endpoint }} with {{ endpoint.active_annotated_findings|length|apnumber }} - active findings -

-
-
- - {% for finding in endpoint.active_annotated_findings %} - {% ifchanged finding.severity %} -

{{ finding.severity|capfirst }}

- {% endifchanged %} -
-
-
-
- Finding {{ forloop.counter }}: {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
-
-
-
- - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - -
SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWEDojo ID
- - {% if finding.severity %} - {{ finding.severity }} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - - {{ finding.id }}
-
-
- - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
CVSS v3
-
{{ finding.cvssv3|markdown_render }}
- {% endif %} - -
Description
-
{{ finding.description|markdown_render }}
- -
Mitigation
-
{{ finding.mitigation|markdown_render }}
- {% if finding.get_report_requests %} -
Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
- {% for req in finding.get_report_requests %} - Request {{forloop.counter}} -
{{ req.get_request }}
- {% if req.get_response != "" %} - Response {{forloop.counter}} -
{{ req.get_response|truncatechars_html:800 }}
- {% endif %} - {% endfor %} - {% endif %} - -
Impact
-
{{ finding.impact|markdown_render }}
- {% if finding.steps_to_reproduce %} -
Steps to Reproduce
-
{{ finding.steps_to_reproduce|markdown_render }}
- {% endif %} - {% if finding.severity_justification %} -
Severity Justification
-
{{ finding.severity_justification|markdown_render }}
- {% endif %} - {% if finding.references %} -
References
-
{{ finding.references|markdown_render }}
- {% endif %} - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="INLINE" %} - {% endif %} - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} -
Notes
- - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
UserDateNote TypeNote
- {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
- {% endif %} - {% endwith %} - {% endif %} - {% endfor %} - {% endfor %} -
diff --git a/dojo/templates_classic/dojo/custom_html_report_finding_list.html b/dojo/templates_classic/dojo/custom_html_report_finding_list.html deleted file mode 100644 index 44d9063574f..00000000000 --- a/dojo/templates_classic/dojo/custom_html_report_finding_list.html +++ /dev/null @@ -1,203 +0,0 @@ -{% load static %} -{% load display_tags %} -{% load humanize %} - -{% load get_note_status %} -{% load get_notetype_availability %} -{% load event_tags %} - -{% if findings %} -

{{ title }}

-{% endif%} - -
- {% for finding in findings %} - {% ifchanged finding.severity %} -

{{ finding.severity|capfirst }}

- {% endifchanged %} -
-
-
-
- Finding {{ forloop.counter }} - {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
-
-
-
- - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe %} - - {% endif %} - {% if finding.vulnerability_ids %} - - {% endif %} - - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe %} - - {% endif %} - {% if finding.vulnerability_ids %} - - {% endif %} - - -
SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWEVulnerability IDsDojo ID
- - {% if finding.severity %} - {{ finding.severity }} - {% if finding.cvssv3_score %} - ({{ finding.cvssv3_score }}) - {% endif %} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} / {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - - {{ finding.vulnerability_ids }}{{ finding.id }}
-
-
- - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
CVSS v3
-
{{ finding.cvssv3|markdown_render }}
- {% endif %} - - {% if finding.description %} -
Description
-
{{ finding.description|markdown_render }}
- {% endif %} - - {% if finding.mitigation %} -
Mitigation
-
{{ finding.mitigation|markdown_render }}
- {% endif %} - - {% if finding.get_report_requests %} -
Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
- {% for req in finding.get_report_requests %} - Request {{forloop.counter}} -
{{ req.get_request }}
- {% if req.get_response != "" %} - Response {{forloop.counter}} -
{{ req.get_response|truncatechars_html:800 }}
- {% endif %} - {% endfor %} - {% endif %} - - {% if finding.impact %} -
Impact
-
{{ finding.impact }}
- {% endif %} - - {% if finding.steps_to_reproduce %} -
Steps to Reproduce
-
{{ finding.steps_to_reproduce|markdown_render }}
- {% endif %} - - {% if finding.severity_justification %} -
Severity Justification
-
{{ finding.severity_justification|markdown_render }}
- {% endif %} - - {% if finding.references %} -
References
-
{{ finding.references|markdown_render }}
- {% endif %} - - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="INLINE" %} - {% endif %} - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} -
Notes
- - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
UserDateNote TypeNote
- {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
- {% endif %} - {% endwith %} - {% endif %} - {% endfor %} -
diff --git a/dojo/templates_classic/dojo/custom_html_report_wysiwyg_content.html b/dojo/templates_classic/dojo/custom_html_report_wysiwyg_content.html deleted file mode 100644 index 275359577f8..00000000000 --- a/dojo/templates_classic/dojo/custom_html_report_wysiwyg_content.html +++ /dev/null @@ -1,9 +0,0 @@ -{% load static %} - -

{{ heading }}

-
- {{ content|safe }} -
-{% if page_break_after %} -
Page Break
-{% endif %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/custom_html_toc.html b/dojo/templates_classic/dojo/custom_html_toc.html deleted file mode 100644 index e6598514600..00000000000 --- a/dojo/templates_classic/dojo/custom_html_toc.html +++ /dev/null @@ -1,85 +0,0 @@ -{% load static %} - -
-

{{ heading }}

-
-
-
Page Break
- - \ No newline at end of file diff --git a/dojo/templates_classic/dojo/dashboard-metrics.html b/dojo/templates_classic/dojo/dashboard-metrics.html deleted file mode 100644 index 7fe86e2855c..00000000000 --- a/dojo/templates_classic/dojo/dashboard-metrics.html +++ /dev/null @@ -1,539 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load static %} -{% load i18n %} -{% block add_styles %} - {{ block.super }} - body{ - padding-top: 0px !important; - } - nav.navbar {display: none;} - .announcement-banner {display: none !important;} - #page-wrapper { - margin: 0 0 0 0 !important; - padding: 0 0 0 0 !important; - } - br {margin: 0;} - h2 {margin: -5px 0px 0px;background-color: white;padding: 5px 15px 8px;border-bottom: 1px solid #e8e8e8;} - - ul.breadcrumb {display:none} - span.close_link {font-size: 14px;} - .metric-data{margin-top: 40px;} - .graph {height: 45vh; width: 100%;} - .graph-500 {height: 65vh; width: 100%;} - #punchcard .flot-x-axis .flot-tick-label span { transform: rotate(45deg);-webkit-transform: - rotate(-45deg);-ms-transform:rotate(-45deg);display: inline-block;} -{% endblock %} -{% block content %} - {{ block.super }} - -
-
-
-
{% trans "Open Bug Count by Month" %}
- -
- -
-

{% trans "Open Bug Count by Month" %}

-

{% trans "This graph represents the number of open bugs per month - categorized by severity. The x-axis represents months, and the y-axis represents the number of open - bugs. The table below provides the same data in text format." %}

- - - - - - - - - - - - - {% for month in opened_per_month %} - - - - - - - - {% endfor %} - -
{% trans "Open bug count breakdown" %}
{% trans "Month" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ month.grouped_date|date:"F Y" }}{{ month.critical }}{{ month.high }}{{ month.medium }}{{ month.low }}
-
-
- -
- -
- -
-
-
{% trans "Risk Accepted Bug Count by Month" %}
- -
- -
-

{% trans "Risk Accepted Bug Count by Month" %}

-

{% trans "This graph represents the number of risk accepted bugs - per month, categorized by severity. The x-axis represents months and the y-axis represents the - number of accepted bugs. The table below provides the same data in text format." %}

- - - - - - - - - - - - - {% for month in accepted_per_month %} - - - - - - - - {% endfor %} - -
{% trans "Risk accepted bug count breakdown" %}
{% trans "Month" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ month.grouped_date|date:"F Y" }}{{ month.critical }}{{ month.high }}{{ month.medium }}{{ month.low }}
-
-
- -
- -
- -
-
-
{% trans "Open Bug Count by Week" %}
- -
- -
-
-

{% trans "Open Bug Count by Week" %}

-

{% trans "This graph represents the number of open bugs per week - categorized by severity. The x-axis represents weeks and the y-axis represents the number of open bugs. - The table below provides the same data in text format." %}

- - - - - - - - - - - - - {% for week in opened_per_week %} - - - - - - - - {% endfor %} - -
{% trans "Open Bug Count Breakdown by Week" %}
{% trans "Week" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ week.grouped_date|date:"W Y" }}{{ week.critical }}{{ week.high }}{{ week.medium }}{{ week.low }}
-
- -
- -
- -
-
-
{% trans "Risk Accepted Bug Count by Week" %}
- -
- -
-

{% trans "Risk accepted bug count by week" %}

-

{% trans "This graph represents the number of risk accepted bugs - per week categorized by severity. The x-axis represents weeks and the y-axis represents the number - of accepted bugs. The table below provides the same data in text format." %}

- - - - - - - - - - - - - {% for week in accepted_per_week %} - - - - - - - - {% endfor %} - -
{% trans "Risk accepted bug count breakdown by week" %}
{% trans "Week" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ week.grouped_date|date:"W Y" }}{{ week.critical }}{{ week.high }}{{ week.medium }}{{ week.low }}
-
-
- -
- -
- - - {% if top_ten_products %} -
-
-
{% blocktrans with length=top_ten_products|length %}Top {{ length }} Products By Bug - Severity{% endblocktrans %}
- -
- -
-
-

{% trans "Top Products By Bug Severity" %}

-

- {% blocktrans %} - This bar chart represents the number of bugs categorized by severity for the top {{ length }} products. - The x-axiscrepresents different products, and the y-axis represents the count of bugs for each severity. - The following table represents the same data in text format. - {% endblocktrans %} -

- - - - - - - - - - - - - {% for product in top_ten_products %} - - - - - - - - {% endfor %} - -
{% trans "Bug count by severity for top products" %}
{% trans "Product" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ product.name }}{{ product.critical|default_if_none:0 }}{{ product.high|default_if_none:0 }}{{ product.medium|default_if_none:0 }}{{ product.low|default_if_none:0 }}
-
- -
- -
- {% endif %} - -
-
-
{% trans "Total Findings In Period By Severity" %}
- -
- -
-
-

{% trans "Total findings in period by severity" %}

-

{% blocktrans with critical=in_period_counts.critical|default_if_none:0 high=in_period_counts.high|default_if_none:0 medium=in_period_counts.medium|default_if_none:0 low=in_period_counts.low|default_if_none:0 %} - This pie chart represents the total number of findings categorized by severity. The following - findings are included: {{ critical }} critical, {{ high }} high, {{ medium }} - medium, and {{ low }} low severity findings. - {% endblocktrans %} -

-
- -
- -
- -
-
-
{% trans "Total Findings Risk Accepted In Period By Severity" %}
- -
- -
-

{% trans "Total risk accepted findings in period by severity" - %}

-

- {% blocktrans with critical=accepted_in_period_counts.critical|default_if_none:0 high=accepted_in_period_counts.high|default_if_none:0 medium=accepted_in_period_counts.medium|default_if_none:0 low=accepted_in_period_counts.low|default_if_none:0 %} - This pie chart represents the total number of risk-accepted findings categorized by severity. The - following findings are included: {{ critical }} critical, {{ high }} high, {{ medium }} medium - and {{ low }} low severity findings. - {%endblocktrans %} -

-
-
- -
- -
- -
-
-
{% trans "Total Findings Closed In Period By Severity" %}
- -
- -
-

{% trans "Total closed in period by severity" %}

-

- {% blocktrans with critical=closed_in_period_counts.critical|default_if_none:0 high=closed_in_period_counts.high|default_if_none:0 medium=closed_in_period_counts.medium|default_if_none:0 low=closed_in_period_counts.low|default_if_none:0 %} - This pie chart represents the total number of closed findings categorized by severity. The following - findings are included: {{ critical }} critical, {{ high }} high {{ medium }} medium - and {{ low }} low severity findings. - {% endblocktrans %} -

-
-
- -
- -
- - {% if punchcard %} -
-
-
- {% trans "Weekly activity, displayed by day, of findings reported.*" %} -
-
- -
-

{% trans "Weekly Activity of Findings Reported" %}

-

- {% trans "This chart represents the weekly activity of findings reported, categorized by day of the - week. The x-axis represents the days of the week, while the y-axis represents the corresponding week - number. The size of each data point indicates the number of findings reported on that particular day. - The table below provides the same data in text format." %} -

- - - - - - - - - - - - - - - - {% for week, values in punchcard.items %} - - - - - - - - - - - {% endfor %} - -
Weekly findings activity Breakdown
WeekMondayTuesdayWednesdayThursdayFridaySaturdaySunday
{{ week }}{{ values.Sunday|default:"0" }}{{ values.Monday|default:"0" }}{{ values.Tuesday|default:"0" }}{{ values.Wednesday|default:"0" }}{{ values.Thursday|default:"0" }}{{ values.Friday|default:"0" }}{{ values.Saturday|default:"0" }}
-
-


{% trans "Week begins on date displayed." %}

-

-
- {% trans "* Weeks are only displayed if findings are available." %} -

-
-
-
- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - - - - {% if punchcard %} - - - - {% endif %} - {% block metrics %} - - {% endblock metrics %} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/dashboard.html b/dojo/templates_classic/dojo/dashboard.html deleted file mode 100644 index a0759f5d4fc..00000000000 --- a/dojo/templates_classic/dojo/dashboard.html +++ /dev/null @@ -1,378 +0,0 @@ -{% extends "base.html" %} -{% load authorization_tags %} -{% load i18n %} -{% load static %} - -{% block add_styles %} - {{ block.super }} - .chart {height: 300px} - .status .panel {min-height: 140px;background-color: #f5f5f5;} - #punchcard {height: 350px} -{% endblock %} - -{% block content %} - {{ block.super }} - {% block finding_stats_tiles %} -
- {% block active_engagements %} -
-
-
-
-
- -
-
-
{{ engagement_count }}
-
{% trans "Active Engagements" %}
-
-
-
- - - -
-
- {% endblock %} - {% block last_seven_days %} -
-
-
-
-
- -
-
-
{{ finding_count }}
-
{% trans "Last Seven Days" %}
-
-
-
- - - -
-
- {% endblock %} - {% block closed_last_seven_days %} -
-
-
-
-
- -
-
-
{{ mitigated_count }}
-
{% trans "Closed In Last Seven Days" %}
-
-
-
- - - -
-
- {% endblock %} - {% block risk_accepted_last_seven_days %} -
-
-
-
-
- -
-
-
{{ accepted_count }}
-
{% trans "Risk Accepted In Last Seven Days" %}
-
-
-
- - - -
-
- {% endblock %} -
- {% endblock %} - {% block graph_row %} -
- {% block historical_finding_severity %} -
-
-
- {% trans "Historical Finding Severity" %} -
- -
-

- This pie chart represents findings categorized by severity: - critical ({{critical}}), high ({{high}}), medium ({{medium}}), - low ({{low}}), and informational ({{info}}). -

- -
- -
- -
- {% endblock %} - {% block reported_finding_severity_by_month %} -
-
-
- {% trans "Reported Finding Severity by Month" %} -
-

- This severity trend chart shows monthly data for findings categorized by severity: - critical, high, medium, and low. The table below the chart provides the data in text format. -

- -
- -
-

{% trans "Reported finding severity breakdown by month" %}

- - - - - - - - - - - - - {% for month in by_month %} - - - - - - - - {% endfor %} - -
{% trans "Reported finding severity breakdown" %}
{% trans "Month" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ month.y|slice:"5:" }} {{ month.y|slice:":4" }}{{ month.a|default_if_none:0 }}{{ month.b|default_if_none:0 }}{{ month.c|default_if_none:0 }}{{ month.d|default_if_none:0 }}
-
-
- -
- -
- {% endblock %} - -
- {% endblock %} - {% block surveys %} - {% if system_settings.enable_questionnaires and "dojo.view_engagement_survey"|has_configuration_permission:request %} -
-
-
-
- {% trans "Unassigned Answered Engagement Questionnaires" %} -
-
- {% if surveys %} - - - - - - - - - - - - - {% for survey in surveys %} - - - - - - - - - - {% endfor %} -
{% trans "Name" %}{% trans "Product" %}{% trans "Engagement" %}{% trans "Responder" %}{% trans "Status" %}{% trans "Completion" %} Date{% trans "Actions" %}
- {% if survey.engagement %} - {{ survey.survey.name }} - {% else %} - {{ survey.survey.name }} - {% endif %} - {{ survey.engagement.product.name }} - {{ survey.engagement.name }} - - {% if survey.responder %} - {{ survey.responder }} - {% else %} - {% trans "Anonymous" %} - {% endif %} - - {% if survey.completed %} - {% trans "Completed" %} - {% else %} - {% trans "Unanswered" %} - {% endif %} - {{ survey.answered_on }} -
- {% if survey.engagement %} - {% trans "View Responses" %} - {% trans "Assign User" %} - {% else %} - {% trans "View Responses" %} - {% trans "Create Engagement" %} - {% trans "Link to Existing Engagement" %} - - {% endif %} -
-
- {% else %} -
-

{% trans "No questionnaires found." %}

-
- {% endif %} -
-
-
-
- {% endif %} - {% endblock %} - {% block punchcard %} -
- {% if punchcard %} -
-
-
- {% trans "Weekly activity, displayed by day, of findings you reported." %}* -
-
-

- This punchcard chart represents findings distribution over time by week. - The table below the chart provides the data in text format. -

- - - - - - - - - - - - - - - - - {% for week, values in punchcard.items %} - - - - - - - - - - - {% endfor %} - -
Weekly findings activity breakdown
WeekMondayTuesdayWednesdayThursdayFridaySaturdaySunday
{{ week }}{{ values.Sunday|default:"0" }}{{ values.Monday|default:"0" }}{{ values.Tuesday|default:"0" }}{{ values.Wednesday|default:"0" }}{{ values.Thursday|default:"0" }}{{ values.Friday|default:"0" }}{{ values.Saturday|default:"0" }}
-

{% trans "Week begins on date displayed." %}

-

-
- * {% trans "Weeks are only displayed if findings are available." %} -

-
-
-
- {% endif %} -
- {% endblock %} -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - - - - {% if punchcard %} - - - - {% endif %} - {% block metrics %} - - {% endblock metrics %} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/defect_finding_review.html b/dojo/templates_classic/dojo/defect_finding_review.html deleted file mode 100644 index cb7de96c4ce..00000000000 --- a/dojo/templates_classic/dojo/defect_finding_review.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% block content %} - {{ block.super }} -

Review Finding from Jira

-

{{ finding.title }}

-

Please provide a description on why the finding should be closed or marked as not fixed.

-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_alerts.html b/dojo/templates_classic/dojo/delete_alerts.html deleted file mode 100644 index 4c8a0f53ba2..00000000000 --- a/dojo/templates_classic/dojo/delete_alerts.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block content %} - {{ block.super }} -

{% blocktrans %}Delete All alerts {{ product }}{% endblocktrans %}

-

{% trans "Delete all alerts will remove all alerts from this instance" %}

-
-
-

{% trans "Danger Zone" %}

-
- {% if delete_preview%} -
-

{% trans "The following alerts will be deleted" %}

-
- {% for alert in alerts %} - - {% if alert.url %}{% endif %}{{ alert.title|linebreaks }}{% if alert.url %}{% endif %} - - {% endfor %} - {% else %} -
-

{% trans "Previewing the relationships has been disabled." %}

-
- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_benchmark.html b/dojo/templates_classic/dojo/delete_benchmark.html deleted file mode 100644 index ccc48d39516..00000000000 --- a/dojo/templates_classic/dojo/delete_benchmark.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block content %} - {{ block.super }} -

{% blocktrans %}Delete Benchmarks for {{ product }}{% endblocktrans %}

-

- {% trans "Deleting these benchmarks will remove the benchmarks from the product and any related notes on the benchmarks." %} -

-
-
-

{% trans "Danger Zone" %}

-
- -
- {% csrf_token %} - {{ form }} -


-
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_cicd_infrastructure.html b/dojo/templates_classic/dojo/delete_cicd_infrastructure.html deleted file mode 100644 index 9fa1f219fa3..00000000000 --- a/dojo/templates_classic/dojo/delete_cicd_infrastructure.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete CI/CD Infrastructure

-

Are you sure you want to delete {{ conf.name }}?

-

Any engagements referencing this infrastructure will have the reference cleared.

-
{% csrf_token %} -
- Cancel - -
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_endpoint.html b/dojo/templates_classic/dojo/delete_endpoint.html deleted file mode 100644 index 223a58ac0f9..00000000000 --- a/dojo/templates_classic/dojo/delete_endpoint.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete Endpoint {{ endpoint }}{% if endpoint.is_broken %} 🚩{% endif %}

-

- Deleting this Endpoint will disassociate it with any findings and products and other relationships associated - with it. These relationships are listed below: -

-
-
-

Danger Zone

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_engagement.html b/dojo/templates_classic/dojo/delete_engagement.html deleted file mode 100644 index 0d147531595..00000000000 --- a/dojo/templates_classic/dojo/delete_engagement.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete {{ engagement }}

-

- Deleting this Engagement will remove any related objects associated - with it. These relationships are listed below: -

-
-
-

Danger Zone

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_finding_group.html b/dojo/templates_classic/dojo/delete_finding_group.html deleted file mode 100644 index 5023822534d..00000000000 --- a/dojo/templates_classic/dojo/delete_finding_group.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete Finding Group {{ finding_group }}

-

- Deleting this Finding Group will NOT remove any findings inside this group. - A Finding Group is just a thin wrapper around a set of findings. -

-
-
-

Danger Zone

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_github.html b/dojo/templates_classic/dojo/delete_github.html deleted file mode 100644 index 3b7ccb323b5..00000000000 --- a/dojo/templates_classic/dojo/delete_github.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete Test {{ test }}

-

- Deleting this configuration will remove any GitHub product configurations, settings, and other relationships associate - with this configuration. These relationships are listed below: -

-
-
-

Danger Zone

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_jira.html b/dojo/templates_classic/dojo/delete_jira.html deleted file mode 100644 index 3694469ea68..00000000000 --- a/dojo/templates_classic/dojo/delete_jira.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete Test {{ test }}

-

- Deleting this configuration will remove any JIRA product configurations, settings, and other relationships associate - with this configuration. These relationships are listed below: -

-
-
-

Danger Zone

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_notification_webhook.html b/dojo/templates_classic/dojo/delete_notification_webhook.html deleted file mode 100644 index f196ad94fc9..00000000000 --- a/dojo/templates_classic/dojo/delete_notification_webhook.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Delete Notification Webhook

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_object.html b/dojo/templates_classic/dojo/delete_object.html deleted file mode 100644 index 14d80ce8bc5..00000000000 --- a/dojo/templates_classic/dojo/delete_object.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete Tracked File, Folder or Artifact

- -
-
-

Danger Zone

-
-
- {% csrf_token %} - {{ form }} -
-
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_presets.html b/dojo/templates_classic/dojo/delete_presets.html deleted file mode 100644 index 913dd6c9467..00000000000 --- a/dojo/templates_classic/dojo/delete_presets.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete Engagement Preset {{ test }}

-

- Deleting this Engagement Preset will remove the engagement relationships associated - with it. These relationships are listed below: -

-
-
-

Danger Zone

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_product.html b/dojo/templates_classic/dojo/delete_product.html deleted file mode 100644 index ef03116d5a8..00000000000 --- a/dojo/templates_classic/dojo/delete_product.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

{{ label_delete_with_name }}

-

{{ labels.ASSET_DELETE_CONFIRM_MESSAGE }}

-
-
-

Danger Zone

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_product_api_scan_configuration.html b/dojo/templates_classic/dojo/delete_product_api_scan_configuration.html deleted file mode 100644 index df095aedf03..00000000000 --- a/dojo/templates_classic/dojo/delete_product_api_scan_configuration.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Delete API Scan Configuration

- -
-
-

Danger Zone

-
-
- {% csrf_token %} - {{ form }} -
-
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_product_type.html b/dojo/templates_classic/dojo/delete_product_type.html deleted file mode 100644 index 69ea3b55704..00000000000 --- a/dojo/templates_classic/dojo/delete_product_type.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block content %} - {{ block.super }} -

{{ label_delete_with_name }}

-

{{ labels.ORG_DELETE_CONFIRM_MESSAGE }}

-
-
-

{% trans "Danger Zone" %}

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

{% trans "No relationships found." %}

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_technology.html b/dojo/templates_classic/dojo/delete_technology.html deleted file mode 100644 index 2fe8b963606..00000000000 --- a/dojo/templates_classic/dojo/delete_technology.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html" %} -{% block content %} -

Delete Technology

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_test.html b/dojo/templates_classic/dojo/delete_test.html deleted file mode 100644 index 0934b9acee0..00000000000 --- a/dojo/templates_classic/dojo/delete_test.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete Test {{ test }}

-

- Deleting this Test will remove any findings, risk acceptances, check lists, and other relationships associated - with it. These relationships are listed below: -

-
-
-

Danger Zone

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

No relationships found.

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_tool_product.html b/dojo/templates_classic/dojo/delete_tool_product.html deleted file mode 100644 index 0d673773eef..00000000000 --- a/dojo/templates_classic/dojo/delete_tool_product.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Delete Product Tool Configuration

- -
-
-

Danger Zone

-
-
- {% csrf_token %} - {{ form }} -
-
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/delete_user.html b/dojo/templates_classic/dojo/delete_user.html deleted file mode 100644 index 3d2c9974d91..00000000000 --- a/dojo/templates_classic/dojo/delete_user.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block content %} - {{ block.super }} -

{% blocktrans %} Delete User {{ to_delete }}{% endblocktrans %}

-

{% blocktrans %}Deleting this User will remove any related objects associated with it. These relationships are listed below:{% endblocktrans %}

-
-
-

{% trans "Danger Zone" %}

-
- {% if rels|length > 1 %} -
    {{ rels|unordered_list }}
- {% else %} -

{% trans "No relationships found." %}

- {% endif %} -
- {% csrf_token %} - {{ form }} - -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/dev_env.html b/dojo/templates_classic/dojo/dev_env.html deleted file mode 100644 index d79dcdac057..00000000000 --- a/dojo/templates_classic/dojo/dev_env.html +++ /dev/null @@ -1,87 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- Environment List - -

-
-
- {% include "dojo/filter_snippet.html" with form=dts.form %} -
-
- {% if devs %} -
- {% include "dojo/paging_snippet.html" with page=devs page_size=True%} -
-
- - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - {% for de in devs %} - - {% if "dojo.change_development_environment"|has_configuration_permission:request %} - - {% else %} - - {% endif %} - - {% endfor %} - -
{% dojo_sort request 'Environment' 'name' 'asc' %}
{{ de.name }} {{ de.name }}
-
-
- {% include "dojo/paging_snippet.html" with page=devs page_size=True%} -
- {% else %} -

No environments found.

- {% endif %} -
- -
-{% endblock %} -{% block postscript %} - {{ block.super }} - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/disable_note_type.html b/dojo/templates_classic/dojo/disable_note_type.html deleted file mode 100644 index c88247e573e..00000000000 --- a/dojo/templates_classic/dojo/disable_note_type.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Disable Note Type {{ nt.name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=nt_form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/dismiss_announcement.html b/dojo/templates_classic/dojo/dismiss_announcement.html deleted file mode 100644 index 6f7b528aa63..00000000000 --- a/dojo/templates_classic/dojo/dismiss_announcement.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block content %} - {{ block.super }} -

{% trans "Dismiss Announcement" %} {{ product }}

-

- {% trans "Dismissing the announcement will remove the current announcement from your view" %} -

-
-
-

{% trans "Danger Zone" %}

-
-
- {% csrf_token %} - {{ form }} -
-
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_cicd_infrastructure.html b/dojo/templates_classic/dojo/edit_cicd_infrastructure.html deleted file mode 100644 index 4e7be802e5b..00000000000 --- a/dojo/templates_classic/dojo/edit_cicd_infrastructure.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Edit CI/CD Infrastructure

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_dev_env.html b/dojo/templates_classic/dojo/edit_dev_env.html deleted file mode 100644 index 598528fb7b8..00000000000 --- a/dojo/templates_classic/dojo/edit_dev_env.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -

Edit Environment {{ pt.name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form1 %} - {{ form2 }} -
-
- - {% if "dojo.delete_development_environment"|has_configuration_permission:request %} - - {% endif %} -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_endpoint.html b/dojo/templates_classic/dojo/edit_endpoint.html deleted file mode 100644 index 1f9e05a284d..00000000000 --- a/dojo/templates_classic/dojo/edit_endpoint.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block add_styles %} - {{ block.super }} -.chosen-container { - width: 70% !important; -} -{% endblock %} -{% block content %} - {{ block.super }} -

Edit Endpoint

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_finding.html b/dojo/templates_classic/dojo/edit_finding.html deleted file mode 100644 index 21df42aa3de..00000000000 --- a/dojo/templates_classic/dojo/edit_finding.html +++ /dev/null @@ -1,235 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load display_tags %} -{% load static %} -{% block add_css %} - {{ block.super }} - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

Edit a Finding

- {% if temp %} -
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% if jform %} -

JIRA

-
- {% include "dojo/form_fields.html" with form=jform %} - {% endif %} - {% if gform %} -

GitHub

-
- {% include "dojo/form_fields.html" with form=gform %} - {% endif %} -
-
- - -
-
-
- {% else %} -
- {% csrf_token %} - {% if return_url %} - - {% endif %} - {% include "dojo/form_fields.html" with form=form %} - {% block additional_forms %} - {% endblock additional_forms %} - - {% if finding.duplicate_finding %} - [original: - {{ finding.duplicate_finding.id }} : {{ finding.duplicate_finding.title }}/ - {{ finding.duplicate_finding.test.engagement.product.name }}/ - {{ finding.duplicate_finding.test.engagement.name }} - {% if finding.duplicate_finding.test.title %} - {{finding.duplicate_finding.test.title}} - {% else %} - {{finding.duplicate_finding.test.test_type}} - {% endif %} - {% if finding.duplicate_finding.cve %} - {% if finding.duplicate_finding.cve|has_vulnerability_url %} - ({{finding.duplicate_finding.cve}}) - {% else %} - {{ finding.duplicate_finding.cve }} - {% endif %} - {% endif %} - {% if finding.duplicate_finding.cwe > 0 %} - (CWE-{{finding.duplicate_finding.cwe}}) - {% endif %} - {% if finding.duplicate_finding.jira_issue %} - {{ finding.duplicate_finding | jira_key }} - {% endif %} - ] - {% endif %} - - - {% if jform %} -

JIRA

-
-
- -
- {% if finding.has_jira_group_issue %} - {{ finding.finding_group | jira_issue_url }} (group) - {% elif finding.has_jira_issue %} - {{ finding | jira_issue_url }} - {% else %} - {{ finding | jira_project_url }} - {% endif %} -
-
- {% include "dojo/form_fields.html" with form=jform %} - {% endif %} - {% if gform %} -

GitHub

-
-
- -
- {% if finding.github_issue and finding.github_conf_new %} - {{finding.github_issue.issue_url}} - {% else %} - None - {% endif %} -
-
- {% include "dojo/form_fields.html" with form=gform %} - {% endif %} -
-
- -
-
-
- {% endif %} - -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_jira.html b/dojo/templates_classic/dojo/edit_jira.html deleted file mode 100644 index 7dbd9374d4e..00000000000 --- a/dojo/templates_classic/dojo/edit_jira.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} - {{ block.super }} -

Edit JIRA Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=jform %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/edit_metadata.html b/dojo/templates_classic/dojo/edit_metadata.html deleted file mode 100644 index ec1597768cc..00000000000 --- a/dojo/templates_classic/dojo/edit_metadata.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block content %} - {{ block.super }} -

Edit Metadata

-
-
- {% csrf_token %} - {% for form in formset %} -
- {% include "dojo/form_fields.html" with form=form %} -
- {% endfor %} -
-
-
- {{ formset.management_form }} - -
-
-
-
-{% endblock content %} diff --git a/dojo/templates_classic/dojo/edit_note.html b/dojo/templates_classic/dojo/edit_note.html deleted file mode 100644 index b85a1fa070f..00000000000 --- a/dojo/templates_classic/dojo/edit_note.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block content %} - {{ block.super }} -

{% trans "Edit Note" %}


-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/edit_note_type.html b/dojo/templates_classic/dojo/edit_note_type.html deleted file mode 100644 index 3663035e32e..00000000000 --- a/dojo/templates_classic/dojo/edit_note_type.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Edit Note Type {{ nt.name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=nt_form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_notification_webhook.html b/dojo/templates_classic/dojo/edit_notification_webhook.html deleted file mode 100644 index 94bd56c2307..00000000000 --- a/dojo/templates_classic/dojo/edit_notification_webhook.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} - {% block content %} - {{ block.super }} -

Edit Notification Webhook

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- - -
-
-
- {% endblock %} - \ No newline at end of file diff --git a/dojo/templates_classic/dojo/edit_object.html b/dojo/templates_classic/dojo/edit_object.html deleted file mode 100644 index 80cf17c54f3..00000000000 --- a/dojo/templates_classic/dojo/edit_object.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} - {{ block.super }} -

Edit Tracked Files

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_presets.html b/dojo/templates_classic/dojo/edit_presets.html deleted file mode 100644 index a323a94dc62..00000000000 --- a/dojo/templates_classic/dojo/edit_presets.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_product.html b/dojo/templates_classic/dojo/edit_product.html deleted file mode 100644 index 569e47b2b7c..00000000000 --- a/dojo/templates_classic/dojo/edit_product.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} -.chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

Edit {{ product.name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% if jform %} -

JIRA

-
- {% include "dojo/form_fields.html" with form=jform %} - {% endif %} - {% if gform %} -

Github

-
- {% include "dojo/form_fields.html" with form=gform %} - {% endif %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_product_api_scan_configuration.html b/dojo/templates_classic/dojo/edit_product_api_scan_configuration.html deleted file mode 100644 index f6a2d5133f2..00000000000 --- a/dojo/templates_classic/dojo/edit_product_api_scan_configuration.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} -

Edit API Scan Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-
- API Scan Configurations are supported for these test types. - -
- -{% endblock %} -{% block postscript %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_product_type.html b/dojo/templates_classic/dojo/edit_product_type.html deleted file mode 100644 index 19971cb2543..00000000000 --- a/dojo/templates_classic/dojo/edit_product_type.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% load i18n %} -{% block add_css %} - {{ block.super }} - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

{{ label_edit_with_name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=pt_form %} - {{ delete_pt_form }} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_regulation.html b/dojo/templates_classic/dojo/edit_regulation.html deleted file mode 100644 index 35027560ebd..00000000000 --- a/dojo/templates_classic/dojo/edit_regulation.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "base.html" %} -{% load authorization_tags %} -{% load static %} - -{% block content %} - {{ block.super }} -

Edit Regulation Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- - {% if "dojo.delete_regulation"|has_configuration_permission:request %} - - {% endif %} -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_sla_config.html b/dojo/templates_classic/dojo/edit_sla_config.html deleted file mode 100644 index e8d9343457d..00000000000 --- a/dojo/templates_classic/dojo/edit_sla_config.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "base.html" %} -{% load authorization_tags %} -{% load static %} - -{% block content %} - {{ block.super }} -

Edit SLA Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- - {% if "dojo.delete_sla_configuration"|has_configuration_permission:request %} - - {% endif %} -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_technology.html b/dojo/templates_classic/dojo/edit_technology.html deleted file mode 100644 index 6b90fcba9ba..00000000000 --- a/dojo/templates_classic/dojo/edit_technology.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} - {% block content %} - {{ block.super }} -

Edit Technology

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
- {% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/edit_test.html b/dojo/templates_classic/dojo/edit_test.html deleted file mode 100644 index e0bb8de6d7f..00000000000 --- a/dojo/templates_classic/dojo/edit_test.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block add_styles %} - {{ block.super }} -.chosen-container { - width: 70% !important; -} -{% endblock %} - -{% block content %} - {{ block.super }} -

Edit Test Details

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_test_type.html b/dojo/templates_classic/dojo/edit_test_type.html deleted file mode 100644 index 88a92d63837..00000000000 --- a/dojo/templates_classic/dojo/edit_test_type.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Edit Test Type {{ pt.name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_tool_config.html b/dojo/templates_classic/dojo/edit_tool_config.html deleted file mode 100644 index 73743b00060..00000000000 --- a/dojo/templates_classic/dojo/edit_tool_config.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} - {{ block.super }} -

Edit Tool Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_tool_product.html b/dojo/templates_classic/dojo/edit_tool_product.html deleted file mode 100644 index c554e254158..00000000000 --- a/dojo/templates_classic/dojo/edit_tool_product.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} - {{ block.super }} -

Edit Product Tool Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/edit_tool_type.html b/dojo/templates_classic/dojo/edit_tool_type.html deleted file mode 100644 index 0de19661181..00000000000 --- a/dojo/templates_classic/dojo/edit_tool_type.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% load i18n %} - -{% block content %} - {{ block.super }} -

{% trans "Edit Tool Type Configuration" %}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/enable_note_type.html b/dojo/templates_classic/dojo/enable_note_type.html deleted file mode 100644 index 9b54c88b09f..00000000000 --- a/dojo/templates_classic/dojo/enable_note_type.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Disable Note Type {{ nt.name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=nt_form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/endpoint_meta_importer.html b/dojo/templates_classic/dojo/endpoint_meta_importer.html deleted file mode 100644 index e723a0b9914..00000000000 --- a/dojo/templates_classic/dojo/endpoint_meta_importer.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} - {{ block.super }} -

Endpoint Meta Importer

-
-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-
-

Endpoint Meta Importer is a very simple CSV mapping that can apply meta data to Endpoints in the form of column:row to tags and DojoMeta

-

Allowable meta is totally arbitray such that any column:row pair will be added to endpoints associated to the product

-

Note: The field "hostname" is required as it is used to query/create endpoints.

- -

Here is an example:

-
- -

hostname, team, public_facing

-

sheets.google.com, data analytics, yes

-

docs.google.com, language processing, yes

-

feedback.internal.google.com, human resources, no

- -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/endpoint_pdf_report.html b/dojo/templates_classic/dojo/endpoint_pdf_report.html deleted file mode 100644 index 37076ef3e9c..00000000000 --- a/dojo/templates_classic/dojo/endpoint_pdf_report.html +++ /dev/null @@ -1,437 +0,0 @@ -{% extends "report_base.html" %} -{% load static %} -{% load display_tags %} -{% load humanize %} - -{% load get_note_status %} -{% load get_notetype_availability %} -{% load event_tags %} -{% block content %} - {{ block.super }} -
- {% if include_table_of_contents%} -
-
-

Table of Contents for {{ product.name }}

-
-
- -
- {% endif %} - {% if include_executive_summary %} -
-
-

Executive Summary

-

- This report represents a security audit performed by the {{ team_name }} team. - It contains confidential information about the state of your network and applications. - Access to this information by unauthorized personnel may allow them to compromise your network. -

-
-
-

Vulnerable Services

-
- {% if endpoints %} - {% colgroup endpoints into 2 cols as grouped_items %} - - {% for row in grouped_items %} - - {% for item in row %} - - {% endfor %} - - {% endfor %} -
{% if item %} {{ item }}{% endif %} -
- {% else %} -
- No endpoints. -
- {% endif %} - -
-
- -
-
-
-
- Endpoint Finding Count -
-
-
-
-
-
-
-
-
-
-
- Finding Age -
-
-
-
-
-
-
-
- {% endif %} - {% if include_disclaimer%} -
- Disclaimer
-

{{ disclaimer | bleach_with_a_tags }}

-
- {% endif %} -
-
- {% if findings %} -

Findings

- {% endif %} - {% for finding in findings %} - {% ifchanged finding.severity %} -

{{ finding.severity|capfirst }}

- {% endifchanged %} -
-
-
-
- Finding {{ finding.id }}: {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
-
-
-
- - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - -
SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWE
- - {% if finding.severity %} - {{ finding.severity }} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - -
-
-
- - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
CVSS v3
-
{{ finding.cvssv3|markdown_render }}
- {% endif %} - -
Description
-
{{ finding.description|markdown_render }}
- - {% if finding.mitigation %} -
Mitigation
-
{{ finding.mitigation|markdown_render }}
- {% endif %} - - {% if finding.get_report_requests %} -
Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
- {% for req in finding.get_report_requests %} -
Request {{forloop.counter}}
-
{{ req.get_request }}
- {% if req.get_response != "" %} -
Response {{forloop.counter}}
-
{{ req.get_response|truncatechars_html:800 }}
- {% endif %} - {% endfor %} - {% endif %} - - {% if finding.impact %} -
Impact
-
{{ finding.impact|markdown_render }}
- {% endif %} - - {% if finding.steps_to_reproduce %} -
Steps to Reproduce
-
{{ finding.steps_to_reproduce|markdown_render }}
- {% endif %} - - {% if finding.severity_justification %} -
Severity Justification
-
{{ finding.severity_justification|markdown_render }}
- {% endif %} - - {% if finding.references %} -
References
-
{{ finding.references|markdown_render }}
- {% endif %} - - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="HTML" %} - {% endif %} - - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} -
Notes
- - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
UserDateNote TypeNote
- {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
- {% endif %} - {% endwith %} - {% endif %} - {% endfor %} -
-
- {% if include_table_of_contents %} -
- {% endif %} -
-{% endblock %} -{% block js %} - {{ block.super }} - - - - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/endpoints.html b/dojo/templates_classic/dojo/endpoints.html deleted file mode 100644 index 5bda216c888..00000000000 --- a/dojo/templates_classic/dojo/endpoints.html +++ /dev/null @@ -1,277 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- {{ name }} - -

-
-
- {% include "dojo/filter_snippet.html" with form=filtered.form %} -
-
- {% if endpoints %} - - -
- {% include "dojo/paging_snippet.html" with page=endpoints page_size=True %} -
-
- - - - {% if not product_tab or product_tab and product_tab.product|has_object_permission:"Location_Edit" %} - - {% endif %} - {% if host_view %} - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% else %} - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% endif %} - {% if not product_tab %} - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% endif %} - - - - - {% for e in endpoints %} - - {% if not product_tab or product_tab and product_tab.product|has_object_permission:"Location_Edit" %} - - {% endif %} - {% if host_view %} - - {% if not product_tab and e.product %} - - {% endif %} - - - - {% endfor %} -
-
- -
-
{% dojo_sort request 'Host' 'host' %}{% dojo_sort request 'Endpoint' 'endpoint' %}{% dojo_sort request 'Product' 'product' 'asc' %}{% dojo_sort request 'Active (Verified) Findings' 'active_finding_count' %}Status
-
- -
-
{{ e.host|url_shortener }} - {% else %} - - {{ e|url_shortener }}{% if e.is_broken %} 🚩{% endif %} - {% endif %} - {% include "dojo/snippets/tags.html" with tags=e.tags.all %} - - {{ e.product }} - {% include "dojo/snippets/tags.html" with tags=e.product.tags.all %} - - {% if host_view %} - {{ e.host_active_findings_count }} ({{ e.host_active_verified_findings_count }}) - {% else %} - {{ e.active_finding_count }} - ({{ e.active_verified_findings_count }}) - {% endif %} - - {% if host_view %} - {{ e.host_mitigated_endpoints_count }} / {{ e.host_endpoints_count }} mitigated endpoints - {% else %} - {% if e.mitigated %} - Mitigated - {% else %} - {% if e.active_findings_count > 0 %} - Vulnerable - {% else %} - No active findings - {% endif %} - {% endif %} - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=endpoints page_size=True %} -
- {% else %} - {% if host_view %} -

No hosts found.

- {% else %} -

No endpoints found.

- {% endif %} - {% endif %} -
-
- -{% endblock %} -{% block postscript %} - {{ block.super }} - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/engagement.html b/dojo/templates_classic/dojo/engagement.html deleted file mode 100644 index 6129f07294e..00000000000 --- a/dojo/templates_classic/dojo/engagement.html +++ /dev/null @@ -1,244 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} - -{% block content %} - {{ block.super }} -
-
-
-
-

- {{ view }} Engagements - -

-
-
- {% include "dojo/filter_snippet.html" with form=filter_form %} -
-
- {% if engagements %} - -
- {% include "dojo/paging_snippet.html" with page=engagements page_size=True %} -
- -
- - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - {% if system_settings.enable_jira %} - - {% endif %} - - - {% for e in engagements %} - - - - - - - - - - {% if system_settings.enable_jira %} - - {% endif %} - - {% endfor %} -
{% dojo_sort request 'Engagement' 'name' 'asc' %}{% dojo_sort request 'Period' 'target_start' 'asc' %}Status{% dojo_sort request labels.ASSET_LABEL 'product__name' 'asc' %}{% dojo_sort request labels.ORG_LABEL 'product__prod_type__name' %}{% dojo_sort request 'Lead' 'lead__first_name' %}TestsJira
-
-
- {% if e.name %}{{ e.name }}{% endif %} -
- {% include "dojo/snippets/tags.html" with tags=e.tags.all %} -
-
{{ e.target_start }} - {{ e.target_end }} - {% if e.is_overdue and e.status != 'Completed' %} - - {{ e.target_end|overdue }} overdue - - {% endif %} - {{ e.status }} - - {{ e.product.name }} - - {{ e.product|jira_project_tag }} - {% include "dojo/snippets/tags.html" with tags=e.product.tags.all %} - - - {{ e.product.prod_type.name }} - - {{ e.lead.first_name }} {{ e.lead.last_name }} - - {{ e.test_count|default:0 }} - - - {{ e|jira_project_tag }} -
-
-
- {% include "dojo/paging_snippet.html" with page=engagements page_size=True %} -
- {% else %} -
No active engagements
- {% endif %} -
-
- -{% endblock %} -{% block postscript %} - {{ block.super }} - - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/engagement_pdf_report.html b/dojo/templates_classic/dojo/engagement_pdf_report.html deleted file mode 100644 index b10b3293ecd..00000000000 --- a/dojo/templates_classic/dojo/engagement_pdf_report.html +++ /dev/null @@ -1,587 +0,0 @@ -{% extends "report_base.html" %} -{% load static %} -{% load display_tags %} -{% load humanize %} - -{% load get_note_status %} -{% load get_notetype_availability %} -{% load event_tags %} -{% block content %} - {{ block.super }} -
-
-
-

Engagement Security Report for {{ engagement.product.name }}

-

- Engagement: {{ engagement.name }} -
Generated: {% display_date %} -

-
-
- {% if include_table_of_contents%} -
-
-

Table of Contents for {{ engagement.name }}

-
-
- -
- {% endif %} - {% if include_executive_summary %} -
-
-

Executive Summary

-

- This report represents a security assessment performed by the {{ team_name }} team including - confidential information about the state of your network and applications. -

-
-
-
-
-
-
- Engagement - {% if engagement.name %} - : {{ engagement.name }} - {% endif %} -
-
-
-
- - - - - - - - - - - - - - {% if engagement.description %} - - - - {% endif %} -
Start DateEnd DateStatusLead
- {{ engagement.target_start|date}} - - {{ engagement.target_end|date}} - - {{ engagement.status }} - - {{ engagement.lead.first_name }} {{ engagement.lead.last_name }} - - {{ engagement.lead }} -
- {{ engagement.description|markdown_render }} -
-
-
- - {% if engagement.test_set %} -

The engagement included the following tests:

-
    - {% for t in engagement.test_set.all %} -
  • {{ t }} - ({{ t.environment.name|default:"unknown" }}): {{ t.target_start|date:"SHORT_DATE_FORMAT" }}
  • - {% endfor %} -
- {% endif %} - {% if engagement.test_strategy %} -

- The test strategy for this engagement can be viewed at - {{ engagement.test_strategy }} -

- {% endif %} -
-
-
-
-
-
- Endpoints -
-
-
-
- {% colgroup endpoints into 2 cols as grouped_items %} - - {% if grouped_items|length > 0 %} - {% for row in grouped_items %} - - {% for item in row %} - - {% endfor %} - - {% endfor %} - {% endif %} -
{% if item %} - {{ item }}{% endif %} -
-
-
-
-
-

- A total of {{ findings|length|apnumber }} finding{{ findings|length|pluralize }} of varying - severity are represented in this report. -

-
-
-
-
-
- Engagement Finding Count -
-
-
-
-
-
-
-
-
-
-
- Finding Age -
-
-
-
-
-
-
-
- {% endif %} - {% if include_disclaimer%} -
- Disclaimer
-

{{ disclaimer | bleach_with_a_tags }}

-
- {% endif %} -
-
- {% if include_finding_notes %} - {% if engagement.test_set.all %} -

Testing Notes

-

- {% for test in engagement.test_set.all %} - {% if test in tests %} - {% if test.notes.all %} - {% for note in test.notes.all %} - {% if not note.private %} - {{ note.author }} - {{ note.date }} - {{ note }}
- {% endif %} - {% endfor %} - {% endif %} - {% endif %} - {% endfor %} -

- {% endif %} - {% endif %} - {% if engagement.risk_acceptance.count > 0 %} -

Risk Accepted Findings

- - - - - - - - {% for risk in engagement.risk_acceptance.all %} - {% for finding in risk.accepted_findings.all %} - - - - - - - {% endfor %} - {% endfor %} -
- Name - - Date - - Severity - - EPSS Score / Percentile -
{{ finding.title }}{{ finding.date }}{{ finding.severity }} - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} -
- {% endif %} -
-
-
-
- {% if findings %} -

Findings

- {% endif %} - {% for finding in findings %} - {% ifchanged finding.severity %} -

{{ finding.severity|capfirst }}

- {% endifchanged %} -
-
-
-
- Finding {{ forloop.counter }}: {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
-
-
-
- - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - -
SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWEDojo ID
- - {% if finding.severity %} - {{ finding.severity }} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - - {{ finding.id }}
-
-
- - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
CVSS v3
-
{{ finding.cvssv3|markdown_render }}
- {% endif %} - -
Description
-
{{ finding.description|markdown_render }}
- - {% if finding.mitigation %} -
Mitigation
-
{{ finding.mitigation|markdown_render }}
- {% endif %} - - {% if finding.get_report_requests %} -
Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
- {% for req in finding.get_report_requests %} -
Request {{forloop.counter}}
-
{{ req.get_request }}
- {% if req.get_response != "" %} -
Response {{forloop.counter}}
-
{{ req.get_response|truncatechars_html:800 }}
- {% endif %} - {% endfor %} - {% endif %} - - {% if finding.impact %} -
Impact
-
{{ finding.impact|markdown_render }}
- {% endif %} - - {% if finding.steps_to_reproduce %} -
Steps to Reproduce
-
{{ finding.steps_to_reproduce|markdown_render }}
- {% endif %} - - {% if finding.severity_justification %} -
Severity Justification
-
{{ finding.severity_justification|markdown_render }}
- {% endif %} - - {% if finding.references %} -
References
-
{{ finding.references|markdown_render }}
- {% endif %} - - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="HTML" %} - {% endif %} - - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} -
Notes
- - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
UserDateNote TypeNote
- {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
- {% endif %} - {% endwith %} - {% endif %} - {% endfor %} -
-
- {% if include_table_of_contents %} -
- {% endif %} -
-{% endblock %} -{% block js %} - {{ block.super }} - - - - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/engagements_all.html b/dojo/templates_classic/dojo/engagements_all.html deleted file mode 100644 index 9f4158759f1..00000000000 --- a/dojo/templates_classic/dojo/engagements_all.html +++ /dev/null @@ -1,285 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- Engagements - -

-
-
- {% include "dojo/filter_snippet.html" with form=filter_form %} -
-
- {% if products %} - -
- {% include "dojo/paging_snippet.html" with page=products page_size=True %} -
- -
- - - - - - - - {% if system_settings.enable_jira %} - - {% endif %} - - - - - - - - - - {% for p in products %} - {% for e in p.engagement_set.all %} - - - - - - {% if system_settings.enable_jira %} - - {% endif %} - - - - - - - - {% endfor %} - {% endfor %} - -
{{ labels.ASSET_LABEL }}{{ labels.ASSET_LABEL }}Engagement NameJIRAStatusPeriodLeadTestsEngagement
-
-
{{ p.name }} - {% include "dojo/snippets/tags.html" with tags=p.tags.all %} - - {{ p.prod_type.name }} - - {% if e.name %}{{ e.name }}{% endif %} - {% include "dojo/snippets/tags.html" with tags=e.tags.all %} -
-
- {{ e|jira_project_tag }} - {{ e.status }} {{ e.target_start }} - {{ e.target_end }} - {% if e.is_overdue and e.active and e.status != 'Completed' %} -
{{ e.target_end|overdue }} overdue
- {% endif %} -
{{ e.lead.first_name }} {{ e.lead.last_name }} - - {{ e.test_count }} - - - {% if p|has_object_permission:"Engagement_Add" %} - - Add - - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=products page_size=True %} -
- {% else %} -
No engagements found
- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/engineer_metrics.html b/dojo/templates_classic/dojo/engineer_metrics.html deleted file mode 100644 index c0b77805ba0..00000000000 --- a/dojo/templates_classic/dojo/engineer_metrics.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} - {% load display_tags %} -
-
-
-
-

- Engineers - -

-
-
- {% include "dojo/filter_snippet.html" with form=filtered.form %} -
-
- {% if users %} -
- {% include "dojo/paging_snippet.html" with page=users page_size=True %} -
-
- - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - {% for u in users %} - - - - - - - - - - {% endfor %} -
{% dojo_sort request 'First Name' 'first_name' %}{% dojo_sort request 'Last Name' 'last_name' %}{% dojo_sort request 'User Name' 'username' 'asc' %}{% dojo_sort request 'Email' 'email' %}{% dojo_sort request 'Last Login' 'last_login'%}{% dojo_sort request 'Active' 'is_active' %}{% dojo_sort request 'Superuser' 'is_superuser' %}
{{ u.first_name }}{{ u.last_name }}{{ u.username }}{{ u.email }}{{ u.last_login }}{% if u.is_active %} - {% else %} - {% endif %}{% if u.is_superuser %} - {% else %} - {% endif %}
-
-
- {% include "dojo/paging_snippet.html" with page=users page_size=True %} -
- {% else %} -
No Users
- {% endif %} -
-
- -{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/filter_js_snippet.html b/dojo/templates_classic/dojo/filter_js_snippet.html deleted file mode 100644 index 5afd9187818..00000000000 --- a/dojo/templates_classic/dojo/filter_js_snippet.html +++ /dev/null @@ -1,60 +0,0 @@ -{% load static %} - - \ No newline at end of file diff --git a/dojo/templates_classic/dojo/filter_snippet.html b/dojo/templates_classic/dojo/filter_snippet.html deleted file mode 100644 index 94311d469b6..00000000000 --- a/dojo/templates_classic/dojo/filter_snippet.html +++ /dev/null @@ -1,163 +0,0 @@ -{% load navigation_tags %} -{% load event_tags %} -{% load static %} -{% block add_css %} - -{% endblock %} -{% block css %} - {{ form.media.css }} -{% endblock %} -{% block js %} - {{ form.media.js }} -{% endblock %} -
-
- {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
- {% for field in form.visible_fields %} -
-
- - {% with placeholder="placeholder:"|add:field.label %} - {{ field|addcss:"class: form-control filter-form-control"|addcss:placeholder }} - {% endwith %} -
-
- {% endfor %} -
-
-
-
-
- {% if submit == 'report' %} - {% query_string_as_hidden %} - - {% else %} - - {% if clear_js %} - - Clear Filters - - {% else %} - - Clear Filters - - {% endif %} - {% if restart_link %} - - Restart - - {% endif %} - {% endif %} -
-
-
-
- -
-
- diff --git a/dojo/templates_classic/dojo/finding_groups_list.html b/dojo/templates_classic/dojo/finding_groups_list.html deleted file mode 100644 index 58ce0e7eb9b..00000000000 --- a/dojo/templates_classic/dojo/finding_groups_list.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load display_tags %} -{% load static %} -{% block content %} - {% comment %} All/Open/Closed Finding Groups {% endcomment %} - {% include "dojo/finding_groups_list_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/finding_groups_list_snippet.html b/dojo/templates_classic/dojo/finding_groups_list_snippet.html deleted file mode 100644 index 290445143b1..00000000000 --- a/dojo/templates_classic/dojo/finding_groups_list_snippet.html +++ /dev/null @@ -1,255 +0,0 @@ -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} - -{% load static %} -{% load i18n %} -{% block finding_groups_list %} -
-
-
-
-

- {% blocktrans %}{{ filter_name }} Findings Groups{% endblocktrans %} - -

-
-
- {% include "dojo/filter_snippet.html" with form=filtered.form %} -
-
- {% if finding_groups %} -
{% include "dojo/paging_snippet.html" with page=finding_groups page_size=True %}
-
- - - - {% block header %} - - - - - - - - - {% endblock %} - - - - {% for finding_group in finding_groups %} - - - - - - - - - - - {% endfor %} - -
{% dojo_sort request "Name" "name" %} - {% trans "Severity" %} - {% dojo_sort request "Findings Count" "findings_count" %}{% trans "Age" %}{% trans "SLA" %}{% trans "Status" %}{% dojo_sort request "Creator" "creator" %}{% dojo_sort request "Deadline" "sla_deadline" %}
- - {{ finding_group.name }} - - - - {{ finding_group.severity }} - - - {{ finding_group.findings_count }} - - {% if finding_group.age %} - {{ finding_group.age }} - {% else %} - {{ finding_group.age }} - {% endif %} - - {% if finding_group.sla_days_remaining %} - {{ finding_group.sla_days_remaining }} - {% else %} - {{ finding_group.sla_days_remaining }} - {% endif %} - - {% if finding_group.status %} - {{ finding_group.status }} - {% else %} - {{ finding_group.status }} - {% endif %} - - {{ finding_group.creator }} - - {% if finding_group.sla_deadline %} - {{ finding_group.sla_deadline|date:"F d, Y" }} - {% else %} - {{ finding_group.sla_deadline }} - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=finding_groups %} -
- {% else %} -
-

- {% trans "No finding groups found." %} -

-
- {% endif %} -
-
-{% endblock %} -{% block postscript %} - - - - {% include "dojo/filter_js_snippet.html" %} - {% include "dojo/snippets/selectpicker_in_dropdown.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/finding_pdf_report.html b/dojo/templates_classic/dojo/finding_pdf_report.html deleted file mode 100644 index d44a3b1092c..00000000000 --- a/dojo/templates_classic/dojo/finding_pdf_report.html +++ /dev/null @@ -1,420 +0,0 @@ -{% extends "report_base.html" %} -{% load static %} -{% load display_tags %} -{% load humanize %} - -{% load get_note_status %} -{% load get_notetype_availability %} -{% load event_tags %} -{% block content %} - {{ block.super }} -
- {% if include_table_of_contents%} -
-
-

Table of Contents for {{ product.name }}

-
-
- -
- {% endif %} - {% if include_executive_summary %} -
-
-

Executive Summary

-

- This report represents a security audit performed by the {{ team_name }} team. - It contains confidential information about the state of your network and applications. - Access to this information by unauthorized personnel may allow them to compromise your network. -

-
-
-
-
-
- Endpoint Finding Count -
-
-
-
-
-
-
-
-
-
-
- Finding Age -
-
-
-
-
-
-
-
- {% endif %} - {% if include_disclaimer%} -
- Disclaimer
-

{{ disclaimer | bleach_with_a_tags }}

-
- {% endif %} -
-
- {% if findings %} -

Findings

- {% endif %} - {% for finding in findings %} - {% ifchanged finding.severity %} -

{{ finding.severity|capfirst }}

- {% endifchanged %} -
-
-
-
- Finding {{ finding.id }}: {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
-
-
-
- - - {% block finding_header %} - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - {% endblock finding_header %} - - - {% block finding_data %} - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - {% endblock finding_data %} - -
SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWE
- - {% if finding.severity %} - {{ finding.severity }} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - -
-
-
- - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
CVSS v3
-
{{ finding.cvssv3|markdown_render }}
- {% endif %} - -
Description
-
{{ finding.description|markdown_render }}
- - {% if finding.mitigation %} -
Mitigation
-
{{ finding.mitigation|markdown_render }}
- {% endif %} - - {% if finding.get_report_requests %} -
Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
- {% for req in finding.get_report_requests %} -
Request {{forloop.counter}}
-
{{ req.get_request }}
- {% if req.get_response != "" %} -
Response {{forloop.counter}}
-
{{ req.get_response|truncatechars_html:800 }}
- {% endif %} - {% endfor %} - {% endif %} - - {% if finding.impact %} -
Impact
-
{{ finding.impact|markdown_render }}
- {% endif %} - - {% if finding.steps_to_reproduce %} -
Steps to Reproduce
-
{{ finding.steps_to_reproduce|markdown_render }}
- {% endif %} - - {% if finding.severity_justification %} -
Severity Justification
-
{{ finding.severity_justification|markdown_render }}
- {% endif %} - - {% if finding.references %} -
References
-
{{ finding.references|markdown_render }}
- {% endif %} - - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="HTML" %} - {% endif %} - - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} -
Notes
- - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
UserDateNote TypeNote
- {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
- {% endif %} - {% endwith %} - {% endif %} - {% endfor %} -
-
- {% if include_table_of_contents %} -
- {% endif %} -
-{% endblock %} -{% block js %} - {{ block.super }} - - - - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/finding_related_actions.html b/dojo/templates_classic/dojo/finding_related_actions.html deleted file mode 100644 index e1820c07ff8..00000000000 --- a/dojo/templates_classic/dojo/finding_related_actions.html +++ /dev/null @@ -1,38 +0,0 @@ -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} -{% load static %} - -{% if finding|has_object_permission:"Finding_Edit" %} - {% comment %} {{ similar_finding.related_actions }} {% endcomment %} - {% if similar_finding.related_actions.0.action == 'None'%} - None - {% else %} - - {% endif %} -{% else %} - None -{% endif %} - - diff --git a/dojo/templates_classic/dojo/finding_related_list.html b/dojo/templates_classic/dojo/finding_related_list.html deleted file mode 100644 index 69579630703..00000000000 --- a/dojo/templates_classic/dojo/finding_related_list.html +++ /dev/null @@ -1,33 +0,0 @@ -{% load navigation_tags %} -{% load static %} -
- {% include "dojo/paging_snippet.html" with page=finding_list prefix=prefix page_size=True %} -
- - - - - - - - - - - - - - - {% if system_settings.enable_jira %} - - {% endif %} - - - - {% if finding_first_related %} - {% include "dojo/finding_related_row.html" with similar_finding=finding_first_related finding_context=finding %} - {% endif %} - - {% for similar_finding in finding_list %} - {% include "dojo/finding_related_row.html" with similar_finding=similar_finding finding_context=finding %} - {% endfor %} -
RelationshipSeverityTitleDateStatusTestEngagementCWEVulnerability IdEPSS ScoreEPSS PercentileFileJIRAAction
\ No newline at end of file diff --git a/dojo/templates_classic/dojo/finding_related_row.html b/dojo/templates_classic/dojo/finding_related_row.html deleted file mode 100644 index 4cbe4858443..00000000000 --- a/dojo/templates_classic/dojo/finding_related_row.html +++ /dev/null @@ -1,74 +0,0 @@ -{% load navigation_tags %} -{% load display_tags %} -{% load static %} - - {% if similar_finding.duplicate_finding == finding_context %} - Duplicate{% if finding_context == similar_finding %}(this){% endif %} - {% elif finding_context.duplicate_finding == similar_finding %} - Original{% if finding_context == similar_finding %} (this){% endif %} - {% elif similar_finding.duplicate and similar_finding.duplicate_finding == finding_context.duplicate_finding %} - Duplicate{% if finding_context == similar_finding %}(this){% endif %} - {% elif not similar_finding.duplicate and similar_finding == finding_context %} - Original{% if finding_context == similar_finding %}(this){% endif %} - {% else %} - Similar - {% endif %} - - - {{ similar_finding.severity_display }} - - - - {{ similar_finding.title|truncatechars:80 }} - {% if similar_finding.tags %} - - {% include "dojo/snippets/tags.html" with tags=similar_finding.tags.all %} - - {% endif %} - {% with similar_finding.notes.count as note_count %} - ({{ note_count }} note{{ note_count|pluralize }}) - {% endwith %} - - {{ similar_finding.date|date }} - {{ similar_finding|finding_display_status|safe }} {{ similar_finding|import_history }} - {{ similar_finding.test }} - {{ similar_finding.test.engagement.name }} - {% if similar_finding.test.engagement.version %} - - - {{ similar_finding.test.engagement.version }} - - {% endif %} - - - {% if similar_finding.cwe > 0 %} - - {{ similar_finding.cwe }} - - {% endif %} - - {{ similar_finding.cve }} - {{ similar_finding.epss_score|format_epss }} - {{ similar_finding.epss_percentile|format_epss }} - {% if similar_finding.file_path %} - - {{ similar_finding.file_path|truncatechars_html:20 }}{% if similar_finding.line > 0 %} (Line {{ similar_finding.line }}){% endif %} - - {% else %} - - {% endif%} - - {% if system_settings.enable_jira %} - - {% if similar_finding.jira_issue %} - {{ similar_finding | jira_key }} - {% endif %} - - {% endif %} - - - {% include "dojo/finding_related_actions.html" with similar_finding=similar_finding finding_context=finding %} - - diff --git a/dojo/templates_classic/dojo/findings_list.html b/dojo/templates_classic/dojo/findings_list.html deleted file mode 100644 index c36eb3874c7..00000000000 --- a/dojo/templates_classic/dojo/findings_list.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load display_tags %} -{% load static %} -{% block content %} - {% comment %} include inherits the current context so findings, filtered and other variables {% endcomment %} - {% include "dojo/findings_list_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/findings_list_snippet.html b/dojo/templates_classic/dojo/findings_list_snippet.html deleted file mode 100644 index 4c0cf743656..00000000000 --- a/dojo/templates_classic/dojo/findings_list_snippet.html +++ /dev/null @@ -1,1229 +0,0 @@ -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} - -{% load static %} -{% load i18n %} -{% block findings_list %} -
-
-
-
-

- {% blocktrans %}{{ filter_name }} Findings{% endblocktrans %} - -

-
-
- {% include "dojo/filter_snippet.html" with form=filtered.form %} -
-
- {% if findings %} -
{% include "dojo/paging_snippet.html" with page=findings page_size=True %}
- {% if not product_tab or product_tab and product_tab.product|has_object_permission:"Finding_Edit" %} - - {% endif %} -
- - - - {% block header %} - {% if not product_tab or product_tab and product_tab.product|has_object_permission:"Finding_Edit" %} - - {% endif %} - - - - - - - - - - - - - {% if system_settings.enable_finding_sla %} - - {% endif %} - - - - {% if system_settings.enable_jira %} - {% if jira_project and product_tab or not product_tab %} - - - - {% endif %} - {% endif %} - {% if 'is_finding_groups_enabled'|system_setting_enabled %} - - {% endif %} - {% if show_product_column and product_tab is None %} - - {% endif %} - - - {% if filter_name != 'Closed' %} - - {% endif %} - {% endblock header %} - - - - {% for finding in findings %} - - {% block body %} - {% if not product_tab or product_tab and product_tab.product|has_object_permission:"Finding_Edit" %} - - {% endif %} - - - - - - - - - - - {% if filter_name == 'Closed' %} - - - {% if system_settings.enable_finding_sla %} - - {% endif %} - - - - {% if system_settings.enable_jira %} - {% if jira_project and product_tab or not product_tab %} - - - - {% endif %} - {% endif %} - {% if 'is_finding_groups_enabled'|system_setting_enabled %} - - {% endif %} - {% if show_product_column and product_tab is None %} - - {% endif %} - - - - {% if filter_name != 'Closed' %} - - {% endif %} - {% endblock body %} - - {% endfor %} - -
- - - {% dojo_sort request 'Severity' 'numerical_severity' %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - {% dojo_sort request 'Name' 'title' %} - - {% dojo_sort request 'CWE' 'cwe' %} - - {% trans "Vulnerability Id" %} - - {% trans "EPSS Score" %} - - {% trans "EPSS Percentile" %} - - {% trans "Known Exploited" %} - - {% trans "Used in Ransomware" %} - - {% trans "Date Added to KEV" %} - - {% if filter_name == 'Closed' %} - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - {% dojo_sort request 'Closed Date' 'mitigated' %} - {% else %} - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - {% dojo_sort request 'Date' 'date' %} - {% endif %} - - {% dojo_sort request 'Age' 'date' %} - - {% dojo_sort request 'SLA' 'sla_age_days' %} - - {% trans "Reporter" %} - - {% trans "Found By" %} - - {% trans "Status" %} - - {% trans "Jira" %} - - {% trans "JIRA Age" %} - - {% trans "JIRA Change" %} - - {% trans "Group" %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - {% dojo_sort request labels.ASSET_LABEL 'test__engagement__product__name' %} - - {% dojo_sort request 'Service' 'service' %} - - {% dojo_sort request 'Planned Remediation' 'planned_remediation_date' %} - - {% trans "Reviewers" %} -
-
- -
-
-
- -
-
- - {{ finding.severity_display }} - - - {% if finding.title %} - {{ finding.title|truncatechars:60 }} - {% else %} - {{ finding.id }} - {% endif %} - {% if finding.file_path %} - - {% endif %} - {% if V3_FEATURE_LOCATIONS %} - {% if finding.locations.exists %} - - {% else %} - ✕ {{ ref.location }} -
- {% endif %} - {% endfor %} - " data-placement="right" data-container="body" data-original-title="Endpoints ({{ finding.active_endpoint_count }} Active, {{ finding.mitigated_endpoint_count }} Mitigated)" title="">
- {% endif %} - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - {% if finding.endpoints.exists %} - - {% endif %} - {% endif %} - {% if finding.component_name %} - - {% endif %} - {% if finding.notes.all %} - - - ({{ finding.notes.count }}) - - {% endif %} - {% include "dojo/snippets/tags.html" with tags=finding.tags.all %} -
- {% if finding.cwe > 0 %} - - {{ finding.cwe|default:"" }} - - {% endif %} - - {% with finding|first_vulnerability_id as first_vulnerability_id %} - {% if first_vulnerability_id %} - {% if first_vulnerability_id|has_vulnerability_url %} - - {{ first_vulnerability_id }} - - {% else %} - {{ first_vulnerability_id }} - {% endif %} - {% endif %} - {% endwith %} - - {{ finding.epss_score|format_epss }} - - {{ finding.epss_percentile|format_epss }} - - {{ finding.known_exploited|yesno|capfirst }} - - {{ finding.ransomware_used|yesno|capfirst }} - - {{ finding.kev_date|date }} - - {{ finding.mitigated|date }} - {% else %} - - {{ finding.date }} - {% endif %} - - {{ finding.age }} - - {{ finding|finding_sla }} - - {% if finding.reporter.get_full_name and finding.reporter.get_full_name.strip %} - {{ finding.reporter.get_full_name }} - {% else %} - {{ finding.reporter }} - {% endif %} - - {% if finding.found_by %} - {{ finding.found_by.all|join:", " }} - {% else %} - {{ finding.test.test_type }} - {% endif %} - - {{ finding|finding_display_status|safe }} {{ finding|import_history }} - - {% if finding.has_jira_group_issue %} - {{ finding.finding_group | jira_key }} - {% elif finding.has_jira_issue %} - {{ finding | jira_key }} - {% endif %} - - {% if finding.has_jira_group_issue %} - {{ finding.finding_group | jira_creation | timesince }} - {% else %} - {{ finding | jira_creation | timesince }} - {% endif %} - - {% if finding.has_jira_group_issue %} - {{ finding.finding_group | jira_change | timesince }} - {% else %} - {{ finding | jira_change | timesince }} - {% endif %} - - {% if finding.has_finding_group %} - {{ finding.finding_group.name }} - {% endif %} - - {{ finding.test.engagement.product }} - - {% if finding.service %}{{ finding.service }}{% endif %} - - {% if finding.planned_remediation_date %}{{ finding.planned_remediation_date }}{% endif %} - - {% if finding.planned_remediation_version %}{{ finding.planned_remediation_version }}{% endif %} - - {% if finding.reviewers %} - {% for reviewer in finding.reviewers.all %} - {{reviewer.get_full_name}} - {% if not forloop.last %}
{% endif %} - {% endfor %} - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=findings page_size=True %} -
- {% else %} -
-

- {% trans "No findings found." %} -

-
- {% endif %} - -
-
-{% endblock %} -{% block postscript %} - - - - - {% include "dojo/filter_js_snippet.html" %} - {% include "dojo/snippets/selectpicker_in_dropdown.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/form_fields.html b/dojo/templates_classic/dojo/form_fields.html deleted file mode 100644 index 2f9dbd1878e..00000000000 --- a/dojo/templates_classic/dojo/form_fields.html +++ /dev/null @@ -1,101 +0,0 @@ -{% load event_tags %} -{% load display_tags %} -{% block css %} - {{ form.media.css }} -{% endblock %} -{% block js %} - {{ form.media.js }} -{% endblock %} -{% if form.non_field_errors %} - -{% endif %} -{% for field in form.hidden_fields %} - {{ field }} -{% endfor %} - -{% if form|has_required_field and SHOW_A11Y_REQUIRED_FIELDS_NOTICE %} -
-
-

- Required fields are marked with an asterisk* -

-
-
-{% endif %} - -{% for field in form.visible_fields %} -
- {% if field|is_checkbox %} -
-
- {% if field.auto_id %} - - {% endif %} - {% if field.help_text %} - - - {% endif %} - {% for error in field.errors %} - {{ error }} - {% endfor %} - -
-
- {% elif field|is_radio %} - {% if field.auto_id %} - - {% endif %} -
- {% for choice in field %} -
- -
- {% endfor %} - - {% for error in field.errors %} - {{ error }} - {% endfor %} - - {% if field.help_text %} - - - {% endif %} -
- {% else %} - {% if field.auto_id %} - - {% endif %} -
- {{ field|addcss:"class:form-control" }} -

{{ field.field.widget.attrs.message }}

- {% for error in field.errors %} - {{ error }} - {% endfor %} -
- {% endif %} -
-{% endfor %} - -{% if form.disclaimer %} -
-
Disclaimer
-
{{ form.disclaimer }}
-
-{% endif %} diff --git a/dojo/templates_classic/dojo/github.html b/dojo/templates_classic/dojo/github.html deleted file mode 100644 index 1c57d80a2b1..00000000000 --- a/dojo/templates_classic/dojo/github.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- GitHub Configuration List - -

-
- -
- {% if confs %} - -
- {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
-
- - - - - - - - - {% for conf in confs %} - - - - - {% endfor %} - -
Name
- {{ conf.configuration_name }} - - {% if "dojo.delete_github_conf"|has_configuration_permission:request %} - - - Delete - - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
- {% else %} -

No GitHub configurations found.

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/import_scan_results.html b/dojo/templates_classic/dojo/import_scan_results.html deleted file mode 100755 index b1482436d1f..00000000000 --- a/dojo/templates_classic/dojo/import_scan_results.html +++ /dev/null @@ -1,75 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% load display_tags %} -{% block add_styles %} - {{ block.super }} -.chosen-container { - width: 70% !important; -} -{% endblock %} -{% block content %} - {{ block.super }} -

Add Tests

-

- Documentation -

-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% include "dojo/form_fields.html" with form=cred_form %} - {% if jform %} -

JIRA

-
-
- -
- {% if product_tab.product.has_jira_configured %} - {{ engagement_or_product|jira_project_url }} - {% else %} - None - {% endif %} -
-
- - {% include "dojo/form_fields.html" with form=jform %} - {% endif %} -
-
- -
-
-
-
-

Upload your third party tool scan results and all the findings - will be imported automatically.

- -

DefectDojo accepts:

-
    -{% for k, v in scan_types %} -
  • {{ k }} - {{ v }}
  • -{% endfor %} -
- - {% if additional_message %} -

- Note: {{ additional_message }} -

- {% endif %} -
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/jira.html b/dojo/templates_classic/dojo/jira.html deleted file mode 100644 index 5e6ac81025a..00000000000 --- a/dojo/templates_classic/dojo/jira.html +++ /dev/null @@ -1,103 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- JIRA Instances - -

-
- -
- {% if jira_instances %} - -
- {% include "dojo/paging_snippet.html" with page=jira_instances page_size=True %} -
-
- - - - - - - - - - - {% for jira_instance in jira_instances %} - - - - - - - {% endfor %} - -
NameURLUsernameAction
- {% if "dojo.change_jira_instance"|has_configuration_permission:request %} - {{ jira_instance.configuration_name }} - {% else %} - {{ jira_instance.configuration_name }} - {% endif %} - - {{ jira_instance.url }} - - - - {{ jira_instance.username }} - - {% if "dojo.change_jira_instance"|has_configuration_permission:request %} - - - Edit - - {% endif %} - {% if "dojo.delete_jira_instance"|has_configuration_permission:request %} - - - Delete - - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=jira_instances page_size=True %} -
- {% else %} -

No JIRA instances found.

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/login.html b/dojo/templates_classic/dojo/login.html deleted file mode 100644 index 7b9870b85be..00000000000 --- a/dojo/templates_classic/dojo/login.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load get_banner %} -{% block content %} - {{ block.super }} -

{% trans "Login" %}

-
{% csrf_token %} -
- {% if "banner_enable"|get_banner_conf %} -
- {{ "banner_message"|get_banner_conf }} -
- {% endif %} - - {% include "dojo/form_fields.html" with form=form %} - - -
-
- -
- - {% if CLASSIC_AUTH_ENABLED %} -
- -
- {% if FORGOT_PASSWORD or FORGOT_USERNAME %} -
- -
- {% endif %} - {% endif %} -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/manage_files.html b/dojo/templates_classic/dojo/manage_files.html deleted file mode 100644 index 75daa0b979a..00000000000 --- a/dojo/templates_classic/dojo/manage_files.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block add_css %} - {{ block.super }} - -{% endblock %} -{% block content %} - {{ block.super }} -

Add files to {{ obj }}

- - -
- {% csrf_token %} - - {% for file_form in files_formset %} -
- {% include "dojo/form_fields.html" with form=file_form %} -
- {% endfor %} -
-
-
- {{ files_formset.management_form }} - -
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/manage_images.html b/dojo/templates_classic/dojo/manage_images.html deleted file mode 100644 index c1030ebe831..00000000000 --- a/dojo/templates_classic/dojo/manage_images.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block content %} - {{ block.super }} -

Add images to {{ finding }}

- - -
- {% csrf_token %} - - {% for pic_form in images_formset %} -
- {% include "dojo/form_fields.html" with form=pic_form %} -
- {% endfor %} -
-
-
- {{ images_formset.management_form }} - -
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/merge_findings.html b/dojo/templates_classic/dojo/merge_findings.html deleted file mode 100644 index 1374d69d2d7..00000000000 --- a/dojo/templates_classic/dojo/merge_findings.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load display_tags %} -{% load static %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

Merge Findings

-
-

-

- -
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-

-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/metrics.html b/dojo/templates_classic/dojo/metrics.html deleted file mode 100644 index 881abbef4cb..00000000000 --- a/dojo/templates_classic/dojo/metrics.html +++ /dev/null @@ -1,967 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load display_tags %} -{% load i18n %} -{% load static %} -{% block add_styles %} - {{ block.super }} - .tabs-below > .nav-tabs, - .tabs-right > .nav-tabs, - .tabs-left > .nav-tabs { - border-bottom: 0; - } - - .tab-content > .tab-pane, - .pill-content > .pill-pane { - display: none; - } - - .tab-content > .active, - .pill-content > .active { - display: block; - } - - .tabs-below > .nav-tabs { - border-top: 1px solid #ddd; - } - - .tabs-below > .nav-tabs > li { - margin-top: -1px; - margin-bottom: 0; - } - - .tabs-below > .nav-tabs > li > a { - -webkit-border-radius: 0 0 4px 4px; - -moz-border-radius: 0 0 4px 4px; - border-radius: 0 0 4px 4px; - } - - .tabs-below > .nav-tabs > li > a:hover, - .tabs-below > .nav-tabs > li > a:focus { - border-top-color: #ddd; - border-bottom-color: transparent; - } - - .tabs-below > .nav-tabs > .active > a, - .tabs-below > .nav-tabs > .active > a:hover, - .tabs-below > .nav-tabs > .active > a:focus { - border-color: transparent #ddd #ddd #ddd; - } - - .tabs-left > .nav-tabs > li, - .tabs-right > .nav-tabs > li { - float: none; - } - - .tabs-left > .nav-tabs > li > a, - .tabs-right > .nav-tabs > li > a { - min-width: 74px; - margin-right: 0; - margin-bottom: 3px; - } - - .tabs-left > .nav-tabs { - float: left; - margin-right: 19px; - border-right: 1px solid #ddd; - } - - .tabs-left > .nav-tabs > li > a { - margin-right: -1px; - -webkit-border-radius: 4px 0 0 4px; - -moz-border-radius: 4px 0 0 4px; - border-radius: 4px 0 0 4px; - } - - .tabs-left > .nav-tabs > li > a:hover, - .tabs-left > .nav-tabs > li > a:focus { - border-color: #eeeeee #dddddd #eeeeee #eeeeee; - } - - .tabs-left > .nav-tabs .active > a, - .tabs-left > .nav-tabs .active > a:hover, - .tabs-left > .nav-tabs .active > a:focus { - border-color: #ddd transparent #ddd #ddd; - *border-right-color: #ffffff; - } - - .tabs-right > .nav-tabs { - float: right; - margin-left: 19px; - border-left: 1px solid #ddd; - } - - .tabs-right > .nav-tabs > li > a { - margin-left: -1px; - -webkit-border-radius: 0 4px 4px 0; - -moz-border-radius: 0 4px 4px 0; - border-radius: 0 4px 4px 0; - } - - .tabs-right > .nav-tabs > li > a:hover, - .tabs-right > .nav-tabs > li > a:focus { - border-color: #eeeeee #eeeeee #eeeeee #dddddd; - } - - .tabs-right > .nav-tabs .active > a, - .tabs-right > .nav-tabs .active > a:hover, - .tabs-right > .nav-tabs .active > a:focus { - border-color: #ddd #ddd #ddd transparent; - *border-left-color: #ffffff; - } - - h3 { - margin-bottom: 10px; - } - - #opened_per_month_2, #active_per_month, #accepted_per_month_2, #opened_per_week_2, #accepted_per_week_2 {height: 300px} -{% endblock %} -{% block content %} - {{ block.super }} -
-
-
-
-
-

- {{ name }} - {% comment %} {% if not critical_prods %} - - {% endif %} {% endcomment %} - {% if not critical_prods %} - - {% endif %} -

-
-
- {% if form %} -
- {% include "dojo/filter_snippet.html" with form=form clear_link="/metrics/product/type" %} -
- {% endif %} -
-
-
- -
-
- {% if critical_prods %} -
- {% for c_prod in critical_prods %} -
-

{{ c_prod.name }}

- -

- {% blocktrans with name=c_prod.name health=c_prod.calc_health %} - The security health score for {{ name }} is {{ health }} percent. - {% endblocktrans %} -

-
- {% if c_prod.critical_present and c_prod.high_present %} -

{% blocktranslate with name=c_prod.name%}{{ name }} is affected by both critical and - high severity vulnerabilities.{% endblocktranslate %}

-

- - {% trans "Critical Severity Vulnerabilities" %}

-

- - {% trans "High Severity Vulnerabilities" %}

- {% elif c_prod.critical_present %} -

{% blocktranslate with name=c_prod.name%} - {{ name }} is affected by critical vulnerabilities.{% endblocktranslate %} -

-

- - {% trans "Critical Severity Vulnerabilities" %}

- {% elif c_prod.high_present %} -

{% blocktranslate with name=c_prod.name%} - {{ name }} is affected by high severity vulnerabilities.{% endblocktranslate %} -

-

- - {% trans "High Severity Vulnerabilities" %}

- {% endif %} -

- - {% trans "Full Metrics" %}

-
-
- {% endfor %} -
- {% elif name == labels.ASSET_METRICS_CRITICAL_LABEL %} -
-
-

{{ labels.ASSET_METRICS_NO_CRITICAL_ERROR_MESSAGE }}

-
-
- {% endif %} - -
- {% if opened_per_month %} -
-
-
{% trans "Open Bug Count by Month" %}
- -
- -
-

{% trans "Open bug count by month" %}

-

- {% trans "This line chart represents the number of open bugs over time, categorized by severity. - The x-axis represents months, while the y-axis represents the number of open bugs. - The table below provides the same data in text format." %} -

- - - - - - - - - - - - - {% for month in opened_per_month %} - - - - - - - - {% endfor %} - -
{% trans "Open bug count breakdown" %}
{% trans "Month" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ month.grouped_date|date:"F Y" }}{{ month.critical|default:"0" }}{{ month.high|default:"0" }}{{ month.medium|default:"0" }}{{ month.low|default:"0" }}
-
-
- -
- -
- {% endif %} - {% if active_per_month %} -
-
-
{% trans "Active Bug Count by Month" %}
- -
- -
-

{% trans "Active Bug Count by Month" %}

-

- {% trans "This graph represents the number of active bugs per month categorized by severity. - The x-axis represents months, and the y-axis represents the number of active bugs. - The table below provides the same data in text format." %} -

- - - - - - - - - - - - - {% for month in active_per_month %} - - - - - - - - {% endfor %} - -
{% trans "Active bug count breakdown" %}
{% trans "Month" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ month.grouped_date|date:"F Y" }}{{ month.critical|default:"0" }}{{ month.high|default:"0" }}{{ month.medium|default:"0" }}{{ month.low|default:"0" }}
-
-
- -
- -
- {% endif %} - {% if accepted_per_month %} -
-
-
- {% trans "Risk Accepted Bug Count by Month" %} -
- -
- -
-

{% trans "Risk accepted bug count by month" %}

-

- {% trans "This graph represents the number of risk accepted bugs per month categorized by severity. - The x-axis represents months, and the y-axis represents the number of risk-accepted bugs. - The table below provides the same data in text format." %} -

- - - - - - - - - - - - - {% for month in accepted_per_month %} - - - - - - - - {% endfor %} - -
{% trans "Risk Accepted Bug Count Breakdown" %}
{% trans "Month" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ month.grouped_date|date:"F Y" }}{{ month.critical|default:"0" }}{{ month.high|default:"0" }}{{ month.medium|default:"0" }}{{ month.low|default:"0" }}
-
-
- -
- -
- {% endif %} - {% if not critical_prods and name != labels.ASSET_METRICS_CRITICAL_LABEL %} -
-
-
- {% trans "Open Bug Count by Week" %} -
- -
- -
-

{% trans "Open bug count by week" %}

-

- {% trans "This graph represents the number of open bugs per week categorized by severity. - The x-axis represents weeks, and the y-axis represents the number of open bugs. - The table below provides the same data in text format." %} -

- - - - - - - - - - - - - {% for week in opened_per_week %} - - - - - - - - {% endfor %} - -
{% trans "Open bug count breakdown by week" %}
{% trans "Week" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ week.grouped_date|date:"W Y" }}{{ week.critical|default:"0" }}{{ week.high|default:"0" }}{{ week.medium|default:"0" }}{{ week.low|default:"0" }}
-
-
- -
- -
-
-
-
- {% trans "Risk Accepted Bug Count by Week" %} -
- -
- -
-

{% trans "Risk Accepted Bug Count by Week" %}

-

- {% trans "This graph represents the number of risk-accepted bugs per week categorized by severity. - The x-axis represents weeks, and the y-axis represents the number of accepted bugs. - The table below provides the same data in text format." %} -

- - - - - - - - - - - - - {% for week in accepted_per_week %} - - - - - - - - {% endfor %} - -
{% trans "Risk Accepted Bug Count Breakdown by Week" %}
{% trans "Week" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}
{{ week.grouped_date|date:"W Y" }}{{ week.critical|default:"0" }}{{ week.high|default:"0" }}{{ week.medium|default:"0" }}{{ week.low|default:"0" }}
-
-
- -
- -
-
-
-
-
-
-

{% trans "Metric Counts" %}

-
-
-
- -
- {% if top_ten_products %} -
- - - - - - - - - - {% for t in top_ten_products %} - - - - - - - - - {% endfor %} -
{{ labels.ASSET_LABEL }}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Total" %}
{{ t.name }} - {{ t.critical|default_if_none:0 }}{{ t.high|default_if_none:0 }}{{ t.medium|default_if_none:0 }}{{ t.low|default_if_none:0 }}{{ t.total|default_if_none:0 }}
-
- {% endif %} -
- {% if findings.count > max_findings_details %} -
- Note: {{ max_findings_details }} Findings listed of {{ findings.count }} total. -
- {% endif %} - - - - - - - - - - - - - {% for finding in findings|slice:max_findings_details %} - - - - - - - - - - - {% endfor %} -
{% trans "Team" %}{{ labels.ASSET_LABEL }}{% trans "Severity" %}{% trans "EPSS Score / Percentile" %}{% trans "Description" %}{% trans "Days
Open" %}
{% trans "Status" %}{% trans "Reporter" %}
{{ finding.test.engagement.product.prod_type.name }} - - {{ finding.test.engagement.product.name|truncatechars_html:20 }} - - {{ finding.severity_display }} - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.title|truncatechars:20 }} - {{ finding.age }}{{ finding.status }}{{ finding.reporter }}
-
-
-
-

{% trans "Opened During Period" %}

- - - - - - - - - - - - - - - - - -
{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}
{{ in_period_counts.critical }}{{ in_period_counts.high }}{{ in_period_counts.medium }}{{ in_period_counts.low }}{{ in_period_counts.info }}{{ in_period_counts.total }}
- - - - - - - - - - - {% for product in in_period_details %} - - - - - - - - - - {% endfor %} -
{{ labels.ASSET_LABEL }}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}
- - {{ product.product_name }} - - {{ product.critical }}{{ product.high }}{{ product.medium }}{{ product.low }}{{ product.info }}{{ product.total }}
-
-
-
-
-

{% trans "Accepted in Period" %}

- - - - - - - - - - - - - - - - - -
{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}
{{ accepted_in_period_counts.critical|default_if_none:0 }}{{ accepted_in_period_counts.high|default_if_none:0 }}{{ accepted_in_period_counts.medium|default_if_none:0 }}{{ accepted_in_period_counts.low|default_if_none:0 }}{{ accepted_in_period_counts.info|default_if_none:0 }}{{ accepted_in_period_counts.total|default_if_none:0 }}
- - - - - - - - - - - {% for product in accepted_in_period_details %} - - - - - - - - - - {% endfor %} -
{{ labels.ASSET_LABEL }}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}
- - {{ product.product_name }} - - {{ product.critical }}{{ product.high }}{{ product.medium }}{{ product.low }}{{ product.info }}{{ product.total }}
-
- - -
-
-
-

{% trans "Closed in Period" %}

- - - - - - - - - - - - - - - - - -
{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}
{{ closed_in_period_counts.critical }}{{ closed_in_period_counts.high }}{{ closed_in_period_counts.medium }}{{ closed_in_period_counts.low }}{{ closed_in_period_counts.info }}{{ closed_in_period_counts.total }}
- - - - - - - - - - - - {% for product in closed_in_period_details %} - - - - - - - - - - {% endfor %} -
{{ labels.ASSET_LABEL }}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}
- - {{ product.product_name }} - - {{ product.critical }}{{ product.high }}{{ product.medium }}{{ product.low }}{{ product.info }}{{ product.total }}
-
-
-
-
- - - - - - - - - - - - {% for week in opened_per_week %} - - - - - - - - - - - {% endfor %} -
{% trans "Weekly" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}{% trans "Closed*" %}
{{ week.grouped_date|date:"m-d-Y" }}{{ week.critical }}{{ week.high }}{{ week.medium }}{{ week.low }}{{ week.info }}{{ week.total }}{{ week.closed }}
-
-
- - - - - - - - - - - - {% for month in opened_per_month %} - - - - - - - - - - - {% endfor %} -
{% trans "Monthly" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}{% trans "Closed*" %}
{{ month.grouped_date|date:"m-Y" }}{{ month.critical }}{{ month.high }}{{ month.medium }}{{ month.low }}{{ month.info }}{{ month.total }}{{ month.closed }}
-

{% trans "*Closed findings may have been opened outside of requested period." %}

-
-
-
-
- - - - - - - - - - - {% for week in accepted_per_week %} - - - - - - - - - - {% endfor %} -
{% trans "By Week" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}
{{ week.grouped_date|date:"m-d-Y" }}{{ week.critical }}{{ week.high }}{{ week.medium }}{{ week.low }}{{ week.info }}{{ week.total }}
-
-
- - - - - - - - - - - {% for month in accepted_per_month %} - - - - - - - - - - {% endfor %} -
{% trans "By Month" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Total" %}
{{ month.grouped_date|date:"m-Y" }}{{ month.critical }}{{ month.high }}{{ month.medium }}{{ month.low }}{{ month.info }}{{ month.total }}
-
-
-
- - - - - - - - - - - - - - - - - - - - - -
{% trans "Days" %}{% trans "Bug Count" %}
{% trans "0 - 30 Days" %}{{ age_detail.age_under_30 }}
{% trans "31 - 60 Days" %}{{ age_detail.age_31_60 }}
{% trans "61 - 90 Days" %}{{ age_detail.age_61_90 }}
{% trans "91+ Days" %}{{ age_detail.age_90_plus }}
-
-
-
-
-
- {% endif %} - - - - -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - - - - {% include "dojo/filter_js_snippet.html" %} - - - {% block metrics %} - - {% endblock metrics %} - -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/migrate_endpoints.html b/dojo/templates_classic/dojo/migrate_endpoints.html deleted file mode 100644 index a070581f7e5..00000000000 --- a/dojo/templates_classic/dojo/migrate_endpoints.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

{{ name }}

-
-
-
- This migration will convert existing Endpoints to the new Location model. Please ensure you have a full backup of your database before proceeding. This operation may take some time depending on the number of endpoints in your system. - Please consult the documentation for more information. -
-
-
- If you would prefer to run this migration from the command line, you can do so by executing: -
python manage.py migrate_endpoints_to_locations
-
-
-
- {% csrf_token %} -
- -
-
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_cicd_infrastructure.html b/dojo/templates_classic/dojo/new_cicd_infrastructure.html deleted file mode 100644 index 6c34d1edf47..00000000000 --- a/dojo/templates_classic/dojo/new_cicd_infrastructure.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Add CI/CD Infrastructure

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_dev_env.html b/dojo/templates_classic/dojo/new_dev_env.html deleted file mode 100644 index 12083f888a7..00000000000 --- a/dojo/templates_classic/dojo/new_dev_env.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Register a new Environment

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_eng.html b/dojo/templates_classic/dojo/new_eng.html deleted file mode 100644 index 16c0dc934c3..00000000000 --- a/dojo/templates_classic/dojo/new_eng.html +++ /dev/null @@ -1,111 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load static %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

{{ title }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - - {% if jira_project_form %} -

JIRA Project

-
- {% include "dojo/form_fields.html" with form=jira_project_form %} - {% endif %} - - {% if jira_epic_form %} -

JIRA Epic

-
-
- -
- {% if engagement.has_jira_issue %} - {{ engagement | jira_issue_url }} - {% elif engagement %} - {{ engagement | jira_project_url }} - {% else %} - {{ product_tab.product | jira_project_url }} - {% endif %} -
-
- {% include "dojo/form_fields.html" with form=jira_epic_form %} - {% endif %} -
-
- - - -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/new_github.html b/dojo/templates_classic/dojo/new_github.html deleted file mode 100644 index effd51857b7..00000000000 --- a/dojo/templates_classic/dojo/new_github.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html"%} -{% block content %} - {{ block.super }} -

Add a GitHub Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=gform %} -
-
- -
-
-
-{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/new_jira.html b/dojo/templates_classic/dojo/new_jira.html deleted file mode 100644 index 6f4cb6e055e..00000000000 --- a/dojo/templates_classic/dojo/new_jira.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html"%} -{% block content %} - {{ block.super }} -

Add a JIRA Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=jform %} -
-
- -
-

- Finding severity mappings and other options can be edited after configuration is complete. -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_jira_advanced.html b/dojo/templates_classic/dojo/new_jira_advanced.html deleted file mode 100644 index 2af3a37c600..00000000000 --- a/dojo/templates_classic/dojo/new_jira_advanced.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html"%} -{% block content %} - {{ block.super }} -

Add a JIRA Configuration (Advanced)

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=jform %} -
-
- -
-
-
-{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/new_object.html b/dojo/templates_classic/dojo/new_object.html deleted file mode 100644 index 7929498e59b..00000000000 --- a/dojo/templates_classic/dojo/new_object.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html"%} -{% load static %} - -{% block content %} - {{ block.super }} -

{{ name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} -{% endblock %} diff --git a/dojo/templates_classic/dojo/new_params.html b/dojo/templates_classic/dojo/new_params.html deleted file mode 100644 index 0d4ec524efe..00000000000 --- a/dojo/templates_classic/dojo/new_params.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/new_product.html b/dojo/templates_classic/dojo/new_product.html deleted file mode 100644 index 6eba8e661e8..00000000000 --- a/dojo/templates_classic/dojo/new_product.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block add_css %} - {{ block.super }} - - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} - -{% block content %} - {{ block.super }} -
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} - {% if jform %} -

JIRA

-
- {% include "dojo/form_fields.html" with form=jform %} - {% endif %} - {% if gform %} -

Github

-
- {% include "dojo/form_fields.html" with form=gform %} - {% endif %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/new_product_authorized_users.html b/dojo/templates_classic/dojo/new_product_authorized_users.html deleted file mode 100644 index 738afe6f0f3..00000000000 --- a/dojo/templates_classic/dojo/new_product_authorized_users.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block add_styles %} - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

{{ name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_product_type.html b/dojo/templates_classic/dojo/new_product_type.html deleted file mode 100644 index d4d6225bb43..00000000000 --- a/dojo/templates_classic/dojo/new_product_type.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% load i18n %} -{% block add_css %} - {{ block.super }} - -{% endblock %} -{% block add_styles %} - {{ block.super }} - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

{{ labels.ORG_CREATE_LABEL }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} - diff --git a/dojo/templates_classic/dojo/new_product_type_authorized_users.html b/dojo/templates_classic/dojo/new_product_type_authorized_users.html deleted file mode 100644 index 9cf3f3b96c5..00000000000 --- a/dojo/templates_classic/dojo/new_product_type_authorized_users.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block add_styles %} - .chosen-container.chosen-container-multi { - width: 70% !important; - } -{% endblock %} -{% block content %} - {{ block.super }} -

{{ name }}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_regulation.html b/dojo/templates_classic/dojo/new_regulation.html deleted file mode 100644 index 3bb130f4a9d..00000000000 --- a/dojo/templates_classic/dojo/new_regulation.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Add new regulation

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_sla_config.html b/dojo/templates_classic/dojo/new_sla_config.html deleted file mode 100644 index 678aa19605b..00000000000 --- a/dojo/templates_classic/dojo/new_sla_config.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html"%} -{% block content %} - {{ block.super }} -

Add an SLA Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_tech.html b/dojo/templates_classic/dojo/new_tech.html deleted file mode 100644 index 70dff0de001..00000000000 --- a/dojo/templates_classic/dojo/new_tech.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} - {% block content %} - {{ block.super }} -

Add Technology

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
- {% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/new_test_type.html b/dojo/templates_classic/dojo/new_test_type.html deleted file mode 100644 index a39bb0cc046..00000000000 --- a/dojo/templates_classic/dojo/new_test_type.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

Register a new Test Type

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_tool_config.html b/dojo/templates_classic/dojo/new_tool_config.html deleted file mode 100644 index 95cfe2291c5..00000000000 --- a/dojo/templates_classic/dojo/new_tool_config.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html"%} -{% block content %} - {{ block.super }} -

Add a Tool Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_tool_product.html b/dojo/templates_classic/dojo/new_tool_product.html deleted file mode 100644 index 2f7fddde778..00000000000 --- a/dojo/templates_classic/dojo/new_tool_product.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html"%} -{% block content %} - {{ block.super }} -

Add Product Tool Type Configuration

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/new_tool_type.html b/dojo/templates_classic/dojo/new_tool_type.html deleted file mode 100644 index 87fe2e66e1d..00000000000 --- a/dojo/templates_classic/dojo/new_tool_type.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html"%} -{% load i18n %} - -{% block content %} - {{ block.super }} -

{% trans "Add a Tool Type Configuration" %}

-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=tform %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/note_type.html b/dojo/templates_classic/dojo/note_type.html deleted file mode 100644 index 151365532ce..00000000000 --- a/dojo/templates_classic/dojo/note_type.html +++ /dev/null @@ -1,135 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- Note Type List - -

-
-
- {% include "dojo/filter_snippet.html" with form=ntl.form %} -
-
- {% if nts %} - -
- {% include "dojo/paging_snippet.html" with page=nts page_size=True %} -
- -
- - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - - - - - {% for nt in nts %} - - - - - - - {% if "dojo.change_note_type"|has_configuration_permission:request %} - - {% endif %} - - - {% endfor %} - -
{% dojo_sort request 'Note Type' 'name' 'asc' %} Description Single/Multiple Mandatory/Optional Active/Disabled
{{ nt.name }} {{ nt.description }} - {% if nt.is_single %} - Single - {% else %} - Multiple - {% endif %} - - {% if nt.is_mandatory %} - Mandatory - {% else %} - Optional - {% endif %} - - {% if nt.is_active %} - Active - {% else %} - Disabled - {% endif %} - -
-
- - Edit Note Type - - {% if nt.is_active %} - - Disable Note Type - - {% else %} - - Enable Note Type - - {% endif %} -
-
-
-
-
- {% include "dojo/paging_snippet.html" with page=nts page_size=True %} -
- {% else %} -

No Note types found.

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/notifications.html b/dojo/templates_classic/dojo/notifications.html deleted file mode 100644 index 9a87197c35e..00000000000 --- a/dojo/templates_classic/dojo/notifications.html +++ /dev/null @@ -1,168 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% load event_tags %} -{% load i18n %} -{% block content %} - {{ block.super }} -

- {% if scope == 'system' %} - {% trans "System" %} - {% elif scope == 'personal' %} - {% trans "Personal" %} - {% else %} - {% trans "System" %} - {% endif %} - {% trans "Notification Settings" %} -

-
- {% csrf_token %} -
- {% if request.user.is_superuser %} -
- - -
-

-   -

-

-   -

-

-   -

- {% endif %} - {% if scope == 'system' %} -

- {% trans "These notification settings apply globally to all products and will be sent to all superusers." %} -

- {% elif scope == 'personal' %} -

- {% trans "These notification settings apply globally to all products that you have read access to and will be sent to you only." %} -

-

- {% trans "If you want only notifications for certain products you should disable everything here and enable notifications on those products." %} -

- {% else %} -

- {% trans "These template template" %} -

- {% endif %} - - {% for u in not_users %} -

- {{ u.username }} {{ u.applicable_notifications_count }} -

- {% for n in u.applicable_notifications %} -

- {{ n.id }} {{ n.product.id }}, {{ n.user }}, e:{{ n.engagement_added }}, t:{{ n.test_added }}, scan:{{ n.scan_added }} -

- {% endfor %} -

- {{ u.merged_notifications.scan_added }} -

- {% endfor %} - - - - - {% if 'slack' in enabled_notifications %} - - {% endif %} - {% if scope == 'system' and 'msteams' in enabled_notifications %} - - {% endif %} - {% if 'mail' in enabled_notifications %} - - {% endif %} - {% if 'webhooks' in enabled_notifications %} - - {% endif %} - - - - - {% for field in form.visible_fields %} - - {% if field.auto_id %} - - {% endif %} - {% for pk,c in field.field.widget.choices %} - {% if scope == 'system' and c in enabled_notifications %} - - {% elif scope == 'personal' and c in enabled_notifications and c != 'msteams' %} - - {% elif scope == 'template' and c in enabled_notifications and c != 'msteams' %} - - {% endif %} - {% endfor %} - - {% for error in field.errors %}{{ error }}{% endfor %} - {% endfor %} - {% block additional_forms %} - {% endblock additional_forms %} - -
{% trans "Event" %}{% trans "Slack" %}{% trans "Microsoft Teams" %}{% trans "Mail" %}{% trans "Webhooks" %}{% trans "Alert" %}
- {{ field.label }} - {% if field.field.required %}*{% endif %} - {% if field.help_text %} - - - {% endif %} - - - - - - -
-
- -
-
-
-{% endblock content%} -{% block postscript %} - {{ block.super }} - -{% endblock postscript %} diff --git a/dojo/templates_classic/dojo/paging_snippet.html b/dojo/templates_classic/dojo/paging_snippet.html deleted file mode 100644 index e4244a0744b..00000000000 --- a/dojo/templates_classic/dojo/paging_snippet.html +++ /dev/null @@ -1,53 +0,0 @@ -{% load i18n %} - -{% if page.paginator.count > 0 %} -{% load navigation_tags %} -{% with page_param=prefix|add:'page' %} -{% with page_size_param=prefix|add:'page_size' %} - - - -{% endwith %} -{% endwith %} -{% endif %} diff --git a/dojo/templates_classic/dojo/product.html b/dojo/templates_classic/dojo/product.html deleted file mode 100644 index 414281af1e8..00000000000 --- a/dojo/templates_classic/dojo/product.html +++ /dev/null @@ -1,437 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- {{ labels.ASSET_READ_LIST_LABEL }} - -

-
-
- {% include "dojo/filter_snippet.html" with form=prod_filter.form %} -
-
- {% if prod_list %} -
- {% include "dojo/paging_snippet.html" with page=prod_list page_size=True %} -
-
- - - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - - {% if system_settings.enable_jira %} - - {% endif %} - {% if system_settings.enable_github %} - - {% endif %} - - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - {% for prod in prod_list %} - - - - - - - - - - {% if system_settings.enable_jira %} - - {% endif %} - - {% if system_settings.enable_github %} - - {% endif %} - - - - - - - {% endfor %} - -
{% dojo_sort request labels.ASSET_LABEL 'name' 'asc' %}Tags Criticality MetadataEng.JiraGitHub{% dojo_sort request 'Active (Verified) Findings' 'findings_count' %} Vulnerable Hosts / Locations Contact{% dojo_sort request labels.ORG_LABEL 'prod_type__name' %}
-
- -
-
- {{ prod.name }} - {% with grade=prod|product_grade %} - {% if grade %} -
{{ grade }}
- {% endif %} - {% endwith %} -
- {% include "dojo/snippets/tags.html" with tags=prod.tags.all %} - {{ prod.business_criticality|business_criticality_icon }} - {{ prod.platform|platform_icon }} - {{ prod.lifecycle|lifecycle_icon }} - {{ prod.origin|origin_icon }} - {{ prod.external_audience|external_audience_icon }} - {{ prod.internet_accessible|internet_accessible_icon }} - - {% if prod.last_engagement_date %} - - {% else %} - - {% endif %} - - {{ prod|jira_project_tag }} - - {% for github_conf in prod.github_confs %} - {% if github_conf.git_conf is not None %} - - - {% endif %} - {% endfor %} - - {% if prod.findings_count %} - {{ prod.findings_count }} -  ({{ prod.findings_active_verified_count }}) - {% else %} - 0 - {% endif %} - - {% if V3_FEATURE_LOCATIONS %} - {{ prod.location_host_count }} / - {{ prod.location_count }} - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - {{ prod.endpoint_host_count }} / - {{ prod.endpoint_count }} - {% endif %} - - {% if prod.product_manager %} - {% if prod.product_manager != "0" %} - {{ prod.product_manager }}, Manager - {% if prod.team_manager or prod.technical_contact %}
{% endif %} - {% endif %} - {% endif %} - {% if prod.team_manager %} - {% if prod.team_manager != "0" %} - {{ prod.team_manager }}, Team Lead - {% if prod.product_manager or prod.technical_contact %}
{% endif %} - {% endif %} - {% endif %} - {% if prod.technical_contact %} - {% if prod.technical_contact != "0" %} - {{ prod.technical_contact }}, Technical - {% endif %} - {% endif %} -
{{ prod.prod_type.name }}
-
-
- {% include "dojo/paging_snippet.html" with page=prod_list page_size=True %} -
- {% else %} -

{{ labels.ASSET_NONE_FOUND_MESSAGE }}

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/product_components.html b/dojo/templates_classic/dojo/product_components.html deleted file mode 100644 index 625ad3aa52b..00000000000 --- a/dojo/templates_classic/dojo/product_components.html +++ /dev/null @@ -1,178 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% block content %} - {{ block.super }} - - -
-
-
-
-

- Components - -

-
-
- {% include "dojo/filter_snippet.html" with form=filter.form %} -
-
-
- {% include "dojo/paging_snippet.html" with page=result page_size=True %} -
-
- - - - - - - - - - - - {% for result in result %} - - - - - - - - {% endfor %} - -
NameVersionActiveDuplicateTotal
- {% if result.component_name == none%} - {{ result.component_name }} - {% else %} - {{ result.component_name }} - {% endif %} - {{result.component_version}} - {% if result.active and result.component_name == none %} - {{ result.active }} - {% elif result.active %} - {{ result.active }} - {% else %} - 0 - {% endif %} - - {% if result.duplicate and result.component_name == none %} - {{ result.duplicate }} - {% elif result.duplicate %} - {{ result.duplicate }} - {% else %} - 0 - {% endif %} - - {% if result.total and result.component_name == none %} - {{ result.total }} - {% elif result.total %} - {{ result.total }} - {% else %} - 0 - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=result page_size=True %} -
-
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% include "dojo/filter_js_snippet.html" %} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/product_endpoint_pdf_report.html b/dojo/templates_classic/dojo/product_endpoint_pdf_report.html deleted file mode 100644 index 63d91e77624..00000000000 --- a/dojo/templates_classic/dojo/product_endpoint_pdf_report.html +++ /dev/null @@ -1,565 +0,0 @@ -{% extends "report_base.html" %} -{% load static %} -{% load display_tags %} -{% load humanize %} - -{% load get_note_status %} -{% load get_notetype_availability %} -{% load event_tags %} -{% block content %} - {{ block.super }} -
- {% if include_table_of_contents%} -
-
-

Table of Contents for {{ product.name }}

-
-
- -
- {% endif %} - {% if include_executive_summary %} -
-
-

Executive Summary

-

- This report represents a security audit performed by the {{ team_name }} team. - It contains confidential information about the state of your network and applications. - Access to this information by unauthorized personnel may allow them to compromise your network. -

-
-
- {% if product %} - {% if product.engagement_set.all %} - {% for eng in product.engagement_set.all %} -

- {% if eng.name and eng.name|length > 0 %} - The {{ eng.name }} - {% else %} - An - {% endif %} - engagement ran from {{ eng.target_start|date:"SHORT_DATE_FORMAT" }} - {% if eng.target_end %} - to {{ eng.target_end|date:"SHORT_DATE_FORMAT" }}. - {% else %} - and is ongoing. - {% endif %} -

- {% if eng.test_set %} -

- The engagement also included the following tests which may be reported here: -

-
    - {% for t in eng.test_set.all %} -
  • {{ t }} - ({{ t.environment.name|default:"unknown" }}): {{ t.target_start|date:"SHORT_DATE_FORMAT" }}
  • - {% endfor %} -
- {% endif %} - {% if eng.test_strategy %} -

- The test strategy for this engagement can be viewed at - {{ eng.test_strategy }}

-

- {% endif %} - {% endfor %} - {% else %} -

No engagements found for {{ product.name }}

- {% endif %} -

Product Metrics

-

- The following graphs represents the overall state of the product. -

- {% endif %} -
-
-
-
-
Open Findings
-
-
-
-
-
-
-
-
-
-
Closed Findings
-
-
-
-
-
-
-
-
-
-
Risk Accepted Findings
-
-
-
-
-
-
- -
-
-
-
Finding Age
-
-
-
-
-
-
- -
- {% endif %} - {% if include_disclaimer%} -
- Disclaimer
-

{{ disclaimer | bleach_with_a_tags }}

-
- {% endif %} -
-
- {% if endpoints %} -

Endpoint Findings

-

- A total of {{ endpoints|length|apnumber }} endpoint{{ endpoints|length|pluralize }} with findngs - of varying severity are represented in this report. -

- {% endif %} - {% for endpoint in endpoints %} -
-
-

- Endpoint: {{ endpoint }} -

-
-
- - {% for finding in endpoint.active_annotated_findings %} - -
-
-
-
- Finding {{ finding.id }}: {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
-
-
-
- - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - -
SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWE
- - {% if finding.severity %} - {{ finding.severity }} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - -
-
-
- - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
CVSS v3
-
{{ finding.cvssv3|markdown_render }}
- {% endif %} - -
Description
-
{{ finding.description|markdown_render }}
- - {% if finding.mitigation %} -
Mitigation
-
{{ finding.mitigation|markdown_render }}
- {% endif %} - - {% if finding.get_report_requests %} -
Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
- {% for req in finding.get_report_requests %} -
Request {{forloop.counter}}
-
{{ req.get_request }}
- {% if req.get_response != "" %} -
Response {{forloop.counter}}
-
{{ req.get_response|truncatechars_html:800 }}
- {% endif %} - {% endfor %} - {% endif %} - - {% if finding.impact %} -
Impact
-
{{ finding.impact|markdown_render }}
- {% endif %} - - {% if finding.steps_to_reproduce %} -
Steps to Reproduce
-
{{ finding.steps_to_reproduce|markdown_render }}
- {% endif %} - - {% if finding.severity_justification %} -
Severity Justification
-
{{ finding.severity_justification|markdown_render }}
- {% endif %} - - {% if finding.references %} -
References
-
{{ finding.references|markdown_render }}
- {% endif %} - - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="HTML" %} - {% endif %} - - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} -
Notes
- - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
UserDateNote TypeNote
- {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
- {% endif %} - {% endwith %} - {% endif %} - {% endfor %} - {% endfor %} -
-
- {% if include_table_of_contents %} -
- {% endif %} -
-{% endblock %} -{% block js %} - {{ block.super }} - - - - - - - {% if punchcard %} - - - - - {% endif %} - {% block metrics %} - - {% endblock metrics %} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/product_metrics.html b/dojo/templates_classic/dojo/product_metrics.html deleted file mode 100644 index 95bf0c4b9a1..00000000000 --- a/dojo/templates_classic/dojo/product_metrics.html +++ /dev/null @@ -1,1300 +0,0 @@ -{% extends "base.html" %} -{% load humanize %} -{% load display_tags %} -{% load static %} -{% block add_styles %} - {{ block.super }} -{% endblock %} -{% block content %} - {{ block.super }} -
-
-
-

- Metrics Overview - -

-
-
-
- {% include "dojo/filter_snippet.html" with form=form clear_link=reset_link %} -
-
-
-
-
-
-
- - - -
-
-
-

Verified {{ view }}{{ view|pluralize }} overview

-
- -

- This graph displays the number of verified {{ view|lower }}{{ view|pluralize }} by severity. - There are {{ verified_objs }} verified findings since {{ start_date|date:"D., M. d, Y" }}: - {{ verified_objs_by_severity.Critical }} critical, - {{ verified_objs_by_severity.High }} high, - {{ verified_objs_by_severity.Medium }} medium, - {{ verified_objs_by_severity.Low }} low and - {{ verified_objs_by_severity.Info }} informational. -

-
- -
-
-
-
-
-
-
- - - -
-
-
-

Open {{ view }}{{ view|pluralize }} overview

-
- -

- This graph displays the number of open {{ view|lower }}{{ view|pluralize }} by severity. - There are {{ open_objs }} open findings since {{ start_date|date:"D., M. d, Y" }}: - {{ open_objs_by_severity.Critical }} critical, - {{ open_objs_by_severity.High }} high, - {{ open_objs_by_severity.Medium }} medium, - {{ open_objs_by_severity.Low }} low, and - {{ open_objs_by_severity.Info }} informational. -

-
- -
-
- -
-
-
-
-
- -
- {{ accepted_objs }} - Risk Accepted - {{ view }}s - -
-
-
-
-

Risk Accepted {{ view|lower }}{{ view|pluralize }} overview

-
- -

- This graph displays the number of risk accepted {{ view|lower }}{{ view|pluralize }} by severity. - There are {{ accepted_objs }} risk accepted {{view|lower}}{{ view|pluralize }} since {{ start_date|date:"D., M. d, Y" }}: - {{ accepted_objs_by_severity.Critical }} critical, - {{ accepted_objs_by_severity.High }} high, - {{ accepted_objs_by_severity.Medium }} medium, - {{ accepted_objs_by_severity.Low }} low, and - {{ accepted_objs_by_severity.Info }} informational. -

-
- -
-
-
-
-
-
-
- - - -
-
-
-

Closed {{ view|lower }}{{ view|pluralize }} overview

-
- -

- This graph displays the number of closed {{ view|lower }}{{ view|pluralize }} by severity. - There are {{ closed_objs }} closed {{ view|lower }}{{ view|pluralize }} since {{ start_date|date:"D., M. d, Y" }}: - {{ closed_objs_by_severity.Critical }} critical, - {{ closed_objs_by_severity.High }} high, - {{ closed_objs_by_severity.Medium }} medium, - {{ closed_objs_by_severity.Low }} low, and - {{ closed_objs_by_severity.Info }} informational. -

-
- -
-
-
-
-
-
- -
-
-

False positive {{ view }}{{ view|pluralize }} overview

-
- -

- This graph displays the number of false positive {{ view|lower }}{{ view|pluralize }} by severity. - There are {{ false_positive_objs }} false positive {{ view|lower }}{{ view|pluralize }} since {{ start_date|date:"D., M. d, Y" }}: - {{ false_positive_objs_by_severity.Critical }} critical, - {{ false_positive_objs_by_severity.High }} high, - {{ false_positive_objs_by_severity.Medium }} medium, - {{ false_positive_objs_by_severity.Low }} low, and - {{ false_positive_objs_by_severity.Info }} informational. -

-
- -
-
-
-
-
-
- -
-
-

Out of Scope {{ view }}{{ view|pluralize }} overview

-
- -

- This graph displays the number of out of scope {{ view|lower }}{{ view|pluralize }} by severity. - There are {{ out_of_scope_objs }} out of scope {{ view|lower }}{{ view|pluralize }} since {{ start_date|date:"D., M. d, Y" }}: - {{ out_of_scope_objs_by_severity.Critical }} Critical, - {{ out_of_scope_objs_by_severity.High }} high, - {{ out_of_scope_objs_by_severity.Medium }} medium, - {{ out_of_scope_objs_by_severity.Low }} low, and - {{ out_of_scope_objs_by_severity.Info }} informational. -

-
- -
-
-
-
-
-
-
- - - -
-
-
-

Total {{ view }}{{ view|pluralize }} overview

-
- -

- This graph displays the number of all {{ view|lower }}{{ view|pluralize }} by severity. - There are {{ all_objs }} {{ view|lower }}{{ view|pluralize }} since {{ start_date|date:"D., M. d, Y" }}: - {{ all_objs_by_severity.Critical }} Critical, - {{ all_objs_by_severity.High }} High, - {{ all_objs_by_severity.Medium }} Medium, - {{ all_objs_by_severity.Low }} Low, and - {{ all_objs_by_severity.Info }} Informational. -

-
- -
-
-
-
-
-
-
- - -
- {{ inactive_objs }} - Inactive - {{ view }}{{ view|pluralize }} - -
-
-
-
-

Inactive {{ view }}{{ view|pluralize }} overview

-
- -

- This graph displays the number of inactive {{ view|lower }}{{ view|pluralize }} by severity. - There are following inactive {{ view|lower }}{{ view|pluralize }} since {{ start_date|date:"D., M. d, Y" }}: - {{ inactive_objs_by_severity.Critical }} Critical, - {{ inactive_objs_by_severity.High }} High, - {{ inactive_objs_by_severity.Medium }} Medium, - {{ inactive_objs_by_severity.Low }} Low, and - {{ inactive_objs_by_severity.Info }} Informational. -

-
- -
-
-
-
- -
-
-
-

- Detailed Metrics -

- - -
-
-
-
-
-
- Open Day to Day by Severity - -
-
-
-

-

Loading Finding Burndown Metrics...

-
-

- Open findings burndown overview -

- -
-

- This burndown chart represents the number of open findings over time, categorized by severity. - The x-axis represents days, while the y-axis represents the number of findings. - Each severity level is color-coded: critical (red), high (orange), medium (yellow), - low (green), and informational (blue). -

-
- - - - - - - - - - - - - - -
Open findings burndown overview
DateCriticalHighMediumLowInformational
-
- -
-
- -
-
-
- Open, Closed, and Risk Accepted Week to Week - -
-
-

Open, Closed, and Risk Accepted Findings Week to Week Overview

- -
-

- This chart shows the weekly count of open, closed, and risk-accepted findings. - The x-axis represents the weeks, while the y-axis represents the number of findings. - Opened findings are shown in red. Closed findings are shown in orange. - Risk accepted findings are shown in purple. The table below provides the same data in text format. -

-
- - - - - - - - - - - - {% for week, values in open_close_weekly.items %} - - - - - - - {% endfor %} - -
Weekly findings breakdown
WeekOpen FindingsClosed FindingsRisk Accepted Findings
{{ values.week|safe }}{{ values.open }}{{ values.closed }}{{ values.accepted }}
- -
- -
-
- -
-
-
- Week to Week by Severity - -
-
-

Weekly Findings by Severity Overview

- -
-
-

- This chart represents the number of findings per week categorized by severity. - The x-axis represents the weeks, while the y-axis represents the number of findings. - Critical findings are shown in red, high findings are shown in orange, medium findings - are shown in yellow, Low findings are shown in blue, informational findings - are shown in gray. The table below provides the same data in text format. -

-
- - - - - - - - - - - - - - {% for week, values in severity_weekly.items %} - - - - - - - - - {% endfor %} - -
Weekly findings by severity breakdown
WeekCriticalHighMediumLowInformational
{{ values.week|safe }}{{ values.critical }}{{ values.high }}{{ values.medium }}{{ values.low }}{{ values.info }}
- -
-
- -
-
-
- Critical Week to Week - -
-
-

- Weekly critical findings overview -

- -
-
-

- This chart represents the number of critical findings per week. - The x-axis represents the weeks, while the y-axis represents the number of findings. - Critical findings are displayed in red. The table below provides the same data in text format. -

-
-

- The following table contains a breakdown of critical findings per week. -

- - - - - - - - - - {% for week, values in critical_weekly.items %} - - - - - {% endfor %} - -
Weekly critical findings breakdown
WeekCritical Findings
{{ values.week|safe }}{{ values.count }}
- -
-
- -
-
-
- High Week to Week - -
-
-

Weekly High Severity Findings Overview

- -
- -
-
-

- This chart represents the number of high-severity findings per week. - The x-axis represents the weeks, while the y-axis represents the number of findings. - High-severity findings are displayed in orange. - The table below provides the same data in text format. -

-
- - - - - - - - - - {% for week, values in high_weekly.items %} - - - - - {% endfor %} - -
Weekly High Severity Findings Breakdown
WeekHigh severity findings
{{ values.week|safe }}{{ values.count }}
-
- -
-
-
- Medium Week to Week - -
-
-

- Weekly medium severity findings Overview -

- -
-

- This chart represents the number of medium severity findings per week. - The x-axis represents the weeks, while the y-axis represents the number of findings. - Medium severity findings are displayed in yellow. - The table below provides the same data in text format. -

-
- - - - - - - - - - {% for week, values in medium_weekly.items %} - - - - - {% endfor %} - -
Weekly medium severity findings breakdown
WeekMedium Severity Findings
{{ values.week|safe }}{{ values.count }}
-
- -
-
- -
-
-
- Finding Age -
-
-

- Finding Age Overview -

- -
-

- This chart represents the distribution of open findings by their age. - The x-axis represents the age of findings. - The y-axis represents the number of open findings. - The table below provides the data in text format. -

-
- - - - - - - - - - {% for age, count in open_objs_by_age.items %} - - - - - {% endfor %} - -
Finding Age Breakdown
Age in daysOpen findings
{{ age }}{{ count }}
-
- - -
-
- -
-
-
- Weekly activity of findings reported - -
-
-

- Weekly activity of reported findings overview -

- -
-

- This chart represents the weekly activity of findings reported. - The x-axis represents the days of the week, while the y-axis represents the week number. - The size of each dot indicates the number of findings reported on that day. - The table below provides the same data in text format. -

-
- - - - - - - - - - - - - - - - {% for week, values in punchcard.items %} - - - - - - - - - - - {% endfor %} - -
Weekly Findings Activity Breakdown
WeekMondayTuesdayWednesdayThursdayFridaySaturdaySunday
{{ week }}{{ values.Sunday|default:"0" }}{{ values.Monday|default:"0" }}{{ values.Tuesday|default:"0" }}{{ values.Wednesday|default:"0" }}{{ values.Thursday|default:"0" }}{{ values.Friday|default:"0" }}{{ values.Saturday|default:"0" }}
- -
- -
-
- -
-
-
- Findings by Test Type - -
-
-

- Findings by test type overview -

- -
-

- This chart represents the number of findings categorized by test type. - The x-axis represents different test types. - The y-axis represents the number of findings for each test type. - The table below provides the data in text format. -

-
- - - - - - - - - - {% for test_type, count in test_data.items %} - - - - - {% endfor %} - -
Findings by Test Type Breakdown
Test TypeFindings Count
{{ test_type }}{{ count }}
-
- -
-
- -
-
-
- Open CWE Vulnerabilities -
-
-

- Open CWE vulnerabilities overview -

- -
-

- This chart represents open CWE vulnerabilities identified in findings. - The x-axis represents different CWE categories. - The y-axis represents the number of findings associated with each CWE. - The table below provides the data in text format. -

-
- - - - - - - - - - {% for cwe, count in open_vulnerabilities.items %} - - - - - {% endfor %} - -
Open CWE Vulnerabilities Breakdown
CWE categoryNumber of findings
{{ cwe }}{{ count }}
-
- -
-
- -
-
-
- Total CWE Vulnerabilities -
-
-

- Total CWE vulnerabilities overview -

- -
-

- This chart represents the total CWE vulnerabilities identified across all findings. - The x-axis represents different CWE categories. - The y-axis represents the number of findings associated with each CWE. - The table below provides the same data in text format. -

-
- - - - - - - - - - -
Total CWE Vulnerabilities Breakdown
CWE categoryNumber of findings
-
- -
-
-
-
- -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - - - {% include "dojo/filter_js_snippet.html" %} - {% if punchcard %} - - - - {% endif %} - - - {% block metrics %} - - {% endblock metrics %} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/product_pdf_report.html b/dojo/templates_classic/dojo/product_pdf_report.html deleted file mode 100644 index 3580801dfbe..00000000000 --- a/dojo/templates_classic/dojo/product_pdf_report.html +++ /dev/null @@ -1,582 +0,0 @@ -{% extends "report_base.html" %} -{% load static %} -{% load display_tags %} -{% load humanize %} -{% load event_tags %} - -{% load get_note_status %} -{% load get_notetype_availability %} -{% block content %} - {{ block.super }} -
-
-
-

Product Security Report for {{ product.name }}

-

Generated: {% display_date %}

-
-
- {% if include_table_of_contents%} -
-
-

Table of Contents for {{ product.name }}

-
-
- -
- {% endif %} - {% if include_executive_summary %} -
-
-

Executive Summary for {{ product.name }}

-

Assessment Details

- Assessed by: {% if product.engagement_set.all.0.lead.first_name|length > 0 %} {{ product.engagement_set.all.0.lead.first_name }} {{ product.engagement_set.all.0.lead.last_name }} {%else%} product.engagement_set.all.0.lead {% endif %}
- Scope: {{ product.name }}
-
-
- {% if product %} - {% if product.engagement_set.all %} -

Engagement Details

- {% for eng in product.engagement_set.all %} -

- {% if eng.name and eng.name|length > 0 %} - The {{ eng.name }} - {% else %} - An - {% endif %} - engagement ran from {{ eng.target_start|date:"SHORT_DATE_FORMAT" }} - {% if eng.target_end %} - to {{ eng.target_end|date:"SHORT_DATE_FORMAT" }}. - {% else %} - and is ongoing. - {% endif %} -

- {% if eng.test_set %} -

- The engagement included the following tests: -

-
    - {% for t in eng.test_set.all %} -
  • {{ t }} - ({{ t.environment.name|default:"unknown" }}): {{ t.target_start|date:"SHORT_DATE_FORMAT" }}
  • - {% endfor %} -
- {% endif %} - {% if eng.test_strategy %} -

- The test strategy for this engagement can be viewed at - {{ eng.test_strategy }}

-

- {% endif %} - {% endfor %} - {% else %} -

No engagements found for {{ product.name }}

- {% endif %} -
-
-
-
-
-
- Endpoints -
-
-
-
- {% colgroup endpoints into 2 cols as grouped_items %} - - {% for row in grouped_items %} - - {% for item in row %} - - {% endfor %} - - {% endfor %} -
{% if item %} - {{ item }}{% endif %} -
-
-
-
-
-

Product Metrics

- - {% endif %} -
- -
-

- A total of {{ findings|length|apnumber }} finding{{ findings|length|pluralize }} of varying - severity are - represented in this report. -

-
-
-
-
-
Findings
-
-
-
-
-
-
-
-
-
-
Finding Age
-
-
-
-
-
-
-
-

- This report represents a security audit performed by the {{ team_name }} team. - It contains confidential information about the state of your network and applications. - Access to this information by unauthorized personnel may allow them to compromise your network. -

-
-
- {% endif %} - {% if include_disclaimer%} -
- Disclaimer
-

{{ disclaimer | bleach_with_a_tags }}

-
- {% endif %} -
-
- {% if engagement.test_set.all %} -

Test Notes

-

- {% for test in engagement.test_set.all %} - {% if test.notes.all %} - {% for note in test.notes.all %} - {% if not note.private %} - {{ note.author }} - {{ note.date }} - {{ note }}
- {% endif %} - {% endfor %} - {% endif %} - {% endfor %} -

- {% endif %} - {% if engagement.risk_acceptance.count > 0 %} -

Risk Accepted Findings

- - - - - - - {% for risk in engagement.risk_acceptance.all %} - {% for finding in risk.accepted_findings.all %} - - - - - - {% endfor %} - {% endfor %} -
- Name - - Date - - Severity -
{{ finding.title }}{{ finding.date }}{{ finding.severity }}
- {% endif %} -
-
-
-
- {% if findings %} -

Findings

- {% endif %} - {% for finding in findings %} - {% ifchanged finding.severity %} -

{{ finding.severity|capfirst }}

- {% endifchanged %} -
-
-
-
- Finding {{ forloop.counter }}: {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
-
-
-
- - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - -
SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWEDojo ID
- - {% if finding.severity %} - {{ finding.severity }} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - - {{ finding.id }}
-
-
- - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
CVSS v3
-
{{ finding.cvssv3|markdown_render }}
- {% endif %} - -
Description
-
{{ finding.description|markdown_render }}
- - {% if finding.mitigation %} -
Mitigation
-
{{ finding.mitigation|markdown_render }}
- {% endif %} - - {% if finding.get_report_requests %} -
Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
- {% for req in finding.get_report_requests %} -
Request {{forloop.counter}}
-
{{ req.get_request }}
- {% if req.get_response != "" %} -
Response {{forloop.counter}}
-
{{ req.get_response|truncatechars_html:800 }}
- {% endif %} - {% endfor %} - {% endif %} - - {% if finding.impact %} -
Impact
-
{{ finding.impact|markdown_render }}
- {% endif %} - - {% if finding.steps_to_reproduce %} -
Steps to Reproduce
-
{{ finding.steps_to_reproduce|markdown_render }}
- {% endif %} - - {% if finding.severity_justification %} -
Severity Justification
-
{{ finding.severity_justification|markdown_render }}
- {% endif %} - - {% if finding.references %} -
References
-
{{ finding.references|markdown_render }}
- {% endif %} - - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="HTML" %} - {% endif %} - - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} -
Notes
- - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
UserDateNote TypeNote
- {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
- {% endif %} - {% endwith %} - {% endif %} - {% endfor %} -
-
- {% if include_table_of_contents %} -
- {% endif %} -
-{% endblock %} -{% block js %} - {{ block.super }} - - - - - - - {% if punchcard %} - - - - - {% endif %} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/product_type.html b/dojo/templates_classic/dojo/product_type.html deleted file mode 100644 index 75b34122307..00000000000 --- a/dojo/templates_classic/dojo/product_type.html +++ /dev/null @@ -1,158 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load authorization_tags %} -{% load display_tags %} -{% load i18n %} - -{% block content %} - {{ block.super }} -
-
-
-
-

- {{ labels.ORG_LABEL }} - -

-
-
- {% include "dojo/filter_snippet.html" with form=ptl.form %} -
-
- {% if pts %} - -
- {% include "dojo/paging_snippet.html" with page=pts page_size=True %} -
- -
- - - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - - - - - {% for pt in pts %} - - - - - - - - - {% endfor %} - -
{% dojo_sort request labels.ORG_LABEL 'name' 'asc' %}{{ labels.ASSET_COUNT_LABEL }}{% trans "Active (Verified) findings" %}{{ labels.ORG_CRITICAL_PRODUCT_LABEL }}{{ labels.ORG_KEY_PRODUCT_LABEL }}
- - {{ pt.name }} {{ pt.prod_count }} - {{ pt.active_findings_count }} -  ({{ pt.active_verified_findings_count }}) - - {% if pt.critical_product %} - - {% else %} - - {% endif %} - - {% if pt.key_product %} - - {% else %} - - {% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=pts page_size=True %} -
- {% else %} -

{{ labels.ORG_NONE_FOUND_MESSAGE }}

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/product_type_pdf_report.html b/dojo/templates_classic/dojo/product_type_pdf_report.html deleted file mode 100644 index 3c5234e6997..00000000000 --- a/dojo/templates_classic/dojo/product_type_pdf_report.html +++ /dev/null @@ -1,465 +0,0 @@ -{% extends "report_base.html" %} -{% load static %} -{% load display_tags %} -{% load humanize %} - -{% load get_note_status %} -{% load get_notetype_availability %} -{% load event_tags %} -{% block content %} - {{ block.super }} -
- {% if include_table_of_contents%} -
-
-

Table of Contents for {{ product_type.name }}

-
-
- -
- {% endif %} - {% if include_executive_summary %} -
-
-

Executive Summary

-

- This report represents a security audit performed by the {{ team_name }} team. - It contains confidential information about the state of your network and applications. - Access to this information by unauthorized personnel may allow them to compromise your network. -

-
-
- {% if product_type %} - {% for prod in context.products %} -

{{ prod.name }}

- {% if prod.engagement_set.all %} - {% for eng in prod.engagement_set.all %} - {% if eng in context.engagements %} -

- {% if eng.name and eng.name|length > 0 %} - The {{ eng.name }} - {% else %} - An - {% endif %} - engagement ran from {{ eng.target_start|date:"SHORT_DATE_FORMAT" }} - {% if eng.target_end %} - to {{ eng.target_end|date:"SHORT_DATE_FORMAT" }}. - {% else %} - and is ongoing. - {% endif %} -

- {% if eng.test_set %} -

- The engagement also included the following tests which may be reported - here: -

-
    - {% for t in eng.test_set.all %} - {% if t in context.tests %} -
  • {{ t }} - ({{ t.environment.name|default:"unknown" }}): - {{ t.target_start|date:"SHORT_DATE_FORMAT" }} -
  • - {% endif %} - {% endfor %} -
- {% endif %} - {% if eng.test_strategy %} -

- The test strategy for this engagement can be viewed at - {{ eng.test_strategy }}

-

- {% endif %} - {% endif %} - {% endfor %} - {% else %} -

No engagements found for {{ prod.name }}

- {% endif %} - {% endfor %} - {% endif %} -

- A total of {{ findings|length|apnumber }} finding{{ findings|length|pluralize }} of varying - severity are represented in this report. -

-
-
-
-
-

- Engagement Finding Count -

-
-
-
-
-
-
-
-
-
-

- Opened bug count by month -

-
- -
-
-
- -
- -
-
- {% endif %} - {% if include_disclaimer%} -
- Disclaimer
-

{{ disclaimer | bleach_with_a_tags }}

-
- {% endif %} -
-
- {% for prod in context.products %} - {% if prod|product_findings:findings %} -

Findings in {{ prod.name}}

- {% endif %} - {% for finding in prod|product_findings:findings %} - {% ifchanged finding.severity %} -

{{ finding.severity|capfirst }}

- {% endifchanged %} -
-
-
-
- Finding {{ finding.id }}: {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
-
-
-
- - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - -
SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWE
- - {% if finding.severity %} - {{ finding.severity }} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - -
-
-
- - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
CVSS v3
-
{{ finding.cvssv3|markdown_render }}
- {% endif %} - -
Description
-
{{ finding.description|markdown_render }}
- - {% if finding.mitigation %} -
Mitigation
-
{{ finding.mitigation|markdown_render }}
- {% endif %} - - {% if finding.get_report_requests %} -
Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
- {% for req in finding.get_report_requests %} -
Request {{forloop.counter}}
-
{{ req.get_request }}
- {% if req.get_response != "" %} -
Response {{forloop.counter}}
-
{{ req.get_response|truncatechars_html:800 }}
- {% endif %} - {% endfor %} - {% endif %} - - {% if finding.impact %} -
Impact
-
{{ finding.impact|markdown_render }}
- {% endif %} - - {% if finding.steps_to_reproduce %} -
Steps to Reproduce
-
{{ finding.steps_to_reproduce|markdown_render }}
- {% endif %} - - {% if finding.severity_justification %} -
Severity Justification
-
{{ finding.severity_justification|markdown_render }}
- {% endif %} - - {% if finding.references %} -
References
-
{{ finding.references|markdown_render }}
- {% endif %} - - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding format="HTML" %} - {% endif %} - - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} - {% with notes_with_type=notes|get_notetype_notes_count %} -
Notes
- - - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - -
UserDateNote TypeNote
- {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
- {% endwith %} - {% endif %} - {% endwith %} - {% endif %} - {% endfor %} - {% endfor %} -
-
- {% if include_table_of_contents %} -
- {% endif %} -
-{% endblock %} -{% block js %} - {{ block.super }} - - - - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/profile.html b/dojo/templates_classic/dojo/profile.html deleted file mode 100644 index 215b577182b..00000000000 --- a/dojo/templates_classic/dojo/profile.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load i18n %} -{% block content %} - {{ block.super }} -

{% blocktrans with full_name=user.get_full_name %} User Profile - {{ full_name }}{% endblocktrans %}

-
-
-
{% csrf_token %} -
- {% trans "Default Information" %} - {% include "dojo/form_fields.html" with form=form %} -
-
- {% trans "Additional Contact Information" %} - {% include "dojo/form_fields.html" with form=contact_form %} -
-
-
- -
-
-
-
-
-
-
    -
  • {% trans "Last Login:" %} {{ user.last_login }}
  • -
  • {% trans "Date Joined:" %} {{ user.date_joined }}
  • -
-
- {% if user.has_usable_password %} - {% trans "Change Password" %} - {% endif %} - {% if API_TOKENS_ENABLED %} - {% trans "API Key" %} - {% endif %} -
-
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/pt_counts.html b/dojo/templates_classic/dojo/pt_counts.html deleted file mode 100644 index 1be1e2e25a1..00000000000 --- a/dojo/templates_classic/dojo/pt_counts.html +++ /dev/null @@ -1,280 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load display_tags %} -{% block add_styles %} - {{ block.super }} - form.biweekly-metrics p { display: inline-block;} - form.biweekly-metrics input {border: 1px solid #ccc;border-radius: 4px;padding: 3px 6px;} - form.biweekly-metrics select {border-radius: 4px;background-color: #fff;background-image: none;border: 1px solid - #ccc;height: 27px;} - .errorlist {color: #a94442; display: inline-block;} -{% endblock %} -{% block content %} - {{ block.super }} - -
- {{ form.as_p }} - -
-
- {% if pt %} -

{% blocktrans with start_date=start_date.date end_date=end_date.date%}Finding Information For Period of {{ start_date }} - {{ end_date }} - {% endblocktrans %}

-

{{ pt.name }}

- {% if pt|class_name == "Product_Type" %} - [{% trans "View Details" %}] - {% elif pt|class_name == "Tagulous_Product_tags" %} - [{% trans "View Details" %}] - {% endif %} -
-
-

{% trans "Total Security Bug Count In Period" %}

-
- - - - - - - - - - - - - - - - - - - -
{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Total" %}
- {{ overall_in_pt.S0|default_if_none:0 }} - - {{ overall_in_pt.S1|default_if_none:0 }} - - {{ overall_in_pt.S2|default_if_none:0 }} - - {{ overall_in_pt.S3|default_if_none:0 }} - - {{ overall_in_pt.Total|default_if_none:0 }} -
-
- -
-
-
-

{% trans "Total Security Bugs Opened In Period" %}

-
- - - - - - - - - - - - - - - - - - - -
{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Total" %}
- {{ opened_in_period.S0|default_if_none:0 }} - - {{ opened_in_period.S1|default_if_none:0 }} - - {{ opened_in_period.S2|default_if_none:0 }} - - {{ opened_in_period.S3|default_if_none:0 }} - - {{ opened_in_period.Total|default_if_none:0 }} -
-
-
-
-
-

{% trans "Total Security Bugs Closed In Period" %}

-
- - - - - - - - - - - - - - - - - - - -
{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Total" %}
- {{ closed_in_period.S0|default_if_none:0 }} - - {{ closed_in_period.S1|default_if_none:0 }} - - {{ closed_in_period.S2|default_if_none:0 }} - - {{ closed_in_period.S3|default_if_none:0 }} - - {{ closed_in_period.Total|default_if_none:0 }} -
-
- -
-
-
-

{% trans "Trending Total Bug Count By Month" %}

-
- - - - - - - - - - - - - - - - - - - - {% for to in trending_opened %} - - - - - - - - - - - {% endfor %} - -
{% trans "Month" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Opened in Month" %}{% trans "Open to Date" %}{% trans "Total Closed" %}*
*{% trans "Closed findings may have been opened outside of requested period." %}
{{ to.start_date.date|date:"M-Y" }} - {{ to.S0|default_if_none:0 }} - - {{ to.S1|default_if_none:0 }} - - {{ to.S2|default_if_none:0 }} - - {{ to.S3|default_if_none:0 }} - - {{ to.Total|default_if_none:0 }} - - {{ to.to_date_total|default_if_none:0 }} - - {{ to.closed|default_if_none:0 }} -
-
- -
-
-
-

{% trans "Top 10 By Bug Severity" %}

-
- - - - - - - - - - - - - {% for p in top_ten %} - - - - - - - - - {% endfor %} - -
{{ labels.ASSET_LABEL }}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Total" %}
- {{ p.name }} - - {{ p.critical|default_if_none:0 }} - - {{ p.high|default_if_none:0 }} - - {{ p.medium|default_if_none:0 }} - - {{ p.low|default_if_none:0 }} - - {{ p.total|default_if_none:0 }} -
-
-
-
-
-

{% blocktrans %}{{ pt }} Open Findings{% endblocktrans %}

-
- - - - - - - - - - - - - - {% for finding in all_current_in_pt %} - - - - - - - - - - {% endfor %} - -
{% trans "No." %}{% trans "Name" %}{% trans "Date" %}{% trans "Severity" %}{% trans "EPSS Score / Percentile" %}{% trans "Age" %}{{ labels.ASSET_LABEL }}
{{ forloop.counter }}{{ finding.title }} - {{ finding.date }}{% if finding.severity == "Critical" or finding.severity == "High" %} -

- {% else %}

{% endif %}{{ finding.severity_display }}

- {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.age }}{{ finding.test.engagement.product }} -
-
- {% endif %} - -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/regulations.html b/dojo/templates_classic/dojo/regulations.html deleted file mode 100644 index 8b06f52d697..00000000000 --- a/dojo/templates_classic/dojo/regulations.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- Regulations - {% if "dojo.add_regulation"|has_configuration_permission:request %} - - {% endif %} -

-
- -
- {% if confs %} - -
- {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
-
- - - - - - - - - - - - - {% for conf in confs %} - - - - - - - - - - {% endfor %} - -
RegulationAcronymJurisdictionCategoryReferenceDescription
- {% if "dojo.change_regulation"|has_configuration_permission:request %} - {{ conf.name }} - {% else %} - {{ conf.name }} - {% endif %} - - {{ conf.acronym }} - - {{ conf.jurisdiction }} - - {{ conf.category }} - - {{ conf.reference }} - - {{ conf.description }} -
-
-
- {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
- {% else %} -

No tool type configurations found.

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/regulations_config.html b/dojo/templates_classic/dojo/regulations_config.html deleted file mode 100644 index 203fbd5b527..00000000000 --- a/dojo/templates_classic/dojo/regulations_config.html +++ /dev/null @@ -1,70 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- Tool Type Configuration - -

-
- -
- {% if confs %} - -
- {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
-
- - - - - - - - - {% for conf in confs %} - - - - - {% endfor %} - -
Tool Type CategoryDescription
- {{ conf.name }} - - {{ conf.description }} -
-
-
- {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
- {% else %} -

No tool type configurations found.

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/remediation_date.html b/dojo/templates_classic/dojo/remediation_date.html deleted file mode 100644 index 2a0aebea26e..00000000000 --- a/dojo/templates_classic/dojo/remediation_date.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% block content %} - {{ block.super }} -

Add or Edit a Planned Remediation Date

-

{{ finding.title }}

-

Please provide the date the Finding will be remediated in the environment it was discovered.

- -
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- - - -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/report_builder.html b/dojo/templates_classic/dojo/report_builder.html deleted file mode 100644 index becafb99be2..00000000000 --- a/dojo/templates_classic/dojo/report_builder.html +++ /dev/null @@ -1,407 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% load display_tags %} -{% block content %} - {{ block.super }} - -
-
-
-
-
-

Report Format

-
-
-
-
    - {% for widget in in_use_widgets %} -
  • - {{ widget.get_option_form }} -
  • - {% endfor %} -
-
- -
-
-
-
-
-

Available Widgets

-
-
-
    - {% for widget in available_widgets %} -
  • - {{ widget.get_option_form }} -
  • - {% endfor %} -
-
-
-
- -
-{% endblock %} -{% block postscript %} - {{ block.super }} - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/report_cover_page.html b/dojo/templates_classic/dojo/report_cover_page.html deleted file mode 100644 index 0130d08f845..00000000000 --- a/dojo/templates_classic/dojo/report_cover_page.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends "report_base.html" %} -{% load static %} -{% block content %} - {{ block.super }} -
-
 
-
-

- DefectDojo Logo -

- -

{{ report_title }}

- -

{{ report_subtitle }}

-

- {{ report_info }} -

-
-
-{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/report_endpoints.html b/dojo/templates_classic/dojo/report_endpoints.html deleted file mode 100644 index 5bdaa3904e9..00000000000 --- a/dojo/templates_classic/dojo/report_endpoints.html +++ /dev/null @@ -1,64 +0,0 @@ -{% load navigation_tags %} -
-
-
-
Vulnerable Endpoints
- -
-
-
-
-
-
Filters
-
-
- {% if extra_help %} - - {% endif %} - {% include "dojo/report_filter_snippet.html" with form=filtered.form title=title%} -
-
- - {% if endpoints %} -
- {% include "dojo/paging_snippet.html" with page=endpoints page_size=False %} -
-
- - - - {% if V3_FEATURE_LOCATIONS %} - - - {% else %} - - - - {% endif %} - - - {% for e in endpoints %} - - {% if V3_FEATURE_LOCATIONS %} - - - {% else %} - - - - {% endif %} - - {% endfor %} - -
EndpointOpen FindingsEndpointProductOpen Findings
{{ e|truncatechars_html:70 }}{{ e.findings.count }}{{ e|truncatechars_html:70 }}{{ e.product }}{{ e.findings.count }}
-
-
- {% include "dojo/paging_snippet.html" with page=endpoints page_size=False %} -
- - {% endif %} -
-
diff --git a/dojo/templates_classic/dojo/report_filter_snippet.html b/dojo/templates_classic/dojo/report_filter_snippet.html deleted file mode 100644 index 80185444576..00000000000 --- a/dojo/templates_classic/dojo/report_filter_snippet.html +++ /dev/null @@ -1,34 +0,0 @@ -{% load navigation_tags %} -{% load static %} -{% block add_css %} - -{% endblock %} -
-
- {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} - {% for field in form.visible_fields %} -
- {{ field.errors }} - {{ field.label_tag }} {{ field }} -
- {% endfor %} - -
- -   - {% if clear_link %} - [Clear Filters] - {% else %} - [Clear Filters] - {% endif %} -
- -
- -
- - diff --git a/dojo/templates_classic/dojo/report_findings.html b/dojo/templates_classic/dojo/report_findings.html deleted file mode 100644 index edcb9f67d37..00000000000 --- a/dojo/templates_classic/dojo/report_findings.html +++ /dev/null @@ -1,71 +0,0 @@ -{% load display_tags %} -{% load navigation_tags %} -
-
-
-
Findings
- -
-
-
-
-
-
Filters
-
-
- {% if extra_help %} - - {% endif %} - {% include "dojo/report_filter_snippet.html" with form=filtered.form title=title%} -
-
- - {% if findings %} -
- {% include "dojo/paging_snippet.html" with page=findings page_size=False %} -
-
- - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - {% for finding in findings %} - - - - - - - - - - {% endfor %} - -
{% dojo_sort request 'Name' 'title' %}{% dojo_sort request 'Date' 'date' %}{% dojo_sort request 'Severity' 'numerical_severity' 'asc' %}{% dojo_sort request 'EPSS Score' 'epss_score' %}{% dojo_sort request 'EPSS Percentile' 'epss_percentile' %}Status{% dojo_sort request asset_label 'test__engagement__product__name' %}
- {{ finding.title }} - {% include "dojo/snippets/tags.html" with tags=finding.tags.all %} - {{ finding.date }}{{ finding.severity }}{{ finding.epss_score|format_epss }}{{ finding.epss_percentile|format_epss }}{{ finding.status }}{{ finding.test.engagement.product }}
-
-
- {% include "dojo/paging_snippet.html" with page=findings page_size=False %} -
- - {% endif %} -
-
diff --git a/dojo/templates_classic/dojo/report_widget.html b/dojo/templates_classic/dojo/report_widget.html deleted file mode 100644 index 1a2254c92ea..00000000000 --- a/dojo/templates_classic/dojo/report_widget.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
-
-
{{ title }}
- -
-
-
- {% if extra_help %} - - {% endif %} -
-
- {% include "dojo/form_fields.html" with form=form %} -
-
-
-
diff --git a/dojo/templates_classic/dojo/request_endpoint_report.html b/dojo/templates_classic/dojo/request_endpoint_report.html deleted file mode 100644 index fa75b3ef0a6..00000000000 --- a/dojo/templates_classic/dojo/request_endpoint_report.html +++ /dev/null @@ -1,97 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load humanize %} -{% block content %} - {{ block.super }} -
-
-
-
-

- Generate Product Endpoint Report - -

-
-
-
Filters
-
- - {% include "dojo/filter_snippet.html" with form=filtered.form %} - -
-
- - -
Report Options
-
- {% include "dojo/filter_snippet.html" with form=report_form submit='report' %} -
-
-
-
-
-
-
- {% if filtered %} -
- {% include "dojo/paging_snippet.html" with page=endpoints %} -
-
- - - {% if V3_FEATURE_LOCATIONS %} - - - - {% else %} - - - {% endif %} - - - {% for e in endpoints %} - - {% if V3_FEATURE_LOCATIONS %} - - - - {% else %} - - - {% endif %} - - {% endfor %} -
EndpointOpen FindingsOpen ProductsEndpointOpen Findings
- {{ e|truncatechars_html:70 }} - {% include "dojo/snippets/tags.html" with tags=e.tags.all %} - {{ e.active_findings }}{{ e.active_products }} - {{ e|truncatechars_html:70 }} - {% include "dojo/snippets/tags.html" with tags=e.tags.all %} - - {{ e.findings_count }} -
-
-
- {% include "dojo/paging_snippet.html" with page=endpoints %} -
- {% else %} -

No endpoints found.

- {% endif %} -
-
- - -{% endblock %} - -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/request_report.html b/dojo/templates_classic/dojo/request_report.html deleted file mode 100644 index 76a4a1b8f23..00000000000 --- a/dojo/templates_classic/dojo/request_report.html +++ /dev/null @@ -1,116 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load humanize %} -{% load display_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- Generate - {% if product_type %} - {{ labels.ORG_LABEL }} - {% elif product %} - {{ labels.ASSET_LABEL }} - {% elif engagement %} - Engagement - {% elif test %} - Test - {% endif %} - Report - - -

-
- -
-
Filters
-
- - {% include "dojo/filter_snippet.html" with form=findings.form %} - -
-
- - -
Report Options
-
- {% include "dojo/filter_snippet.html" with form=report_form submit='report' %} -
-
-
-
-
- -
-
- {% if findings %} -
-

- The findings listed below will be included in the report. Use the filters above to fine tune the results. -

-
-
- {% include "dojo/paging_snippet.html" with page=paged_findings %} -
-
- - - - - - - - - - - - - - {% for finding in paged_findings %} - - - - - - - - - - {% endfor %} - -
TitleDateStatusMitigatedSeverityEPSS Score / Percentile{{ labels.ASSET_LABEL }}
- {{ finding.title|truncatechars:50 }} - {% include "dojo/snippets/tags.html" with tags=finding.tags.all %} - {{ finding.date }}{{ finding.status }}{{ finding.mitigated }}{% if finding.severity == "Critical" or finding.severity == "High" %} -

- {% else %}

{% endif %}{{ finding.severity }}

- {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.test.engagement.product }}
-
-
- {% include "dojo/paging_snippet.html" with page=paged_findings %} -
- {% else %} -

No findings found.

- {% endif %} -
-
- -{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/review_finding.html b/dojo/templates_classic/dojo/review_finding.html deleted file mode 100644 index a4ba9a16d1c..00000000000 --- a/dojo/templates_classic/dojo/review_finding.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load static %} - -{% block add_styles %} - {{ block.super }} -.chosen-container { - width: 70% !important; -} -{% endblock %} -{% block content %} - {{ block.super }} -

Mark Finding for Review

-

{{ finding.title }}

-

Please provide a reason why this finding needs to be reviewed.

-
- {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-{% endblock %} diff --git a/dojo/templates_classic/dojo/simple_metrics.html b/dojo/templates_classic/dojo/simple_metrics.html deleted file mode 100644 index b805ba23415..00000000000 --- a/dojo/templates_classic/dojo/simple_metrics.html +++ /dev/null @@ -1,66 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load i18n %} -{% load display_tags %} -{% block content %} - {{ block.super }} - -

{{ name }}

-
{% csrf_token %} - {{ form.as_p }} - -
-
- - {% for key, value in findings.items %} - -

{{ key.name }}

[ - {% trans "View Details" %}] -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Total" %}{% trans "Critical" %}{% trans "High" %}{% trans "Medium" %}{% trans "Low" %}{% trans "Info" %}{% trans "Opened This Month" %}{% trans "Closed This Month" %}
- {{ value.Total }} - - {{ value.S0 }} - - {{ value.S1 }} - - {{ value.S2 }} - - {{ value.S3 }} - - {{ value.S4 }} - - {{ value.Opened }} - - {{ value.Closed }} -
-
- {% endfor %} - -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/simple_search.html b/dojo/templates_classic/dojo/simple_search.html deleted file mode 100644 index 1ae6820c68f..00000000000 --- a/dojo/templates_classic/dojo/simple_search.html +++ /dev/null @@ -1,562 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load event_tags %} -{% load display_tags %} - -{% block content %} - {{ block.super }} -

{{ name }} - -

- -
-
-
- - - - -
-
-
- -
-

-
- -
- - - - -
- {% if findings %} -
- {% comment %} include inherits the current context so findings, filtered and other variables {% endcomment %} - {% include "dojo/findings_list_snippet.html" with form_id="custom_search_filter" %} -
- {% endif %} - {% if vulnerability_ids %} -
- - - - - - - - - - - - - {% for vulnerability_id in vulnerability_ids %} - {% if vulnerability_id.id %} - - - - - - - - - {% endif %} - {% endfor %} - -
{% trans "Vulnerability Id" %}{% trans "EPSS Score / Percentile" %}{% trans "Product" %}{% trans "Engagement" %}{% trans "Test" %}{% trans "Finding" %}
- - {{ vulnerability_id.vulnerability_id }} - - {{ vulnerability_id.finding.epss_score|format_epss }} - / - {{ vulnerability_id.finding.epss_percentile|format_epss }} - - - {{ vulnerability_id.finding.test.engagement.product.name }} - {% include "dojo/snippets/tags.html" with tags=vulnerability_id.finding.test.engagement.product.tags.all %} - - - {{ vulnerability_id.finding.test.engagement.name }} - {% include "dojo/snippets/tags.html" with tags=vulnerability_id.finding.test.engagement.tags.all %} - - - {{ vulnerability_id.finding.test }} - {% include "dojo/snippets/tags.html" with tags=vulnerability_id.finding.test.tags.all %} - - - {{ vulnerability_id.finding.title }} - {% include "dojo/snippets/tags.html" with tags=vulnerability_id.finding.tags.all %} -
-
- {% endif %} - {% if products %} -
- - - - - - - - - {% for product in products %} - {% comment %} sometimes the index contains references to findings that no longer exist. even after rebuilding the index. {% endcomment %} - {% if product.id %} - - - - - {% endif %} - {% endfor %} - -
{% trans "Name" %}{% trans "Description" %}
- {{ product.name }} - {% include "dojo/snippets/tags.html" with tags=product.tags.all %} - {{ product.description|truncatechars_html:150|markdown_render }}
-
- {% endif %} - {% if engagements %} -
- - - - - - - - - - - {% for engagement in engagements %} - {% comment %} sometimes the index contains references to findings that no longer exist. even after rebuilding the index. {% endcomment %} - {% if engagement.id %} - - - - - - - {% endif %} - {% endfor %} - -
{% trans "Type" %}{% trans "Product" %}{% trans "Date" %}{% trans "Status" %}
- {{ engagement.name }} - {% include "dojo/snippets/tags.html" with tags=engagement.tags.all %} - - {{ engagement.product.name }} - {% include "dojo/snippets/tags.html" with tags=engagement.product.tags.all %} - {{ engagement.target_start|date }} - {{ engagement.target_end|date }}{{ engagement.status }}
-
- {% endif %} - {% if tests %} -
- - - - - - - - - - - - {% for test in tests %} - {% comment %} sometimes the index contains references to findings that no longer exist. even after rebuilding the index. {% endcomment %} - {% if test.id %} - - - - - - - - {% endif %} - {% endfor %} - -
{% trans "Title" %}{% trans "Product" %}{% trans "Engagement" %}{% trans "Date" %}{% trans "Status" %}
- {{ test }} - {% include "dojo/snippets/tags.html" with tags=test.tags.all %} - - {{ test.engagement.product.name }} - {% include "dojo/snippets/tags.html" with tags=test.engagement.product.tags.all %} - - {{ test.engagement.name }} - {% include "dojo/snippets/tags.html" with tags=test.engagement.tags.all %} - {{ test.engagement.target_start|date }} - {{ test.engagement.target_end|date }}{{ test.engagement.status }}
-
- {% endif %} - {% if endpoints %} -
- - - - - - - - - - {% for e in endpoints %} - - - {% if e.product %} - - {% else %} - - {% endif %} - - - {% endfor %} - -
{% trans "Endpoint" %}{% trans "Product" %}{% trans "Open Findings" %}
- {{ e }}{% if e.is_broken %} 🚩{% endif %} - {% include "dojo/snippets/tags.html" with tags=e.tags.all %} - - {{ e.product.name }} - {% include "dojo/snippets/tags.html" with tags=e.product.tags.all %} - {% trans "None" %} - {% if e.active_finding_count > 0 %} - {{ e.active_finding_count }} - {% else %} - {% trans "No Open, Active Findings" %} - {% endif %} -
-
- {% endif %} - {% if finding_templates %} -
- - - - - - - - - {% for finding_template in finding_templates %} - - - - - {% endfor %} - -
{% trans "Title" %}{% trans "Description" %}
- {{ finding_template.title }} - {% include "dojo/snippets/tags.html" with tags=finding_template.tags.all %} - {{ finding_template.description }}
-
- {% endif %} - {% if languages %} -
- - - - - - - - - {% for lang in languages %} - - - - - {% endfor %} - -
{% trans "Language" %}{% trans "Product" %}
{{ lang.language.language }} - {{ lang.product.name }} - {% include "dojo/snippets/tags.html" with tags=lang.product.tags.all %} -
-
- {% endif %} - {% if app_analysis %} -
- - - - - - - - - {% for app in app_analysis %} - - - - - {% endfor %} - -
{% trans "Technology" %}{% trans "Product" %}
{{ app.name }} - {{ app.product.name }} - {% include "dojo/snippets/tags.html" with tags=app.product.tags.all %} -
-
- {% endif %} - {% if tagged_findings or tagged_finding_templates or tagged_products or tagged_tests or tagged_endpoints or tagged_engagements %} -
- - - - - - - - - {% if tagged_tests %} - {% for test in tagged_tests %} - - - - - {% endfor %} - {% endif %} - {% for finding in tagged_findings %} - - - - - {% endfor %} - {% for finding_template in tagged_finding_templates %} - - - - - {% endfor %} - {% for product in tagged_products %} - - - - - {% endfor %} - {% for endpoint in tagged_endpoints %} - - - - - {% endfor %} - {% for eng in tagged_engagements %} - - - - - {% endfor %} - -
{% trans "Type" %}{% trans "Item" %}
{% trans "Test" %} - {{ test }} - {% include "dojo/snippets/tags.html" with tags=test.tags.all %} -
{% trans "Finding" %}{{ finding.title }} -
{% trans "Finding Template" %} - {{ finding_template.title }} - {% include "dojo/snippets/tags.html" with tags=finding_template.tags.all %} -
{% trans "Product" %} - {{ product.name }} - {% include "dojo/snippets/tags.html" with tags=product.tags.all %} -
{% trans "Endpoint" %} - {{ endpoint }}{% if endpoint.is_broken %} 🚩{% endif %} - {% include "dojo/snippets/tags.html" with tags=endpoint.tags.all %} -
{% trans "Engagement" %} - {{ eng }} - {% include "dojo/snippets/tags.html" with tags=eng.tags.all %} -
-
- {% endif %} - {% if generic %} -
- - - - - - - - - - - - - - - {% for result in generic %} - {% comment %} sometimes the index contains references to findings that no longer exist. even after rebuilding the index. {% endcomment %} - {% if result.object.id %} - - - - - - - - - - - {% endif %} - {% endfor %} - -
{% trans "ID" %}{% trans "Type" %}{% trans "Product" %}{% trans "Severity" %}{% trans "Title" %}{% trans "Description" %}{% trans "JIRA" %}{% trans "Rank" %}
- - - {{ result.object.id }} - - - - - {{ result.object| class_name }} - - - - {% comment %} a{{ result.meta.engagement__product__name }}b {% endcomment %} - {% firstof result.meta.finding__test__engagement__product__name result.meta.test__engagement__product__name result.meta.engagement__product__name result.meta.product__name result.object.name %} - - - {% if result.meta.severity_display %} - - - {{ result.meta.severity_display }} - - - {% endif %} - - - {{ result.title }} - - - {% comment %} {{ result.content }} {% endcomment %} - -

{{ result.object.description }}

-

{{ result.object.impact }}

-

{{ result.object.mitigation }}

-
-
- - {{ result.meta.jira_issue__jira_key }} - - - {{ result.watson_rank }} -
-
- {% endif %} -
-
- -{% endblock %} -{% block postscript %} - {{ block.super }} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/sla_config.html b/dojo/templates_classic/dojo/sla_config.html deleted file mode 100644 index 3a6c8942b8a..00000000000 --- a/dojo/templates_classic/dojo/sla_config.html +++ /dev/null @@ -1,92 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
-
-
-
-

- SLA Configurations - -

-
- -
- {% if confs %} -
- {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
-
- - - - - - - - - - - - - {% for conf in confs %} - - - - - - - - - {% endfor %} - -
NameDescriptionCritical Finding SLA DaysHigh Finding SLA DaysMedium Finding SLA DaysLow Finding SLA Days
- {% if "dojo.change_sla_configuration"|has_configuration_permission:request %} - {{ conf.name }} - {% else %} - {{ conf.name }} - {% endif %} - - {% if conf.description %}{{ conf.description }}{% endif %} - - {% if conf.critical and conf.enforce_critical %}{{ conf.critical }}{% endif %} - - {% if conf.high and conf.enforce_high %}{{ conf.high }}{% endif %} - - {% if conf.medium and conf.enforce_medium %}{{ conf.medium }}{% endif %} - - {% if conf.low and conf.enforce_low %}{{ conf.low }}{% endif %} -
-
-
- {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
- {% else %} -

No SLA configurations found.

- {% endif %} -
-
-{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/snippets/comments.html b/dojo/templates_classic/dojo/snippets/comments.html deleted file mode 100644 index 70e18c1a698..00000000000 --- a/dojo/templates_classic/dojo/snippets/comments.html +++ /dev/null @@ -1,85 +0,0 @@ -{% load humanize %} -{% load display_tags %} -{% load authorization_tags %} -
-
-

Notes

-
- {% if object|has_object_permission:"Note_Add" %} -
-
{% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
-
- -
-
-
-
- {% endif %} -
-{% for note in notes %} -
-
-
- {% if user.username == note.author.username or object|has_object_permission:"Note_Delete" or user.is_superuser %} -
-
- {% csrf_token %} - - -
-
- {% endif %} - {% if user.username == note.author.username or object|has_object_permission:"Note_Edit" %} -
-
- {% csrf_token %} - - -
-
- {% endif %} - {% if user.username == note.author.username or object|has_object_permission:"Note_View_History" %} -
-
- {% csrf_token %} - - -
-
- {% endif %} - -
- {{ note.author }} - commented {{ note.date }} -
- {% if note.edited %} -
- {{ note.editor }} - edited {{ note.edit_time }} -
- {% endif %} - {% if note.private %} -
- (will not appear in report) -
- {% endif %} -
-
- {% if note.note_type != None %} - Note type : {{ note.note_type }} -

- {% endif %} - {{ note|linebreaks }} -
-
-
-{% endfor %} diff --git a/dojo/templates_classic/dojo/snippets/endpoints.html b/dojo/templates_classic/dojo/snippets/endpoints.html deleted file mode 100644 index 84f10304d00..00000000000 --- a/dojo/templates_classic/dojo/snippets/endpoints.html +++ /dev/null @@ -1,280 +0,0 @@ -{% load display_tags %} -{% load authorization_tags %} -{% load static %} - - -{% if destination == "Report" %} - {% if finding.has_endpoints%} - {% with endpoints=finding.active_endpoints %} - {% if endpoints %} -
-
-
-
-
Vulnerable Endpoints / Systems ({{ finding.active_endpoint_count }})
-
- - - - - - - - - {% for endpoint in endpoints %} - - {% if V3_FEATURE_LOCATIONS %} - - - - - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - - - - - {% endif %} - - {% endfor %} - -
EndpointStatusDate DiscoveredLast Modified
{{ endpoint.location }}{% if endpoint.is_broken %} 🚩{% endif %}{{ endpoint.status }}{{ endpoint.created|date }}{{ endpoint.audit_time|date}}{{ endpoint }}{% if endpoint.endpoint.is_broken %} 🚩{% endif %}{{ endpoint.status }}{{ endpoint.date|date }}{{ endpoint.last_modified|date}}
-
-
-
- {% endif %} - {% endwith %} - - {% with endpoints=finding.mitigated_endpoints %} - {% if endpoints %} -
-
-
-
-
Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }})
-
- - - - - {% if V3_FEATURE_LOCATIONS %} - - - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - - - {% endif %} - - - {% for endpoint in endpoints %} - - {% if V3_FEATURE_LOCATIONS %} - - - - - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - - - - - {% endif %} - - {% endfor %} - -
EndpointStatusAuditorAudit TimeMitigatorMitigation Time
{{ endpoint.location }}{% if endpoint.is_broken %} 🚩{% endif %}{{ endpoint.status }}{{ endpoint.auditor }}{{ endpoint.audit_time|date }}{{ endpoint }}{% if endpoint.endpoint.is_broken %} 🚩{% endif %}{{ endpoint.status }}{{ endpoint.mitigated_by }}{{ endpoint.mitigated_time|date }}
-
-
-
- {% endif %} - {% endwith %} - {% endif %} - {% if finding.file_path %} -
-
-
-
-
Location
-
- - - {% if finding.service %} - - {% endif %} - {% if finding.component_name %} - - {% endif %} - {% if finding.component_version %} - - {% endif %} - {% if finding.line > 0 %} - - {% endif %} - - - {% if finding.service %} - - {% endif %} - {% if finding.component_name %} - - {% endif %} - {% if finding.component_version %} - - {% endif %} - {% if finding.line > 0 %} - - {% endif %} - -
ServiceComponentVersionLine Number
{{ finding.service }}{{ finding.component_name }}{{ finding.component_version }}{{ finding.line }}
- - - - - - - -
File Path
{{ finding.file_path }}
-
-
-
- {% endif %} -{% else %} - {% with endpoints=finding.active_endpoints %} - {% if endpoints %} -
-
-
-
-

Vulnerable Endpoints / Systems ({{ finding.active_endpoint_count }}) - -

-
- - - - {% if finding|has_object_permission:"Finding_Edit" %} - - {% endif %} - - - - - - - {% for endpoint in endpoints %} - - {% if finding|has_object_permission:"Finding_Edit" %} - - {% endif %} - {% if V3_FEATURE_LOCATIONS %} - - - - - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - - - - - {% endif %} - - {% endfor %} - -
-
- -
- Select All -
EndpointStatusDate DiscoveredLast Modified
-
- -
-
- {{ endpoint.location|url_shortener }}{% if endpoint.is_broken %} 🚩{% endif %} - {% include "dojo/snippets/tags.html" with tags=endpoint.location.tags.all %} - {{ endpoint.status }}{{ endpoint.created|date }}{{ endpoint.audit_time|date}} - {{ endpoint.endpoint|url_shortener }}{% if endpoint.endpoint.is_broken %} 🚩{% endif %} - {% include "dojo/snippets/tags.html" with tags=endpoint.endpoint.tags.all %} - {{ endpoint.status }}{{ endpoint.date|date }}{{ endpoint.last_modified|date}}
-
-
-
- {% endif %} - {% endwith %} - - {% with endpoints=finding.mitigated_endpoints %} - {% if endpoints %} -
-
-
-
-

Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }}) - -

-
- - - {% if finding|has_object_permission:"Finding_Edit" %} - - {% endif %} - - - {% if V3_FEATURE_LOCATIONS %} - - - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - - - {% endif %} - - - {% for endpoint in endpoints %} - - {% if finding|has_object_permission:"Finding_Edit" %} - - {% endif %} - {% if V3_FEATURE_LOCATIONS %} - - - - - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - - - - - {% endif %} - - {% endfor %} - -
-
- -
- Select All -
EndpointStatusAuditorAudit TimeMitigatorMitigation Time
-
- -
-
- {{ endpoint.location|url_shortener }}{% if endpoint.is_broken %} 🚩{% endif %} - {% include "dojo/snippets/tags.html" with tags=endpoint.location.tags.all %} - {{ endpoint.get_status_display }}{{ endpoint.auditor }}{{ endpoint.audit_time|date }} - {{ endpoint.endpoint|url_shortener }}{% if endpoint.endpoint.is_broken %} 🚩{% endif %} - {% include "dojo/snippets/tags.html" with tags=endpoint.endpoint.tags.all %} - {{ endpoint.status }}{{ endpoint.mitigated_by }}{{ endpoint.mitigated_time|date }}
-
-
-
- {% endif %} - {% endwith %} -{% endif %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/snippets/engagement_list.html b/dojo/templates_classic/dojo/snippets/engagement_list.html deleted file mode 100644 index 78228bfc142..00000000000 --- a/dojo/templates_classic/dojo/snippets/engagement_list.html +++ /dev/null @@ -1,367 +0,0 @@ -{% load filter_tags %} -{% load humanize %} -{% load display_tags %} -{% load authorization_tags %} -
-
-
-

{% if status == "open" %}Active{% elif status == "paused" %}Paused {% else %}Closed{% endif %} Engagements ({{ count }}) - -

-
-
- {% include "dojo/filter_snippet.html" with form=filter.form %} -
- -
- {% include "dojo/paging_snippet.html" with page=engs prefix=prefix page_size=True %} -
- {% if engs %} - - - - - - - - - - {% if system_settings.enable_jira %} - - {% endif %} - - - - - - - {% if status == "paused" or status == "closed" %} - - {% endif %} - - - - {% for eng in engs %} - - - - - - - - {% if system_settings.enable_jira %} - - {% endif %} - - - - - - - {% if status == "paused" or status == "closed" %} - - {% endif %} - {% endfor %} - -
NameTypeLeadDateLengthJIRATestsActive (Verified / Fixable)MitigatedAcceptedAllDuplicatesStatus
-
-
- - {{ eng.name|truncatechars_html:35|default:"N/A" }} - {% if eng.version %} - - - {{ eng.version }} - - {% endif %} - {% include "dojo/snippets/tags.html" with tags=eng.tags.all %} - {{ eng.engagement_type }} - {% if eng.lead.get_full_name and eng.lead.get_full_name.strip %} - {{ eng.lead.get_full_name }} - {% else %} - {{ eng.lead |default_if_none:""}} - {% endif %} - - - {{ eng.target_start|date:"jS F" }} {% if eng.target_start|datediff_time:eng.target_end != "1 day" %} - {{ eng.target_end|date:"jS F" }}{% endif %} - - {{ eng.target_start|datediff_time:eng.target_end }} - {% if status == "open" %} - {% if eng.is_overdue and eng.status != 'Completed' %} - -
- {{ eng.target_end|overdue }} overdue -
-
- {% endif %} - {% if eng.count_tests == 0 %} -   -
- no tests -
-
- {% endif %} - {% if eng.count_findings_all == 0 %} -   -
- no findings -
-
- {% endif %} - {% endif %} -
- {{ eng|jira_project_tag }} - - - {{ eng.count_findings_open }} ({{ eng.count_findings_open_verified}}/{{ eng.count_findings_fix_available}}){{ eng.count_findings_close }}{{ eng.count_findings_accepted }}{{ eng.count_findings_all }}{{ eng.count_findings_duplicate }} - {% if eng.status == "Blocked" %} - - {% elif eng.status == "On Hold" %} - - {% else %} - - {% endif %} - {{ eng.status }} - -
- {% else %} -
-

No {% if status == "open" %}active{% elif status == "paused" %}paused{% else %}closed{% endif %} engagements found.

-
- {% endif %} - -
- {% include "dojo/paging_snippet.html" with page=engs prefix=prefix page_size=True %} -
-
-
-{% block postscript %} - - {% endblock %} diff --git a/dojo/templates_classic/dojo/snippets/file_images.html b/dojo/templates_classic/dojo/snippets/file_images.html deleted file mode 100644 index 8a559282e03..00000000000 --- a/dojo/templates_classic/dojo/snippets/file_images.html +++ /dev/null @@ -1,29 +0,0 @@ -{% load display_tags %} - -{% if format == "HTML" %} - {% with images=obj|file_images %} -
Images
- {% for pic in images %} -

Finding Image

- {% empty %} -

No images found.

- {% endfor %} - {% endwith %} -{% elif format == "INLINE" %} - {% with images=obj|file_images %} -
Images
- {% for pic in images %} -

Finding Image

- {% empty %} -

No images found.

- {% endfor %} - {% endwith %} -{% else %} - {% with images=obj|file_images %} - {% for pic in images %} - image::{{ request.scheme }}://{{ request.get_host }}{% pic_token pic size %}[] +
- {% empty %} - No images found. - {% endfor %} - {% endwith %} -{% endif %} diff --git a/dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet.html b/dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet.html deleted file mode 100644 index 039685ee577..00000000000 --- a/dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet.html +++ /dev/null @@ -1,54 +0,0 @@ -{% load display_tags %} -{% load authorization_tags %} - -{% if include_view %} -
  • - - View Risk Acceptance - -
  • -{% endif %} - -{% if engagement.product.enable_full_risk_acceptance %} - {% if engagement|has_object_permission:"Risk_Acceptance" %} -
  • - - Edit Risk Acceptance - -
  • - - {% if risk_acceptance.is_expired %} -
  • - - Reinstate - -
    {% csrf_token %}
    -
  • - {% else %} -
  • - - Expire Now - -
    {% csrf_token %}
    -
  • - {% endif %} - {% endif %} -{% endif %} - -
  • - - View History - -
  • -{% if engagement|has_object_permission:"Risk_Acceptance" %} - -
  • - - Delete Risk Acceptance - -
    - {% csrf_token %} - -
    -
  • -{% endif %} diff --git a/dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet_js.html b/dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet_js.html deleted file mode 100644 index b09787d2c02..00000000000 --- a/dojo/templates_classic/dojo/snippets/risk_acceptance_actions_snippet_js.html +++ /dev/null @@ -1,22 +0,0 @@ -$('a.delete-risk_acceptance').on('click', function (e) { - if (confirm('Are you sure you want to delete this risk acceptance? ' + - 'Deleting this Risk Acceptance will remove the uploaded file, delete associated Notes and make findings active again. ' + - 'Metrics over historical data will no longer show/count anything related to this risk acceptance.' - )) { - $(this).next().submit(); - } -}); - -$('a.reinstate-risk_acceptance').on('click', function (e) { - return confirm('Are you sure you want reinstate this risk acceptance? ' + - 'All its findings will be deactivated and marked as accepted. ' + - 'The default of {{ system_settings.risk_acceptance_form_default_days }} days from now will be the expiration date.' - ) -}); - -$('a.expire-risk_acceptance').on('click', function (e) { - return confirm('Are you sure you want let this risk acceptance expire now? ' + - 'All its findings will be re-activated and no longer marked as accepted. ' + - 'The expiration date for this risk acceptance will be set to "now".' - ) -}); diff --git a/dojo/templates_classic/dojo/snippets/selectpicker_in_dropdown.html b/dojo/templates_classic/dojo/snippets/selectpicker_in_dropdown.html deleted file mode 100644 index 36c0ccf82d5..00000000000 --- a/dojo/templates_classic/dojo/snippets/selectpicker_in_dropdown.html +++ /dev/null @@ -1,39 +0,0 @@ -{% load static %} - \ No newline at end of file diff --git a/dojo/templates_classic/dojo/snippets/sonarqube_history.html b/dojo/templates_classic/dojo/snippets/sonarqube_history.html deleted file mode 100644 index 6f41ed0f851..00000000000 --- a/dojo/templates_classic/dojo/snippets/sonarqube_history.html +++ /dev/null @@ -1,29 +0,0 @@ -{% load humanize %} -{% if finding.sonarqube_issue %} -
    -
    -

    SonarQube Transition History -

    -
    -
    -
    - - - - - - - - {% for transition in finding.sonarqube_issue.sonarqube_issue_transition_set.all %} - - - - - - - {% endfor %} -
    Finding Date updatedSonarQube StatusTransitions appliedFinding Status
    {{ transition.created }}{{ transition.sonarqube_status }}{{ transition.transitions }}{{ transition.finding_status }}
    -
    -
    -
    -{% endif %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/snippets/tags.html b/dojo/templates_classic/dojo/snippets/tags.html deleted file mode 100644 index f039dfa9ce7..00000000000 --- a/dojo/templates_classic/dojo/snippets/tags.html +++ /dev/null @@ -1,13 +0,0 @@ -{% if tags %} - {% if tags|length < 5 %} - - {% endif %} - - {% for tag in tags %} - {{ tag }} - {% endfor %} - - {% if tags|length < 5 %} - - {% endif %} -{% endif %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/support.html b/dojo/templates_classic/dojo/support.html deleted file mode 100644 index e81d2cd01f3..00000000000 --- a/dojo/templates_classic/dojo/support.html +++ /dev/null @@ -1,82 +0,0 @@ -{% extends "base.html" %} -{% load authorization_tags %} -{% load static %} - -{% block content %} - {{ block.super }} -
    -

    Options are available for teams of all sizes.

    -
    -
    -
    -
    -

    Community Support

    -
    -

    What's included:

    -

    Support from the community via OWASP Slack

    -

    Community-based discussion

    -
    - Join #defectdojo -
    - -
    -
    -

    Go Pro!

    -
    -

    What's included:

    -

    New UI - - -

    -

    Connectors - - -

    -

    Insights - - -

    -

    Data Enrichment - - -

    -

    Universal Importer - - -

    -

    Async Functions - - -

    -

    Support directly from the DefectDojo Team

    -

    Assistance with best practice and implementation

    -
    - Try Pro for Free! -
    -
    -
    -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/system_settings.html b/dojo/templates_classic/dojo/system_settings.html deleted file mode 100644 index 11bb7c4e58e..00000000000 --- a/dojo/templates_classic/dojo/system_settings.html +++ /dev/null @@ -1,92 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block add_styles %} - {{ block.super }} -.chosen-container { - width: 70% !important; -} -{% endblock add_styles %} - -{% block content %} - {{ block.super }} -
    - {% block settings %} -
    -

    System Settings

    -
    {% csrf_token %} - {% block form %} - {% include "dojo/form_fields.html" with form=form %} - {% endblock form %} -
    -
    - -
    -
    -
    -
    - {% endblock settings %} -{% endblock content %} -{comment} -Django forms are very rigid so without crispy-forms we're forced to use javascript to add some flavour... -{endcomment} -{% block postscript %} - {{ block.super }} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/templates.html b/dojo/templates_classic/dojo/templates.html deleted file mode 100644 index 9c36a499ef9..00000000000 --- a/dojo/templates_classic/dojo/templates.html +++ /dev/null @@ -1,195 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -

    - {% if add_from_template %} - Add From Finding Templates - {% elif apply_template %} - Apply Template to Finding - {% else %} - Finding Templates - {% endif %} - -

    -
    -
    - {% include "dojo/filter_snippet.html" with form=filtered.form %} -
    -
    - {% if templates %} -
    - {% include "dojo/paging_snippet.html" with page=templates page_size=True %} -
    -
    - - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - - {% for finding in templates %} - - - - - {% if add_from_template %} - - {% elif apply_template %} - - {% else %} - - {% endif %} - - {% endfor %} - -
    {% dojo_sort request 'CWE' 'cwe' 'asc' %}{% dojo_sort request 'Name' 'title' %}{% dojo_sort request 'Severity' 'numerical_severity' %}Actions
    - {% if finding.cwe > 0 %} - - {{ finding.cwe }} - - {% endif %} - - {% if add_from_template %} - - {{ finding.title }} - {% include "dojo/snippets/tags.html" with tags=finding.tags.all %} - - {% elif apply_template %} - - {{ finding.title }} - {% include "dojo/snippets/tags.html" with tags=finding.tags.all %} - - {% else %} - {% if "dojo.change_finding_template"|has_configuration_permission:request %} - - {{ finding.title }} - {% include "dojo/snippets/tags.html" with tags=finding.tags.all %} - - {% else %} - {{ finding.title }} - {% endif %} - {% endif %} - {% if finding.severity == "Critical" or finding.severity == "High" %} -

    - {% else %}

    {% endif %}{{ finding.severity }}

    - - Use This Template - - - - Use This Template - - - {% if "Finding_Edit"|has_global_permission %} - Edit - {% endif %} - {% if "Finding_Delete"|has_global_permission %} -
    - {% csrf_token %} - - -
    - {% endif %} -
    -
    -
    - {% include "dojo/paging_snippet.html" with page=templates page_size=True %} -
    - {% else %} -

    No finding templates found.

    - {% endif %} -
    -
    - - -{% endblock %} -{% block postscript %} - {{ block.super }} - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/test_pdf_report.html b/dojo/templates_classic/dojo/test_pdf_report.html deleted file mode 100644 index 0989b8796c0..00000000000 --- a/dojo/templates_classic/dojo/test_pdf_report.html +++ /dev/null @@ -1,605 +0,0 @@ -{% extends "report_base.html" %} -{% load static %} -{% load display_tags %} -{% load humanize %} - -{% load get_note_status %} -{% load get_notetype_availability %} -{% load event_tags %} -{% block content %} - {{ block.super }} -
    -
    -
    -

    Test Security Report for {{ test }}

    -

    - Engagement: {{ test.engagement.name }} -
    Test: {{ test }} -
    Generated: {% display_date %} -

    -
    -
    - {% if include_table_of_contents%} -
    -
    -

    Table of Contents for {{ test }}

    -
    -
    - -
    - {% endif %} - {% if include_executive_summary %} -
    -
    -

    Executive Summary

    -

    - This report represents a security audit performed by the {{ team_name }} team. - It contains confidential information about the state of your network and applications. - Access to this information by unauthorized personnel may allow them to compromise your network. -

    -
    -
    -
    -
    -
    -
    - {{ test }} -
    -
    -
    -
    - - - - - - - - - - - - - - - -
    EnvironmentEngagementStart DateEnd DateProgress
    - {{ test.environment }} - - {{ test.engagement }} - - {{ test.target_start|date }} - - {{ test.target_end|date }} - - {{ test.percent_complete }}% -
    -
    -
    -
    -
    -

    - A {{ test }} was conducted in the {{ test.environment.name }} environment - {% if test.target_end %} - from {{ test.target_start|date:"SHORT_DATE_FORMAT" }} to - {{ test.target_end|date:"SHORT_DATE_FORMAT" }} - {% else %} - on {{ test.target_start|date:"SHORT_DATE_FORMAT" }} - {% endif %} - which yielded a total of {{ findings|length|apnumber }} finding{{ findings|length|pluralize }} - of varying - severity. -

    -

    - The test was part of - {% if test.engagement.name %} - the {{ test.engagement.name }} - {% else %} - an - {% endif %} - engagement which ran from {{ test.engagement.target_start|date:"SHORT_DATE_FORMAT" }} - {% if test.engagement.target_end %} - to {{ test.engagement.target_end|date:"SHORT_DATE_FORMAT" }}. - {% else %} - and is ongoing. - {% endif %} -

    - {% if test.engagement.test_set %} -

    The engagement also included the following tests which are not reported here:

    -
      - {% for t in test.engagement.test_set.all %} - {% if test.id != t.id %} -
    • {{ t }} - ({{ t.environment.name|default:"unknown" }}): - {{ t.target_start|date:"SHORT_DATE_FORMAT" }}
    • - {% endif %} - {% endfor %} -
    - {% endif %} -
    -
    -
    -
    -
    - Finding Age -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - Engagement Finding Count -
    -
    -
    -
    -
    -
    -
    -
    - {% endif %} - {% if include_disclaimer%} -
    - Disclaimer
    -

    {{ disclaimer | bleach_with_a_tags }}

    -
    - {% endif %} -
    -
    - {% if include_finding_notes %} - {% with notes=test.notes.all|get_public_notes %} - {% if notes.count > 0 %} -

    Test Notes

    - - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
    UserDateNote TypeNote
    - {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
    - {% endif %} - {% endwith %} - {% endif %} - - {% if test.engagement.risk_acceptance.count > 0 %} -

    Risk Accepted Findings

    - - - - - - - - {% for risk in test.engagement.risk_acceptance.all %} - {% for finding in risk.accepted_findings.all %} - - - - - - - {% endfor %} - {% endfor %} -
    - Name - - Date - - Severity - - EPSS Score / Percentile -
    {{ finding.title }}{{ finding.date }}{{ finding.severity }} - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} -
    - {% endif %} -
    -
    -
    -
    - {% if findings %} -

    Findings

    - {% endif %} - {% for finding in findings %} - {% ifchanged finding.severity %} -

    {{ finding.severity|capfirst }}

    - {% endifchanged %} -
    -
    -
    -
    - Finding {{ forloop.counter }}: {{ finding.title }} - {% if finding.tags %} - - {% for tag in finding.tags.all %} - {{ tag }} - {% endfor %} - - {% endif %} -
    -
    -
    -
    - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - - - - - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - - - - {% if finding.mitigated %} - - - {% endif %} - {% if finding.cwe > 0 %} - - {% endif %} - - -
    SeverityEPSS Score / PercentileStatusAcceptanceDate discoveredAgeReporterDate MitigatedMitigated ByCWEDojo ID
    - - {% if finding.severity %} - {{ finding.severity }} - {% else %} - Unknown - {% endif %} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.status }} - {% comment %} for some reason the font-awesome icons don't work with the report template{% endcomment %} - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - acceptance  - {% endfor %} - {{ finding.date }}{{ finding.age }} days{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - - {{ finding.cwe }} - - {{ finding.id }}
    -
    -
    - - {% include "dojo/snippets/endpoints.html" with finding=finding destination="Report" %} - - {% if finding.cvssv3 %} -
    CVSS v3
    -
    {{ finding.cvssv3|markdown_render }}
    - {% endif %} - -
    Description
    -
    {{ finding.description|markdown_render }}
    - - {% if finding.mitigation %} -
    Mitigation
    -
    {{ finding.mitigation|markdown_render }}
    - {% endif %} - - {% if finding.get_report_requests %} -
    Sample Request(s): Displaying {{finding.get_report_requests.count}} of {{finding.burprawrequestresponse_set.count}}
    - {% for req in finding.get_report_requests %} -
    Request {{forloop.counter}}
    -
    {{ req.get_request }}
    - {% if req.get_response != "" %} -
    Response {{forloop.counter}}
    -
    {{ req.get_response|truncatechars_html:800 }}
    - {% endif %} - {% endfor %} - {% endif %} - - {% if finding.impact %} -
    Impact
    -
    {{ finding.impact|markdown_render }}
    - {% endif %} - - {% if finding.steps_to_reproduce %} -
    Steps to Reproduce
    -
    {{ finding.steps_to_reproduce|markdown_render }}
    - {% endif %} - - {% if finding.severity_justification %} -
    Severity Justification
    -
    {{ finding.severity_justification|markdown_render }}
    - {% endif %} - - {% if finding.references %} -
    References
    -
    {{ finding.references|markdown_render }}
    - {% endif %} - - {% if include_finding_images %} - {% include "dojo/snippets/file_images.html" with size='original' obj=finding size="original" format="HTML" %} - {% endif %} - - {% if include_finding_notes %} - {% with notes=finding.notes.all|get_public_notes %} - {% if notes.count > 0 %} -
    Notes
    - - - - - - {% with notes_with_type=notes|get_notetype_notes_count %} - {% if notes_with_type > 0 %} - - {% endif %} - - - - - {% for note in notes reversed %} - - - - {% if notes_with_type > 0 %} - - {% endif %} - - - {% endfor %} - - {% endwith %} -
    UserDateNote TypeNote
    - {{ note.author.username }} - - {{ note.date }} - - {% if note.note_type != None %} - {{ note.note_type }} - {% endif %} - - {{ note|linebreaks }} -
    - {% endif %} - {% endwith %} - {% endif %} - {% endfor %} -
    -
    - {% if include_table_of_contents %} -
    - {% endif %} -
    -{% endblock %} -{% block js %} - {{ block.super }} - - - - - - - - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/test_type.html b/dojo/templates_classic/dojo/test_type.html deleted file mode 100644 index 73c9c8a0e64..00000000000 --- a/dojo/templates_classic/dojo/test_type.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -

    - Test Type List - -

    -
    -
    - {% include "dojo/filter_snippet.html" with form=test_types.form %} -
    -
    - {% if tts %} -
    - {% include "dojo/paging_snippet.html" with page=tts page_size=True %} -
    -
    - - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - {% for tt in tts %} - - {% if "dojo.change_test_type"|has_configuration_permission:request %} - - {% else %} - - {% endif %} - - {% endfor %} - -
    {% dojo_sort request 'Test Type' 'name' 'asc' %}
    {{ tt.name }} {{ tt.name }}
    -
    -
    - {% include "dojo/paging_snippet.html" with page=tts page_size=True %} -
    - {% else %} -

    No test types found.

    - {% endif %} -
    -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/tool_config.html b/dojo/templates_classic/dojo/tool_config.html deleted file mode 100644 index c4f49688c9c..00000000000 --- a/dojo/templates_classic/dojo/tool_config.html +++ /dev/null @@ -1,85 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -

    - Tool Configurations - -

    -
    - -
    - {% if confs %} - -
    - {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
    -
    - - - - - - - - - - - {% for conf in confs %} - - - - - - - {% endfor %} - -
    NameDescriptionURLCategory
    - {% if "dojo.change_tool_configuration"|has_configuration_permission:request %} - {{ conf.name }} - {% else %} - {{ conf.name }} - {% endif %} - - {% if conf.description %}{{ conf.description }}{% endif %} - - {% if conf.url %}{{ conf.url }}{% endif %} - - {{ conf.tool_type.name }} -
    -
    -
    - {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
    - {% else %} -

    No tool configurations found.

    - {% endif %} -
    -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/tool_type.html b/dojo/templates_classic/dojo/tool_type.html deleted file mode 100644 index d8d6ac93a30..00000000000 --- a/dojo/templates_classic/dojo/tool_type.html +++ /dev/null @@ -1,78 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load authorization_tags %} -{% load i18n %} - -{% block content %} - {{ block.super }} -
    -
    -
    -
    -

    - {% trans "Tool Types" %} - {% if "dojo.add_tool_type"|has_configuration_permission:request %} - - {% endif %} -

    -
    - -
    - {% if confs %} - -
    - {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
    -
    - - - - - - - - - {% for conf in confs %} - - - - - {% endfor %} - -
    NameDescription
    - {% if "dojo.change_tool_type"|has_configuration_permission:request %} - {{ conf.name }} - {% else %} - {{ conf.name }} - {% endif %} - {% if conf.description %}{{ conf.description }}{% endif %} -
    -
    -
    - {% include "dojo/paging_snippet.html" with page=confs page_size=True %} -
    - {% else %} -

    {% trans "No tool types found" %}

    - {% endif %} -
    -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/up_threat.html b/dojo/templates_classic/dojo/up_threat.html deleted file mode 100644 index e48516ef7bd..00000000000 --- a/dojo/templates_classic/dojo/up_threat.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -
    {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
    -
    - -
    -
    -
    -{% endblock %} diff --git a/dojo/templates_classic/dojo/url/create.html b/dojo/templates_classic/dojo/url/create.html deleted file mode 100644 index f67d0e58168..00000000000 --- a/dojo/templates_classic/dojo/url/create.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } -{% endblock add_styles %} -{% block content %} - {{ block.super }} -

    Create Endpoint

    -
    - {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
    -
    - -
    -
    -
    -{% endblock content %} diff --git a/dojo/templates_classic/dojo/url/delete.html b/dojo/templates_classic/dojo/url/delete.html deleted file mode 100644 index 473feb247b3..00000000000 --- a/dojo/templates_classic/dojo/url/delete.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

    - Delete Endpoint {{ location }} - {% if location.url.host_validation_failure %} - 🚩 - {% endif %} -

    -

    - Deleting this Endpoint will disassociate it with any findings and products and other relationships associated - with it. These relationships are listed below: -

    -
    -
    -

    Danger Zone

    -
    - {% if rels|length > 1 %} -
      - {{ rels|unordered_list }} -
    - {% else %} -

    No relationships found.

    - {% endif %} -
    - {% csrf_token %} - {{ form }} -
    - -
    -
    -
    -
    -{% endblock content %} diff --git a/dojo/templates_classic/dojo/url/list.html b/dojo/templates_classic/dojo/url/list.html deleted file mode 100644 index 0461db2ffa8..00000000000 --- a/dojo/templates_classic/dojo/url/list.html +++ /dev/null @@ -1,276 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load display_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -

    - {{ name }} - -

    -
    -
    - {% include "dojo/filter_snippet.html" with form=filtered.form %} -
    -
    - {% if locations %} - -
    {% include "dojo/paging_snippet.html" with page=locations page_size=True %}
    -
    - - - {% if not product_tab or product_tab and product_tab.product|has_object_permission:"Location_Edit" %} - - {% endif %} - {% if host_view %} - - {% else %} - - {% endif %} - {% if not product_tab %}{% endif %} - - - - {% for location in locations %} - - {% if not product_tab or product_tab and product_tab.product|has_object_permission:"Location_Edit" %} - - {% endif %} - {% if host_view %} - - {% else %} - - {% endif %} - {% if not product_tab %} - - {% endif %} - - - - {% endfor %} -
    -
    - -
    -
    HostEndpointActive (Total) Products{% dojo_sort request 'Active (Total) Findings' 'active_findings' field='ordering' %}Overall Status
    -
    - -
    -
    - {{ location.host|url_shortener }} - - {{ location|url_shortener }} - {% if location.host_validation_failure %} - 🚩 - {% endif %} - - {% include "dojo/snippets/tags.html" with tags=location.tags.all %} - - {% if host_view %} - {{ location.active_products }} - ({{ location.total_products }}) - {% else %} - {{ location.active_products }} - ({{ location.total_products }}) - {% endif %} - - {% if host_view %} - {{ location.active_findings }} - ({{ location.total_findings }}) - {% else %} - {{ location.active_findings }} - ({{ location.total_findings }}) - {% endif %} - {{ location.overall_status }}
    -
    -
    {% include "dojo/paging_snippet.html" with page=locations page_size=True %}
    - {% else %} - {% if host_view %} -
    -

    No hosts found.

    -
    - {% else %} -
    -

    No endpoints found.

    -
    - {% endif %} - {% endif %} -
    -
    -{% endblock content %} -{% block postscript %} - {{ block.super }} - - {% include "dojo/filter_js_snippet.html" %} -{% endblock postscript %} diff --git a/dojo/templates_classic/dojo/url/update.html b/dojo/templates_classic/dojo/url/update.html deleted file mode 100644 index 3967bf18690..00000000000 --- a/dojo/templates_classic/dojo/url/update.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} -{% load static %} -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } -{% endblock add_styles %} -{% block content %} - {{ block.super }} -

    Edit Endpoint

    -
    - {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
    -
    - -
    -
    -
    -{% endblock content %} diff --git a/dojo/templates_classic/dojo/url/view.html b/dojo/templates_classic/dojo/url/view.html deleted file mode 100644 index d517f8cc39a..00000000000 --- a/dojo/templates_classic/dojo/url/view.html +++ /dev/null @@ -1,410 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load humanize %} -{% load display_tags %} -{% load authorization_tags %} -{% load multiply %} -{% load static %} -{% block add_styles %} - {{ block.super }} - .graph {min-height: 158px;} - h3 { margin-top: 5px; margin-bottom: 5px; font-size: 20px; line-height: 22px;} - .tooltip-inner { - max-width: 650px; - } -{% endblock add_styles %} -{% block content %} - {{ block.super }} -
    -
    -
    -

    - {% if host_view %} - Host: {{ host|url_shortener }} - {% else %} - - Endpoint: {{ location|url_shortener }} - {% if location.host_validation_failure %} - 🚩 - {% endif %} - - {% endif %} -

    - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -   - - Finding Age ({{ all_findings_count|apnumber }} - finding{{ all_findings_count|pluralize }}) - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - {{ all_findings_count }} - - {% if not host_view %} - - {% else %} - - {% endif %} - endpoint finding{{ all_findings_count|pluralize }} - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - Opened findings count by month -
    -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - {% if host_view %} -
    -

    Endpoints ({{ mitigated_location_count }} / {{ location_count }} Mitigated Endpoints)

    -
    - {% if locations %} - {% colgroup locations into 3 cols as grouped_items %} - - {% for row in grouped_items %} - - {% for item in row %} - - {% endfor %} - - {% endfor %} -
    - {% if item %} - - {% if item.overall_status == "Active" %} - - {% else %} - - {% endif %} -  {{ item|url_shortener }} - {% if location.url.host_validation_failure %} - 🚩 - {% endif %} - - {% endif %} -
    - {% else %} -
    No endpoints.
    - {% endif %} - {% else %} -
    -

    Host

    -
    - - {% endif %} -
    - {% if not host_view and location.tags.exists %} -
    -
    -

    Tags

    -
    -
    {% include "dojo/snippets/tags.html" with tags=location.tags.all %}
    -
    - {% endif %} - {% if not host_view and metadata %} -
    -
    -
    -
    -

    - Additional Information - -

    -
    -
    - {% for key, value in metadata.items %} -
    - {{ key }} -
    - {{ value }} -
    -   -
    - {% endfor %} -
    -
    -
    -
    - {% endif %} -
    -
    -

    Open Findings ({{ active_findings_count }} / {{ all_findings_count }} Active Findings)

    -
    - {% if findings %} -
    - {% include "dojo/paging_snippet.html" with page=findings page_size=True %} -
    -
    - - - - - - - - - - - - - {% for finding in findings %} - - - - - - - - - {% endfor %} - -
    TitleSeverityEPSS Score / PercentileDateAgeFound by
    - {{ finding.title }} - - {{ finding.severity }} - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.date }}{{ finding.age }} - {% for scanner in finding.found_by.all %}{{ scanner }}{% endfor %} -
    -
    -
    - {% include "dojo/paging_snippet.html" with page=findings page_size=True %} -
    - {% else %} -

    No findings found.

    - {% endif %} -
    - -{% endblock content %} -{% block postscript %} - {{ block.super }} - - - - - - - {% block metrics %} - - {% endblock metrics %} - -{% endblock postscript %} diff --git a/dojo/templates_classic/dojo/users.html b/dojo/templates_classic/dojo/users.html deleted file mode 100644 index 80bcbb57887..00000000000 --- a/dojo/templates_classic/dojo/users.html +++ /dev/null @@ -1,159 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load i18n %} -{% load navigation_tags %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} - {% load display_tags %} - {% trans "First Name" as first_name %} - {% trans "Last Name" as last_name %} - {% trans "User Name" as user_name %} - {% trans "Email" as email %} - {% trans "Active" as active %} - {% trans "Superuser" as superuser %} - {% trans "Staff" as staff %} -
    -
    -
    -
    -

    - {{ name }} - -

    -
    -
    - {% include "dojo/filter_snippet.html" with form=filtered.form %} -
    -
    - {% if users %} -
    - {% include "dojo/paging_snippet.html" with page=users page_size=True %} -
    -
    - {% block users_table %} - - {% block users_table_header_row %} - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - {% comment %} The display field is translated in the function. No need to translate here as well{% endcomment %} - - - - - - - {% block users_table_extra_header_rows %} - {% endblock users_table_extra_header_rows %} - - {% endblock users_table_header_row %} - {% block users_table_data_rows %} - {% for u in users %} - - - - - - - - - - - - - - - - {% block users_table_extra_data_rows %} - {% endblock users_table_extra_data_rows %} - - {% endfor %} - {% endblock users_table_data_rows %} -
    {% dojo_sort request first_name 'first_name' %}{% dojo_sort request last_name 'last_name' %}{% dojo_sort request user_name 'username' 'asc' %}{% dojo_sort request email 'email' %}{% trans "Title" %}{% trans "Phone Number(s)" %}{% dojo_sort request active 'is_active' %}{% dojo_sort request superuser 'is_superuser' %}{% dojo_sort request staff 'is_staff' %}{% trans "Date Joined" %}{% trans "Last Login" %}{% trans "Token Last Reset" %}{% trans "Password Last Reset" %}
    - - {{ u.first_name }}{{ u.last_name }}{{ u.username }}{{ u.email }}{% if u.usercontactinfo.title %} {{ u.usercontactinfo.title }} {% endif %} - {% if u.usercontactinfo.phone_number %} {% trans "Phone:" %} {{ u.usercontactinfo.phone_number }} {% endif %} - {% if u.usercontactinfo.phone_number and u.usercontactinfo.cell_number %}
    {% endif %} - {% if u.usercontactinfo.cell_number %} {% trans "Cell:" %} {{ u.usercontactinfo.cell_number }} {% endif %} -
    {% if u.is_active %} - {% else %} - {% endif %}{% if u.is_superuser %} - {% else %} - {% endif %}{% if u.is_staff %} - {% else %} - {% endif %}{{ u.date_joined }}{% if u.last_login %}{{ u.last_login }}{% else %}{% trans "Never" %}{% endif %}{% if u.usercontactinfo.token_last_reset %}{{ u.usercontactinfo.token_last_reset }}{% else %}{% trans "Never" %}{% endif %}{% if u.usercontactinfo.password_last_reset %}{{ u.usercontactinfo.password_last_reset }}{% else %}{% trans "Never" %}{% endif %}
    - {% endblock users_table %} -
    -
    - {% include "dojo/paging_snippet.html" with page=users page_size=True %} -
    - {% else %} -
    {% trans "No Users" %}
    - {% endif %} -
    -
    - -{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/verify_finding.html b/dojo/templates_classic/dojo/verify_finding.html deleted file mode 100644 index f07cca04143..00000000000 --- a/dojo/templates_classic/dojo/verify_finding.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block content %} - {{ block.super }} -

    {% trans "Verify Finding" %}

    -

    {{ finding.title }}

    -

    {% trans "Use this form to mark the finding as verified. Adding a comment is optional." %}

    -
    - {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
    -
    - -
    -
    -
    -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_endpoint.html b/dojo/templates_classic/dojo/view_endpoint.html deleted file mode 100644 index e32407ac7ee..00000000000 --- a/dojo/templates_classic/dojo/view_endpoint.html +++ /dev/null @@ -1,418 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load humanize %} -{% load display_tags %} -{% load authorization_tags %} -{% load multiply %} -{% load static %} -{% block add_styles %} - {{ block.super }} - .graph {min-height: 158px;} - h3 { margin-top: 5px; margin-bottom: 5px; font-size: 20px; line-height: 22px;} - .tooltip-inner { - max-width: 650px; - } -{% endblock %} -{% block content %} - {{ block.super }} -
    -
    -
    -

    - {% if host_view %} - - Host: {{ endpoint.host|url_shortener }} - - {{ endpoint.host_mitigated_endpoints_count }} / {{ endpoint.host_endpoints_count }} mitigated endpoints - - {% else %} - - Endpoint: {{ endpoint|url_shortener }}{% if endpoint.is_broken %} 🚩{% endif %} - - {% if endpoint.mitigated %} - Mitigated - {% elif endpoint.active_findings_count > 0 %} - Vulnerable - {% else %} - No active verified findings - {% endif %} - - {% endif %} -

    - - - -
    -
    -
    -
    -
    -
    -
    -
    - - -
    -   - - Finding Age ({{ all_findings|length|apnumber }} - finding{{ all_findings|length|pluralize }}) - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - -
    - {{ all_findings|length }} - - {% if not host_view %} - - {% else %} - - {% endif %} - endpoint finding{{ all_findings|length|pluralize }} - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - -
    - Opened findings count by month -
    -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - {% if host_view %} -

    Endpoints

    - {% if endpoints %} - {% colgroup endpoints into 3 cols as grouped_items %} - - {% for row in grouped_items %} - - {% for item in row %} - - {% endfor %} - - {% endfor %} -
    {% if item %} - - {% if item.vulnerable %} - - {% else %} - - {% endif %} -  {{ item|url_shortener }}{% if endpoint.is_broken %} 🚩{% endif %} - {% endif %} -
    - {% else %} -
    - No endpoints. -
    - {% endif %} - {% else %} -

    Host

    - - {% endif %} -
    - - {% if not host_view and endpoint.tags.all %} -
    -
    -

    Tags

    -
    -
    - {% include "dojo/snippets/tags.html" with tags=endpoint.tags.all %} -
    -
    - {% endif %} - - {% if not host_view and endpoint_metadata %} -
    -
    -
    -
    -

    Additional Information - -

    -
    - -
    - {% for key, value in endpoint_metadata.items %} -
    - {{ key }}
    - {{ value }} -
      -
    - {% endfor %} -
    -
    -
    -
    - {% endif %} - -
    -
    -

    Open Findings

    -
    - {% if findings %} -
    - {% include "dojo/paging_snippet.html" with page=findings page_size=True %} -
    -
    - - - - - - - - - - - - - {% for finding in findings %} - - - - - - - - - {% endfor %} - - -
    TitleSeverityEPSS Score / PercentileDateAgeFound by
    - {{ finding.title }} - - - {{ finding.severity }} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - - {{ finding.date }} - - {{ finding.age }} - - {% for scanner in finding.found_by.all %} - {{ scanner }} - {% endfor %} -
    -
    -
    - {% include "dojo/paging_snippet.html" with page=findings page_size=True %} -
    - {% else %} -

    No findings found.

    - {% endif %} -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - - {% block metrics %} - - {% endblock metrics %} - -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_eng.html b/dojo/templates_classic/dojo/view_eng.html deleted file mode 100644 index 734d789e6e4..00000000000 --- a/dojo/templates_classic/dojo/view_eng.html +++ /dev/null @@ -1,1031 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load display_tags %} -{% load humanize %} -{% load survey_tags %} -{% load authorization_tags %} -{% load static %} -{% block add_styles %} - .tooltip-inner { - max-width: 350px; - } -{% endblock %} -{% block content %} -
    -
    -
    -
    -
    -

    - Description -

    - -
    -
    -
    - {% if eng.description %} - {{ eng.description|markdown_render }} - {% else %} - There is no description. - {% endif %} -
    -
    - {% if eng.preset %} -
    -
    -
    -
    -
    -

    - Engagement Presets {{ eng.preset.title|truncatechars_html:60 }} -

    - {% if eng.product|has_object_permission:"Product_Edit" %} - - {% endif %} -
    -
    -
    - - - - - - - - - - - - - -
    Test TypeNetwork
    - {% if preset_test_type.count > 1 %} - {% for test in preset_test_type %} - {{test.name}}{%if not forloop.last%},{%endif%} - {% endfor %} - {% else %} - {{ preset_test_type.0.name }} - {% endif %} - - {% if network.count > 1 %} - {% for net in network %} - {{ net.location }}{%if not forloop.last%},{%endif%} - {% endfor %} - {% else %} - {{ network.0.location }} - {% endif %} -
    -
    -
    - {% if eng.preset.notes %} - Notes: {{ eng.preset.notes|markdown_render }} - {% else %} - No test notes found. - {% endif %} - {% if eng.preset.scope %} - Scope: {{ eng.preset.scope|markdown_render }} - {% else %} - Testing scope not specified. - {% endif %} -
    -
    -
    -
    - {% endif %} -
    -
    -
    -
    -
    -

    - Tests ({{tests.paginator.count}}) {{ eng.id|get_severity_count:"engagement" }} - -

    -
    -
    -
    - {% include "dojo/filter_snippet.html" with form=filter.form %} -
    - {% if tests %} -
    - {% include "dojo/paging_snippet.html" with page=tests page_size=True %} -
    -
    - - - - {% block tests_header %} - - - - - - - - - - {% if 'TRACK_IMPORT_HISTORY'|setting_enabled %} - - {% endif %} - {% endblock tests_header %} - - - - {% for test in tests %} - - {% block test_body %} - - - - - - - - - - {% if 'TRACK_IMPORT_HISTORY'|setting_enabled %} - - {% endif %} - {% endblock test_body %} - - {% endfor %} - -
    Title / TypeDateLeadTotal FindingsActive (Verified / Fixable)MitigatedDuplicatesNotesReimports
    - - {{ test }} - {% if test.version %} - - - {{ test.version }} - - {% endif %} - {% include "dojo/snippets/tags.html" with tags=test.tags.all %} - {{ test.target_start|date }} - {{ test.target_end|date }} - {% if test.lead.get_full_name and test.lead.get_full_name.strip %} - {{ test.lead.get_full_name }} - {% elif test.lead %} - {{ test.lead }} - {% endif %} - {{ test.count_findings_test_all }} - {{ test.count_findings_test_active }}  - ({{ test.count_findings_test_active_verified }} - / {{ test.count_findings_test_active_fix_available }}) - {{ test.count_findings_test_mitigated }}{{ test.count_findings_test_dups }} - {% if test.notes.count %} - - {{ test.notes.count }} - - {% endif %} - - {{ test.total_reimport_count }} -
    -
    -
    - {% include "dojo/paging_snippet.html" with page=tests page_size=True %} -
    - {% else %} -
    - No Tests found. -
    - {% endif %} -
    -
    -
    -
    -
    -
    -
    -

    Risk Acceptance - {% if eng.product.enable_full_risk_acceptance %} - {% if eng|has_object_permission:"Risk_Acceptance" %} - - - {% endif %} - {% endif %} -

    -
    - {% if risks_accepted %} -
    - - - - {% block risk_acceptance_header %} - - - - - - - - - - - {% endblock risk_acceptance_header %} - - - - {% for risk_acceptance in risks_accepted %} - - {% block risk_acceptances %} - - - - - - - - - {% if risk_acceptance.filename %} - - {% else %} - - {% endif %} - - {% endblock risk_acceptances %} - - {% endfor %} - -
    DateAccepted ByNameDecisionExpirationFindingsProofOwner
    -
      - -
    -
    {{ risk_acceptance.created|date }}{{ risk_acceptance.accepted_by }}{{ risk_acceptance.name }} - {{ risk_acceptance.get_decision_display|default_if_none:"" }} - {% if risk_acceptance.decision_details %} -   - {% endif %} - - {% if risk_acceptance.expiration_date %} - {{ risk_acceptance.expiration_date|date }} - {% else %} - Never - {% endif %} - {{ risk_acceptance.accepted_findings_count }}Yes -   - No{{ risk_acceptance.owner.get_full_name }}
    -
    - {% else %} -
    - No Risk Acceptances found. -
    - {% endif %} -
    -
    -
    - {% block global_risk_acceptances %}{% endblock %} -
    -
    -

    Additional Features - -

    -
    -
    - {% if eng.engagement_type == "Interactive" and system_settings.enable_checklists %} -
    -
    -

    Checklist -   - - - {% if eng|has_object_permission:"Engagement_Edit" %} - {% if check %} - - - {% else %} - - - {% endif %} - {% endif %} -

    -
    -
    - {% if check %} -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - -
    SessionEncryptionConfigurationAuthenticationAuthorizationData InputSensitive DataOther
    {{ check.session_management }}{{ check.encryption_crypto }}{{ check.configuration_management }}{{ check.authentication }}{{ check.authorization_and_access_control }}{{ check.data_input_sanitization_validation }}{{ check.sensitive_data }}{{ check.other }}
    -
    -
    - {% else %} -
    -
    - Checklist has not been completed. -
    -
    - {% endif %} -
    -
    - {% endif %} - {% if system_settings.enable_questionnaires %} -
    -
    -

    Questionnaires -   - - - {% if eng|has_object_permission:"Engagement_Edit" %} - {% add_surveys eng %} - {% endif %} -

    -
    -
    -
    -
    -
    - {% show_surveys eng users %} -
    -
    - -
    -
    -
    - {% endif %} -
    -
    -

    Notes - - -

    -
    -
    - {% if eng|has_object_permission:"Note_Add" %} -
    - {% csrf_token %} - {% include "dojo/form_fields.html" with form=form %} -
    -
    - -
    -
    -
    - {% endif %} -
    -
    -

    Note Log - - -

    -
    -
    - {% for note in notes %} -
    -
    -
    - {% if user.username == note.author.username or eng|has_object_permission:"Note_Delete" or user.is_superuser %} -
    -
    - {% csrf_token %} - - -
    -
    - {% endif %} - {% if user.username == note.author.username or eng|has_object_permission:"Note_Edit" %} -
    -
    - {% csrf_token %} - - -
    -
    - {% endif %} - {% if user.username == note.author.username or eng|has_object_permission:"Note_View_History" %} -
    -
    - {% csrf_token %} - - -
    -
    - {% endif %} -
    - {{ note.author.username }} - commented {{ note.date }} -
    - {% if note.edited %} -
    - {{ note.editor.username }} - edited {{ note.edit_time }} -
    - {% endif %} - {% if note.private %} -
    - (will not appear in report) -
    - {% endif %} -
    -
    - {% if note.note_type != None %} - Note type : {{ note.note_type }} -

    - {% endif %} - {{ note|linebreaks }} -
    -
    -
    - {% endfor %} -
    -
    -
    -
    -
    -
    -

    Files -   - - - {% if eng|has_object_permission:"Engagement_Edit" %} - - - {% endif %} -

    -
    -
    - {% for file in files %} -
    -
    - {% url 'access_file' fid=file.id oid=eng.id obj_type='Engagement' as image_url %} - - {% if file|get_thumbnail %} - thumbnail - {% else %} - - {% endif %} - -
    -
    - {{ file.title }} -
    -
    - {% endfor %} -
    -
    -
    -
    - -
    -
    -
    -
    -

    - {% if eng.name %} - {{ eng.name }} - {% else %} - Engagement for {{ eng.product }} - {% endif %} - {% if eng.version %} - - - {{ eng.version }} - - {% endif %} - {% include "dojo/snippets/tags.html" with tags=eng.tags.all %} -

    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% if jissue and jira_project %} - - - - - {% elif jira_project %} - - - - - {% endif %} - - - - - - - - - -
    Status - {% if eng.status == "Blocked" %} - - {% elif eng.status == "On Hold" %} - - {% else %} - - {% endif %} - {{ eng.status }} - -
    Dates - {{ eng.target_start|date:"jS F" }} - {{ eng.target_end|date:"jS F" }} - -
    Length - {{ eng.target_start|datediff_time:eng.target_end }} - {% if eng.is_overdue and eng.status != 'Completed'%} - -
    - {{ eng.target_end|overdue }} overdue -
    -
    - {% endif %} -
    - - {% if eng.engagement_type == "Interactive" %} - Lead - {% else %} - Service Account - {% endif %} - - - {% if eng.lead.get_full_name and eng.lead.get_full_name.strip %} - {{ eng.lead.get_full_name }} - {% elif eng.lead %} - {{ eng.lead }} - {% else %} - None Assigned - {% endif %} -
    Tracker - {% if eng.tracker %} - - {{ eng.tracker|last_value }} - {% else %} - {{ eng.tracker|notspecified}} - {% endif %} -
    Repo - {% if eng.source_code_management_uri %} - - {{ eng.source_code_management_uri|last_value }} - - {% else %} - {{ eng.source_code_management_uri|notspecified}} - {% endif %} -
    Test Strategy - {% if eng.test_strategy %} - - {{ eng.test_strategy|last_value }} - - {% else %} - {{ eng.test_strategy|notspecified}} - {% endif %} -
    Jira - {{ eng | jira_key }} - (epic) - - {% if eng|has_object_permission:"Engagement_Edit" %} -   - - {% endif %} -
    JIRA - {{ eng | jira_key }} - {% if jira_project.engagement is not eng %} - (inherited) - {% else %} - (project) - {% endif %} - -
    Updated - {{ eng.updated|naturaltime|default_if_none:"" }} -
    Created - {{ eng.created|naturaltime|default_if_none:"" }} -
    -
    -
    - {% if eng.engagement_type == "CI/CD" %} -
    -
    -
    -

    - CI/CD Engagement Details -

    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Build ID{{ eng.build_id|notspecified }}
    Commit Hash{{ eng.commit_hash|notspecified|truncatechars_html:13 }}
    Branch/Tag{{ eng.branch_tag|notspecified }}
    SCM Server - {% if eng.cicd_scm_server.id %} - {{ eng.cicd_scm_server.name }} - {% if eng.cicd_scm_server.url %} - - {% endif %} - {% else %} - {{ eng.cicd_scm_server.name|notspecified }} - {% endif %} -
    Build Server - {% if eng.cicd_build_server.id %} - {{ eng.cicd_build_server.name }} - {% if eng.cicd_build_server.url %} - - {% endif %} - {% else %} - {{ eng.cicd_build_server.name|notspecified }} - {% endif %} -
    Orchestration - {% if eng.cicd_orchestration_engine.id %} - {{ eng.cicd_orchestration_engine.name }} - {% if eng.cicd_orchestration_engine.url %} - - {% endif %} - {% else %} - {{ eng.cicd_orchestration_engine.name|notspecified }} - {% endif %} -
    -
    -
    -
    - {% endif %} -
    -
    -
    - ProTip! Type e to edit this engagement. Type i to import scan results or a to add tests. -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_engagements.html b/dojo/templates_classic/dojo/view_engagements.html deleted file mode 100644 index ad6fb357d3b..00000000000 --- a/dojo/templates_classic/dojo/view_engagements.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends "base.html" %} -{% load humanize %} -{% load display_tags %} -{% load static %} -{% block add_styles %} - {{ block.super }} - .tooltip-inner { - max-width: 350px; - } -{% endblock %} -{% block content %} - {{ block.super }} -
    - {% include "dojo/snippets/engagement_list.html" with engs=engs filter=engs_filter count=engs_count prefix="engs" status="open" type=engagement_type recent_test_day_count=recent_test_day_count %} - {% include "dojo/snippets/engagement_list.html" with engs=queued_engs filter=queued_engs_filter count=queued_engs_count prefix="queued_engs" status="paused" type=engagement_type recent_test_day_count=recent_test_day_count %} - {% include "dojo/snippets/engagement_list.html" with engs=inactive_engs filter=inactive_engs_filter count=inactive_engs_count prefix="inactive_engs" status="closed" type=engagement_type recent_test_day_count=recent_test_day_count %} -
    -
    - ProTip! Type a to add a new engagement. -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_engineer.html b/dojo/templates_classic/dojo/view_engineer.html deleted file mode 100644 index 2860ca5ffed..00000000000 --- a/dojo/templates_classic/dojo/view_engineer.html +++ /dev/null @@ -1,655 +0,0 @@ -{% extends "base.html" %} -{% load event_tags %} -{% load display_tags %} -{% load static %} -{% block add_styles %} - {{ block.super }} - #chart_div3 .flot-x-axis .tickLabel, #chart_div4 .flot-x-axis .tickLabel - { top: 290px !important; - font-size: 10px; - transform: rotate(-15deg); - -ms-transform:rotate(-15deg); /* IE 9 */ - -moz-transform:rotate(-15deg); /* Firefox */ - -webkit-transform:rotate(-15deg); /* Safari and Chrome */ - -o-transform:rotate(-15deg); /* Opera */ - } - #chart_div, #chart_div2, #chart_div3, #chart_div4 {height: 300px} -{% endblock %} -{% block content %} - {{ block.super }} -

    {{ name }}

    -
    -
    -
    -
    -
    -
    - Open Bug Count by Month -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - Risk Accepted Bug Count by Month -
    - -
    -
    -
    - -
    - -
    - -
    -
    -
    - Open Bug Count by Week -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    - Risk Accepted Bug Count by Week -
    - -
    -
    -
    - -
    - -
    -
    -
    -
    -
    -
    -
    -
    - Detail Breakdown -
    - - - - - - - - - - - - - {% for detail in details %} - - - - - - - - - - {% endfor %} -
    TeamApplication NameSeverityShort DescriptionDays OpenStatus Engineer
    {{ detail.0 }}{{ detail.1 }}{{ detail.2 }}{{ detail.3 }}{{ detail.4 }}{{ detail.5 }}{{ detail.6 }}
    -
    - -
    -
    -
    -
    -
    -
    - Top 10 products by bug severity -
    - - {% if metric %} - - - - - - - - - - {% for t in update %} - - - - - - - - - {% endfor %} -
    ProductCriticalHighMediumLowTotal
    {{ t.0|safe }}{{ t.1 }}{{ t.2 }}{{ t.3 }}{{ t.4 }}{{ t.5 }}
    - {% endif %} - -
    - -
    -
    -
    -
    -
    -
    - Opened This Week -
    - - - - - - - - - - - - - - - - -
    CriticalHighMediumLowTotal
    {{ open_week_count.crit }}{{ open_week_count.high }}{{ open_week_count.med }}{{ open_week_count.low }} {{ open_week | length }}
    - - - - - - - - - - - {% for key, value in o_week_dict.items %} - - - {% for i in value %} - - {% endfor %} - - {% endfor %} -
    ProductCriticalHighMediumLowTotal
    {{ key }} {{ i }}
    - -
    - -
    -
    -
    -
    - Opened This Month -
    - - - - - - - - - - - - - - - - -
    CriticalHighMediumLowTotal
    {{ critical_open_month }}{{ high_open_month }}{{ medium_open_month }}{{ low_open_month }} {{ open_month | length }}
    - - - - - - - - - - - {% for key, value in o_dict.items %} - - - {% for i in value %} - - {% endfor %} - - {% endfor %} -
    ProductCriticalHighMediumLowTotal
    {{ key }} {{ i }}
    - -
    - -
    -
    -
    -
    -
    -
    - Risk Accepted This Week -
    - - - - - - - - - - - - - - - - -
    CriticalHighMediumLowTotal
    {{ accepted_week_count.crit }}{{ accepted_week_count.high }}{{ accepted_week_count.med }}{{ accepted_week_count.low }} {{ accepted_week | length }}
    - - - - - - - - - - - {% for key, value in a_week_dict.items %} - - - {% for i in value %} - - {% endfor %} - - {% endfor %} -
    ProductCriticalHighMediumLowTotal
    {{ key }} {{ i }}
    - -
    - -
    -
    -
    -
    - Risk Accepted This Month -
    - - - - - - - - - - - - - - - - -
    CriticalHighMediumLowTotal
    {{ critical_a_month }}{{ high_a_month }}{{ medium_a_month }}{{ low_a_month }} {{ a_month | length }}
    - - - - - - - - - - - {% for key, value in a_dict.items %} - - - {% for i in value %} - - {% endfor %} - - {% endfor %} -
    ProductCriticalHighMediumLowTotal
    {{ key }} {{ i }}
    - -
    - -
    -
    -
    - -
    -
    -
    - Closed This Week -
    - - - - - - - - - - - - - - - - -
    CriticalHighMediumLowTotal
    {{ closed_week_count.crit }}{{ closed_week_count.high }}{{ closed_week_count.med }}{{ closed_week_count.low }} {{ closed_week | length }}
    - - - - - - - - - - - {% for key, value in c_week_dict.items %} - - - {% for i in value %} - - {% endfor %} - - {% endfor %} -
    ProductCriticalHighMediumLowTotal
    {{ key }} {{ i }}
    - -
    - -
    -
    -
    -
    - Closed This Month -
    - - - - - - - - - - - - - - - - -
    CriticalHighMediumLowTotal
    {{ critical_c_month }}{{ high_c_month }}{{ medium_c_month }}{{ low_c_month }} {{ closed_month | length }}
    - - - - - - - - - - - {% for key, value in c_dict.items %} - - - {% for i in value %} - - {% endfor %} - - {% endfor %} -
    ProductCriticalHighMediumLowTotal
    {{ key }} {{ i }}
    - -
    - -
    -
    -
    -
    -
    -
    - Trending Open Bug Count -
    - - - - - - - - - - - {% for items in week_stuff %} - - {% for item in items %} - - {% endfor %} - - {% endfor %} -
    WeeklyCriticalHighMediumLowTotalClosed
    {{ item }}
    - - - - - - - - - - - {% for items in total %} - - {% for item in items %} - - {% endfor %} - - {% endfor %} -
    MonthlyCriticalHighMediumLowTotalClosed
    {{ item }}
    -
    -
    -
    -
    -
    - Trending Risk Accepted Bug Count -
    - - - - - - - - - - {% for items in week_a_stuff %} - - {% for item in items %} - - {% endfor %} - - {% endfor %} -
    By WeekCriticalHighMediumLowTotal
    {{ item }}
    - - - - - - - - - - {% for items in a_total %} - - {% for item in items %} - - {% endfor %} - - {% endfor %} -
    By MonthCriticalHighMediumLowTotal
    {{ item }}
    -
    -
    -
    -
    - {% if metric %} -
    -
    -
    - All Issues -
    - - - - - - - - - - {% for t in total_update %} - - - - - - - - - {% endfor %} -
    ProductCriticalHighMediumLowTotal
    {{ t.0|safe }}{{ t.1 }}{{ t.2 }}{{ t.3 }}{{ t.4 }}{{ t.5 }}
    -
    -
    - {% endif %} -
    -
    -
    - Age of Open Issues (Critical and High Only) -
    - - - - - - - - - - - - - - - - - - - - - -
    DaysBug Count
    0 - 30 Days{{ lt }}
    31 - 60 Days{{ ls }}
    61 - 90 Days{{ ln }}
    91+ Days{{ mn }}
    -
    -
    -
    -
    -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - - - - {% block metrics %} - - {% endblock metrics %} - -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/view_finding.html b/dojo/templates_classic/dojo/view_finding.html deleted file mode 100755 index e00838626da..00000000000 --- a/dojo/templates_classic/dojo/view_finding.html +++ /dev/null @@ -1,1600 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load multiply %} -{% load authorization_tags %} -{% load humanize %} -{% load static %} -{% load i18n %} - -{% block add_styles %} - {{ block.super }} -{% endblock %} -{% block add_css_before %} - {{ block.super }} - - -{% endblock %} -{% block content %} - {{ block.super }} -{% user_can_clear_peer_review finding dojo_user as clear_peer_review %} -
    -
    -
    -
    -

    - {{ finding.title }} - {% include "dojo/snippets/tags.html" with tags=finding.tags.all %} - {% if finding.last_reviewed %} - Last Reviewed {{ finding.last_reviewed | naturalday }} - by {{ finding.last_reviewed_by }}, - {% else %} - Last Reviewed {{ finding.date | naturalday }} by {{ finding.reporter }}, - {% endif %} - {% if finding.last_status_update %} - Last Status Update {{ finding.last_status_update | naturalday }}, - {% endif %} - Created - {% if finding.last_reviewed > finding.created %} - {{ finding.created | naturalday }} - {% else %} - {{ finding.date | naturalday }} - {% endif %} - - {% if latest_test_import_finding_action %} - , Last Mentioned in (Re)Import: {{ latest_test_import_finding_action.created | naturalday }} as {{ latest_test_import_finding_action.get_action_display }} - {% endif %} -

    - -
    -
    -
    - - {% if finding.under_review %} - - - - {% endif %} - {% if finding.under_defect_review %} - - - - {% endif %} - - {% block header_head %} - - - {% if system_settings.enable_finding_sla %} - - {% endif %} - {% if finding.scanner_confidence %} - - {% endif %} - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - {% if finding.duplicate_finding %} - - {% endif %} - {% if duplicate_cluster and not finding.duplicate %} - - {% elif duplicate_cluster and finding.duplicate %} - - {% endif %} - - - - {% if finding.publish_date %} - - {% endif %} - {% if finding.planned_remediation_date %} - - {% endif%} - {% if finding.planned_remediation_version %} - - {% endif %} - - {% if finding.mitigated %} - - - {% endif %} - - - {% if finding.epss_score != None or finding.epss_percentile != None %} - {% if finding.epss_score != None and finding.epss_percentile != None %} - - {% elif finding.epss_score != None and finding.epss_percentile == None %} - - {% elif finding.epss_score == None and finding.epss_percentile != None %} - - {% endif %} - {% endif %} - - {% if finding.vuln_id_from_tool %} - - {% endif %} - {% endblock header_head %} - - - {% block header_body %} - - - {% if system_settings.enable_finding_sla %} - - {% endif %} - {% if finding.scanner_confidence %} - - {% endif %} - - {% if finding.risk_acceptance_set.all %} - - {% endif %} - {% if finding.duplicate_finding %} - - {% endif %} - {% if duplicate_cluster %} - - {% endif %} - - - - {% if finding.publish_date %} - - {% endif %} - {% if finding.planned_remediation_date %} - - {% endif %} - {% if finding.planned_remediation_version %} - - {% endif %} - - {% if finding.mitigated %} - - - {% endif %} - - - {% if finding.epss_score != None or finding.epss_percentile != None %} - {% if finding.epss_score != None and finding.epss_percentile != None %} - - {% elif finding.epss_score != None and finding.epss_percentile == None %} - - {% elif finding.epss_score == None and finding.epss_percentile != None %} - - {% endif %} - {% endif %} - - {% endwith %} - {% if finding.vuln_id_from_tool %} - - {% endif %} - {% endblock header_body %} - -
    - - - Alert: This Finding is under review and may not be 100% accurate. - - {% if finding|has_object_permission:"Finding_Edit" and clear_peer_review %} - [Clear Review] - {% endif %} -
    - - - Alert: Please review this finding to verify if the defect is remediated. - - [Review Finding for Closure] -
    IDSeveritySLAScanner ConfidenceStatusRisk AcceptanceOriginalDuplicatesDuplicate ClusterTypeDate discoveredAgeVuln Publish datePlanned Remediation{% trans "Planned Remediation version" %}ReporterDate MitigatedMitigated ByCWEVulnerability IdEPSS Score / PercentileEPSS ScoreEPSS PercentileFound byVuln ID from tool
    {{ finding.id }} - - {% if finding.severity %} - {{ finding.severity_display }} - {% if system_settings.enable_cvss4_display and finding.cvssv4_score or system_settings.enable_cvss3_display and finding.cvssv3_score %} - - ({% if system_settings.enable_cvss4_display and finding.cvssv4_score %}{{ finding.cvssv4_score }}{% if system_settings.enable_cvss3_display and finding.cvssv3_score %}, {% endif %}{% endif %}{% if system_settings.enable_cvss3_display and finding.cvssv3_score %}{{ finding.cvssv3_score }}{% endif %}) - {% endif %} - {% else %} - Unknown - {% endif %} - - - {{ finding|finding_sla }} - {{finding.get_scanner_confidence_text}} - {% comment %} - {% if finding.duplicate %} - {% include "dojo/finding_related_actions.html" with similar_finding=finding finding_context=finding intro=finding|finding_display_status|safe %} - {% else %} - {{ finding|finding_display_status|safe }} - {% endif %} - {% endcomment %} - {{ finding|finding_display_status|safe }} -  {{ finding|import_history }} - - {% for ra in finding.risk_acceptance_set.all|slice:":5" %} - - {% endfor %} - - - -
    - -
    -
    - {% if finding.static_finding and finding.dynamic_finding > 0 %} - Static/Dynamic - {% elif finding.static_finding > 0 %} - Static - {% else %} - Dynamic - {% endif %} - {{ finding.date }}{{ finding.age }} days{{ finding.publish_date }}{{ finding.planned_remediation_date }}{{ finding.planned_remediation_version }}{{ finding.reporter }}{{ finding.mitigated }}{{ finding.mitigated_by }} - {% if finding.cwe > 0 %} - - {{ finding.cwe }} - - {% endif %} - - {% with finding|first_vulnerability_id as first_vulnerability_id %} - {% if first_vulnerability_id %} - {% if first_vulnerability_id|has_vulnerability_url %} - - {{ first_vulnerability_id }} - - {% else %} - {{ first_vulnerability_id }} - {% endif %} - {% endif %} - {{ finding.epss_score|multiply:100|floatformat:"2" }}% / {{ finding.epss_percentile|multiply:100|floatformat:"2" }}%{{ finding.epss_score|multiply:100|floatformat:"2" }}%{{ finding.epss_percentile|multiply:100|floatformat:"2" }}% - {% if found_by %} - {% for scanner in found_by %} - {{ scanner }} - {% endfor %} - {% else %} - {{ finding.test.test_type }} - {% endif %} - {{ finding.vuln_id_from_tool }}
    -
    -
    - -
    - {% with finding|additional_vulnerability_ids as additional_vulnerability_ids %} - {% if additional_vulnerability_ids %} -
    - - - - - - - -
    Additional Vulnerability Ids
    - {% for vulnerability_id in additional_vulnerability_ids %} - {% if vulnerability_id|has_vulnerability_url%} - - {{ vulnerability_id }} - {% else %} - {{ vulnerability_id }} - {% endif %} - {% if not forloop.last %}, {% endif %} - {% endfor %} -
    -
    - {% endif %} - {% endwith %} - {% with finding.cwes|slice:"1:" as additional_cwes %} - {% if additional_cwes %} -
    - - - - - - - -
    Additional CWEs
    - {% for cwe in additional_cwes %} - - {{ cwe }} - {% if not forloop.last %}, {% endif %} - {% endfor %} -
    -
    - {% endif %} - {% endwith %} -
    - - {% if finding.static_finding or finding.line > 0 %} - {% if finding.sast_source_object or finding.sast_sink_object or finding.sast_source_file_path or finding.sast_source_line > 0 %} - {# For tools that give information on both source (start) and sink (end) of the attack vector #} - -
    - - - - - - - - - - - -
    Source FilepathSource Line NumberSource Object
    - - {{ finding.get_sast_source_file_path_with_link|safe }} - - - - {{ finding.sast_source_line }} - - - - {{ finding.sast_source_object }} - -
    -
    -
    - - - - - - - - - - - -
    Sink FilepathSink Line NumberSink Object
    - - {{ finding.get_file_path_with_link|safe }} - - - - {{ finding.line }} - - - - {{ finding.sast_sink_object }} - -
    -
    - {% endif %} - {% endif %} - {% if finding.service or finding.file_path or finding.line > 0 or finding.has_jira_configured or finding.has_jira_issue or finding.github_issue or finding.github_conf_new or finding.finding_group or finding.component_name or finding.nb_occurences > 1 %} -
    - - - - {% if finding.file_path %} - - {% endif %} - {% if finding.line %} - - {% endif %} - {% if finding.nb_occurences > 1 %} - - {% endif %} - {% if finding.component_name %} - - {% endif %} - {% if finding.component_version %} - - {% endif %} - {% if finding.fix_available %} - - {% endif %} - {% if finding.fix_version %} - - {% endif %} - {% if finding.has_jira_configured or finding.jira_issue %} - - - {% endif %} - {% if finding.github_conf_new or finding.github_issue %} - - {% endif %} - {% if 'is_finding_groups_enabled'|system_setting_enabled and finding.finding_group %} - - {% endif %} - {% if finding.effort_for_fixing %} - - {% endif %} - - - - {% if finding.file_path %} - - {% endif %} - {% if finding.line %} - - {% endif %} - {% if finding.nb_occurences > 1 %} - - {% endif %} - {% if finding.component_name %} - - {% endif %} - {% if finding.component_version %} - - {% endif %} - {% if finding.fix_available %} - - {% endif %} - {% if finding.fix_version %} - - {% endif %} - {% if finding.has_jira_configured or finding.has_jira_issue or finding.has_jira_group_issue %} - - - {% endif %} - {% if finding.github_conf_new or finding.github_issue %} - - {% endif %} - {% if 'is_finding_groups_enabled'|system_setting_enabled and finding.finding_group %} - - {% endif %} - {% if finding.effort_for_fixing %} - - {% endif %} - -
    ServiceLocationLine NumberNb occurencesComponent NameComponent VersionFix AvailableFixed VersionJIRAJIRA ChangeGitHubGroup{% trans "Effort for fixing" %}
    - - {{ finding.service }} - - - - {{ finding.get_file_path_with_link|safe }} - - - - - {{ finding.line }} - - - - {{ finding.nb_occurences }} - - - - {{ finding.component_name }} - - - - {{ finding.component_version }} - - - - {{ finding.fix_available }} - - - - {{ finding.fix_version }} - - - {% if finding.has_jira_group_issue %} - {{ finding.finding_group | jira_key }} - {% endif %} - {% if finding.has_jira_issue %} - {{ finding | jira_key }} - - - {% else %} - {% if can_be_pushed_to_jira %} - {% if not finding.has_jira_group_issue %} - None - - {% comment %} - - {% endcomment %} - {% endif %} - {% else %} - - - {% endif %} - {% endif %} - - {% if finding.has_jira_group_issue %} -
    {{ finding.finding_group.jira_issue.jira_change|naturalday }}
    - {% elif finding.jira_issue %} -
    {{ finding.jira_issue.jira_change|naturalday }}
    - {% endif %} -
    - {% if finding.github_issue %} - #{{ finding.github_issue.issue_id }} - {% endif %} - - {{ finding.finding_group.name }} - - - - {{ finding.effort_for_fixing }} - -
    -
    - {% endif %} - {% if finding.param or finding.payload %} -
    - - - - {% if finding.payload %} - - {% endif %} - - - - {% if finding.payload %} - - {% endif %} - -
    Injected Parameter(s)Payload
    - - {{ finding.param|default_if_none:"" }} - - - - {{ finding.payload|default_if_none:"" }} - -
    -
    - {% endif %} - - {% if finding.duplicate_finding_set %} - {% comment %} - little extra div to serve as anchor, with some padding and padding cancelling margin to make sure it scrolls into view correctly - {% endcomment %} -
    -
    -
    -

    Duplicate Cluster ({{ finding|finding_duplicate_cluster_size }})

    -
    -
    - {% if finding.duplicate_finding %} - {% include "dojo/finding_related_list.html" with finding_context=finding finding_first_related=finding.duplicate_finding finding_list=duplicate_cluster prefix='duplicate' %} - {% else %} - {% include "dojo/finding_related_list.html" with finding_context=finding finding_first_related=finding finding_list=duplicate_cluster prefix='duplicate' %} - {% endif %} -
    -
    - {% endif %} - - {% if similar_findings_enabled %} -
    -
    -

    Similar Findings ({{ similar_findings.paginator.count }}) - - - - -   - - -

    -
    - -
    - {% url 'view_finding' finding.id as finding_url %} - {% include "dojo/filter_snippet.html" with form=similar_findings_filter.form form_id="similar" clear_js=True restart_link=finding_url %} -
    - {% if similar_findings_filter %} -
    - {% include "dojo/finding_related_list.html" with finding_context=finding finding_list=similar_findings prefix='similar' %} -
    - {% endif %} -
    -
    - {% endif %} - {% comment %} Add a form to (ab)use to submit any actions related to similar/duplicates as POST requests {% endcomment %} - - - - {% if 'TRACK_IMPORT_HISTORY'|setting_enabled and latest_test_import_finding_action %} -
    -
    -
    -

    - {% trans "Import History" %} ({{ test_import_finding_actions_count }}) - - - -

    -
    -
    -
    - {% include "dojo/filter_snippet.html" with form=test_import_filter.form %} - {% include "dojo/filter_snippet.html" with form=test_import_finding_action_filter.form %} -
    -
    - {% if paged_test_import_finding_actions %} - - - - - - - - - - - - - - - {% for test_import_finding_action in paged_test_import_finding_actions %} - - - - - - - - - - - - {% endfor %} - -
    {% trans "Action" %}{% trans "Date/Time" %}{% trans "Import Type" %}{% trans "Branch/Tag" %}{% trans "Build ID" %}{% trans "Commit" %}{% trans "Version" %}{% trans "Endpoint" %}
    - {{ test_import_finding_action.get_action_display }} - - - {{ test_import_finding_action.test_import.created|date:"DATETIME_FORMAT" }} - - {{ test_import_finding_action.test_import|import_settings_tag }} - - {{ test_import_finding_action.test_import.type }} - - {{ test_import_finding_action.test_import.branch_tag|default_if_none:"" }} - - {{ test_import_finding_action.test_import.build_id|default_if_none:"" }} - - {{ test_import_finding_action.test_import.commit_hash|default_if_none:"" }} - - {{ test_import_finding_action.test_import.version|default_if_none:"" }} - - {% if V3_FEATURE_LOCATIONS %} - {{ test_import_finding_action.test_import.import_settings.locations|default_if_none:"" }} - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - {{ test_import_finding_action.test_import.import_settings.endpoint|default_if_none:"" }} - {% endif %} -
    - {% else %} -
    -

    - {% trans "No import history found." %} -

    -
    - {% endif %} -
    - {% include "dojo/paging_snippet.html" with page=paged_test_import_finding_actions prefix='test_import_finding_actions' page_size=True %} -
    -
    -
    - {% endif %} - - - - - {% include "dojo/snippets/endpoints.html" with finding=finding destination="UI" %} - -
    -
    -
    -

    Description

    -
    -
    -
    {{ finding.description|markdown_render|default_if_none:"" }}
    -
    -
    - - {% if files %} -
    -
    -
    -
    -

    Files - - - - -

    -
    - -
    - {% for file in files %} -
    -
    - {% url 'access_file' fid=file.id oid=finding.id obj_type='Finding' as image_url %} - - {% if file|get_thumbnail %} - thumbnail - {% else %} - - {% endif %} - -
    -
    - {{ file.title }} -
    -
    - {% endfor %} -
    -
    -
    -
    - {% endif %} - -
    -
    -

    Mitigation

    -
    -
    -
    {{ finding.mitigation|markdown_render|default_if_none:"" }}
    -
    -
    - {% if finding.burprawrequestresponse_set.all %} -
    -
    -

    Request / Response Pairs -

    -
    -
    - {% for req_resp in finding.burprawrequestresponse_set.all %} -
    -
    -

    Request #{{ forloop.counter }} - -

    -
    -
    -
    {{ req_resp.get_request }}
    -
    -
    -
    -
    -

    Response #{{ forloop.counter }} - -

    -
    - -
    -
    {{ req_resp.get_response }}
    -
    -
    - {% endfor %} -
    -
    - {% endif %} -
    -
    -

    Impact

    -
    -
    -
    {{ finding.impact|markdown_render|default_if_none:"" }}
    -
    -
    - -
    -
    -

    Steps To Reproduce

    -
    -
    -
    {{ finding.steps_to_reproduce|markdown_render|default_if_none:"" }}
    -
    -
    - -
    -
    -

    Severity Justification

    -
    -
    -
    {{ finding.severity_justification|markdown_render|default_if_none:"" }}
    -
    -
    - -
    -
    -

    References

    -
    -
    -
    {{ finding.get_references_with_links|markdown_render|default_if_none:"" }}
    -
    -
    - -
    - - {% include "dojo/snippets/sonarqube_history.html" with finding=finding only %} - - {% include "dojo/snippets/comments.html" with notes=notes object=finding destination="finding" %} - -
    -
    - -
    - ProTip! Type e to edit any finding, p and n to navigate to the previous or next finding, v to verify, and c to close the finding. -
    -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - - - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_finding_group.html b/dojo/templates_classic/dojo/view_finding_group.html deleted file mode 100644 index 326967ce4a2..00000000000 --- a/dojo/templates_classic/dojo/view_finding_group.html +++ /dev/null @@ -1,30 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load display_tags %} -{% load static %} -{% block content %} - {% comment %} include inherits the current context so findings, filtered and other variables {% endcomment %} - {% include "dojo/findings_list_snippet.html" %} -
    -
    -
    -
    -
    -

    - Edit {{ filter_name }} -

    -
    -
    -
    - {% csrf_token %} - {% include "dojo/form_fields.html" with form=edit_finding_group_form %} - -
    -
    - -
    -
    -
    -
    -
    -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/view_note_history.html b/dojo/templates_classic/dojo/view_note_history.html deleted file mode 100644 index e05df266c9f..00000000000 --- a/dojo/templates_classic/dojo/view_note_history.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% block content %} - {{ block.super }} -

    {% trans "Note History" %}


    -
    {% csrf_token %} - {% for entry in history %} -
    -
    -
    -
    - {{ entry.current_editor }} - {% if forloop.first %} - {% blocktranslate with time=entry.time%}commented {{ time }}{% endblocktranslate %} - {% else %} - {% blocktranslate with time=entry.time%}made changes on {{ time }}{% endblocktranslate %} - {% endif %} -
    -
    -
    - {% if note.note_type != None %} - {% trans "Note type:" %} {{ note.note_type }}
    - {% endif %} - {{ entry.data }} -
    -
    -
    - {% endfor %} -
    -
    - -
    -
    -
    -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/view_notification_webhooks.html b/dojo/templates_classic/dojo/view_notification_webhooks.html deleted file mode 100644 index 6b02c0888d3..00000000000 --- a/dojo/templates_classic/dojo/view_notification_webhooks.html +++ /dev/null @@ -1,101 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load display_tags %} -{% load i18n %} -{% load authorization_tags %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -

    - Notification Webhook List - -

    -
    - -
    - {% if nwhs %} - -
    - {% include "dojo/paging_snippet.html" with page=nwhs page_size=True %} -
    - -
    - - - - - - - - - - - - {% for nwh in nwhs %} - - - - - - - {% if "dojo.edit_notification_webhook"|has_configuration_permission:request %} - - {% endif %} - - {% endfor %} - -
    {% dojo_sort request 'Notification Webhook Name' 'name' 'asc' %}URLStatusNoteOwner
    {{ nwh.name }}{{ nwh.url }}{{ nwh.get_status_display }} - {% if nwh.first_error or nwh.last_error %} - - {% endif %} - {{ nwh.note }}{% if nwh.owner %}{{ nwh.owner }}{% else %}System Webhook{% endif %} - -
    -
    -
    - {% include "dojo/paging_snippet.html" with page=nwhs page_size=True %} -
    - {% else %} -

    No Notification Webook found.

    - {% endif %} -
    -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_objects.html b/dojo/templates_classic/dojo/view_objects.html deleted file mode 100644 index 51aaf105dbc..00000000000 --- a/dojo/templates_classic/dojo/view_objects.html +++ /dev/null @@ -1,146 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load authorization_tags %} -{% load get_config_setting %} -{% load static %} -{% block add_styles %} - {{ block.super }} - ul#select_by_severity a:hover, ul#bulk_edit a:hover { - cursor: pointer; - } -{% endblock %} -{% block content %} - {{ block.super }} -
    -
    -
    -

    Tracked Files, Paths and Artifacts

    - {% if product|has_object_permission:"Product_Tracking_Files_Add" %} - - {% endif %} -
    -
    -
    - - - - - - - - - {% for object in object_queryset %} - - - - - - - - {% endfor %} -
    ObjectObject TypeNameActionDetails
    {{ object|tracked_object_value }} - {% include "dojo/snippets/tags.html" with tags=object.tags.all %} - {{ object|tracked_object_type }}{{ object.name|default_if_none:"" }}{{ object.review_status }} -
    - {% if product|has_object_permission:"Product_Tracking_Files_Edit" %} - Edit - {% endif %} - {% if product|has_object_permission:"Product_Tracking_Files_Delete" %} - Delete - {% endif %} -
    -
    -
    -
    - - -{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_objects_eng.html b/dojo/templates_classic/dojo/view_objects_eng.html deleted file mode 100644 index 335b9feec27..00000000000 --- a/dojo/templates_classic/dojo/view_objects_eng.html +++ /dev/null @@ -1,130 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load get_config_setting %} -{% load static %} -{% block add_styles %} - {{ block.super }} - ul#select_by_severity a:hover, ul#bulk_edit a:hover { - cursor: pointer; - } -{% endblock %} -{% block content %} - {{ block.super }} -{% if object_queryset %} -
    -
    -
    -

    Files from Build #{{ object_queryset.0.build_id }} on {{ object_queryset.0.created }}

    -
    -
    -
    - - - - - - - - - - {% for object in object_queryset %} - - - - - - - - - {% endfor %} -
    ObjectObject TypeNameChange TypePercent UnchangedAction
    - {% if object.full_url %} - {{ object.object_id|tracked_object_value }} - {% else %} - {{ object.object_id|tracked_object_value }} - {% endif %} - {% include "dojo/snippets/tags.html" with tags=object.object_id.tags.all %} - {{ object.object_id|tracked_object_type }}{{ object.object_id.name|default_if_none:"" }}{{ object.type|default_if_none:"" }}{{ object.percentUnchanged|default_if_none:"" }}{{ object.object_id.review_status }}
    -
    -
    - - -{% else %} -No files for this build were found. -{% endif %} - -{% endblock %} -{% block postscript %} - {{ block.super }} - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_presets.html b/dojo/templates_classic/dojo/view_presets.html deleted file mode 100644 index cddeaf7dc08..00000000000 --- a/dojo/templates_classic/dojo/view_presets.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load get_config_setting %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -

    Engagement Presets - -

    -
    - {% if presets %} - - - - - - - - - - - {% for preset in presets %} - - - - - - - {% endfor %} - -
    NameTest TypeNetwork
    - {{ preset.title }} - - {% if preset.test_type.all.count > 1 %} - {% for test in preset.test_type.all %} - {{test.name}}{%if not forloop.last%},{%endif%} - {% endfor %} - {% else %} - {{ preset.test_type.all.0.name }} - {% endif %} - - {% if preset.network_locations.all.count > 1 %} - {% for net in preset.network_locations.all %} - {{ net.location }}{%if not forloop.last%},{%endif%} - {% endfor %} - {% else %} - {{ preset.network_locations.all.0.location }} - {% endif %} - -
    - Edit - Delete -
    -
    - {% else %} -
    -

    No engagement presets configured.

    -
    - {% endif %} -
    -
    -
    -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_product_api_scan_configurations.html b/dojo/templates_classic/dojo/view_product_api_scan_configurations.html deleted file mode 100644 index 3561a428763..00000000000 --- a/dojo/templates_classic/dojo/view_product_api_scan_configurations.html +++ /dev/null @@ -1,68 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load get_config_setting %} -{% load static %} -{% block add_styles %} - ul#select_by_severity a:hover, ul#bulk_edit a:hover { - cursor: pointer; - } -{% endblock %} -{% block content %} -
    -
    -
    -

    API Scan Configurations

    - - -
    -
    -
    - - - - - - - {% for product_api_scan_configuration in product_api_scan_configurations %} - - - - - - {% endfor %} -
    Tool ConfigurationDetailsActions
    - {% if request.user.is_superuser %} - - {% endif %} - {{ product_api_scan_configuration.tool_configuration }} - {% if request.user.is_superuser %} - - {% endif %} - {{ product_api_scan_configuration.details }} -
    - Edit - Delete -
    -
    -
    -
    - - -{% endblock %} -{% block postscript %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_product_details.html b/dojo/templates_classic/dojo/view_product_details.html deleted file mode 100644 index f7f42173066..00000000000 --- a/dojo/templates_classic/dojo/view_product_details.html +++ /dev/null @@ -1,645 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load static %} -{% load humanize %} -{% load display_tags %} -{% load authorization_tags %} -{% block add_styles %} - {{ block.super }} - .chart {height: 160px} -{% endblock %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -
    -

    {% trans "Description" %}

    - -
    -
    -
    - {{ prod.description|markdown_render }} -
    -
    - - - - -
    -
    -
    -
    -

    {% trans "Technologies" %} {% if app_analysis %}({{ app_analysis.count }}){% endif %} - {% if prod|has_object_permission:"Technology_Add" %} - - {% endif %} -

    -
    - {% if app_analysis %} - - - {% for app in app_analysis %} - - - - - - {% endfor %} - -
    - - {{ app.name }}{{ app.version|version_num }}
    - {% else %} -
      -
    • {% trans "There are no technologies." %}
    • -
    - {% endif %} -
    -
    -
    -
    -
    -

    {% trans "Regulations" %} {% if prod.regulations.count > 0%}({{ prod.regulations.count }}){%endif%}

    -
    -
      - {% for regulation in prod.regulations.all %} -
    • {{ regulation.get_category_display }}{{ regulation.acronym }} {{ regulation.jurisdiction }}
    • - {% empty %} -
    • {% trans "There are no regulations." %}
    • - {% endfor %} -
    - -
    -
    -
    - - -{% if system_settings.enable_benchmark %} - -{% endif %} - - {% block authorized_users_panel %} -
    -
    -
    -

    {% trans "Authorized Users" %}

    -   - {% if prod|has_object_permission:"Product_Manage_Members" %} - - {% endif %} -
    -
    - {% if authorized_users %} -
    - - - - {% if prod|has_object_permission:"Product_Manage_Members" %} - - {% endif %} - - - - - - {% for u in authorized_users %} - - {% if prod|has_object_permission:"Product_Manage_Members" %} - - {% endif %} - - - - {% endfor %} - -
    {% trans "User" %}{% trans "Email" %}
    - - - {{ u.username }}{{ u.email|default:"" }}
    -
    - {% else %} -
    - {% trans "No authorized users." %} -
    - {% endif %} -
    - {% endblock authorized_users_panel %} - - {% block rbac_members_panel %}{% endblock %} - {% block rbac_groups_panel %}{% endblock %} - -
    - -
    -
    -
    -

    {% trans "Metadata" %}

    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% with jira_project=prod|jira_project %} - {% if jira_project %} - - - - - {% endif %} - {% endwith %} - -
    {% trans "Business Criticality" %}{{ prod.get_business_criticality_display|notspecified }}
    {{ labels.ORG_LABEL }}{{ prod.prod_type }}
    {% trans "Platform" %}{{ prod.get_platform_display|notspecified }}
    {% trans "Lifecycle" %}{{ prod.get_lifecycle_display|notspecified }}
    {% trans "Origin" %}{{ prod.get_origin_display|notspecified }}
    {% trans "User Records" %} - {% if prod.user_records > 0 %} - {{ prod.user_records|intcomma }} - {% else %} - {{ prod.user_records|notspecified }} - {% endif %} -
    {% trans "Revenue" %} - {% if prod.revenue > 0 %} - {% if prod.revenue < 1000000 %}{{ prod.revenue|intcomma }}{% else %}{{ prod.revenue|intword }}{% endif %} - {% else %} - {{ prod.revenue|notspecified }} - {% endif %} -
    {% trans "JIRA" %} {{ prod | jira_key }}
    -
    -
    - - - - {% if system_settings.enable_finding_sla %} -
    -
    -

     Service Level Agreement

    -
    -

    {{ sla.name }}

    -

    {{ sla.description }}

    -
    -
    - - - - - - - - - - - - - - - - - - - -
    Critical - {% if sla.enforce_critical %} - {{ sla.critical }} days to remediate - {% else %} - Not Enforced - {% endif %} -
    High - {% if sla.enforce_high %} - {{ sla.high }} days to remediate - {% else %} - Not Enforced - {% endif %} -
    Medium - {% if sla.enforce_medium %} - {{ sla.medium }} days to remediate - {% else %} - Not Enforced - {% endif %} -
    Low - {% if sla.enforce_low %} - {{ sla.low }} days to remediate - {% else %} - Not Enforced - {% endif %} -
    -
    -
    -{% endif %} - - -{% if languages %} -
    -
    -

    {% trans "Languages" %} ({{ languages.count }})

    -
    -
    -
    -
    -
    - {% if langSummary.files__sum > 0 %} - {{ langSummary.files__sum|intcomma }} {% trans "files" %} {% if langSummary.code__sum %} {% trans "and" %} {%endif%} - {% endif %} - {% if langSummary.code__sum %} - {{ langSummary.code__sum|intcomma }} {% trans "lines of code" %} - {% endif %} -
    -
    -
    -
    -
    -
    -
    -{% endif %} - - -{% if product_metadata %} -
    -
    -

    {% trans "Custom Fields" %}

    -
    -
    - - - {% for key, value in product_metadata.items %} - - - - - {% endfor %} - -
    {{ key }}{{ value }}
    -
    -
    -{% endif %} - - -
    -
    -

    {% trans "Contacts" %}

    -
    -
    - - - - - - - - - - - - - - - -
    {% trans "Team Manager" %}{% if prod.team_manager != "0" %}{{ prod.team_manager | default:"Unknown" }}{% endif %}
    {{ labels.ASSET_MANAGER_LABEL }}{% if prod.product_manager != "0" %}{{ prod.product_manager | default:"Unknown" }}{% endif %}
    {% trans "Technical Contact" %}{% if prod.technical_contact != "0" %}{{ prod.technical_contact | default:"Unknown" }}{% endif %}
    -
    -
    - - -
    -
    - {% url 'notifications' as notifications_url %} -

    {% trans "Notifications" %} - -

    -
    -
    {% csrf_token %} -
    - - - - - {% if "slack" in enabled_notifications %} - - {% endif %} - {% if "mail" in enabled_notifications %} - - {% endif %} - - - {% for field in personal_notifications_form.visible_fields %} - - - {% for pk,c in field.field.widget.choices %} - {% if c != "msteams" and c != "webhooks" and c in enabled_notifications %} - - {% endif %} - {% endfor %} - - {% endfor %} - -
     {% trans "Slack" %}{% trans "Mail" %}{% trans "Alert" %}
    {{ field.label }}  - {% if field.help_text %} - - {% endif %} -
    -
    -
    -
    - - -
    -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - - - - - - - - - - {% block metrics %} - - {% endblock metrics %} - - {% endblock %} diff --git a/dojo/templates_classic/dojo/view_product_type.html b/dojo/templates_classic/dojo/view_product_type.html deleted file mode 100644 index a5334a4a5e4..00000000000 --- a/dojo/templates_classic/dojo/view_product_type.html +++ /dev/null @@ -1,250 +0,0 @@ -{% extends "base.html" %} -{% load filter_tags %} -{% load i18n %} -{% load display_tags %} -{% load authorization_tags %} - -{% block content %} - {{ block.super }} -

    {{ labels.ORG_LABEL }} {{ pt.name }}

    -
    -
    -
    -
    -
    -

    {% trans "Description" %}

    - -
    -
    -
    - {{ pt.description|markdown_render }} -
    -
    -
    -
    -
    -

    {{ labels.ASSET_PLURAL_LABEL }}

    - -
    -
    -
    - {% include "dojo/filter_snippet.html" with form=prod_filter.form %} -
    - {% if products %} -
    - {% include "dojo/paging_snippet.html" with page=products page_size=True %} -
    -
    - - - - - - - - - - - - {% for product in products %} - - - - - - - - - - {% endfor %} - -
    Name{% trans "Tags" %}{% trans "Criticality" %}{% trans "Metadata" %}{% trans "Active (Verified) Findings" %}
    {{ product.name }} - {% include "dojo/snippets/tags.html" with tags=product.tags.all %} - {{ product.business_criticality|business_criticality_icon }} - {{ product.platform|platform_icon }} - {{ product.lifecycle|lifecycle_icon }} - {{ product.origin|origin_icon }} - {{ product.external_audience|external_audience_icon }} - {{ product.internet_accessible|internet_accessible_icon }} - - {% if product.findings_count %} - {{ product.findings_count }} -  ({{ product.findings_active_verified_count }}) - {% else %} - 0 - {% endif %} -
    -
    -
    - {% include "dojo/paging_snippet.html" with page=products page_size=True %} -
    - {% else %} -
    - {{ labels.ASSET_NONE_FOUND_MESSAGE }} -
    - {% endif %} -
    - {% block authorized_users_panel %} -
    -
    -
    -

    {% trans "Authorized Users" %}

    -   - {% if pt|has_object_permission:"Product_Type_Manage_Members" %} - - {% endif %} -
    -
    - {% if authorized_users %} -
    - - - - {% if pt|has_object_permission:"Product_Type_Manage_Members" %} - - {% endif %} - - - - - - {% for u in authorized_users %} - - {% if pt|has_object_permission:"Product_Type_Manage_Members" %} - - {% endif %} - - - - {% endfor %} - -
    {% trans "User" %}{% trans "Email" %}
    - - - {{ u.username }}{{ u.email|default:"" }}
    -
    - {% else %} -
    - {% trans "No authorized users." %} -
    - {% endif %} -
    - {% endblock authorized_users_panel %} - - {% block rbac_members_panel %}{% endblock %} - {% block rbac_groups_panel %}{% endblock %} -
    -
    -
    -
    -

    - {{ pt.name }} -

    -
    -
    - - - - - - - - - - - -
    {{ labels.ORG_CRITICAL_PRODUCT_LABEL }} - {% if pt.critical_product %} - - {% else %} - - {% endif %} -
    {{ labels.ORG_KEY_PRODUCT_LABEL }} - {% if pt.key_product %} - - {% else %} - - {% endif %} -
    -
    -
    -
    -
    -{% endblock %} -{% block postscript %} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/dojo/view_risk_acceptance.html b/dojo/templates_classic/dojo/view_risk_acceptance.html deleted file mode 100644 index f2879819eb2..00000000000 --- a/dojo/templates_classic/dojo/view_risk_acceptance.html +++ /dev/null @@ -1,439 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load authorization_tags %} -{% load humanize %} -{% load static %} - -{% block add_styles %} - {{ block.super }} - .chosen-container { - width: 70% !important; - } - .editor-toolbar, .editor-statusbar, .editor-preview-side, .CodeMirror { - width: 70% !important; - } -{% endblock %} -{% block add_css %} - {{ block.super }} - -{% endblock %} -{% block content %} - {{ block.super }} - {% if risk_acceptance.is_expired %} -
    -
    -

    Expired

    -
    -
    - {% endif %} - {% if edit_mode %} -
    -
    -
    -

    - Risk Acceptance -

    -
    -
    -
    -
    - {% csrf_token %} - {% if return_url %} - - {% endif %} - {% include "dojo/form_fields.html" with form=risk_acceptance_form %} -
    -
    - -
    -
    -
    -
    - {% else %} -
    -
    -
    -

    - Risk Acceptance added by {{ risk_acceptance.owner.get_full_name }}, Updated {{ risk_acceptance.updated|naturaltime|default_if_none:"" }}, Created {{ risk_acceptance.created|naturaltime|default_if_none:"" }} -

    - - -
    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - {% if risk_acceptance.filename %} - - {% else %} - - {% endif %} - - - - -
    DateAccepted ByNameDecisionExpirationExpiration handledFindingsProofReactivate - - - Restart SLA - - - Owner
    {{ risk_acceptance.created|date }}{{ risk_acceptance.accepted_by }}{{ risk_acceptance.name }}{{ risk_acceptance.get_decision_display|default_if_none:"" }} - {% if risk_acceptance.expiration_date %} - {{ risk_acceptance.expiration_date|date }} - {% else %} - Never - {% endif %} - {{ risk_acceptance.expiration_date_handled|date }}{{ risk_acceptance.accepted_findings.count }}{{ risk_acceptance.filename }}No - {{ risk_acceptance.reactivate_expired }} - - {{ risk_acceptance.restart_sla_expired }} - {{ risk_acceptance.owner.get_full_name }}
    -
    -
    - -
    -
    -

    Decision & Recommendation

    -
    - - - - - - - - - - - - - - - - - -
    RecommendationDetailsDecisionDetails
    - {{ risk_acceptance.get_recommendation_display }} - - {{ risk_acceptance.recommendation_details }} - - {{ risk_acceptance.get_decision_display }} - - {{ risk_acceptance.decision_details }} -
    -
    - {% endif %} - -
    -
    -

    Findings Accepted

    -
    - {% if accepted_findings %} - - - - - - - - - - - - {% for finding in accepted_findings %} - - - - - - - - - - {% endfor %} - -
    SeverityEPSS Score / PercentileNameDateActiveActions
    - - {{ finding.severity_display }} - - - {{ finding.epss_score|format_epss }} - / - {{ finding.epss_percentile|format_epss }} - {{ finding.title|truncatechars:140 }}{{ finding.date }}{{ finding.active }} - {% if engagement|has_object_permission:"Risk_Acceptance" %} -
    - {% csrf_token %} - - -
    - {% else %} - Not Authorized - {% endif %} -
    -
    - {% include "dojo/paging_snippet.html" with page=accepted_findings %} -
    - {% else %} -
    -

    No findings associated with this Risk Acceptance.

    -
    - {% endif %} -
    - - {% if engagement|has_object_permission:"Risk_Acceptance" %} -
    -
    -

    Accept Additional Findings

    -
    -
    -
    {% csrf_token %} -
    -
    Add findings as accepted:
    - {{ add_findings_form.as_p }} -
    -
    - {% include "dojo/paging_snippet.html" with page=add_findings prefix="apage" %} -
    -
    -
    - -
    -
    -
    -
    - {% endif %} - -
    -
    -

    Uploaded Proof

    -
    - - - - - - - - - - {% if risk_acceptance.filename %} - - - - {% else %} - - - - {% endif %} - - -
    Current FileDate AddedActions
    - {{ risk_acceptance.filename }} - - {{ risk_acceptance.created }} - - {% if engagement|has_object_permission:"Risk_Acceptance" %} -
    - {% csrf_token %} - - -
    - {% else %} - Not Authorized - {% endif %} -
    NoneNoneNone
    -
    - -
    -
    -

    Notes

    -
    - {% if notes %} - - - - - - - - - - {% for note in notes %} - - - - - - - {% endfor %} - - -
    UserDateNoteActions
    - {{ note.author.username }} - - {{ note.date }} - - {{ note }} - - {% if engagement|has_object_permission:"Risk_Acceptance" %} -
    - {% csrf_token %} - - -
    - {% else %} - Not Authorized - {% endif %} -
    - {% else %} -

    No notes saved.

    - {% endif %} -
    -
    {% csrf_token %} - {% include "dojo/form_fields.html" with form=note_form %} -
    -
    - -
    -
    -
    -
    -{% endblock %} - -{% block postscript %} - {{ block.super }} -{% if not edit_mode %} - -{% else %} - - -{% endif %} - - - -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_test.html b/dojo/templates_classic/dojo/view_test.html deleted file mode 100644 index ca168455cca..00000000000 --- a/dojo/templates_classic/dojo/view_test.html +++ /dev/null @@ -1,1872 +0,0 @@ -{% extends "base.html" %} -{% load display_tags %} -{% load authorization_tags %} - -{% load static %} -{% load i18n %} -{% load humanize %} -{% block add_styles %} - {{ block.super }} - ul#select_by_severity a:hover, ul#bulk_edit a:hover { - cursor: pointer; - } - .btn-success { - color: #fff; - background-color: #546474!important; - border-color: #546474; - } - .btn-success:hover { - background-color: #546474!important; - } -{% endblock %} -{% block content %} - {{ block.super }} - -
    -
    -
    -

    - {{ test }} - {% include "dojo/snippets/tags.html" with tags=test.tags.all %} - {% trans "Updated" %} {{ test.updated|naturaltime|default_if_none:"" }}, {% trans "Created" %} {{ test.created|naturaltime|default_if_none:"" }} -

    - -
    -
    -
    - - - {% block test_header %} - - - - - {% if test.percent_complete > 0 %}{% endif %} - - - {% if 'TRACK_IMPORT_HISTORY'|setting_enabled and test.test_import_set.all %} - - {% endif %} - {% if test.api_scan_configuration %}{% endif %} - {% endblock test_header %} - - - {% block test_body %} - - - - - {% if test.percent_complete > 0 %} - - {% endif %} - - - {% if 'TRACK_IMPORT_HISTORY'|setting_enabled and test.total_reimport_count %} - - {% endif %} - {% if test.api_scan_configuration %} - - {% endif %} - {% endblock test_body %} - -
    {% trans "Engagement" %}{% trans "Environment" %}{% trans "Dates" %}{% trans "Updated" %}{% trans "Progress" %}{% trans "Fix Available" %}{% trans "Version" %} - {% trans "Reimports" %} - - - {% trans "API Scan Configuration" %}
    - {{ test.engagement.name }} - - {% if test.environment %} - {{ test.environment }} - {% else %} - Unknown - {% endif %} - {{ test.target_start|date }} - {{ test.target_end|date }} - {{ test.updated|date }} - -
    -
    - {{ test.percent_complete }}% -
    -
    -
    {{ fix_available_count }}{{ test.version }}{{ test.total_reimport_count }} - {% if prod|has_object_permission:"Product_Edit" %} - - {% endif %} - {{ test.api_scan_configuration }} - {% if prod|has_object_permission:"Product_Edit" %} - - {% endif %} -
    -
    -
    - {% if test.description %} -
    -
    -
    -
    -

    - {% trans "Details" %} -

    -
    -
    {{ test.description|markdown_render }}
    -
    -
    -
    - {% endif %} - - - {% if 'TRACK_IMPORT_HISTORY'|setting_enabled and test.total_reimport_count %} -
    -
    -
    -

    - {% trans "Import History" %} ({{ test.total_reimport_count }}) - - - -

    -
    -
    -
    - {% include "dojo/filter_snippet.html" with form=test_import_filter.form %} -
    -
    - {% if paged_test_imports %} - - - - - - - - - - - - - - - {% for test_import in paged_test_imports %} - - - - - - - - - - - {% endfor %} - -
    {% trans "Type" %}{% trans "Date/Time" %}{% trans "Branch/Tag" %}{% trans "Build ID" %}{% trans "Commit" %}{% trans "Version" %}{% trans "Endpoint" %}{% trans "Actions" %}
    - {{ test_import.type }} - - - {{ test_import.created|date:"DATETIME_FORMAT" }} - - {{ test_import|import_settings_tag }} - - {{ test_import.branch_tag|default_if_none:"" }} - - {{ test_import.build_id|default_if_none:"" }} - - {{ test_import.commit_hash|default_if_none:"" }} - - {{ test_import.version|default_if_none:"" }} - - {% if V3_FEATURE_LOCATIONS %} - {{ test_import.import_settings.locations|default_if_none:"" }} - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - {{ test_import.import_settings.endpoint|default_if_none:"" }} - {% endif %} - - {% regroup test_import.test_import_finding_action_set.all by get_action_display as finding_action_list %} - - {% for action in finding_action_list %} - {{ action.list|length }} {{ action.grouper }} - {% if not forloop.last %},{% endif %} - {% empty %} - There were no findings created, closed, or modified - {% endfor %} - -
    - {% else %} -
    -

    - {% trans "No import history found." %} -

    -
    - {% endif %} -
    - {% include "dojo/paging_snippet.html" with page=paged_test_imports prefix='test_imports' page_size=True %} -
    -
    -
    - {% endif %} - - - {% if 'is_finding_groups_enabled'|system_setting_enabled %} -
    -
    -
    -

    - {% trans "Groups" %} ({{ finding_groups|length }}) - -

    -
    -
    - -
    -
    - {% if finding_groups %} - - - - - - - - - - - - {% if system_settings.enable_finding_sla %} - - {% endif %} - - - {% if system_settings.enable_jira %} - {% if jira_project and product_tab or not product_tab %} - - - - {% endif %} - {% endif %} - - - - {% for group in finding_groups %} - - - - - - - - - - {% if system_settings.enable_finding_sla %} - - {% endif %} - - - {% if system_settings.enable_jira %} - {% if jira_project %} - - - - {% endif %} - {% endif %} - - {% endfor %} - -
    - - {% trans "Severity" %} - - {% trans "Name" %} - - {% trans "Findings" %} - - {% trans "Vulnerability Ids" %} - - {% trans "Components" %} - - {% trans "Date" %} - - {% trans "Age" %} - - {% trans "SLA" %} - - {% trans "Creator" %} - - {% trans "Status" %} - - {% trans "Jira" %} - - {% trans "Jira Age" %} - - {% trans "Jira Change" %} -
    -
    - -
    -
    - - {{ group.severity }} - - - {{ group.name|truncatechars_html:60 }} - - - {{ group.findings.all|length }} - - - {% for find in group.findings.all %} - {% with find|first_vulnerability_id as first_vulnerability_id %} - {% if first_vulnerability_id %} - {% if first_vulnerability_id|has_vulnerability_url %} - {{ first_vulnerability_id }} - {% else %} - {{ first_vulnerability_id }} - {% endif %} - {% if not forloop.last %},{% endif %} - {% endif %} - {% endwith %} - {% endfor %} - - {{ group.components }} - - {{ group.created }} - - {{ group.age }} - - {{ group|group_sla }} - - {% if group.creator.get_full_name and group.creator.get_full_name.strip %} - {{ group.creator.get_full_name }} - {% else %} - {{ group.creator }} - {% endif %} - - {{ group.status }} - - {% if group.jira_issue %} - {{ group | jira_key }} - - - {% else %} - {% trans "None" %} - - {% comment %} - - {% endcomment %} - {% endif %} - - {{ group | jira_creation | timesince }} - - {{ group | jira_change | timesince }} -
    - {% else %} -
    -

    - {% trans "No Groups found." %} -

    -
    - {% endif %} -
    - {% endif %} - - -
    -
    -
    -

    - {% trans "Findings" %} ({{ findings.total_count }}) {{ test.id|get_severity_count:"test" }} - - -   - -

    -
    -
    -
    - {% include "dojo/filter_snippet.html" with form=filtered.form %} -
    - {% if findings %} -
    - {% include "dojo/paging_snippet.html" with page=findings prefix='findings' page_size=True %} -
    - {% if test|has_object_permission:"Finding_Edit" or test|has_object_permission:"Finding_Delete" %} - - {% endif %} -
    - - - - {% block header %} - {% if test|has_object_permission:"Test_Edit" or test|has_object_permission:"Test_Delete" %} - - {% endif %} - - - - - - - - - - {% if system_settings.enable_finding_sla %} - - {% endif %} - - - {% if system_settings.enable_jira %} - {% if jira_project and product_tab or not product_tab %} - - - - {% endif %} - {% endif %} - {% if 'is_finding_groups_enabled'|system_setting_enabled %} - - {% endif %} - - {% endblock header %} - - - - {% for finding in findings %} - - {% block body %} - {% if test|has_object_permission:"Test_Edit" or test|has_object_permission:"Test_Delete" %} - - {% endif %} - - - - - - - - - - {% if system_settings.enable_finding_sla %} - - {% endif %} - - - {% if system_settings.enable_jira %} - {% if jira_project and product_tab or not product_tab %} - - - - {% endif %} - {% endif %} - {% if 'is_finding_groups_enabled'|system_setting_enabled %} - - {% endif %} - - {% endblock body %} - - {% endfor %} - -
    - - - {% trans "Severity" %} - - {% trans "Name" %} - - {% trans "CWE" %} - - {% trans "Vulnerability Id" %} - - {% trans "EPSS Score" %} - - {% trans "EPSS Percentile" %} - - {% trans "Date" %} - - {% trans "Age" %} - - {% trans "SLA" %} - - {% trans "Reporter" %} - - {% trans "Status" %} - - {% trans "Jira" %} - - {% trans "Jira Age" %} - - {% trans "Jira Change" %} - - {% trans "Group" %} - - {% trans "Planned Remediation" %} -
    -
    - -
    -
    -
    - -
    -
    - - {{ finding.severity_display }} - - - {% if finding.title %} - {{ finding.title|truncatechars:60 }} - {% else %} - {{ finding.id }} - {% endif %} - {% if finding.file_path %} - - {% endif %} - {% if V3_FEATURE_LOCATIONS %} - {% if finding.locations.exists %} - - {% else %} - ✕ {{ ref.location }} -
    - {% endif %} - {% endfor %} - " data-placement="right" data-container="body" data-original-title="Endpoints ({{ finding.active_endpoint_count }} Active, {{ finding.mitigated_endpoint_count }} Mitigated)" title="">
    - {% endif %} - {% else %} - {% comment %} TODO: Delete this after the move to Locations {% endcomment %} - {% if finding.endpoints.all %} - - {% endif %} - {% endif %} - {% if finding.component_name %} - - {% endif %} - {% if finding.notes.all %} - - - ({{ finding.notes.count }}) - - {% endif %} - {% include "dojo/snippets/tags.html" with tags=finding.tags.all %} -
    - {% if finding.cwe > 0 %} - - {{ finding.cwe }} - - {% endif %} - - {% with finding|first_vulnerability_id as first_vulnerability_id %} - {% if first_vulnerability_id %} - {% if first_vulnerability_id|has_vulnerability_url %} - - {{ first_vulnerability_id|default:"" }} - - {% else %} - {{ first_vulnerability_id }} - {% endif %} - {% endif %} - {% endwith %} - - {{ finding.epss_score|format_epss }} - - {{ finding.epss_percentile|format_epss }} - - {{ finding.date }} - - {{ finding.age }} - - {{ finding|finding_sla }} - - {% if finding.reporter.get_full_name and finding.reporter.get_full_name.strip %} - {{ finding.reporter.get_full_name }} - {% else %} - {{ finding.reporter }} - {% endif %} - - {{ finding|finding_display_status|safe }} {{ finding|import_history }} - - {% if finding.has_jira_group_issue %} - {{ finding.finding_group | jira_key }} - {% elif finding.has_jira_issue %} - {{ finding | jira_key }} - {% endif %} - - {% if finding.has_jira_group_issue %} - {{ finding.finding_group | jira_creation | timesince }} - {% else %} - {{ finding | jira_creation | timesince }} - {% endif %} - - {% if finding.has_jira_group_issue %} - {{ finding.finding_group | jira_change | timesince }} - {% else %} - {{ finding | jira_change | timesince }} - {% endif %} - - {% if finding.has_finding_group %} - {{ finding.finding_group.name }} - {% endif %} - - {% if finding.planned_remediation_date %}{{ finding.planned_remediation_date }}{% endif %} -
    -
    - {% include "dojo/paging_snippet.html" with page=findings prefix='findings' page_size=True %} -
    -
    - {% else %} -
    -

    - {% trans "No findings found." %} -

    -
    - {% endif %} -
    - - -
    -
    -
    -
    -

    - {% trans "Files" %}  - - - {% if test|has_object_permission:"Test_Edit" %} - - - {% endif %} -

    -
    -
    - {% for file in files %} -
    -
    - {% url 'access_file' fid=file.id oid=test.id obj_type='Test' as image_url %} - - {% if file|get_thumbnail %} - thumbnail - {% else %} - - {% endif %} - -
    -
    - {{ file.title }} -
    -
    - {% endfor %} -
    -
    -
    -
    - - {% include "dojo/snippets/comments.html" with notes=notes object=test destination="test" %} -
    - {% trans "ProTip!" %} {% trans "Type" %}s e {% trans "to edit this test. Type" %} a {% trans "to add a finding to this test." %} -
    -{% endblock %} - -{% block postscript %} - {{ block.super }} - - - - - {% include "dojo/filter_js_snippet.html" %} - {% include "dojo/snippets/selectpicker_in_dropdown.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_tool_product_all.html b/dojo/templates_classic/dojo/view_tool_product_all.html deleted file mode 100644 index 566fffe42b0..00000000000 --- a/dojo/templates_classic/dojo/view_tool_product_all.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% load navigation_tags %} -{% load get_config_setting %} -{% block content %} - {{ block.super }} -
    -
    -
    -
    -

    Tools - -

    -
    - {% if tools %} - - - - - - - - - - - - {% for tool in tools %} - - - - - - - - {% endfor %} - -
    NameIDTypeCategoryActions
    - {% if tool.url %} - {{ tool.name }} - {% else %} - {{ tool.name }} - {% endif %} - {{ tool.tool_project_id }} - {% if tool.tool_configuration.url %} - {{ tool.tool_configuration.name }} - {% else %} - {{ tool.tool_configuration.name }} - {% endif %} - {{ tool.tool_configuration.tool_type.name }} -
    - Edit - Delete -
    -
    - {% else %} -
    -

    No tools configured.

    -
    - {% endif %} -
    -
    -
    -{% endblock %} -{% block postscript %} - {{ block.super }} - {% include "dojo/filter_js_snippet.html" %} -{% endblock %} diff --git a/dojo/templates_classic/dojo/view_user.html b/dojo/templates_classic/dojo/view_user.html deleted file mode 100644 index ed9e90ee8cc..00000000000 --- a/dojo/templates_classic/dojo/view_user.html +++ /dev/null @@ -1,399 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load display_tags %} -{% load authorization_tags %} - -{% block content %} - {{ block.super }} -

    {% blocktrans with full_name=user.get_full_name %}User: {{ full_name }}{% endblocktrans %}

    -
    -
    -
    -
    -
    -

    {% trans "Default Information" %}

    - -
    -
    -
    - - - - - - - - - - - - - - - - - -
    {% trans "First Name" %}{{ user.first_name }}
    {% trans "Last Name" %}{{ user.last_name }}
    {% trans "Username" %}{{ user.username }}
    {% trans "Email" %}{{ user.email }}
    -
    -
    -
    -
    -
    -

    {% trans "Contact Information" %}

    -
    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {% trans "Title" %}{% if user.usercontactinfo.title %} {{ user.usercontactinfo.title }} {% endif %}
    {% trans "Phone Number" %}{{ user.usercontactinfo.phone_number }}
    {% trans "Cell Number" %}{{ user.usercontactinfo.cell_number }}
    {% trans "Twitter Username" %}{% if user.usercontactinfo.twitter_username %} {{ user.usercontactinfo.twitter_username }} - {% endif %}
    {% trans "Github Username" %}{% if user.usercontactinfo.github_username %} {{ user.usercontactinfo.github_username }} {% endif %}
    {% trans "Slack Email Address" %}{% if user.usercontactinfo.slack_username %} {{ user.usercontactinfo.slack_username }} {%endif %}
    -
    -
    - {% block user_product_types_panel %} -
    -
    -
    -

    {{ labels.ORG_USERS_LABEL }}

    - {% if request.user.is_staff %} - - {% endif %} -
    -
    - {% if accessible_product_types %} -
    - - - - {% if request.user.is_staff %}{% endif %} - - - - - {% for pt in accessible_product_types %} - - {% if request.user.is_staff %} - - {% endif %} - - - {% endfor %} - -
    {{ labels.ORG_LABEL }}
    - - - {{ pt.name }}
    -
    - {% else %} -
    - {{ labels.ORG_USERS_NO_ACCESS_MESSAGE }} -
    - {% endif %} -
    - {% endblock user_product_types_panel %} - {% block user_products_panel %} -
    -
    -
    -

    {{ labels.ASSET_USERS_ACCESS_LABEL }}

    - {% if request.user.is_staff %} - - {% endif %} -
    -
    - {% if accessible_products %} -
    - - - - {% if request.user.is_staff %}{% endif %} - - - - - {% for p in accessible_products %} - - {% if request.user.is_staff %} - - {% endif %} - - - {% endfor %} - -
    {{ labels.ASSET_LABEL }}
    - - - {{ p.name }}
    -
    - {% else %} -
    - {{ labels.ASSET_USERS_NO_ACCESS_MESSAGE }} -
    - {% endif %} -
    - {% endblock user_products_panel %} -
    -
    -
    -
    -

    - {% trans "Metadata" %} -

    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {% trans "Active" %} - {% if user.is_active %} - - {% else %} - - {% endif %} -
    {% trans "Superuser" %} - {% if user.is_superuser %} - - {% else %} - - {% endif %} -
    {% trans "Staff" %} - {% if user.is_staff %} - - {% else %} - - {% endif %} -
    {% trans "Block execution" %} - {% if user.usercontactinfo.block_execution %} - - {% else %} - - {% endif %} -
    {% trans "Deduplication execution mode" %} - {{ user.usercontactinfo.get_deduplication_execution_mode_display|default:_("Async (do not wait)") }} -
    {% trans "Date Joined" %}{{ user.date_joined }}
    {% trans "Last Login" %}{% if user.last_login %} {{ user.last_login }} {% else %} {% trans "Never" %} {% endif %}
    {% trans "Token Last Reset" %}{% if user.usercontactinfo.token_last_reset %} {{ user.usercontactinfo.token_last_reset }} {% else %} {% trans "Never" %} {% endif %}
    {% trans "Password Last Reset" %}{% if user.usercontactinfo.password_last_reset %} {{ user.usercontactinfo.password_last_reset }} {% else %} {% trans "Never" %} {% endif %}
    -
    -
    - - {% if not user.is_superuser %} -
    -
    -

      {% trans "Configuration Permissions" %}

    -
    -
    {% csrf_token %} -
    - - - - - - - - - {% for field in configuration_permission_form.permission_fields %} - - - - - - - - {% endfor %} -
     {% trans "View" %}{% trans "Add" %}{% trans "Edit" %}{% trans "Delete" %}
    {{ field.display_name }} - {% if field.view_codename %} - - {% endif %} - - {% if field.add_codename %} - - {% endif %} - - {% if field.change_codename %} - - {% endif %} - - {% if field.delete_codename %} - - {% endif %} -
    -
    -
    -
    - {% endif %} - -
    -
    -{% endblock %} -{% block postscript %} -{{ block.super }} - -{% endblock %} diff --git a/dojo/templates_classic/google_sheet_error.html b/dojo/templates_classic/google_sheet_error.html deleted file mode 100644 index 9cd2a354dae..00000000000 --- a/dojo/templates_classic/google_sheet_error.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "base.html" %} -{% block content %} - {{ block.super }} -

    Error

    -
    -

    - {{ error_message }} -

    -{% endblock %} diff --git a/dojo/templates_classic/issue-trackers/jira_full/jira-description.tpl b/dojo/templates_classic/issue-trackers/jira_full/jira-description.tpl deleted file mode 100644 index 610fabeae01..00000000000 --- a/dojo/templates_classic/issue-trackers/jira_full/jira-description.tpl +++ /dev/null @@ -1,104 +0,0 @@ -{% load navigation_tags %} -{% load display_tags %} -{% load get_endpoint_status %} -{% url 'view_product' finding.test.engagement.product.id as product_url %} -{% url 'view_engagement' finding.test.engagement.id as engagement_url %} -{% url 'view_test' finding.test.id as test_url %} -{% url 'view_finding' finding.id as finding_url %} -*Title*: [{{ finding.title|jiraencode}}|{{ finding_url|full_url }}] - -*Defect Dojo link:* {{ finding_url|full_url }} ({{ finding.id }}) - -*Severity:* {{ finding.severity }} - -{% if finding.sla_deadline %} -*Due Date:* {{ finding.sla_deadline }} -{% endif %} - -{% if finding.cwe > 0 %} -*CWE:* [CWE-{{ finding.cwe }}|{{ finding.cwe|cwe_url }}] -{% endif %} - -{% if finding.cve %} -*CVE:* [{{ finding.cve }}|{{ finding.cve|vulnerability_url }}] -{% else %} -*CVE:* Unknown -{% endif %} - -{% if finding.cvssv3_score %} -*CVSSv3 Score:* {{ finding.cvssv3_score }} {% if finding.cvssv3 %}({{ finding.cvssv3 }}){% endif %} -{% endif %} - -*Product/Engagement/Test:* [{{ finding.test.engagement.product.name }}|{{ product_url|full_url }}] / [{{ finding.test.engagement.name }}|{{ engagement_url|full_url }}] / [{{ finding.test }}|{{ test_url|full_url }}] - -{% if finding.test.engagement.branch_tag %} -*Branch/Tag:* {{ finding.test.engagement.branch_tag }} -{% endif %} - -{% if finding.test.engagement.build_id %} -*BuildID:* {{ finding.test.engagement.build_id }} -{% endif %} - -{% if finding.test.engagement.commit_hash %} -*Commit hash:* {{ finding.test.engagement.commit_hash }} -{% endif %} - -{% if finding.endpoints.all %} -*Systems/Endpoints*: -||System/Endpoint||Status|| -{% for endpoint in finding|get_vulnerable_endpoints %}|{{ endpoint }}|{{ endpoint|endpoint_display_status:finding }}| -{% endfor %}{% for endpoint in finding|get_mitigated_endpoints %}|{{ endpoint }}|{{ endpoint|endpoint_display_status:finding }}| -{% endfor %} -{%endif%} - - -{% if finding.component_name %} -*Vulnerable Component*: {{finding.component_name }} - {{ finding.component_version }} -{% endif %} - -{% if finding.sast_source_object %} -*Source Object*: {{ finding.sast_source_object }} -*Source File*: {{ finding.sast_source_file_path }} -*Source Line*: {{ finding.sast_source_line }} -*Sink Object*: {{ finding.sast_sink_object }} -{% elif finding.static_finding %} -{% if finding.file_path and finding.get_file_path_with_raw_link %} -*Source File*: [{{ finding.file_path }} | {{ finding.get_file_path_with_raw_link }}] -{% elif finding.file_path %} -*Source File*: {{ finding.file_path }} -{% endif %} -{% if finding.line %} -*Source Line*: {{ finding.line }} -{% endif %} -{% endif %} - -*Description*: -{{ finding.description|safe }} - -{% if finding.mitigation %} -*Mitigation*: -{{ finding.mitigation|safe }} -{% endif %} - -{% if finding.impact|safe %} -*Impact*: -{{ finding.impact }} -{% endif %} - -{% if finding.steps_to_reproduce %} -*Steps to reproduce*: -{{ finding.steps_to_reproduce|safe }} -{% endif %} - -{% if finding.references %} -*References*: -{{ finding.references|safe }} -{% endif %} - -{% if finding_text %} -*Finding Text*: -{{ finding_text|safe }} -{% endif %} - - -*Reporter:* [{{ finding.reporter|full_name}} ({{ finding.reporter.email }})|mailto:{{ finding.reporter.email }}] diff --git a/dojo/templates_classic/issue-trackers/jira_full/jira-finding-group-description.tpl b/dojo/templates_classic/issue-trackers/jira_full/jira-finding-group-description.tpl deleted file mode 100644 index 1d53270ff80..00000000000 --- a/dojo/templates_classic/issue-trackers/jira_full/jira-finding-group-description.tpl +++ /dev/null @@ -1,104 +0,0 @@ -{% load navigation_tags %} -{% load display_tags %} -{% load get_endpoint_status %} -{% url 'view_finding_group' finding_group.id as finding_group_url %} -{% url 'view_product' finding_group.test.engagement.product.id as product_url %} -{% url 'view_engagement' finding_group.test.engagement.id as engagement_url %} -{% url 'view_test' finding_group.test.id as test_url %} - -A group of Findings has been pushed to JIRA to be investigated and fixed: - -h2. Group -*Group*: [{{ finding_group.name|jiraencode}}|{{ finding_group_url|full_url }}] in [{{ finding_group.test.engagement.product.name|jiraencode }}|{{ product_url|full_url }}] / [{{ finding_group.test.engagement.name|jiraencode }}|{{ engagement_url|full_url }}] / [{{ finding_group.test|stringformat:'s'|jiraencode }}|{{ test_url|full_url }}] - -h2. Summary -*Severity:* {{ finding_group.findings.all | jira_severity }} {% if finding_group.sla_deadline %} *Due Date:* {{ finding_group | jira_sla_deadline }} {% endif %} - -Findings matching the Active, Verified and Severity criteria: -|| Severity || CVE || CWE || Component || Version || Title || Status ||{% for finding in finding_group|jira_qualified_findings %} -|{{finding.severity}}|{% if finding.cve %}[{{finding.cve}}|{{finding.cve|vulnerability_url}}]{% else %}None{% endif %}|[{{finding.cwe}}|{{finding.cwe|cwe_url}}]|{{finding.component_name|jiraencode_component}}|{{finding.component_version}}|{% url 'view_finding' finding.id as finding_url %}[{{ finding.title|jiraencode}}|{{ finding_url|full_url }}]|{{ finding.status }}|{% endfor %} - -Findings *not* matching the Active, Verified and Severity criteria: -|| Severity || CVE || CWE || Component || Version || Title || Status ||{% for finding in finding_group|jira_non_qualified_findings %} -|{{finding.severity}}|{% if finding.cve %}[{{finding.cve}}|{{finding.cve|vulnerability_url}}]{% else %}None{% endif %}|[{{finding.cwe}}|{{finding.cwe|cwe_url}}]|{{finding.component_name|jiraencode_component}}|{{finding.component_version}}|{% url 'view_finding' finding.id as finding_url %}[{{ finding.title|jiraencode}}|{{ finding_url|full_url }}]|{{ finding.status }}|{% endfor %} - -{% if finding_group.test.engagement.branch_tag %} -*Branch/Tag:* {{ finding_group.test.engagement.branch_tag }} -{% endif %} - -{% if finding_group.test.engagement.build_id %} -*BuildID:* {{ finding_group.test.engagement.build_id }} -{% endif %} - -{% if finding_group.test.engagement.commit_hash %} -*Commit hash:* {{ finding_group.test.engagement.commit_hash }} -{% endif %} - -{% for finding in finding_group.findings.all %} -{% url 'view_finding' finding.id as finding_url %} - -h1. Findings - -h3. [{{ finding.title|jiraencode}}|{{ finding_url|full_url }}] -*Defect Dojo link:* {{ finding_url|full_url }} ({{ finding.id }}) -*Severity:* {{ finding.severity }} -{% if finding.sla_deadline %} *Due Date:* {{ finding.sla_deadline }} {% endif %} -{% if finding.cwe > 0 %} *CWE:* [CWE-{{ finding.cwe }}|{{ finding.cwe|cwe_url }}] {% endif %} -{% if finding.cve %}*CVE:* [{{ finding.cve }}|{{ finding.cve|vulnerability_url }}]{% else %}*CVE:* Unknown{% endif %} -{% if finding.cvssv3_score %} *CVSSv3 Score:* {{ finding.cvssv3_score }} {% endif %} - -{% if finding.endpoints.all %} -*Systems/Endpoints*: -||System/Endpoint||Status|| -{% for endpoint in finding|get_vulnerable_endpoints %}|{{ endpoint }}|{{ endpoint|endpoint_display_status:finding }}| -{% endfor %}{% for endpoint in finding|get_mitigated_endpoints %}|{{ endpoint }}|{{ endpoint|endpoint_display_status:finding }}| -{% endfor %} -{%endif%} - -{% if finding.sast_source_object %} -*Source Object*: {{ finding.sast_source_object }} -*Source File*: {{ finding.sast_source_file_path }} -*Source Line*: {{ finding.sast_source_line }} -*Sink Object*: {{ finding.sast_sink_object }} -{% elif finding.static_finding %} -{% if finding.file_path and finding.get_file_path_with_raw_link %} -*Source File*: [{{ finding.file_path }} | {{ finding.get_file_path_with_raw_link }}] -{% elif finding.file_path %} -*Source File*: {{ finding.file_path }} -{% endif %} -{% if finding.line %} -*Source Line*: {{ finding.line }} -{% endif %} -{% endif %} - -*Description*: -{{ finding.description|safe }} - -{% if finding.mitigation %} -*Mitigation*: -{{ finding.mitigation|safe }} -{% endif %} - -{% if finding.impact %} -*Impact*: -{{ finding.impact|safe }} -{% endif %} - -{% if finding.steps_to_reproduce %} -*Steps to reproduce*: -{{ finding.steps_to_reproduce|safe }} -{% endif %} - -{% if finding.references %} -*References*: -{{ finding.references|safe }} -{% endif %} - -{% if finding_text %} -*Finding Text*: -{{ finding_text|safe }} -{% endif %} - - -*Reporter:* [{{ finding.reporter|full_name}} ({{ finding.reporter.email }})|mailto:{{ finding.reporter.email }}] -{% endfor %} \ No newline at end of file diff --git a/dojo/templates_classic/issue-trackers/jira_limited/jira-description.tpl b/dojo/templates_classic/issue-trackers/jira_limited/jira-description.tpl deleted file mode 100644 index 633ffc901e9..00000000000 --- a/dojo/templates_classic/issue-trackers/jira_limited/jira-description.tpl +++ /dev/null @@ -1,17 +0,0 @@ -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_product' finding.test.engagement.product.id as product_url %} -{% url 'view_engagement' finding.test.engagement.id as engagement_url %} -{% url 'view_test' finding.test.id as test_url %} -{% url 'view_finding' finding.id as finding_url %} - -*Defect Dojo link:* {{ finding_url|full_url }} ({{ finding.id }}) - -*Product/Engagement/Test:* [{{ finding.test.engagement.product.name }}|{{ product_url|full_url }}] / [{{ finding.test.engagement.name }}|{{ engagement_url|full_url }}] / [{{ finding.test }}|{{ test_url|full_url }}] - -*Reporter:* [{{ finding.reporter|full_name}} ({{ finding.reporter.email }})|mailto:{{ finding.reporter.email }}] - -{% if finding_text %} -*Finding Text*: -{{ finding_text|safe }} -{% endif %} diff --git a/dojo/templates_classic/issue-trackers/jira_limited/jira-finding-group-description.tpl b/dojo/templates_classic/issue-trackers/jira_limited/jira-finding-group-description.tpl deleted file mode 100644 index c47fb85390c..00000000000 --- a/dojo/templates_classic/issue-trackers/jira_limited/jira-finding-group-description.tpl +++ /dev/null @@ -1,39 +0,0 @@ -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_finding_group' finding_group.id as finding_group_url %} -{% url 'view_product' finding_group.test.engagement.product.id as product_url %} -{% url 'view_engagement' finding_group.test.engagement.id as engagement_url %} -{% url 'view_test' finding_group.test.id as test_url %} - -A group of Findings has been pushed to JIRA to be investigated and fixed: - -h2. Group -*Group*: [{{ finding_group.name|jiraencode}}|{{ finding_group_url|full_url }}] in [{{ finding_group.test.engagement.product.name|jiraencode }}|{{ product_url|full_url }}] / [{{ finding_group.test.engagement.name|jiraencode }}|{{ engagement_url|full_url }}] / [{{ finding_group.test|stringformat:'s'|jiraencode }}|{{ test_url|full_url }}] - -h2. Summary -*Severity:* {{ finding_group.findings.all | jira_severity }} {% if finding_group.sla_deadline %} *Due Date:* {{ finding_group | jira_sla_deadline }} {% endif %} - -Findings matching the Active, Verified and Severity criteria: -{% for finding in finding_group|jira_qualified_findings %} -- [{{ finding.title|jiraencode}}|{{ finding_url|full_url }}]{% endfor %} - -Findings *not* matching the Active, Verified and Severity criteria: -{% for finding in finding_group|jira_non_qualified_findings %} -- [{{ finding.title|jiraencode}}|{{ finding_url|full_url }}]{% endfor %} - -{% if finding_group.test.engagement.branch_tag %} -*Branch/Tag:* {{ finding_group.test.engagement.branch_tag }} -{% endif %} - -{% if finding_group.test.engagement.build_id %} -*BuildID:* {{ finding_group.test.engagement.build_id }} -{% endif %} - -{% if finding_group.test.engagement.commit_hash %} -*Commit hash:* {{ finding_group.test.engagement.commit_hash }} -{% endif %} - -{% if finding_text %} -*Finding Text*: -{{ finding_text|safe }} -{% endif %} \ No newline at end of file diff --git a/dojo/templates_classic/login/forgot_password.tpl b/dojo/templates_classic/login/forgot_password.tpl deleted file mode 100644 index 6ddbdffe60f..00000000000 --- a/dojo/templates_classic/login/forgot_password.tpl +++ /dev/null @@ -1,12 +0,0 @@ -{% load i18n %} -{% autoescape off %} -{% blocktranslate %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktranslate %} - -{% translate "Please go to the following page and choose a new password:" %} -{% block reset_link %}{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}{% endblock %} -{% blocktranslate %}The link above expires on: {{ link_expiration_date }}{% endblocktranslate %} - - -{% translate "Thanks for using our site!" %} -{% blocktranslate %}The {{ site_name }} team{% endblocktranslate %} -{% endautoescape %} \ No newline at end of file diff --git a/dojo/templates_classic/login/forgot_username.html b/dojo/templates_classic/login/forgot_username.html deleted file mode 100644 index 7cfe6c957a5..00000000000 --- a/dojo/templates_classic/login/forgot_username.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'base.html' %} -{% load i18n static %} -{% block content %} - {{ block.super }} -

    {% translate 'Retrieve Username' %}

    -

    {% translate "Forgotten your username? Enter your email address below, and we'll email it to you." %}

    -
    {% csrf_token %} -
    - {% include "dojo/form_fields.html" with form=form %} -
    -
    - -
    -
    -
    -
    -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/login/forgot_username.tpl b/dojo/templates_classic/login/forgot_username.tpl deleted file mode 100644 index 63ca61ab6cd..00000000000 --- a/dojo/templates_classic/login/forgot_username.tpl +++ /dev/null @@ -1,10 +0,0 @@ -{% load i18n %} -{% autoescape off %} -{% blocktranslate %}You're receiving this email because you requested your username for your user account at {{ site_name }}.{% endblocktranslate %} - -{% translate 'Here is your username:' %} {{ user.get_username }} - - -{% translate "Thanks for using our site!" %} -{% blocktranslate %}The {{ site_name }} team{% endblocktranslate %} -{% endautoescape %} \ No newline at end of file diff --git a/dojo/templates_classic/login/forgot_username_done.html b/dojo/templates_classic/login/forgot_username_done.html deleted file mode 100644 index dad4796b466..00000000000 --- a/dojo/templates_classic/login/forgot_username_done.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends 'base.html' %} -{% load i18n static %} -{% block content %} -{{ block.super }} -

    {% translate 'Retrieve Username' %}

    -
    -

    {% translate "We've emailed you information about your user account, if an account exists with the email you entered. You should receive the requested information shortly." %}

    -

    {% translate "If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder." %}

    -
    -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/login/forgot_username_subject.html b/dojo/templates_classic/login/forgot_username_subject.html deleted file mode 100644 index 55bbc9e2045..00000000000 --- a/dojo/templates_classic/login/forgot_username_subject.html +++ /dev/null @@ -1,3 +0,0 @@ -{% load i18n %}{% autoescape off %} -{% blocktranslate %}Retrieve username on {{ site_name }}{% endblocktranslate %} -{% endautoescape %} \ No newline at end of file diff --git a/dojo/templates_classic/login/password_reset.html b/dojo/templates_classic/login/password_reset.html deleted file mode 100644 index a017f542c99..00000000000 --- a/dojo/templates_classic/login/password_reset.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'base.html' %} -{% load i18n static %} -{% block content %} - {{ block.super }} -

    {% translate 'Password reset' %}

    -

    {% translate "Forgotten your password? Enter your email address below, and we'll email instructions for setting a new one." %}

    - -
    {% csrf_token %} -
    - {% include "dojo/form_fields.html" with form=form %} -
    -
    - -
    -
    -
    -
    -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/login/password_reset_complete.html b/dojo/templates_classic/login/password_reset_complete.html deleted file mode 100644 index fcea30e42e0..00000000000 --- a/dojo/templates_classic/login/password_reset_complete.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'base.html' %} -{% load i18n static %} -{% block content %} -{{ block.super }} -

    {% translate 'Password reset' %}

    -
    -

    {% translate "Your password has been set. You may go ahead and log in now." %}

    - -
    -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/login/password_reset_confirm.html b/dojo/templates_classic/login/password_reset_confirm.html deleted file mode 100644 index 23c081f68d3..00000000000 --- a/dojo/templates_classic/login/password_reset_confirm.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'base.html' %} -{% load i18n static %} -{% block content %} -{{ block.super }} -

    {% translate 'Password reset confirmation' %}

    -{% if validlink %} -

    {% translate "Please enter your new password twice so we can verify you typed it in correctly." %}

    -
    {% csrf_token %} -
    - {% include "dojo/form_fields.html" with form=form %} -
    -
    - -
    -
    -
    -
    -{% else %} -
    -

    {% translate "The password reset link was invalid, possibly because it has already been used. Please request a new password reset." %}

    -
    -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/login/password_reset_done.html b/dojo/templates_classic/login/password_reset_done.html deleted file mode 100644 index a758fbe8f78..00000000000 --- a/dojo/templates_classic/login/password_reset_done.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends 'base.html' %} -{% load i18n static %} -{% block content %} -{{ block.super }} -

    {% translate 'Password reset' %}

    -
    -

    {% translate "We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly." %}

    -

    {% translate "If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder." %}

    -
    -{% endblock %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/engagement_added.tpl b/dojo/templates_classic/notifications/alert/engagement_added.tpl deleted file mode 100644 index 6e249b24fd3..00000000000 --- a/dojo/templates_classic/notifications/alert/engagement_added.tpl +++ /dev/null @@ -1,3 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed with eng_name=engagement.name eng_product=engagement.product %} -The engagement "{{ eng_name }}" has been created in the product "{{ eng_product }}". -{% endblocktranslate %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/engagement_closed.tpl b/dojo/templates_classic/notifications/alert/engagement_closed.tpl deleted file mode 100644 index 2468c566e30..00000000000 --- a/dojo/templates_classic/notifications/alert/engagement_closed.tpl +++ /dev/null @@ -1,3 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed with eng_name=engagement.name eng_product=engagement.product %} -The engagement "{{ eng_name }}" has been closed in the product "{{ eng_product }}". -{% endblocktranslate %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/other.tpl b/dojo/templates_classic/notifications/alert/other.tpl deleted file mode 100644 index 442019b34ca..00000000000 --- a/dojo/templates_classic/notifications/alert/other.tpl +++ /dev/null @@ -1 +0,0 @@ -{% load i18n %}{{ description|safe }} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/product_added.tpl b/dojo/templates_classic/notifications/alert/product_added.tpl deleted file mode 100644 index bfe6af7c7d5..00000000000 --- a/dojo/templates_classic/notifications/alert/product_added.tpl +++ /dev/null @@ -1,3 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed %} -The new product "{{ title }}" has been added -{% endblocktranslate %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/product_type_added.tpl b/dojo/templates_classic/notifications/alert/product_type_added.tpl deleted file mode 100644 index c1b0337e298..00000000000 --- a/dojo/templates_classic/notifications/alert/product_type_added.tpl +++ /dev/null @@ -1,3 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed %} -The new product type "{{ title }}" has been added -{% endblocktranslate %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/review_requested.tpl b/dojo/templates_classic/notifications/alert/review_requested.tpl deleted file mode 100644 index 486e1ce6fe3..00000000000 --- a/dojo/templates_classic/notifications/alert/review_requested.tpl +++ /dev/null @@ -1,20 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed %} - User {{ requested_by }} has requested that the following users review the finding "{{ finding }}" for accuracy: -{% endblocktranslate %} - -{% for user in reviewers %} - - {{ user.get_full_name }} -{% endfor %} - -{% blocktranslate trimmed %} - {{ note }} -{% endblocktranslate %} - -{% trans "Full details of the finding can be reviewed at" %} {{ url|full_url }} - -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - {% trans "Disclaimer:" %} - {{ system_settings.disclaimer_notifications }} -{% endif %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/scan_added_empty.tpl b/dojo/templates_classic/notifications/alert/scan_added_empty.tpl deleted file mode 100644 index 6d749556aa2..00000000000 --- a/dojo/templates_classic/notifications/alert/scan_added_empty.tpl +++ /dev/null @@ -1 +0,0 @@ -{% include notifications/alert/scan_added.tpl %} diff --git a/dojo/templates_classic/notifications/alert/sla_breach.tpl b/dojo/templates_classic/notifications/alert/sla_breach.tpl deleted file mode 100644 index 61b5a3cce5c..00000000000 --- a/dojo/templates_classic/notifications/alert/sla_breach.tpl +++ /dev/null @@ -1,3 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed with finding_id=finding.id product_name=finding.test.engagement.product %} -SLA breach alert for finding {{ finding_id }} in product {{ product_name }}. Relative days count to SLA due date: {{sla_age}}. -{% endblocktranslate %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/test_added.tpl b/dojo/templates_classic/notifications/alert/test_added.tpl deleted file mode 100644 index ea0dfe47d77..00000000000 --- a/dojo/templates_classic/notifications/alert/test_added.tpl +++ /dev/null @@ -1,3 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed with eng_product=engagement.product test_type=test.test_type %} -New test added for engagement {{ eng_product }}: {{ test_type }}. -{% endblocktranslate %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/upcoming_engagement.tpl b/dojo/templates_classic/notifications/alert/upcoming_engagement.tpl deleted file mode 100644 index 16f26602eeb..00000000000 --- a/dojo/templates_classic/notifications/alert/upcoming_engagement.tpl +++ /dev/null @@ -1,3 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed with eng_product=engagement.product start=engagement.target_start %} -The engagement "{{ eng_product }}" is starting on {{ start }}. -{% endblocktranslate %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/alert/user_mentioned.tpl b/dojo/templates_classic/notifications/alert/user_mentioned.tpl deleted file mode 100644 index 9a0b35c0470..00000000000 --- a/dojo/templates_classic/notifications/alert/user_mentioned.tpl +++ /dev/null @@ -1,4 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed %} -User {{ requested_by }} jotted a note on {{ section }}{% endblocktranslate %}: - - {{ note }} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/mail/engagement_added.tpl b/dojo/templates_classic/notifications/mail/engagement_added.tpl deleted file mode 100644 index f5e6a41dd4f..00000000000 --- a/dojo/templates_classic/notifications/mail/engagement_added.tpl +++ /dev/null @@ -1,41 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_product' engagement.product.id as product_url %} -{% url 'view_engagement' engagement.id as engagement_url %} - - - {% autoescape on %} -

    - {% trans "Hello" %}, -

    -

    - {% blocktranslate trimmed with engagement_name=engagement.name engagement_product=engagement.product prod_url=product_url|full_url eng_url=engagement_url|full_url%} - The engagement "{{ engagement_name }}" has been created in the product "{{ engagement_product }}". It can be viewed here: {{ engagement_product }} / {{ engagement_name }} - {% endblocktranslate %} -

    -
    -
    - {% trans "Kind regards" %},
    -
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/engagement_closed.tpl b/dojo/templates_classic/notifications/mail/engagement_closed.tpl deleted file mode 100644 index 868ee903c2a..00000000000 --- a/dojo/templates_classic/notifications/mail/engagement_closed.tpl +++ /dev/null @@ -1,41 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_product' engagement.product.id as product_url %} -{% url 'view_engagement' engagement.id as engagement_url %} - - - {% autoescape on %} -

    - {% trans "Hello" %}, -

    -

    - {% blocktranslate trimmed with engagement_name=engagement.name engagement_product=engagement.product prod_url=product_url|full_url eng_url=engagement_url|full_url%} - The engagement "{{ engagement_name }}" has been closed in the product "{{ engagement_product }}". It can be viewed here: {{ engagement_product }} / {{ engagement_name }} - {% endblocktranslate %} -

    -
    -
    - {% trans "Kind regards" %},
    -
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer and system_settings.disclaimer.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/other.tpl b/dojo/templates_classic/notifications/mail/other.tpl deleted file mode 100644 index 65e5d854c3b..00000000000 --- a/dojo/templates_classic/notifications/mail/other.tpl +++ /dev/null @@ -1,43 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} - - - {% autoescape on %} -

    - {% trans "Hello" %}, -

    -

    - {{ description|safe }} -

    - {% if url is not None %} -
    -
    - {% blocktranslate trimmed with event_url=url|full_url %} - More information on this event can be found here: {{ event_url }} - {% endblocktranslate %} - {% endif %} -
    -
    - {% trans "Kind regards" %},
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/product_added.tpl b/dojo/templates_classic/notifications/mail/product_added.tpl deleted file mode 100644 index 9e5ecded414..00000000000 --- a/dojo/templates_classic/notifications/mail/product_added.tpl +++ /dev/null @@ -1,40 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} - - - {% autoescape on %} -

    - {% trans "Hello" %}, -

    -

    - {% blocktranslate trimmed with prod_url=url|full_url %} - The new product "{{ title }}" has been added. It can be viewed here: {{ title }} - {% endblocktranslate %} -

    -
    -
    - {% trans "Kind regards" %},
    -
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -

    -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/product_type_added.tpl b/dojo/templates_classic/notifications/mail/product_type_added.tpl deleted file mode 100644 index 1f41b3e5a14..00000000000 --- a/dojo/templates_classic/notifications/mail/product_type_added.tpl +++ /dev/null @@ -1,57 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_product_type' product_type.id as product_type_url %} - - - - {% autoescape on %} -

    - {% trans "Hello" %}, -

    - -

    - {% blocktranslate trimmed with title=title prod_url=product_type_url|full_url %} - The new product type "{{ title }}" has been added. - It can be viewed here: {{ title }} - {% endblocktranslate %} -

    - -
    -
    - - {% trans "Kind regards" %},
    -
    - - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} - -

    -
    -
    -

    - -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: - {{ notification_url|full_url }} -

    - - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - - {% trans "Disclaimer" %} - -
    -

    - {{ system_settings.disclaimer_notifications }} -

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/review_requested.tpl b/dojo/templates_classic/notifications/mail/review_requested.tpl deleted file mode 100644 index c3ef81b1a12..00000000000 --- a/dojo/templates_classic/notifications/mail/review_requested.tpl +++ /dev/null @@ -1,45 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} - - - {% autoescape on %} -

    - {% trans "Hello" %}, -

    -

    - {% blocktranslate trimmed %} - User {{ requested_by }} has requested that the following users review the finding "{{ finding }}" for accuracy: - {% endblocktranslate %} - {% for user in reviewers %} -

  • {{ user.get_full_name }}
  • - {% endfor %} -
    - {{ note }} -
    -
    - It can be reviewed at {{ url|full_url }} -

    -
    - {% trans "Kind regards" %},
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/risk_acceptance_expiration.tpl b/dojo/templates_classic/notifications/mail/risk_acceptance_expiration.tpl deleted file mode 100644 index ce76a2d1b5b..00000000000 --- a/dojo/templates_classic/notifications/mail/risk_acceptance_expiration.tpl +++ /dev/null @@ -1,64 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_risk_acceptance' risk_acceptance.engagement.id risk_acceptance.id as risk_acceptance_url %} -{% url 'view_product' risk_acceptance.engagement.product.id as product_url %} -{% url 'view_engagement' risk_acceptance.engagement.id as engagement_url %} - - - {% autoescape on %} -

    - {% trans "Hello" %} {{ user.get_full_name }}, -

    -

    - {{ description }} -

    - - {% if risk_acceptance.is_expired %} - {% blocktranslate with risk_url=risk_acceptance_url|full_url risk_findings=risk_acceptance.accepted_findings.all|length risk_date=risk_acceptance.expiration_date_handled|date %}Risk acceptance {{ risk_acceptance }} with {{ risk_findings }} has expired {{ risk_date }}{% endblocktranslate %} - {% else %} - {% blocktranslate with risk_url=risk_acceptance_url|full_url risk_findings=risk_acceptance.accepted_findings.all|length risk_date=risk_acceptance.expiration_date|date %}Risk acceptance {{ risk_acceptance }} with {{ risk_findings }} will expire {{ risk_date }}{% endblocktranslate %} - {% endif %} -
    - {% if risk_acceptance.reactivate_expired %} -

    {% blocktranslate %}Findings have been reactivated

    {% endblocktranslate %} - {% endif %} -
    - {% if risk_acceptance.restart_sla_expired %} -

    {% blocktranslate %}Findings SLA start date have been reset

    {% endblocktranslate %} - {% endif %} -
    -

    - {% trans "Findings" %}: -
    - {% for finding in risk_acceptance.accepted_findings.all %} - {% url 'view_finding' finding.id as finding_url %} - {{ finding.title }} ({{ finding.severity }}) {{ finding.status }}
    - {% empty %} - {% trans "None" %}
    - {% endfor %} -

    -

    - {% trans "Kind regards" %}, -

    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/scan_added.tpl b/dojo/templates_classic/notifications/mail/scan_added.tpl deleted file mode 100644 index 567d80ee47e..00000000000 --- a/dojo/templates_classic/notifications/mail/scan_added.tpl +++ /dev/null @@ -1,117 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_product' test.engagement.product.id as product_url %} -{% url 'view_engagement' test.engagement.id as engagement_url %} -{% url 'view_test' test.id as test_url %} - - - {% autoescape on %} -

    - {% trans "Hello" %} {{ user.get_full_name }}, -

    -

    - {{ description }} -

    - {% blocktranslate %}{{ finding_count }} findings have been updated for while a scan was uploaded{% endblocktranslate %}: - {{product}} / {{ engagement.name }} / {{ test }}
    -
    -

    - {% blocktranslate %}New findings{% endblocktranslate %} ({{ findings_new | length }})
    - {% for finding in findings_new %} - {% url 'view_finding' finding.id as finding_url %} - {{ finding.title }} ({{ finding.severity }})
    - {% empty %} - {% trans "None" %}
    - {% endfor %} -
    -

    - {% if findings_new_duplicate %} -

    -

    - {% blocktranslate %}New findings detected as duplicates{% endblocktranslate %} ({{ findings_new_duplicate | length }})
    - {% for finding in findings_new_duplicate %} - {% url 'view_finding' finding.id as finding_url %} - {{ finding.title }} ({{ finding.severity }})
    - {% endfor %} -
    -

    - {% endif %} -

    -

    - {% blocktranslate %}Reactivated findings{% endblocktranslate %} ({{ findings_reactivated | length }})
    - {% for finding in findings_reactivated %} - {% url 'view_finding' finding.id as finding_url %} - {{ finding.title }} ({{ finding.severity }})
    - {% empty %} - {% trans "None" %}
    - {% endfor %} -
    -

    - {% if findings_reactivated_duplicate %} -

    -

    - {% blocktranslate %}Reactivated findings detected as duplicates{% endblocktranslate %} ({{ findings_reactivated_duplicate | length }})
    - {% for finding in findings_reactivated_duplicate %} - {% url 'view_finding' finding.id as finding_url %} - {{ finding.title }} ({{ finding.severity }})
    - {% endfor %} -
    -

    - {% endif %} -

    -

    - {% blocktranslate %}Closed findings{% endblocktranslate %} ({{ findings_mitigated | length }})
    - {% for finding in findings_mitigated %} - {% url 'view_finding' finding.id as finding_url %} - {{ finding.title }} ({{ finding.severity }})
    - {% empty %} - {% trans "None" %}
    - {% endfor %} -
    -

    -

    -

    - {% blocktranslate %}Untouched findings{% endblocktranslate %} ({{ findings_untouched | length }})
    - {% for finding in findings_untouched %} - {% url 'view_finding' finding.id as finding_url %} - {{ finding.title }} ({{ finding.severity }})
    - {% empty %} - {% trans "None" %}
    - {% endfor %} -
    -

    - {% if findings_untouched_duplicate %} -

    -

    - {% blocktranslate %}Existing findings detected as duplicates{% endblocktranslate %} ({{ findings_untouched_duplicate | length }})
    - {% for finding in findings_untouched_duplicate %} - {% url 'view_finding' finding.id as finding_url %} - {{ finding.title }} ({{ finding.severity }})
    - {% endfor %} -
    -

    - {% endif %} -

    - {% trans "Kind regards" %}, -

    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -

    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/scan_added_empty.tpl b/dojo/templates_classic/notifications/mail/scan_added_empty.tpl deleted file mode 120000 index 03f4bb86dc1..00000000000 --- a/dojo/templates_classic/notifications/mail/scan_added_empty.tpl +++ /dev/null @@ -1 +0,0 @@ -{% include "notifications/mail/scan_added.tpl" %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/mail/sla_breach.tpl b/dojo/templates_classic/notifications/mail/sla_breach.tpl deleted file mode 100644 index d23340794c8..00000000000 --- a/dojo/templates_classic/notifications/mail/sla_breach.tpl +++ /dev/null @@ -1,57 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_finding' finding.id as finding_url %} - - - {% autoescape on %} -

    - {% trans "Hello" %} {{ user.get_full_name }}, -

    -
    -
    -

    - {% if sla_age < 0 %} - {% blocktranslate trimmed %} - This security finding has breached its SLA. -
    - - Day(s) overdue: {{sla_age}} - {% endblocktranslate %} - {% else %} - {% blocktranslate trimmed %} - A security finding is about to breach its SLA. -
    - - Day(s) remaining: {{sla_age}} - {% endblocktranslate %} - {% endif %} -

    -
    - - {% trans "Title" %}: {{finding.title}} -
    - - {% trans "Severity" %}: {{finding.severity}} -

    - {% trans "Please refer to your SLA documentation for further guidance" %} -

    -

    - {% trans "Kind regards" %}, -

    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -

    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/sla_breach_combined.tpl b/dojo/templates_classic/notifications/mail/sla_breach_combined.tpl deleted file mode 100644 index 4fc8c8f801e..00000000000 --- a/dojo/templates_classic/notifications/mail/sla_breach_combined.tpl +++ /dev/null @@ -1,72 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} - - - {% autoescape on %} -

    {% trans "Hello" %} {{ user.get_full_name }},

    -

    - {% trans "Product summary" %}: -

      -
    • {% trans "name" %}: {{ product.name }}
    • -
    • {% trans "product type" %}: {{ product.prod_type }}
    • -
    • {% trans "team manager" %}: {{ product.team_manager }}
    • -
    • {% trans "product manager" %}: {{ product.product_manager }}
    • -
    • {% trans "technical contact" %}: {{ product.technical_contact }}
    • -
    -

    -

    - {% if breach_kind == 'breached' %} - {% blocktranslate trimmed %} - These security findings have breached their SLA: - {% endblocktranslate %} - {% elif breach_kind == 'prebreach' %} - {% blocktranslate trimmed %} - These security findings are about to breach their SLA: - {% endblocktranslate %} - {% elif breach_kind == 'breaching' %} - {% blocktranslate trimmed %} - These security findings breaching their SLA today: - {% endblocktranslate %} - {% else %} - This should not happen, check 'breach_kind' and 'kind' properties value in the source code. - {% endif %} -
    -

      - {% for f in findings %} - {% url 'view_finding' f.id as finding_url %} -
    • - "{{ f.title }}" ({{ f.severity }} {% trans "severity" %}), {% trans "SLA age" %}: {{ f.sla_age }} -
    • - {% endfor %} -
    -
    - {% trans "Please refer to your SLA documentation for further guidance" %} -

    - {% trans "Kind regards" %}, -
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %} -
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/test_added.tpl b/dojo/templates_classic/notifications/mail/test_added.tpl deleted file mode 100644 index 3c235ea0762..00000000000 --- a/dojo/templates_classic/notifications/mail/test_added.tpl +++ /dev/null @@ -1,42 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} -{% url 'view_product' test.engagement.product.id as product_url %} -{% url 'view_engagement' test.engagement.id as engagement_url %} -{% url 'view_test' test.id as test_url %} - - - {% autoescape on %} -

    - {% trans "Hello" %} {{ user.get_full_name }}, -

    -

    - {% blocktranslate trimmed with prod_url=product_url|full_url eng_url=engagement_url|full_url eng_name=engagement.name t_url=test_url|full_url %} - A new test has been added: {{product}} / {{ eng_name }} / {{ test }}
    - Finding details in the 'scan_added' email, which is a separate notification (for now). - {% endblocktranslate %} -

    -
    - {% trans "Kind regards" %},
    -
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/upcoming_engagement.tpl b/dojo/templates_classic/notifications/mail/upcoming_engagement.tpl deleted file mode 100644 index 858314449c0..00000000000 --- a/dojo/templates_classic/notifications/mail/upcoming_engagement.tpl +++ /dev/null @@ -1,40 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} - - - {% autoescape on %} -

    - {% trans "Hello" %}, -

    -

    - {% blocktranslate trimmed with product=engagement.product%} - this is a reminder that the engagement "{{ product }}" is about to start shortly. - {% endblocktranslate %} -

    - {% trans "Project start" %}: {{ engagement.target_start }}
    - {% trans "Project end" %}: {{ engagement.target_end }}
    -
    -
    - {% trans "Kind regards" %},
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/mail/user_mentioned.tpl b/dojo/templates_classic/notifications/mail/user_mentioned.tpl deleted file mode 100644 index d828940400d..00000000000 --- a/dojo/templates_classic/notifications/mail/user_mentioned.tpl +++ /dev/null @@ -1,43 +0,0 @@ -{% load i18n %} -{% load navigation_tags %} -{% load display_tags %} - - - {% autoescape on %} -

    - {% trans "Hello" %}, -

    -

    - {% blocktranslate trimmed %} - User {{ requested_by }} jotted a note on {{ section }}:
    -
    - {{ note }}
    -
    - It can be reviewed at {{ url }} - {% endblocktranslate %} -

    - -
    -
    - {% trans "Kind regards" %},
    - {% if system_settings.team_name %} - {{ system_settings.team_name }} - {% else %} - Defect Dojo - {% endif %} -
    -
    -

    - {% url 'notifications' as notification_url %} - {% trans "You can manage your notification settings here" %}: {{ notification_url|full_url }} -

    - {% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -
    -
    - {% trans "Disclaimer" %}
    -

    {{ system_settings.disclaimer_notifications }}

    -
    - {% endif %} - {% endautoescape %} - - diff --git a/dojo/templates_classic/notifications/msteams/engagement_added.tpl b/dojo/templates_classic/notifications/msteams/engagement_added.tpl deleted file mode 100644 index 0981d5574cc..00000000000 --- a/dojo/templates_classic/notifications/msteams/engagement_added.tpl +++ /dev/null @@ -1,103 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_engagement' engagement.id as url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Engagement Added' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'A new engagement' %} [{{ engagement.name }}]({{ url|full_url }}) {% trans 'has been added.' %}", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product' %}:", - "value": "{{ engagement.product.name }}" - }, - { - "title": "{% trans 'Engagement' %}:", - "value": "{{ engagement.name }}" - }, - { - "title": "{% trans 'Start date' %}:", - "value": "{{ engagement.target_start }}" - }, - { - "title": "{% trans 'End date' %}:", - "value": "{{ engagement.target_end }}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Engagement' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/engagement_closed.tpl b/dojo/templates_classic/notifications/msteams/engagement_closed.tpl deleted file mode 100644 index 9c0d7178eb3..00000000000 --- a/dojo/templates_classic/notifications/msteams/engagement_closed.tpl +++ /dev/null @@ -1,103 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_engagement' engagement.id as url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Engagement Closed' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'An engagement' %} [{{ engagement.name }}]({{ url|full_url }}) {% trans 'has been closed.' %}", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product' %}:", - "value": "{{ engagement.product.name }}" - }, - { - "title": "{% trans 'Engagement' %}:", - "value": "{{ engagement.name }}" - }, - { - "title": "{% trans 'Start date' %}:", - "value": "{{ engagement.target_start }}" - }, - { - "title": "{% trans 'End date' %}:", - "value": "{{ engagement.target_end }}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Engagement' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/other.tpl b/dojo/templates_classic/notifications/msteams/other.tpl deleted file mode 100644 index cbe261a7a5a..00000000000 --- a/dojo/templates_classic/notifications/msteams/other.tpl +++ /dev/null @@ -1,81 +0,0 @@ -{% load i18n %}{% load display_tags %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Event' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% autoescape on %}{{ description }}{% endautoescape %}", - "wrap": true, - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/product_added.tpl b/dojo/templates_classic/notifications/msteams/product_added.tpl deleted file mode 100644 index 24ff6187241..00000000000 --- a/dojo/templates_classic/notifications/msteams/product_added.tpl +++ /dev/null @@ -1,107 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_product' product.id as url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Product Added' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'A new product' %} [{{ product.name }}]({{ url|full_url }}) {% trans 'has been added.' %}", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product' %}:", - "value": "{{ product.name }}" - }, - { - "title": "{% trans 'Tags' %}:", - "value": "{% for tag in product.tags.all %}{{ tag.name }}{% if not forloop.last %}, {% endif %}{% empty %}{% trans 'None' %}{% endfor %}" - }, - { - "title": "{% trans 'Product Type' %}:", - "value": "{{ product.prod_type.name }}" - }, - { - "title": "{% trans 'SLA Configuration' %}:", - "value": "{% if product.sla_configuration %}{{ product.sla_configuration.name }}{% else %}{% trans 'None' %}{% endif %}" - }, - { - "title": "{% trans 'Internet Accessible' %}:", - "value": "{% if product.internet %}{% trans 'Yes' %}{% else %}{% trans 'No' %}{% endif %}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Product' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/product_type_added.tpl b/dojo/templates_classic/notifications/msteams/product_type_added.tpl deleted file mode 100644 index ff1395b2ed2..00000000000 --- a/dojo/templates_classic/notifications/msteams/product_type_added.tpl +++ /dev/null @@ -1,99 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_product_type' product_type.id as url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Product Type Added' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'A new product type' %} [{{ product_type.name }}]({{ url|full_url }}) {% trans 'has been added.' %}", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product Type' %}:", - "value": "{{ product_type.name }}" - }, - { - "title": "{% trans 'Critical Product' %}:", - "value": "{% if product_type.critical_product %}{% trans 'Yes' %}{% else %}{% trans 'No' %}{% endif %}" - }, - { - "title": "{% trans 'Key Product' %}:", - "value": "{% if product_type.key_product %}{% trans 'Yes' %}{% else %}{% trans 'No' %}{% endif %}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Product Type' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/review_requested.tpl b/dojo/templates_classic/notifications/msteams/review_requested.tpl deleted file mode 100644 index c62496bbf28..00000000000 --- a/dojo/templates_classic/notifications/msteams/review_requested.tpl +++ /dev/null @@ -1,124 +0,0 @@ -{% comment %} -NOTE: This template is currently NOT USED in practice because: -- review_requested notifications are sent to specific recipients (the reviewers) -- Microsoft Teams only supports system-wide notifications, not user-specific ones -- The notification system processes recipient-specific notifications using personal settings -- Since Teams doesn't have individual user channels/usernames (unlike email or Slack), - these notifications will only work for channels that support user-specific delivery -{% endcomment %} -{% load i18n %}{% load display_tags %}{% url 'view_finding' finding.id as finding_url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Review Requested' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'A review has been requested for finding' %} [{{ finding.title }}]({{ finding_url|full_url }}).", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Reviewers:' %} {% for reviewer in reviewers %}{{ reviewer.get_full_name|default:reviewer.username }}{% if not forloop.last %}, {% endif %}{% endfor %}", - "wrap": true, - "spacing": "Small" - }, - { - "type": "TextBlock", - "text": "{{ note.entry }}", - "wrap": true, - "spacing": "Small", - "isSubtle": true - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product' %}:", - "value": "{{ finding.test.engagement.product.name }}" - }, - { - "title": "{% trans 'Engagement' %}:", - "value": "{{ finding.test.engagement.name }}" - }, - { - "title": "{% trans 'Finding' %}:", - "value": "{{ finding.title }}" - }, - { - "title": "{% trans 'Severity' %}:", - "value": "{{ finding.severity }}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Finding' %}", - "url": "{{ finding_url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/risk_acceptance_expiration.tpl b/dojo/templates_classic/notifications/msteams/risk_acceptance_expiration.tpl deleted file mode 100644 index 3d77a6472f7..00000000000 --- a/dojo/templates_classic/notifications/msteams/risk_acceptance_expiration.tpl +++ /dev/null @@ -1,107 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_risk_acceptance' risk_acceptance.id as url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% if risk_acceptance.is_expired %}{% trans 'Risk Acceptance Expired' %}{% else %}{% trans 'Risk Acceptance Expiring' %}{% endif %}", - "weight": "Bolder", - "size": "Large", - "color": "{% if risk_acceptance.is_expired %}Attention{% else %}Warning{% endif %}" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% if risk_acceptance.is_expired %}{% trans 'Risk acceptance' %} [{{ risk_acceptance }}]({{ url|full_url }}) {% blocktranslate with risk_findings=risk_acceptance.accepted_findings.all|length risk_date=risk_acceptance.expiration_date_handled|date %}with {{ risk_findings }} finding(s) has expired on {{ risk_date }}.{% endblocktranslate %}{% else %}{% trans 'Risk acceptance' %} [{{ risk_acceptance }}]({{ url|full_url }}) {% blocktranslate with risk_findings=risk_acceptance.accepted_findings.all|length risk_date=risk_acceptance.expiration_date|date %}with {{ risk_findings }} finding(s) will expire on {{ risk_date }}.{% endblocktranslate %}{% endif %}", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Risk Acceptance' %}:", - "value": "{{ risk_acceptance }}" - }, - { - "title": "{% trans 'Accepted Findings' %}:", - "value": "{{ risk_acceptance.accepted_findings.all|length }}" - }, - { - "title": "{% if risk_acceptance.is_expired %}{% trans 'Expired Date' %}{% else %}{% trans 'Expiration Date' %}{% endif %}:", - "value": "{% if risk_acceptance.is_expired %}{{ risk_acceptance.expiration_date_handled|date }}{% else %}{{ risk_acceptance.expiration_date|date }}{% endif %}" - }{% if risk_acceptance.reactivate_expired %}, - { - "title": "{% trans 'Reactivation Status' %}:", - "value": "{% trans 'Findings have been reactivated' %}" - }{% endif %}{% if risk_acceptance.restart_sla_expired %}, - { - "title": "{% trans 'SLA Status' %}:", - "value": "{% trans 'Findings SLA start date have been reset' %}" - }{% endif %} - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Risk Acceptance' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/scan_added.tpl b/dojo/templates_classic/notifications/msteams/scan_added.tpl deleted file mode 100644 index bdbaa2bf081..00000000000 --- a/dojo/templates_classic/notifications/msteams/scan_added.tpl +++ /dev/null @@ -1,123 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_test' test.id as url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Scan Added' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% blocktranslate with count=finding_count %}{{ count }} findings have been updated for scan{% endblocktranslate %} [{{ test }}]({{ url|full_url }}).", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product' %}:", - "value": "{{ test.engagement.product.name }}" - }, - { - "title": "{% trans 'Engagement' %}:", - "value": "{{ test.engagement.name }}" - }, - { - "title": "{% trans 'Scan' %}:", - "value": "{{ test }}" - }, - { - "title": "{% trans 'Scan Type' %}:", - "value": "{{ test.test_type.name }}" - }, - { - "title": "{% trans 'Updated Findings' %}:", - "value": "{{ finding_count }}" - }, - { - "title": "{% trans 'New Findings' %}:", - "value": "{{ findings_new|length|default:'0' }}" - }, - { - "title": "{% trans 'Reactivated Findings' %}:", - "value": "{{ findings_reactivated|length|default:'0' }}" - }, - { - "title": "{% trans 'Closed Findings' %}:", - "value": "{{ findings_mitigated|length|default:'0' }}" - }, - { - "title": "{% trans 'Untouched Findings' %}:", - "value": "{{ findings_untouched|length|default:'0' }}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Scan' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/scan_added_empty.tpl b/dojo/templates_classic/notifications/msteams/scan_added_empty.tpl deleted file mode 120000 index cf88b460f91..00000000000 --- a/dojo/templates_classic/notifications/msteams/scan_added_empty.tpl +++ /dev/null @@ -1 +0,0 @@ -{% include "notifications/msteams/scan_added.tpl" %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/sla_breach.tpl b/dojo/templates_classic/notifications/msteams/sla_breach.tpl deleted file mode 100644 index e7f5d635b5c..00000000000 --- a/dojo/templates_classic/notifications/msteams/sla_breach.tpl +++ /dev/null @@ -1,107 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_finding' finding.id as finding_url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'SLA Breach' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'SLA breach' %} [{{ finding.title }}]({{ finding_url|full_url }}) {% blocktranslate with severity=finding.severity sla_age=sla_age %}with severity {{ severity }} has breached its SLA ({{ sla_age }} days).{% endblocktranslate %}", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product' %}:", - "value": "{{ finding.test.engagement.product.name }}" - }, - { - "title": "{% trans 'Engagement' %}:", - "value": "{{ finding.test.engagement.name }}" - }, - { - "title": "{% trans 'Finding' %}:", - "value": "{{ finding.title }}" - }, - { - "title": "{% trans 'Severity' %}:", - "value": "{{ finding.severity }}" - }, - { - "title": "{% trans 'SLA age' %}:", - "value": "{{ sla_age }} days" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Finding' %}", - "url": "{{ finding_url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/test_added.tpl b/dojo/templates_classic/notifications/msteams/test_added.tpl deleted file mode 100644 index e43bec7698f..00000000000 --- a/dojo/templates_classic/notifications/msteams/test_added.tpl +++ /dev/null @@ -1,103 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_test' test.id as url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Test Added' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'A new test' %} [{{ test }}]({{ url|full_url }}) {% trans 'has been added.' %}", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product' %}:", - "value": "{{ test.engagement.product.name }}" - }, - { - "title": "{% trans 'Engagement' %}:", - "value": "{{ test.engagement.name }}" - }, - { - "title": "{% trans 'Test' %}:", - "value": "{{ test }}" - }, - { - "title": "{% trans 'Scan Type' %}:", - "value": "{{ test.test_type.name }}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Test' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/upcoming_engagement.tpl b/dojo/templates_classic/notifications/msteams/upcoming_engagement.tpl deleted file mode 100644 index eaaa10e7a09..00000000000 --- a/dojo/templates_classic/notifications/msteams/upcoming_engagement.tpl +++ /dev/null @@ -1,103 +0,0 @@ -{% load i18n %}{% load display_tags %}{% url 'view_engagement' engagement.id as engagement_url %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'Upcoming Engagement' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'Engagement' %} [{{ engagement.name }}]({{ engagement_url|full_url }}) {% blocktranslate with start_date=engagement.target_start %}is starting on {{ start_date }}.{% endblocktranslate %}", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'Product' %}:", - "value": "{{ engagement.product.name }}" - }, - { - "title": "{% trans 'Engagement' %}:", - "value": "{{ engagement.name }}" - }, - { - "title": "{% trans 'Start date' %}:", - "value": "{{ engagement.target_start }}" - }, - { - "title": "{% trans 'End date' %}:", - "value": "{{ engagement.target_end }}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Engagement' %}", - "url": "{{ engagement_url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/msteams/user_mentioned.tpl b/dojo/templates_classic/notifications/msteams/user_mentioned.tpl deleted file mode 100644 index aba4d11c089..00000000000 --- a/dojo/templates_classic/notifications/msteams/user_mentioned.tpl +++ /dev/null @@ -1,107 +0,0 @@ -{% comment %} -NOTE: This template is currently NOT USED in practice because: -- review_requested notifications are sent to specific recipients (the reviewers) -- Microsoft Teams only supports system-wide notifications, not user-specific ones -- The notification system processes recipient-specific notifications using personal settings -- Since Teams doesn't have individual user channels/usernames (unlike email or Slack), - these notifications will only work for channels that support user-specific delivery -{% endcomment %} -{% load i18n %}{% load display_tags %} -{ - "type": "message", - "attachments": [ - { - "contentType": "application/vnd.microsoft.card.adaptive", - "content": { - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "items": [ - { - "type": "Image", - "url": "https://raw.githubusercontent.com/DefectDojo/django-DefectDojo/master/dojo/static/dojo/img/chop.png", - "size": "Small" - } - ] - }, - { - "type": "Column", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "text": "DefectDojo", - "weight": "Bolder", - "size": "Medium" - }, - { - "type": "TextBlock", - "text": "{% trans 'User Mentioned' %}", - "weight": "Bolder", - "size": "Large", - "color": "Accent" - } - ] - } - ] - }, - { - "type": "TextBlock", - "text": "{% trans 'User' %} {{ requested_by }} {% trans 'mentioned you in' %} {{ section }}.", - "wrap": true, - "spacing": "Medium" - }, - { - "type": "FactSet", - "facts": [ - { - "title": "{% trans 'User' %}:", - "value": "{{ requested_by }}" - }, - { - "title": "{% trans 'Section' %}:", - "value": "{{ section }}" - }, - { - "title": "{% trans 'Note' %}:", - "value": "{{ note }}" - } - ], - "spacing": "Medium" - }{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %}, - { - "type": "Container", - "style": "attention", - "items": [ - { - "type": "TextBlock", - "text": "{% trans 'Disclaimer' %}", - "weight": "Bolder" - }, - { - "type": "TextBlock", - "text": "{{ system_settings.disclaimer_notifications }}", - "wrap": true - } - ], - "spacing": "Medium" - }{% endif %} - ], - "actions": [ - { - "type": "Action.OpenUrl", - "title": "{% trans 'View Details' %}", - "url": "{{ url|full_url }}" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/slack/engagement_added.tpl b/dojo/templates_classic/notifications/slack/engagement_added.tpl deleted file mode 100644 index 212f0f5ee3c..00000000000 --- a/dojo/templates_classic/notifications/slack/engagement_added.tpl +++ /dev/null @@ -1,10 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed with name=engagement.name eng_product=engagement.product eng_url=url|full_url %} -The engagement "{{ name }}" has been created in the product "{{ eng_product }}". It can be viewed here: {{ eng_url }} -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/engagement_closed.tpl b/dojo/templates_classic/notifications/slack/engagement_closed.tpl deleted file mode 100644 index 313c7a1c937..00000000000 --- a/dojo/templates_classic/notifications/slack/engagement_closed.tpl +++ /dev/null @@ -1,10 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed with name=engagement.name eng_product=engagement.product eng_url=url|full_url %} -The engagement "{{ name }}" has been closed in the product "{{ eng_product }}". It can be viewed here: {{ eng_url }} -{% endblocktranslate %} -{% if system_settings.disclaimer and system_settings.disclaimer.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/other.tpl b/dojo/templates_classic/notifications/slack/other.tpl deleted file mode 100644 index f371af5263d..00000000000 --- a/dojo/templates_classic/notifications/slack/other.tpl +++ /dev/null @@ -1,13 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{{ description|safe }} -{% if url is not None %} -{% blocktranslate trimmed with event_url=url|full_url %} - More information on this event can be found here: {{ event_url }} -{% endblocktranslate %} -{% endif %} -{% if system_settings.disclaimer_notifications|length %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/product_added.tpl b/dojo/templates_classic/notifications/slack/product_added.tpl deleted file mode 100644 index 6d1cef84455..00000000000 --- a/dojo/templates_classic/notifications/slack/product_added.tpl +++ /dev/null @@ -1,10 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed with prod_url=url|full_url %} -The new product "{{ title }}" has been added. It can be viewed here: {{ prod_url }} -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/product_type_added.tpl b/dojo/templates_classic/notifications/slack/product_type_added.tpl deleted file mode 100644 index 8818a3f101a..00000000000 --- a/dojo/templates_classic/notifications/slack/product_type_added.tpl +++ /dev/null @@ -1,10 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed with prod_url=url|full_url %} -The new product type "{{ title }}" has been added. It can be viewed here: {{ prod_url }} -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/report_created.tpl b/dojo/templates_classic/notifications/slack/report_created.tpl deleted file mode 100644 index 8c5ca4a9290..00000000000 --- a/dojo/templates_classic/notifications/slack/report_created.tpl +++ /dev/null @@ -1,10 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed with name=report.name report_url=url|full_url %} -Your report "{{ name }}" is ready. It can be downloaded here: {{ report_url }} -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/review_requested.tpl b/dojo/templates_classic/notifications/slack/review_requested.tpl deleted file mode 100644 index 395954e7745..00000000000 --- a/dojo/templates_classic/notifications/slack/review_requested.tpl +++ /dev/null @@ -1,21 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed %} - User {{ requested_by }} has requested that the following users review the finding "{{ finding }}" for accuracy: -{% endblocktranslate %} - -{% for user in reviewers %} - - {{ user.get_full_name }} -{% endfor %} - -{% blocktranslate trimmed %} - {{ note }} -{% endblocktranslate %} - - -{% trans "Full details of the finding can be reviewed at" %} {{ url|full_url }} - -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - {% trans "Disclaimer:" %} - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/risk_acceptance_expiration.tpl b/dojo/templates_classic/notifications/slack/risk_acceptance_expiration.tpl deleted file mode 100644 index d5222c81826..00000000000 --- a/dojo/templates_classic/notifications/slack/risk_acceptance_expiration.tpl +++ /dev/null @@ -1,18 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{{ description }} - -{% if risk_acceptance.is_expired %} - {% trans "Risk Acceptance Expired" %} -{% else %} - {% trans "Risk Acceptance Will Expire Soon" %} -{% endif %} - -{% blocktranslate trimmed with risk_url=risk_acceptance_url|full_url %} -Risk Acceptance can be viewed here: {{ risk_url }} -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/scan_added.tpl b/dojo/templates_classic/notifications/slack/scan_added.tpl deleted file mode 100644 index 97488ee6c0b..00000000000 --- a/dojo/templates_classic/notifications/slack/scan_added.tpl +++ /dev/null @@ -1,15 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{{ description }} -{% if url is not None %} - - {% blocktranslate trimmed with scan_url=url|full_url %} - {{ test }} results have been uploaded. - They can be viewed here: {{ scan_url }} - {% endblocktranslate %} -{% endif %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/scan_added_empty.tpl b/dojo/templates_classic/notifications/slack/scan_added_empty.tpl deleted file mode 120000 index 0e129371211..00000000000 --- a/dojo/templates_classic/notifications/slack/scan_added_empty.tpl +++ /dev/null @@ -1 +0,0 @@ -{% include "notifications/slack/scan_added.tpl" %} \ No newline at end of file diff --git a/dojo/templates_classic/notifications/slack/sla_breach.tpl b/dojo/templates_classic/notifications/slack/sla_breach.tpl deleted file mode 100644 index 0e9d4ce9e5b..00000000000 --- a/dojo/templates_classic/notifications/slack/sla_breach.tpl +++ /dev/null @@ -1,13 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed with id=finding.id product_name=finding.test.engagement.product title=finding.title severity=finding.severity sla_url=url|full_url %} -SLA breach alert for finding {{ id }} in product {{ product_name }}. Relative days count to SLA due date: {{sla_age}}. -Title: {{title}} -Severity: {{severity}} -You can find details here: {{ sla_url }} -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/test_added.tpl b/dojo/templates_classic/notifications/slack/test_added.tpl deleted file mode 100644 index fddccc1e815..00000000000 --- a/dojo/templates_classic/notifications/slack/test_added.tpl +++ /dev/null @@ -1,13 +0,0 @@ -{% load i18n %} -{% load display_tags %} -{% blocktranslate trimmed with eng_name=engagement.name eng_product=engagement.product title=test.title test_type=test.test_type test_url=url|full_url %} -New test added for engagement {{eng_name }} in product {{ eng_product}}. -Title: {{title}} -Type: {{ test_type }} -You can find details here: {{ test_url }} -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/upcoming_engagement.tpl b/dojo/templates_classic/notifications/slack/upcoming_engagement.tpl deleted file mode 100644 index 1b2e80df6da..00000000000 --- a/dojo/templates_classic/notifications/slack/upcoming_engagement.tpl +++ /dev/null @@ -1,8 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed with eng_product=engagement.product start=engagement.target_start %} -The engagement "{{ eng_product }}" is starting on {{ start }}. -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/slack/user_mentioned.tpl b/dojo/templates_classic/notifications/slack/user_mentioned.tpl deleted file mode 100644 index 9131de845a8..00000000000 --- a/dojo/templates_classic/notifications/slack/user_mentioned.tpl +++ /dev/null @@ -1,12 +0,0 @@ -{% load i18n %}{% blocktranslate trimmed %} -User {{ requested_by }} jotted a note on {{ section }}: - -{{ note }} - -Full details of the note can be reviewed at {{ url }} -{% endblocktranslate %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} - - {% trans "Disclaimer" %}: - {{ system_settings.disclaimer_notifications }} -{% endif %} diff --git a/dojo/templates_classic/notifications/webhooks/engagement_added.tpl b/dojo/templates_classic/notifications/webhooks/engagement_added.tpl deleted file mode 100644 index a8110965e35..00000000000 --- a/dojo/templates_classic/notifications/webhooks/engagement_added.tpl +++ /dev/null @@ -1,2 +0,0 @@ -{% include 'notifications/webhooks/subtemplates/base.tpl' %} -{% include 'notifications/webhooks/subtemplates/engagement.tpl' %} diff --git a/dojo/templates_classic/notifications/webhooks/other.tpl b/dojo/templates_classic/notifications/webhooks/other.tpl deleted file mode 100644 index 40947ead3a8..00000000000 --- a/dojo/templates_classic/notifications/webhooks/other.tpl +++ /dev/null @@ -1 +0,0 @@ -{% include 'notifications/webhooks/subtemplates/base.tpl' %} diff --git a/dojo/templates_classic/notifications/webhooks/product_added.tpl b/dojo/templates_classic/notifications/webhooks/product_added.tpl deleted file mode 100644 index f2994c18756..00000000000 --- a/dojo/templates_classic/notifications/webhooks/product_added.tpl +++ /dev/null @@ -1,2 +0,0 @@ -{% include 'notifications/webhooks/subtemplates/base.tpl' %} -{% include 'notifications/webhooks/subtemplates/product.tpl' %} diff --git a/dojo/templates_classic/notifications/webhooks/product_type_added.tpl b/dojo/templates_classic/notifications/webhooks/product_type_added.tpl deleted file mode 100644 index d0790400ad6..00000000000 --- a/dojo/templates_classic/notifications/webhooks/product_type_added.tpl +++ /dev/null @@ -1,2 +0,0 @@ -{% include 'notifications/webhooks/subtemplates/base.tpl' %} -{% include 'notifications/webhooks/subtemplates/product_type.tpl' %} diff --git a/dojo/templates_classic/notifications/webhooks/scan_added.tpl b/dojo/templates_classic/notifications/webhooks/scan_added.tpl deleted file mode 100644 index 0f68a72eb12..00000000000 --- a/dojo/templates_classic/notifications/webhooks/scan_added.tpl +++ /dev/null @@ -1,18 +0,0 @@ -{% include 'notifications/webhooks/subtemplates/base.tpl' %} -{% include 'notifications/webhooks/subtemplates/test.tpl' %} -finding_count: {{ finding_count }} -findings: - new: -{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_new %} - reactivated: -{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_reactivated %} - mitigated: -{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_mitigated %} - untouched: -{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_untouched %} - new_duplicate: -{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_new_duplicate %} - reactivated_duplicate: -{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_reactivated_duplicate %} - untouched_duplicate: -{% include 'notifications/webhooks/subtemplates/findings_list.tpl' with findings=findings_untouched_duplicate %} diff --git a/dojo/templates_classic/notifications/webhooks/scan_added_empty.tpl b/dojo/templates_classic/notifications/webhooks/scan_added_empty.tpl deleted file mode 120000 index 4efb291e005..00000000000 --- a/dojo/templates_classic/notifications/webhooks/scan_added_empty.tpl +++ /dev/null @@ -1 +0,0 @@ -scan_added.tpl \ No newline at end of file diff --git a/dojo/templates_classic/notifications/webhooks/subtemplates/base.tpl b/dojo/templates_classic/notifications/webhooks/subtemplates/base.tpl deleted file mode 100644 index 8f810747b48..00000000000 --- a/dojo/templates_classic/notifications/webhooks/subtemplates/base.tpl +++ /dev/null @@ -1,15 +0,0 @@ -{% load display_tags %} -{% load as_json %} ---- -description: {{ description | as_json_no_html_esc }} -title: {{ title | as_json_no_html_esc }} -{% include 'notifications/webhooks/subtemplates/user.tpl' %} -{% if url %} -url_ui: {{ url | full_url | as_json_no_html_esc }} -{% endif %} -{% if url_api %} -url_api: {{ url_api | full_url | as_json_no_html_esc }} -{% endif %} -{% if system_settings.disclaimer_notifications and system_settings.disclaimer_notifications.strip %} -disclaimer: {{ system_settings.disclaimer_notifications | as_json_no_html_esc }} -{% endif %} diff --git a/dojo/templates_classic/notifications/webhooks/subtemplates/engagement.tpl b/dojo/templates_classic/notifications/webhooks/subtemplates/engagement.tpl deleted file mode 100644 index 0ea0b8ab546..00000000000 --- a/dojo/templates_classic/notifications/webhooks/subtemplates/engagement.tpl +++ /dev/null @@ -1,14 +0,0 @@ -{% load display_tags %} -{% load as_json %} -{% if product %} -{% include 'notifications/webhooks/subtemplates/product.tpl' with product=product %} -{% else %} -{% include 'notifications/webhooks/subtemplates/product.tpl' with product=engagement.product %} -{% endif %} -{% url 'view_engagement' engagement.id as engagement_url_ui %} -{% url 'engagement-detail' engagement.id as engagement_url_api %} -engagement: - name: {{ engagement.name | as_json_no_html_esc }} - id: {{ engagement.pk }} - url_ui: {{ engagement_url_ui | full_url | as_json_no_html_esc }} - url_api: {{ engagement_url_api | full_url | as_json_no_html_esc }} diff --git a/dojo/templates_classic/notifications/webhooks/subtemplates/findings_list.tpl b/dojo/templates_classic/notifications/webhooks/subtemplates/findings_list.tpl deleted file mode 100644 index 46d3a7e6885..00000000000 --- a/dojo/templates_classic/notifications/webhooks/subtemplates/findings_list.tpl +++ /dev/null @@ -1,13 +0,0 @@ -{% load display_tags %} -{% load as_json %} -{% for finding in findings %} -{% url 'view_finding' finding.id as finding_url_ui %} -{% url 'finding-detail' finding.id as finding_url_api %} - - id: {{ finding.pk }} - title: {{ finding.title | as_json_no_html_esc }} - severity: {{ finding.severity | as_json_no_html_esc }} - url_ui: {{ finding_url_ui | full_url | as_json_no_html_esc }} - url_api: {{ finding_url_api | full_url | as_json_no_html_esc }} -{% empty %} - [] -{% endfor %} diff --git a/dojo/templates_classic/notifications/webhooks/subtemplates/product.tpl b/dojo/templates_classic/notifications/webhooks/subtemplates/product.tpl deleted file mode 100644 index ee0b68687e5..00000000000 --- a/dojo/templates_classic/notifications/webhooks/subtemplates/product.tpl +++ /dev/null @@ -1,14 +0,0 @@ -{% load display_tags %} -{% load as_json %} -{% if product_type %} -{% include 'notifications/webhooks/subtemplates/product_type.tpl' with product_type=product_type %} -{% else %} -{% include 'notifications/webhooks/subtemplates/product_type.tpl' with product_type=product.prod_type %} -{% endif %} -{% url 'view_product' product.id as product_url_ui %} -{% url 'product-detail' product.id as product_url_api %} -product: - name: {{ product.name | as_json_no_html_esc }} - id: {{ product.pk }} - url_ui: {{ product_url_ui | full_url | as_json_no_html_esc }} - url_api: {{ product_url_api | full_url | as_json_no_html_esc }} diff --git a/dojo/templates_classic/notifications/webhooks/subtemplates/product_type.tpl b/dojo/templates_classic/notifications/webhooks/subtemplates/product_type.tpl deleted file mode 100644 index 895374ddcdd..00000000000 --- a/dojo/templates_classic/notifications/webhooks/subtemplates/product_type.tpl +++ /dev/null @@ -1,9 +0,0 @@ -{% load display_tags %} -{% load as_json %} -{% url 'view_product_type' product_type.id as product_type_url_ui %} -{% url 'product_type-detail' product_type.id as product_type_url_api %} -product_type: - name: {{ product_type.name | as_json_no_html_esc }} - id: {{ product_type.pk }} - url_ui: {{ product_type_url_ui | full_url | as_json_no_html_esc }} - url_api: {{ product_type_url_api | full_url | as_json_no_html_esc }} diff --git a/dojo/templates_classic/notifications/webhooks/subtemplates/test.tpl b/dojo/templates_classic/notifications/webhooks/subtemplates/test.tpl deleted file mode 100644 index fdb9574b3fa..00000000000 --- a/dojo/templates_classic/notifications/webhooks/subtemplates/test.tpl +++ /dev/null @@ -1,14 +0,0 @@ -{% load display_tags %} -{% load as_json %} -{% if engagement %} -{% include 'notifications/webhooks/subtemplates/engagement.tpl' with engagement=engagement %} -{% else %} -{% include 'notifications/webhooks/subtemplates/engagement.tpl' with engagement=test.engagement %} -{% endif %} -{% url 'view_test' test.id as test_url_ui %} -{% url 'test-detail' test.id as test_url_api %} -test: - title: {{ test.title | as_json_no_html_esc }} - id: {{ test.pk }} - url_ui: {{ test_url_ui | full_url | as_json_no_html_esc }} - url_api: {{ test_url_api | full_url | as_json_no_html_esc }} diff --git a/dojo/templates_classic/notifications/webhooks/subtemplates/user.tpl b/dojo/templates_classic/notifications/webhooks/subtemplates/user.tpl deleted file mode 100644 index 2128433b1e4..00000000000 --- a/dojo/templates_classic/notifications/webhooks/subtemplates/user.tpl +++ /dev/null @@ -1,16 +0,0 @@ -{% load display_tags %} -{% load as_json %} -{% if user %} -{% url 'view_user' user.id as user_url_ui %} -{% url 'user-detail' user.id as user_url_api %} -user: - id: {{ user.pk }} - email: {{ user.email | as_json_no_html_esc }} - username: {{ user.username | as_json_no_html_esc }} - first_name: {{ user.first_name | as_json_no_html_esc }} - last_name: {{ user.last_name | as_json_no_html_esc }} - url_ui: {{ user_url_ui | full_url | as_json_no_html_esc }} - url_api: {{ user_url_api | full_url | as_json_no_html_esc }} -{% else %} -user: {{ user | as_json_no_html_esc }} -{% endif %} diff --git a/dojo/templates_classic/notifications/webhooks/test_added.tpl b/dojo/templates_classic/notifications/webhooks/test_added.tpl deleted file mode 100644 index a1b719b6d33..00000000000 --- a/dojo/templates_classic/notifications/webhooks/test_added.tpl +++ /dev/null @@ -1,2 +0,0 @@ -{% include 'notifications/webhooks/subtemplates/base.tpl' %} -{% include 'notifications/webhooks/subtemplates/test.tpl' %} diff --git a/dojo/templates_classic/pt_nav_items.html b/dojo/templates_classic/pt_nav_items.html deleted file mode 100644 index 06a16df982f..00000000000 --- a/dojo/templates_classic/pt_nav_items.html +++ /dev/null @@ -1,3 +0,0 @@ -{% for p in pt %} -
  • {{ p.name }}
  • -{% endfor %} diff --git a/dojo/templates_classic/report_base.html b/dojo/templates_classic/report_base.html deleted file mode 100644 index a3328eaadba..00000000000 --- a/dojo/templates_classic/report_base.html +++ /dev/null @@ -1,226 +0,0 @@ -{% load static %} - - - - - {{ report_name }} - - - - - - - -{% block content %} - {{ block.super }} -{% endblock %} -{% block js %} -{% endblock %} - - diff --git a/dojo/user/models.py b/dojo/user/models.py index f762edf86ea..b0a2d8cbbb2 100644 --- a/dojo/user/models.py +++ b/dojo/user/models.py @@ -124,7 +124,6 @@ class UserContactInfo(models.Model): ), ) force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) - ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) # Extensible per-user UI state: dismissed banners, "seen"/"don't show again" diff --git a/dojo/user/ui/forms.py b/dojo/user/ui/forms.py index 4affc20cc97..7ccb7880b16 100644 --- a/dojo/user/ui/forms.py +++ b/dojo/user/ui/forms.py @@ -80,7 +80,7 @@ class Meta: # Swap order: password_last_reset before token_last_reset field_order = [ "title", "phone_number", "cell_number", "twitter_username", "github_username", - "slack_username", "ui_use_tailwind", "block_execution", "deduplication_execution_mode", "force_password_reset", "reset_api_token", + "slack_username", "block_execution", "deduplication_execution_mode", "force_password_reset", "reset_api_token", "password_last_reset", "token_last_reset", ] diff --git a/dojo/user/ui/views.py b/dojo/user/ui/views.py index e5da6b4487c..d3f7057d979 100644 --- a/dojo/user/ui/views.py +++ b/dojo/user/ui/views.py @@ -226,11 +226,9 @@ def view_profile(request): messages.SUCCESS, _("Profile updated successfully."), extra_tags="alert-success") - # Redirect so the response renders against a fresh request — this - # ensures UIPreferenceLoader and the UI-toggle banner read the - # just-saved usercontactinfo (e.g. ui_use_tailwind) instead of any - # state cached on the POST request. Also prevents form - # resubmission on refresh. + # Redirect so the response renders against a fresh request, reading + # the just-saved usercontactinfo instead of any state cached on the + # POST request. Also prevents form resubmission on refresh. return HttpResponseRedirect(reverse("view_profile")) add_breadcrumb(title=_("User Profile - %(user_full_name)s") % {"user_full_name": user.get_full_name()}, top_level=True, request=request) return render(request, "dojo/profile.html", { diff --git a/tests/announcement_banner_test.py b/tests/announcement_banner_test.py index de17fafe5ef..4d87126c322 100644 --- a/tests/announcement_banner_test.py +++ b/tests/announcement_banner_test.py @@ -18,7 +18,7 @@ def test_setup(self): driver = self.driver driver.get(self.base_url + "configure_announcement") if self.is_element_by_css_selector_present("input.btn.btn-danger"): - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-danger").click() + self.click_submit(driver, "input.btn.btn-danger") def enable_announcement(self, message, dismissable, style): driver = self.driver @@ -31,12 +31,12 @@ def enable_announcement(self, message, dismissable, style): if xor(bool(dismissable_control.is_selected()), bool(dismissable)): dismissable_control.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) def disable_announcement(self): driver = self.driver driver.get(self.base_url + "configure_announcement") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-danger").click() + self.click_submit(driver, "input.btn.btn-danger") def test_create_announcement(self): driver = self.driver diff --git a/tests/banner_test.py b/tests/banner_test.py index 4c83a67116d..df906f89430 100644 --- a/tests/banner_test.py +++ b/tests/banner_test.py @@ -35,7 +35,7 @@ def test_save_banner_configuration(self): enable_fields = driver.find_elements(By.ID, "id_banner_enable") if len(enable_fields) > 0 and not enable_fields[0].is_selected(): enable_fields[0].click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(1) self.assertTrue( @@ -54,7 +54,7 @@ def test_disable_banner(self): enable_fields = driver.find_elements(By.ID, "id_banner_enable") if len(enable_fields) > 0 and enable_fields[0].is_selected(): enable_fields[0].click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(1) self.assertTrue( diff --git a/tests/base_test_class.py b/tests/base_test_class.py index d94b5020aed..0d0a4d4297d 100644 --- a/tests/base_test_class.py +++ b/tests/base_test_class.py @@ -6,7 +6,7 @@ from pathlib import Path from selenium import webdriver -from selenium.common.exceptions import NoAlertPresentException, NoSuchElementException +from selenium.common.exceptions import NoAlertPresentException, NoSuchElementException, TimeoutException from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions @@ -231,6 +231,50 @@ def goto_all_findings_list(self, driver): self.wait_for_datatable_if_content("no_findings", "open_findings_wrapper") return driver + def open_product_tab(self, driver, tab): + """ + Click a tab in the product tab bar, e.g. "findings" or "engagements". + + Do not reach for these tabs with By.LINK_TEXT / By.PARTIAL_LINK_TEXT. The + global sidebar repeats every one of these labels ("Engagements", + "Findings", "Endpoints", "Components", "Metrics") and renders well before + the tab bar, so a link-text lookup resolves to the sidebar entry instead. + Those sidebar entries only expand a submenu -- they are bound to Alpine + with @click.prevent and never navigate -- so the click silently does the + wrong thing and the tab's dropdown stays closed. The failure then surfaces + much later, as a NoSuchElementException on the menu item you actually + wanted, which is in the DOM but not rendered. + + The data-testid attributes live on the tab bar in dojo/templates/base.html. + """ + driver.find_element(By.CSS_SELECTOR, f'[data-testid="product-tab-{tab}"]').click() + return driver + + def click_centered(self, driver, element): + """ + Click an element after centring it in the viewport. + + Anything near the bottom of a long form competes with the footer, and + the footer wins the click often enough to matter. Selenium sometimes + reports that honestly (ElementClickInterceptedException naming + #footer-wrapper) and sometimes the click simply lands on the footer and + is lost, which is the dangerous case: nothing raises, and the test fails + later somewhere that looks unrelated. Centring the element first puts it + clear of the footer. + + This bites both kinds of click. A lost click on a submit button leaves + the form unsubmitted. A lost click on a checkbox is worse: the submit + still happens, so the form saves with the checkbox in its old state and + the setting silently does not change. + """ + driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element) + element.click() + return driver + + def click_submit(self, driver, selector="input.btn.btn-primary"): + """Click a form's submit button, clear of the footer. See click_centered().""" + return self.click_centered(driver, driver.find_element(By.CSS_SELECTOR, selector)) + def wait_for_datatable_if_content(self, no_content_id, wrapper_id): if not self.is_element_by_id_present(no_content_id): # wait for product_wrapper div as datatables javascript modifies the DOM on page load. @@ -264,7 +308,23 @@ def is_info_message_present(self, text=None): return self.is_element_by_css_selector_present(".alert-info", text=text) def is_success_message_present(self, text=None): - return self.is_element_by_css_selector_present(".alert-success", text=text) + """ + Wait for the success banner, rather than sampling for it once. + + Every caller asserts the banner IS there, and it only renders after the + redirect that follows a form POST. Sampling once leaves the assertion + riding on the 1s implicit wait, which is enough locally and not always + enough on a loaded CI runner -- a slow redirect then reads as a missing + message. Nothing asserts the banner's absence, so waiting costs nothing + on the happy path and only delays a genuine failure. + """ + try: + WebDriverWait(self.driver, 10).until( + lambda _: self.is_element_by_css_selector_present(".alert-success", text=text), + ) + except TimeoutException: + return False + return True def is_error_message_present(self, text=None): return self.is_element_by_css_selector_present(".alert-danger", text=text) @@ -295,9 +355,9 @@ def change_system_setting(self, setting_id, *, enable=True): is_enabled = driver.find_element(By.ID, setting_id).is_selected() if (enable and not is_enabled) or (not enable and is_enabled): # driver.find_element(By.XPATH, '//*[@id=' + setting_id + ']').click() - driver.find_element(By.ID, setting_id).click() + self.click_centered(driver, driver.find_element(By.ID, setting_id)) # save settings - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # check if it's enabled after reload is_enabled = driver.find_element(By.ID, setting_id).is_selected() @@ -338,9 +398,9 @@ def set_block_execution(self, *, block_execution=True): driver.find_element(By.ID, "id_block_execution").is_selected() != block_execution ): - driver.find_element(By.XPATH, '//*[@id="id_block_execution"]').click() + self.click_centered(driver, driver.find_element(By.ID, "id_block_execution")) # save settings - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # check if it's enabled after reload self.assertEqual( driver.find_element(By.ID, "id_block_execution").is_selected(), @@ -365,7 +425,7 @@ def set_deduplication_execution_mode(self, mode="async"): if select.first_selected_option.get_attribute("value") != mode: select.select_by_value(mode) # save settings - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # check it persisted after reload driver.get(self.base_url + "profile") select = Select(driver.find_element(By.ID, "id_deduplication_execution_mode")) diff --git a/tests/calendar_test.py b/tests/calendar_test.py index 352a9810ef4..c8f878cf304 100644 --- a/tests/calendar_test.py +++ b/tests/calendar_test.py @@ -15,14 +15,14 @@ def test_create_engagement_for_calendar(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "Add New Interactive Engagement").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Calendar Test Engagement") driver.find_element(By.ID, "id_name").send_keys(Keys.TAB, "Engagement for calendar testing.") Select(driver.find_element(By.ID, "id_lead")).select_by_visible_text("Admin User (admin)") Select(driver.find_element(By.ID, "id_status")).select_by_visible_text("In Progress") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Engagement added successfully")) @on_exception_html_source_logger diff --git a/tests/check_various_pages.py b/tests/check_various_pages.py index d03ec3e0cd5..e1d81e07cdd 100644 --- a/tests/check_various_pages.py +++ b/tests/check_various_pages.py @@ -2,7 +2,6 @@ import unittest from base_test_class import BaseTestCase -from selenium.webdriver.common.by import By class VariousPagesTest(BaseTestCase): @@ -18,7 +17,7 @@ def test_calendar_status(self): driver = self.driver driver.get(self.base_url + "calendar") # click apply to see if this helps webdriver to catch the javascript errors we're seeing - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) def test_finding_group_open_status(self): driver = self.driver diff --git a/tests/close_old_findings_dedupe_test.py b/tests/close_old_findings_dedupe_test.py index dc6a8e05d9e..a5844d27761 100644 --- a/tests/close_old_findings_dedupe_test.py +++ b/tests/close_old_findings_dedupe_test.py @@ -58,7 +58,7 @@ def test_enable_deduplication(self): if not driver.find_element(By.ID, "id_enable_deduplication").is_selected(): driver.find_element(By.XPATH, '//*[@id="id_enable_deduplication"]').click() # save settings - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # check if it's enabled after reload driver.get(self.base_url + "system_settings") self.assertTrue(driver.find_element(By.ID, "id_enable_deduplication").is_selected()) diff --git a/tests/dedupe_test.py b/tests/dedupe_test.py index ccf71ee2081..e571eb3afd3 100644 --- a/tests/dedupe_test.py +++ b/tests/dedupe_test.py @@ -64,7 +64,7 @@ def test_enable_deduplication(self): if driver.find_element(By.ID, "id_retroactive_false_positive_history").is_selected(): driver.find_element(By.XPATH, '//*[@id="id_retroactive_false_positive_history"]').click() # save settings - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # check if it's enabled after reload driver.get(self.base_url + "system_settings") self.assertTrue(driver.find_element(By.ID, "id_enable_deduplication").is_selected()) @@ -127,7 +127,7 @@ def test_add_path_test_suite(self): driver.find_element(By.ID, "id_title").send_keys("Path Test 2") Select(driver.find_element(By.ID, "id_test_type")).select_by_visible_text("Bandit Scan") Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Development") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Test added successfully")) @@ -203,7 +203,7 @@ def test_add_endpoint_test_suite(self): driver.find_element(By.ID, "id_title").send_keys("Endpoint Test 2") Select(driver.find_element(By.ID, "id_test_type")).select_by_visible_text("Immuniweb Scan") Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Development") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Test added successfully")) @@ -267,7 +267,7 @@ def test_add_same_eng_test_suite(self): driver.find_element(By.ID, "id_title").send_keys("Same Eng Test 2") Select(driver.find_element(By.ID, "id_test_type")).select_by_visible_text("Generic Findings Import") Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Development") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Test added successfully")) @@ -337,7 +337,7 @@ def test_add_path_test_suite_checkmarx_scan(self): driver.find_element(By.ID, "id_title").send_keys("Path Test 2") Select(driver.find_element(By.ID, "id_test_type")).select_by_visible_text("Checkmarx Scan") Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Development") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Test added successfully")) @@ -394,7 +394,7 @@ def test_add_cross_test_suite(self): driver.find_element(By.ID, "id_title").send_keys("Generic Test") Select(driver.find_element(By.ID, "id_test_type")).select_by_visible_text("Generic Findings Import") Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Development") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Test added successfully")) @@ -411,7 +411,7 @@ def test_add_cross_test_suite(self): driver.find_element(By.ID, "id_title").send_keys("Immuniweb Test") Select(driver.find_element(By.ID, "id_test_type")).select_by_visible_text("Immuniweb Scan") Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Development") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Test added successfully")) diff --git a/tests/endpoint_extended_test.py b/tests/endpoint_extended_test.py index 63eebc7b1b1..76297fc003b 100644 --- a/tests/endpoint_extended_test.py +++ b/tests/endpoint_extended_test.py @@ -66,7 +66,7 @@ def test_add_endpoint_meta_data(self): # Navigate to the product and its endpoints self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Endpoints").click() + self.open_product_tab(driver, "endpoints") driver.find_element(By.LINK_TEXT, "View Endpoints").click() # Click on the first endpoint driver.find_element(By.CSS_SELECTOR, "table tbody tr td a").click() @@ -91,7 +91,7 @@ def test_edit_endpoint_meta_data(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Endpoints").click() + self.open_product_tab(driver, "endpoints") driver.find_element(By.LINK_TEXT, "View Endpoints").click() # Click on the first endpoint driver.find_element(By.CSS_SELECTOR, "table tbody tr td a").click() diff --git a/tests/endpoint_test.py b/tests/endpoint_test.py index bf20a1e6670..a84e935079d 100644 --- a/tests/endpoint_test.py +++ b/tests/endpoint_test.py @@ -26,7 +26,7 @@ def test_create_endpoint(self): # Select product to assign endpoint to Select(driver.find_element(By.ID, "id_product")).select_by_visible_text("QA Test") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) @@ -43,7 +43,7 @@ def test_create_endpoint(self): # Select product to assign endpoint to Select(driver.find_element(By.ID, "id_product")).select_by_visible_text("QA Test") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) @@ -62,7 +62,7 @@ def test_create_endpoint(self): # Select product to assign endpoint to Select(driver.find_element(By.ID, "id_product")).select_by_visible_text("QA Test") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) @@ -79,7 +79,7 @@ def test_create_endpoint(self): # Select product to assign endpoint to Select(driver.find_element(By.ID, "id_product")).select_by_visible_text("QA Test") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) @@ -96,7 +96,7 @@ def test_create_endpoint(self): # Select product to assign endpoint to Select(driver.find_element(By.ID, "id_product")).select_by_visible_text("QA Test") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) @@ -113,7 +113,7 @@ def test_create_endpoint(self): # Select product to assign endpoint to Select(driver.find_element(By.ID, "id_product")).select_by_visible_text("QA Test") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) @@ -130,7 +130,7 @@ def test_create_endpoint(self): # Select product to assign endpoint to Select(driver.find_element(By.ID, "id_product")).select_by_visible_text("QA Test") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) @@ -155,7 +155,7 @@ def test_edit_endpoint(self): driver.find_element(By.ID, "id_port").clear() driver.find_element(By.ID, "id_port").send_keys("8080") # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the product has been added # Assert ot the query to dtermine status of failure diff --git a/tests/engagement_checklist_test.py b/tests/engagement_checklist_test.py index 10c2d833daf..c9a4eeeccf1 100644 --- a/tests/engagement_checklist_test.py +++ b/tests/engagement_checklist_test.py @@ -63,7 +63,7 @@ def test_fill_and_save_checklist(self): if len(fields) > 0: Select(fields[0]).select_by_visible_text("Pass") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(1) self.assertTrue( diff --git a/tests/engagement_extended_test.py b/tests/engagement_extended_test.py index f490fb372dc..dafcc7f487c 100644 --- a/tests/engagement_extended_test.py +++ b/tests/engagement_extended_test.py @@ -22,14 +22,14 @@ def test_create_engagement_for_extended_tests(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "Add New Interactive Engagement").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Extended Test Engagement") driver.find_element(By.ID, "id_name").send_keys(Keys.TAB, "Extended engagement for testing.") Select(driver.find_element(By.ID, "id_lead")).select_by_visible_text("Admin User (admin)") Select(driver.find_element(By.ID, "id_status")).select_by_visible_text("In Progress") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Engagement added successfully")) @@ -38,7 +38,7 @@ def test_close_engagement_for_reopen(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() driver.find_element(By.LINK_TEXT, "Extended Test Engagement").click() driver.find_element(By.ID, "dropdownMenu1").click() @@ -51,7 +51,7 @@ def test_reopen_closed_engagement(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() self.wait_for_datatable_if_content("no_active_engagements", "open_wrapper") driver.find_element(By.LINK_TEXT, "Extended Test Engagement").click() @@ -65,13 +65,13 @@ def test_copy_engagement(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() driver.find_element(By.LINK_TEXT, "Extended Test Engagement").click() driver.find_element(By.ID, "dropdownMenu1").click() driver.find_element(By.LINK_TEXT, "Copy Engagement").click() driver.find_element(By.ID, "id_done").click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Engagement Copied successfully.")) @@ -80,7 +80,7 @@ def test_engagement_ics_export(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() driver.find_element(By.LINK_TEXT, "Extended Test Engagement").click() driver.find_element(By.ID, "dropdownMenu1").click() @@ -102,7 +102,7 @@ def test_delete_extended_engagement(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() driver.find_element(By.LINK_TEXT, "Extended Test Engagement").click() driver.find_element(By.ID, "dropdownMenu1").click() diff --git a/tests/engagement_presets_test.py b/tests/engagement_presets_test.py index 7bae1de82f1..35f74c46eb2 100644 --- a/tests/engagement_presets_test.py +++ b/tests/engagement_presets_test.py @@ -32,7 +32,7 @@ def test_add_engagement_preset(self): driver.get(self.base_url + f"product/{pid}/engagement_presets/add") driver.find_element(By.ID, "id_title").clear() driver.find_element(By.ID, "id_title").send_keys("Test Preset") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Engagement Preset Successfully Created") @@ -47,7 +47,7 @@ def test_edit_engagement_preset(self): driver.find_element(By.LINK_TEXT, "Edit").click() driver.find_element(By.ID, "id_title").clear() driver.find_element(By.ID, "id_title").send_keys("Edited Test Preset") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Engagement Preset Successfully Updated") diff --git a/tests/engagement_test.py b/tests/engagement_test.py index 11ffbe12b28..1bb1707289e 100644 --- a/tests/engagement_test.py +++ b/tests/engagement_test.py @@ -34,7 +34,7 @@ def test_add_new_engagement(self): driver.find_element(By.ID, "id_test_strategy").clear() driver.find_element(By.ID, "id_test_strategy").send_keys("http://localhost:5000") Select(driver.find_element(By.ID, "id_status")).select_by_visible_text("In Progress") - driver.find_element(By.CSS_SELECTOR, "input[value='Done']").click() + self.click_submit(driver, "input[value='Done']") self.assertTrue(self.is_success_message_present(text="Engagement added successfully.")) @@ -49,7 +49,7 @@ def test_edit_created_new_engagement(self): driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("edited test engagement") Select(driver.find_element(By.ID, "id_status")).select_by_visible_text("In Progress") - driver.find_element(By.CSS_SELECTOR, "input[value='Done']").click() + self.click_submit(driver, "input[value='Done']") self.assertTrue(self.is_success_message_present(text="Engagement updated successfully.")) @@ -85,12 +85,12 @@ def test_new_ci_cd_engagement(self): # wait for product_wrapper div as datatables javascript modifies the DOM on page load. driver.find_element(By.ID, "products_wrapper") driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.XPATH, "//a[@class='dropdown-toggle active']//span[@class='hidden-xs']").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "Add New CI/CD Engagement").click() driver.find_element(By.ID, "id_name").send_keys("test new ci/cd engagement") driver.find_element(By.ID, "id_name").send_keys("\ttest new ci/cd engagement") driver.find_element(By.ID, "id_deduplication_on_engagement").get_attribute("checked") - driver.find_element(By.CSS_SELECTOR, "input[value='Done']").click() + self.click_submit(driver, "input[value='Done']") self.assertTrue(self.is_success_message_present(text="Engagement added successfully.")) diff --git a/tests/environment_test.py b/tests/environment_test.py index 65fe9f51e8c..97fbf0ee9d9 100644 --- a/tests/environment_test.py +++ b/tests/environment_test.py @@ -25,7 +25,7 @@ def test_create_environment(self): driver.find_element(By.LINK_TEXT, "New Environment").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("environment test") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Environment added successfully.")) @@ -35,7 +35,7 @@ def test_edit_environment(self): driver.find_element(By.LINK_TEXT, "environment test").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Edited environment test") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Environment updated successfully.")) @@ -43,7 +43,7 @@ def test_delete_environment(self): driver = self.driver driver.get(self.base_url + "dev_env") driver.find_element(By.LINK_TEXT, "Edited environment test").click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-danger").click() + self.click_submit(driver, "input.btn.btn-danger") self.assertTrue(self.is_success_message_present(text="Environment deleted successfully.")) diff --git a/tests/false_positive_history_test.py b/tests/false_positive_history_test.py index 6e00a496685..4e9ffc9d84b 100644 --- a/tests/false_positive_history_test.py +++ b/tests/false_positive_history_test.py @@ -19,7 +19,7 @@ def create_finding(self, product_name, engagement_name, test_name, finding_name) # Select and click on the particular product to create finding for driver.find_element(By.LINK_TEXT, product_name).click() # Click on the 'Engagement' Dropdown button - driver.find_element(By.PARTIAL_LINK_TEXT, "Engagement").click() + self.open_product_tab(driver, "engagements") # Click on the Add New Engagement option driver.find_element(By.LINK_TEXT, "Add New Interactive Engagement").click() # Fill up engagement name @@ -96,7 +96,7 @@ def bulk_edit(self, finding_url, status_id): driver.find_element(By.ID, "id_bulk_status").click() driver.find_element(By.ID, status_id).click() # Submit - driver.find_element(By.CSS_SELECTOR, "input[type='submit']").click() + self.click_submit(driver, "input[type='submit']") def test_retroactive_edit_finding(self): # Create two equal findings on different engagements diff --git a/tests/finding_extended_test.py b/tests/finding_extended_test.py index 83fb16c6e06..3dc64389200 100644 --- a/tests/finding_extended_test.py +++ b/tests/finding_extended_test.py @@ -19,7 +19,7 @@ def test_copy_finding(self): driver.find_element(By.LINK_TEXT, "App Vulnerable to XSS").click() driver.find_element(By.ID, "dropdownMenu1").click() driver.find_element(By.LINK_TEXT, "Copy Finding").click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Finding Copied successfully") @@ -104,7 +104,7 @@ def test_remediation_date(self): driver.find_element(By.ID, "id_planned_remediation_date").send_keys("2030-12-31") # Dismiss the datepicker overlay before clicking submit driver.find_element(By.ID, "id_planned_remediation_date").send_keys(Keys.ESCAPE) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Remediation date") @@ -117,7 +117,7 @@ def test_ad_hoc_finding_create(self): self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() # Click on the Findings dropdown - driver.find_element(By.PARTIAL_LINK_TEXT, "Findings").click() + self.open_product_tab(driver, "findings") driver.find_element(By.LINK_TEXT, "Add New Finding").click() # Fill finding form driver.find_element(By.ID, "id_title").clear() diff --git a/tests/finding_test.py b/tests/finding_test.py index 45e915b134f..bfd621af721 100644 --- a/tests/finding_test.py +++ b/tests/finding_test.py @@ -497,7 +497,7 @@ def test_close_finding(self): # fill notes stating why finding should be closed driver.find_element(By.ID, "id_entry").send_keys("All issues in this Finding have been resolved successfully") # click 'close Finding' submission button - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Finding closed.")) @@ -694,7 +694,7 @@ def test_create_finding_with_unqiue_characters(self): self.assertTrue(self.is_text_present_on_page(text="App Vulnerable to XSS From \\Template")) # Navigate back to the finding list - driver.find_element(By.LINK_TEXT, "Findings").click() + self.goto_all_findings_list(driver) self.assertNoConsoleErrors() driver.find_element(By.LINK_TEXT, "App Vulnerable to XSS from \\Template").click() self.assertNoConsoleErrors() @@ -725,7 +725,7 @@ def test_import_scan_result(self): # Select and click on the particular finding to edit driver.find_element(By.LINK_TEXT, "App Vulnerable to XSS").click() # Click on the 'Finding' dropdown menubar - driver.find_element(By.PARTIAL_LINK_TEXT, "Findings").click() + self.open_product_tab(driver, "findings") # Click on `Import Scan Results` link text driver.find_element(By.LINK_TEXT, "Import Scan Results").click() # Select `ZAP Scan` as Scan Type diff --git a/tests/ibm_appscan_test.py b/tests/ibm_appscan_test.py index 3aee47c7eaf..abdcde4965e 100644 --- a/tests/ibm_appscan_test.py +++ b/tests/ibm_appscan_test.py @@ -23,7 +23,7 @@ def test_import_ibm_app_scan_result(self): driver.find_element(By.ID, "products_wrapper") driver.find_element(By.LINK_TEXT, "QA Test").click() # "Click" the Finding Drop down - driver.find_element(By.PARTIAL_LINK_TEXT, "Findings").click() + self.open_product_tab(driver, "findings") # "Click" the New Endpoint driver.find_element(By.LINK_TEXT, "Import Scan Results").click() # Select scan type diff --git a/tests/note_type_test.py b/tests/note_type_test.py index 840212c7d63..cc4bc590802 100644 --- a/tests/note_type_test.py +++ b/tests/note_type_test.py @@ -24,7 +24,7 @@ def test_create_note_type(self): driver.find_element(By.ID, "id_description").clear() driver.find_element(By.ID, "id_description").send_keys("Test note type description") driver.find_element(By.ID, "id_is_single").click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Note Type added successfully.")) @@ -40,7 +40,7 @@ def test_edit_note_type(self): edit_links[0].click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Edited test note type") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Note type updated successfully.")) @@ -54,7 +54,7 @@ def test_disable_note_type(self): self.assertTrue(self.is_text_present_on_page(text="Note Type")) return disable_links[0].click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-danger").click() + self.click_submit(driver, "input.btn.btn-danger") self.assertTrue(self.is_success_message_present(text="Note type Disabled successfully.")) @@ -68,7 +68,7 @@ def test_enable_note_type(self): self.assertTrue(self.is_text_present_on_page(text="Note Type")) return enable_links[0].click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-success").click() + self.click_submit(driver, "input.btn.btn-success") self.assertTrue(self.is_success_message_present(text="Note type Enabled successfully.")) diff --git a/tests/notification_webhook_test.py b/tests/notification_webhook_test.py index 71daaa0ad45..f3b723aeca4 100644 --- a/tests/notification_webhook_test.py +++ b/tests/notification_webhook_test.py @@ -32,7 +32,7 @@ def test_enable_webhook_notifications(self): webhook_checkbox = driver.find_element(By.ID, "id_enable_webhooks_notifications") if not webhook_checkbox.is_selected(): webhook_checkbox.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertFalse(self.is_error_message_present()) @on_exception_html_source_logger @@ -49,7 +49,7 @@ def test_add_notification_webhook(self): driver.find_element(By.ID, "id_name").send_keys("Test Webhook") driver.find_element(By.ID, "id_url").clear() driver.find_element(By.ID, "id_url").send_keys(WEBHOOK_ENDPOINT_URL) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.wait_for_alert() self.assertFalse(self.is_error_message_present()) @@ -70,7 +70,7 @@ def test_edit_notification_webhook(self): # Ensure the endpoint stays pointed at the local mock so the save-time ping succeeds. driver.find_element(By.ID, "id_url").clear() driver.find_element(By.ID, "id_url").send_keys(WEBHOOK_ENDPOINT_URL) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.wait_for_alert() self.assertFalse(self.is_error_message_present()) @@ -86,7 +86,7 @@ def test_delete_notification_webhook(self): if len(delete_links) == 0: self.fail("No Delete link found for webhook") delete_links[0].click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-danger").click() + self.click_submit(driver, "input.btn.btn-danger") self.wait_for_alert() self.assertFalse(self.is_error_message_present()) @@ -100,7 +100,7 @@ def test_disable_webhook_notifications(self): webhook_checkbox = driver.find_element(By.ID, "id_enable_webhooks_notifications") if webhook_checkbox.is_selected(): webhook_checkbox.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertFalse(self.is_error_message_present()) diff --git a/tests/notifications_test.py b/tests/notifications_test.py index 637b4a0e11b..20db246c487 100644 --- a/tests/notifications_test.py +++ b/tests/notifications_test.py @@ -3,7 +3,6 @@ from base_test_class import BaseTestCase from selenium.common.exceptions import NoSuchElementException -from selenium.webdriver import ActionChains from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.ui import WebDriverWait @@ -21,8 +20,8 @@ def enable_notification(self): driver.get(self.base_url + "system_settings") mail_control = driver.find_element(By.ID, f"id_enable_{self.type}_notifications") if not mail_control.is_selected(): - mail_control.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_centered(driver, mail_control) + self.click_submit(driver) def disable_notification(self): driver = self.driver @@ -30,8 +29,8 @@ def disable_notification(self): driver.get(self.base_url + "system_settings") mail_control = driver.find_element(By.ID, f"id_enable_{self.type}_notifications") if mail_control.is_selected(): - mail_control.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_centered(driver, mail_control) + self.click_submit(driver) def test_disable_personal_notification(self): # Login to the site. Password will have to be modified @@ -127,9 +126,8 @@ def test_user_mail_notifications_change(self): driver = self.driver wait = WebDriverWait(driver, 5) - actions = ActionChains(driver) - configuration_menu = driver.find_element(By.ID, "menu_configuration") - actions.move_to_element(configuration_menu).perform() + # The sidebar's Configuration section expands on click, not on hover. + driver.find_element(By.ID, "menu_configuration").click() wait.until(expected_conditions.visibility_of_element_located((By.LINK_TEXT, "Notifications"))).click() originally_selected = { @@ -138,10 +136,10 @@ def test_user_mail_notifications_change(self): "scan_added": driver.find_element(By.XPATH, "//input[@name='scan_added' and @value='mail']").is_selected(), } - driver.find_element(By.XPATH, "//input[@name='product_added' and @value='mail']").click() - driver.find_element(By.XPATH, "//input[@name='scan_added' and @value='mail']").click() + self.click_centered(driver, driver.find_element(By.XPATH, "//input[@name='product_added' and @value='mail']")) + self.click_centered(driver, driver.find_element(By.XPATH, "//input[@name='scan_added' and @value='mail']")) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Settings saved")) self.assertNotEqual(originally_selected["product_added"], diff --git a/tests/object_test.py b/tests/object_test.py index f9c981cd878..9fda7d7c623 100644 --- a/tests/object_test.py +++ b/tests/object_test.py @@ -17,7 +17,7 @@ def test_enable_product_tracking_files(self): tracking_checkbox = driver.find_element(By.ID, "id_enable_product_tracking_files") if not tracking_checkbox.is_selected(): tracking_checkbox.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertFalse(self.is_error_message_present()) @on_exception_html_source_logger @@ -64,7 +64,7 @@ def test_add_object(self): select = Select(review_fields[0]) if len(select.options) > 1: select.select_by_index(1) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="added successfully") or self.is_text_present_on_page(text="Tracked Files"), diff --git a/tests/product_member_test.py b/tests/product_member_test.py index 7e750bd6b03..f7e956409aa 100644 --- a/tests/product_member_test.py +++ b/tests/product_member_test.py @@ -35,7 +35,7 @@ def test_user_add_product_member(self): product_option = element.find_elements(By.TAG_NAME, "option")[0] Select(element).select_by_value(product_option.get_attribute("value")) # Submit (legacy auth does not use a role field) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Authorized propersahm")) # Verify QA Test is now listed in the user's accessible products self.assertEqual(driver.find_elements(By.NAME, "member_product")[0].text, "QA Test") @@ -79,7 +79,7 @@ def test_product_add_product_member(self): break self.assertIsNotNone(propersahm_option, "propersahm option not found in users select") Select(element).select_by_value(propersahm_option.get_attribute("value")) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Added 1 user(s) to authorized users.")) # Verify propersahm is now listed in the product's authorized users usernames = [e.text for e in driver.find_elements(By.NAME, "authorized_user_username")] diff --git a/tests/product_test.py b/tests/product_test.py index 29a979ed4bd..90302c0fa03 100644 --- a/tests/product_test.py +++ b/tests/product_test.py @@ -54,7 +54,7 @@ def test_create_product(self): # some wild guess to print some debug info Select(driver.find_element(By.ID, "id_prod_type")).select_by_visible_text("Research and Development") # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the product has been added # Assert ot the query to dtermine status of failure @@ -75,7 +75,7 @@ def test_list_components(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.LINK_TEXT, "Components").click() + # The sidebar's Components entry, which lists components across all products. driver.find_element(By.ID, "product_component_view").click() self.assertTrue(self.is_element_by_css_selector_present("table")) @@ -98,7 +98,7 @@ def test_edit_product_description(self): # Edit product description driver.find_element(By.ID, "id_name").send_keys(Keys.TAB, "Updated Desription: ") # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the product has been added # Assert ot the query to dtermine status of failure @@ -126,7 +126,7 @@ def test_enable_simple_risk_acceptance(self): driver.find_element(By.XPATH, '//*[@id="id_enable_simple_risk_acceptance"]').click() # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the product has been added # Assert ot the query to dtermine status of failure @@ -146,7 +146,7 @@ def test_add_product_engagement(self): # "Click" the dropdown option driver.find_element(By.ID, "dropdownMenu1").click() # Click on the 'Engagement dropdown button' - driver.find_element(By.PARTIAL_LINK_TEXT, "Engagement").click() + self.open_product_tab(driver, "engagements") # 'click' the Add New Engagement option driver.find_element(By.LINK_TEXT, "Add New Interactive Engagement").click() # Keep a good practice of clearing field before entering value @@ -166,7 +166,7 @@ def test_add_product_engagement(self): # engagement status Select(driver.find_element(By.ID, "id_status")).select_by_visible_text("In Progress") # "Click" the Done button to Add the engagement - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the product has been added # Assert of the query to dtermine status of failure @@ -191,7 +191,7 @@ def test_add_technology(self): driver.find_element(By.ID, "id_version").clear() driver.find_element(By.ID, "id_version").send_keys("2.1.0-RELEASE") # "Click" the Submit button to Add the technology - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Assert of the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Technology added successfully")) # Query the site to determine if the member has been added @@ -218,7 +218,7 @@ def test_edit_technology(self): driver.find_element(By.ID, "id_version").clear() driver.find_element(By.ID, "id_version").send_keys("2.2.0-RELEASE") # "Click" the Submit button to change the technology - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Assert of the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Technology changed successfully")) # Query the site to determine if the member has been added @@ -240,7 +240,7 @@ def test_delete_technology(self): driver.find_elements(By.NAME, "dropdownManageTechnologies")[0].click() driver.find_elements(By.NAME, "deleteTechnology")[0].click() # "Click" the Submit button to delete the technology - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-danger").click() + self.click_submit(driver, "input.btn.btn-danger") # Assert of the query to dtermine status of failure self.assertTrue(self.is_success_message_present(text="Technology deleted successfully")) # Query the site to determine if the technology has been deleted @@ -256,7 +256,7 @@ def test_add_product_finding(self): # Select and click on the particular product to edit driver.find_element(By.LINK_TEXT, "QA Test").click() # Click on the 'Finding dropdown button' - driver.find_element(By.PARTIAL_LINK_TEXT, "Findings").click() + self.open_product_tab(driver, "findings") # Click on `Add New Finding` driver.find_element(By.LINK_TEXT, "Add New Finding").click() # Keep a good practice of clearing field before entering value @@ -312,7 +312,7 @@ def test_add_product_endpoints(self): # Select and click on the particular product to edit driver.find_element(By.LINK_TEXT, "QA Test").click() # Click on the 'Endpoints' dropdown button - driver.find_element(By.PARTIAL_LINK_TEXT, "Endpoints").click() + self.open_product_tab(driver, "endpoints") # 'click' the Add New Endpoint option driver.find_element(By.LINK_TEXT, "Add New Endpoint").click() # V2 Endpoints @@ -323,7 +323,7 @@ def test_add_product_endpoints(self): driver.find_element(By.ID, "id_endpoint").clear() driver.find_element(By.ID, "id_endpoint").send_keys("strange.prod.dev\n123.45.6.30") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # V3 Locations -- the freeform text box is gone, need to add each individually else: # Keep a good practice of clearing field before entering value @@ -331,18 +331,18 @@ def test_add_product_endpoints(self): driver.find_element(By.ID, "id_host").clear() driver.find_element(By.ID, "id_host").send_keys("strange.prod.dev") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Assert ot the query to determine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) # it was so fun let's do it again! - driver.find_element(By.PARTIAL_LINK_TEXT, "Endpoints").click() + self.open_product_tab(driver, "endpoints") # 'click' the Add New Endpoint option driver.find_element(By.LINK_TEXT, "Add New Endpoint").click() # Keep a good practice of clearing field before entering value driver.find_element(By.ID, "id_host").clear() driver.find_element(By.ID, "id_host").send_keys("123.45.6.30") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to determine status of failure self.assertTrue(self.is_success_message_present(text="Endpoint added successfully")) @@ -422,7 +422,7 @@ def test_add_product_tracking_files(self): # REview Status Select(driver.find_element(By.ID, "id_review_status")).select_by_visible_text("Untracked") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the finding has been added # Assert ot the query to dtermine status of failure @@ -448,7 +448,7 @@ def test_edit_product_tracking_files(self): driver.find_element(By.ID, "id_path").clear() driver.find_element(By.ID, "id_path").send_keys("/unknown/folder/") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the Tracking file has been updated # Assert ot the query to dtermine status of failure @@ -464,7 +464,7 @@ def test_product_metrics(self): driver.find_element(By.LINK_TEXT, "QA Test").click() # "Click" the dropdown option # driver.find_element(By.XPATH, "//span[contains(., 'Metrics')]").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Metrics").click() + self.open_product_tab(driver, "metrics") @on_exception_html_source_logger def test_delete_product(self, name="QA Test"): @@ -535,7 +535,7 @@ def test_product_type_counts_metrics(self): my_select = Select(driver.find_element(By.ID, "id_product_type")) my_select.select_by_index(1) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) def test_simple_metrics(self): # Test To Edit Product Tracking Files diff --git a/tests/product_type_member_test.py b/tests/product_type_member_test.py index 14d80b67c27..eb16919d108 100644 --- a/tests/product_type_member_test.py +++ b/tests/product_type_member_test.py @@ -33,7 +33,7 @@ def test_user_add_product_type_member(self): element = driver.find_element(By.XPATH, "//select[@name='product_types']") product_type_option = element.find_elements(By.TAG_NAME, "option")[0] Select(element).select_by_value(product_type_option.get_attribute("value")) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Authorized propersahm")) # Verify Research and Development is now listed in the user's accessible product types member_pts = [e.text for e in driver.find_elements(By.NAME, "member_product_type")] @@ -76,7 +76,7 @@ def test_product_type_add_product_type_member(self): break self.assertIsNotNone(propersahm_option, "propersahm option not found in users select") Select(element).select_by_value(propersahm_option.get_attribute("value")) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Added 1 user(s) to authorized users.")) usernames = [e.text for e in driver.find_elements(By.NAME, "authorized_user_username")] self.assertIn("propersahm", usernames) diff --git a/tests/product_type_test.py b/tests/product_type_test.py index ef25b5a9a6c..498aadd8445 100644 --- a/tests/product_type_test.py +++ b/tests/product_type_test.py @@ -20,7 +20,7 @@ def test_create_product_type(self): driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Product test type") driver.find_element(By.ID, "id_critical_product").click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Organization added successfully.")) self.assertFalse(self.is_error_message_present()) @@ -42,7 +42,7 @@ def test_create_product_for_product_type(self): # Tab into the description area to fill some text # Couldnt find a way to get into the box with selenium driver.find_element(By.ID, "id_name").send_keys("\tThis is just a test. Be very afraid.") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Assert ot the query to dtermine status of failure # Also confirm success even if Product is returned as already exists for test sake @@ -67,7 +67,7 @@ def test_edit_product_type(self): driver.find_element(By.PARTIAL_LINK_TEXT, "Edit").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Edited product test type") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Organization updated successfully.")) diff --git a/tests/questionnaire_advanced_test.py b/tests/questionnaire_advanced_test.py index 66d0da02fc9..0b6901b671a 100644 --- a/tests/questionnaire_advanced_test.py +++ b/tests/questionnaire_advanced_test.py @@ -50,7 +50,7 @@ def test_create_question(self): text_fields[0].clear() text_fields[0].send_keys("Is the application using encryption?") # Submit the form - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary[name='submit']").click() + self.click_submit(driver, "input.btn.btn-primary[name='submit']") time.sleep(1) self.assertTrue( @@ -89,7 +89,7 @@ def test_create_questionnaire(self): if len(submit_btns) > 0: submit_btns[0].click() else: - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(1) self.assertTrue( @@ -118,7 +118,7 @@ def test_add_questionnaire_to_engagement(self): if len(submit_btns) > 0: submit_btns[0].click() else: - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(1) self.assertTrue( diff --git a/tests/questionnaire_test.py b/tests/questionnaire_test.py index 1adac93ae15..7c3b2cdae9f 100644 --- a/tests/questionnaire_test.py +++ b/tests/questionnaire_test.py @@ -22,7 +22,7 @@ def test_create_questionnaire(self): driver.find_element(By.ID, "id_name").send_keys("Test Questionnaire") driver.find_element(By.ID, "id_description").clear() driver.find_element(By.ID, "id_description").send_keys("This is a test questionnaire for E2E testing") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Questionnaire successfully created") @@ -39,7 +39,7 @@ def test_edit_questionnaire(self): # We should now be on the edit page (clicking the name goes to edit) driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Edited Test Questionnaire") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Questionnaire successfully updated") diff --git a/tests/report_builder_test.py b/tests/report_builder_test.py index 0ce5bb08110..613c15b278d 100644 --- a/tests/report_builder_test.py +++ b/tests/report_builder_test.py @@ -79,7 +79,7 @@ def test_product_type_report(self): driver = self.driver driver.get(self.base_url + "product/type") driver.find_element(By.ID, "dropdownMenuProductType").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Report").click() + driver.find_element(By.CSS_SELECTOR, '[data-testid="report-link"]').click() my_select = Select(driver.find_element(By.ID, "id_include_finding_notes")) my_select.select_by_index(1) @@ -122,11 +122,11 @@ def test_engagement_report(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Engagements").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() driver.find_element(By.LINK_TEXT, "Ad Hoc Engagement").click() driver.find_element(By.ID, "dropdownMenu1").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Report").click() + driver.find_element(By.CSS_SELECTOR, '[data-testid="report-link"]').click() my_select = Select(driver.find_element(By.ID, "id_include_finding_notes")) my_select.select_by_index(1) @@ -144,12 +144,12 @@ def test_test_report(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Engagements").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() driver.find_element(By.LINK_TEXT, "Ad Hoc Engagement").click() driver.find_element(By.LINK_TEXT, "Pen Test").click() driver.find_element(By.ID, "dropdownMenu1").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Report").click() + driver.find_element(By.CSS_SELECTOR, '[data-testid="report-link"]').click() my_select = Select(driver.find_element(By.ID, "id_include_finding_notes")) my_select.select_by_index(1) @@ -167,7 +167,7 @@ def test_product_endpoint_report(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.PARTIAL_LINK_TEXT, "Endpoints").click() + self.open_product_tab(driver, "endpoints") driver.find_element(By.LINK_TEXT, "Endpoint Report").click() # extra dropdown click diff --git a/tests/risk_acceptance_test.py b/tests/risk_acceptance_test.py index 5ed3a52bdf0..b274b670b7f 100644 --- a/tests/risk_acceptance_test.py +++ b/tests/risk_acceptance_test.py @@ -23,7 +23,7 @@ def test_enable_full_risk_acceptance(self): checkbox = driver.find_element(By.ID, "id_enable_full_risk_acceptance") if not checkbox.is_selected(): checkbox.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Asset updated successfully") @@ -63,7 +63,7 @@ def test_add_risk_acceptance(self): proof_path = Path(os.path.realpath(__file__)).parent / "dedupe_scans" / "dedupe_path_1.json" driver.find_element(By.ID, "id_path").send_keys(str(proof_path)) # Owner is pre-filled (current user), submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(1) self.assertTrue( diff --git a/tests/sla_configuration_test.py b/tests/sla_configuration_test.py index 424747656c2..3e5b6bfa913 100644 --- a/tests/sla_configuration_test.py +++ b/tests/sla_configuration_test.py @@ -25,7 +25,7 @@ def test_add_sla_config(self): driver.find_element(By.ID, "id_medium").send_keys("3") driver.find_element(By.ID, "id_low").clear() driver.find_element(By.ID, "id_low").send_keys("4") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="SLA configuration Successfully Created.")) diff --git a/tests/system_settings_test.py b/tests/system_settings_test.py index 48ec105428f..0e179782afb 100644 --- a/tests/system_settings_test.py +++ b/tests/system_settings_test.py @@ -25,8 +25,17 @@ def test_toggle_deduplication(self): def test_toggle_false_positive_history(self): # Disable then re-enable to test both states # (always end with disabled since that's the default) + # + # False positive history and deduplication are mutually exclusive: the + # settings view refuses the save outright with "Deduplicate findings and + # False positive history can not be set at the same time", and the + # deduplication toggle above deliberately ends with deduplication ON. So + # turn deduplication off for the duration and put it back afterwards, + # the way dedupe_test.py expects to find it. + self.disable_system_setting("id_enable_deduplication") self.enable_system_setting("id_false_positive_history") self.disable_system_setting("id_false_positive_history") + self.enable_system_setting("id_enable_deduplication") @on_exception_html_source_logger def test_toggle_jira_integration(self): @@ -51,21 +60,21 @@ def test_change_max_dupes_setting(self): original_value = max_dupes_field.get_attribute("value") max_dupes_field.clear() max_dupes_field.send_keys("10") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Verify saved driver.get(self.base_url + "system_settings") self.assertEqual(driver.find_element(By.ID, "id_max_dupes").get_attribute("value"), "10") # Reset to original driver.find_element(By.ID, "id_max_dupes").clear() driver.find_element(By.ID, "id_max_dupes").send_keys(original_value) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) @on_exception_html_source_logger def test_settings_save_and_reload(self): driver = self.driver driver.get(self.base_url + "system_settings") # Just verify the page loads and save button works - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # After save, the page should reload without errors self.assertFalse(self.is_error_message_present()) diff --git a/tests/test_copy_test.py b/tests/test_copy_test.py index 4a1016442c8..5c6f3eeba3b 100644 --- a/tests/test_copy_test.py +++ b/tests/test_copy_test.py @@ -15,7 +15,7 @@ def test_create_engagement_and_test(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "Add New Interactive Engagement").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Copy Test Engagement") @@ -24,11 +24,11 @@ def test_create_engagement_and_test(self): Select(driver.find_element(By.ID, "id_status")).select_by_visible_text("In Progress") # Click "Add Tests" submit button which creates the engagement and # redirects directly to the add tests page - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary[value='Add Tests']").click() + self.click_submit(driver, "input.btn.btn-primary[value='Add Tests']") self.assertTrue(self.is_success_message_present(text="Engagement added successfully")) Select(driver.find_element(By.ID, "id_test_type")).select_by_visible_text("Pen Test") Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Development") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Test added successfully")) @on_exception_html_source_logger @@ -36,7 +36,7 @@ def test_copy_test(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() driver.find_element(By.LINK_TEXT, "Copy Test Engagement").click() driver.find_element(By.LINK_TEXT, "Pen Test").click() @@ -44,7 +44,7 @@ def test_copy_test(self): driver.find_element(By.LINK_TEXT, "Copy Test").click() # Select the first available engagement in the copy form Select(driver.find_element(By.ID, "id_engagement")).select_by_index(1) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Test Copied successfully") diff --git a/tests/test_test.py b/tests/test_test.py index 419931cd578..09249e2374c 100644 --- a/tests/test_test.py +++ b/tests/test_test.py @@ -59,7 +59,7 @@ def test_create_test(self): # # "Click" the dropdown option # driver.find_element(By.ID, "dropdownMenu1").click() # Click on the 'Engagement' Dropdown button - driver.find_element(By.PARTIAL_LINK_TEXT, "Engagement").click() + self.open_product_tab(driver, "engagements") # 'click' the Add New Engagement option driver.find_element(By.LINK_TEXT, "Add New Interactive Engagement").click() # Keep a good practice of clearing field before entering value @@ -90,7 +90,7 @@ def test_create_test(self): # Select Testing Environment Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Development") # submit - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the Test has been added # Assert on the query to determine success or failure @@ -111,7 +111,7 @@ def test_edit_test(self): # Change Testing Environment to Staging from Development Select(driver.find_element(By.ID, "id_environment")).select_by_visible_text("Staging") # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the Test has been updated # Assert ot the query to dtermine status of failure diff --git a/tests/test_type_test.py b/tests/test_type_test.py index 53daed463f8..8536cfe2b66 100644 --- a/tests/test_type_test.py +++ b/tests/test_type_test.py @@ -21,7 +21,7 @@ def test_add_test_type(self): time.sleep(1) driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Integration Test Type") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Test type added successfully") @@ -40,7 +40,7 @@ def test_edit_test_type(self): time.sleep(1) driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Integration Test Type Edited") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Test type updated successfully") or self.is_text_present_on_page(text="Test Type List"), diff --git a/tests/threat_model_test.py b/tests/threat_model_test.py index f7c7a9ff178..bf7c1177124 100644 --- a/tests/threat_model_test.py +++ b/tests/threat_model_test.py @@ -15,14 +15,14 @@ def test_create_engagement_for_threatmodel(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "Add New Interactive Engagement").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Threat Model Engagement") driver.find_element(By.ID, "id_name").send_keys(Keys.TAB, "Engagement for threat model testing.") Select(driver.find_element(By.ID, "id_lead")).select_by_visible_text("Admin User (admin)") Select(driver.find_element(By.ID, "id_status")).select_by_visible_text("In Progress") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Engagement added successfully")) @on_exception_html_source_logger @@ -30,7 +30,7 @@ def test_view_threatmodel_upload_page(self): driver = self.driver self.goto_product_overview(driver) driver.find_element(By.LINK_TEXT, "QA Test").click() - driver.find_element(By.CSS_SELECTOR, ".dropdown-toggle.active").click() + self.open_product_tab(driver, "engagements") driver.find_element(By.LINK_TEXT, "View Engagements").click() driver.find_element(By.LINK_TEXT, "Threat Model Engagement").click() # Click the dropdown to find the Upload Threat Model link diff --git a/tests/tool_config.py b/tests/tool_config.py index 8259d2ba2d8..6ece426157f 100644 --- a/tests/tool_config.py +++ b/tests/tool_config.py @@ -33,7 +33,7 @@ def test_setup_tt_via_api_scan_configuration(self): # Check if form is prefieled self.assertEqual(driver.find_element(By.ID, "id_name").get_attribute("value"), "Edgescan") # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Tool Type Configuration Successfully Created.")) self.assertFalse(self.is_error_message_present()) @@ -61,7 +61,7 @@ def test_setup_tc_via_api_scan_configuration(self): # Choose Ath type Select(driver.find_element(By.ID, "id_authentication_type")).select_by_visible_text("API Key") # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="Tool Configuration successfully updated.")) self.assertFalse(self.is_error_message_present()) @@ -85,7 +85,7 @@ def test_setup_api_scan_configuration(self): driver.find_element(By.ID, "id_service_key_1").clear() driver.find_element(By.ID, "id_service_key_1").send_keys("service key") # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue(self.is_success_message_present(text="API Scan Configuration added successfully.")) self.assertFalse(self.is_error_message_present()) diff --git a/tests/tool_product_test.py b/tests/tool_product_test.py index aea571d61b2..d2228e063f4 100644 --- a/tests/tool_product_test.py +++ b/tests/tool_product_test.py @@ -54,7 +54,7 @@ def test_ensure_tool_type_exists(self): if len(description_fields) > 0: description_fields[0].clear() description_fields[0].send_keys("Tool type for integration tests") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(1) # Verify tool type list page loads without errors driver.get(self.base_url + "tool_type") @@ -88,7 +88,7 @@ def test_ensure_tool_configuration_exists(self): # No non-API tool type available; select index 1 as last resort if len(tool_type_select.options) > 1: tool_type_select.select_by_index(1) - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(2) # Verify tool config list page loads driver.get(self.base_url + "tool_config") @@ -127,7 +127,7 @@ def test_add_tool_product(self): if len(url_fields) > 0: url_fields[0].clear() url_fields[0].send_keys("https://tool.example.com") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) time.sleep(2) self.assertTrue( @@ -146,7 +146,7 @@ def test_edit_tool_product(self): edit_links[0].click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Edited Tool Product Config") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Tool Product Configuration Successfully Updated") or self.is_text_present_on_page(text="Tools"), diff --git a/tests/tool_type_test.py b/tests/tool_type_test.py index 10e8d4dcd4d..f458abc500b 100644 --- a/tests/tool_type_test.py +++ b/tests/tool_type_test.py @@ -26,7 +26,7 @@ def test_add_tool_type(self): if len(description_fields) > 0: description_fields[0].clear() description_fields[0].send_keys("A test tool type for integration testing") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Tool Type Configuration Successfully Created") @@ -40,7 +40,7 @@ def test_edit_tool_type(self): driver.find_element(By.LINK_TEXT, "Test Tool Type").click() driver.find_element(By.ID, "id_name").clear() driver.find_element(By.ID, "id_name").send_keys("Edited Test Tool Type") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Tool Type Configuration Successfully Updated") diff --git a/tests/user_profile_test.py b/tests/user_profile_test.py index 3db6b353531..1229f963752 100644 --- a/tests/user_profile_test.py +++ b/tests/user_profile_test.py @@ -29,7 +29,7 @@ def test_edit_profile(self): last_name = driver.find_element(By.ID, "id_last_name") last_name.clear() last_name.send_keys("User") - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.assertTrue( self.is_success_message_present(text="Profile updated successfully") diff --git a/tests/user_test.py b/tests/user_test.py index 8997bcdaa65..01685a93981 100644 --- a/tests/user_test.py +++ b/tests/user_test.py @@ -36,7 +36,7 @@ def test_create_user(self): driver.find_element(By.ID, "id_email").clear() driver.find_element(By.ID, "id_email").send_keys("propersam@example.com") # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the user has been created # Assert ot the query to dtermine status of failure @@ -75,7 +75,7 @@ def test_create_user_with_writer_global_role(self): if not staff_checkbox.is_selected(): staff_checkbox.click() # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the user has been created # Assert ot the query to determine status of failure self.assertTrue(self.is_success_message_present(text="User added successfully.") @@ -88,7 +88,7 @@ def enable_user_profile_writing(self): checkbox = driver.find_element(By.ID, "id_enable_user_profile_editable") if not checkbox.is_selected(): checkbox.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.logout() def disable_user_profile_writing(self): @@ -98,7 +98,7 @@ def disable_user_profile_writing(self): checkbox = driver.find_element(By.ID, "id_enable_user_profile_editable") if checkbox.is_selected(): checkbox.click() - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) self.logout() def test_user_edit_permissions(self): @@ -122,7 +122,7 @@ def test_user_edit_permissions(self): # Select Superuser Permission driver.find_element(By.NAME, "is_superuser").click() # "Click" the submit button to complete the transaction - driver.find_element(By.CSS_SELECTOR, "input.btn.btn-primary").click() + self.click_submit(driver) # Query the site to determine if the User permission has been changed # Assert ot the query to dtermine status of failure diff --git a/unittests/test_os_message.py b/unittests/test_os_message.py index 3ae2fe9374a..ce0d2b9e4c2 100644 --- a/unittests/test_os_message.py +++ b/unittests/test_os_message.py @@ -271,13 +271,12 @@ def test_os_and_session_banners_combined(self): self.assertEqual(result["additional_banners"][0]["source"], "os") self.assertEqual(result["additional_banners"][1]["source"], "product_announcement") - def _authed_request(self, *, dismissed_hash="", ui_use_tailwind=True): + def _authed_request(self, *, dismissed_hash=""): request = RequestFactory().get("/") request.user = SimpleNamespace( is_authenticated=True, usercontactinfo=SimpleNamespace( user_state_details={os_message.OS_MESSAGE_DISMISSED_KEY: dismissed_hash}, - ui_use_tailwind=ui_use_tailwind, ), ) return request diff --git a/unittests/test_simple_search_tab_panes.py b/unittests/test_simple_search_tab_panes.py index 4227cc1cc4c..6d3e746b56e 100644 --- a/unittests/test_simple_search_tab_panes.py +++ b/unittests/test_simple_search_tab_panes.py @@ -15,17 +15,15 @@ # The stylesheet hides every result pane that does not carry the `active` class: # .tab-content > .tab-pane { display: none } # .tab-content > .tab-pane.active { display: block } -# so the pane of the tab the view opened on has to get that class from somewhere -- a -# literal class in the classic templates, an Alpine `:class` binding in the Tailwind -# ones. Alpine's x-show cannot do that job: it only clears the inline `display: none` -# it set itself, which leaves the class rule in force and the pane invisible. That is -# what made the whole results area render blank while the tab headers and their result -# counts rendered fine. +# so the pane of the tab the view opened on has to get that class from somewhere -- an +# Alpine `:class` binding. Alpine's x-show cannot do that job: it only clears the inline +# `display: none` it set itself, which leaves the class rule in force and the pane +# invisible. That is what made the whole results area render blank while the tab headers +# and their result counts rendered fine. # -# Both UI trees are covered, because UIPreferenceLoader picks the tree per user and the -# panes are wired up differently in each. The status code assertion matters as much as -# the pane assertion for the Tailwind tree: an `id:` query leaves the view without a -# filter form, which used to make the filter panel raise while rendering. +# The status code assertion matters as much as the pane assertion: an `id:` query leaves +# the view without a filter form, which used to make the filter panel raise while +# rendering. # # V3_FEATURE_LOCATIONS is pinned off to match OSS CI defaults, as in # test_simple_search_scoping.py. @@ -36,11 +34,9 @@ class TestSimpleSearchTabPanes(DojoTestCase): fixtures = ["dojo_testdata.json"] - def _findings_pane_for_ui(self, *, use_tailwind): + def _findings_pane(self): admin = Dojo_User.objects.get(username="admin") - contact, _ = UserContactInfo.objects.get_or_create(user=admin) - contact.ui_use_tailwind = use_tailwind - contact.save() + UserContactInfo.objects.get_or_create(user=admin) self.client.force_login(admin) # An `id:` query needs no watson index, so the findings tab opens deterministically. @@ -53,8 +49,5 @@ def _findings_pane_for_ui(self, *, use_tailwind): self.assertIsNotNone(pane, "findings result pane did not render") return pane.group(0) - def test_active_pane_is_displayable_in_tailwind_ui(self): - self.assertRegex(self._findings_pane_for_ui(use_tailwind=True), CARRIES_ACTIVE) - - def test_active_pane_is_displayable_in_classic_ui(self): - self.assertRegex(self._findings_pane_for_ui(use_tailwind=False), CARRIES_ACTIVE) + def test_active_pane_is_displayable(self): + self.assertRegex(self._findings_pane(), CARRIES_ACTIVE) From 62695c68e80d8054ed63885277496a533b2e4c65 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Fri, 7 Aug 2026 21:07:26 -0600 Subject: [PATCH 028/110] docs(psirt): PSIRT module documentation (#15555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(psirt): new PSIRT section — advisory feeds, terms acceptance, clearance states Documents the platform-native PSIRT advisory feeds shipping in Pro (beta, behind the PSIRT feature flag + license entitlement): the customer-self-fetch posture, the 20-source shipped catalog with its cleared / pending-clearance states, the recorded terms-acceptance transaction (append-only ledger, encrypted credentials), custom feeds, feed health, and the attribution rendered with advisory content. Co-Authored-By: Claude Fable 5 * docs(psirt): Import SBOM and Feed Findings pages Documents the two surfaces that answer "am I vulnerable to this advisory?" — the global SBOM upload that gets inventory in without visiting each product, and the triage queue that gives each advisory its answer. The Feed Findings page spends its length on the part users will otherwise get wrong: the answer is three-valued, and "not affected" and "unknown" are different claims. Reporting a component with no recorded version as "not affected" would tell someone they are safe on the strength of missing data, so the docs say what each state means and what the reader can do about an unknown. Same reasoning for verified vs unverified matches, and for why a confirmed match is never retracted automatically. Co-Authored-By: Claude Fable 5 * docs(psirt): Matching Rules and Cases/SLA pages Two surfaces that were shipping without documentation, and both need it more than the pages already here — each has a behaviour a user will otherwise read as a bug. **Matching Rules** explains why a rule can be refused. A rule graded weak cannot be enabled, and someone who hits that without knowing the rule exists will conclude the feature is broken rather than that their rule matches every advisory in the store. The page leads with the grading, states the two-sided requirement with a worked example, and covers group scope, opt-out precedence, templates, and why deleting a group switches its rules off. **Cases and SLA** documents the grouping suggestions and the clock. The parts that need saying out loud: * a breach never clears — work finished late stops as breached, not met; * the triage clock cannot be paused at all, and why; * a paused clock's deadline moves, so paused time is not charged to the team; * the tier is fixed when the clock starts and can only ever move up; * mitigation priority is not the SLA tier, and downgrading one does not move a deadline; * closing a case does not stop a clock, because the obligation belongs to the advisories inside it; * you do not need a case at all to get a finding into DefectDojo. Also reordered the section so the sidebar matches the order the entries appear in the app menu — they disagreed, which would have had a reader following two different sequences for the same feature — and added a "how it fits together" list to the section index, including that the workflow pieces are optional. * docs(psirt): advisory publishing and the PSIRT dashboard Two pages for the halves of PSIRT that were undocumented, plus the index entries that place them. `advisories.md` covers the direction nothing else in PSIRT does: publishing your own advisories rather than consuming other people's. The parts a reader needs before they use it, not after — that editing approved content costs you the approvals (and why that is not a setting), that a rejection needs a reason, that preflight reports un-built checks as pending rather than passing, that exclusions beat both derived and added recipients, and that `skipped_not_configured` and `not_implemented` are neither success nor failure. `dashboard.md` leads with the four-way exposure split and says plainly that "not affected" and "no signal" are not the same claim, because that is the misreading the whole design exists to prevent. Also why feed health and triage SLA sit side by side — an SLA that looks healthy while a feed has silently stopped polling is the worst combination, and only visible together — and why feed health declines to say "all clear". The index now reads as seven steps with an explicit note that steps 1-5 are about what others published and step 6 is the other direction. Co-Authored-By: Claude Opus 5 * docs(psirt): components, SLA policies, settings, and the relevance surfaces Three new pages for surfaces that previously had no documentation, and which until now could only be configured through the Django admin: * Components — the SBOM inventory as PSIRT reads it, one row per (component, asset), and the per-pair judgement you add: a PCRSS rating, an authoritative CPE (the strongest correlation axis, which a purl-keyed SBOM row cannot carry), and tags. Says plainly that a versionless row can only ever be answered "unknown". * SLA Policies — tuning the tier ladder. Separates the clock definition (the obligation, decided in code) from the tiers (the calibration you own), and explains why editing a tier never moves a running clock and why deleting one that live clocks use is refused. * PSIRT Settings — case-worthiness weights and bands, the "new" item window, and the material-change policy, including why KEV-add is on by default and KEV-remove is not. Matching Rules gains the four capabilities from the relevance pipeline: building rules from an inventory, previewing a rule or an unsaved condition set, per-rule effectiveness with the dead vs insufficient-data distinction, and per-asset coverage with the inventory tempering that stops "covered" being earned by one precise rule beside a thousand unwatched components. Index updated and the chapter renumbered so the reading order matches the workflow. --------- Co-authored-by: Greg Anderson Co-authored-by: Claude Fable 5 --- docs/content/psirt/_index.md | 75 ++++++++++ docs/content/psirt/advisories.md | 167 ++++++++++++++++++++++ docs/content/psirt/cases.md | 140 ++++++++++++++++++ docs/content/psirt/components.md | 75 ++++++++++ docs/content/psirt/dashboard.md | 117 +++++++++++++++ docs/content/psirt/feed-findings.md | 103 ++++++++++++++ docs/content/psirt/feeds.md | 83 +++++++++++ docs/content/psirt/import-sbom.md | 63 +++++++++ docs/content/psirt/matching-rules.md | 203 +++++++++++++++++++++++++++ docs/content/psirt/settings.md | 81 +++++++++++ docs/content/psirt/sla-policies.md | 109 ++++++++++++++ 11 files changed, 1216 insertions(+) create mode 100644 docs/content/psirt/_index.md create mode 100644 docs/content/psirt/advisories.md create mode 100644 docs/content/psirt/cases.md create mode 100644 docs/content/psirt/components.md create mode 100644 docs/content/psirt/dashboard.md create mode 100644 docs/content/psirt/feed-findings.md create mode 100644 docs/content/psirt/feeds.md create mode 100644 docs/content/psirt/import-sbom.md create mode 100644 docs/content/psirt/matching-rules.md create mode 100644 docs/content/psirt/settings.md create mode 100644 docs/content/psirt/sla-policies.md diff --git a/docs/content/psirt/_index.md b/docs/content/psirt/_index.md new file mode 100644 index 00000000000..701f6caad43 --- /dev/null +++ b/docs/content/psirt/_index.md @@ -0,0 +1,75 @@ +--- +title: "PSIRT" +description: "Product Security Incident Response: advisory feeds, SBOM matching, and advisory publishing" +summary: "" +date: 2026-08-04T00:00:00+00:00 +lastmod: 2026-08-04T00:00:00+00:00 +draft: false +weight: 6 +chapter: true +sidebar: + collapsed: true +seo: + title: "" + description: "" + canonical: "" + noindex: false +--- + +DefectDojo Pro's PSIRT module helps product security teams answer one question +continuously: **am I vulnerable to this new advisory?** It ingests security +advisories from the publishers you choose, matches them against your software +inventory, and turns confirmed exposure into DefectDojo findings. + +PSIRT is a Pro feature in beta. It requires the **PSIRT** feature flag (which +depends on **Locations**) and the **PSIRT Advisory Engine** license +entitlement. + +### If you don't see PSIRT in the menu + +The PSIRT flag depends on **Locations**, and a flag with an unmet dependency is +forced off — so with Locations disabled, PSIRT is off no matter how its own +switch is set, and none of its pages are reachable. Settings → Feature Flags +says so on the PSIRT row ("Requires Locations to be enabled"). Enable Locations +first; it is environment-sourced, so it needs a restart to take effect. + +If the entries are visible but locked, that is the other gate: the licence. +PSIRT needs the **PSIRT Advisory Engine** entitlement, and a locked entry +explains what to ask for. + +## How it fits together + +1. **[Advisory Feeds](feeds/)** — choose which publishers to poll. Every source + ships disabled; enabling one records your acceptance of its terms. +2. **[Import SBOM](import-sbom/)** — give PSIRT an inventory to match against. +3. **[Components](components/)** — that inventory as PSIRT reads it, plus the + judgement you add to it: a risk rating, an authoritative CPE, tags. +4. **[Matching Rules](matching-rules/)** — optional. For advisories that publish + no machine-readable version ranges, where structural matching has nothing to + compare. Also where you preview a rule, check coverage per asset, and see + whether a rule has earned its keep. +5. **[Feed Findings](feed-findings/)** — the queue. Each advisory leads with an + explicit answer to "am I affected?", and confirming a match files a DefectDojo + finding. +6. **[Cases and SLA](cases/)** — group related matches into work items and track + the triage obligation on them. +7. **[Advisories](advisories/)** — write, review and publish your *own* advisories + about your own products, with fingerprint-bound signoffs and honest delivery + states. +8. **[PSIRT dashboard](dashboard/)** — a shared template that leads with the + affected question, beside whether the pipeline is actually working. + +Two pages are configuration rather than workflow: + +- **[SLA Policies](sla-policies/)** — how long each severity tier gets before a + triage clock warns and breaches. +- **[PSIRT Settings](settings/)** — the case-worthiness calibration, the "new" + item window, and which upstream changes count as material. + +You do not need all of it. Feeds plus an inventory is enough to start getting +answers; rules, cases and SLAs are for teams that want the workflow around them, +and advisory publishing is for teams that ship software to customers who need to +be told. + +Everything up to step 6 is about what other people published. Step 7 is the other +direction — what you publish — and it is a separate job with separate approvals. diff --git a/docs/content/psirt/advisories.md b/docs/content/psirt/advisories.md new file mode 100644 index 00000000000..2d197a5b82f --- /dev/null +++ b/docs/content/psirt/advisories.md @@ -0,0 +1,167 @@ +--- +title: "Advisories" +description: "Write, review, publish and revise your own security advisories" +draft: false +weight: 8 +pro-feature: true +--- + +Everything else in PSIRT is about what other people published. This page is about what +**you** publish: a security advisory about your own product, written by your team, +reviewed, and sent to the customers it affects. + +**PSIRT → Advisories** lists them. Opening one gives you the workbench: the composer, +the review panel, the preflight checklist, and the publishing controls, in the order you +need them. + +## The lifecycle + +An advisory moves through six states, and the transitions are enforced: + +| State | Means | +|---|---| +| Draft | Being written. Nobody outside has seen it. | +| In review | Out with reviewers. | +| Approved | Every reviewer approved. Ready to publish. | +| Published | Sent. Recipients have it. | +| Revised | Published, then materially changed and republished. | +| Superseded | Replaced by a different advisory. | + +You cannot skip Approved, and you cannot publish from Draft. That is not procedure for +its own sake: publishing an advisory is irreversible from the recipient's side, and the +review gate is the only thing standing between a draft and a customer's inbox. + +## Review, and why editing costs you approvals + +Ask for review with **Request review**, naming a reviewer and a role. They approve, +reject with a reason, or recuse themselves. + +Here is the part worth understanding before you use it. **An approval is bound to the +exact content that was approved.** Edit the summary, the impact, the remediation, the +recipient list or the remediation milestones after somebody has approved, and their +approval goes stale — the workbench says so, and the advisory drops back to needing +review. + +That is deliberate, and it is not a nuisance setting you can turn off. An approval that +survived an edit would mean a reviewer's name is attached to text they never read. The +workbench warns you *before* you save an edit that would do it, so you can decide whether +the change is worth another round. + +A **rejection needs a reason**. An advisory rejected without one is a reviewer who has +blocked their colleagues and told them nothing. + +**The case list is content too.** The workbench's *Cases covered* section shows which +cases the advisory speaks for — that set decides which findings publishing will file, so +it is part of what a reviewer approves. You can attach and detach cases at any point +before publication, including after approval, because the same binding applies: change +the set and existing approvals go stale exactly as a text edit would. Once published, +the covered set is fixed; changing it means a revision. + +## Preflight + +**Run preflight** before publishing. Six checks, and only failures block: + +| Check | Fails when | +|---|---| +| Content | A required section is empty. | +| Signoffs | A reviewer has not decided, or an approval has gone stale. | +| Recipients | The advisory resolves to nobody. | +| Comments | An unresolved blocking comment remains. | +| Embargo | The embargo date has not passed. | +| CSAF export | Not implemented — reported as pending, never as a pass. | + +The last row matters more than it looks. A check that has not been built reports +**pending**, not success. A green checklist that included un-built checks would tell you +the advisory had been verified in ways it had not. + +## Recipients + +An advisory reaches: + +* every product with a confirmed match on one of its cases (**derived**), plus +* anything you add by hand (**added**), minus +* anything you exclude. + +**Exclusions beat both.** If a product is derived *and* excluded, it is excluded — the +narrower, more deliberate instruction wins, because an exclusion is something a person +typed on purpose. + +**Preview recipients** before publishing. It names the products, and it names what you +excluded, so a mistake is visible before it is irreversible rather than after. + +Retracted matches and matches marked false-positive do not produce recipients. + +## Publishing + +Publish now, or schedule it. + +A **scheduled publish** fires from a background task. When one fails, the failure is +classified, and the three kinds behave differently: + +* **Deterministic** — the advisory will never publish as it stands (a preflight failure, + a missing recipient). It is disarmed, and you are told, because retrying nightly for a + week would just be a nightly failure. +* **Transient** — a mail server was down. It retries, up to three attempts. +* **Blocked** — PSIRT is switched off, or the licence no longer includes it. This stops + the whole pass and disarms *nothing*: the advisories are still meant to publish, and + they will once the block clears. + +Delivery states are honest about what did not happen: + +* `skipped_not_configured` — no mail server is set up. Not a failure; nothing was + attempted. +* `not_implemented` — a channel that does not exist yet. Never reported as sent. + +Neither counts as a success, and neither counts as a failure. An advisory whose email +was skipped because nothing is configured has not been delivered, and the status says so. + +## Snapshots + +Every publish takes a snapshot: the content exactly as sent, its fingerprint, and the +branding in force at the time. **Snapshot history** shows them. + +Branding is captured rather than looked up, so a snapshot of a 2024 advisory still +renders with your 2024 logo. Re-rendering it with today's branding would misrepresent +what the customer actually received. + +## Revisions and errata + +**Revise** starts a new version of a published advisory, carrying the content forward. +The published version stays published; the revision goes back through review, because +the facts changed and the previous approvals were for the previous facts. + +**Flag errata** marks a published advisory as containing an error, without waiting for a +revision to be written. + +An advisory and all its revisions share one lineage id, so a reader can follow one story +across supersessions. + +## Material change + +When the underlying advisory data moves after you publish — a CVSS revision, a new KEV +listing — the advisory is flagged as possibly out of date and you get a notification. + +This is separate from the approval question, and it does not invalidate anybody's +approval. It means what the customer was told may no longer be the whole story, which is +a judgement for a human, not a gate. + +The workbench shows the flag as a banner naming what moved — the feed's own facts, the +enrichment data, or both. Two honest ways to clear it: **revise** the advisory if the +published text no longer stands, or **acknowledge** if you have read the change and the +text still holds. Acknowledging is a recorded statement that somebody looked — it does +not republish, edit, or touch anybody's approval. If the facts move again later, the +banner comes back. + +A material change on published content also arms the **Revision SLA clock**, tiered by +the kind of change (an active-exploitation flip is hours; a severity downgrade is +weeks — see [SLA policies](../sla-policies/)). Either exit stops it: publishing a +revision, or acknowledging. A clock that kept running after a recorded "no revision +needed" would report disagreement as lateness. + +Which upstream changes raise the flag at all is yours to tune, in +[Settings](../settings/). + +## Locks + +Opening the workbench takes an edit lock for 30 minutes so two people do not overwrite +each other. It expires on its own, and you can release it by hand. diff --git a/docs/content/psirt/cases.md b/docs/content/psirt/cases.md new file mode 100644 index 00000000000..829ad390c6e --- /dev/null +++ b/docs/content/psirt/cases.md @@ -0,0 +1,140 @@ +--- +title: "Cases and SLA" +description: "Group matches into work items, and track the triage obligation on them" +draft: false +weight: 7 +pro-feature: true +--- + +A match is an observation. A **case** is the work: several matches about the same +problem, grouped, prioritised and carried to a conclusion. Matching a busy feed set +produces thousands of matches; cases are what turn that into a queue somebody can +finish. + +## Grouping + +**PSIRT → Cases → Group matches** proposes groupings over every match not already in +a case, most confident first. Four kinds: + +| Grouping | What it catches | +|---|---| +| Same vulnerability, one product | one CVE affecting several components in one product | +| Same vulnerability | one CVE wherever it landed | +| Same component | one component carrying several advisories | +| Same advisory | one advisory across several components | + +A CVE that arrived on three different feeds appears **once**, not three times — the +grouping keys on the normalised vulnerability identity rather than on the feed +record, so a corroborating source consolidates instead of duplicating. + +Each match appears in exactly one suggestion: the most confident grouping that could +claim it. Nothing is created until you pick a group. + +Every card shows how many of its matches were **version-proven** and how many are +**name-only**. That distinction matters more than the count: eight version-proven +matches are a confirmed exposure, and eight name-only ones are a research task. + +The suggestion list is capped. When it is, the dialog says so — a truncated list +reads as "there is nothing left to group" otherwise. + +## The SLA clock + +PSIRT tracks a **triage obligation**: the time between an advisory arriving in scope +and a triage decision being recorded against it. + +A clock arms when an advisory is either high/critical severity or listed as known +exploited, **and** has at least one live match. Both halves are required — a severe +advisory that touches nothing you run creates no obligation, and neither does a +low-severity advisory nobody is exploiting. + +Five tiers, from the advisory's severity and exploitation status: + +| Tier | Deadline | Warning at | +|---|---|---| +| Critical, known exploited | 4 hours | 1 hour before | +| Critical | 24 hours | 4 hours before | +| High | 3 days | 12 hours before | +| Medium | 10 days | 2 days before | +| Low | 30 days | 5 days before | + +These are defaults. Edit the durations under **SLA policies**; the tier logic itself +is not configurable. + +A clock stops when a decision is recorded — the advisory is reviewed, it is +suppressed, or every live match on it has been judged. The third case matters +because an analyst who works the matches individually and never touches the advisory +row has still done the work. + +### The tier is fixed when the clock starts + +The tier is captured at arm time and never recomputed. A case re-tiered next week did +not retroactively owe a shorter deadline, and a clock that recalculated its own +deadline from current state would erase breaches by making them un-late after the +fact. + +For the same reason, a tier can only ever move **up** on a running clock. Moving a +deadline outward is how a breach disappears. + +### Pausing + +A clock can be paused with a category and a reason, and the deadline moves forward by +exactly the time it was paused — so waiting on somebody else is not charged to the +team. + +The reason is **required and recorded permanently**. That is the whole argument for +allowing pauses: an unexplained pause is indistinguishable from avoiding a deadline. + +The **triage clock cannot be paused**. Nothing outside the team is being waited on +during triage, so a pause there could only ever hide lateness. Clocks that can be +paused say so; clocks that cannot explain why where the control would be. + +### Overdue, then breached + +A clock past its deadline reads **Overdue** until a background scan records the miss, +after which it reads **Breached**. The two are different: overdue is a comparison, +breached is a recorded fact that appears in reporting. + +**A breach never clears.** If the work finishes afterwards, the clock stops as +*breached*, not as *met* — late work finished is still late. This is deliberate: an +SLA that erases its own misses the moment somebody catches up measures nothing. + +A paused clock cannot breach. + +## Priority is not the SLA tier + +A case carries a **mitigation priority** (P0/K through P3) that you set. It is your +own ranking of the work, and the SLA does not read it — the tier comes from advisory +severity and exploitation. + +They are shown as separate columns for that reason. Downgrading a priority does not +move a deadline. + +## Scores + +A case rolls up the highest CVSS, EPSS and PCRSS across its matches. Setting any of +them by hand marks the case as overridden, and the roll-up then **leaves it alone** +on every subsequent recompute. Recomputing an overridden case is refused until you +confirm you want the hand-set values replaced. + +## Closing a case + +Closing a case records a conclusion and a mitigation status. It does **not** stop any +SLA clock: a case is a grouping, and the obligation belongs to the advisories inside +it. Closing a case cannot be used to discharge an outstanding triage obligation. + +Reopening leaves the mitigation status as the analyst left it. + +## Getting findings into DefectDojo + +You do not need a case to file a finding. Confirming a match in +[Feed Findings](../feed-findings/) and exporting it creates the DefectDojo finding +directly — one finding per vulnerability identity per product, under a standing +**PSIRT Monitoring** engagement and a **Security Advisory** test. + +A component that appears in several products produces a finding in each, because a +vulnerable library shipped in eight products is a problem in eight products. The +export reports every product it touched. + +Re-exporting the same match updates the finding it already created; it never +duplicates. A second advisory carrying the same CVE attaches as corroborating +evidence to the finding that exists. diff --git a/docs/content/psirt/components.md b/docs/content/psirt/components.md new file mode 100644 index 00000000000..f521b0da9e4 --- /dev/null +++ b/docs/content/psirt/components.md @@ -0,0 +1,75 @@ +--- +title: "Components" +description: "The inventory PSIRT matches against, and the judgement you add to it" +draft: false +weight: 4 +pro-feature: true +--- + +Components is the software inventory as PSIRT reads it: one row for each component +in each asset that carries it. The rows come from the SBOMs you import — this page +does not create inventory, it annotates it. + +The same library in two assets is two rows on purpose. A library in your payment +path and the same library in a build tool are not the same risk, and the +annotations below are per pair so you can say so. + +## What each row tells you + +| Column | Meaning | +|---|---| +| Component | Name, with its package type | +| Version | The version recorded in the SBOM. `none` means no version was recorded | +| Asset | Which asset carries this copy | +| PCRSS | Your risk rating for this component here, 1–5 | +| CPE | The CPE you supplied for it, if any | +| Tags | Your labels | +| Matches | How many advisories currently match it, and how many of those verified the version | + +A **`none` version is worth noticing.** Version comparison is what turns a +correlation into evidence, so a component with no recorded version can only ever +be answered "unknown" — never "not affected". If a lot of rows say `none`, the +SBOM that produced them is the thing to fix. + +## Annotating a component + +Select **Annotate** (or **Edit**) on a row. Three fields, all optional, all scoped +to that component in that asset: + +**PCRSS (1–5)** — the static risk rating your PSIRT policy assigns this component +in this context. It is copied onto matches produced for the pair, where it feeds +case worthiness, unless an analyst overrides it on the match itself. + +**CPE** — the single most useful thing you can add. Advisories are frequently +keyed by CPE, and an SBOM records package URLs, so a component that carries no CPE +cannot correlate on the strongest axis PSIRT has. Supplying one here makes that +axis reachable for this component. An unparseable CPE is refused rather than +stored. + +**Tags** — your own labels, lower-cased and de-duplicated. + +### An annotation changes future matching, not past matches + +Saving a CPE does not rewrite matches that already exist. The matcher reads the +annotation on each advisory's next pass, so the change shows up as advisories are +re-evaluated. + +**Re-evaluate matches** on the dialog does not wait for that. It re-queues the +advisories that already matched this component, and reports how many, so you can +see the effect of an annotation you just made rather than wondering whether it +took. + +## Filters + +**All components / Annotated only** switches between the whole inventory and the +rows somebody has judged. "Annotated only" is the useful view when you are +reviewing your own coverage; the full list is the one to work through when you are +building it. + +Keyword search covers component name, namespace and asset name. + +## When the list is empty + +An empty list means no SBOM has been imported for any asset yet, and PSIRT has +nothing to compare advisories against. **Import SBOM** on the toolbar goes +straight to the [global upload page](../import-sbom/). diff --git a/docs/content/psirt/dashboard.md b/docs/content/psirt/dashboard.md new file mode 100644 index 00000000000..ec56feddf47 --- /dev/null +++ b/docs/content/psirt/dashboard.md @@ -0,0 +1,117 @@ +--- +title: "PSIRT dashboard" +description: "A shared dashboard template that answers \"am I affected?\" first" +draft: false +weight: 9 +pro-feature: true +--- + +PSIRT ships a dashboard template. Add it from **Dashboard → Shared Templates → PSIRT**, +which clones it into your own dashboard so you can rearrange or remove anything. + +The template is only offered when the `psirt` feature flag is on. Every widget in it +needs PSIRT, so offering it otherwise would hand you four empty panels and no way to tell +whether the dashboard was broken or you were genuinely unaffected. + +## Am I affected? + +The lead widget, full width, and the reason the page exists. It counts advisories that +arrived in the window and splits them **four ways**: + +| Bucket | Means | +|---|---| +| **Affected** | A component you run has a version inside a published affected range. | +| **Unknown** | A component corresponds, but its recorded version cannot be judged — nothing recorded, or something that is not a version like `latest`. | +| **Not affected** | Matching ran, and every corresponding component is provably outside the range. | +| **No signal** | Nothing in your inventory corresponds — or matching has not run yet. | + +**"Not affected" and "no signal" are never merged, and you should not read them as the +same thing.** "Not affected" is an answer: the engine looked and your components are +outside the range. "No signal" is the absence of one. Collapsing them would report +ignorance as safety, which is the single wrong answer a product security team cannot +afford — and a dashboard that did it would be worse than no dashboard, because it would +be trusted. + +Below the tiles, the affected advisories are named, with how many components and products +each one touches. Clicking a tile opens Feed Findings filtered to that bucket. + +The counts come from stored match rows rather than from re-running the matcher, so they +are a few minutes behind rather than exact to the second. The alternative would make a +dashboard load re-match every advisory in the window. + +**Configuration:** window in days (1–90, default 7), and an optional severity floor. Leave +the floor off unless you have another panel covering the rest — it can hide the +low-severity advisory that turns out to matter. + +## Is the pipeline healthy? + +Two widgets, side by side on purpose. + +### Feed health + +Which enabled feeds are **failing to poll**, and which have gone **silent** — enabled, but +nothing delivered for two days. + +It leads with what is wrong rather than a count of successes, because a feed that stopped +polling produces no advisories, and no advisories looks exactly like a quiet week. A +widget that counted successes would look healthy at precisely the moment it should not. + +It also distinguishes a feed that has *never* polled from one that has *stopped*: those +need different actions. + +There is no filter on this widget, deliberately. Anything that could hide a silent feed +would remove the only signal separating a broken pipeline from a calm week. + +When nothing is wrong it says nothing is wrong with the feeds — not "all clear". Working +feeds are a narrower claim than being unaffected. + +### Triage SLA + +Live SLA clocks by state, and **breached is counted apart from overdue**: + +* **Overdue** — the deadline has passed and the breach scanner has not been round yet. +* **Breached** — the miss is on the record, appears in reporting, and does **not** clear + when the work is finished. + +One combined "late" number would hide which of those you are looking at. + +It also shows how many scheduled publishes are armed, and how many **gave up** — a +schedule that exhausted its retries is invisible everywhere else. + +Why these two sit together: an SLA that looks perfectly healthy while a feed has silently +stopped polling is the worst combination there is, and it is only visible when both are in +view at once. + +## Our advisories + +Your own advisories by lifecycle status, plus the ones sitting in review or approved with +**no movement for a fortnight**. + +The stalled list is the useful part. A plain status breakdown buries a two-week-old review +among the drafts. + +## What is deliberately not here + +A chart of advisory volume over time. It looks like insight and answers nothing: a spike +could be a busy week at CISA or a feed that was down and caught up, and the widget cannot +tell you which. + +## Adding the widgets individually + +All four are in **Add Widget** under Numbers and Lists, prefixed `PSIRT:`. Add them to any +dashboard. The three operational widgets take no configuration beyond a title and a +refresh interval — and they reject unknown configuration rather than ignoring it, so a knob +that did nothing cannot leave you believing the panel is filtered. + +## Notifications + +Three PSIRT notifications go through your normal notification preferences (email, Slack, +in-app): + +* an SLA clock entering **warning**, and again on **breach**; +* a **signoff requested** of you; +* a **material change** on a published advisory. + +Advisory **delivery to customers** is not one of these. It uses an explicit recipient list, +never notification preferences, because a preference fan-out would let somebody opt out of +being told about a vulnerability in a product they own. diff --git a/docs/content/psirt/feed-findings.md b/docs/content/psirt/feed-findings.md new file mode 100644 index 00000000000..f43ff4a13bf --- /dev/null +++ b/docs/content/psirt/feed-findings.md @@ -0,0 +1,103 @@ +--- +title: "Feed Findings" +description: "Triage incoming advisories and read the tri-state answer to 'am I affected?'" +draft: false +weight: 6 +pro-feature: true +--- + +**PSIRT → Feed Findings** is the queue of advisories your enabled feeds have +brought in, and the place each one gets its answer. + +## The answer is three-valued, not two + +Opening an advisory leads with an explicit answer, and there are four states +rather than a yes/no: + +- **Affected** — at least one dependency in your inventory has a version inside + the advisory's affected range. The panel names the components and the products + that carry them. +- **Not affected** — every dependency that corresponds to this advisory has a + version provably outside the affected range. +- **Unknown** — a dependency corresponds, but there is nothing comparable to + judge on. Usually the inventory records no version, or records something that + is not a version (`latest`, a branch name, a build tag). +- **No inventory signal** — nothing in your inventory corresponds to this + advisory at all. + +The last two are separate on purpose. "Every version I run is outside the range" +is an answer; "I have no version to check" is not, and reporting the second as +"not affected" would tell you that you are safe on the strength of missing data. +If a component shows as **unknown**, the fix is on your side: record the version, +or supply a CPE for that component. + +For the same reason, an advisory whose publisher never stated which versions it +affects can only ever produce **unknown** — there is nothing to compare against. +Those advisories show a version range of "all versions" only when the publisher +actually said so. + +## The queue + +Each row carries the advisory's identifier and title, its feed, severity and +CVSS, EPSS, whether it is known to be exploited, and how many matches it has +produced — with the verified count shown separately (see below). Filter by feed, +severity, pipeline state, or exploited status; search across identifiers, titles, +and CVE ids; sort by any of the scores or by match count. + +Advisories still moving through the pipeline show it: an advisory waiting on +enrichment has not yet been matched, because enrichment is what supplies the +version ranges matching needs. + +## Verified and unverified matches + +A match tells you a component and an advisory correspond. **Verified** tells you +something stronger: that there is structured, in-range version evidence behind +it. + +- A **verified** match exists because a version range actually covered the + version you run. +- An **unverified** match agreed on a name but had no version evidence to + confirm — the same package name in a different ecosystem, or a component whose + version could not be compared. + +Unverified matches are shown, not hidden, and ranked below verified ones with the +reason stated. They are leads, not conclusions. + +## Retracted matches + +Publishers revise advisories. When a revision narrows a range so that a match no +longer holds, PSIRT marks that match **retracted** rather than deleting it: the +record of why it once existed is worth keeping, and a stale match left on the +queue is worse than one withdrawn. Retracted matches are out of the default view +and come back automatically if a later revision re-widens the range. + +A match you have confirmed, or exported as a finding, is never retracted +automatically. Once a person has judged it, that judgement stands until a person +changes it. + +## Triage actions + +- **Review** marks that you have looked at an advisory. It is independent of the + advisory's pipeline state and never overwrites it. +- **Suppress** hides an advisory from the default queue. Suppressed advisories + remain viewable so you can audit what was filtered out. +- **Unsuppress** brings one back permanently: a pre-filter rule that hid it once + will not hide it again. This is deliberate — your decision that an advisory + matters outlasts the rule that disagreed. +- **Re-match** queues the advisory for another matching pass. Useful after an SBOM + import or after correcting a component's version. +- On a match: **confirm**, mark **false positive**, or leave notes. Everything + else on a match is the engine's record of why it exists and is read-only. + +**Findings are filed by people, never by the engine.** A structurally-verified +match — right component, version confirmed in range — still needs an analyst's +confirm before anything reaches the findings your product teams see. That is +deliberate: exported findings are where trust in this feature lives, and a false +positive there costs more than the click it saves. Confirming is one click on the +match; the export follows from it. + +## Attribution + +An advisory's detail shows the credit its publisher requires. Several feed +licenses require that notice to travel with the content, so it appears wherever +the content is read. diff --git a/docs/content/psirt/feeds.md b/docs/content/psirt/feeds.md new file mode 100644 index 00000000000..073cba2000e --- /dev/null +++ b/docs/content/psirt/feeds.md @@ -0,0 +1,83 @@ +--- +title: "Advisory Feeds" +description: "Enable security advisory feeds for PSIRT: the shipped catalog, terms acceptance, and feed health." +draft: false +weight: 2 +pro-feature: true +--- + +PSIRT's advisory feeds bring publisher security advisories (CISA, NVD, Red Hat, +Debian, EUVD, and more) directly into your DefectDojo Pro instance, where they +are matched against your software inventory to answer one question: **am I +vulnerable to this new advisory?** + +Feeds are part of the platform-native PSIRT module (beta). Two prerequisites: + +- The **PSIRT** feature flag (Settings → Feature Flags). It requires the + **Locations** feature, because matching runs against the SBOM-derived + dependency inventory. +- The **PSIRT Advisory Engine** entitlement on your license. + +## Your installation fetches its own data + +Every feed is polled by **your** DefectDojo instance, from your network, under +your organization's own relationship with each publisher. DefectDojo operates +no shared feed service and redistributes no publisher content. This is a +deliberate design: you fetch, you store, you use. + +## The shipped catalog + +PSIRT ships a curated catalog of 20 advisory sources. Every source arrives +**disabled** — nothing polls until a person in your organization reviews and +accepts that source's terms. + +Each source shows a clearance state: + +- **Cleared** — the publisher's terms permit this use; you can enable it after + accepting the terms. +- **Pending clearance** — DefectDojo is completing a licensing agreement with + the publisher, or the publisher's terms are under legal review. These + sources are visible but cannot be enabled yet; each one explains what + unlocks it. Sources unlock in product updates as clearances land. + +A few cleared sources are marked **transport coming soon**: their re-pointed, +cleanly-licensed channels (for example the Linux kernel CVE git repository) +need a connector that arrives in an upcoming update. + +## Enabling a feed + +Enabling is a recorded transaction, not a toggle: + +1. Open **PSIRT → Feeds** and choose a source. +2. Review the terms panel: the publisher's license, the attribution DefectDojo + will render, and any additional notices. +3. Accept the terms. Your acceptance (who, when, and the exact terms text) is + recorded in an append-only ledger that retention jobs never touch. +4. If the source needs a credential (for example your own NVD API key), enter + it during the same step. Credentials are encrypted at rest and are never + echoed back by the API or the UI. +5. Polling begins on the source's schedule (every 6, 12, or 24 hours, + depending on the source). + +If a publisher's terms change in a later release, the feed keeps polling, but +the Feeds page asks for a fresh acceptance of the new text. + +## Custom feeds + +You can add your own RSS/Atom sources. The same acceptance step applies — for +a source DefectDojo has not vetted, your acceptance records that your +organization takes responsibility for its terms. + +## Feed health + +Each source shows a health chip driven by its recent poll outcomes: healthy, +degraded, failing, never polled, or off — alongside the last successful poll +time and the most recent error when there is one. Health reflects poll +reliability, not how many advisories a publisher happened to issue. + +## Attribution + +Wherever advisory content appears — in PSIRT screens and in findings exported +from advisories — DefectDojo renders the credit each publisher requires, +including the MITRE CVE designation and license notice for CVE data and +NIST's non-endorsement disclaimer for NVD-derived data. diff --git a/docs/content/psirt/import-sbom.md b/docs/content/psirt/import-sbom.md new file mode 100644 index 00000000000..0e346458f79 --- /dev/null +++ b/docs/content/psirt/import-sbom.md @@ -0,0 +1,63 @@ +--- +title: "Import SBOM" +description: "Upload SBOMs for PSIRT matching without navigating to each product first." +draft: false +weight: 3 +pro-feature: true +--- + +PSIRT answers "am I vulnerable to this advisory?" by comparing each advisory's +affected version ranges against your dependency inventory. That inventory comes +from SBOMs, so getting them in is the first step. + +There are two ways to do it, and they suit different moments. + +## From the product page + +Each product's page has an SBOM import that attaches the document to that +product. This is the right tool when you are already looking at the product you +want to update. + +## From PSIRT → Import SBOM + +A build usually produces several SBOMs at once, and visiting each product in turn +to upload them is the slow part. The **Import SBOM** page takes the whole set: + +1. Drop one or more SBOM files, or use **Choose**. Up to 25 files at a time. +2. Each file is read far enough to see what it says it describes — CycloneDX's + own `metadata.component`, or an SPDX document's name and described package — + and matched against the products you have permission to import into. +3. Confirm the product for each file. Where the document is unambiguous, the + match is pre-filled and the **Why** column says how it was reached (an exact + name, a normalized name, or one of several possible matches). Where it is not, + the row asks rather than guessing. +4. Press **Import**. + +Supported formats are **CycloneDX** (JSON/XML) and **SPDX** (JSON, XML, and +tag-value). + +Two properties of this page are deliberate: + +- **Nothing is written until you press Import.** The inspection step reads the + files and suggests; it creates no dependencies and modifies no product. +- **A suggestion is never applied on its own.** A wrong suggestion costs you a + correction in a dropdown, not a wrong inventory. + +You can only import into products you already have import permission for, and +the suggestion list is scoped the same way — the page will not offer you a +product you cannot write to. + +## Replace or merge + +**Replace existing dependencies** applies to every file in the batch. When on, +dependencies absent from the new import are removed unless they are linked to +findings. When off, the import merges into what is already recorded. Leave it off +when you are adding a component SBOM alongside others; turn it on when the SBOM +is the complete, current picture for that product. + +## What happens next + +Imported dependencies become part of the inventory that PSIRT matching reads. The +next matching pass compares open advisories against the new inventory, so an +import can change the answer on an advisory you looked at yesterday — which is +the intended behavior, not a caching bug. diff --git a/docs/content/psirt/matching-rules.md b/docs/content/psirt/matching-rules.md new file mode 100644 index 00000000000..976ec6d2ff1 --- /dev/null +++ b/docs/content/psirt/matching-rules.md @@ -0,0 +1,203 @@ +--- +title: "Matching Rules" +description: "Write your own matching rules, and understand why some of them are refused" +draft: false +weight: 5 +pro-feature: true +--- + +Most matching needs no configuration. When an advisory publishes machine-readable +affected-version ranges, PSIRT compares them against your inventory on its own, +and the match it produces carries that comparison as evidence. + +**Matching Rules exist for the advisories that publish nothing.** A vendor +bulletin that names a product in prose, with no CPE and no version range, cannot +be matched structurally — there is nothing to compare. A rule lets you say what +that advisory means for your inventory. + +The trade is that a rule matches on what you wrote rather than on evidence, so a +loose rule produces a great deal of output that is not exposure. The authoring +surface is built around limiting that. + +## A rule graded "weak" cannot be enabled + +Every rule is graded, and the grade is shown while you type: + +- **Strong** — the rule correlates against the advisory itself, usually via CPE or + the advisory's own affected-version ranges. Matches it produces carry evidence. +- **OK** — the rule constrains both what you run *and* which advisories reach you. + It can run, but a match is a correlation rather than proof. +- **Weak** — the rule has no narrowing signal. **It can be saved as a draft but not + enabled.** + +The refusal is not a warning you can dismiss. A rule saying "tell me about +anything mentioning openssl" fires on every advisory that mentions openssl, +whether or not the version you run is affected, and enabling it is how a triage +queue becomes unusable. + +When a rule is refused, the message tells you what the rule actually says and what +is missing. For example: + +> This rule is too broad to enable. Matches a component named "docker" in your +> inventory. This keyword/regex rule only constrains the component side. It would +> match every advisory. Pair it with an advisory-side (feed_text) condition. + +## Both sides, not one + +The most common authoring mistake is gating only the component side. A rule that +says "the component is named libxml2" matches *every* advisory in the store +against every libxml2 you carry, because nothing in the rule says which advisories +are relevant. + +A usable keyword rule constrains both halves under AND: + +| Condition | Field | What it does | +|---|---|---| +| keyword `libxml2` | Component name | narrows which components | +| keyword `libxml2` | Advisory text | narrows which advisories | + +The editor offers to add the missing half for you. Copying a template does it +automatically, so a copy can never start out one-sided. + +## Conditions + +A rule combines conditions with AND or OR. Ten condition types are available; the +editor describes each one inline as you choose it, including whether it narrows the +component side, the advisory side, or both. + +Two are worth knowing about before you start: + +- **CPE** is the strongest available signal, because it compares against + identifiers the advisory itself publishes. +- **Affected version** asks whether the advisory's own ranges cover your installed + version. It is a *lenient* gate: it passes when an advisory declares no ranges at + all, which most advisories do. It narrows real exposure without excluding the + advisories a rule exists to catch. + +## Groups, scope, and templates + +Rules live in **groups**, and a group is what gets scoped: + +- **AND** means every rule in the group must fire — adding a rule *narrows* the + group, and one disabled rule can silence it entirely. +- **OR** means any one rule suffices — adding a rule *widens* it. + +A group applies where you subscribe it: to a product, to a product type, or as a +global default. An **opt-out** exempts one product and beats every other route, +including the global default. + +A group with no subscription matches nothing. That is indistinguishable from a +broken group, so the list has an "Applies to" column, and the *resolved* reach — +after opt-out precedence — is available per group rather than left for you to work +out from the subscription rows. + +**Templates** are curated groups that are never evaluated directly. Copy one to get +a group you own, which you can then edit, scope and enable. The copy is renamed to +avoid colliding with the template, its loose conditions are paired automatically, +and it starts out applying to every product so it is never born silently inert — +narrow it from the scope control afterwards. + +## Deleting a group + +Deleting a group does not delete its rules; they survive as standalone rules. A +rule outside a group has no subscription of its own, so it would otherwise keep +matching everywhere. Deleting a group therefore **switches its rules off**, and the +confirmation says how many. + +## Where matches go + +A rule-produced match appears in +[Feed Findings](../feed-findings/) alongside the structured ones, and is marked with the +rule that produced it. Structured matches outrank rule matches on the same +advisory, so a rule cannot displace evidence. + +## Build rules from your inventory + +Rather than authoring rules one at a time, PSIRT can propose one per component in +an asset's [inventory](../components/) and let you keep the ones you want. + +Each proposal is generated in a sound shape, never a loose one: + +- a component with a **CPE** becomes a single CPE condition; +- a component without one becomes a **two-sided keyword pair** — the component + name on both the inventory side and the advisory side; +- a component with a **version** additionally gets a version constraint, so the + rule fires only for advisories whose affected range covers what you run. + +Proposals arrive graded and pre-selected, and two kinds arrive **deselected**: +components an in-scope rule already covers (the proposal names the covering +group), and components whose name is a broad platform token like `docker`, where +a rule would pull in far more than your exposure. They are shown rather than +hidden, because "already covered" is worth knowing while you decide. + +When you create the group, the conditions are regenerated on the server from the +components you kept. The selection says *what* to cover; it never says what the +rules contain. + +## Preview: what would this rule do? + +Preview runs a rule against the recent advisory corpus and reports how many +advisories it would match, with samples — **without writing anything**. Matching +itself stays with the background pipeline; a preview that could persist would be a +second way to produce matches, and the two would drift. + +Preview works on a rule, on a whole group (composed by the group's operator), and +on conditions you have not saved yet, which is what makes "would this be better?" +answerable while you are still editing. Editing an existing rule also reports the +**before/after** counts, because the new number alone does not tell you whether +your edit widened or narrowed anything. + +Two things to read carefully: + +- the **corpus size** is how many advisories were evaluated. A rule matching 3 of + 40 means something different from 3 of 4,000, and if the corpus was capped the + result says so. +- the **heuristic flags** are the same warnings the authoring badge raises: a + keyword that is too short, a common English word, a broad platform token, an + unanchored regex, or a one-sided rule. + +## Effectiveness: has this rule earned its keep? + +Effectiveness reports what a rule has actually produced: lifetime and 30-day match +counts, how many an analyst confirmed, how many were marked false positive, the +resulting false-positive rate, and when it last fired. + +From that it derives up to three flags: + +- **Noisy** — most of what a reviewer looked at was wrong. Only raised once enough + matches have been reviewed to mean anything, so a rule with three false + positives and no confirmations is not condemned on that evidence. +- **Dead** — nothing for 90 days, on a rule old enough to judge. +- **Insufficient data** — never fired, but younger than that window. Deliberately + distinct from dead: a new rule that has not fired yet has told you nothing, and + reporting it as dead would invite deleting rules that were simply new. + +"Dead" is an age heuristic, not a volume-aware one. A rule with nothing matchable +in the feed during the window is reported dead even though it never had the +chance — worth remembering before deleting on that signal alone. + +## Coverage: is anything watching this asset? + +Coverage answers the question a list of rule groups does not: for each asset, is +anything actually watching it, and does that cover what the asset carries? + +Each asset gets a grade: + +- **Covered** — at least one strong group applies, *and* in-scope rules name most + of the asset's inventory. +- **Partial** — something applies, but it is weaker or narrower than that. +- **None** — nothing applies. + +The inventory test is what stops "covered" being earned cheaply. One precise rule +beside a thousand unwatched components is not coverage, so a provisional "covered" +is capped at "partial" when in-scope rules name less than 80% of the inventory. An +asset with no inventory at all is not capped — there is nothing to be +under-covered against, and a product nobody has imported an SBOM for is a +different problem from a real gap. + +Organizations are graded weakest-link: covered only when every asset in them is. + +One state is called out separately: **rules are scoped here, but all disabled**. +That is a different problem from having authored nothing, and it has a different +fix, so the two do not share a grade. + diff --git a/docs/content/psirt/settings.md b/docs/content/psirt/settings.md new file mode 100644 index 00000000000..0a45b08d1f9 --- /dev/null +++ b/docs/content/psirt/settings.md @@ -0,0 +1,81 @@ +--- +title: "PSIRT Settings" +description: "Case-worthiness calibration, the \"new\" item window, and what counts as a material change" +draft: false +weight: 11 +pro-feature: true +--- + +Three settings that shape how PSIRT scores and re-surfaces work. Each section +saves on its own, and each says who changed it last. + +## Case worthiness + +Case worthiness is the score the triage queue sorts by — how much an advisory +looks like it deserves attention, from four signals fused together. + +**The weights** are points and must sum to exactly 100: + +| Weight | What it rewards | +|---|---| +| Specificity | How precisely the match was made — a version-verified structural match over a keyword correlation | +| Pre-filter | Signals your pre-filters asserted, such as known exploitation | +| Severity | CVSS, or an analyst-asserted severity floor | +| Urgency | Exploitation and EPSS | + +**The thresholds** are the floors of the risk bands, and must ascend: +low-medium < medium < high < critical. + +The page tracks both rules as you type — a running weight total, and a warning +when the thresholds stop ascending — and the save is disabled until they hold. A +half-edited calibration summing to 90 would silently rescale every score. + +**A change applies to scores computed from then on.** Already-computed scores keep +their values until the advisory is re-evaluated, so re-calibrating does not +rewrite history. The change reaches the worker you saved it on immediately, and +every other worker within a minute. + +If you have never changed anything, the page shows the shipped calibration, which +is what is in force. + +## "New" item window + +How far back "new" reaches. One of 7, 14, 30 or 90 days. + +This window is what the dashboard's **"Am I affected?"** widget counts by default — +the widget and this page always agree on what "new" means, because the widget reads +this setting rather than carrying its own number. A dashboard can still override the +window per-widget in the widget's own settings, and that override wins for that +widget alone. + +## Material-change policy + +Advisories change after publication. This section decides which of those changes +are *material* — worth stamping a revision and re-opening attention on something +you may already have triaged. + +| Setting | Default | What it catches | +|---|---|---| +| Severity tier transitions | on | moves within medium/high/critical, and any escalation into that set from below | +| CVE set changes | on | a CVE joining or leaving an advisory | +| Added to the CISA KEV list | on | a vulnerability now known to be exploited | +| Removed from the CISA KEV list | **off** | de-listing rarely changes what you must do | +| EPSS crossing the high boundary | on | exploitation probability crossing your line | +| Affected-product set changes | on | the publisher adding or removing affected products | +| CVSS v3 delta threshold | 0.5 | how far a score must move to count | +| EPSS high boundary | 0.8 | where "high" starts | +| Severity floor | none | changes below this band are never material | + +The defaults are deliberately asymmetric. Being added to KEV is news; being +removed from it usually is not, and treating it as material would re-open triaged +work for a reason nobody needs to act on. + +Severity is judged on the tier the feed supplies, so it works even for publishers +that never provide a CVSS score — which is also why an escalation *into* +medium/high/critical counts: a feed that only speaks in tiers must not be able to +raise something to critical without anybody being told. Downgrades out of the set +are not material; nothing needs re-triage because it got less serious. + +Turning things **off** here is how a noisy revision stream gets quieter — but +every switch you turn off is a class of upstream change you will no longer be told +about, on advisories you have already decided about. diff --git a/docs/content/psirt/sla-policies.md b/docs/content/psirt/sla-policies.md new file mode 100644 index 00000000000..655a3ed9541 --- /dev/null +++ b/docs/content/psirt/sla-policies.md @@ -0,0 +1,109 @@ +--- +title: "SLA Policies" +description: "Tune how long each severity tier gets before a PSIRT clock warns and breaches" +draft: false +weight: 10 +pro-feature: true +--- + +SLA Policies is where you set how much time each severity tier gets. It is the +tuning surface for the clocks described in [Cases and SLA](../cases/) — that page +explains what a clock is and how pausing works; this one is about changing the +numbers. + +The page separates two things deliberately. + +## The clock definition is the obligation + +Each card is a clock definition: what arms it, what stops it, and whether it can +be paused. Those are decided in code, so the card shows them read-only. The +shipped definition is **Triage SLA**, which runs on a source advisory from the +moment it arrives in scope until a triage decision is recorded. + +The one control on a definition is its switch. Disabling a definition stops it +arming new clocks; clocks already running are unaffected, because a clock that +already started measures a real obligation. + +Beside each definition is a live count of the clocks currently running or paused +under it, so you can see what a change is about to affect. + +## The tiers are the calibration + +Each definition has a ladder of tiers. A tier says: for work that resolves to +*this* severity, allow this long, and start warning this far before the deadline. + +Three clock definitions ship, each with a default ladder you can edit freely. + +**Triage** — from an in-scope advisory arriving to a triage decision: + +| Tier | Deadline | Warns | +|---|---|---| +| `critical_exploited` | 4 hours | 1 hour before | +| `critical` | 1 day | 4 hours before | +| `high` | 3 days | 12 hours before | +| `medium` | 10 days | 2 days before | +| `low` | 30 days | 5 days before | + +**Publication** — from an advisory being drafted over publication-worthy findings +(high/critical, or known-exploited whatever the label) to it being published. This +clock may be paused: publishing genuinely waits on the outside world — embargoes, +vendor coordination, counsel — in a way triage never does. Its tier can also rise +while it runs, so a finding escalating under a draft shortens the deadline: + +| Tier | Deadline | Warns | +|---|---|---| +| `critical_exploited` | 1 day | 4 hours before | +| `critical` | 3 days | 12 hours before | +| `high` | 7 days | 1 day before | +| `medium` | 21 days | 3 days before | +| `low` | 60 days | 7 days before | + +**Revision** — from a material change on already-published content to a revision +going out, *or* to the recorded judgement that the published wording still stands +(acknowledging the change stops this clock — a breach should mean silence, not +disagreement). Its tiers are keyed by the **kind of change** that armed it, not by +severity: + +| Tier | Deadline | Warns | +|---|---|---| +| `exploitation_flip_active` | 4 hours | 1 hour before | +| `severity_raised_to_critical` | 1 day | 4 hours before | +| `scope_expanded` | 3 days | 12 hours before | +| `severity_raised_to_high` | 7 days | 1 day before | +| `mitigation_available` | 7 days | 1 day before | +| `severity_lowered` | 21 days | 3 days before | + +Add, edit, disable or delete tiers freely. Durations are entered as days, hours +and minutes, and the warning must fit inside the deadline — a warning that fires +after the deadline it warns about has nothing to warn anybody about. + +Both boundaries notify. When a running clock crosses its warning threshold you get +one notification — once, not on every scanner pass — and crossing the deadline +sends the breach notification. A clock that blows through both between two scanner +passes reports only the breach: a warning about a deadline already missed would +just be noise. Paused clocks warn about nothing; their deadline is moving with +them, which is the point of pausing. + +**Tier codes come from the definition's resolver, not from case priority.** The +form offers the shipped vocabulary and accepts anything you type, but a tier whose +code the resolver never returns is inert: nothing will ever resolve to it. The +`critical_exploited` tier exists because exploited-in-the-wild is treated as its +own tier rather than as "critical, but sooner". + +## Changing a tier does not move a running clock + +A clock snapshots its tier and its deadline when it arms. Editing a tier changes +what future clocks get, and leaves every running clock measuring the terms it +started under. + +This is why the page never offers to edit a deadline directly. A deadline that +could be changed after the fact is not evidence of anything, and the whole point +of the clock is to be able to say afterwards what was promised and whether it was +met. + +## Deleting a tier that clocks are running under is refused + +If live clocks were armed from a tier, deleting it is refused with a count, and +the refusal says to disable it instead. Disabling stops the tier being used for +new clocks without erasing the terms the running ones were armed with — which is +what you actually want, and what deleting would silently destroy. From 9887d71835c79c62e4769eae4815564d3b425f06 Mon Sep 17 00:00:00 2001 From: Sam Vader Date: Sat, 8 Aug 2026 14:10:29 -0500 Subject: [PATCH 029/110] feat(api-tokens): API token expiry and revoke-by-key endpoint (#14932) * API token revocation * Correct TokenAuthentication import and add expiry to classic template * Ruff linter compliance for token expiry feature * Enhance API token management documentation and improve token expiry error message * feat(api-tokens): wire model field, URL registration, and unit tests - Add token_expiry DateTimeField to UserContactInfo (was missing from initial commit despite migration referencing it) - Register ApiTokenViewSet at api-tokens/ in urls.py (was missing from initial commit despite ViewSet existing in views.py) - Add unit tests for list, retrieve, revoke, expiry enforcement, and default-expiry-on-reset behaviours * Ruff compliance and docs correction * Fix silent pass in API test * refactor: replace proposed token viewset with simple revoke-by-key endpoint * docs: update token management section after change to revocation and expiry processes * fix(lint): hoist token expiry imports to module scope The serializer imported Token and token_expires_at inside get_token_expiry to sidestep a circular import that does not exist: dojo.user.authentication reaches only authorization, models and notifications.helper, none of which import back into dojo.user.api.serializer, and dojo/user/api/views.py already imports both modules at module scope. Resolves PLC0415 (import-outside-top-level), which was gating the whole unit-test suite. Co-Authored-By: Claude Opus 4.8 * fix(tests): keep the admin token fresh, pin the retroactive behaviour, drop a Docsy shortcode The six failures were all in the new test file; the rest of the suite (6491 tests) was unaffected. Five of them failed in setup with 403 "API token has expired." rather than at their assertion: dojo_testdata's admin token has an old created timestamp, so any test switching on a default lifetime expired the very credential used to build its fixtures. The admin token is now stamped fresh in setUp. That collision is worth stating rather than papering over, so test_enabling_the_default_expires_pre_existing_tokens now pins it directly: a 200-day-old token works, and the moment a 90-day default is switched on the same token is rejected. Evaluating expiry at use is what makes the control impossible to sidestep, and retroactivity is the price. The docs now carry that warning, along with the recovery path (the UI uses session auth and can issue a new token). The sixth was a real test defect: a disabled form field falls back to its initial value through a round trip that drops microseconds, so comparing exact datetimes failed on precision while the property under test held. It now asserts the posted value was ignored, to the second. Also replaces the {{% alert %}} block added with the earlier docs edit. That is a Docsy shortcode; this site runs Thulite Doks, where {{< highlight >}} is the only shortcode in use across docs/content, so it would not have rendered. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Greg Anderson Co-authored-by: Claude Opus 4.8 --- docs/content/automation/api/api-v2-docs.md | 56 ++++ .../0290_usercontactinfo_token_expiry.py | 16 + dojo/settings/settings.dist.py | 5 +- dojo/templates/dojo/api_v2_key.html | 5 + dojo/urls.py | 3 +- dojo/user/api/serializer.py | 24 ++ dojo/user/api/views.py | 73 ++++- dojo/user/authentication.py | 61 +++- dojo/user/models.py | 1 + dojo/user/ui/forms.py | 6 + dojo/user/ui/views.py | 7 +- unittests/test_apiv2_token.py | 276 ++++++++++++++++++ 12 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 dojo/db_migrations/0290_usercontactinfo_token_expiry.py create mode 100644 unittests/test_apiv2_token.py diff --git a/docs/content/automation/api/api-v2-docs.md b/docs/content/automation/api/api-v2-docs.md index 82fb8730765..dbbfaea3cee 100644 --- a/docs/content/automation/api/api-v2-docs.md +++ b/docs/content/automation/api/api-v2-docs.md @@ -47,6 +47,62 @@ If you use [an alternative authentication method](/admin/sso/) for users, you ma Using of DefectDojo API tokens can be disabled by specifying the environment variable `DD_API_TOKENS_ENABLED` to `False`. Or only `api/v2/api-token-auth/` endpoint can be disabled by setting `DD_API_TOKEN_AUTH_ENDPOINT_ENABLED` to `False`. +### Token management + +DefectDojo provides API endpoints to revoke and set expiry on API tokens programmatically. + +#### Revoking a token by key + +When a token value is compromised, a superuser or Global Owner can revoke it directly by its key, without needing to know which user it belongs to: + +``` +POST /api/v2/api-tokens/revoke/ +Authorization: Token +Content-Type: application/json + +{"key": ""} +``` + +Returns 204 on success. The token is deleted, any per-user expiry override is cleared, and the owner is notified that their token was revoked. The owner will need to generate a new token via the UI (`/api/key-v2`) or via `POST /api/v2/users/{id}/reset_api_token/` before they can authenticate again. + +Returns 404 if no token matches the supplied key, 400 if `key` is missing, and 403 for non-superusers. + +#### Token expiry + +An optional expiry datetime can be set per user via the `user_contact_infos` endpoint (superuser only): + +``` +PATCH /api/v2/user_contact_infos/{id}/ +Authorization: Token +Content-Type: application/json + +{"token_expiry": "2026-12-31T23:59:59Z"} +``` + +Once set, any request using that user's token after the expiry datetime will receive a `403 Forbidden` response with `{"detail": "API token has expired."}`. The user must generate a new token to regain access. + +To clear a per-user expiry, set `token_expiry` to `null`. The token then falls back to the instance-wide default described below, so this makes a token permanent only when that default is `0`. + +Setting this field is restricted to superusers. It is not editable from the user profile page, and an expired token is rejected rather than deleted, so raising the expiry restores access without forcing a rotation. + +#### Default token lifetime + +To enforce a maximum token lifetime across all users, set the environment variable: + +``` +DD_API_TOKEN_DEFAULT_EXPIRY_DAYS=90 +``` + +When set to a value greater than `0`, every token expires that many days after it was created. This is measured from the token's own creation time and is evaluated when the token is used, so it applies to every token on the instance, including ones that already exist and ones obtained through `POST /api/v2/api-token-auth/` or the UI key page. The default is `0`, meaning tokens do not expire unless a per-user expiry is set. + +A per-user `token_expiry` takes precedence over this default, in either direction: it can pin a shorter life for one user, or grant a longer one. + +Resetting or revoking a token clears the per-user override, because that value described the token being replaced. The instance default still applies to the new token, measured from its creation. + +**Switching this setting on is retroactive.** Because expiry is evaluated when a token is used rather than recorded when it is issued, raising the value above `0` immediately invalidates every token on the instance that is already older than the window. That includes your own API token and any token driving a CI/CD pipeline. + +Plan the change before making it: pick a window, ask token owners to rotate first, then enable the setting. If you do lock yourself out of the API, the UI is unaffected because it uses session authentication, and you can issue a new token from `/api/key-v2`. + ## Sample Code Here are some simple python examples and their results produced against diff --git a/dojo/db_migrations/0290_usercontactinfo_token_expiry.py b/dojo/db_migrations/0290_usercontactinfo_token_expiry.py new file mode 100644 index 00000000000..105fc2914f5 --- /dev/null +++ b/dojo/db_migrations/0290_usercontactinfo_token_expiry.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dojo", "0289_remove_usercontactinfo_ui_use_tailwind"), + ] + + operations = [ + migrations.AddField( + model_name="usercontactinfo", + name="token_expiry", + field=models.DateTimeField(blank=True, help_text="Explicit expiry for this user's API token. Overrides the instance-wide default from DD_API_TOKEN_DEFAULT_EXPIRY_DAYS. Leave empty to use that default. Once the effective expiry has passed the token is rejected at authentication.", null=True), + ), + ] diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index adcd4b2f2fe..074d0007fea 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -267,6 +267,8 @@ DD_API_TOKENS_ENABLED=(bool, True), # Enable endpoint which allow user to get API token when user+pass is provided DD_API_TOKEN_AUTH_ENDPOINT_ENABLED=(bool, True), + # Default token lifetime in days. 0 = no expiry (tokens last forever). + DD_API_TOKEN_DEFAULT_EXPIRY_DAYS=(int, 0), # You can set extra Jira headers by suppling a dictionary in header: value format (pass as env var like "headr_name=value,another_header=anohter_value") DD_ADDITIONAL_HEADERS=(dict, {}), # Set fields used by the hashcode generator for deduplication, via en env variable that contains a JSON string @@ -699,6 +701,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param API_TOKENS_ENABLED = env("DD_API_TOKENS_ENABLED") API_TOKEN_AUTH_ENDPOINT_ENABLED = env("DD_API_TOKEN_AUTH_ENDPOINT_ENABLED") +API_TOKEN_DEFAULT_EXPIRY_DAYS = env("DD_API_TOKEN_DEFAULT_EXPIRY_DAYS") REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", @@ -718,7 +721,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param } if API_TOKENS_ENABLED: - REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] += ("rest_framework.authentication.TokenAuthentication",) + REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] += ("dojo.user.authentication.ExpiringTokenAuthentication",) SPECTACULAR_SETTINGS = { "TITLE": "DefectDojo API v2", diff --git a/dojo/templates/dojo/api_v2_key.html b/dojo/templates/dojo/api_v2_key.html index 6b4d56e9338..eb2b8d83a11 100644 --- a/dojo/templates/dojo/api_v2_key.html +++ b/dojo/templates/dojo/api_v2_key.html @@ -8,6 +8,11 @@

    {{ name }}


    {% trans "Your current API key is" %} {{ key.key }}

    {% trans "Your current API Authorization Header value is" %} Token {{ key.key }}

    + {% if token_expiry %} +

    {% trans "Your API key expires on" %} {{ token_expiry }}

    + {% else %} +

    {% trans "Your API key does not expire." %}

    + {% endif %}

    {% trans "Has your key been exposed? Are you ready for a new one?" %}

    {% csrf_token %} diff --git a/dojo/urls.py b/dojo/urls.py index f3cd0f39863..9e29ca7cbe2 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -86,7 +86,7 @@ from dojo.url.api.urls import add_url_urls from dojo.url.ui.urls import urlpatterns as url_patterns from dojo.user.api.urls import add_user_urls -from dojo.user.api.views import UserProfileView +from dojo.user.api.views import RevokeApiTokenView, UserProfileView from dojo.user.ui.urls import urlpatterns as user_urls from dojo.utils import get_system_setting @@ -202,6 +202,7 @@ # Django Rest Framework API v2 re_path(r"^{}api/v2/".format(get_system_setting("url_prefix")), include(v2_api.urls)), re_path(r"^{}api/v2/user_profile/".format(get_system_setting("url_prefix")), UserProfileView.as_view(), name="user_profile"), + re_path(r"^{}api/v2/api-tokens/revoke/$".format(get_system_setting("url_prefix")), RevokeApiTokenView.as_view(), name="api-token-revoke"), ] if hasattr(settings, "API_TOKENS_ENABLED") and hasattr(settings, "API_TOKEN_AUTH_ENDPOINT_ENABLED"): diff --git a/dojo/user/api/serializer.py b/dojo/user/api/serializer.py index 8bf4b471d94..8a65875357b 100644 --- a/dojo/user/api/serializer.py +++ b/dojo/user/api/serializer.py @@ -5,8 +5,10 @@ from django.core.exceptions import ValidationError from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from rest_framework.authtoken.models import Token from dojo.models import Dojo_User, UserContactInfo +from dojo.user.authentication import token_expires_at from dojo.user.utils import get_configuration_permissions_codenames User = get_user_model() @@ -17,6 +19,7 @@ class UserSerializer(serializers.ModelSerializer): last_login = serializers.DateTimeField(read_only=True, allow_null=True) email = serializers.EmailField(required=True) token_last_reset = serializers.SerializerMethodField() + token_expiry = serializers.SerializerMethodField() password_last_reset = serializers.SerializerMethodField() password = serializers.CharField( write_only=True, @@ -48,6 +51,7 @@ class Meta: "is_staff", "is_superuser", "token_last_reset", + "token_expiry", "password_last_reset", "password", "configuration_permissions", @@ -58,6 +62,12 @@ def get_token_last_reset(self, instance): uci = getattr(instance, "usercontactinfo", None) return getattr(uci, "token_last_reset", None) + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_token_expiry(self, instance): + """Effective expiry, including the instance-wide default, not just an explicit override.""" + token = Token.objects.filter(user=instance).first() + return token_expires_at(token) if token else None + @extend_schema_field(serializers.DateTimeField(allow_null=True)) def get_password_last_reset(self, instance): uci = getattr(instance, "usercontactinfo", None) @@ -236,3 +246,17 @@ class Meta: class UserProfileSerializer(serializers.Serializer): user = UserSerializer(many=False) user_contact_info = UserContactInfoSerializer(many=False, required=False) + + +class RevokeApiTokenSerializer(serializers.Serializer): + + """Input for revoking a single API token by its key value.""" + + key = serializers.CharField(write_only=True, trim_whitespace=True, max_length=256) + + def validate_key(self, value): + value = value.strip() + if not value: + msg = "key may not be blank." + raise serializers.ValidationError(msg) + return value diff --git a/dojo/user/api/views.py b/dojo/user/api/views.py index 6dd02f0eb2c..1f7025765a8 100644 --- a/dojo/user/api/views.py +++ b/dojo/user/api/views.py @@ -2,11 +2,13 @@ from crum import get_current_user from django.contrib.auth import get_user_model +from django.urls import reverse from django_filters.rest_framework import DjangoFilterBackend -from drf_spectacular.utils import extend_schema_view +from drf_spectacular.utils import extend_schema, extend_schema_view from rest_framework import status +from rest_framework.authtoken.models import Token from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.generics import GenericAPIView from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.response import Response @@ -14,8 +16,10 @@ from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, schema_with_prefetch from dojo.authorization import api_permissions as permissions from dojo.models import UserContactInfo +from dojo.notifications.helper import create_notification from dojo.user.api.filters import ApiUserContactInfoFilter, ApiUserFilter from dojo.user.api.serializer import ( + RevokeApiTokenSerializer, UserContactInfoSerializer, UserProfileSerializer, UserSerializer, @@ -83,6 +87,71 @@ def get_queryset(self): return UserContactInfo.objects.all().order_by("id") +# Authorization: superuser or global owner +class RevokeApiTokenView(GenericAPIView): + + """ + Revoke an API token by its key value. + + Accepts ``{"key": ""}`` and immediately deletes the matching token. Intended for + incident response, when a token is found leaked and the owning user is not known. + """ + + permission_classes = (IsAuthenticated, permissions.IsSuperUserOrGlobalOwner) + serializer_class = RevokeApiTokenSerializer + pagination_class = None + filter_backends = [] + + @extend_schema( + request=RevokeApiTokenSerializer, + responses={204: None}, + summary="Revoke an API token by its key value", + ) + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + key = serializer.validated_data["key"] + + token = Token.objects.select_related("user").filter(key=key).first() + if token is None: + # Reported so an operator does not believe a mistyped key was revoked. The endpoint + # is superuser-only, so this is not an oracle available to untrusted callers. + msg = "No token matching the provided key." + raise NotFound(msg) + + target_user = token.user + token.delete() + + # The explicit expiry described the token just deleted; leaving it set would expire the + # user's next token immediately. + uci = getattr(target_user, "usercontactinfo", None) + if uci is not None and uci.token_expiry is not None: + uci.token_expiry = None + uci.save(update_fields=["token_expiry"]) + + logger.info( + "API token for user %s revoked by %s", + target_user.username, + request.user.username, + ) + + # The owner's automation just stopped working; tell them why. + create_notification( + event="other", + title="API Token Revoked", + description=( + f"Your API token has been revoked by " + f"{request.user.get_full_name() or request.user.username}. " + f"Generate a new token via the UI to keep using the API." + ), + recipients=[target_user], + url=reverse("api_v2_key"), + requested_by=request.user, + icon="key", + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + # Authorization: authenticated users class UserProfileView(GenericAPIView): permission_classes = (IsAuthenticated,) diff --git a/dojo/user/authentication.py b/dojo/user/authentication.py index 11f8fce104c..f0f31e2f197 100644 --- a/dojo/user/authentication.py +++ b/dojo/user/authentication.py @@ -1,14 +1,67 @@ +from datetime import timedelta + from django.conf import settings from django.urls import reverse from django.utils import timezone +from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied, ValidationError from dojo.authorization.authorization import user_is_superuser_or_global_owner from dojo.models import Dojo_User, UserContactInfo from dojo.notifications.helper import create_notification +def default_expiry_days() -> int: + """Instance-wide token lifetime in days. 0 (the default) means tokens never expire.""" + try: + days = int(getattr(settings, "API_TOKEN_DEFAULT_EXPIRY_DAYS", 0) or 0) + except (TypeError, ValueError): + return 0 + return max(days, 0) + + +def token_expires_at(token: Token): + """ + Effective expiry for a token, or None when it never expires. + + An explicit ``token_expiry`` on the owner's contact info wins, so a superuser can pin or + extend a single user. Otherwise the instance-wide default is measured from the token's own + ``created`` timestamp. + + Deriving the default from ``created`` rather than stamping an expiry at creation time is + deliberate: tokens are minted by three separate paths (``reset_token_for_user``, the lazy + create on the UI key page, and DRF's ``api-token-auth`` endpoint), and a stamping approach + silently grants an unexpiring token to anyone who uses a path that forgot to stamp. + Computing it here covers every path, including any added later. + """ + uci = getattr(token.user, "usercontactinfo", None) + explicit = getattr(uci, "token_expiry", None) + if explicit: + return explicit + days = default_expiry_days() + if days and token.created: + return token.created + timedelta(days=days) + return None + + +def token_is_expired(token: Token) -> bool: + expires_at = token_expires_at(token) + return expires_at is not None and expires_at <= timezone.now() + + +class ExpiringTokenAuthentication(TokenAuthentication): + + """Token auth that additionally rejects tokens past their effective expiry.""" + + def authenticate_credentials(self, key): + user, token = super().authenticate_credentials(key) + if token_is_expired(token): + msg = "API token has expired." + raise AuthenticationFailed(msg) + return user, token + + def reset_token_for_user(*, acting_user: Dojo_User, target_user: Dojo_User, allow_self_reset: bool = False) -> None: if not settings.API_TOKENS_ENABLED: msg = "API tokens are disabled." @@ -31,9 +84,13 @@ def reset_token_for_user(*, acting_user: Dojo_User, target_user: Dojo_User, allo Token.objects.filter(user=target_user).delete() Token.objects.create(user=target_user) + # Clear any explicit expiry: it belonged to the token just replaced, and a past datetime + # left behind would expire the new token immediately. The instance-wide default still + # applies, measured from the new token's creation time. uci, _ = UserContactInfo.objects.get_or_create(user=target_user) uci.token_last_reset = timezone.now() - uci.save(update_fields=["token_last_reset"]) + uci.token_expiry = None + uci.save(update_fields=["token_last_reset", "token_expiry"]) # Send notification to the target user if acting_user == target_user: diff --git a/dojo/user/models.py b/dojo/user/models.py index b0a2d8cbbb2..9a515e3297a 100644 --- a/dojo/user/models.py +++ b/dojo/user/models.py @@ -125,6 +125,7 @@ class UserContactInfo(models.Model): ) force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) + token_expiry = models.DateTimeField(null=True, blank=True, help_text=_("Explicit expiry for this user's API token. Overrides the instance-wide default from DD_API_TOKEN_DEFAULT_EXPIRY_DAYS. Leave empty to use that default. Once the effective expiry has passed the token is rejected at authentication.")) password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) # Extensible per-user UI state: dismissed banners, "seen"/"don't show again" # flags, and similar small ephemeral preferences. Store new flags of this kind diff --git a/dojo/user/ui/forms.py b/dojo/user/ui/forms.py index 7ccb7880b16..438b5070d49 100644 --- a/dojo/user/ui/forms.py +++ b/dojo/user/ui/forms.py @@ -94,6 +94,12 @@ def __init__(self, *args, **kwargs): self.fields["password_last_reset"].disabled = True if "token_last_reset" in self.fields: self.fields["token_last_reset"].disabled = True + # token_expiry is a security control, not a preference: this form is reachable by any user + # for their own profile, and Meta.exclude only drops "user"/"slack_user_id", so leaving it + # enabled would let a user clear or extend their own token expiry. Setting it is reserved + # for superusers via /api/v2/user_contact_infos/. + if "token_expiry" in self.fields: + self.fields["token_expiry"].disabled = True # Do not expose force password reset if the current user does not have a password to reset if user is not None: if not user.has_usable_password(): diff --git a/dojo/user/ui/views.py b/dojo/user/ui/views.py index d3f7057d979..ab2bd837e1d 100644 --- a/dojo/user/ui/views.py +++ b/dojo/user/ui/views.py @@ -38,7 +38,7 @@ ) from dojo.labels import get_labels from dojo.models import Alerts, Dojo_User, Product, Product_Type, UserContactInfo -from dojo.user.authentication import reset_token_for_user +from dojo.user.authentication import reset_token_for_user, token_expires_at from dojo.user.ui.filters import UserFilter from dojo.user.ui.forms import ( AddDojoUserForm, @@ -108,11 +108,16 @@ def api_v2_key(request): api_key = Token.objects.create(user=request.user) add_breadcrumb(title=_("API Key"), top_level=True, request=request) + # Show the effective expiry (explicit override, else the instance-wide default measured from + # the token's creation), so the page never claims a token is permanent when it is not. + token_expiry = token_expires_at(api_key) if isinstance(api_key, Token) else None + return render(request, "dojo/api_v2_key.html", {"name": _("API v2 Key"), "metric": False, "user": request.user, "key": api_key, + "token_expiry": token_expiry, "form": form, }) diff --git a/unittests/test_apiv2_token.py b/unittests/test_apiv2_token.py new file mode 100644 index 00000000000..b692b0cc468 --- /dev/null +++ b/unittests/test_apiv2_token.py @@ -0,0 +1,276 @@ +from datetime import timedelta + +from crum import impersonate +from django.test import override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient, APITestCase + +from dojo.models import User, UserContactInfo +from dojo.user.authentication import token_expires_at, token_is_expired +from dojo.user.ui.forms import UserContactInfoForm +from unittests.dojo_test_case import versioned_fixtures + + +@versioned_fixtures +class ApiTokenTest(APITestCase): + + """Test API token expiry enforcement and the revoke-by-key endpoint.""" + + fixtures = ["dojo_testdata.json"] + + def setUp(self): + token = Token.objects.get(user__username="admin") + # The fixture's admin token was created long ago. Any test that switches on a default + # lifetime would otherwise expire it and fail during setup rather than at its assertion + # -- see test_enabling_the_default_expires_pre_existing_tokens, which pins that behaviour + # deliberately instead of tripping over it. + Token.objects.filter(pk=token.pk).update(created=timezone.now()) + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + + def _create_user(self, username): + password = "testTEST1234!@#$" + r = self.client.post(reverse("user-list"), { + "username": username, + "email": f"{username}@dojo.com", + "password": password, + }, format="json") + self.assertEqual(r.status_code, 201, r.content[:1000]) + user = User.objects.get(id=r.json()["id"]) + token = Token.objects.get_or_create(user=user)[0] + return user, token, password + + def _client_for(self, token_key): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Token " + token_key) + return client + + def _revoke_url(self): + return reverse("api-token-revoke") + + def _set_created(self, token, when): + """``Token.created`` is auto_now_add, so it can only be backdated after insert.""" + Token.objects.filter(pk=token.pk).update(created=when) + token.refresh_from_db() + return token + + # --- revoke --- + + def test_revoke_by_key_as_superuser(self): + user, token, _ = self._create_user("api-token-revoke-super") + + r = self.client.post(self._revoke_url(), {"key": token.key}, format="json") + self.assertEqual(r.status_code, 204, r.content[:1000]) + self.assertFalse(Token.objects.filter(user=user).exists()) + + def test_revoke_by_key_clears_explicit_expiry(self): + """A stale override would otherwise expire the user's next token on arrival.""" + user, token, _ = self._create_user("api-token-revoke-expiry") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + uci.token_expiry = timezone.now() + timedelta(days=30) + uci.save(update_fields=["token_expiry"]) + + r = self.client.post(self._revoke_url(), {"key": token.key}, format="json") + self.assertEqual(r.status_code, 204, r.content[:1000]) + + uci.refresh_from_db() + self.assertIsNone(uci.token_expiry) + + def test_revoke_unknown_key_returns_404(self): + r = self.client.post(self._revoke_url(), {"key": "notarealtoken"}, format="json") + self.assertEqual(r.status_code, 404, r.content[:1000]) + + def test_revoke_requires_a_key(self): + for payload in ({}, {"key": ""}, {"key": " "}): + r = self.client.post(self._revoke_url(), payload, format="json") + self.assertEqual(r.status_code, 400, r.content[:1000]) + + def test_revoke_by_key_non_superuser_forbidden(self): + _user, token, _ = self._create_user("api-token-revoke-nonsuperuser") + client = self._client_for(token.key) + + r = client.post(self._revoke_url(), {"key": token.key}, format="json") + self.assertEqual(r.status_code, 403, r.content[:1000]) + + def test_revoke_by_key_unauthenticated_forbidden(self): + _user, token, _ = self._create_user("api-token-revoke-anon") + + r = APIClient().post(self._revoke_url(), {"key": token.key}, format="json") + self.assertIn(r.status_code, (401, 403), r.content[:1000]) + self.assertTrue(Token.objects.filter(key=token.key).exists()) + + # --- expiry enforcement --- + + def test_expired_token_rejected(self): + user, token, _ = self._create_user("api-token-expired") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + uci.token_expiry = timezone.now() - timedelta(days=1) + uci.save(update_fields=["token_expiry"]) + + client = self._client_for(token.key) + r = client.get(reverse("user-list")) + self.assertEqual(r.status_code, 403, r.content[:1000]) + self.assertIn("API token has expired.", r.content.decode("utf-8")) + + def test_unexpired_token_accepted(self): + user, token, _ = self._create_user("api-token-unexpired") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + uci.token_expiry = timezone.now() + timedelta(days=1) + uci.save(update_fields=["token_expiry"]) + + r = self._client_for(token.key).get(reverse("user_profile")) + self.assertEqual(r.status_code, 200, r.content[:1000]) + + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=7) + def test_explicit_expiry_overrides_the_instance_default(self): + user, token, _ = self._create_user("api-token-override") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + explicit = timezone.now() + timedelta(days=365) + uci.token_expiry = explicit + uci.save(update_fields=["token_expiry"]) + + token.refresh_from_db() + self.assertEqual(token_expires_at(token), explicit) + + # --- the instance-wide default applies to every token, however it was minted --- + + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=30) + def test_default_expiry_is_measured_from_token_creation(self): + _user, token, _ = self._create_user("api-token-default-window") + + self.assertFalse(token_is_expired(token)) + expected = token.created + timedelta(days=30) + self.assertEqual(token_expires_at(token), expected) + + def test_enabling_the_default_expires_pre_existing_tokens(self): + """ + Turning the default on is retroactive, and that is deliberate. + + Expiry is evaluated when a token is used, not stamped when it is issued, which is what + makes the control impossible to sidestep by choosing a different minting path. The + consequence is that switching the setting on immediately invalidates every token already + older than the window, including the operator's own. Recovery is via the UI (session + auth), which is unaffected, and the key page issues a fresh token. + """ + _user, token, _ = self._create_user("api-token-preexisting") + self._set_created(token, timezone.now() - timedelta(days=200)) + + # Before the policy exists, an old token is perfectly valid. + self.assertFalse(token_is_expired(token)) + r = self._client_for(token.key).get(reverse("user_profile")) + self.assertEqual(r.status_code, 200, r.content[:1000]) + + with override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=90): + token.refresh_from_db() + self.assertTrue(token_is_expired(token)) + r = self._client_for(token.key).get(reverse("user_profile")) + self.assertEqual(r.status_code, 403, r.content[:1000]) + self.assertIn("API token has expired.", r.content.decode("utf-8")) + + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=30) + def test_default_expiry_rejects_a_token_older_than_the_window(self): + _user, token, _ = self._create_user("api-token-default-aged") + self._set_created(token, timezone.now() - timedelta(days=31)) + + r = self._client_for(token.key).get(reverse("user-list")) + self.assertEqual(r.status_code, 403, r.content[:1000]) + self.assertIn("API token has expired.", r.content.decode("utf-8")) + + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=30) + def test_default_expiry_covers_tokens_minted_by_the_auth_endpoint(self): + """ + Regression: the default must not be bypassable. + + Tokens are minted by three separate paths. If the default were stamped onto the user at + rotation time rather than derived from the token, anyone could obtain a token with no + expiry by going through ``api-token-auth`` instead of a reset. + """ + user, token, password = self._create_user("api-token-authendpoint") + token.delete() + + r = APIClient().post( + reverse("api-token-auth"), {"username": user.username, "password": password}, format="json", + ) + self.assertEqual(r.status_code, 200, r.content[:1000]) + + minted = Token.objects.get(user=user) + self.assertIsNotNone(token_expires_at(minted)) + + self._set_created(minted, timezone.now() - timedelta(days=31)) + r = self._client_for(minted.key).get(reverse("user-list")) + self.assertEqual(r.status_code, 403, r.content[:1000]) + + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=0) + def test_no_expiry_when_default_is_zero(self): + _user, token, _ = self._create_user("api-token-no-expiry") + + self.assertIsNone(token_expires_at(token)) + self.assertFalse(token_is_expired(token)) + + @override_settings(API_TOKEN_DEFAULT_EXPIRY_DAYS=7) + def test_reset_clears_a_stale_override_so_the_new_token_is_usable(self): + """A past override left in place would kill the replacement token immediately.""" + user, _token, _ = self._create_user("api-token-reset-clears") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + uci.token_expiry = timezone.now() - timedelta(days=1) + uci.save(update_fields=["token_expiry"]) + + r = self.client.post("{}{}/reset_api_token/".format(reverse("user-list"), user.id)) + self.assertEqual(r.status_code, 204, r.content[:1000]) + + uci.refresh_from_db() + self.assertIsNone(uci.token_expiry) + + new_token = Token.objects.get(user=user) + self.assertFalse(token_is_expired(new_token)) + self.assertEqual(token_expires_at(new_token), new_token.created + timedelta(days=7)) + + # --- serializer --- + + def test_user_serializer_exposes_token_expiry(self): + user, _, _ = self._create_user("api-token-user-serializer") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + uci.token_expiry = timezone.now() + timedelta(days=14) + uci.save(update_fields=["token_expiry"]) + + r = self.client.get("{}{}/".format(reverse("user-list"), user.id)) + self.assertEqual(r.status_code, 200, r.content[:1000]) + body = r.json() + self.assertIn("token_expiry", body) + self.assertIsNotNone(body["token_expiry"]) + + # --- a user must not be able to lift their own expiry --- + + def test_profile_form_cannot_change_token_expiry(self): + """ + Regression: ``UserContactInfoForm`` is the self-service profile form and its Meta only + excludes ``user``/``slack_user_id``, so a new model field lands on it as editable. Token + expiry is a security control; setting it is reserved for superusers via the API. + """ + user, _token, _ = self._create_user("api-token-selfedit") + uci, _ = UserContactInfo.objects.get_or_create(user=user) + original = timezone.now() - timedelta(days=1) + uci.token_expiry = original + uci.save(update_fields=["token_expiry"]) + + with impersonate(user): + form = UserContactInfoForm(instance=uci, user=user) + self.assertTrue(form.fields["token_expiry"].disabled) + + posted = { + "title": "", "phone_number": "", "cell_number": "", + "twitter_username": "", "github_username": "", "slack_username": "", + "token_expiry": (timezone.now() + timedelta(days=3650)).isoformat(), + } + bound = UserContactInfoForm(data=posted, instance=uci, user=user) + bound.is_valid() + # A disabled field falls back to its initial value; the form round trip drops + # microseconds, so compare to the second and assert the posted value was ignored. + cleaned = bound.cleaned_data.get("token_expiry") + self.assertIsNotNone(cleaned) + self.assertLess(abs((cleaned - original).total_seconds()), 1) + + uci.refresh_from_db() + self.assertEqual(uci.token_expiry, original) From e97be18ab0e7c3a9369149685a2147d414e5aeec Mon Sep 17 00:00:00 2001 From: devGregA Date: Sat, 8 Aug 2026 18:54:48 -0600 Subject: [PATCH 030/110] docs: Exporting SBOMs and VEX (Pro) Documents the new Pro export endpoints: an Asset's dependency inventory as CycloneDX 1.6 / SPDX 2.3, and finding statuses as a CycloneDX VEX document, including the status-to-analysis mapping and current-inventory boundaries. Cross-linked from the Working with SBOMs page. Co-Authored-By: Claude Fable 5 --- .../locations/PRO__exporting_sboms_and_vex.md | 52 +++++++++++++++++++ .../locations/PRO__working_with_sboms.md | 4 ++ 2 files changed, 56 insertions(+) create mode 100644 docs/content/asset_modelling/locations/PRO__exporting_sboms_and_vex.md diff --git a/docs/content/asset_modelling/locations/PRO__exporting_sboms_and_vex.md b/docs/content/asset_modelling/locations/PRO__exporting_sboms_and_vex.md new file mode 100644 index 00000000000..3d7026c9db3 --- /dev/null +++ b/docs/content/asset_modelling/locations/PRO__exporting_sboms_and_vex.md @@ -0,0 +1,52 @@ +--- +title: "Exporting SBOMs and VEX" +description: "Export an Asset's dependency inventory as CycloneDX or SPDX, and its finding statuses as a CycloneDX VEX document" +audience: pro +weight: 7 +--- + +DefectDojo Pro can serialize an Asset's current dependency inventory back out as a standards-compliant SBOM, and its vulnerability triage decisions as a machine-readable VEX document. Both are produced from the same [Dependency Locations](../pro__working_with_sboms/) that SBOM imports and scan findings populate, so whatever your scanners and uploads have accumulated is what the export describes — the documents are consumed by downstream tooling, compliance pipelines, and AI agents, not just humans. + +Both endpoints require **V3 Locations** to be enabled, and respect Asset-level permissions: an Asset the requesting user cannot view returns a 404. + +## Exporting an SBOM + +``` +GET /api/v2/sbom/{asset_id}/ +GET /api/v2/sbom/{asset_id}/?spec=spdx +``` + +| Parameter | Values | Default | +| --- | --- | --- | +| `spec` | `cyclonedx` (CycloneDX 1.6 JSON), `spdx` (SPDX 2.3 JSON) | `cyclonedx` | + +The response is a downloadable JSON document (`Content-Disposition: attachment`). Components carry their Package URL, group/namespace, version, artifact hashes (algorithms each specification supports), and — when the SBOM import recorded one — the license expression for this Asset's use of the component. + +Two current-state boundaries to be aware of: + +- The export describes the Asset's **current** inventory. Version-pinned snapshots (the SBOM of release 5.2 specifically) are on the roadmap alongside Asset versions. +- Imports flatten the dependency graph today, so the exported `dependencies` section declares root → component edges only. + +## Exporting a VEX document + +``` +GET /api/v2/sbom/{asset_id}/vex/ +``` + +The VEX export is a standalone CycloneDX 1.6 document built from the statuses of findings attached to the Asset's dependencies. Component references are Package URLs — the same identifiers the SBOM export uses — so the pair can be consumed together without any reference translation. SPDX has no VEX profile, so this endpoint is CycloneDX-only. + +Statement grouping: one vulnerability entry per (vulnerability ID × analysis state), with every affected component listed under `affects`. Findings without a vulnerability ID are omitted — VEX statements are keyed by vulnerability identifier. + +### How DefectDojo statuses map to VEX analysis + +The status of each **finding ↔ location edge** (where per-location triage lives) drives the analysis: + +| DefectDojo status | VEX `analysis.state` | Notes | +| --- | --- | --- | +| Active | `exploitable` | | +| Risk Accepted | `exploitable` | with `response: ["will_not_fix"]` | +| Out of Scope | `not_affected` | | +| False Positive | `false_positive` | | +| Mitigated | `resolved` | | + +When the same vulnerability × component pair carries conflicting statuses across findings, the **least-resolved status wins** — exporting `resolved` while any observation is still active would be the dangerous direction to be wrong in. diff --git a/docs/content/asset_modelling/locations/PRO__working_with_sboms.md b/docs/content/asset_modelling/locations/PRO__working_with_sboms.md index 2ac1995b8ec..81371158d85 100644 --- a/docs/content/asset_modelling/locations/PRO__working_with_sboms.md +++ b/docs/content/asset_modelling/locations/PRO__working_with_sboms.md @@ -100,6 +100,10 @@ When Locations is enabled, the navigation exposes: - **New Dependency** — Form to create a single library by entering its pURL components manually. - **Findings detail** — A Finding that touches a library shows its Dependency Locations alongside any URL Locations, so you can see *"this CVE affects `log4j-core@2.14.1` on Asset 6 and Asset 9"* in one place. +## Exporting + +The inventory flows back out as well: an Asset's dependencies can be exported as a CycloneDX 1.6 or SPDX 2.3 SBOM, and its finding statuses as a CycloneDX VEX document. See [Exporting SBOMs and VEX](../pro__exporting_sboms_and_vex/). + ## What's Not in the MVP - **SWID Tag SBOM format** — Not parsed. CycloneDX or SPDX is required. From ed550def3ed8ee7d98d2da6eb7677de658be0bf2 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Sun, 9 Aug 2026 20:06:45 -0700 Subject: [PATCH 031/110] feat(parsers): add 39 file-import parsers (with tests, fixtures, and docs) (#15595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(parsers): kube-score, ModelScan, TFLint and Kingfisher Adds four file-import parsers covering Kubernetes manifest scoring, ML model supply chain, IaC linting and secrets detection. Each parser was written against real output from the tool rather than from its documentation. Fixtures are the tool's own output, trimmed and with local paths replaced by generic ones. Severity mapping follows each vendor's own scale: - kube-score grades checks (1 critical, 5 warning, 10 passing) rather than assigning severities; passing and skipped checks are not imported. - ModelScan's CRITICAL/HIGH/MEDIUM/LOW map directly. - TFLint's error/warning/notice map to High/Medium/Info. - Kingfisher has no severity at all. A credential it validated as live is Critical; anything else falls back to the match confidence. Co-Authored-By: Claude Opus 5 * feat(parsers): 2ms and Quark-Engine Adds secrets detection (2ms) and Android behaviour scoring (Quark-Engine). 2ms reports a severity and a CVSS score per rule, both of which are carried across. Its result groups are keyed by a secret id that it reuses for every location the same secret appears in, so the id is recorded in the description rather than in unique_id_from_tool, where it would imply a uniqueness it does not have. The 2ms hashcode fields include description because a single file and line can hold two different secrets -- the sample report does exactly that -- and title, file path and line alone would collapse them into one Finding. Quark-Engine assigns no severity. It reports a confidence in 20% increments, one per detection stage matched, and severity is derived from that: 100% (all APIs plus a confirmed data flow) is High, 80% is Medium. Co-Authored-By: Claude Opus 5 * feat(parsers): CISA ScubaGoggles and ScubaGear Adds the two CISA SCuBA assessment tools: ScubaGoggles for Google Workspace (ScubaResults JSON) and ScubaGear for Microsoft 365 (ActionPlan CSV). Neither reports a severity. Both grade a baseline control by its criticality, where "Shall" is mandatory and "Should" is recommended, so severity is derived from the result and criticality together: a failed Shall is High, a failed Should is Medium, a Warning is Low. Controls whose criticality ends in Not-Implemented are baselines the tool does not yet evaluate. They describe a gap in the tooling rather than in the tenant and are not imported. Both tools render their requirement and detail text for an HTML report, so the markup is stripped before it reaches a Finding. The ScubaGear action plan is written with a UTF-8 byte order mark, which is consumed rather than left in the first column. Co-Authored-By: Claude Opus 5 * chore(parsers): use neutral placeholder addresses in ScubaGoggles fixtures CISA's public sample report uses reserved example domains but with person-like local parts. Replace them so no sample data reads as a real individual. Co-Authored-By: Claude Opus 5 * feat(parsers): kubesec and Cloudsplaining Adds Kubernetes object scoring (kubesec) and AWS IAM policy assessment (Cloudsplaining). kubesec sorts rules into critical, advise and passed buckets rather than assigning severities. Critical maps to High and advise to Low, since advise lists hardening an object does not have yet rather than a weakness it has. Passed rules are not imported. Cloudsplaining raises one Finding per policy and risk category rather than per flagged action: a single permissive policy can flag thousands of actions, and PowerUserAccess in the vendor's own example results yields over three thousand under infrastructure modification alone. The actions are listed in the description. Policies Cloudsplaining marks excluded are kept in its results rather than removed, and are skipped on import. Severity follows Cloudsplaining's own prioritisation, where its CLI treats resource exposure, privilege escalation and data exfiltration as the high priority risks. Co-Authored-By: Claude Opus 5 * feat(parsers): bomber and sbomqs Adds SBOM vulnerability lookup (bomber) and SBOM quality scoring (sbomqs). Both were run locally against a CycloneDX document to capture real output. bomber normalises every provider onto its own scale, which uses MODERATE where most tools use medium. Package coordinates arrive as a purl and are split into component name and version. Advisory ids that are CVEs are attached as vulnerability ids; provider-specific ids such as GHSA references are recorded in vuln_id_from_tool only, so they are not mistaken for CVEs. sbomqs scores document quality rather than searching for vulnerabilities, so its findings describe gaps in an SBOM and carry no CVE or CWE. It has no severity concept: severity comes from how far a feature falls short of its maximum, with an entirely absent element being the most serious. Features sbomqs marks ignored for the run are skipped. Co-Authored-By: Claude Opus 5 * feat(parsers): Pluto and GuardDog Adds deprecated Kubernetes API detection (Pluto) and malicious package detection (GuardDog). Both were run locally to capture real output. Pluto reports two booleans rather than a severity. An object on an API version that has already been removed will not apply to a cluster at that version at all, so removed maps to High and deprecated to Medium. GuardDog splits its rules into threat-* and capability-* families, where a capability records what a package is able to do rather than evidence that it does it. Where GuardDog's risk engine correlates a rule into a scored risk, that risk carries its own low/medium/high severity and takes precedence over the rule family; an uncorrelated capability is imported as Info. The package-level risk_score describes the package as a whole, so it is recorded in the description rather than mapped onto per-finding severity. Co-Authored-By: Claude Opus 5 * feat(parsers): kube-no-trouble (kubent) Adds deprecated Kubernetes API detection from kubent, run locally against manifests to capture real output. kubent reports only deprecated usages and grades none of them, so every item is imported as Medium. Unlike Pluto it carries no removed flag, so severity cannot distinguish an API that is already gone from one merely deprecated; the docs page points at the Pluto parser for that. The release an API is removed in is only stated in prose, as the ruleset name, so it is parsed out of there and recorded in the description. Cluster and Helm collectors must be disabled when scanning files, or kubent will additionally try to reach whatever cluster the ambient kubeconfig points at. Co-Authored-By: Claude Opus 5 * feat(parsers): OpenSSF Scorecard Adds supply chain posture scoring from OpenSSF Scorecard, against real output taken from Scorecard's public API. Scorecard grades each check from 0 to 10 rather than assigning a severity, so severity is derived from how far a check falls short. A score of -1 means the check could not reach a conclusion, usually for want of access or metadata, which is not a failure and is not imported; nor is a check that scored full marks. Scorecard can also emit SARIF, which the existing generic SARIF parser handles. This parser covers the native JSON, which carries the per-check numeric score that SARIF does not. Co-Authored-By: Claude Opus 5 * chore(parsers): use a placeholder address in the Scorecard fixture Scorecard's public API returns a maintainer's real address in the contributor detail for one check. Replace it with a neutral placeholder so no sample data carries a real individual's contact details. Co-Authored-By: Claude Opus 5 * feat(parsers): uv audit Adds Python dependency auditing from 'uv audit', run locally against a locked project to capture real output. uv audit reports advisories without any severity, exactly as pip-audit does, so findings are imported as Medium to match the existing pip-audit parser rather than inventing a scale uv does not publish. Advisories arrive under their own identifier, usually a PYSEC id, with CVE and GHSA identifiers as aliases. Only the CVE aliases become vulnerability ids; the GHSA reference stays in the description so a GHSA is never mistaken for a CVE. uv marks both the audit command and its JSON output as experimental and warns the schema may change without notice. The report's schema version is recorded in each Finding's description so a report from a newer uv is identifiable, and the docs page flags that this parser will need revisiting if the shape changes. Co-Authored-By: Claude Opus 5 * feat(parsers): Ansible Lint, ShellCheck and Fickling Adds configuration-management linting, shell script analysis and Python pickle safety checking. All three were run locally to capture real output. ansible-lint and ShellCheck are general linters rather than dedicated security scanners, and both docs pages say so. Their security-relevant checks are real though -- risky-file-permissions and command-instead-of-shell for Ansible, SC2086 and SC2115 for shell -- so rule categories and levels are preserved on the Finding to let stylistic results be filtered after import. ansible-lint emits the Code Climate severity scale rather than one of its own; ShellCheck's numeric check id is rendered in its documented SCnnnn form and linked. Fickling returns one verdict per file. Each observation behind that verdict becomes its own Finding so a malicious pickle does not collapse into a single result. LIKELY_SAFE is not imported: Fickling's own wording is that it failed to detect overtly unsafe code, not that the file is safe. Its JSON does not record which file was analysed, so Findings carry no path, which the docs page states. Co-Authored-By: Claude Opus 5 * feat(parsers): Regula and OWASP Threat Dragon Adds OPA-based IaC policy evaluation (Regula, run locally) and threat model import (Threat Dragon, from the project's own demo model). Regula reports every rule it evaluated against every resource, PASS results included, so only FAIL results are imported. Its own Critical/High/Medium/Low scale maps directly. Threat Dragon threats already marked Mitigated are imported as mitigated, inactive Findings rather than dropped, so the decisions recorded in the model survive the import. Both schema versions are handled: v1 nests diagram cells under diagramJson and labels them through rendering attributes, v2 carries cells and names directly, and the element name falls back across both rather than assuming a version. Co-Authored-By: Claude Opus 5 * feat(parsers): Safety, Slither, Binwalk and pip-licenses Safety checks Python packages against the PyUp database; built from the project's own committed report. PyUp assigns a severity and CVE only to some advisories, so what is present is mapped and the rest default to Medium rather than inventing a scale. Ignored advisories stay in Safety's report, flagged rather than removed, and are not imported. Slither analyses Solidity, a category with no previous coverage. It reports impact and confidence as separate axes; impact is the severity and confidence is recorded in the description rather than blended into it. Binwalk and pip-licenses are inventory tools rather than vulnerability scanners, and both docs pages lead with that. Binwalk reports what a firmware image contains and assigns no severity, so everything imports as Info. pip-licenses lists every installed distribution as Info, with the single exception of a distribution whose licence it could not determine, which is a compliance gap in its own right and imports as Low. Neither tool decides policy for the organisation. Co-Authored-By: Claude Opus 5 * feat(parsers): Syft SBOM, and ScubaGear per-product JSON reports Syft catalogues packages but never judges them, so every artifact imports as Info inventory and the docs page points at Grype or bomber for the vulnerability half of the job. Licences are read from both the current object shape and the older bare-string one. ScubaGear gains a second scan type for the per-product JSON reports under IndividualReports/, alongside the existing ActionPlan CSV. The two artifacts differ in more than format: the action plan holds only what failed, while the JSON holds the full pass and fail set. ScubaGear writes the CSV as UTF-8 with a byte order mark and the JSON as UTF-16, so the encoding is detected from the mark rather than requiring the user to convert anything first. Co-Authored-By: Claude Opus 5 * feat(parsers): capa Adds capability identification for executables, run locally against a real PE with the upstream capa-rules set. capa reports what a binary is able to do and maps each capability to MITRE ATT&CK and MBC. It reports no vulnerabilities and assigns no severity, so capabilities import as Info with their techniques recorded for triage. capa's library and subscope rules exist only to build other matches and are not capabilities of the sample, so they are skipped. Co-Authored-By: Claude Opus 5 * feat(parsers): dockerfile_lint, and the ScubaGoggles action plan ScubaGoggles gains a second scan type for its ActionPlan CSV, mirroring the ScubaGear pair: the JSON holds the full pass and fail set, the CSV holds only what failed. dockerfile_lint is a general Dockerfile linter whose error/warn/info buckets double as its severity scale, and whose security-relevant rules cover root users, the floating latest tag and remote archive fetches. Two behaviours from its real output are handled explicitly: rules that apply to the file rather than a line are reported with line -1, which becomes no line rather than a nonsensical one, and rules that ship without a label take their title from the message. Co-Authored-By: Claude Opus 5 * feat(parsers): Dodgy and licensecheck Dodgy searches source for hardcoded secrets — cloud keys, passwords, private key material, connection strings. It assigns no severity, but a match is a credential in source control, so findings import as High. Run locally to capture real output. licensecheck evaluates each dependency's licence for compatibility with the project's own, which is a step beyond a plain inventory: an incompatible licence can violate the project's distribution terms and imports as High, while compatible dependencies are informational inventory. The docs page is explicit that licensecheck's compatibility matrix, not DefectDojo, makes the call. Co-Authored-By: Claude Opus 5 * feat(parsers): Noir and Grant Noir discovers the API attack surface of a codebase from source — every route, method and parameter. It reports endpoints rather than vulnerabilities, so each imports as Info inventory; endpoints Noir tags as security-relevant (an admin route, a sensitive parameter) import as Low so they surface. Run locally against a Flask app to capture real output. Grant checks the licences in an SBOM against a policy. A denied package — a forbidden licence, or none at all — is a compliance defect and imports as High; allowed packages are the compliant remainder and are dropped. Run locally against a real Syft SBOM. Co-Authored-By: Claude Opus 5 * feat(parsers): CFRipper and Grype CFRipper audits CloudFormation for security misconfiguration — wildcard IAM, privilege escalation, public resources — which is distinct from the syntax linting the existing cfn-lint and cfn-nag parsers cover. Its risk_value is the severity. Run locally to capture real output. Grype scans SBOMs, images and directories for known vulnerabilities and had no parser despite being a widely used scanner. Grype's own severity is used, and the CVE, CWE, highest CVSS base score and fixed version are carried across where present; a GHSA primary id contributes any CVE it is aliased to as the vulnerability id. Run locally against a directory to capture real output. Co-Authored-By: Claude Opus 5 * feat(parsers): Tartufo and Prospector Tartufo scans git history for secrets, so each finding records the commit and branch that introduced the string, not just the file. A secret in history is High: rewriting history does not undo the exposure, so it must be rotated. Its signature is a stable per-match hash, used as the dedupe key. Run locally against a throwaway git repo. Prospector aggregates several Python analysis tools. Rather than flatten them, the parser weights by source so a hardcoded secret from dodgy or bandit is High while pylint and pyflakes style noise stays Low, and the originating tool is recorded so results can be filtered after import. Run locally to capture real multi-tool output. Co-Authored-By: Claude Opus 5 * feat(parsers): Gixy Adds nginx configuration security analysis, a category with no previous coverage. Gixy flags HTTP splitting, host header and referrer validation issues, and version disclosure. Its own HIGH/MEDIUM/LOW severity is used and the offending config snippet is included. Run locally against an nginx.conf to capture real output. Co-Authored-By: Claude Opus 5 * feat(parsers): Python Taint (pyt) Adds taint-tracking analysis for Python. pyt follows untrusted input from a source to a dangerous sink rather than matching patterns, so a result is a completed exploitable flow, not a hint — findings import as High. The source, sink and propagation trace are recorded, and the finding anchors on the sink line. Run locally against a Flask app to capture real output. Co-Authored-By: Claude Opus 5 * feat(parsers): Ruff; drop duplicate Grype parser Adds Ruff, whose flake8-bandit 'S' ruleset covers Python security issues (shell injection, weak hashes, hardcoded passwords). A Ruff run can mix security with style, so the parser weights S-codes to Medium and the rest to Low, the same approach the Prospector parser takes. This reads Ruff's own JSON and is distinct from the Bandit parser. Run locally to capture real output. Also removes the Grype parser added earlier in this branch: DefectDojo already ships an anchore_grype parser that reads the same 'grype -o json' output, so the new one was a functional duplicate. Grype is an Anchore tool and the two parse an identical format. Also scrubs rule-author and advisory contact emails, and local build paths, that capa/syft embed in their real output, from those fixtures. Co-Authored-By: Claude Opus 5 * chore(parsers): drop local scanner config from Syft fixtures Syft echoes its runtime configuration under descriptor.configuration, including absolute paths to the local module and package caches. Remove that block from the fixtures; it is not finding data and leaked home-directory paths. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: devGregA Co-authored-by: Claude Opus 5 --- .../parsers/file/ansible_lint.md | 44 + .../supported_tools/parsers/file/binwalk.md | 29 + .../supported_tools/parsers/file/bomber.md | 38 + .../supported_tools/parsers/file/capa.md | 30 + .../supported_tools/parsers/file/cfripper.md | 36 + .../parsers/file/cloudsplaining.md | 46 + .../parsers/file/dockerfile_lint.md | 37 + .../supported_tools/parsers/file/dodgy.md | 29 + .../supported_tools/parsers/file/fickling.md | 47 + .../supported_tools/parsers/file/gixy.md | 34 + .../supported_tools/parsers/file/grant.md | 35 + .../supported_tools/parsers/file/guarddog.md | 47 + .../parsers/file/kingfisher.md | 40 + .../parsers/file/kube_score.md | 35 + .../supported_tools/parsers/file/kubent.md | 33 + .../supported_tools/parsers/file/kubesec.md | 37 + .../parsers/file/licensecheck.md | 38 + .../supported_tools/parsers/file/modelscan.md | 37 + .../supported_tools/parsers/file/noir.md | 34 + .../parsers/file/ossf_scorecard.md | 48 + .../parsers/file/pip_licenses.md | 39 + .../supported_tools/parsers/file/pluto.md | 38 + .../parsers/file/prospector.md | 35 + .../parsers/file/python_taint.md | 29 + .../parsers/file/quark_engine.md | 42 + .../supported_tools/parsers/file/regula.md | 38 + .../supported_tools/parsers/file/ruff.md | 39 + .../supported_tools/parsers/file/safety.md | 39 + .../supported_tools/parsers/file/sbomqs.md | 36 + .../supported_tools/parsers/file/scubagear.md | 50 + .../parsers/file/scubagoggles.md | 47 + .../parsers/file/shellcheck.md | 40 + .../supported_tools/parsers/file/slither.md | 40 + .../supported_tools/parsers/file/syft.md | 34 + .../supported_tools/parsers/file/tartufo.md | 26 + .../supported_tools/parsers/file/tflint.md | 34 + .../parsers/file/threat_dragon.md | 40 + .../supported_tools/parsers/file/two_ms.md | 42 + .../supported_tools/parsers/file/uv_audit.md | 38 + dojo/settings/settings.dist.py | 82 + dojo/tools/ansible_lint/__init__.py | 0 dojo/tools/ansible_lint/parser.py | 79 + dojo/tools/binwalk/__init__.py | 0 dojo/tools/binwalk/parser.py | 66 + dojo/tools/bomber/__init__.py | 0 dojo/tools/bomber/parser.py | 87 + dojo/tools/capa/__init__.py | 0 dojo/tools/capa/parser.py | 84 + dojo/tools/cfripper/__init__.py | 0 dojo/tools/cfripper/parser.py | 63 + dojo/tools/cloudsplaining/__init__.py | 0 dojo/tools/cloudsplaining/parser.py | 88 + dojo/tools/dockerfile_lint/__init__.py | 0 dojo/tools/dockerfile_lint/parser.py | 74 + dojo/tools/dodgy/__init__.py | 0 dojo/tools/dodgy/parser.py | 55 + dojo/tools/fickling/__init__.py | 0 dojo/tools/fickling/parser.py | 83 + dojo/tools/gixy/__init__.py | 0 dojo/tools/gixy/parser.py | 62 + dojo/tools/grant/__init__.py | 0 dojo/tools/grant/parser.py | 79 + dojo/tools/guarddog/__init__.py | 0 dojo/tools/guarddog/parser.py | 104 + dojo/tools/kingfisher/__init__.py | 0 dojo/tools/kingfisher/parser.py | 79 + dojo/tools/kube_score/__init__.py | 0 dojo/tools/kube_score/parser.py | 86 + dojo/tools/kubent/__init__.py | 0 dojo/tools/kubent/parser.py | 79 + dojo/tools/kubesec/__init__.py | 0 dojo/tools/kubesec/parser.py | 71 + dojo/tools/licensecheck/__init__.py | 0 dojo/tools/licensecheck/parser.py | 70 + dojo/tools/modelscan/__init__.py | 0 dojo/tools/modelscan/parser.py | 70 + dojo/tools/noir/__init__.py | 0 dojo/tools/noir/parser.py | 74 + dojo/tools/ossf_scorecard/__init__.py | 0 dojo/tools/ossf_scorecard/parser.py | 88 + dojo/tools/pip_licenses/__init__.py | 0 dojo/tools/pip_licenses/parser.py | 64 + dojo/tools/pluto/__init__.py | 0 dojo/tools/pluto/parser.py | 81 + dojo/tools/prospector/__init__.py | 0 dojo/tools/prospector/parser.py | 62 + dojo/tools/python_taint/__init__.py | 0 dojo/tools/python_taint/parser.py | 68 + dojo/tools/quark_engine/__init__.py | 0 dojo/tools/quark_engine/parser.py | 86 + dojo/tools/regula/__init__.py | 0 dojo/tools/regula/parser.py | 86 + dojo/tools/ruff/__init__.py | 0 dojo/tools/ruff/parser.py | 65 + dojo/tools/safety/__init__.py | 0 dojo/tools/safety/parser.py | 90 + dojo/tools/sbomqs/__init__.py | 0 dojo/tools/sbomqs/parser.py | 91 + dojo/tools/scubagear/__init__.py | 0 dojo/tools/scubagear/parser.py | 129 ++ dojo/tools/scubagoggles/__init__.py | 0 dojo/tools/scubagoggles/parser.py | 125 ++ dojo/tools/shellcheck/__init__.py | 0 dojo/tools/shellcheck/parser.py | 70 + dojo/tools/slither/__init__.py | 0 dojo/tools/slither/parser.py | 82 + dojo/tools/syft/__init__.py | 0 dojo/tools/syft/parser.py | 81 + dojo/tools/tartufo/__init__.py | 0 dojo/tools/tartufo/parser.py | 66 + dojo/tools/tflint/__init__.py | 0 dojo/tools/tflint/parser.py | 65 + dojo/tools/threat_dragon/__init__.py | 0 dojo/tools/threat_dragon/parser.py | 104 + dojo/tools/two_ms/__init__.py | 0 dojo/tools/two_ms/parser.py | 77 + dojo/tools/uv_audit/__init__.py | 0 dojo/tools/uv_audit/parser.py | 80 + .../scans/ansible_lint/many_findings.json | 346 +++ unittests/scans/ansible_lint/no_findings.json | 1 + unittests/scans/ansible_lint/one_finding.json | 22 + unittests/scans/binwalk/many_findings.json | 50 + unittests/scans/binwalk/no_findings.json | 9 + unittests/scans/binwalk/one_finding.json | 20 + unittests/scans/bomber/many_findings.json | 78 + unittests/scans/bomber/no_findings.json | 24 + unittests/scans/bomber/one_finding.json | 38 + unittests/scans/capa/many_findings.json | 1952 +++++++++++++++++ unittests/scans/capa/no_findings.json | 922 ++++++++ unittests/scans/capa/one_finding.json | 1038 +++++++++ unittests/scans/cfripper/many_findings.json | 92 + unittests/scans/cfripper/no_findings.json | 4 + unittests/scans/cfripper/one_finding.json | 34 + .../scans/cloudsplaining/many_findings.json | 110 + .../scans/cloudsplaining/no_findings.json | 5 + .../scans/cloudsplaining/one_finding.json | 13 + .../scans/dockerfile_lint/many_findings.json | 63 + .../scans/dockerfile_lint/no_findings.json | 15 + .../scans/dockerfile_lint/one_finding.json | 29 + unittests/scans/dodgy/many_findings.json | 28 + unittests/scans/dodgy/no_findings.json | 3 + unittests/scans/dodgy/one_finding.json | 10 + unittests/scans/fickling/many_findings.json | 10 + unittests/scans/fickling/no_findings.json | 5 + unittests/scans/fickling/one_finding.json | 9 + unittests/scans/gixy/many_findings.json | 46 + unittests/scans/gixy/no_findings.json | 1 + unittests/scans/gixy/one_finding.json | 22 + unittests/scans/grant/many_findings.json | 217 ++ unittests/scans/grant/no_findings.json | 63 + unittests/scans/grant/one_finding.json | 74 + unittests/scans/guarddog/many_findings.json | 74 + unittests/scans/guarddog/no_findings.json | 24 + unittests/scans/guarddog/one_finding.json | 50 + unittests/scans/kingfisher/many_findings.json | 83 + unittests/scans/kingfisher/no_findings.json | 19 + unittests/scans/kingfisher/one_finding.json | 41 + unittests/scans/kube_score/empty.json | 1 + unittests/scans/kube_score/many_findings.json | 257 +++ unittests/scans/kube_score/no_findings.json | 72 + unittests/scans/kube_score/one_finding.json | 53 + unittests/scans/kubent/many_findings.json | 29 + unittests/scans/kubent/no_findings.json | 1 + unittests/scans/kubent/one_finding.json | 11 + unittests/scans/kubesec/many_findings.json | 202 ++ unittests/scans/kubesec/no_findings.json | 55 + unittests/scans/kubesec/one_finding.json | 19 + .../scans/licensecheck/many_findings.json | 120 + unittests/scans/licensecheck/no_findings.json | 9 + unittests/scans/licensecheck/one_finding.json | 40 + unittests/scans/modelscan/many_findings.json | 41 + unittests/scans/modelscan/no_findings.json | 24 + unittests/scans/modelscan/one_finding.json | 33 + unittests/scans/noir/many_findings.json | 88 + unittests/scans/noir/no_findings.json | 4 + unittests/scans/noir/one_finding.json | 31 + .../scans/ossf_scorecard/many_findings.json | 188 ++ .../scans/ossf_scorecard/no_findings.json | 180 ++ .../scans/ossf_scorecard/one_finding.json | 106 + .../scans/pip_licenses/many_findings.json | 37 + unittests/scans/pip_licenses/no_findings.json | 1 + unittests/scans/pip_licenses/one_finding.json | 7 + unittests/scans/pluto/many_findings.json | 55 + unittests/scans/pluto/no_findings.json | 3 + unittests/scans/pluto/one_finding.json | 21 + unittests/scans/prospector/many_findings.json | 93 + unittests/scans/prospector/no_findings.json | 21 + unittests/scans/prospector/one_finding.json | 35 + .../scans/python_taint/many_findings.json | 49 + unittests/scans/python_taint/no_findings.json | 4 + unittests/scans/python_taint/one_finding.json | 27 + .../scans/quark_engine/many_findings.json | 459 ++++ unittests/scans/quark_engine/no_findings.json | 8 + unittests/scans/quark_engine/one_finding.json | 161 ++ unittests/scans/regula/many_findings.json | 416 ++++ unittests/scans/regula/no_findings.json | 152 ++ unittests/scans/regula/one_finding.json | 74 + unittests/scans/ruff/many_findings.json | 199 ++ unittests/scans/ruff/no_findings.json | 1 + unittests/scans/ruff/one_finding.json | 21 + unittests/scans/safety/many_findings.json | 220 ++ unittests/scans/safety/no_findings.json | 105 + unittests/scans/safety/one_finding.json | 144 ++ unittests/scans/sbomqs/many_findings.json | 113 + unittests/scans/sbomqs/no_findings.json | 185 ++ unittests/scans/sbomqs/one_finding.json | 49 + unittests/scans/scubagear/many_findings.csv | 16 + unittests/scans/scubagear/no_findings.csv | 1 + unittests/scans/scubagear/one_finding.csv | 2 + .../scans/scubagear/report_many_findings.json | Bin 0 -> 89062 bytes .../scans/scubagear/report_no_findings.json | Bin 0 -> 47658 bytes .../scans/scubagear/report_one_finding.json | Bin 0 -> 7614 bytes .../scubagoggles/actionplan_many_findings.csv | 303 +++ .../scubagoggles/actionplan_no_findings.csv | 1 + .../scubagoggles/actionplan_one_finding.csv | 6 + .../scans/scubagoggles/many_findings.json | 374 ++++ unittests/scans/scubagoggles/no_findings.json | 83 + unittests/scans/scubagoggles/one_finding.json | 29 + unittests/scans/shellcheck/many_findings.json | 109 + unittests/scans/shellcheck/no_findings.json | 1 + unittests/scans/shellcheck/one_finding.json | 34 + unittests/scans/slither/many_findings.json | 705 ++++++ unittests/scans/slither/no_findings.json | 7 + unittests/scans/slither/one_finding.json | 260 +++ unittests/scans/syft/many_findings.json | 1033 +++++++++ unittests/scans/syft/no_findings.json | 17 + unittests/scans/syft/one_finding.json | 48 + unittests/scans/tartufo/many_findings.json | 33 + unittests/scans/tartufo/no_findings.json | 10 + unittests/scans/tartufo/one_finding.json | 22 + unittests/scans/tflint/many_findings.json | 115 + unittests/scans/tflint/no_findings.json | 4 + unittests/scans/tflint/one_finding.json | 27 + .../scans/threat_dragon/many_findings.json | 952 ++++++++ .../scans/threat_dragon/no_findings.json | 128 ++ .../scans/threat_dragon/one_finding.json | 75 + unittests/scans/two_ms/many_findings.json | 193 ++ unittests/scans/two_ms/no_findings.json | 5 + unittests/scans/two_ms/one_finding.json | 31 + unittests/scans/uv_audit/many_findings.json | 133 ++ unittests/scans/uv_audit/no_findings.json | 12 + unittests/scans/uv_audit/one_finding.json | 33 + unittests/tools/test_ansible_lint_parser.py | 63 + unittests/tools/test_binwalk_parser.py | 43 + unittests/tools/test_bomber_parser.py | 66 + unittests/tools/test_capa_parser.py | 65 + unittests/tools/test_cfripper_parser.py | 40 + unittests/tools/test_cloudsplaining_parser.py | 75 + .../tools/test_dockerfile_lint_parser.py | 48 + unittests/tools/test_dodgy_parser.py | 40 + unittests/tools/test_fickling_parser.py | 56 + unittests/tools/test_gixy_parser.py | 42 + unittests/tools/test_grant_parser.py | 43 + unittests/tools/test_guarddog_parser.py | 61 + unittests/tools/test_kingfisher_parser.py | 50 + unittests/tools/test_kube_score_parser.py | 59 + unittests/tools/test_kubent_parser.py | 54 + unittests/tools/test_kubesec_parser.py | 53 + unittests/tools/test_licensecheck_parser.py | 44 + unittests/tools/test_modelscan_parser.py | 46 + unittests/tools/test_noir_parser.py | 45 + unittests/tools/test_ossf_scorecard_parser.py | 56 + unittests/tools/test_pip_licenses_parser.py | 45 + unittests/tools/test_pluto_parser.py | 51 + unittests/tools/test_prospector_parser.py | 45 + unittests/tools/test_python_taint_parser.py | 39 + unittests/tools/test_quark_engine_parser.py | 54 + unittests/tools/test_regula_parser.py | 51 + unittests/tools/test_ruff_parser.py | 51 + unittests/tools/test_safety_parser.py | 46 + unittests/tools/test_sbomqs_parser.py | 52 + unittests/tools/test_scubagear_parser.py | 96 + unittests/tools/test_scubagoggles_parser.py | 98 + unittests/tools/test_shellcheck_parser.py | 53 + unittests/tools/test_slither_parser.py | 57 + unittests/tools/test_syft_parser.py | 48 + unittests/tools/test_tartufo_parser.py | 41 + unittests/tools/test_tflint_parser.py | 50 + unittests/tools/test_threat_dragon_parser.py | 61 + unittests/tools/test_two_ms_parser.py | 64 + unittests/tools/test_uv_audit_parser.py | 68 + 281 files changed, 21464 insertions(+) create mode 100644 docs/content/supported_tools/parsers/file/ansible_lint.md create mode 100644 docs/content/supported_tools/parsers/file/binwalk.md create mode 100644 docs/content/supported_tools/parsers/file/bomber.md create mode 100644 docs/content/supported_tools/parsers/file/capa.md create mode 100644 docs/content/supported_tools/parsers/file/cfripper.md create mode 100644 docs/content/supported_tools/parsers/file/cloudsplaining.md create mode 100644 docs/content/supported_tools/parsers/file/dockerfile_lint.md create mode 100644 docs/content/supported_tools/parsers/file/dodgy.md create mode 100644 docs/content/supported_tools/parsers/file/fickling.md create mode 100644 docs/content/supported_tools/parsers/file/gixy.md create mode 100644 docs/content/supported_tools/parsers/file/grant.md create mode 100644 docs/content/supported_tools/parsers/file/guarddog.md create mode 100644 docs/content/supported_tools/parsers/file/kingfisher.md create mode 100644 docs/content/supported_tools/parsers/file/kube_score.md create mode 100644 docs/content/supported_tools/parsers/file/kubent.md create mode 100644 docs/content/supported_tools/parsers/file/kubesec.md create mode 100644 docs/content/supported_tools/parsers/file/licensecheck.md create mode 100644 docs/content/supported_tools/parsers/file/modelscan.md create mode 100644 docs/content/supported_tools/parsers/file/noir.md create mode 100644 docs/content/supported_tools/parsers/file/ossf_scorecard.md create mode 100644 docs/content/supported_tools/parsers/file/pip_licenses.md create mode 100644 docs/content/supported_tools/parsers/file/pluto.md create mode 100644 docs/content/supported_tools/parsers/file/prospector.md create mode 100644 docs/content/supported_tools/parsers/file/python_taint.md create mode 100644 docs/content/supported_tools/parsers/file/quark_engine.md create mode 100644 docs/content/supported_tools/parsers/file/regula.md create mode 100644 docs/content/supported_tools/parsers/file/ruff.md create mode 100644 docs/content/supported_tools/parsers/file/safety.md create mode 100644 docs/content/supported_tools/parsers/file/sbomqs.md create mode 100644 docs/content/supported_tools/parsers/file/scubagear.md create mode 100644 docs/content/supported_tools/parsers/file/scubagoggles.md create mode 100644 docs/content/supported_tools/parsers/file/shellcheck.md create mode 100644 docs/content/supported_tools/parsers/file/slither.md create mode 100644 docs/content/supported_tools/parsers/file/syft.md create mode 100644 docs/content/supported_tools/parsers/file/tartufo.md create mode 100644 docs/content/supported_tools/parsers/file/tflint.md create mode 100644 docs/content/supported_tools/parsers/file/threat_dragon.md create mode 100644 docs/content/supported_tools/parsers/file/two_ms.md create mode 100644 docs/content/supported_tools/parsers/file/uv_audit.md create mode 100644 dojo/tools/ansible_lint/__init__.py create mode 100644 dojo/tools/ansible_lint/parser.py create mode 100644 dojo/tools/binwalk/__init__.py create mode 100644 dojo/tools/binwalk/parser.py create mode 100644 dojo/tools/bomber/__init__.py create mode 100644 dojo/tools/bomber/parser.py create mode 100644 dojo/tools/capa/__init__.py create mode 100644 dojo/tools/capa/parser.py create mode 100644 dojo/tools/cfripper/__init__.py create mode 100644 dojo/tools/cfripper/parser.py create mode 100644 dojo/tools/cloudsplaining/__init__.py create mode 100644 dojo/tools/cloudsplaining/parser.py create mode 100644 dojo/tools/dockerfile_lint/__init__.py create mode 100644 dojo/tools/dockerfile_lint/parser.py create mode 100644 dojo/tools/dodgy/__init__.py create mode 100644 dojo/tools/dodgy/parser.py create mode 100644 dojo/tools/fickling/__init__.py create mode 100644 dojo/tools/fickling/parser.py create mode 100644 dojo/tools/gixy/__init__.py create mode 100644 dojo/tools/gixy/parser.py create mode 100644 dojo/tools/grant/__init__.py create mode 100644 dojo/tools/grant/parser.py create mode 100644 dojo/tools/guarddog/__init__.py create mode 100644 dojo/tools/guarddog/parser.py create mode 100644 dojo/tools/kingfisher/__init__.py create mode 100644 dojo/tools/kingfisher/parser.py create mode 100644 dojo/tools/kube_score/__init__.py create mode 100644 dojo/tools/kube_score/parser.py create mode 100644 dojo/tools/kubent/__init__.py create mode 100644 dojo/tools/kubent/parser.py create mode 100644 dojo/tools/kubesec/__init__.py create mode 100644 dojo/tools/kubesec/parser.py create mode 100644 dojo/tools/licensecheck/__init__.py create mode 100644 dojo/tools/licensecheck/parser.py create mode 100644 dojo/tools/modelscan/__init__.py create mode 100644 dojo/tools/modelscan/parser.py create mode 100644 dojo/tools/noir/__init__.py create mode 100644 dojo/tools/noir/parser.py create mode 100644 dojo/tools/ossf_scorecard/__init__.py create mode 100644 dojo/tools/ossf_scorecard/parser.py create mode 100644 dojo/tools/pip_licenses/__init__.py create mode 100644 dojo/tools/pip_licenses/parser.py create mode 100644 dojo/tools/pluto/__init__.py create mode 100644 dojo/tools/pluto/parser.py create mode 100644 dojo/tools/prospector/__init__.py create mode 100644 dojo/tools/prospector/parser.py create mode 100644 dojo/tools/python_taint/__init__.py create mode 100644 dojo/tools/python_taint/parser.py create mode 100644 dojo/tools/quark_engine/__init__.py create mode 100644 dojo/tools/quark_engine/parser.py create mode 100644 dojo/tools/regula/__init__.py create mode 100644 dojo/tools/regula/parser.py create mode 100644 dojo/tools/ruff/__init__.py create mode 100644 dojo/tools/ruff/parser.py create mode 100644 dojo/tools/safety/__init__.py create mode 100644 dojo/tools/safety/parser.py create mode 100644 dojo/tools/sbomqs/__init__.py create mode 100644 dojo/tools/sbomqs/parser.py create mode 100644 dojo/tools/scubagear/__init__.py create mode 100644 dojo/tools/scubagear/parser.py create mode 100644 dojo/tools/scubagoggles/__init__.py create mode 100644 dojo/tools/scubagoggles/parser.py create mode 100644 dojo/tools/shellcheck/__init__.py create mode 100644 dojo/tools/shellcheck/parser.py create mode 100644 dojo/tools/slither/__init__.py create mode 100644 dojo/tools/slither/parser.py create mode 100644 dojo/tools/syft/__init__.py create mode 100644 dojo/tools/syft/parser.py create mode 100644 dojo/tools/tartufo/__init__.py create mode 100644 dojo/tools/tartufo/parser.py create mode 100644 dojo/tools/tflint/__init__.py create mode 100644 dojo/tools/tflint/parser.py create mode 100644 dojo/tools/threat_dragon/__init__.py create mode 100644 dojo/tools/threat_dragon/parser.py create mode 100644 dojo/tools/two_ms/__init__.py create mode 100644 dojo/tools/two_ms/parser.py create mode 100644 dojo/tools/uv_audit/__init__.py create mode 100644 dojo/tools/uv_audit/parser.py create mode 100644 unittests/scans/ansible_lint/many_findings.json create mode 100644 unittests/scans/ansible_lint/no_findings.json create mode 100644 unittests/scans/ansible_lint/one_finding.json create mode 100644 unittests/scans/binwalk/many_findings.json create mode 100644 unittests/scans/binwalk/no_findings.json create mode 100644 unittests/scans/binwalk/one_finding.json create mode 100644 unittests/scans/bomber/many_findings.json create mode 100644 unittests/scans/bomber/no_findings.json create mode 100644 unittests/scans/bomber/one_finding.json create mode 100644 unittests/scans/capa/many_findings.json create mode 100644 unittests/scans/capa/no_findings.json create mode 100644 unittests/scans/capa/one_finding.json create mode 100644 unittests/scans/cfripper/many_findings.json create mode 100644 unittests/scans/cfripper/no_findings.json create mode 100644 unittests/scans/cfripper/one_finding.json create mode 100644 unittests/scans/cloudsplaining/many_findings.json create mode 100644 unittests/scans/cloudsplaining/no_findings.json create mode 100644 unittests/scans/cloudsplaining/one_finding.json create mode 100644 unittests/scans/dockerfile_lint/many_findings.json create mode 100644 unittests/scans/dockerfile_lint/no_findings.json create mode 100644 unittests/scans/dockerfile_lint/one_finding.json create mode 100644 unittests/scans/dodgy/many_findings.json create mode 100644 unittests/scans/dodgy/no_findings.json create mode 100644 unittests/scans/dodgy/one_finding.json create mode 100644 unittests/scans/fickling/many_findings.json create mode 100644 unittests/scans/fickling/no_findings.json create mode 100644 unittests/scans/fickling/one_finding.json create mode 100644 unittests/scans/gixy/many_findings.json create mode 100644 unittests/scans/gixy/no_findings.json create mode 100644 unittests/scans/gixy/one_finding.json create mode 100644 unittests/scans/grant/many_findings.json create mode 100644 unittests/scans/grant/no_findings.json create mode 100644 unittests/scans/grant/one_finding.json create mode 100644 unittests/scans/guarddog/many_findings.json create mode 100644 unittests/scans/guarddog/no_findings.json create mode 100644 unittests/scans/guarddog/one_finding.json create mode 100644 unittests/scans/kingfisher/many_findings.json create mode 100644 unittests/scans/kingfisher/no_findings.json create mode 100644 unittests/scans/kingfisher/one_finding.json create mode 100644 unittests/scans/kube_score/empty.json create mode 100644 unittests/scans/kube_score/many_findings.json create mode 100644 unittests/scans/kube_score/no_findings.json create mode 100644 unittests/scans/kube_score/one_finding.json create mode 100644 unittests/scans/kubent/many_findings.json create mode 100644 unittests/scans/kubent/no_findings.json create mode 100644 unittests/scans/kubent/one_finding.json create mode 100644 unittests/scans/kubesec/many_findings.json create mode 100644 unittests/scans/kubesec/no_findings.json create mode 100644 unittests/scans/kubesec/one_finding.json create mode 100644 unittests/scans/licensecheck/many_findings.json create mode 100644 unittests/scans/licensecheck/no_findings.json create mode 100644 unittests/scans/licensecheck/one_finding.json create mode 100644 unittests/scans/modelscan/many_findings.json create mode 100644 unittests/scans/modelscan/no_findings.json create mode 100644 unittests/scans/modelscan/one_finding.json create mode 100644 unittests/scans/noir/many_findings.json create mode 100644 unittests/scans/noir/no_findings.json create mode 100644 unittests/scans/noir/one_finding.json create mode 100644 unittests/scans/ossf_scorecard/many_findings.json create mode 100644 unittests/scans/ossf_scorecard/no_findings.json create mode 100644 unittests/scans/ossf_scorecard/one_finding.json create mode 100644 unittests/scans/pip_licenses/many_findings.json create mode 100644 unittests/scans/pip_licenses/no_findings.json create mode 100644 unittests/scans/pip_licenses/one_finding.json create mode 100644 unittests/scans/pluto/many_findings.json create mode 100644 unittests/scans/pluto/no_findings.json create mode 100644 unittests/scans/pluto/one_finding.json create mode 100644 unittests/scans/prospector/many_findings.json create mode 100644 unittests/scans/prospector/no_findings.json create mode 100644 unittests/scans/prospector/one_finding.json create mode 100644 unittests/scans/python_taint/many_findings.json create mode 100644 unittests/scans/python_taint/no_findings.json create mode 100644 unittests/scans/python_taint/one_finding.json create mode 100644 unittests/scans/quark_engine/many_findings.json create mode 100644 unittests/scans/quark_engine/no_findings.json create mode 100644 unittests/scans/quark_engine/one_finding.json create mode 100644 unittests/scans/regula/many_findings.json create mode 100644 unittests/scans/regula/no_findings.json create mode 100644 unittests/scans/regula/one_finding.json create mode 100644 unittests/scans/ruff/many_findings.json create mode 100644 unittests/scans/ruff/no_findings.json create mode 100644 unittests/scans/ruff/one_finding.json create mode 100644 unittests/scans/safety/many_findings.json create mode 100644 unittests/scans/safety/no_findings.json create mode 100644 unittests/scans/safety/one_finding.json create mode 100644 unittests/scans/sbomqs/many_findings.json create mode 100644 unittests/scans/sbomqs/no_findings.json create mode 100644 unittests/scans/sbomqs/one_finding.json create mode 100644 unittests/scans/scubagear/many_findings.csv create mode 100644 unittests/scans/scubagear/no_findings.csv create mode 100644 unittests/scans/scubagear/one_finding.csv create mode 100644 unittests/scans/scubagear/report_many_findings.json create mode 100644 unittests/scans/scubagear/report_no_findings.json create mode 100644 unittests/scans/scubagear/report_one_finding.json create mode 100644 unittests/scans/scubagoggles/actionplan_many_findings.csv create mode 100644 unittests/scans/scubagoggles/actionplan_no_findings.csv create mode 100644 unittests/scans/scubagoggles/actionplan_one_finding.csv create mode 100644 unittests/scans/scubagoggles/many_findings.json create mode 100644 unittests/scans/scubagoggles/no_findings.json create mode 100644 unittests/scans/scubagoggles/one_finding.json create mode 100644 unittests/scans/shellcheck/many_findings.json create mode 100644 unittests/scans/shellcheck/no_findings.json create mode 100644 unittests/scans/shellcheck/one_finding.json create mode 100644 unittests/scans/slither/many_findings.json create mode 100644 unittests/scans/slither/no_findings.json create mode 100644 unittests/scans/slither/one_finding.json create mode 100644 unittests/scans/syft/many_findings.json create mode 100644 unittests/scans/syft/no_findings.json create mode 100644 unittests/scans/syft/one_finding.json create mode 100644 unittests/scans/tartufo/many_findings.json create mode 100644 unittests/scans/tartufo/no_findings.json create mode 100644 unittests/scans/tartufo/one_finding.json create mode 100644 unittests/scans/tflint/many_findings.json create mode 100644 unittests/scans/tflint/no_findings.json create mode 100644 unittests/scans/tflint/one_finding.json create mode 100644 unittests/scans/threat_dragon/many_findings.json create mode 100644 unittests/scans/threat_dragon/no_findings.json create mode 100644 unittests/scans/threat_dragon/one_finding.json create mode 100644 unittests/scans/two_ms/many_findings.json create mode 100644 unittests/scans/two_ms/no_findings.json create mode 100644 unittests/scans/two_ms/one_finding.json create mode 100644 unittests/scans/uv_audit/many_findings.json create mode 100644 unittests/scans/uv_audit/no_findings.json create mode 100644 unittests/scans/uv_audit/one_finding.json create mode 100644 unittests/tools/test_ansible_lint_parser.py create mode 100644 unittests/tools/test_binwalk_parser.py create mode 100644 unittests/tools/test_bomber_parser.py create mode 100644 unittests/tools/test_capa_parser.py create mode 100644 unittests/tools/test_cfripper_parser.py create mode 100644 unittests/tools/test_cloudsplaining_parser.py create mode 100644 unittests/tools/test_dockerfile_lint_parser.py create mode 100644 unittests/tools/test_dodgy_parser.py create mode 100644 unittests/tools/test_fickling_parser.py create mode 100644 unittests/tools/test_gixy_parser.py create mode 100644 unittests/tools/test_grant_parser.py create mode 100644 unittests/tools/test_guarddog_parser.py create mode 100644 unittests/tools/test_kingfisher_parser.py create mode 100644 unittests/tools/test_kube_score_parser.py create mode 100644 unittests/tools/test_kubent_parser.py create mode 100644 unittests/tools/test_kubesec_parser.py create mode 100644 unittests/tools/test_licensecheck_parser.py create mode 100644 unittests/tools/test_modelscan_parser.py create mode 100644 unittests/tools/test_noir_parser.py create mode 100644 unittests/tools/test_ossf_scorecard_parser.py create mode 100644 unittests/tools/test_pip_licenses_parser.py create mode 100644 unittests/tools/test_pluto_parser.py create mode 100644 unittests/tools/test_prospector_parser.py create mode 100644 unittests/tools/test_python_taint_parser.py create mode 100644 unittests/tools/test_quark_engine_parser.py create mode 100644 unittests/tools/test_regula_parser.py create mode 100644 unittests/tools/test_ruff_parser.py create mode 100644 unittests/tools/test_safety_parser.py create mode 100644 unittests/tools/test_sbomqs_parser.py create mode 100644 unittests/tools/test_scubagear_parser.py create mode 100644 unittests/tools/test_scubagoggles_parser.py create mode 100644 unittests/tools/test_shellcheck_parser.py create mode 100644 unittests/tools/test_slither_parser.py create mode 100644 unittests/tools/test_syft_parser.py create mode 100644 unittests/tools/test_tartufo_parser.py create mode 100644 unittests/tools/test_tflint_parser.py create mode 100644 unittests/tools/test_threat_dragon_parser.py create mode 100644 unittests/tools/test_two_ms_parser.py create mode 100644 unittests/tools/test_uv_audit_parser.py diff --git a/docs/content/supported_tools/parsers/file/ansible_lint.md b/docs/content/supported_tools/parsers/file/ansible_lint.md new file mode 100644 index 00000000000..4b51eb95eeb --- /dev/null +++ b/docs/content/supported_tools/parsers/file/ansible_lint.md @@ -0,0 +1,44 @@ +--- +title: "Ansible Lint" +toc_hide: true +--- +Import ansible-lint reports in JSON format. ansible-lint checks Ansible playbooks, roles and +collections against its rule set. + +Generate a report with: + +``` +ansible-lint -f json playbook.yml > ansible-lint.json +``` + +### Scope +ansible-lint is a general linter, not solely a security scanner. Its rules cover correctness +and idiom alongside security-relevant patterns such as `risky-file-permissions` (world-writable +modes), `command-instead-of-shell` (shell use where a module exists) and unvalidated +certificates. Every rule's categories are recorded on the Finding so security-relevant results +can be filtered from stylistic ones after import. + +### Severity Mapping +ansible-lint emits Code Climate formatted JSON and uses that project's severity scale rather +than one of its own: + +| ansible-lint severity | DefectDojo severity | +| --- | --- | +| blocker | Critical | +| critical | High | +| major | Medium | +| minor | Low | +| info | Info | + +Each issue carries a `fingerprint` that is stable for a given rule violation in a given file, +which is stored as `unique_id_from_tool`. + +### Sample Scan Data +Sample Ansible Lint scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/ansible_lint). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/binwalk.md b/docs/content/supported_tools/parsers/file/binwalk.md new file mode 100644 index 00000000000..e8def82ac3e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/binwalk.md @@ -0,0 +1,29 @@ +--- +title: "Binwalk" +toc_hide: true +--- +Import Binwalk reports in JSON format. Binwalk identifies embedded files, compression, +cryptographic constants and licence strings inside firmware images and binaries. + +Generate a report with: + +``` +binwalk -l binwalk.json firmware.bin +``` + +### Scope and Severity +Binwalk reports what a file **contains**, not whether it is vulnerable, and it assigns no +severity — it reports a match confidence instead. Every result is therefore imported as +**Info**: this is firmware inventory to review, not a list of defects. + +Use it to answer questions like "does this image embed a gzip filesystem, an AES S-box, or a +third-party licence string", then follow up with a tool that judges what it finds. + +### Sample Scan Data +Sample Binwalk scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/binwalk). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- file_path diff --git a/docs/content/supported_tools/parsers/file/bomber.md b/docs/content/supported_tools/parsers/file/bomber.md new file mode 100644 index 00000000000..80140d98159 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/bomber.md @@ -0,0 +1,38 @@ +--- +title: "bomber" +toc_hide: true +--- +Import bomber reports in JSON format. bomber reads an existing SBOM (CycloneDX, SPDX or +Syft) and looks each component up against a vulnerability provider. + +Generate a report with: + +``` +bomber scan --output json sbom.json > bomber.json +``` + +### Severity Mapping +bomber normalises every provider onto its own scale, which uses `MODERATE` where most tools +use medium: + +| bomber severity | DefectDojo severity | +| --- | --- | +| CRITICAL | Critical | +| HIGH | High | +| MODERATE | Medium | +| LOW | Low | +| UNSPECIFIED | Info | + +Package coordinates are reported as a purl and split into the component name and version. +Advisory identifiers are recorded in `vuln_id_from_tool`; those that are CVEs are additionally +attached as vulnerability ids, while provider-specific ids such as GHSA references are not. + +### Sample Scan Data +Sample bomber scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/bomber). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name +- component_version diff --git a/docs/content/supported_tools/parsers/file/capa.md b/docs/content/supported_tools/parsers/file/capa.md new file mode 100644 index 00000000000..1ab90332a17 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/capa.md @@ -0,0 +1,30 @@ +--- +title: "capa" +toc_hide: true +--- +Import capa reports in JSON format. capa identifies the capabilities of an executable — what +it is able to do — and maps each to MITRE ATT&CK and MBC. + +Generate a report with: + +``` +capa -j sample.exe > capa.json +``` + +### Scope and Severity +capa reports **capabilities, not vulnerabilities**. "This binary can accept command line +arguments" or "this binary can encrypt data" is intelligence about a sample, not a defect in +it, and capa assigns no severity. Every capability therefore imports as **Info**, with its +ATT&CK and MBC techniques recorded in the description for triage. + +capa also emits library and subscope rules, which exist only to build other matches. Those are +not capabilities of the sample and are not imported. + +### Sample Scan Data +Sample capa scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/capa). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name diff --git a/docs/content/supported_tools/parsers/file/cfripper.md b/docs/content/supported_tools/parsers/file/cfripper.md new file mode 100644 index 00000000000..0188ff51f7e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/cfripper.md @@ -0,0 +1,36 @@ +--- +title: "CFRipper" +toc_hide: true +--- +Import CFRipper reports in JSON format. CFRipper audits CloudFormation templates for security +misconfiguration — wildcard IAM policies, privilege escalation, publicly exposed resources. + +Generate a report with: + +``` +cfripper template.json --format json > cfripper.json +``` + +CFRipper checks security posture, which is distinct from the syntax and best-practice linting +that the existing cfn-lint and cfn-nag parsers cover. + +### Severity Mapping +CFRipper assigns a risk value per rule, which DefectDojo maps directly: + +| CFRipper risk_value | DefectDojo severity | +| --- | --- | +| HIGH | High | +| MEDIUM | Medium | +| LOW | Low | + +Each finding names the resource that tripped the rule and CFRipper's rule mode (BLOCKING or +MONITOR). + +### Sample Scan Data +Sample CFRipper scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/cfripper). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name diff --git a/docs/content/supported_tools/parsers/file/cloudsplaining.md b/docs/content/supported_tools/parsers/file/cloudsplaining.md new file mode 100644 index 00000000000..ec9843e226b --- /dev/null +++ b/docs/content/supported_tools/parsers/file/cloudsplaining.md @@ -0,0 +1,46 @@ +--- +title: "Cloudsplaining" +toc_hide: true +--- +Import Cloudsplaining results in JSON format. Cloudsplaining assesses AWS IAM policies for +overly permissive access, reading an account authorization details file. + +Generate a report with: + +``` +aws iam get-account-authorization-details > account.json +cloudsplaining scan --input-file account.json +``` + +### Granularity +A single permissive policy can flag thousands of actions -- `PowerUserAccess` in +Cloudsplaining's own example results yields over three thousand under infrastructure +modification alone. Importing one Finding per action would bury the report, so DefectDojo +raises **one Finding per policy and risk category**, listing the flagged actions in the +description. + +Policies that Cloudsplaining marks as excluded are kept in the results rather than removed, so +that the exclusion stays auditable. They are not imported. + +### Severity Mapping +Cloudsplaining assigns no severity. It sorts risky actions into its own risk categories, and +its CLI treats resource exposure, privilege escalation and data exfiltration as the high +priority ones. DefectDojo maps the categories as follows: + +| Cloudsplaining risk | DefectDojo severity | +| --- | --- | +| PrivilegeEscalation | Critical | +| DataExfiltration | High | +| ResourceExposure | High | +| CredentialsExposure | High | +| ServiceWildcard | Medium | +| InfrastructureModification | Low | + +### Sample Scan Data +Sample Cloudsplaining scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/cloudsplaining). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name diff --git a/docs/content/supported_tools/parsers/file/dockerfile_lint.md b/docs/content/supported_tools/parsers/file/dockerfile_lint.md new file mode 100644 index 00000000000..5625bacdc4f --- /dev/null +++ b/docs/content/supported_tools/parsers/file/dockerfile_lint.md @@ -0,0 +1,37 @@ +--- +title: "dockerfile_lint" +toc_hide: true +--- +Import dockerfile_lint reports in JSON format. dockerfile_lint checks a Dockerfile against a +rule set covering build correctness and image hygiene. + +Generate a report with: + +``` +dockerfile_lint -f Dockerfile -j > dockerfile_lint.json +``` + +### Scope and Severity +dockerfile_lint is a general Dockerfile linter, but several of its rules carry real security +weight — running as root, using the floating `latest` tag, and adding remote archives over +the network. Its own buckets double as its severity scale: + +| dockerfile_lint bucket | DefectDojo severity | +| --- | --- | +| error | High | +| warn | Medium | +| info | Low | + +Rules that apply to the file as a whole rather than to a line — a required `LABEL` that is +missing entirely, for instance — are reported by dockerfile_lint with a line number of `-1`. +Those Findings carry no line rather than a nonsensical one, and rules that ship without a +label take their title from the message instead. + +### Sample Scan Data +Sample dockerfile_lint scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/dockerfile_lint). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- line diff --git a/docs/content/supported_tools/parsers/file/dodgy.md b/docs/content/supported_tools/parsers/file/dodgy.md new file mode 100644 index 00000000000..77f879b6718 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/dodgy.md @@ -0,0 +1,29 @@ +--- +title: "Dodgy" +toc_hide: true +--- +Import Dodgy reports in JSON format. Dodgy searches source for hardcoded secrets — passwords, +cloud keys, private key material and connection strings. + +Generate a report with: + +``` +dodgy > dodgy.json +``` + +Dodgy writes its JSON report to standard output. + +### Severity Mapping +Dodgy assigns no severity. A match is a credential committed to source control, which is +treated as **High** regardless of the kind of secret. The rule that fired — `aws_secret_key`, +`secret`, and so on — is kept in `vuln_id_from_tool`. + +### Sample Scan Data +Sample Dodgy scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/dodgy). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/fickling.md b/docs/content/supported_tools/parsers/file/fickling.md new file mode 100644 index 00000000000..0851f9d44dc --- /dev/null +++ b/docs/content/supported_tools/parsers/file/fickling.md @@ -0,0 +1,47 @@ +--- +title: "Fickling" +toc_hide: true +--- +Import Fickling safety-check reports in JSON format. Fickling statically analyses and +decompiles Python pickle files, which can execute arbitrary code when loaded. + +Generate a report with: + +``` +fickling --check-safety --json-output fickling.json model.pkl +``` + +Note that `--json-output` only writes a file when `--check-safety` is also passed. + +### Severity Mapping +Fickling returns a single verdict for the file on its own scale: + +| Fickling verdict | DefectDojo severity | +| --- | --- | +| OVERTLY_MALICIOUS | Critical | +| LIKELY_OVERTLY_MALICIOUS | Critical | +| LIKELY_UNSAFE | High | +| SUSPICIOUS | Medium | +| LIKELY_SAFE | not imported | + +Each observation behind the verdict — unsafe imports, unused variables assigned from calls — +becomes its own Finding carrying the file's verdict, so a malicious pickle does not collapse +into one undifferentiated result. Where Fickling returns a verdict with no itemised results, +a single Finding records the verdict. + +A `LIKELY_SAFE` verdict is not imported. Fickling's own wording is that it "failed to detect +any overtly unsafe code, but the pickle file may still be unsafe" — it is an absence of +evidence, not a clean bill of health. + +### Limitations +Fickling's JSON does not record which file was analysed, so Findings carry no file path. +Import one report per pickle if you need to tell them apart. + +### Sample Scan Data +Sample Fickling scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/fickling). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- severity diff --git a/docs/content/supported_tools/parsers/file/gixy.md b/docs/content/supported_tools/parsers/file/gixy.md new file mode 100644 index 00000000000..3adca9226f5 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/gixy.md @@ -0,0 +1,34 @@ +--- +title: "Gixy" +toc_hide: true +--- +Import Gixy reports in JSON format. Gixy analyses nginx configuration for security +misconfiguration — HTTP splitting, host header issues, referrer and origin validation, version +disclosure. + +Generate a report with: + +``` +gixy -f json nginx.conf > gixy.json +``` + +### Severity Mapping +Gixy assigns its own severity per finding, which DefectDojo maps directly: + +| Gixy severity | DefectDojo severity | +| --- | --- | +| HIGH | High | +| MEDIUM | Medium | +| LOW | Low | + +Each finding names the plugin that raised it and includes the offending configuration snippet. + +### Sample Scan Data +Sample Gixy scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/gixy). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/grant.md b/docs/content/supported_tools/parsers/file/grant.md new file mode 100644 index 00000000000..a5ebcb87d26 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/grant.md @@ -0,0 +1,35 @@ +--- +title: "Grant" +toc_hide: true +--- +Import Grant reports in JSON format. Grant checks the licences in an SBOM or container image +against a licence policy. + +Generate a report with: + +``` +grant check sbom.json -o json > grant.json +``` + +### Severity Mapping +Grant evaluates every package against the policy and records a decision. DefectDojo imports the +denials: + +| Grant decision | DefectDojo severity | +| --- | --- | +| deny (forbidden or missing licence) | High | +| allow | not imported | + +A denied package is a compliance defect — shipping it may breach the policy — so it imports as +High. A package with no declared licence at all is denied and called out as such. Packages the +policy allows are the compliant remainder and are not imported. + +### Sample Scan Data +Sample Grant scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/grant). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- component_name +- component_version +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/guarddog.md b/docs/content/supported_tools/parsers/file/guarddog.md new file mode 100644 index 00000000000..c7338e1c7f2 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/guarddog.md @@ -0,0 +1,47 @@ +--- +title: "GuardDog" +toc_hide: true +--- +Import GuardDog reports in JSON format. GuardDog inspects PyPI and npm packages for +indicators of malicious behaviour, using Semgrep rules over the package source plus metadata +heuristics. + +Generate a report with: + +``` +guarddog pypi scan --output-format json > guarddog.json +``` + +### Severity Mapping +GuardDog's rules fall into two families. A `threat-*` rule describes behaviour that is +suspicious in a package; a `capability-*` rule records that the package is *able* to do +something, which is an observation rather than an accusation. + +Where GuardDog's risk engine correlates a rule into a scored risk, that risk carries its own +severity on GuardDog's low/medium/high scale, and it takes precedence: + +| GuardDog signal | DefectDojo severity | +| --- | --- | +| Correlated risk, severity `high` | High | +| Correlated risk, severity `medium` | Medium | +| Correlated risk, severity `low` | Low | +| Uncorrelated `threat-*` rule | Medium | +| Uncorrelated `capability-*` rule | Info | + +The package-level `risk_score` (`no_risks_detected`, `low`, `suspicious`, `high_risk`) is +recorded in each Finding's description rather than mapped onto severity, since it describes +the package as a whole rather than the individual match. + +GuardDog reports every rule it ran, including the ones that did not match; only rules with at +least one match are imported. + +### Sample Scan Data +Sample GuardDog scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/guarddog). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/kingfisher.md b/docs/content/supported_tools/parsers/file/kingfisher.md new file mode 100644 index 00000000000..f3f0b76669c --- /dev/null +++ b/docs/content/supported_tools/parsers/file/kingfisher.md @@ -0,0 +1,40 @@ +--- +title: "Kingfisher" +toc_hide: true +--- +Import Kingfisher reports in JSON format. Kingfisher scans source, git history and +filesystems for credentials, and where a provider allows it will actively validate a match +to establish whether the credential is still live. + +Generate a report with: + +``` +kingfisher scan /path/to/repo --format json > kingfisher.json +``` + +### Severity Mapping +Kingfisher has no severity concept. It reports a confidence for each match, plus the result +of validating the credential against the provider. DefectDojo derives severity from both: + +| Kingfisher signal | DefectDojo severity | +| --- | --- | +| Validation confirmed a live credential | Critical | +| Otherwise, confidence `high` | High | +| Otherwise, confidence `medium` | Medium | +| Otherwise, confidence `low` | Low | + +A credential that validation reports as inactive keeps its confidence-derived severity — it +is still a secret committed to source, it is simply no longer usable. + +Kingfisher's `fingerprint` is stored as `unique_id_from_tool`, so re-imports track the same +match across scans. + +### Sample Scan Data +Sample Kingfisher scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/kingfisher). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/kube_score.md b/docs/content/supported_tools/parsers/file/kube_score.md new file mode 100644 index 00000000000..3708688d1bb --- /dev/null +++ b/docs/content/supported_tools/parsers/file/kube_score.md @@ -0,0 +1,35 @@ +--- +title: "kube-score" +toc_hide: true +--- +Import kube-score reports in JSON format. kube-score performs static analysis of Kubernetes +object definitions and grades every check it runs. + +Generate a report with: + +``` +kube-score score manifests/*.yaml --output-format json > kube-score.json +``` + +### Severity Mapping +kube-score does not assign severities. It grades each check on its own scale, and DefectDojo +maps that grade as follows: + +| kube-score grade | DefectDojo severity | +| --- | --- | +| 1 (critical) | High | +| 5 (warning) | Medium | +| 10 (passing) | not imported | + +Checks that kube-score marks as `skipped` express no opinion on the object and are not +imported, regardless of the grade attached to them. + +### Sample Scan Data +Sample kube-score scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/kube_score). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- component_name +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/kubent.md b/docs/content/supported_tools/parsers/file/kubent.md new file mode 100644 index 00000000000..ba4d83ff1b8 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/kubent.md @@ -0,0 +1,33 @@ +--- +title: "kube-no-trouble (kubent)" +toc_hide: true +--- +Import kube-no-trouble reports in JSON format. kubent finds Kubernetes objects using +deprecated API versions, reading manifest files, Helm releases or a live cluster. + +Generate a report with: + +``` +kubent -f manifests/ -o json > kubent.json +``` + +### Severity Mapping +kubent assigns no severity, and unlike [Pluto](../pluto/) it does not distinguish an API that +is merely deprecated from one that has already been removed. Every object it reports is a +deprecated API usage, so all findings are imported as **Medium**. + +The release the API is removed in is parsed out of kubent's ruleset name (for example +`Deprecated APIs removed in 1.16`) and recorded in the description, along with the release the +API was deprecated in and the replacement API. + +If you want severity to reflect whether the API is already gone, use the Pluto parser instead: +Pluto reports an explicit `removed` flag. + +### Sample Scan Data +Sample kubent scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/kubent). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name diff --git a/docs/content/supported_tools/parsers/file/kubesec.md b/docs/content/supported_tools/parsers/file/kubesec.md new file mode 100644 index 00000000000..01ad56dadff --- /dev/null +++ b/docs/content/supported_tools/parsers/file/kubesec.md @@ -0,0 +1,37 @@ +--- +title: "kubesec" +toc_hide: true +--- +Import kubesec reports in JSON format. kubesec scores Kubernetes object definitions against a +fixed set of security rules and assigns each object an overall point score. + +Generate a report with: + +``` +kubesec scan manifest.yaml > kubesec.json +``` + +### Severity Mapping +kubesec assigns no severity. It sorts each rule into one of three buckets and attaches a point +value, which DefectDojo maps as follows: + +| kubesec bucket | DefectDojo severity | +| --- | --- | +| critical | High | +| advise | Low | +| passed | not imported | + +The `advise` bucket lists hardening the object does not have yet rather than a weakness it +has, which is why it maps to Low. The `passed` bucket lists rules the object already +satisfies and is not imported. Each Finding records the rule's point value and the object's +overall score in its description. + +### Sample Scan Data +Sample kubesec scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/kubesec). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name +- file_path diff --git a/docs/content/supported_tools/parsers/file/licensecheck.md b/docs/content/supported_tools/parsers/file/licensecheck.md new file mode 100644 index 00000000000..2a18dcd476e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/licensecheck.md @@ -0,0 +1,38 @@ +--- +title: "licensecheck" +toc_hide: true +--- +Import licensecheck reports in JSON format. Unlike a plain licence inventory, licensecheck +evaluates each dependency's licence for **compatibility with the project's own licence**. + +Generate a report with: + +``` +licensecheck -f json > licensecheck.json +``` + +### Severity Mapping +licensecheck assigns no severity, but it does make a compatibility judgement per dependency, +and DefectDojo maps that: + +| licensecheck result | DefectDojo severity | +| --- | --- | +| Licence incompatible with the project | High | +| Licence compatible | Info | + +An incompatible licence is a genuine compliance finding — shipping the dependency may violate +the project's own distribution terms. Compatible dependencies are recorded as informational +inventory, with the licence in `vuln_id_from_tool`. + +DefectDojo does not decide your licence policy; licensecheck's own compatibility matrix does. +If your policy differs, triage on the recorded licence after import. + +### Sample Scan Data +Sample licensecheck scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/licensecheck). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- component_name +- component_version +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/modelscan.md b/docs/content/supported_tools/parsers/file/modelscan.md new file mode 100644 index 00000000000..4b0f0237c59 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/modelscan.md @@ -0,0 +1,37 @@ +--- +title: "ModelScan" +toc_hide: true +--- +Import ModelScan reports in JSON format. ModelScan inspects serialized machine-learning +models (pickle, TensorFlow SavedModel, Keras, NumPy) for operators that are able to execute +code when the model is loaded. + +Generate a report with: + +``` +modelscan -p /path/to/models -r json -o modelscan.json +``` + +### Severity Mapping +ModelScan assigns a severity to each unsafe operator it knows about, and DefectDojo maps that +scale directly: + +| ModelScan severity | DefectDojo severity | +| --- | --- | +| CRITICAL | Critical | +| HIGH | High | +| MEDIUM | Medium | +| LOW | Low | + +ModelScan reports an unsafe operator rather than a known vulnerability, so its findings carry +no CVE or CWE. The operator is recorded in `vuln_id_from_tool` as `module.operator`. + +### Sample Scan Data +Sample ModelScan scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/modelscan). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- file_path +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/noir.md b/docs/content/supported_tools/parsers/file/noir.md new file mode 100644 index 00000000000..59d0da6ddb6 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/noir.md @@ -0,0 +1,34 @@ +--- +title: "Noir" +toc_hide: true +--- +Import Noir reports in JSON format. Noir discovers the API attack surface of a codebase — every +route, its method and its parameters — directly from source. + +Generate a report with: + +``` +noir -b . -f json > noir.json +``` + +### Scope and Severity +Noir reports **endpoints, not vulnerabilities**. Knowing the full attack surface is the value: +each discovered endpoint imports as **Info** inventory. Noir additionally tags endpoints it +considers security-relevant — an admin route, an endpoint taking a file path — and a tagged +endpoint imports as **Low** so it surfaces for review. + +| Noir endpoint | DefectDojo severity | +| --- | --- | +| Tagged (admin, sensitive parameter, …) | Low | +| Untagged | Info | + +Each endpoint is also attached to the Finding as a DefectDojo Endpoint. + +### Sample Scan Data +Sample Noir scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/noir). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path diff --git a/docs/content/supported_tools/parsers/file/ossf_scorecard.md b/docs/content/supported_tools/parsers/file/ossf_scorecard.md new file mode 100644 index 00000000000..dc7972a908f --- /dev/null +++ b/docs/content/supported_tools/parsers/file/ossf_scorecard.md @@ -0,0 +1,48 @@ +--- +title: "OpenSSF Scorecard" +toc_hide: true +--- +Import OpenSSF Scorecard JSON. Scorecard grades a repository's supply chain posture across a +set of independent checks — branch protection, pinned dependencies, token permissions, SAST +coverage and others. + +Generate a report with: + +``` +scorecard --repo=github.com// --format=json > scorecard.json +``` + +Scorecard also publishes results for many public repositories through its API, which returns +the same JSON: + +``` +curl https://api.securityscorecards.dev/projects/github.com// +``` + +Scorecard can additionally emit SARIF, which DefectDojo's generic SARIF parser already +handles. Use this parser for the native JSON, which carries the numeric score per check that +SARIF does not. + +### Severity Mapping +Scorecard scores each check from 0 to 10 rather than assigning severities, and DefectDojo +derives severity from how far the check falls short: + +| Scorecard score | DefectDojo severity | +| --- | --- | +| 0–3 | High | +| 4–6 | Medium | +| 7–9 | Low | +| 10 | not imported | +| -1 | not imported | + +A score of -1 means the check could not reach a conclusion, usually because it lacks the +access or metadata it needs. That is not the same as a failure, so it is not imported. + +### Sample Scan Data +Sample OpenSSF Scorecard scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/ossf_scorecard). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name diff --git a/docs/content/supported_tools/parsers/file/pip_licenses.md b/docs/content/supported_tools/parsers/file/pip_licenses.md new file mode 100644 index 00000000000..3e0af2c9e45 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/pip_licenses.md @@ -0,0 +1,39 @@ +--- +title: "pip-licenses" +toc_hide: true +--- +Import pip-licenses reports in JSON format. pip-licenses inventories the licence of every +installed Python distribution. + +Generate a report with: + +``` +pip-licenses --format=json > pip-licenses.json +``` + +### Scope and Severity +pip-licenses is a **compliance inventory, not a vulnerability scanner**. It reports what is +installed and under which licence, and judges nothing — so every package it lists becomes a +Finding, and they are informational by default. + +The one exception is a distribution whose licence pip-licenses could not determine. An +undetermined licence is itself a compliance gap, so those are raised to **Low**: + +| pip-licenses result | DefectDojo severity | +| --- | --- | +| Licence declared | Info | +| `UNKNOWN` / no licence metadata | Low | + +DefectDojo does not decide which licences are acceptable for your organisation. If you need +copyleft or licence-policy enforcement, filter or triage on the licence recorded in +`vuln_id_from_tool` after import. + +### Sample Scan Data +Sample pip-licenses scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/pip_licenses). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- component_name +- component_version +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/pluto.md b/docs/content/supported_tools/parsers/file/pluto.md new file mode 100644 index 00000000000..ef4b9b6274d --- /dev/null +++ b/docs/content/supported_tools/parsers/file/pluto.md @@ -0,0 +1,38 @@ +--- +title: "Pluto" +toc_hide: true +--- +Import Pluto reports in JSON format. Pluto finds Kubernetes objects that use deprecated or +removed API versions, either in manifest files or in a live cluster's Helm releases. + +Generate a report with: + +``` +pluto detect-files -d . -o json > pluto.json +``` + +### Severity Mapping +Pluto assigns no severity. It reports two booleans per object, and DefectDojo derives severity +from them: + +| Pluto state | DefectDojo severity | +| --- | --- | +| `removed` | High | +| `deprecated` | Medium | + +An object on an API version that has already been removed will not apply to a cluster running +that version at all, which is why it outranks one that is merely deprecated. Objects Pluto +inspected but flagged as neither are not imported. + +The replacement API, the version the API was deprecated in and the version it was removed in +are all recorded on the Finding, and the mitigation names the migration target. + +### Sample Scan Data +Sample Pluto scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/pluto). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name +- file_path diff --git a/docs/content/supported_tools/parsers/file/prospector.md b/docs/content/supported_tools/parsers/file/prospector.md new file mode 100644 index 00000000000..459fb01090f --- /dev/null +++ b/docs/content/supported_tools/parsers/file/prospector.md @@ -0,0 +1,35 @@ +--- +title: "Prospector" +toc_hide: true +--- +Import Prospector reports in JSON format. Prospector runs several Python analysis tools — +pylint, pyflakes, dodgy, bandit and others — and reports their messages under a common shape. + +Generate a report with: + +``` +prospector --output-format json > prospector.json +``` + +### Severity Mapping +Prospector is a meta-tool, and its messages range from style to security. DefectDojo weights +by the tool that raised each message so a real security finding is not lost among linter +noise: + +| Prospector source | DefectDojo severity | +| --- | --- | +| dodgy, bandit (security tools) | High | +| pylint, pyflakes, pep8, … | Low | + +The originating tool and its check code are recorded on every Finding, so results can be +filtered by source after import. + +### Sample Scan Data +Sample Prospector scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/prospector). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/python_taint.md b/docs/content/supported_tools/parsers/file/python_taint.md new file mode 100644 index 00000000000..12873ac3bc4 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/python_taint.md @@ -0,0 +1,29 @@ +--- +title: "Python Taint" +toc_hide: true +--- +Import Python Taint (pyt) reports in JSON format. Python Taint performs taint tracking — it +follows untrusted input from a source to a dangerous sink, rather than matching patterns. + +Generate a report with: + +``` +pyt -j project/ > pyt.json +``` + +### Severity Mapping +Python Taint reports no severity. Every result is a completed flow from an untrusted source to +a dangerous sink — an exploitable path, not a hint — so all findings import as **High**. + +Each finding records the source, the sink and the propagation trace between them, and anchors +on the sink line, where the dangerous operation happens. + +### Sample Scan Data +Sample Python Taint scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/python_taint). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/quark_engine.md b/docs/content/supported_tools/parsers/file/quark_engine.md new file mode 100644 index 00000000000..32fede828af --- /dev/null +++ b/docs/content/supported_tools/parsers/file/quark_engine.md @@ -0,0 +1,42 @@ +--- +title: "Quark-Engine" +toc_hide: true +--- +Import Quark-Engine reports in JSON format. Quark-Engine scores Android applications by +detecting behaviours ("crimes") such as sending device location over SMS, rather than by +matching known vulnerabilities. + +Generate a report with: + +``` +quark -a sample.apk -s -o quark-report.json +``` + +### Severity Mapping +Quark-Engine assigns no severity. Each detected behaviour carries a confidence expressing +how many of Quark's five detection stages matched — requested permission, native API call, +API combination, calling sequence, and shared register — in 20% increments. A confidence of +100% means every required API was used *and* a data flow between them was confirmed; 80% +means the APIs were present but no data flow was found. + +DefectDojo derives severity from that confidence: + +| Quark confidence | DefectDojo severity | +| --- | --- | +| 100% | High | +| 80% | Medium | +| 40–60% | Low | +| 0–20% | Info | + +The report's own `threat_level` and `total_score` describe the APK as a whole rather than +any single behaviour, so they are not mapped onto individual Findings. + +### Sample Scan Data +Sample Quark-Engine scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/quark_engine). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- component_name +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/regula.md b/docs/content/supported_tools/parsers/file/regula.md new file mode 100644 index 00000000000..819fac4d046 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/regula.md @@ -0,0 +1,38 @@ +--- +title: "Regula" +toc_hide: true +--- +Import Regula reports in JSON format. Regula evaluates infrastructure as code — Terraform, +CloudFormation, Kubernetes manifests and Azure Resource Manager templates — against Open +Policy Agent rules. + +Generate a report with: + +``` +regula run . --format json > regula.json +``` + +### Severity Mapping +Regula assigns its own severity per rule, which DefectDojo maps directly: + +| Regula severity | DefectDojo severity | +| --- | --- | +| Critical | Critical | +| High | High | +| Medium | Medium | +| Low | Low | +| Informational | Info | + +Regula reports every rule it evaluated against every resource, `PASS` results included. Only +`FAIL` results are imported. Each finding records the resource id, the provider and input +type, and links to the rule's remediation documentation. + +### Sample Scan Data +Sample Regula scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/regula). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name +- file_path diff --git a/docs/content/supported_tools/parsers/file/ruff.md b/docs/content/supported_tools/parsers/file/ruff.md new file mode 100644 index 00000000000..4f6a1ce7f79 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/ruff.md @@ -0,0 +1,39 @@ +--- +title: "Ruff" +toc_hide: true +--- +Import Ruff reports in JSON format. Ruff is a fast Python linter that includes the +flake8-bandit security ruleset — its `S` codes cover shell injection, weak hashes, hardcoded +passwords and similar issues. + +Generate a report with: + +``` +ruff check --output-format json > ruff.json +``` + +To scan only for security issues, select the bandit ruleset: `ruff check --select S`. + +### Severity Mapping +A Ruff run can mix security findings with style, so DefectDojo weights by rule category, the +same way it treats the Prospector meta-tool: + +| Ruff rule prefix | DefectDojo severity | +| --- | --- | +| `S` (flake8-bandit security rules) | Medium | +| everything else (`E`, `F`, `W`, …) | Low | + +This keeps a real security finding such as `S602` (subprocess with `shell=True`) from being +buried among formatting warnings. The rule code is kept in `vuln_id_from_tool` so results can +be filtered by ruleset after import. This parser is distinct from the Bandit parser: it reads +Ruff's own JSON, not Bandit's. + +### Sample Scan Data +Sample Ruff scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/ruff). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/safety.md b/docs/content/supported_tools/parsers/file/safety.md new file mode 100644 index 00000000000..efc0312921e --- /dev/null +++ b/docs/content/supported_tools/parsers/file/safety.md @@ -0,0 +1,39 @@ +--- +title: "Safety" +toc_hide: true +--- +Import Safety reports in JSON format. Safety checks installed Python packages against the +PyUp advisory database. + +Generate a report with: + +``` +safety check --json --output safety.json +``` + +### Severity Mapping +PyUp assigns a severity only to some advisories. Its own `PVE-` advisories frequently carry +neither a CVE nor a severity, so DefectDojo maps what is present and defaults the rest to +Medium rather than inventing a scale: + +| Safety severity | DefectDojo severity | +| --- | --- | +| critical | Critical | +| high | High | +| medium | Medium | +| low | Low | +| absent | Medium | + +Advisories that carry a CVE have it recorded as a vulnerability id; PyUp's own numeric +advisory id is always kept in `vuln_id_from_tool`. Advisories the user has ignored stay in +Safety's report, flagged rather than removed, and are not imported. + +### Sample Scan Data +Sample Safety scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/safety). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name +- component_version diff --git a/docs/content/supported_tools/parsers/file/sbomqs.md b/docs/content/supported_tools/parsers/file/sbomqs.md new file mode 100644 index 00000000000..c5f25818237 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/sbomqs.md @@ -0,0 +1,36 @@ +--- +title: "sbomqs" +toc_hide: true +--- +Import sbomqs reports in JSON format. sbomqs scores the **quality** of an SBOM rather than +searching it for vulnerabilities: whether the document carries suppliers, licences, checksums +and the other elements required by NTIA minimum elements and BSI TR-03183 guidance. + +Generate a report with: + +``` +sbomqs score sbom.json --json > sbomqs.json +``` + +### Severity Mapping +sbomqs has no severity concept. It scores each feature out of a maximum, and DefectDojo +derives severity from the size of the gap: + +| Feature score | DefectDojo severity | +| --- | --- | +| 0 (element entirely absent) | Medium | +| below half of maximum | Low | +| at or above half of maximum | Info | +| at maximum | not imported | + +Features sbomqs marks as ignored for the run are not imported. Because these Findings describe +gaps in a document rather than exploitable weaknesses, none of them carry a CVE or CWE. + +### Sample Scan Data +Sample sbomqs scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/sbomqs). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name diff --git a/docs/content/supported_tools/parsers/file/scubagear.md b/docs/content/supported_tools/parsers/file/scubagear.md new file mode 100644 index 00000000000..7b873b60f78 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/scubagear.md @@ -0,0 +1,50 @@ +--- +title: "CISA ScubaGear" +toc_hide: true +--- +Import the CISA ScubaGear action plan in CSV format. ScubaGear assesses a Microsoft 365 +tenant against the CISA SCuBA secure configuration baselines. + +Run an assessment and import the resulting `ActionPlan.csv`: + +``` +Invoke-SCuBA -ProductNames aad, defender, exo, sharepoint, teams +``` + +Two artifacts are supported, each with its own scan type: + +- **ScubaGear Scan** — the `ActionPlan.csv`, holding only the controls that did not pass. + Written as UTF-8 with a byte order mark. +- **ScubaGear Report Scan** — a per-product JSON report from `IndividualReports/`, holding the + full pass and fail set. Written as UTF-16; the encoding is detected from the byte order mark, + so no conversion is needed before import. + +Both are graded identically; the JSON simply carries more, since passing controls are present +in the source and dropped on import. + +### Severity Mapping +ScubaGear reports no severity. Each baseline control carries a criticality — `Shall` is +mandatory, `Should` is recommended — and a result. DefectDojo derives severity from the +pair: + +| Result | Criticality | DefectDojo severity | +| --- | --- | --- | +| Fail | Shall | High | +| Fail | Should | Medium | +| Warning | any | Low | +| Pass | any | not imported | + +Controls whose criticality ends in `Not-Implemented` are ones ScubaGear does not yet +evaluate, and describe a gap in the tool rather than in the tenant, so they are not imported. + +The `Requirement` and `Details` columns are rendered for the HTML report and contain markup, +which is stripped before the text reaches the Finding. + +### Sample Scan Data +Sample ScubaGear scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/scubagear). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- severity diff --git a/docs/content/supported_tools/parsers/file/scubagoggles.md b/docs/content/supported_tools/parsers/file/scubagoggles.md new file mode 100644 index 00000000000..ed4fb6af49a --- /dev/null +++ b/docs/content/supported_tools/parsers/file/scubagoggles.md @@ -0,0 +1,47 @@ +--- +title: "CISA ScubaGoggles" +toc_hide: true +--- +Import CISA ScubaGoggles reports in JSON format. ScubaGoggles assesses a Google Workspace +tenant against the CISA SCuBA secure configuration baselines. + +Two artifacts are supported, each with its own scan type: + +- **ScubaGoggles Scan** — the `ScubaResults_*.json`, holding the full pass and fail set. +- **ScubaGoggles Action Plan** — the `ActionPlan.csv`, holding only the controls that did not + pass. + +Run an assessment and import either: + +``` +scubagoggles gws -o output +``` + +### Severity Mapping +ScubaGoggles reports no severity. Each baseline policy carries a criticality — `Shall` is +mandatory, `Should` is recommended — and a result. DefectDojo derives severity from the +pair: + +| Result | Criticality | DefectDojo severity | +| --- | --- | --- | +| Fail | Shall | High | +| Fail | Should | Medium | +| Warning | any | Low | +| Pass | any | not imported | +| N/A, No events found | any | not imported | + +Baselines whose criticality ends in `Not-Implemented` are ones ScubaGoggles does not yet +evaluate. They describe a gap in the tool rather than in the tenant, so they are not +imported. + +The `Requirement` and `Details` fields are rendered for the HTML report and contain markup, +which is stripped before the text reaches the Finding. + +### Sample Scan Data +Sample ScubaGoggles scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/scubagoggles). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name diff --git a/docs/content/supported_tools/parsers/file/shellcheck.md b/docs/content/supported_tools/parsers/file/shellcheck.md new file mode 100644 index 00000000000..e67e189d1d7 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/shellcheck.md @@ -0,0 +1,40 @@ +--- +title: "ShellCheck" +toc_hide: true +--- +Import ShellCheck reports in JSON format. ShellCheck is a static analyser for shell scripts. + +Generate a report with: + +``` +shellcheck -f json script.sh > shellcheck.json +``` + +### Scope +ShellCheck is a general linter rather than a dedicated security scanner, but several of its +checks are security relevant — `SC2086` (unquoted expansion allowing word splitting and +globbing), `SC2115` (`rm -rf $VAR/*` expanding to `/*` when the variable is empty) and unsafe +`eval` use among them. Results are imported with ShellCheck's own levels preserved so +stylistic results can be filtered after import. + +### Severity Mapping +| ShellCheck level | DefectDojo severity | +| --- | --- | +| error | High | +| warning | Medium | +| info | Low | +| style | Info | + +ShellCheck reports its check id as a bare integer; it is rendered in the documented `SCnnnn` +form and linked to the corresponding wiki page. Checks that ShellCheck can rewrite +automatically are marked as having a fix available. + +### Sample Scan Data +Sample ShellCheck scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/shellcheck). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/slither.md b/docs/content/supported_tools/parsers/file/slither.md new file mode 100644 index 00000000000..80b8068077f --- /dev/null +++ b/docs/content/supported_tools/parsers/file/slither.md @@ -0,0 +1,40 @@ +--- +title: "Slither" +toc_hide: true +--- +Import Slither reports in JSON format. Slither is a static analyser for Solidity smart +contracts. + +Generate a report with: + +``` +slither . --json slither.json +``` + +Slither needs a matching `solc` on the path, which `solc-select` can manage. + +### Severity Mapping +Slither reports two independent axes per detector result: **impact**, which is the severity +axis, and **confidence**, which describes how sure Slither is that the result is real. +DefectDojo maps impact and records confidence in the description rather than blending them: + +| Slither impact | DefectDojo severity | +| --- | --- | +| High | High | +| Medium | Medium | +| Low | Low | +| Informational | Info | +| Optimization | Info | + +Slither's `id` is a stable hash of the result's content and is stored as +`unique_id_from_tool`, so a result tracks across re-imports even when line numbers move. + +### Sample Scan Data +Sample Slither scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/slither). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/syft.md b/docs/content/supported_tools/parsers/file/syft.md new file mode 100644 index 00000000000..e2e6f02f556 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/syft.md @@ -0,0 +1,34 @@ +--- +title: "Syft" +toc_hide: true +--- +Import Syft SBOMs in Syft's native JSON format. Syft catalogues the packages present in a +container image, directory or archive. + +Generate an SBOM with: + +``` +syft scan dir:. -o syft-json > syft.json +``` + +Syft can also emit CycloneDX and SPDX, both of which DefectDojo parses separately. Use this +parser for Syft's native format, which carries its own artifact ids, CPEs and catalogue +metadata. + +### Scope and Severity +Syft is an **SBOM generator, not a vulnerability scanner**. It reports what is installed and +never whether it is vulnerable, and it assigns no severity — so every catalogued package +imports as **Info** inventory. + +To find vulnerabilities in the same material, feed the SBOM to a scanner such as Grype or +bomber, both of which DefectDojo also parses. + +### Sample Scan Data +Sample Syft SBOMs can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/syft). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- component_name +- component_version +- vuln_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/tartufo.md b/docs/content/supported_tools/parsers/file/tartufo.md new file mode 100644 index 00000000000..9f8ac22a6e9 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/tartufo.md @@ -0,0 +1,26 @@ +--- +title: "Tartufo" +toc_hide: true +--- +Import Tartufo reports in JSON format. Tartufo scans git history — not just the working tree — +for secrets, so a finding names the commit that introduced the string and the branch it is on. + +Generate a report with: + +``` +tartufo --output-format json scan-local-repo . > tartufo.json +``` + +### Severity Mapping +Tartufo assigns no severity. A secret found in git history is treated as **High**: rewriting +history does not undo the exposure, so the credential must be considered compromised and +rotated. Tartufo's `signature` is a stable hash of the match and is stored as +`unique_id_from_tool` so a re-scan tracks the same finding. + +### Sample Scan Data +Sample Tartufo scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/tartufo). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- unique_id_from_tool diff --git a/docs/content/supported_tools/parsers/file/tflint.md b/docs/content/supported_tools/parsers/file/tflint.md new file mode 100644 index 00000000000..ca84821734d --- /dev/null +++ b/docs/content/supported_tools/parsers/file/tflint.md @@ -0,0 +1,34 @@ +--- +title: "TFLint" +toc_hide: true +--- +Import TFLint reports in JSON format. TFLint lints Terraform configuration using its core +ruleset plus any enabled provider plugins, such as the AWS, Azure or Google rulesets. + +Generate a report with: + +``` +tflint --format json > tflint.json +``` + +### Severity Mapping +TFLint attaches a severity to each rule, and DefectDojo maps that scale as follows: + +| TFLint severity | DefectDojo severity | +| --- | --- | +| error | High | +| warning | Medium | +| notice | Info | + +The `errors` array in a TFLint report holds problems TFLint hit while running (an unparseable +file, a missing plugin) rather than problems with the configuration, so it is not imported. + +### Sample Scan Data +Sample TFLint scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/tflint). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- file_path +- line diff --git a/docs/content/supported_tools/parsers/file/threat_dragon.md b/docs/content/supported_tools/parsers/file/threat_dragon.md new file mode 100644 index 00000000000..a6a96a00618 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/threat_dragon.md @@ -0,0 +1,40 @@ +--- +title: "OWASP Threat Dragon" +toc_hide: true +--- +Import OWASP Threat Dragon threat models in JSON format. Threat Dragon is a threat modelling +tool; its models hold diagrams whose elements carry the threats identified against them. + +Export the model from Threat Dragon and import the resulting `.json` file directly. + +### Severity Mapping +Threat Dragon records a severity per threat, which DefectDojo maps directly: + +| Threat Dragon severity | DefectDojo severity | +| --- | --- | +| Critical | Critical | +| High | High | +| Medium | Medium | +| Low | Low | +| TBD | Info | + +Threats the modeller has marked as **Mitigated** are imported as mitigated, inactive Findings +rather than being dropped, so the decisions recorded in the model survive the import. Open +threats import as active. + +Each Finding records the STRIDE category, the diagram element the threat applies to, and the +diagram and model titles. + +Both Threat Dragon schema versions are handled: v1 models nest diagram cells under +`diagramJson` and label them through their rendering attributes, while v2 models carry cells +and names directly. + +### Sample Scan Data +Sample Threat Dragon models can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/threat_dragon). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- component_name +- severity diff --git a/docs/content/supported_tools/parsers/file/two_ms.md b/docs/content/supported_tools/parsers/file/two_ms.md new file mode 100644 index 00000000000..012ae697896 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/two_ms.md @@ -0,0 +1,42 @@ +--- +title: "2ms (too many secrets)" +toc_hide: true +--- +Import 2ms reports in JSON format. 2ms scans repositories, filesystems and collaboration +platforms such as Confluence, Discord, Slack and Paligo for exposed secrets. + +Generate a report with: + +``` +2ms filesystem --path . --report-path . --report-format json +``` + +### Severity Mapping +2ms assigns a severity and a CVSS score to each rule, and DefectDojo maps that scale +directly: + +| 2ms severity | DefectDojo severity | +| --- | --- | +| Critical | Critical | +| High | High | +| Medium | Medium | +| Low | Low | +| Info | Info | + +The `cvssScore` from the report is stored on the Finding. 2ms groups its results by the +identity it assigns each secret, so a secret found in several places produces one Finding +per location under the same group. + +### Sample Scan Data +Sample 2ms scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/two_ms). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- title +- file_path +- line +- description + +The description is part of the hashcode because 2ms can report two different secrets at the +same line of the same file; title, file path and line alone do not tell them apart. diff --git a/docs/content/supported_tools/parsers/file/uv_audit.md b/docs/content/supported_tools/parsers/file/uv_audit.md new file mode 100644 index 00000000000..3fb91837c99 --- /dev/null +++ b/docs/content/supported_tools/parsers/file/uv_audit.md @@ -0,0 +1,38 @@ +--- +title: "uv audit" +toc_hide: true +--- +Import `uv audit` reports in JSON format. uv audits a project's locked dependency set against +the Python advisory databases. + +Generate a report with: + +``` +uv audit --output-format json > uv-audit.json +``` + +### Schema stability +uv marks both the `audit` command and its JSON output as **experimental**, and warns that the +schema may change without notice. The report's own schema version is recorded in each +Finding's description so a report produced by a newer uv can be identified. If uv promotes the +command out of preview and changes the shape, this parser will need revisiting. + +### Severity Mapping +uv audit reports advisories **without any severity**, exactly as pip-audit does. Every finding +is therefore imported as **Medium**, matching the existing pip-audit parser rather than +inventing a scale uv does not publish. + +Each advisory is reported under its own identifier — typically a PYSEC id — with CVE and GHSA +identifiers listed as aliases. Only the CVE aliases are recorded as vulnerability ids; the +GHSA reference is kept in the description. Where uv reports fixed versions, the mitigation +names the upgrade target. + +### Sample Scan Data +Sample uv audit scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/uv_audit). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vuln_id_from_tool +- component_name +- component_version diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 074d0007fea..313de178173 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1329,6 +1329,47 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "Sqlmap Scan": ["title", "endpoints"], "Nettacker Scan": ["title", "endpoints"], "httpx Scan": ["title", "endpoints"], + "kube-score Scan": ["title", "component_name", "vuln_id_from_tool"], + "ModelScan Scan": ["title", "file_path", "vuln_id_from_tool"], + "TFLint Scan": ["vuln_id_from_tool", "file_path", "line"], + "Kingfisher Scan": ["title", "file_path", "line"], + "2ms Scan": ["title", "file_path", "line", "description"], + "Quark-Engine Scan": ["title", "component_name", "vuln_id_from_tool"], + "ScubaGoggles Scan": ["vuln_id_from_tool", "component_name"], + "ScubaGear Scan": ["vuln_id_from_tool", "severity"], + "kubesec Scan": ["vuln_id_from_tool", "component_name", "file_path"], + "Cloudsplaining Scan": ["vuln_id_from_tool", "component_name"], + "bomber Scan": ["vuln_id_from_tool", "component_name", "component_version"], + "sbomqs Scan": ["vuln_id_from_tool", "component_name"], + "Pluto Scan": ["vuln_id_from_tool", "component_name", "file_path"], + "GuardDog Scan": ["vuln_id_from_tool", "component_name", "file_path", "line"], + "kubent Scan": ["vuln_id_from_tool", "component_name"], + "OpenSSF Scorecard": ["vuln_id_from_tool", "component_name"], + "uv audit Scan": ["vuln_id_from_tool", "component_name", "component_version"], + "Ansible Lint Scan": ["vuln_id_from_tool", "file_path", "line"], + "ShellCheck Scan": ["vuln_id_from_tool", "file_path", "line"], + "Fickling Scan": ["vuln_id_from_tool", "severity"], + "Regula Scan": ["vuln_id_from_tool", "component_name", "file_path"], + "Threat Dragon Scan": ["title", "component_name", "severity"], + "Safety Scan": ["vuln_id_from_tool", "component_name", "component_version"], + "Slither Scan": ["vuln_id_from_tool", "file_path", "line"], + "Binwalk Scan": ["title", "file_path"], + "pip-licenses Scan": ["component_name", "component_version", "vuln_id_from_tool"], + "Syft SBOM": ["component_name", "component_version", "vuln_id_from_tool"], + "ScubaGear Report Scan": ["vuln_id_from_tool", "severity"], + "capa Scan": ["vuln_id_from_tool", "component_name"], + "ScubaGoggles Action Plan": ["vuln_id_from_tool", "severity"], + "dockerfile_lint Scan": ["title", "line"], + "Dodgy Scan": ["vuln_id_from_tool", "file_path", "line"], + "Licensecheck Scan": ["component_name", "component_version", "vuln_id_from_tool"], + "Noir Scan": ["vuln_id_from_tool", "file_path"], + "Grant Scan": ["component_name", "component_version", "vuln_id_from_tool"], + "CFRipper Scan": ["vuln_id_from_tool", "component_name"], + "Tartufo Scan": ["unique_id_from_tool"], + "Prospector Scan": ["vuln_id_from_tool", "file_path", "line"], + "Gixy Scan": ["vuln_id_from_tool", "file_path", "line"], + "Python Taint Scan": ["vuln_id_from_tool", "file_path", "line"], + "Ruff Scan": ["vuln_id_from_tool", "file_path", "line"], } # Override the hardcoded settings here via the env var @@ -1718,6 +1759,47 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "Snyk Code Scan": DEDUPE_ALGO_HASH_CODE, "Cycognito Scan": DEDUPE_ALGO_HASH_CODE, "n0s1 Scanner": DEDUPE_ALGO_HASH_CODE, + "kube-score Scan": DEDUPE_ALGO_HASH_CODE, + "ModelScan Scan": DEDUPE_ALGO_HASH_CODE, + "TFLint Scan": DEDUPE_ALGO_HASH_CODE, + "Kingfisher Scan": DEDUPE_ALGO_HASH_CODE, + "2ms Scan": DEDUPE_ALGO_HASH_CODE, + "Quark-Engine Scan": DEDUPE_ALGO_HASH_CODE, + "ScubaGoggles Scan": DEDUPE_ALGO_HASH_CODE, + "ScubaGear Scan": DEDUPE_ALGO_HASH_CODE, + "kubesec Scan": DEDUPE_ALGO_HASH_CODE, + "Cloudsplaining Scan": DEDUPE_ALGO_HASH_CODE, + "bomber Scan": DEDUPE_ALGO_HASH_CODE, + "sbomqs Scan": DEDUPE_ALGO_HASH_CODE, + "Pluto Scan": DEDUPE_ALGO_HASH_CODE, + "GuardDog Scan": DEDUPE_ALGO_HASH_CODE, + "kubent Scan": DEDUPE_ALGO_HASH_CODE, + "OpenSSF Scorecard": DEDUPE_ALGO_HASH_CODE, + "uv audit Scan": DEDUPE_ALGO_HASH_CODE, + "Ansible Lint Scan": DEDUPE_ALGO_HASH_CODE, + "ShellCheck Scan": DEDUPE_ALGO_HASH_CODE, + "Fickling Scan": DEDUPE_ALGO_HASH_CODE, + "Regula Scan": DEDUPE_ALGO_HASH_CODE, + "Threat Dragon Scan": DEDUPE_ALGO_HASH_CODE, + "Safety Scan": DEDUPE_ALGO_HASH_CODE, + "Slither Scan": DEDUPE_ALGO_HASH_CODE, + "Binwalk Scan": DEDUPE_ALGO_HASH_CODE, + "pip-licenses Scan": DEDUPE_ALGO_HASH_CODE, + "Syft SBOM": DEDUPE_ALGO_HASH_CODE, + "ScubaGear Report Scan": DEDUPE_ALGO_HASH_CODE, + "capa Scan": DEDUPE_ALGO_HASH_CODE, + "ScubaGoggles Action Plan": DEDUPE_ALGO_HASH_CODE, + "dockerfile_lint Scan": DEDUPE_ALGO_HASH_CODE, + "Dodgy Scan": DEDUPE_ALGO_HASH_CODE, + "Licensecheck Scan": DEDUPE_ALGO_HASH_CODE, + "Noir Scan": DEDUPE_ALGO_HASH_CODE, + "Grant Scan": DEDUPE_ALGO_HASH_CODE, + "CFRipper Scan": DEDUPE_ALGO_HASH_CODE, + "Tartufo Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL, + "Prospector Scan": DEDUPE_ALGO_HASH_CODE, + "Gixy Scan": DEDUPE_ALGO_HASH_CODE, + "Python Taint Scan": DEDUPE_ALGO_HASH_CODE, + "Ruff Scan": DEDUPE_ALGO_HASH_CODE, } # Override the hardcoded settings here via the env var diff --git a/dojo/tools/ansible_lint/__init__.py b/dojo/tools/ansible_lint/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/ansible_lint/parser.py b/dojo/tools/ansible_lint/parser.py new file mode 100644 index 00000000000..14b413719e2 --- /dev/null +++ b/dojo/tools/ansible_lint/parser.py @@ -0,0 +1,79 @@ +import json + +from dojo.models import Finding + + +class AnsibleLintParser: + + """ + Parser for ansible-lint JSON reports. + + ansible-lint emits Code Climate formatted JSON. Its rules cover correctness and idiom as + well as security-relevant patterns such as unvalidated certificates, world-writable + permissions and shell use where a module exists, so the rule categories are recorded on + each Finding to make triage possible. + """ + + # The Code Climate severity scale that ansible-lint emits. + SEVERITY = { + "blocker": "Critical", + "critical": "High", + "major": "Medium", + "minor": "Low", + "info": "Info", + } + + def get_scan_types(self): + return ["Ansible Lint Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import ansible-lint reports in JSON format, generated with 'ansible-lint -f json'." + + def get_findings(self, file, test): + data = json.load(file) + findings = [] + for issue in data or []: + check_name = issue.get("check_name") + location = issue.get("location") or {} + path = location.get("path") + line = self._line(location) + categories = issue.get("categories") or [] + + description = [] + if issue.get("description"): + description.append(issue["description"]) + description.append(f"**Rule:** {check_name}") + if categories: + description.append(f"**Categories:** {', '.join(categories)}") + if path: + description.append(f"**Location:** {path}:{line}" if line else f"**Location:** {path}") + + findings.append(Finding( + title=f"{check_name}: {issue.get('description')}" if issue.get("description") else check_name, + test=test, + description="\n".join(description), + severity=self.SEVERITY.get(str(issue.get("severity")).lower(), "Low"), + file_path=path, + line=line, + vuln_id_from_tool=check_name, + # ansible-lint's fingerprint is stable for a given issue in a given file. + unique_id_from_tool=issue.get("fingerprint"), + references=issue.get("url") or None, + static_finding=True, + dynamic_finding=False, + )) + return findings + + def _line(self, location): + """ansible-lint reports either a bare line or a begin/end position block.""" + positions = location.get("positions") or {} + begin = positions.get("begin") + if isinstance(begin, dict): + return begin.get("line") + if isinstance(begin, int): + return begin + lines = location.get("lines") + return lines.get("begin") if isinstance(lines, dict) else None diff --git a/dojo/tools/binwalk/__init__.py b/dojo/tools/binwalk/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/binwalk/parser.py b/dojo/tools/binwalk/parser.py new file mode 100644 index 00000000000..2c64e4d5c4c --- /dev/null +++ b/dojo/tools/binwalk/parser.py @@ -0,0 +1,66 @@ +import json + +from dojo.models import Finding + + +class BinwalkParser: + + """ + Parser for Binwalk JSON reports. + + Binwalk identifies embedded files, compression, cryptographic constants and licence + strings inside a firmware image or binary. It reports what a file *contains*, not whether + it is vulnerable, so results are imported as informational inventory. Binwalk assigns no + severity; it reports a match confidence instead. + """ + + def get_scan_types(self): + return ["Binwalk Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import Binwalk reports in JSON format, generated with 'binwalk -l report.json '." + + def get_findings(self, file, test): + data = json.load(file) + findings = [] + for entry in data or []: + analysis = entry.get("Analysis") or entry + file_path = analysis.get("file_path") + findings.extend( + self._to_finding(match, file_path, test) + for match in analysis.get("file_map") or [] + ) + return findings + + def _to_finding(self, match, file_path, test): + name = match.get("name") + offset = match.get("offset") + + description = [] + if match.get("description"): + description.append(match["description"]) + description.append(f"**Signature:** {name}") + if offset is not None: + description.append(f"**Offset:** {offset}") + if match.get("size") is not None: + description.append(f"**Size:** {match['size']}") + if match.get("confidence") is not None: + description.append(f"**Confidence:** {match['confidence']}") + if file_path: + description.append(f"**File:** {file_path}") + + return Finding( + title=f"{name} signature at offset {offset}" if offset is not None else f"{name} signature", + test=test, + description="\n".join(description), + # Binwalk identifies content; it does not judge it. + severity="Info", + file_path=file_path, + vuln_id_from_tool=name, + unique_id_from_tool=match.get("id"), + static_finding=True, + dynamic_finding=False, + ) diff --git a/dojo/tools/bomber/__init__.py b/dojo/tools/bomber/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/bomber/parser.py b/dojo/tools/bomber/parser.py new file mode 100644 index 00000000000..c67487985ae --- /dev/null +++ b/dojo/tools/bomber/parser.py @@ -0,0 +1,87 @@ +import json + +from dojo.models import Finding + + +class BomberParser: + + """ + Parser for bomber JSON reports. + + bomber reads an existing SBOM and looks each component up against a vulnerability + provider (OSV, OSS Index, Snyk or GitLab). Findings are grouped under the package + coordinates, which bomber reports as a purl. + """ + + # bomber normalises every provider onto its own scale, which uses MODERATE rather + # than the more common MEDIUM. + SEVERITY = { + "CRITICAL": "Critical", + "HIGH": "High", + "MODERATE": "Medium", + "LOW": "Low", + "UNSPECIFIED": "Info", + } + + def get_scan_types(self): + return ["bomber Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import bomber reports in JSON format, generated with 'bomber scan --output json '." + + def get_findings(self, file, test): + data = json.load(file) + provider = data.get("meta", {}).get("provider") + findings = [] + for package in data.get("packages", []): + coordinates = package.get("coordinates") + name, version = self._split_purl(coordinates) + findings.extend( + self._to_finding(vulnerability, coordinates, name, version, provider, test) + for vulnerability in package.get("vulnerabilities", []) + ) + return findings + + def _to_finding(self, vulnerability, coordinates, name, version, provider, test): + identifier = vulnerability.get("id") + cve = vulnerability.get("cve") + + description = [] + if vulnerability.get("description"): + description.append(vulnerability["description"]) + if coordinates: + description.append(f"**Package:** {coordinates}") + if provider: + description.append(f"**Provider:** {provider}") + + finding = Finding( + title=vulnerability.get("title") or identifier, + test=test, + description="\n".join(description), + severity=self.SEVERITY.get(str(vulnerability.get("severity")).upper(), "Medium"), + component_name=name, + component_version=version, + vuln_id_from_tool=identifier, + static_finding=True, + dynamic_finding=False, + ) + # bomber repeats the identifier in the cve field when the advisory has one; some + # providers return an advisory id such as GHSA-xxxx there instead. + if cve and cve.upper().startswith("CVE-"): + finding.unsaved_vulnerability_ids = [cve] + elif identifier and identifier.upper().startswith("CVE-"): + finding.unsaved_vulnerability_ids = [identifier] + return finding + + def _split_purl(self, coordinates): + """Pull the component name and version out of a purl such as pkg:maven/group/name@1.2.3.""" + if not coordinates: + return None, None + without_scheme = coordinates.split("pkg:", 1)[-1] + name, _, version = without_scheme.partition("@") + # Drop the purl type prefix (maven/, npm/, golang/ ...), keeping any namespace. + _, _, name = name.partition("/") + return name or None, version or None diff --git a/dojo/tools/capa/__init__.py b/dojo/tools/capa/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/capa/parser.py b/dojo/tools/capa/parser.py new file mode 100644 index 00000000000..75248befad2 --- /dev/null +++ b/dojo/tools/capa/parser.py @@ -0,0 +1,84 @@ +import json + +from dojo.models import Finding + + +class CapaParser: + + """ + Parser for capa JSON reports. + + capa identifies the *capabilities* of an executable — what it is able to do — and maps + each to MITRE ATT&CK and MBC. It reports no vulnerabilities and assigns no severity, so + capabilities import as informational intelligence about a sample, tagged with the + techniques they correspond to. + """ + + def get_scan_types(self): + return ["capa Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import capa reports in JSON format, generated with 'capa -j '." + + def get_findings(self, file, test): + data = json.load(file) + sample = (data.get("meta") or {}).get("sample") or {} + findings = [] + for rule in (data.get("rules") or {}).values(): + meta = rule.get("meta") or {} + # capa emits helper rules used only to build other matches; they are not + # capabilities of the sample in their own right. + if meta.get("lib") or meta.get("is_subscope_rule"): + continue + findings.append(self._to_finding(rule, meta, sample, test)) + return findings + + def _to_finding(self, rule, meta, sample, test): + name = meta.get("name") + namespace = meta.get("namespace") + attack = self._techniques(meta.get("attack")) + mbc = self._techniques(meta.get("mbc")) + + description = [] + if meta.get("description"): + description.append(meta["description"]) + description.append(f"**Capability:** {name}") + if namespace: + description.append(f"**Namespace:** {namespace}") + if attack: + description.append(f"**ATT&CK:** {', '.join(attack)}") + if mbc: + description.append(f"**MBC:** {', '.join(mbc)}") + description.append(f"**Matches:** {len(rule.get('matches') or [])}") + if sample.get("sha256"): + description.append(f"**Sample SHA256:** {sample['sha256']}") + + return Finding( + title=f"{name} ({namespace})" if namespace else name, + test=test, + description="\n".join(description), + # capa describes what a sample can do, not whether it is wrong. + severity="Info", + component_name=sample.get("sha256"), + vuln_id_from_tool=name, + references="\n".join(meta.get("references") or []) or None, + static_finding=True, + dynamic_finding=False, + ) + + def _techniques(self, entries): + """Capa reports ATT&CK and MBC as objects carrying the technique and its id.""" + values = [] + for entry in entries or []: + if not isinstance(entry, dict): + continue + technique = entry.get("subtechnique") or entry.get("technique") or entry.get("behavior") + identifier = entry.get("id") + if technique and identifier: + values.append(f"{technique} ({identifier})") + elif technique: + values.append(technique) + return values diff --git a/dojo/tools/cfripper/__init__.py b/dojo/tools/cfripper/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/cfripper/parser.py b/dojo/tools/cfripper/parser.py new file mode 100644 index 00000000000..a9d6fca6864 --- /dev/null +++ b/dojo/tools/cfripper/parser.py @@ -0,0 +1,63 @@ +import json + +from dojo.models import Finding + + +class CfripperParser: + + """ + Parser for CFRipper JSON reports. + + CFRipper audits CloudFormation templates for security misconfiguration — wildcard IAM, + privilege escalation, public resources — which is distinct from the syntax linting that + cfn-lint and cfn-nag perform. Each failure carries CFRipper's own risk value. + """ + + SEVERITY = { + "high": "High", + "medium": "Medium", + "low": "Low", + } + + def get_scan_types(self): + return ["CFRipper Scan"] + + def get_label_for_scan_types(self, scan_type): + return scan_type + + def get_description_for_scan_types(self, scan_type): + return "Import CFRipper reports in JSON format, generated with 'cfripper