Merging Nessus XML Reports with PowerShell

one of the most frequent tasks that friends have asked me is how can they merge Nessus XML reports. On this blog post I will cover how to do it using PowerShell. The process is very simple since Microsoft .Net Framework makes working with this type of data a breeze.

Wen we look at a exported Nessus in XML format the structure is a simple one as it can be seen in this screen shot:

The root element for the document is NessusClientData_v2 under it we can find as child elements:

  • Policy a copy of the policy used to perform the scan.
  • Report contains a collection of child elements called ReportHost each containing all the information about each of the scanned hosts and what was found on each.

Nessus requires that all reports must have this elements and the Policy to be a valid structured ones to be imported. With this information at hand the best way to merge the data would be by copying the ReportHost elements from one scan in to another if all have the same policy.

In PowerShell the simplest way to do this would be to read each of the files we want to join together as .Net XMLDocument objects using the PowerShell XML Type accelerator:

[xml]$Report1 = Get-Content -Path 'C:\Users\Carlos Perez\Downloads\Lab1_k6tcev.nessus'
[xml]$Report2 = Get-Content -Path 'C:\Users\Carlos Perez\Downloads\Lab2_j25p67.nessus'

now we have 2 variables containing each of the reports $report1 and $report2. We now select the ReportHost nodes from the report we want to merge using XPath and the SelectNodes() method of the XMLDocument object:

$ReportHostsToAdd = $Report2.NessusClientData_v2.Report.SelectNodes("ReportHost")

Now that we have the nodes from the report we can add them to the other report by importing each node using the ImportNode() method. Importing a node creates an XmlNode object owned by the importing document, we then import the object in to the document using the method AppendChildNode().

foreach($ReportHost in $ReportHostsToAdd)
{
    $Node = $Report1.ImportNode($ReportHost, $true)
    $Report1.NessusClientData_v2.Report.AppendChild($Node)
}

Once all the nodes have been appended under the Report Element we can now save the document using the Save() method. We need to provide it a full path.

$Report1.Save('C:\users\Carlos Perez\Desktop\consolidated.nessus')

Creating an Advanced Function

Now that we know the steps needed to merge 2 reports we can build and advanced function to use these steps. We start by using ISE Snippet menu and selecting the snippet for a Advanced Function by pressing Crtl-j

The structure of an advanced function is simple:

  • Param - Contain each of the parameters that the function will take and the options for each of the parameters.
  • Begin Block - Script block that is executed at the start of execution of the functions and is only executed once.
  • Process Block - Script block that is executed for each object the function receives in the pipeline.
  • End Block - Script block that is executed at the end of function and/or after all objects have been processed by the pipeline.

I always prefer to start by naming my function with an approved Verb and give it a descriptive none plural noun so as to follow Microsoft PowerShell Cmdlet naming guidelines. To get a list of approved verbs we can just type in a PowerShell session Get-Verb. For this function we will use the Merge verb and for noun NessusReport. After naming the function a decide if I will use parameter sets. In the case of processing files I like to follow when possible the same 2 parameters used by most PowerShell core cmdlets of Path and LiteralPath where LiteralPath is the parameter that will accepts files from the pipeline using the Alias PSPath. So my parameters for this function would be:

  1. Report - Report that will server as the master report where all other reports will merge in to. Its options are:
    • Mandatory.
    • Accepts values from the pipeline by property name.
    • First position if parameter name not specified.
    • Validate that the file exists.
  2. OutFile - The file that will contain all merged ReportHost elements.
    • Mandatory.
    • Accepts values from the pipeline by property name.
    • Second position if parameter name not specified.
  3. Path - Relative path to the file we want to merge.
    • Mandatory.
    • Accepts values from the pipeline by property name.
    • First position if parameter name not specified.
    • Validate that the file exists.
    • Part of parameter set named "Path"
  4. LiteralPath - Full path to the file we want to merge.
    • Mandatory.
    • Accepts values from the pipeline by property name.
    • First position if parameter name not specified.
    • Validate that the file exists.
    • Part of parameter set named "LiteralPath"
    • Has alias of PSPath.
  5. ReportName - Name for the merged report that is shown when imported in to Nessus.
    • Optional

After planning the parameters they can be set in the function:

    Param
    (
        # Report that will server as the master report where all other reports
        # will merge in to
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [ValidateScript({Test-Path -Path $_})]
        [string]
        $Report,

        # The file that will contain all merged ReportHost elements.
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=1)]
        [string]
        $OutFile,

        # Relative path to the file we want to merge.
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=2,
                   ParameterSetName = 'Path')]
        [ValidateScript({Test-Path -Path $_})]
        [string]
        $Path,

        # Full path to the file we want to merge.
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=2,
                   ParameterSetName = 'LiteralPath')]
        [ValidateScript({Test-Path -Path $_})]
        [Alias('PSPath')]
        [string]
        $LiteralPath,

        # Name for the merged report that is shown when imported in to Nessus.
        [Parameter(Mandatory=$true)]
        [string]
        $ReportName

    )

We can test the function and that the parameter sets are configured properly by using the Get-Help cmdlet:

Since we will be opening the master report only once to merge other reports in to it we will create the XMLDocument object in the Beguin block:

    Begin
    {
        [xml]$MainReport = Get-Content -Path $Report
    }

In the process block we will create the XMLDocument object for each of the files passed via the pipeline by selecting the proper parameter set and import the ReportHost elements from each file:

    Process
    {
        switch($PSCmdlet.ParameterSetName)
        {
            'Path' 
            { 
                Write-Verbose -Message "Merging $($Path) in to $($Report)"
                [xml]$Report2Merge = Get-Content -Path $Path
            }

            'LiteralPath' 
            {   
                Write-Verbose -Message "Merging $($LiteralPath) in to $($Report)"
                [xml]$Report2Merge = Get-Content -LiteralPath $LiteralPath
            }
        }

        $ReportHostsToAdd = $Report2Merge.NessusClientData_v2.Report.SelectNodes("ReportHost")
        foreach($ReportHost in $ReportHostsToAdd)
        {
            $Node = $MainReport.ImportNode($ReportHost, $true)
            $MainReport.NessusClientData_v2.Report.AppendChild($Node)
        }
    }

In the End block we resolve the path to the file we will save the data into and also set the name for the report if one is provided. Since the file does not exist we can not use the Resolve-Path cmdlet and need to use $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath() method to resolve a full path and save the content to the file:

    End
    {
        if ($ReportName.Length -gt 0)
        {
            $MainReport.NessusClientData_v2.Report.name = $ReportName
        }

        $MergedFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutFile)
        $MainReport.Save($MergedFile)
    }

We can now test the function by piping a list of files to it and provide the right parameters:

Hope you find this simple example useful. It can be expanded and more error handling can be added but I wanted to keep it simple.