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 { }
+}