diff --git a/dist/ModuleInfo.json b/dist/ModuleInfo.json index 942cfdc..27fd703 100644 --- a/dist/ModuleInfo.json +++ b/dist/ModuleInfo.json @@ -1,6 +1,6 @@ { - "Version": "1.4.0", - "ReleaseTag": "Interested Siamese Fighting Fish", + "Version": "1.4.2", + "ReleaseTag": "Broken-Stair", "Name": "LogRhythm.Tools", "Psm1": "LogRhythm.Tools.psm1", "Psd1": "LogRhythm.Tools.psd1", diff --git a/dist/common/LogRhythm.Tools.json b/dist/common/LogRhythm.Tools.json index a2c6328..700e16e 100644 --- a/dist/common/LogRhythm.Tools.json +++ b/dist/common/LogRhythm.Tools.json @@ -23,6 +23,11 @@ "Token": "" }, + "Qualys": { + "BaseUrl": "https://qualysapi.qualys.com/", + "Credential": "" + }, + "LogRhythmEcho": { "BaseUrl": "https://[NOT_SET]:33333/api" }, diff --git a/dist/installer/config/Lrt.Config.Input.json b/dist/installer/config/Lrt.Config.Input.json index dea5035..778455d 100644 --- a/dist/installer/config/Lrt.Config.Input.json +++ b/dist/installer/config/Lrt.Config.Input.json @@ -86,7 +86,21 @@ "Fields": {} }, - + "Qualys": { + "Name": "Qualys", + "Optional": true, + "Message": "Use of Qualys requires an API key.", + "HasCredential": true, + + "Fields": { + "BaseUrl": { + "Prompt": "Qualys Base URL", + "Hint": "FQDN - Example: https://qualysapi.qualys.com", + "InputCmd": "Get-InputApiUrl", + "FallThru": false + } + } + }, "LogRhythmEcho": { "Name": "LogRhythm Echo", diff --git a/examples/qualys/Sync-ExabeamPHI500ToQualys.ps1 b/examples/qualys/Sync-ExabeamPHI500ToQualys.ps1 new file mode 100644 index 0000000..8ef12ad --- /dev/null +++ b/examples/qualys/Sync-ExabeamPHI500ToQualys.ps1 @@ -0,0 +1,746 @@ +<# +.SYNOPSIS + Synchronize Exabeam PHI 500 - Endpoints context table to Qualys tags. + +.DESCRIPTION + This script pulls data from the Exabeam context table "PHI 500 - Endpoints" and + applies the "PHI500-E" tag to matching assets in Qualys. + + The script: + 1. Retrieves hostnames from Exabeam context table + 2. Strips domain from FQDN hostnames + 3. Searches for matching assets in Qualys + 4. Applies the PHI500-E tag to matched assets + 5. Optionally removes the tag from assets no longer in Exabeam (with -RemoveStale) + +.PARAMETER TestMode + When enabled, performs all operations except applying/removing tags in Qualys. + This allows you to see which assets would be modified without making changes. + +.PARAMETER RemoveStale + When enabled, removes the PHI500-E tag from Qualys assets that are no longer + in the Exabeam context table. This ensures Qualys reflects the current state + of Exabeam as the source of truth. + +.PARAMETER ExabeamTableName + Name of the Exabeam context table to query. Defaults to "PHI 500 - Endpoints". + +.PARAMETER QualysTagName + Name of the Qualys tag to apply. Defaults to "PHI500-E". + +.PARAMETER ExabeamCredential + PSCredential for Exabeam API. If not provided, uses $LrtConfig.Exabeam.Credential. + +.PARAMETER QualysCredential + PSCredential for Qualys API. If not provided, uses $LrtConfig.Qualys.Credential. + +.PARAMETER WebhookToken + Bearer token for webhook logging to Exabeam cloud collector endpoint. + When provided, the script will send progress and activity logs to: + https://api2.sa.exabeam.cloud/cloud-collectors/v1/logs/json + +.EXAMPLE + PS C:\> .\Sync-ExabeamPHI500ToQualys.ps1 -TestMode + + Runs in test mode, showing which assets would be tagged without making changes. + +.EXAMPLE + PS C:\> .\Sync-ExabeamPHI500ToQualys.ps1 + + Applies the PHI500-E tag to all matching Qualys assets. + +.EXAMPLE + PS C:\> .\Sync-ExabeamPHI500ToQualys.ps1 -RemoveStale -TestMode + + Shows which assets would have the tag removed (stale assets not in Exabeam). + +.EXAMPLE + PS C:\> .\Sync-ExabeamPHI500ToQualys.ps1 -RemoveStale + + Adds tags to matching assets AND removes tags from assets no longer in Exabeam. + +.EXAMPLE + PS C:\> .\Sync-ExabeamPHI500ToQualys.ps1 -TestMode -Verbose + + Runs in test mode with verbose output showing detailed progress. + +.EXAMPLE + PS C:\> .\Sync-ExabeamPHI500ToQualys.ps1 -RemoveStale -WebhookToken "your-bearer-token-here" + + Full sync with webhook logging enabled. Logs will be sent to Exabeam cloud collector. + +.NOTES + Requires: + - LogRhythm.Tools module with Exabeam and Qualys cmdlets + - Exabeam context table: PHI 500 - Endpoints + - Qualys tag: PHI500-E (must exist before running) + +.LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools +#> + +[CmdletBinding()] +Param( + [Parameter(Mandatory = $false)] + [switch] $TestMode, + + [Parameter(Mandatory = $false)] + [switch] $RemoveStale, + + [Parameter(Mandatory = $false)] + [string] $ExabeamTableName = "PHI 500 - Endpoints", + + [Parameter(Mandatory = $false)] + [string] $QualysTagName = "PHI500-E", + + [Parameter(Mandatory = $false)] + [pscredential] $ExabeamCredential, + + [Parameter(Mandatory = $false)] + [pscredential] $QualysCredential, + + [Parameter(Mandatory = $false)] + [string] $WebhookToken +) + +#region: Webhook Configuration + +# Webhook endpoint for logging activity +$EnableWebhookLogging = $true +$WebhookEndpoint = "" +$WebhookToken = '' +# Sync job name for tracking +$SyncJobName = "$ExabeamTableName -> Qualys Tag: $QualysTagName" + +#endregion + +#region: Helper Functions + +Function Send-WebhookLog { + <# + .SYNOPSIS + Sends a log entry to the webhook endpoint. + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true)] + [string] $Message, + + [Parameter(Mandatory = $true)] + [ValidateSet('Info', 'Success', 'Warning', 'Error')] + [string] $Status, + + [Parameter(Mandatory = $true)] + [string] $Task, + + [Parameter(Mandatory = $false)] + [string] $Hostname, + + [Parameter(Mandatory = $false)] + [string] $JobName + ) + + if (-not $EnableWebhookLogging) { + Write-Verbose "[WEBHOOK] Logging disabled - skipping" + return + } + + try { + # Generate ISO 8601 timestamp + $Timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fffZ" + + $LogEntry = @{ + sourcekey = "Exabeam2QualysPHI500-Endpoint" + timestamp = $Timestamp + message = $Message + status = $Status + task = $Task + } + + # Add hostname if provided + if ($Hostname) { + $LogEntry.hostname = $Hostname + } + + # Add job name if provided, otherwise use script-level variable + if ($JobName) { + $LogEntry.job = $JobName + } elseif ($script:SyncJobName) { + $LogEntry.job = $script:SyncJobName + } + + # Build headers using Dictionary type (consistent with other Exabeam cmdlets) + $Headers = [System.Collections.Generic.Dictionary[string,string]]::new() + $Headers.Add("Authorization", "Bearer $WebhookToken") + $Headers.Add("Content-Type", "application/json") + + $Body = $LogEntry | ConvertTo-Json -Depth 2 -Compress + + Write-Verbose "[WEBHOOK] Enabled: $EnableWebhookLogging" + Write-Verbose "[WEBHOOK] Endpoint: $WebhookEndpoint" + Write-Verbose "[WEBHOOK] Token length: $($WebhookToken.Length) chars" + Write-Verbose "[WEBHOOK] Token starts with: $($WebhookToken.Substring(0, [Math]::Min(20, $WebhookToken.Length)))..." + Write-Verbose "[WEBHOOK] Headers: Authorization=Bearer [REDACTED], Content-Type=application/json" + Write-Verbose "[WEBHOOK] Body: $Body" + + $Response = Invoke-RestMethod -Uri $WebhookEndpoint -Method Post -Headers $Headers -Body $Body -ErrorAction Stop + + Write-Verbose "[WEBHOOK] ✓ Successfully sent - Status: $Status, Task: $Task" + if ($Response) { + Write-Verbose "[WEBHOOK] Response: $($Response | ConvertTo-Json -Compress)" + } + } catch { + Write-Verbose "[WEBHOOK] ✗ Failed to send webhook log" + Write-Verbose "[WEBHOOK] Error: $($_.Exception.Message)" + if ($_.Exception.Response) { + Write-Verbose "[WEBHOOK] HTTP Status: $($_.Exception.Response.StatusCode.value__) - $($_.Exception.Response.StatusDescription)" + } + if ($_.ErrorDetails) { + Write-Verbose "[WEBHOOK] Error Details: $($_.ErrorDetails.Message)" + } + } +} + +Function Get-HostnameWithoutDomain { + <# + .SYNOPSIS + Extracts hostname from FQDN. + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true)] + [string] $Hostname + ) + + # If hostname contains a dot, split and take first part + if ($Hostname -match '\.') { + return $Hostname.Split('.')[0] + } + + # Otherwise return as-is + return $Hostname +} + +#endregion + +#region: Initialization + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Exabeam to Qualys PHI500 Tag Sync" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +if ($TestMode) { + Write-Host "[TEST MODE] No changes will be made to Qualys" -ForegroundColor Yellow + Write-Host "" +} + +if ($RemoveStale) { + Write-Host "[REMOVE STALE MODE] Tags will be removed from assets not in Exabeam" -ForegroundColor Yellow + Write-Host "" +} + +# Log script start +Send-WebhookLog -Message "Script execution started - TestMode: $TestMode, RemoveStale: $RemoveStale" -Status Info -Task "Initialization" + +# Statistics +$Stats = @{ + ExabeamHostsRetrieved = 0 + QualysAssetsFound = 0 + QualysAssetsNotFound = 0 + TagsAlreadyPresent = 0 + TagsApplied = 0 + TagsRemoved = 0 + StaleAssetsFound = 0 + Errors = 0 +} + +# Results collection +$Results = [System.Collections.ArrayList]::new() +$StaleResults = [System.Collections.ArrayList]::new() + +#endregion + +#region: Step 1 - Get Qualys Tag ID + +Write-Host "[Step 1/4] Looking up Qualys tag: $QualysTagName" -ForegroundColor White + +try { + $GetTagParams = @{ + Name = $QualysTagName + Exact = $true + } + if ($QualysCredential) { + $GetTagParams.Credential = $QualysCredential + } + + $QualysTag = Get-QualysTags @GetTagParams + + if (-not $QualysTag -or $QualysTag.Error) { + Write-Host " [ERROR] Qualys tag '$QualysTagName' not found or API error occurred" -ForegroundColor Red + Write-Host " Please create the tag in Qualys before running this script" -ForegroundColor Yellow + exit 1 + } + + # Handle case where multiple tags are returned + if ($QualysTag -is [System.Array]) { + $QualysTag = $QualysTag[0] + Write-Verbose " Multiple tags found, using first match" + } + + $QualysTagId = $QualysTag.id + Write-Host " [OK] Found Qualys tag: $QualysTagName (ID: $QualysTagId)" -ForegroundColor Green +} catch { + Write-Host " [ERROR] Failed to retrieve Qualys tag: $_" -ForegroundColor Red + exit 1 +} + +Write-Host "" + +#endregion + +#region: Step 2 - Get Exabeam Context Table Records + +Write-Host "[Step 2/4] Retrieving records from Exabeam context table: $ExabeamTableName" -ForegroundColor White + +try { + # First, get the table ID by name + $GetTableParams = @{ + Name = $ExabeamTableName + Exact = $true + } + if ($ExabeamCredential) { + $GetTableParams.Credential = $ExabeamCredential + } + + $ExabeamTable = Get-ExaContextTables @GetTableParams + + if (-not $ExabeamTable) { + Write-Host " [ERROR] Exabeam context table '$ExabeamTableName' not found" -ForegroundColor Red + Write-Host " Please verify the table name exists in Exabeam" -ForegroundColor Yellow + exit 1 + } + + # Handle case where multiple tables are returned + if ($ExabeamTable -is [System.Array]) { + $ExabeamTable = $ExabeamTable[0] + Write-Verbose " Multiple tables found, using first match" + } + + $TableId = $ExabeamTable.id + Write-Verbose " Found Exabeam table: $ExabeamTableName (ID: $TableId)" + + # Now get the records using the table ID + $GetRecordsParams = @{ + id = $TableId + } + if ($ExabeamCredential) { + $GetRecordsParams.Credential = $ExabeamCredential + } + + $ExabeamResponse = Get-ExaContextRecords @GetRecordsParams + + if (-not $ExabeamResponse -or -not $ExabeamResponse.records) { + Write-Host " [WARNING] No records found in Exabeam table" -ForegroundColor Yellow + exit 0 + } + + $ExabeamRecords = $ExabeamResponse.records + $Stats.ExabeamHostsRetrieved = @($ExabeamRecords).Count + Write-Host " [OK] Retrieved $($Stats.ExabeamHostsRetrieved) records from Exabeam" -ForegroundColor Green + + Send-WebhookLog -Message "Retrieved $($Stats.ExabeamHostsRetrieved) records from source table: $ExabeamTableName" -Status Success -Task "Exabeam Source Retrieval" +} catch { + Write-Host " [ERROR] Failed to retrieve Exabeam records: $_" -ForegroundColor Red + Send-WebhookLog -Message "Failed to retrieve Exabeam records: $($_.Exception.Message)" -Status Error -Task "Retrieve Exabeam Records" + $Stats.Errors++ + exit 1 +} + +Write-Host "" + +#endregion + +#region: Step 3 - Process Each Hostname + +Write-Host "[Step 3/4] Processing hostnames and searching Qualys" -ForegroundColor White + +foreach ($Record in $ExabeamRecords) { + # Extract hostname attribute (adjust based on your Exabeam table structure) + # Common attributes: hostname, host, asset_name, etc. + $Hostname = $null + + # Debug: Show available properties on first record + if ($ExabeamRecords.IndexOf($Record) -eq 0) { + Write-Verbose " First record properties: $($Record.PSObject.Properties.Name -join ', ')" + } + + # Try common attribute names + if ($Record.hostname) { + $Hostname = $Record.hostname + Write-Verbose " Using 'hostname' property: $Hostname" + } elseif ($Record.host) { + $Hostname = $Record.host + Write-Verbose " Using 'host' property: $Hostname" + } elseif ($Record.asset_name) { + $Hostname = $Record.asset_name + Write-Verbose " Using 'asset_name' property: $Hostname" + } elseif ($Record.name) { + $Hostname = $Record.name + Write-Verbose " Using 'name' property: $Hostname" + } + + if (-not $Hostname) { + Write-Host " [WARNING] No hostname found in record. Available properties: $($Record.PSObject.Properties.Name -join ', ')" -ForegroundColor Yellow + continue + } + + # Strip domain from FQDN + $ShortHostname = Get-HostnameWithoutDomain -Hostname $Hostname + Write-Verbose " Processing: $Hostname -> $ShortHostname" + + # Search for asset in Qualys + try { + # Try uppercase search first (Qualys typically stores hostnames in uppercase) + $UpperHostname = $ShortHostname.ToUpper() + $QualysParams = @{ + Name = $UpperHostname + } + if ($QualysCredential) { + $QualysParams.Credential = $QualysCredential + } + + Write-Verbose " Searching Qualys for: $UpperHostname" + $QualysAssets = Get-QualysHostAssets @QualysParams + Write-Verbose " Qualys returned: $($QualysAssets.Count) result(s)" + + # If not found and original was different case, try original case as fallback + if ((-not $QualysAssets -or $QualysAssets.Count -eq 0) -and ($ShortHostname -cne $UpperHostname)) { + Write-Verbose " Trying original case search: $ShortHostname" + $QualysParams.Name = $ShortHostname + $QualysAssets = Get-QualysHostAssets @QualysParams + Write-Verbose " Qualys returned: $($QualysAssets.Count) result(s)" + + if (-not $QualysAssets -or $QualysAssets.Count -eq 0) { + # Keep uppercase for reporting consistency + $ShortHostname = $UpperHostname + } + } else { + # Use uppercase for reporting + $ShortHostname = $UpperHostname + } + + if ($QualysAssets -and -not $QualysAssets.Error) { + # Handle multiple matches + $MatchedAssets = @($QualysAssets) + Write-Verbose " Processing $($MatchedAssets.Count) matched asset(s)" + + foreach ($Asset in $MatchedAssets) { + $Stats.QualysAssetsFound++ + + $ResultEntry = [PSCustomObject]@{ + ExabeamHostname = $Hostname + ShortHostname = $ShortHostname + QualysAssetId = $Asset.id + QualysAssetName = $Asset.name + QualysAddress = $Asset.address + TagApplied = $false + TestMode = $TestMode + Error = $null + } + + Write-Host " [FOUND] $ShortHostname -> Qualys Asset ID: $($Asset.id) ($($Asset.name))" -ForegroundColor Green + Send-WebhookLog -Message "Asset found in Qualys" -Status Success -Task "Qualys Lookup" -Hostname $Asset.name + + # Retrieve full asset details to check tags + Write-Verbose " Retrieving full asset details for ID: $($Asset.id)" + try { + $GetAssetParams = @{ + Id = $Asset.id + } + if ($QualysCredential) { + $GetAssetParams.Credential = $QualysCredential + } + + $FullAsset = Get-QualysHostAsset @GetAssetParams + + if ($FullAsset -and -not $FullAsset.Error) { + # Check if tag already exists on this asset + $TagAlreadyExists = $false + if ($FullAsset.tags -and $FullAsset.tags.list) { + $ExistingTagIds = @($FullAsset.tags.list.TagSimple | ForEach-Object { $_.id }) + Write-Verbose " Asset has $($ExistingTagIds.Count) existing tag(s): $($ExistingTagIds -join ', ')" + if ($ExistingTagIds -contains $QualysTagId) { + $TagAlreadyExists = $true + } + } else { + Write-Verbose " Asset has no existing tags" + } + + if ($TagAlreadyExists) { + Write-Host " [SKIP] Tag already present on asset" -ForegroundColor Cyan + $ResultEntry.TagApplied = $true + $Stats.TagsAlreadyPresent++ + Send-WebhookLog -Message "Tag $QualysTagName already exists on asset (skipped)" -Status Info -Task "Tag Management" -Hostname $Asset.name + } else { + # Apply tag (unless in test mode) + if (-not $TestMode) { + try { + $UpdateParams = @{ + Id = $Asset.id + AddTagIds = @($QualysTagId) + } + if ($QualysCredential) { + $UpdateParams.Credential = $QualysCredential + } + + $UpdateResult = Update-QualysHostAsset @UpdateParams + + if ($UpdateResult.Error) { + Write-Host " [ERROR] Failed to apply tag: $($UpdateResult.Note)" -ForegroundColor Red + $ResultEntry.Error = $UpdateResult.Note + $Stats.Errors++ + Send-WebhookLog -Message "Failed to apply tag: $($UpdateResult.Note)" -Status Error -Task "Tag Management" -Hostname $Asset.name + } else { + Write-Host " [OK] Tag applied successfully" -ForegroundColor Green + $ResultEntry.TagApplied = $true + $Stats.TagsApplied++ + Send-WebhookLog -Message "Tag $QualysTagName added to asset" -Status Success -Task "Tag Management" -Hostname $Asset.name + } + } catch { + Write-Host " [ERROR] Exception applying tag: $_" -ForegroundColor Red + $ResultEntry.Error = $_.Exception.Message + $Stats.Errors++ + Send-WebhookLog -Message "Exception applying tag: $($_.Exception.Message)" -Status Error -Task "Tag Management" -Hostname $Asset.name + } + } else { + Write-Host " [TEST MODE] Would apply tag: $QualysTagName" -ForegroundColor Yellow + $ResultEntry.TagApplied = $null # Null indicates test mode + Send-WebhookLog -Message "Would apply tag $QualysTagName (test mode)" -Status Info -Task "Tag Management" -Hostname $Asset.name + } + } + } else { + Write-Host " [ERROR] Failed to retrieve full asset details" -ForegroundColor Red + $ResultEntry.Error = "Failed to retrieve full asset details" + $Stats.Errors++ + } + } catch { + Write-Host " [ERROR] Exception retrieving asset details: $_" -ForegroundColor Red + $ResultEntry.Error = $_.Exception.Message + $Stats.Errors++ + } + + [void]$Results.Add($ResultEntry) + } + } else { + $Stats.QualysAssetsNotFound++ + $Stats.Errors++ # Asset not found is an error (404) + + # Provide more detailed error information + if ($QualysAssets.Error) { + Write-Host " [ERROR] $ShortHostname - Qualys API error: $($QualysAssets.Note)" -ForegroundColor Red + $ErrorMessage = "Qualys API error: $($QualysAssets.Note)" + Send-WebhookLog -Message "Qualys API error: $($QualysAssets.Note)" -Status Error -Task "Qualys Lookup" -Hostname $ShortHostname + } else { + Write-Host " [NOT FOUND] $ShortHostname - No matching asset in Qualys" -ForegroundColor DarkYellow + $ErrorMessage = "Asset not found in Qualys (404)" + Send-WebhookLog -Message "Asset not found in Qualys" -Status Error -Task "Qualys Lookup" -Hostname $ShortHostname + } + + $ResultEntry = [PSCustomObject]@{ + ExabeamHostname = $Hostname + ShortHostname = $ShortHostname + QualysAssetId = $null + QualysAssetName = $null + QualysAddress = $null + TagApplied = $false + TestMode = $TestMode + Error = $ErrorMessage + } + [void]$Results.Add($ResultEntry) + } + } catch { + Write-Host " [ERROR] Exception searching for $ShortHostname : $_" -ForegroundColor Red + $Stats.Errors++ + + $ResultEntry = [PSCustomObject]@{ + ExabeamHostname = $Hostname + ShortHostname = $ShortHostname + QualysAssetId = $null + QualysAssetName = $null + QualysAddress = $null + TagApplied = $false + TestMode = $TestMode + Error = $_.Exception.Message + } + [void]$Results.Add($ResultEntry) + } +} + +Write-Host "" + +#endregion + +#region: Step 4 - Remove Stale Tags (if enabled) + +if ($RemoveStale) { + Write-Host "[Step 4/5] Removing stale tags from Qualys assets" -ForegroundColor White + + # Build a HashSet of uppercase hostnames from Exabeam for fast lookup + $ExabeamHostnameSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Record in $ExabeamRecords) { + $Hostname = $null + if ($Record.hostname) { + $Hostname = $Record.hostname + } elseif ($Record.host) { + $Hostname = $Record.host + } elseif ($Record.asset_name) { + $Hostname = $Record.asset_name + } elseif ($Record.name) { + $Hostname = $Record.name + } + + if ($Hostname) { + $ShortHostname = Get-HostnameWithoutDomain -Hostname $Hostname + [void]$ExabeamHostnameSet.Add($ShortHostname.ToUpper()) + } + } + + Write-Verbose " Built lookup set with $($ExabeamHostnameSet.Count) Exabeam hostnames" + + # Get all Qualys assets that have the PHI500-E tag + try { + $TaggedAssetParams = @{ + TagId = $QualysTagId + } + if ($QualysCredential) { + $TaggedAssetParams.Credential = $QualysCredential + } + + Write-Verbose " Retrieving all Qualys assets with tag: $QualysTagName (ID: $QualysTagId)" + $TaggedAssets = Get-QualysHostAssets @TaggedAssetParams + + if ($TaggedAssets -and -not $TaggedAssets.Error) { + $TaggedAssetsList = @($TaggedAssets) + Write-Host " Found $($TaggedAssetsList.Count) Qualys assets with tag $QualysTagName" -ForegroundColor Cyan + + foreach ($Asset in $TaggedAssetsList) { + $AssetName = $Asset.name.ToUpper() + + # Check if this asset exists in Exabeam + if (-not $ExabeamHostnameSet.Contains($AssetName)) { + $Stats.StaleAssetsFound++ + + $StaleEntry = [PSCustomObject]@{ + QualysAssetId = $Asset.id + QualysAssetName = $Asset.name + QualysAddress = $Asset.address + TagRemoved = $false + TestMode = $TestMode + Error = $null + } + + Write-Host " [STALE] $($Asset.name) (ID: $($Asset.id)) - Not in Exabeam list" -ForegroundColor Magenta + Send-WebhookLog -Message "Stale asset detected - not in Exabeam source" -Status Warning -Task "Stale Detection" -Hostname $Asset.name + + # Remove tag (unless in test mode) + if (-not $TestMode) { + try { + $RemoveParams = @{ + Id = $Asset.id + RemoveTagIds = @($QualysTagId) + } + if ($QualysCredential) { + $RemoveParams.Credential = $QualysCredential + } + + $RemoveResult = Update-QualysHostAsset @RemoveParams + + if ($RemoveResult.Error) { + Write-Host " [ERROR] Failed to remove tag: $($RemoveResult.Note)" -ForegroundColor Red + $StaleEntry.Error = $RemoveResult.Note + $Stats.Errors++ + Send-WebhookLog -Message "Failed to remove tag: $($RemoveResult.Note)" -Status Error -Task "Tag Management" -Hostname $Asset.name + } else { + Write-Host " [OK] Tag removed successfully" -ForegroundColor Green + $StaleEntry.TagRemoved = $true + $Stats.TagsRemoved++ + Send-WebhookLog -Message "Tag $QualysTagName removed from stale asset" -Status Success -Task "Tag Management" -Hostname $Asset.name + } + } catch { + Write-Host " [ERROR] Exception removing tag: $_" -ForegroundColor Red + $StaleEntry.Error = $_.Exception.Message + $Stats.Errors++ + Send-WebhookLog -Message "Exception removing tag: $($_.Exception.Message)" -Status Error -Task "Tag Management" -Hostname $Asset.name + } + } else { + Write-Host " [TEST MODE] Would remove tag: $QualysTagName" -ForegroundColor Yellow + $StaleEntry.TagRemoved = $null # Null indicates test mode + Send-WebhookLog -Message "Would remove tag $QualysTagName (test mode)" -Status Info -Task "Tag Management" -Hostname $Asset.name + } + + [void]$StaleResults.Add($StaleEntry) + } else { + Write-Verbose " [CURRENT] $($Asset.name) - Still in Exabeam list" + } + } + } else { + if ($TaggedAssets.Error) { + Write-Host " [ERROR] Failed to retrieve tagged assets: $($TaggedAssets.Note)" -ForegroundColor Red + } else { + Write-Host " No assets found with tag $QualysTagName" -ForegroundColor DarkYellow + } + } + } catch { + Write-Host " [ERROR] Exception retrieving tagged assets: $_" -ForegroundColor Red + $Stats.Errors++ + } + + Write-Host "" +} + +#endregion + +#region: Step 5 - Summary + +Write-Host "[Step $(@{$true=5;$false=4}[$RemoveStale])/5] Summary" -ForegroundColor White +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Exabeam hosts retrieved: $($Stats.ExabeamHostsRetrieved)" -ForegroundColor White +Write-Host "Qualys assets found: $($Stats.QualysAssetsFound)" -ForegroundColor Green +Write-Host "Qualys assets not found: $($Stats.QualysAssetsNotFound)" -ForegroundColor DarkYellow + +if ($TestMode) { + Write-Host "Tags that would be applied: $($Stats.QualysAssetsFound - $Stats.TagsAlreadyPresent)" -ForegroundColor Yellow +} else { + Write-Host "Tags already present: $($Stats.TagsAlreadyPresent)" -ForegroundColor Cyan + Write-Host "Tags applied successfully: $($Stats.TagsApplied)" -ForegroundColor Green +} + +if ($RemoveStale) { + Write-Host "Stale assets found: $($Stats.StaleAssetsFound)" -ForegroundColor Magenta + if ($TestMode) { + Write-Host "Tags that would be removed: $($Stats.StaleAssetsFound)" -ForegroundColor Yellow + } else { + Write-Host "Tags removed successfully: $($Stats.TagsRemoved)" -ForegroundColor Green + } +} + +if ($Stats.Errors -gt 0) { + Write-Host "Errors encountered: $($Stats.Errors)" -ForegroundColor Red +} + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +if ($TestMode) { + Write-Host "[TEST MODE] No changes were made to Qualys" -ForegroundColor Yellow + Write-Host "Run without -TestMode to apply/remove tags" -ForegroundColor Yellow +} + + +$CompletionStatus = "Info" + +$SummaryMessage = "Sync completed - Source: $($Stats.ExabeamHostsRetrieved) records, Found: $($Stats.QualysAssetsFound), Not Found: $($Stats.QualysAssetsNotFound), Already Tagged: $($Stats.TagsAlreadyPresent), Tags Added: $($Stats.TagsApplied), Tags Removed: $($Stats.TagsRemoved), Errors: $($Stats.Errors)" +Send-WebhookLog -Message $SummaryMessage -Status $CompletionStatus -Task "Sync Summary" + +#endregion diff --git a/examples/qualys/Test-QualysHostSearch.ps1 b/examples/qualys/Test-QualysHostSearch.ps1 new file mode 100644 index 0000000..d6d30f3 --- /dev/null +++ b/examples/qualys/Test-QualysHostSearch.ps1 @@ -0,0 +1,82 @@ +# Test script to debug Qualys host asset search +Param( + [Parameter(Mandatory = $true)] + [string] $Hostname +) + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Testing Qualys Host Asset Search" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Searching for: $Hostname" -ForegroundColor White +Write-Host "" + +# Try different search methods +Write-Host "[Test 1] Searching by Name (CONTAINS)" -ForegroundColor Yellow +$Result1 = Get-QualysHostAssets -Name $Hostname -Verbose +if ($Result1) { + Write-Host " FOUND: $($Result1.Count) result(s)" -ForegroundColor Green + $Result1 | Select-Object id, name, dnsHostName, address | Format-Table -AutoSize +} else { + Write-Host " NOT FOUND" -ForegroundColor Red +} +Write-Host "" + +Write-Host "[Test 2] Searching by DnsHostName (CONTAINS)" -ForegroundColor Yellow +$Result2 = Get-QualysHostAssets -DnsHostName $Hostname -Verbose +if ($Result2) { + Write-Host " FOUND: $($Result2.Count) result(s)" -ForegroundColor Green + $Result2 | Select-Object id, name, dnsHostName, address | Format-Table -AutoSize +} else { + Write-Host " NOT FOUND" -ForegroundColor Red +} +Write-Host "" + +Write-Host "[Test 3] Getting ALL assets and filtering locally" -ForegroundColor Yellow +Write-Host " This may take a while..." -ForegroundColor DarkGray +$AllAssets = Get-QualysHostAssets +if ($AllAssets) { + Write-Host " Total assets retrieved: $($AllAssets.Count)" -ForegroundColor Cyan + + # Try exact match + $ExactMatch = $AllAssets | Where-Object { $_.name -eq $Hostname } + if ($ExactMatch) { + Write-Host " EXACT MATCH FOUND:" -ForegroundColor Green + $ExactMatch | Select-Object id, name, dnsHostName, address | Format-Table -AutoSize + } + + # Try case-insensitive match + $CaseInsensitiveMatch = $AllAssets | Where-Object { $_.name -like $Hostname } + if ($CaseInsensitiveMatch) { + Write-Host " CASE-INSENSITIVE MATCH FOUND:" -ForegroundColor Green + $CaseInsensitiveMatch | Select-Object id, name, dnsHostName, address | Format-Table -AutoSize + } + + # Try partial match + $PartialMatch = $AllAssets | Where-Object { $_.name -like "*$Hostname*" } + if ($PartialMatch) { + Write-Host " PARTIAL MATCH FOUND:" -ForegroundColor Green + $PartialMatch | Select-Object id, name, dnsHostName, address | Format-Table -AutoSize + } + + # Try dnsHostName match + $DnsMatch = $AllAssets | Where-Object { $_.dnsHostName -like "*$Hostname*" } + if ($DnsMatch) { + Write-Host " DNS HOSTNAME MATCH FOUND:" -ForegroundColor Green + $DnsMatch | Select-Object id, name, dnsHostName, address | Format-Table -AutoSize + } + + if (-not $ExactMatch -and -not $CaseInsensitiveMatch -and -not $PartialMatch -and -not $DnsMatch) { + Write-Host " NO MATCHES FOUND in local filtering" -ForegroundColor Red + Write-Host "" + Write-Host " Sample of asset names from Qualys:" -ForegroundColor DarkGray + $AllAssets | Select-Object -First 10 -ExpandProperty name | ForEach-Object { + Write-Host " - $_" -ForegroundColor DarkGray + } + } +} else { + Write-Host " Failed to retrieve assets" -ForegroundColor Red +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan diff --git a/src/Public/Qualys/HostAssets/Get-QualysHostAsset.ps1 b/src/Public/Qualys/HostAssets/Get-QualysHostAsset.ps1 new file mode 100644 index 0000000..26392b2 --- /dev/null +++ b/src/Public/Qualys/HostAssets/Get-QualysHostAsset.ps1 @@ -0,0 +1,121 @@ +using namespace System +using namespace System.Collections.Generic + +Function Get-QualysHostAsset { + <# + .SYNOPSIS + Get a single Qualys host asset by ID. + .DESCRIPTION + Returns detailed information about a specific host asset by its ID. + + Use Get-QualysHostAssets (plural) to search for multiple assets by various criteria. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Id + The host asset ID to retrieve. + .OUTPUTS + PSCustomObject representing the Qualys HostAsset object with full details. + .EXAMPLE + PS C:\> Get-QualysHostAsset -Id 12345 + + Returns detailed information about host asset with ID 12345. + .EXAMPLE + PS C:\> Get-QualysHostAssets -Name "web01" | Select-Object -First 1 -ExpandProperty id | Get-QualysHostAsset + + Search for an asset by name and get its full details. + .NOTES + Qualys-API v2.0 + + Permissions required: Managers with full scope. Other users must have requested + asset in their scope and these permissions: Access Permission "API Access" and + Asset Management Permission "Read Asset" + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [int] $Id, + + [Parameter(Mandatory = $false, Position = 1)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + + # Define HTTP Method + $Method = $HttpMethod.Get + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + } + + Process { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $Id + Raw = $null + } + + # Define URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/get/am/hostasset/$Id" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $Asset = $Response.ServiceResponse.data.HostAsset + Write-Verbose "[$Me]: Host asset retrieved successfully. ID: $($Asset.id), Name: $($Asset.name)" + return $Asset + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + + if ($Response.ServiceResponse.responseErrorDetails) { + $ErrorObject.Note = $Response.ServiceResponse.responseErrorDetails.errorMessage + } else { + $ErrorObject.Note = "Qualys API returned an error" + } + + $ErrorObject.Raw = $Response + return $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + return $ErrorObject + } + } + + End { } +} diff --git a/src/Public/Qualys/HostAssets/Get-QualysHostAssets.ps1 b/src/Public/Qualys/HostAssets/Get-QualysHostAssets.ps1 new file mode 100644 index 0000000..54b182b --- /dev/null +++ b/src/Public/Qualys/HostAssets/Get-QualysHostAssets.ps1 @@ -0,0 +1,283 @@ +using namespace System +using namespace System.Collections.Generic + +Function Get-QualysHostAssets { + <# + .SYNOPSIS + Search for Qualys host assets. + .DESCRIPTION + Returns a list of host assets matching the provided criteria. Assets are returned + when they are visible to the user (i.e. in the user's scope). + + A maximum of 100 host assets are returned by default. Pagination is automatically + handled to retrieve all matching assets. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Id + Filter by host asset ID. + .PARAMETER Name + Filter by asset name (supports partial matches). + .PARAMETER DnsHostName + Filter by DNS hostname. + .PARAMETER Address + Filter by IP address. + .PARAMETER Os + Filter by operating system. + .PARAMETER TagName + Filter by tag name. + .PARAMETER TagId + Filter by tag ID. + .PARAMETER TrackingMethod + Filter by tracking method. + + Valid values: NONE, IP, DNSNAME, NETBIOS, INSTANCE_ID, QAGENT, EC2_INSTANCE_ID, + GCP_INSTANCE_ID, VIRTUAL_MACHINE_ID, etc. + .PARAMETER CustomFilters + Hashtable of custom filter criteria for advanced searches. + Example: @{field="qwebHostId"; operator="EQUALS"; value="12345"} + .OUTPUTS + Array of PSCustomObject representing Qualys HostAsset objects. + .EXAMPLE + PS C:\> Get-QualysHostAssets + + Returns all host assets visible to the user. + .EXAMPLE + PS C:\> Get-QualysHostAssets -Name "web" + + Returns all host assets with "web" in the name. + .EXAMPLE + PS C:\> Get-QualysHostAssets -Address "10.0.0.1" + + Returns host assets with the specified IP address. + .EXAMPLE + PS C:\> Get-QualysHostAssets -TagName "Production" + + Returns all host assets tagged with "Production". + .EXAMPLE + PS C:\> Get-QualysHostAssets -Os "Windows" -TagId 12345 + + Returns Windows hosts with specific tag ID. + .NOTES + Qualys-API v2.0 + + Permissions required: Managers with full scope, other users must have Access + Permission "API Access" and Asset Management Permission "Read Asset" + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding()] + Param( + [Parameter(Mandatory = $false, Position = 0)] + [int] $Id, + + [Parameter(Mandatory = $false, Position = 1)] + [string] $Name, + + [Parameter(Mandatory = $false, Position = 2)] + [string] $DnsHostName, + + [Parameter(Mandatory = $false, Position = 3)] + [string] $Address, + + [Parameter(Mandatory = $false, Position = 4)] + [string] $Os, + + [Parameter(Mandatory = $false, Position = 5)] + [string] $TagName, + + [Parameter(Mandatory = $false, Position = 6)] + [int] $TagId, + + [Parameter(Mandatory = $false, Position = 7)] + [string] $TrackingMethod, + + [Parameter(Mandatory = $false, Position = 8)] + [hashtable[]] $CustomFilters, + + [Parameter(Mandatory = $false, Position = 9)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + $Headers.Add("Content-Type", "text/xml") + + # Define HTTP Method + $Method = $HttpMethod.Post + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + + # Pagination settings + $PageSize = 100 + $Offset = 1 + $AllAssets = @() + } + + Process { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $null + Raw = $null + } + + # Build XML Request Body + $XmlBuilder = [System.Text.StringBuilder]::new() + [void]$XmlBuilder.AppendLine('') + + # Add filters if provided + $HasFilters = $false + if ($Id -or $Name -or $DnsHostName -or $Address -or $Os -or $TagName -or $TagId -or $TrackingMethod -or $CustomFilters) { + [void]$XmlBuilder.AppendLine(' ') + $HasFilters = $true + } + + if ($Id) { + [void]$XmlBuilder.AppendLine(" $Id") + } + + if ($Name) { + $EncodedName = [System.Security.SecurityElement]::Escape($Name) + [void]$XmlBuilder.AppendLine(" $EncodedName") + } + + if ($DnsHostName) { + $EncodedDns = [System.Security.SecurityElement]::Escape($DnsHostName) + [void]$XmlBuilder.AppendLine(" $EncodedDns") + } + + if ($Address) { + $EncodedAddress = [System.Security.SecurityElement]::Escape($Address) + [void]$XmlBuilder.AppendLine(" $EncodedAddress") + } + + if ($Os) { + $EncodedOs = [System.Security.SecurityElement]::Escape($Os) + [void]$XmlBuilder.AppendLine(" $EncodedOs") + } + + if ($TagName) { + $EncodedTagName = [System.Security.SecurityElement]::Escape($TagName) + [void]$XmlBuilder.AppendLine(" $EncodedTagName") + } + + if ($TagId) { + [void]$XmlBuilder.AppendLine(" $TagId") + } + + if ($TrackingMethod) { + [void]$XmlBuilder.AppendLine(" $TrackingMethod") + } + + # Custom filters + if ($CustomFilters) { + foreach ($Filter in $CustomFilters) { + $FilterField = $Filter.field + $FilterOperator = if ($Filter.operator) { $Filter.operator } else { "EQUALS" } + $FilterValue = [System.Security.SecurityElement]::Escape($Filter.value) + [void]$XmlBuilder.AppendLine(" $FilterValue") + } + } + + if ($HasFilters) { + [void]$XmlBuilder.AppendLine(' ') + } + + # Add pagination preferences + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(" $PageSize") + [void]$XmlBuilder.AppendLine(" $Offset") + [void]$XmlBuilder.AppendLine(' ') + + [void]$XmlBuilder.AppendLine('') + + $RequestBody = $XmlBuilder.ToString() + Write-Verbose "[$Me]: Request Body:`n$RequestBody" + + # Define Search URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/search/am/hostasset" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + # Pagination loop + Do { + # Update offset in XML body for subsequent requests + if ($Offset -gt 1) { + $RequestBody = $RequestBody -replace '\d+', "$Offset" + Write-Verbose "[$Me]: Pagination - Offset: $Offset" + } + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers -Body $RequestBody + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $Assets = $Response.ServiceResponse.data.HostAsset + $Count = [int]$Response.ServiceResponse.count + $HasMoreRecords = $Response.ServiceResponse.hasMoreRecords -eq "true" + + Write-Verbose "[$Me]: Retrieved $Count assets. HasMoreRecords: $HasMoreRecords" + + # Add assets to collection + if ($Assets) { + if ($Assets -is [System.Array]) { + $AllAssets += $Assets + } else { + # Single asset returned + $AllAssets += $Assets + } + } + + # Update offset for next page + $Offset += $PageSize + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + $ErrorObject.Note = "Qualys API returned an error" + $ErrorObject.Raw = $Response + return $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + return $ErrorObject + } + } While ($HasMoreRecords) + + # Return results + if ($AllAssets.Count -gt 0) { + return $AllAssets + } else { + Write-Verbose "[$Me]: No host assets found matching the criteria" + return $null + } + } + + End { } +} diff --git a/src/Public/Qualys/HostAssets/Update-QualysHostAsset.ps1 b/src/Public/Qualys/HostAssets/Update-QualysHostAsset.ps1 new file mode 100644 index 0000000..54da892 --- /dev/null +++ b/src/Public/Qualys/HostAssets/Update-QualysHostAsset.ps1 @@ -0,0 +1,212 @@ +using namespace System +using namespace System.Collections.Generic + +Function Update-QualysHostAsset { + <# + .SYNOPSIS + Update a Qualys host asset. + .DESCRIPTION + Updates fields for a host asset, including adding or removing tags. + + NOTE: Only static tags can be added/removed. Dynamic tags are managed automatically + by Qualys based on tag rules. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Id + Host asset ID to update. + .PARAMETER Name + New name for the host asset. + .PARAMETER AddTagIds + Array of tag IDs to add to this host asset (static tags only). + .PARAMETER RemoveTagIds + Array of tag IDs to remove from this host asset (static tags only). + .OUTPUTS + PSCustomObject representing the updated host asset ID or an error object. + .EXAMPLE + PS C:\> Update-QualysHostAsset -Id 12345 -Name "Updated Server Name" + + Updates the host asset name. + .EXAMPLE + PS C:\> Update-QualysHostAsset -Id 12345 -AddTagIds @(111, 222) + + Adds tags with IDs 111 and 222 to the host asset. + .EXAMPLE + PS C:\> Update-QualysHostAsset -Id 12345 -RemoveTagIds @(333) + + Removes tag with ID 333 from the host asset. + .EXAMPLE + PS C:\> Update-QualysHostAsset -Id 12345 -AddTagIds @(111) -RemoveTagIds @(222) + + Adds one tag and removes another in a single operation. + .EXAMPLE + PS C:\> Get-QualysHostAssets -Name "webserver" | ForEach-Object { + Update-QualysHostAsset -Id $_.id -AddTagIds @(999) + } + + Pipeline example: Find all webservers and add a tag to each. + .NOTES + Qualys-API v2.0 + + Permissions required: Managers with full scope, other users must have the requested + assets in their scope and these permissions: Access Permission "API Access" and + Asset Management Permission "Update Asset" + + Important: Only static tags can be added/removed. Dynamic tags cannot be manually + managed and will be declined in the API request. + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)] + [int] $Id, + + [Parameter(Mandatory = $false, Position = 1)] + [string] $Name, + + [Parameter(Mandatory = $false, Position = 2)] + [int[]] $AddTagIds, + + [Parameter(Mandatory = $false, Position = 3)] + [int[]] $RemoveTagIds, + + [Parameter(Mandatory = $false, Position = 4)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + $Headers.Add("Content-Type", "text/xml") + + # Define HTTP Method + $Method = $HttpMethod.Post + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + } + + Process { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $Id + Raw = $null + } + + # Validate that at least one update parameter is provided + if (-not ($Name -or $AddTagIds -or $RemoveTagIds)) { + $ErrorObject.Error = $true + $ErrorObject.Type = "ValidationError" + $ErrorObject.Note = "At least one update parameter must be specified" + return $ErrorObject + } + + # Build XML Request Body + $XmlBuilder = [System.Text.StringBuilder]::new() + [void]$XmlBuilder.AppendLine('') + [void]$XmlBuilder.AppendLine('') + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(' ') + + # Asset Name + if ($Name) { + $EncodedName = [System.Security.SecurityElement]::Escape($Name) + [void]$XmlBuilder.AppendLine(" $EncodedName") + } + + # Tags + if ($AddTagIds -or $RemoveTagIds) { + [void]$XmlBuilder.AppendLine(' ') + + # Add tags + if ($AddTagIds -and $AddTagIds.Count -gt 0) { + [void]$XmlBuilder.AppendLine(' ') + foreach ($TagId in $AddTagIds) { + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(" $TagId") + [void]$XmlBuilder.AppendLine(' ') + } + [void]$XmlBuilder.AppendLine(' ') + } + + # Remove tags + if ($RemoveTagIds -and $RemoveTagIds.Count -gt 0) { + [void]$XmlBuilder.AppendLine(' ') + foreach ($TagId in $RemoveTagIds) { + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(" $TagId") + [void]$XmlBuilder.AppendLine(' ') + } + [void]$XmlBuilder.AppendLine(' ') + } + + [void]$XmlBuilder.AppendLine(' ') + } + + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine('') + + $RequestBody = $XmlBuilder.ToString() + Write-Verbose "[$Me]: Request Body:`n$RequestBody" + + # Define URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/update/am/hostasset/$Id" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers -Body $RequestBody + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $UpdatedAsset = $Response.ServiceResponse.data.HostAsset + Write-Verbose "[$Me]: Host asset updated successfully. ID: $($UpdatedAsset.id)" + return $UpdatedAsset + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + + if ($Response.ServiceResponse.responseErrorDetails) { + $ErrorObject.Note = $Response.ServiceResponse.responseErrorDetails.errorMessage + } else { + $ErrorObject.Note = "Qualys API returned an error" + } + + $ErrorObject.Raw = $Response + return $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + return $ErrorObject + } + } + + End { } +} diff --git a/src/Public/Qualys/Tags/Get-QualysTag.ps1 b/src/Public/Qualys/Tags/Get-QualysTag.ps1 new file mode 100644 index 0000000..d76924b --- /dev/null +++ b/src/Public/Qualys/Tags/Get-QualysTag.ps1 @@ -0,0 +1,120 @@ +using namespace System +using namespace System.Collections.Generic + +Function Get-QualysTag { + <# + .SYNOPSIS + Get a single Qualys tag by ID. + .DESCRIPTION + Returns detailed information about a specific tag by its ID. + + Use Get-QualysTags (plural) to search for multiple tags by various criteria. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Id + The tag ID to retrieve. + .OUTPUTS + PSCustomObject representing the Qualys Tag object with full details. + .EXAMPLE + PS C:\> Get-QualysTag -Id 12345 + + Returns detailed information about tag with ID 12345. + .EXAMPLE + PS C:\> Get-QualysTags -Name "Production" | Select-Object -First 1 -ExpandProperty id | Get-QualysTag + + Search for a tag by name and get its full details. + .NOTES + Qualys-API v2.0 + + Permissions required: Managers with full scope, other users must have + Access Permission "API Access" + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] + [int] $Id, + + [Parameter(Mandatory = $false, Position = 1)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + + # Define HTTP Method + $Method = $HttpMethod.Get + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + } + + Process { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $Id + Raw = $null + } + + # Define URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/get/am/tag/$Id" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $Tag = $Response.ServiceResponse.data.Tag + Write-Verbose "[$Me]: Tag retrieved successfully. ID: $($Tag.id), Name: $($Tag.name)" + return $Tag + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + + if ($Response.ServiceResponse.responseErrorDetails) { + $ErrorObject.Note = $Response.ServiceResponse.responseErrorDetails.errorMessage + } else { + $ErrorObject.Note = "Qualys API returned an error" + } + + $ErrorObject.Raw = $Response + return $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + return $ErrorObject + } + } + + End { } +} diff --git a/src/Public/Qualys/Tags/Get-QualysTagCount.ps1 b/src/Public/Qualys/Tags/Get-QualysTagCount.ps1 new file mode 100644 index 0000000..ac38449 --- /dev/null +++ b/src/Public/Qualys/Tags/Get-QualysTagCount.ps1 @@ -0,0 +1,208 @@ +using namespace System +using namespace System.Collections.Generic + +Function Get-QualysTagCount { + <# + .SYNOPSIS + Get count of Qualys tags. + .DESCRIPTION + Returns a count of tags that match the provided criteria. This is useful for + counting child tags of a parent tag or getting a count before retrieving all tags. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Id + Filter by tag ID. + .PARAMETER Name + Filter by tag name (supports partial matches). + .PARAMETER Parent + Filter by parent tag ID. Use this to count all children of a specific tag. + .PARAMETER RuleType + Filter by rule type. + + Valid values: STATIC, GROOVY, OS_REGEX, NETWORK_RANGE, NAME_CONTAINS, INSTALLED_SOFTWARE, + OPEN_PORTS, VULN_EXIST, ASSET_SEARCH, CLOUD_ASSET + .PARAMETER Provider + Filter by cloud provider. + + Valid values: EC2, AZURE, GCP, IBM, OCI + .PARAMETER Color + Filter by tag color (hex format: #FFFFFF). + .OUTPUTS + Integer representing the count of matching tags. + .EXAMPLE + PS C:\> Get-QualysTagCount + + Returns the total count of all tags. + .EXAMPLE + PS C:\> Get-QualysTagCount -Parent 12345 + + Returns the count of all child tags under parent tag ID 12345. + .EXAMPLE + PS C:\> Get-QualysTagCount -Name "Production" + + Returns the count of tags with "Production" in the name. + .EXAMPLE + PS C:\> Get-QualysTagCount -RuleType "CLOUD_ASSET" -Provider "AZURE" + + Returns the count of Azure cloud asset tags. + .NOTES + Qualys-API v2.0 + + Permissions required: Managers with full scope, other users must have + Access Permission "API Access" + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding()] + Param( + [Parameter(Mandatory = $false, Position = 0)] + [int] $Id, + + [Parameter(Mandatory = $false, Position = 1)] + [string] $Name, + + [Parameter(Mandatory = $false, Position = 2)] + [int] $Parent, + + [Parameter(Mandatory = $false, Position = 3)] + [ValidateSet('STATIC', 'GROOVY', 'OS_REGEX', 'NETWORK_RANGE', 'NAME_CONTAINS', 'INSTALLED_SOFTWARE', + 'OPEN_PORTS', 'VULN_EXIST', 'ASSET_SEARCH', 'CLOUD_ASSET', ignorecase=$true)] + [string] $RuleType, + + [Parameter(Mandatory = $false, Position = 4)] + [ValidateSet('EC2', 'AZURE', 'GCP', 'IBM', 'OCI', ignorecase=$true)] + [string] $Provider, + + [Parameter(Mandatory = $false, Position = 5)] + [ValidatePattern('^#[0-9A-Fa-f]{6}$')] + [string] $Color, + + [Parameter(Mandatory = $false, Position = 6)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + $Headers.Add("Content-Type", "text/xml") + + # Define HTTP Method + $Method = $HttpMethod.Post + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + } + + Process { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $null + Raw = $null + } + + # Build XML Request Body + $XmlBuilder = [System.Text.StringBuilder]::new() + [void]$XmlBuilder.AppendLine('') + + # Add filters if provided + $HasFilters = $false + if ($Id -or $Name -or $Parent -or $RuleType -or $Provider -or $Color) { + [void]$XmlBuilder.AppendLine(' ') + $HasFilters = $true + } + + if ($Id) { + [void]$XmlBuilder.AppendLine(" $Id") + } + + if ($Name) { + $EncodedName = [System.Security.SecurityElement]::Escape($Name) + [void]$XmlBuilder.AppendLine(" $EncodedName") + } + + if ($Parent) { + [void]$XmlBuilder.AppendLine(" $Parent") + } + + if ($RuleType) { + [void]$XmlBuilder.AppendLine(" $RuleType") + } + + if ($Provider) { + [void]$XmlBuilder.AppendLine(" $Provider") + } + + if ($Color) { + $EncodedColor = [System.Security.SecurityElement]::Escape($Color) + [void]$XmlBuilder.AppendLine(" $EncodedColor") + } + + if ($HasFilters) { + [void]$XmlBuilder.AppendLine(' ') + } + + [void]$XmlBuilder.AppendLine('') + + $RequestBody = $XmlBuilder.ToString() + Write-Verbose "[$Me]: Request Body:`n$RequestBody" + + # Define URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/count/am/tag" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers -Body $RequestBody + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $Count = [int]$Response.ServiceResponse.count + Write-Verbose "[$Me]: Count retrieved successfully: $Count" + return $Count + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + + if ($Response.ServiceResponse.responseErrorDetails) { + $ErrorObject.Note = $Response.ServiceResponse.responseErrorDetails.errorMessage + } else { + $ErrorObject.Note = "Qualys API returned an error" + } + + $ErrorObject.Raw = $Response + return $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + return $ErrorObject + } + } + + End { } +} diff --git a/src/Public/Qualys/Tags/Get-QualysTags.ps1 b/src/Public/Qualys/Tags/Get-QualysTags.ps1 new file mode 100644 index 0000000..9bd0b19 --- /dev/null +++ b/src/Public/Qualys/Tags/Get-QualysTags.ps1 @@ -0,0 +1,264 @@ +using namespace System +using namespace System.Collections.Generic + +Function Get-QualysTags { + <# + .SYNOPSIS + Get Qualys Tags. + .DESCRIPTION + Returns a list of tags from Qualys that match the provided criteria. + + A maximum of 100 tags are returned by default. Pagination is automatically + handled to retrieve all matching tags. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Id + Filter by tag ID. + .PARAMETER Name + Filter by tag name (supports partial matches). + .PARAMETER Parent + Filter by parent tag ID. + .PARAMETER RuleType + Filter by rule type. + + Valid values: GROOVY, OS_REGEX, NETWORK_RANGE, NAME_CONTAINS, INSTALLED_SOFTWARE, + OPEN_PORTS, VULN_EXIST, ASSET_SEARCH, CLOUD_ASSET, BUSINESS_INFORMATION + .PARAMETER Provider + Filter by cloud provider. + + Valid values: EC2, AZURE, GCP, IBM, OCI, Alibaba + .PARAMETER Color + Filter by tag color (hex format: #FFFFFF). + .PARAMETER CriticalityScore + Filter by criticality score. + .PARAMETER Exact + Return only exact matches for the Name parameter. + .OUTPUTS + PSCustomObject representing Qualys Tag objects. + .EXAMPLE + PS C:\> Get-QualysTags + + Returns all tags from Qualys. + .EXAMPLE + PS C:\> Get-QualysTags -Name "Production" + + Returns all tags with "Production" in the name. + .EXAMPLE + PS C:\> Get-QualysTags -Name "Production" -Exact + + Returns only the tag with the exact name "Production". + .EXAMPLE + PS C:\> Get-QualysTags -CriticalityScore 3 + + Returns all tags with criticality score of 3. + .EXAMPLE + PS C:\> Get-QualysTags -RuleType "CLOUD_ASSET" -Provider "AZURE" + + Returns all Azure cloud asset tags. + .NOTES + Qualys-API v2.0 + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding()] + Param( + [Parameter(Mandatory = $false, Position = 0)] + [int] $Id, + + [Parameter(Mandatory = $false, Position = 1)] + [string] $Name, + + [Parameter(Mandatory = $false, Position = 2)] + [int] $Parent, + + [Parameter(Mandatory = $false, Position = 3)] + [ValidateSet('GROOVY', 'OS_REGEX', 'NETWORK_RANGE', 'NAME_CONTAINS', 'INSTALLED_SOFTWARE', + 'OPEN_PORTS', 'VULN_EXIST', 'ASSET_SEARCH', 'CLOUD_ASSET', 'BUSINESS_INFORMATION', + ignorecase=$true)] + [string] $RuleType, + + [Parameter(Mandatory = $false, Position = 4)] + [ValidateSet('EC2', 'AZURE', 'GCP', 'IBM', 'OCI', 'Alibaba', ignorecase=$true)] + [string] $Provider, + + [Parameter(Mandatory = $false, Position = 5)] + [ValidatePattern('^#[0-9A-Fa-f]{6}$')] + [string] $Color, + + [Parameter(Mandatory = $false, Position = 6)] + [int] $CriticalityScore, + + [Parameter(Mandatory = $false, Position = 7)] + [switch] $Exact, + + [Parameter(Mandatory = $false, Position = 8)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + $Headers.Add("Content-Type", "text/xml") + + # Define HTTP Method + $Method = $HttpMethod.Post + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + + # Pagination settings + $PageSize = 100 + $Offset = 1 + $AllTags = @() + } + + Process { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $null + Raw = $null + } + + # Build XML Request Body + $XmlBuilder = [System.Text.StringBuilder]::new() + [void]$XmlBuilder.AppendLine('') + + # Add filters if provided + $HasFilters = $false + if ($Id -or $Name -or $Parent -or $RuleType -or $Provider -or $Color -or $CriticalityScore) { + [void]$XmlBuilder.AppendLine(' ') + $HasFilters = $true + } + + if ($Id) { + [void]$XmlBuilder.AppendLine(" $Id") + } + + if ($Name) { + $Operator = if ($Exact) { "EQUALS" } else { "CONTAINS" } + $EncodedName = [System.Security.SecurityElement]::Escape($Name) + [void]$XmlBuilder.AppendLine(" $EncodedName") + } + + if ($Parent) { + [void]$XmlBuilder.AppendLine(" $Parent") + } + + if ($RuleType) { + [void]$XmlBuilder.AppendLine(" $RuleType") + } + + if ($Provider) { + [void]$XmlBuilder.AppendLine(" $Provider") + } + + if ($Color) { + $EncodedColor = [System.Security.SecurityElement]::Escape($Color) + [void]$XmlBuilder.AppendLine(" $EncodedColor") + } + + if ($CriticalityScore) { + [void]$XmlBuilder.AppendLine(" $CriticalityScore") + } + + if ($HasFilters) { + [void]$XmlBuilder.AppendLine(' ') + } + + # Add pagination preferences + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(" $PageSize") + [void]$XmlBuilder.AppendLine(" $Offset") + [void]$XmlBuilder.AppendLine(' ') + + [void]$XmlBuilder.AppendLine('') + + $RequestBody = $XmlBuilder.ToString() + Write-Verbose "[$Me]: Request Body:`n$RequestBody" + + # Define Search URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/search/am/tag" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + # Pagination loop + Do { + # Update offset in XML body for subsequent requests + if ($Offset -gt 1) { + $RequestBody = $RequestBody -replace '\d+', "$Offset" + Write-Verbose "[$Me]: Pagination - Offset: $Offset" + } + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers -Body $RequestBody + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $Tags = $Response.ServiceResponse.data.Tag + $Count = [int]$Response.ServiceResponse.count + $HasMoreRecords = $Response.ServiceResponse.hasMoreRecords -eq "true" + + Write-Verbose "[$Me]: Retrieved $Count tags. HasMoreRecords: $HasMoreRecords" + + # Add tags to collection + if ($Tags) { + if ($Tags -is [System.Array]) { + $AllTags += $Tags + } else { + # Single tag returned + $AllTags += $Tags + } + } + + # Update offset for next page + $Offset += $PageSize + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + $ErrorObject.Note = "Qualys API returned an error" + $ErrorObject.Raw = $Response + return $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + return $ErrorObject + } + } While ($HasMoreRecords) + + # Return results + if ($AllTags.Count -gt 0) { + return $AllTags + } else { + Write-Verbose "[$Me]: No tags found matching the criteria" + return $null + } + } + + End { } +} diff --git a/src/Public/Qualys/Tags/New-QualysTag.ps1 b/src/Public/Qualys/Tags/New-QualysTag.ps1 new file mode 100644 index 0000000..9477e0b --- /dev/null +++ b/src/Public/Qualys/Tags/New-QualysTag.ps1 @@ -0,0 +1,310 @@ +using namespace System +using namespace System.Collections.Generic + +Function New-QualysTag { + <# + .SYNOPSIS + Create a new Qualys tag. + .DESCRIPTION + Creates a new tag and optionally child tags in Qualys. + + Supports creating static tags, dynamic tags with various rule types, and tags with child tags. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Name + Name of the tag to create. + .PARAMETER Color + Tag color in hex format (e.g., #FFFFFF). Defaults to #FFFFFF. + .PARAMETER CriticalityScore + Asset criticality score for the tag (1-5, where 5 is highest). + .PARAMETER RuleType + Type of rule for dynamic tags. + + Valid values: STATIC, GROOVY, OS_REGEX, NETWORK_RANGE, NAME_CONTAINS, INSTALLED_SOFTWARE, + OPEN_PORTS, VULN_EXIST, ASSET_SEARCH, CLOUD_ASSET, BUSINESS_INFORMATION, GLOBAL_ASSET_VIEW, + NETWORK_RANGE_ENHANCED, TAG_SET + .PARAMETER RuleText + The rule definition text for dynamic tags. Required when RuleType is specified. + + The format varies by RuleType: + + GROOVY: + Groovy script code (e.g., "if(asset.getAssetType()!=Asset.AssetType.HOST) return false;") + + GLOBAL_ASSET_VIEW: + Query syntax (e.g., "operatingSystem.lifecycle.stage:`EOL`") + + OS_REGEX: + Regular expression pattern to match OS names + + NETWORK_RANGE: + IP ranges (e.g., "10.0.0.0-10.0.0.255") + + NAME_CONTAINS: + String to match in asset names + + INSTALLED_SOFTWARE: + Software name to match + + OPEN_PORTS: + Port numbers or ranges + + VULN_EXIST: + Vulnerability QIDs + + ASSET_SEARCH: + XML-formatted search criteria (complex, see Qualys API docs) + + CLOUD_ASSET: + Cloud-specific query syntax + + TAG_SET: + XML CDATA containing tag set rules (complex, see Qualys API docs) + .PARAMETER Provider + Cloud provider name. Required for CLOUD_ASSET rule type. + + Valid values: EC2, AZURE, GCP, IBM, OCI, Alibaba + .PARAMETER ChildTags + Array of child tag names to create under this parent tag. + .OUTPUTS + PSCustomObject representing the created Qualys Tag object. + .EXAMPLE + PS C:\> New-QualysTag -Name "Production Servers" -Color "#FF0000" + + Creates a simple static tag with red color. + .EXAMPLE + PS C:\> New-QualysTag -Name "Critical Assets" -CriticalityScore 5 -Color "#FF0000" + + Creates a tag with criticality score of 5. + .EXAMPLE + PS C:\> New-QualysTag -Name "Parent Tag" -ChildTags @("Child 1", "Child 2", "Child 3") + + Creates a parent tag with three child tags. + .EXAMPLE + PS C:\> New-QualysTag -Name "EOL Systems" -RuleType "GLOBAL_ASSET_VIEW" -RuleText "operatingSystem.lifecycle.stage:`EOL`" + + Creates a dynamic tag using GLOBAL_ASSET_VIEW rule to find end-of-life systems. + .EXAMPLE + PS C:\> New-QualysTag -Name "Linux Servers" -RuleType "GLOBAL_ASSET_VIEW" -RuleText "operatingSystem.name:`"Linux`"" + + Creates a dynamic tag to match Linux operating systems. + .EXAMPLE + PS C:\> New-QualysTag -Name "Azure VMs" -RuleType "CLOUD_ASSET" -Provider "AZURE" -RuleText "cloud.provider:azure" + + Creates a cloud asset tag for Azure resources. + .EXAMPLE + PS C:\> New-QualysTag -Name "Critical Vulns" -RuleType "GROOVY" -RuleText "if(asset.getAssetType()!=Asset.AssetType.HOST) return false; return asset.hasVulnsWithSeverity(4,5)" + + Creates a Groovy-based dynamic tag to match assets with severity 4 or 5 vulnerabilities. + .EXAMPLE + PS C:\> New-QualysTag -Name "Windows OS" -RuleType "OS_REGEX" -RuleText ".*Windows.*" + + Creates a tag using regex to match any Windows operating system. + .EXAMPLE + PS C:\> New-QualysTag -Name "Private Network" -RuleType "NETWORK_RANGE" -RuleText "10.0.0.0-10.255.255.255" + + Creates a tag for assets in the 10.x.x.x private network range. + .EXAMPLE + PS C:\> New-QualysTag -Name "Web Servers" -RuleType "NAME_CONTAINS" -RuleText "web" + + Creates a tag matching assets with "web" in their name. + .EXAMPLE + PS C:\> New-QualysTag -Name "Has Chrome" -RuleType "INSTALLED_SOFTWARE" -RuleText "Google Chrome" + + Creates a tag for assets with Google Chrome installed. + .EXAMPLE + PS C:\> New-QualysTag -Name "Open SSH" -RuleType "OPEN_PORTS" -RuleText "22" + + Creates a tag for assets with port 22 open. + .NOTES + Qualys-API v2.0 + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter(Mandatory = $false, Position = 1)] + [ValidatePattern('^#[0-9A-Fa-f]{6}$')] + [string] $Color = "#FFFFFF", + + [Parameter(Mandatory = $false, Position = 2)] + [ValidateRange(1, 5)] + [int] $CriticalityScore, + + [Parameter(Mandatory = $false, Position = 3)] + [ValidateSet('STATIC', 'GROOVY', 'OS_REGEX', 'NETWORK_RANGE', 'NAME_CONTAINS', 'INSTALLED_SOFTWARE', + 'OPEN_PORTS', 'VULN_EXIST', 'ASSET_SEARCH', 'CLOUD_ASSET', 'BUSINESS_INFORMATION', + 'GLOBAL_ASSET_VIEW', 'NETWORK_RANGE_ENHANCED', 'TAG_SET', ignorecase=$true)] + [string] $RuleType, + + [Parameter(Mandatory = $false, Position = 4)] + [string] $RuleText, + + [Parameter(Mandatory = $false, Position = 5)] + [ValidateSet('EC2', 'AZURE', 'GCP', 'IBM', 'OCI', 'Alibaba', ignorecase=$true)] + [string] $Provider, + + [Parameter(Mandatory = $false, Position = 6)] + [string[]] $ChildTags, + + [Parameter(Mandatory = $false, Position = 7)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + $Headers.Add("Content-Type", "text/xml") + + # Define HTTP Method + $Method = $HttpMethod.Post + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + } + + Process { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $Name + Raw = $null + } + + # Validate RuleType and RuleText dependencies + if ($RuleType -and -not $RuleText) { + $ErrorObject.Error = $true + $ErrorObject.Type = "ValidationError" + $ErrorObject.Note = "RuleText is required when RuleType is specified" + return $ErrorObject + } + + if ($RuleType -eq 'CLOUD_ASSET' -and -not $Provider) { + $ErrorObject.Error = $true + $ErrorObject.Type = "ValidationError" + $ErrorObject.Note = "Provider is required when RuleType is CLOUD_ASSET" + return $ErrorObject + } + + # Build XML Request Body + $XmlBuilder = [System.Text.StringBuilder]::new() + [void]$XmlBuilder.AppendLine('') + [void]$XmlBuilder.AppendLine('') + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(' ') + + # Tag Name + $EncodedName = [System.Security.SecurityElement]::Escape($Name) + [void]$XmlBuilder.AppendLine(" $EncodedName") + + # Color + if ($Color) { + $EncodedColor = [System.Security.SecurityElement]::Escape($Color) + [void]$XmlBuilder.AppendLine(" $EncodedColor") + } + + # Criticality Score + if ($CriticalityScore) { + [void]$XmlBuilder.AppendLine(" $CriticalityScore") + } + + # Rule Type + if ($RuleType) { + [void]$XmlBuilder.AppendLine(" $RuleType") + } + + # Rule Text + if ($RuleText) { + $EncodedRuleText = [System.Security.SecurityElement]::Escape($RuleText) + [void]$XmlBuilder.AppendLine(" $EncodedRuleText") + } + + # Provider (for cloud assets) + if ($Provider) { + [void]$XmlBuilder.AppendLine(" $Provider") + } + + # Child Tags + if ($ChildTags -and $ChildTags.Count -gt 0) { + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(' ') + foreach ($ChildTag in $ChildTags) { + $EncodedChildName = [System.Security.SecurityElement]::Escape($ChildTag) + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(" $EncodedChildName") + [void]$XmlBuilder.AppendLine(' ') + } + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(' ') + } + + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine('') + + $RequestBody = $XmlBuilder.ToString() + Write-Verbose "[$Me]: Request Body:`n$RequestBody" + + # Define URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/create/am/tag" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers -Body $RequestBody + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $Tag = $Response.ServiceResponse.data.Tag + Write-Verbose "[$Me]: Tag created successfully. ID: $($Tag.id)" + return $Tag + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + + if ($Response.ServiceResponse.responseErrorDetails) { + $ErrorObject.Note = $Response.ServiceResponse.responseErrorDetails.errorMessage + } else { + $ErrorObject.Note = "Qualys API returned an error" + } + + $ErrorObject.Raw = $Response + return $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + return $ErrorObject + } + } + + End { } +} diff --git a/src/Public/Qualys/Tags/Remove-QualysTag.ps1 b/src/Public/Qualys/Tags/Remove-QualysTag.ps1 new file mode 100644 index 0000000..007701a --- /dev/null +++ b/src/Public/Qualys/Tags/Remove-QualysTag.ps1 @@ -0,0 +1,149 @@ +using namespace System +using namespace System.Collections.Generic + +Function Remove-QualysTag { + <# + .SYNOPSIS + Delete a Qualys tag. + .DESCRIPTION + Deletes one or more tags from Qualys by tag ID. + + Note: Using the NOT EQUALS operator for deleting tags could result in accidental + deletion of unknown tags without any warning. To prevent accidental deletion, + NOT EQUALS operator is not supported for delete actions. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Id + Tag ID(s) to delete. Accepts a single ID or an array of IDs. + .PARAMETER Force + Bypasses confirmation prompts. + .OUTPUTS + PSCustomObject representing the deleted tag ID(s) or an error object. + .EXAMPLE + PS C:\> Remove-QualysTag -Id 12345 + + Deletes the tag with ID 12345 (with confirmation prompt). + .EXAMPLE + PS C:\> Remove-QualysTag -Id 12345 -Force + + Deletes the tag with ID 12345 without confirmation. + .EXAMPLE + PS C:\> Remove-QualysTag -Id 12345,67890 -Force + + Deletes multiple tags by ID. + .EXAMPLE + PS C:\> Get-QualysTags -Name "TestTag" | ForEach-Object { Remove-QualysTag -Id $_.id -Force } + + Finds and deletes all tags matching "TestTag". + .NOTES + Qualys-API v2.0 + + Permissions required: Managers with full scope, other users must have Access + Permission "API Access" and Tag Permission "Delete User Tag" + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] + Param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)] + [int[]] $Id, + + [Parameter(Mandatory = $false, Position = 1)] + [switch] $Force, + + [Parameter(Mandatory = $false, Position = 2)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + $Headers.Add("Content-Type", "text/xml") + + # Define HTTP Method + $Method = $HttpMethod.Post + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + + # Results collection + $Results = @() + } + + Process { + foreach ($TagId in $Id) { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $TagId + Raw = $null + } + + # Confirmation prompt + if ($Force -or $PSCmdlet.ShouldProcess("Tag ID: $TagId", "Delete Qualys Tag")) { + + # Define URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/delete/am/tag/$TagId" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $DeletedTag = $Response.ServiceResponse.data.SimpleTag + Write-Verbose "[$Me]: Tag deleted successfully. ID: $($DeletedTag.id)" + $Results += $DeletedTag + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + + if ($Response.ServiceResponse.responseErrorDetails) { + $ErrorObject.Note = $Response.ServiceResponse.responseErrorDetails.errorMessage + } else { + $ErrorObject.Note = "Qualys API returned an error" + } + + $ErrorObject.Raw = $Response + $Results += $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + $Results += $ErrorObject + } + } else { + Write-Verbose "[$Me]: Deletion cancelled by user for Tag ID: $TagId" + } + } + } + + End { + return $Results + } +} diff --git a/src/Public/Qualys/Tags/Update-QualysTag.ps1 b/src/Public/Qualys/Tags/Update-QualysTag.ps1 new file mode 100644 index 0000000..d54cebf --- /dev/null +++ b/src/Public/Qualys/Tags/Update-QualysTag.ps1 @@ -0,0 +1,332 @@ +using namespace System +using namespace System.Collections.Generic + +Function Update-QualysTag { + <# + .SYNOPSIS + Update a Qualys tag. + .DESCRIPTION + Updates fields for a tag, including name, color, criticality score, rule type/text, + and child tags. + + Note: Provider name cannot be updated after tag creation. + Using the NOT EQUALS operator for updating tags could result in accidental updates + of unknown tags. NOT EQUALS operator is not supported for update actions. + .PARAMETER Credential + PSCredential containing Qualys username and password. + .PARAMETER Id + Tag ID to update. + .PARAMETER Name + New name for the tag. + .PARAMETER Color + New tag color in hex format (e.g., #FFFFFF). + .PARAMETER CriticalityScore + New asset criticality score for the tag (1-5, where 5 is highest). + .PARAMETER RuleType + New rule type for dynamic tags. + + Valid values: STATIC, GROOVY, OS_REGEX, NETWORK_RANGE, NAME_CONTAINS, INSTALLED_SOFTWARE, + OPEN_PORTS, VULN_EXIST, ASSET_SEARCH, CLOUD_ASSET, BUSINESS_INFORMATION, GLOBAL_ASSET_VIEW, + NETWORK_RANGE_ENHANCED, TAG_SET + .PARAMETER RuleText + New rule definition text for dynamic tags. + + The format varies by RuleType: + + GROOVY: + Groovy script code (e.g., "if(asset.getAssetType()!=Asset.AssetType.HOST) return false;") + + GLOBAL_ASSET_VIEW: + Query syntax (e.g., "operatingSystem.lifecycle.stage:`EOL`") + + OS_REGEX: + Regular expression pattern to match OS names + + NETWORK_RANGE: + IP ranges (e.g., "10.0.0.0-10.0.0.255") + + NAME_CONTAINS: + String to match in asset names + + INSTALLED_SOFTWARE: + Software name to match + + OPEN_PORTS: + Port numbers or ranges + + VULN_EXIST: + Vulnerability QIDs + + ASSET_SEARCH: + XML-formatted search criteria (complex, see Qualys API docs) + + CLOUD_ASSET: + Cloud-specific query syntax + + TAG_SET: + XML CDATA containing tag set rules (complex, see Qualys API docs) + + See docs/QualysRuleTextGuide.md for detailed examples and guidance. + .PARAMETER AddChildTags + Array of child tag names to add to this parent tag. + .PARAMETER RemoveChildTagIds + Array of child tag IDs to remove from this parent tag. + .OUTPUTS + PSCustomObject representing the updated tag ID or an error object. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -Name "Updated Tag Name" + + Updates the tag name. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -CriticalityScore 5 -Color "#FF0000" + + Updates criticality score to highest (5) and changes color to red. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -Name "Updated" -AddChildTags @("Child") -RemoveChildTagIds @(999) + + Updates name, adds a child tag, and removes another child tag in a single operation. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -AddChildTags @("New Child 1", "New Child 2") + + Adds new child tags to the parent. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -RemoveChildTagIds @(123, 456) + + Removes multiple child tags by their IDs. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -RuleType "GLOBAL_ASSET_VIEW" -RuleText "operatingSystem.lifecycle.stage:`EOL`" + + Updates tag to use GLOBAL_ASSET_VIEW rule for end-of-life systems. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -RuleType "NAME_CONTAINS" -RuleText "production" + + Changes tag to a NAME_CONTAINS rule matching assets with "production" in the name. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -RuleType "NETWORK_RANGE" -RuleText "192.168.1.0/24" + + Updates tag to match a specific network range. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -RuleType "GROOVY" -RuleText "return asset.hasVulnsWithSeverity(5)" + + Updates to a Groovy rule for assets with severity 5 vulnerabilities. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -RuleType "OPEN_PORTS" -RuleText "80,443,8080" + + Updates tag to match assets with specific open ports. + .EXAMPLE + PS C:\> Get-QualysTags -Name "Old Name" -Exact | Update-QualysTag -Name "New Name" -CriticalityScore 4 + + Pipeline example: Find a tag by exact name and update it. + .EXAMPLE + PS C:\> Update-QualysTag -Id 12345 -Color "#00FF00" + + Updates only the tag color to green, leaving all other properties unchanged. + .NOTES + Qualys-API v2.0 + + Permissions required: Managers with full scope, other users must have these permissions: + Access Permission "API Access", Tag Permission "Create User Tag", Tag Permission + "Modify Dynamic Tag Rules" (to create a dynamic tag) + + Important Notes: + - Provider name cannot be updated after tag creation + - Only specify the parameters you want to update; others remain unchanged + - See docs/QualysRuleTextGuide.md for detailed RuleText format guidance + - Use -Verbose to see the exact XML being sent to Qualys + .LINK + https://github.com/LogRhythm-Tools/LogRhythm.Tools + #> + + [CmdletBinding()] + Param( + [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)] + [int] $Id, + + [Parameter(Mandatory = $false, Position = 1)] + [string] $Name, + + [Parameter(Mandatory = $false, Position = 2)] + [ValidatePattern('^#[0-9A-Fa-f]{6}$')] + [string] $Color, + + [Parameter(Mandatory = $false, Position = 3)] + [ValidateRange(1, 5)] + [int] $CriticalityScore, + + [Parameter(Mandatory = $false, Position = 4)] + [ValidateSet('STATIC', 'GROOVY', 'OS_REGEX', 'NETWORK_RANGE', 'NAME_CONTAINS', 'INSTALLED_SOFTWARE', + 'OPEN_PORTS', 'VULN_EXIST', 'ASSET_SEARCH', 'CLOUD_ASSET', 'BUSINESS_INFORMATION', + 'GLOBAL_ASSET_VIEW', 'NETWORK_RANGE_ENHANCED', 'TAG_SET', ignorecase=$true)] + [string] $RuleType, + + [Parameter(Mandatory = $false, Position = 5)] + [string] $RuleText, + + [Parameter(Mandatory = $false, Position = 6)] + [string[]] $AddChildTags, + + [Parameter(Mandatory = $false, Position = 7)] + [int[]] $RemoveChildTagIds, + + [Parameter(Mandatory = $false, Position = 8)] + [ValidateNotNull()] + [pscredential] $Credential = $LrtConfig.Qualys.Credential + ) + + Begin { + $Me = $MyInvocation.MyCommand.Name + + # Request Setup + $BaseUrl = $LrtConfig.Qualys.BaseUrl + $Username = $Credential.GetNetworkCredential().UserName + $Password = $Credential.GetNetworkCredential().Password + + # Create Basic Auth Header + $Headers = [Dictionary[string,string]]::new() + $AuthString = "$Username`:$Password" + $AuthBytes = [System.Text.Encoding]::UTF8.GetBytes($AuthString) + $AuthBase64 = [System.Convert]::ToBase64String($AuthBytes) + $Headers.Add("Authorization", "Basic $AuthBase64") + $Headers.Add("Content-Type", "text/xml") + + # Define HTTP Method + $Method = $HttpMethod.Post + + # Check preference requirements for self-signed certificates and set enforcement for Tls1.2 + Enable-TrustAllCertsPolicy + } + + Process { + # Establish General Error object Output + $ErrorObject = [PSCustomObject]@{ + Code = $null + Error = $false + Type = $null + Note = $null + Value = $Id + Raw = $null + } + + # Validate that at least one update parameter is provided + if (-not ($Name -or $Color -or $CriticalityScore -or $RuleType -or $RuleText -or $AddChildTags -or $RemoveChildTagIds)) { + $ErrorObject.Error = $true + $ErrorObject.Type = "ValidationError" + $ErrorObject.Note = "At least one update parameter must be specified" + return $ErrorObject + } + + # Build XML Request Body + $XmlBuilder = [System.Text.StringBuilder]::new() + [void]$XmlBuilder.AppendLine('') + [void]$XmlBuilder.AppendLine('') + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(' ') + + # Tag Name + if ($Name) { + $EncodedName = [System.Security.SecurityElement]::Escape($Name) + [void]$XmlBuilder.AppendLine(" $EncodedName") + } + + # Color + if ($Color) { + $EncodedColor = [System.Security.SecurityElement]::Escape($Color) + [void]$XmlBuilder.AppendLine(" $EncodedColor") + } + + # Criticality Score + if ($CriticalityScore) { + [void]$XmlBuilder.AppendLine(" $CriticalityScore") + } + + # Rule Type + if ($RuleType) { + [void]$XmlBuilder.AppendLine(" $RuleType") + } + + # Rule Text + if ($RuleText) { + $EncodedRuleText = [System.Security.SecurityElement]::Escape($RuleText) + [void]$XmlBuilder.AppendLine(" $EncodedRuleText") + } + + # Child Tags + if ($AddChildTags -or $RemoveChildTagIds) { + [void]$XmlBuilder.AppendLine(' ') + + # Add child tags + if ($AddChildTags -and $AddChildTags.Count -gt 0) { + [void]$XmlBuilder.AppendLine(' ') + foreach ($ChildTag in $AddChildTags) { + $EncodedChildName = [System.Security.SecurityElement]::Escape($ChildTag) + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(" $EncodedChildName") + [void]$XmlBuilder.AppendLine(' ') + } + [void]$XmlBuilder.AppendLine(' ') + } + + # Remove child tags + if ($RemoveChildTagIds -and $RemoveChildTagIds.Count -gt 0) { + [void]$XmlBuilder.AppendLine(' ') + foreach ($ChildId in $RemoveChildTagIds) { + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(" $ChildId") + [void]$XmlBuilder.AppendLine(' ') + } + [void]$XmlBuilder.AppendLine(' ') + } + + [void]$XmlBuilder.AppendLine(' ') + } + + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine(' ') + [void]$XmlBuilder.AppendLine('') + + $RequestBody = $XmlBuilder.ToString() + Write-Verbose "[$Me]: Request Body:`n$RequestBody" + + # Define URL + $RequestUrl = $BaseUrl.TrimEnd('/') + "/qps/rest/2.0/update/am/tag/$Id" + Write-Verbose "[$Me]: Request URL: $RequestUrl" + + Try { + $Response = Invoke-RestMethod -Uri $RequestUrl -Method $Method -Headers $Headers -Body $RequestBody + + # Parse XML response + if ($Response.ServiceResponse.responseCode -eq "SUCCESS") { + $UpdatedTag = $Response.ServiceResponse.data.Tag + Write-Verbose "[$Me]: Tag updated successfully. ID: $($UpdatedTag.id)" + return $UpdatedTag + } else { + $ErrorObject.Error = $true + $ErrorObject.Type = "APIError" + $ErrorObject.Code = $Response.ServiceResponse.responseCode + + if ($Response.ServiceResponse.responseErrorDetails) { + $ErrorObject.Note = $Response.ServiceResponse.responseErrorDetails.errorMessage + } else { + $ErrorObject.Note = "Qualys API returned an error" + } + + $ErrorObject.Raw = $Response + return $ErrorObject + } + } catch { + $ErrorObject.Error = $true + $ErrorObject.Type = "Exception" + $ErrorObject.Note = $_.Exception.Message + $ErrorObject.Raw = $_ + + if ($_.Exception.Response.StatusCode.value__) { + $ErrorObject.Code = $_.Exception.Response.StatusCode.value__ + Write-Verbose "[$Me]: HTTP Code: $($ErrorObject.Code)" + } + + return $ErrorObject + } + } + + End { } +}