Skip to content
This repository was archived by the owner on May 14, 2021. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ install:
- pip install -r requirements_test.txt
script:
- if [[ $TRAVIS_PYTHON_VERSION != 2.6 ]]; then flake8; fi
- py.test tests
- py.test --vcr-record-mode=none tests
34 changes: 33 additions & 1 deletion demo/run_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,37 @@ def DemonstrateTicketAPI():
print(" Comment:", repr(event.comment))


def DemonstrateReportAPI():
print('Report API')
print('----------')

# TODO: show off more of the report API functionality than just a single config save/delete
report = nexpose.ReportConfiguration('Python API Client Test Report', 'audit-report', 'raw-xml-v2')
report.add_filter('scan', 'last') # this should use site/group/tag filter(s) instead of scan in real world use
report.add_common_vuln_filters() # adds vuln filters to match UI defaults

# these imports should be at the top, or perhaps be auto imports in the init file?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on that

from nexpose.nexpose_report import Email, Delivery, Frequency, Schedule
email = Email(True, send_as='file')
email.smtp_relay_server = 'whatever.example.com'
email.sender = 'whatever@example.com'
email.recipients.append('someone@example.com')
delivery = Delivery(True, None, email)
report.delivery = delivery
schedule = Schedule('weekly', 1, "20171105T164239700")
freq = Frequency(False, True, schedule)
report.frequency = freq
report.timezone = 'America/Los_Angeles'

print('Saving report configuration...')
resp = session.SaveReportConfiguration(report)
print('Saved Report ID: {}'.format(resp))

print('Deleting report configuration with ID {}...'.format(resp))
session.DeleteReportConfiguration(resp)
print('Done with Report API demo.')


def GetNexposeLoginSettings():
"""
Returns a list with following information: hostname_or_ip, port, username, password.
Expand Down Expand Up @@ -720,13 +751,14 @@ def main():
#DemonstrateVulnerabilityAPI()
#DemonstrateVulnerabilityExceptionAPI()
#DemonstrateRoleAPI()
DemonstrateSiteAPI()
#DemonstrateSiteAPI()
#DemonstrateEngineAPI()
#DemonstrateDiscoveryConnectionAPI()
#DemonstrateScanPI()
#DemonstrateUserAPI()
#DemonstrateAssetGroupAPI()
#DemonstrateTicketAPI()
#DemonstrateReportAPI()

#print session.GenerateScanReport(1)

Expand Down
57 changes: 49 additions & 8 deletions nexpose/nexpose.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from .nexpose_engine import EngineStatus, EnginePriority, EngineBase, EngineSummary, EngineConfiguration
from .nexpose_node import NodeScanStatus, NodeBase, Node
from .nexpose_privileges import AssetGroupPrivileges, GlobalPrivileges, SitePrivileges
from .nexpose_report import ReportStatus, ReportTemplate, ReportConfigurationSummary, ReportConfiguration, ReportSummary
from .nexpose_report import AdhocReportConfiguration, ReportStatus, ReportTemplate, ReportConfigurationSummary, ReportConfiguration, ReportSummary
from .nexpose_role import RoleScope, RoleSummary, RoleDetails
from .nexpose_scansummary import VulnerabilityStatus, ScanStatus, ScanSummary, ScanSummaryNodeCounts, ScanSummaryTaskCounts, ScanSummaryVulnerability
from .nexpose_site import Host, Range, SiteBase, SiteSummary, SiteConfiguration
Expand Down Expand Up @@ -284,7 +284,7 @@ def GetSecurityConsoleStatus(self):
try:
response = OpenWebRequest(self._URI_root, None, {}, self.timeout)
return NexposeStatus.GetStatusFromURL(response.geturl())
except:
except Exception:
return NexposeStatus.UNKNOWN


Expand Down Expand Up @@ -596,10 +596,15 @@ def RequestReportConfig(self, reportconfiguration_id):
Retreive the detailed report configuration (definition) of the specified report configuration.
This function will return a single ReportConfigResponse XML object (API 1.1).
"""
return self.ExecuteBasicOnReportConfiguration("ReportConfigRequest", reportconfiguration_id)
response = self.ExecuteBasicOnReportConfiguration("ReportConfigRequest", reportconfiguration_id)
return get_element(response, 'ReportConfig')

def RequestReportSave(self):
raise NotImplementedError() # TODO
def RequestReportSave(self, report_configuration, generate_now=False):
"""
Save a report configuration and optionally generate a report immediately.
This function will return a single ReportSaveResponse XML object (API 1.1).
"""
return self.ExecuteBasicWithElement('ReportSaveRequest', {'generate-now': 1 if generate_now else 0}, report_configuration)

def RequestReportGenerate(self, reportconfiguration_id):
"""
Expand All @@ -620,6 +625,13 @@ def RequestReportDelete(self, report_id, reportconfiguration_id=0):
else:
return self.ExecuteBasicOnReport("ReportDeleteRequest", report_id)

def RequestAdhocReport(self, report_config):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite understand why this is a new function...? Couldn't we just re-use the one below? Or do you want to make breaking changes here that wouldn't work with the below function?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't really remember why I made new ones, but they don't need to be new. I am not terribly concerned about backwards compatibility either at this point since there is a lot of work needed to get this to be on par with the ruby gem for functionality.

"""
Generate a new report using the specified report configuration (definition).
This function will return a single ReportGenerateResponse XML object (API 1.1).
"""
return self.ExecuteBasicWithElement('ReportAdhocGenerateRequest', {}, as_xml(report_config))

def RequestReportAdhocGenerate(self, id):
request = """
<AdhocReportConfig format="raw-xml-v2" template-id="audit-report">
Expand All @@ -629,7 +641,6 @@ def RequestReportAdhocGenerate(self, id):
</AdhocReportConfig>
"""
return self.ExecuteBasicWithElement("ReportAdhocGenerateRequest", {}, as_xml(request.format(id)))
raise NotImplementedError() # TODO

#
# The following functions implement the User Management API:
Expand Down Expand Up @@ -1466,7 +1477,7 @@ def GetAssetGroupConfiguration(self, assetgroup_or_id):
asset_group.description = json_dict.get['description']
if asset_group.description is None:
asset_group.description = asset_group.short_description
except:
except Exception:
pass

return asset_group
Expand Down Expand Up @@ -1969,7 +1980,7 @@ def GetReportConfigurationDetails(self, report_or_configuration_or_id):
report_or_configuration_or_id = report_or_configuration_or_id.configuration_id
response = self.VerifySuccess(self.RequestReportConfig(report_or_configuration_or_id))
element = get_element(response, 'ReportConfig')
return ReportConfigurationSummary.CreateFromXML(element) # TODO: THIS MUST BE A FULL CONFIGURATION
return ReportConfiguration.CreateFromXML(element) # TODO: THIS MUST BE A FULL CONFIGURATION

def GetReportHistory(self, reportconfiguration_or_id):
"""
Expand Down Expand Up @@ -2048,6 +2059,36 @@ def GenerateScanReport(self, scan_or_id):
body = ''.join(data[4:-1])
return as_xml(base64.urlsafe_b64decode(body))

def GenerateAdHocReport(self, adhoc_report_configuration):
"""
Generate adhoc report and return decoded contents.
"""
# TODO: add optional filename param to store the report to disk instead of memory
self._RequireInstanceOf(adhoc_report_configuration, AdhocReportConfiguration)
data = self.RequestAdhocReport(adhoc_report_configuration.AsXML())
data = self.VerifySuccess(data)
# TODO: figure out a way to handle this safely/correctly for different formats
data = data.tail.replace('\r', '').strip().split('\n')
# assert data[1] == 'Content-Type: text/xml; name=report.xml'
assert data[2] == 'Content-Transfer-Encoding: base64'
assert data[3] == ''
assert data[0] == data[-1][:-2]
body = ''.join(data[4:-1])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had experience when requesting an XML report & within the response there were multiple report items.
i.e. the response would look like this:

<ReportAdhocGenerateResponse success="1"/>
--AxB9sl3299asdjvbA
Content-Type: image/gif; name=Chart00000001.gif
Content-Transfer-Encoding: base64
<Base64 encoded gif>
--AxB9sl3299asdjvbA
Content-Type: image/gif; name=Chart00000005.gif
Content-Transfer-Encoding: base64
<Base64 encoded gif>
--AxB9sl3299asdjvbA
Content-Type: text/xml; name=report.xml
Content-Transfer-Encoding: base64
<Base64 encoded xml>
--AxB9sl3299asdjvbA
Content-Type: image/gif; name=Chart00000002.gif
Content-Transfer-Encoding: base64
<Base64 encoded gif>

So in this case, there should be some looping trying to find the actual report.

Also, it's really confusing to see splits & then calling each element without an example response.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is just copied from the existing adhoc report method (which only takes a scan id) and it seems pretty limited. I would like this to be able to work the way the ruby gem does where you can write to file instead of holding it all in memory. Being able to capture the extra attachments (like charts) would be nice, but the ruby gem doesn't support that either so I'm not super concerned about it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this. After experimenting a bit, it is possible to use Python's MIME parser to handle these cases but you need to modify the input slightly, i.e. prefix some headers. Here is an excerpt of how we implemented it:

        full_msg = 'Content-Type: {}\r\n\r\n{}'.format(
            resp.headers['Content-Type'],
            resp.text,
        )
        msg = email.message_from_string(full_msg)
        if not msg.is_multipart():
            raise RuntimeError("Got unexpected (non multipart MIME) response from GenerateAdhocReport: {}".format(msg))


        vuln_data = None
        # There will be several parts in the response (images etc.),
        # so linear search for the part that is the actual report.
        for part in msg.walk():
            if part.get_filename() == "report.csv":
                assert part.get_content_type() == 'text/csv'

                # Decode the bytes.
                csv_ = part.get_payload(decode=True).decode('utf8')
                vuln_data = csv.DictReader(io.StringIO(csv_))
                break
        else:
            raise RuntimeError("Could not find report in GenerateAdhocReport response")

A similar approach could work here as well I assume. That way all the verification can be deferred to a library but the content needs to be prepared properly.

return base64.urlsafe_b64decode(body)

def SaveReportConfiguration(self, report_configuration, generate_now=False):
"""
Save the configuration of a report and return the id of the saved report config.
If successful, the id will also have been updated in the provided ReportConfiguration object.
To create a new report, specify -1 as id.
"""
self._RequireInstanceOf(report_configuration, ReportConfiguration)
response = self.RequestReportSave(report_configuration.AsXML(exclude_id=False), generate_now)
self.VerifySuccess(response)
report_cfg_id = int(get_attribute(response, 'reportcfg-id'))
report_configuration.id = report_cfg_id
return report_cfg_id

#
# The following functions implement the Role Management API:
# =========================================================
Expand Down
Loading