qualys-mcp 2.2.2__tar.gz → 2.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qualys-mcp
3
- Version: 2.2.2
3
+ Version: 2.2.5
4
4
  Summary: MCP server for Qualys security APIs - natural language interaction with vulnerability, asset, and cloud security data
5
5
  Project-URL: Homepage, https://github.com/nelssec/qualys-mcp
6
6
  Project-URL: Repository, https://github.com/nelssec/qualys-mcp
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "qualys-mcp"
7
- version = "2.2.2"
7
+ version = "2.2.5"
8
8
  description = "MCP server for Qualys security APIs - natural language interaction with vulnerability, asset, and cloud security data"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -116,6 +116,22 @@ def get_assets(limit=100, qql=None):
116
116
  return []
117
117
 
118
118
 
119
+ def get_eol_count(qql_filter):
120
+ """Get count of assets matching QQL filter (fast, no pagination)"""
121
+ token = get_bearer_token()
122
+ url = f"{GATEWAY_URL}/rest/2.0/count/am/asset"
123
+ filter_body = json.dumps({"filter": qql_filter})
124
+ req = Request(url, data=filter_body.encode(), method='POST')
125
+ req.add_header('Authorization', f'Bearer {token}' if token else f'Basic {BASIC_AUTH}')
126
+ req.add_header('Content-Type', 'application/json')
127
+ req.add_header('X-Requested-With', 'qualys-mcp')
128
+ try:
129
+ with urlopen(req, timeout=30) as resp:
130
+ return json.loads(resp.read()).get('count', 0)
131
+ except:
132
+ return 0
133
+
134
+
119
135
  def get_eol_assets_by_qql(qql_filter, limit=500):
120
136
  """Query assets using QQL filter syntax with pagination"""
121
137
  token = get_bearer_token()
@@ -158,81 +174,49 @@ def is_eol_stage(stage):
158
174
  return ('EOL' in s or 'EOS' in s) and s != 'NOT APPLICABLE'
159
175
 
160
176
 
161
- def get_all_eol_assets(limit=300):
162
- """Get all EOL/EOS assets across OS, hardware, and software"""
163
- results = {'os': [], 'hardware': [], 'software': []}
164
- seen_ids = set()
177
+ def get_eol_sample(qql_filter, limit=100):
178
+ """Get single page of assets (no pagination) and filter for EOL"""
179
+ token = get_bearer_token()
180
+ url = f"{GATEWAY_URL}/rest/2.0/search/am/asset?pageSize={limit}"
181
+ filter_body = json.dumps({"filter": qql_filter})
182
+ req = Request(url, data=filter_body.encode(), method='POST')
183
+ req.add_header('Authorization', f'Bearer {token}' if token else f'Basic {BASIC_AUTH}')
184
+ req.add_header('Content-Type', 'application/json')
185
+ req.add_header('X-Requested-With', 'qualys-mcp')
186
+ try:
187
+ with urlopen(req, timeout=30) as resp:
188
+ return json.loads(resp.read()).get('assetListData', {}).get('asset', [])
189
+ except:
190
+ return []
191
+
192
+
193
+ def get_all_eol_assets(limit=15):
194
+ """Get EOL/EOS asset samples (fast, single page per query)"""
195
+ results = {'os': [], 'hardware': []}
165
196
 
166
- os_assets = get_eol_assets_by_qql("operatingSystem.lifecycle.stage:`EOL/EOS` or operatingSystem.lifecycle.stage:EOL or operatingSystem.lifecycle.stage:EOS", limit * 5)
167
- for a in os_assets:
197
+ for a in get_eol_sample("operatingSystem.lifecycle.stage:`EOL/EOS`", 100):
168
198
  os_info = a.get('operatingSystem', {}) or {}
169
199
  lifecycle = os_info.get('lifecycle', {}) or {}
170
200
  stage = lifecycle.get('stage', '')
171
- if not is_eol_stage(stage):
172
- continue
173
- aid = a.get('assetId')
174
- if aid in seen_ids:
175
- continue
176
- seen_ids.add(aid)
177
- results['os'].append({
178
- 'assetId': aid,
179
- 'address': a.get('address', ''),
180
- 'dnsName': a.get('dnsHostName', '') or a.get('dnsName', ''),
181
- 'type': 'os',
182
- 'name': os_info.get('osName', '') or os_info.get('fullName', '') or 'Unknown',
183
- 'stage': stage,
184
- 'eolDate': lifecycle.get('eolDate', ''),
185
- 'eosDate': lifecycle.get('eosDate', '')
186
- })
187
- if len(results['os']) >= limit:
188
- break
189
-
190
- seen_ids.clear()
191
- hw_assets = get_eol_assets_by_qql("hardware.lifecycle.stage:`EOL/EOS` or hardware.lifecycle.stage:EOL or hardware.lifecycle.stage:EOS", limit * 5)
192
- for a in hw_assets:
201
+ if is_eol_stage(stage) and len(results['os']) < limit:
202
+ results['os'].append({
203
+ 'assetId': a.get('assetId'),
204
+ 'address': a.get('address', ''),
205
+ 'name': os_info.get('osName', '') or 'Unknown',
206
+ 'stage': stage
207
+ })
208
+
209
+ for a in get_eol_sample("hardware.lifecycle.stage:`EOL/EOS`", 100):
193
210
  hw_info = a.get('hardware', {}) or {}
194
211
  lifecycle = hw_info.get('lifecycle', {}) or {}
195
212
  stage = lifecycle.get('stage', '')
196
- if not is_eol_stage(stage):
197
- continue
198
- aid = a.get('assetId')
199
- if aid in seen_ids:
200
- continue
201
- seen_ids.add(aid)
202
- results['hardware'].append({
203
- 'assetId': aid,
204
- 'address': a.get('address', ''),
205
- 'dnsName': a.get('dnsHostName', '') or a.get('dnsName', ''),
206
- 'type': 'hardware',
207
- 'name': hw_info.get('model', '') or hw_info.get('name', '') or 'Unknown',
208
- 'stage': stage,
209
- 'eolDate': lifecycle.get('eolDate', ''),
210
- 'eosDate': lifecycle.get('eosDate', '')
211
- })
212
- if len(results['hardware']) >= limit:
213
- break
214
-
215
- seen_ids.clear()
216
- sw_assets = get_eol_assets_by_qql("software:(lifecycle.stage:`EOL/EOS` or lifecycle.stage:EOL or lifecycle.stage:EOS)", limit * 5)
217
- for a in sw_assets:
218
- aid = a.get('assetId')
219
- if aid in seen_ids:
220
- continue
221
- seen_ids.add(aid)
222
- sw_list = a.get('software', []) or []
223
- eol_sw = [s for s in sw_list if is_eol_stage((s.get('lifecycle', {}) or {}).get('stage', ''))]
224
- sw_names = [s.get('name', 'Unknown') for s in eol_sw[:3]]
225
- results['software'].append({
226
- 'assetId': aid,
227
- 'address': a.get('address', ''),
228
- 'dnsName': a.get('dnsHostName', '') or a.get('dnsName', ''),
229
- 'type': 'software',
230
- 'name': ', '.join(sw_names) if sw_names else 'Has EOL software',
231
- 'stage': 'EOL',
232
- 'eolSoftwareCount': len(eol_sw)
233
- })
234
- if len(results['software']) >= limit:
235
- break
213
+ if is_eol_stage(stage) and len(results['hardware']) < limit:
214
+ results['hardware'].append({
215
+ 'assetId': a.get('assetId'),
216
+ 'address': a.get('address', ''),
217
+ 'name': hw_info.get('model', '') or 'Unknown',
218
+ 'stage': stage
219
+ })
236
220
 
237
221
  return results
238
222
 
@@ -613,51 +597,62 @@ def get_asset_risk(asset_id: str) -> dict:
613
597
  return result
614
598
 
615
599
 
616
- @mcp.tool()
617
- def get_tech_debt(days_until_eol: int = 0) -> dict:
618
- """Get EOL/EOS across OS, hardware, and software. Use days_until_eol to find items approaching end-of-life."""
619
- result = {
620
- 'stats': {'osEOL': 0, 'osEOS': 0, 'hardwareEOL': 0, 'softwareEOL': 0, 'total': 0},
621
- 'os': [],
622
- 'hardware': [],
623
- 'software': [],
624
- 'byCategory': {}
625
- }
600
+ def get_criticality(asset):
601
+ """Extract criticality score from asset"""
602
+ crit = asset.get('criticality')
603
+ if isinstance(crit, dict):
604
+ return crit.get('score', 0) or 0
605
+ return crit or 0
626
606
 
627
- all_eol = get_all_eol_assets(300)
628
607
 
629
- for item in all_eol['os']:
630
- stage = (item.get('stage', '') or '').upper()
631
- if 'EOL' in stage and 'EOS' not in stage:
632
- result['stats']['osEOL'] += 1
633
- else:
634
- result['stats']['osEOS'] += 1
635
- if len(result['os']) < 20:
636
- result['os'].append(item)
637
-
638
- for item in all_eol['hardware']:
639
- result['stats']['hardwareEOL'] += 1
640
- if len(result['hardware']) < 20:
641
- result['hardware'].append(item)
642
-
643
- sw_by_name = {}
644
- for item in all_eol['software']:
645
- name = item.get('name', 'Unknown')
646
- if name not in sw_by_name:
647
- sw_by_name[name] = {'name': name, 'version': item.get('version', ''), 'count': 0, 'stage': item.get('stage', '')}
648
- sw_by_name[name]['count'] += 1
649
- result['stats']['softwareEOL'] += 1
650
-
651
- result['software'] = sorted(sw_by_name.values(), key=lambda x: x['count'], reverse=True)[:20]
652
-
653
- result['stats']['total'] = (result['stats']['osEOL'] + result['stats']['osEOS'] +
654
- result['stats']['hardwareEOL'] + result['stats']['softwareEOL'])
655
-
656
- result['byCategory'] = {
657
- 'operatingSystem': len(all_eol['os']),
658
- 'hardware': len(all_eol['hardware']),
659
- 'software': len(all_eol['software'])
660
- }
608
+ @mcp.tool()
609
+ def get_tech_debt(limit: int = 100) -> dict:
610
+ """Get all EOL/EOS operating systems and hardware, sorted by criticality and risk score."""
611
+ result = {'os': [], 'hardware': []}
612
+ seen = set()
613
+
614
+ for a in get_eol_assets_by_qql("operatingSystem.lifecycle.stage:`EOL/EOS`", limit * 10):
615
+ aid = a.get('assetId')
616
+ if aid in seen:
617
+ continue
618
+ os_info = a.get('operatingSystem', {}) or {}
619
+ lifecycle = os_info.get('lifecycle', {}) or {}
620
+ stage = lifecycle.get('stage', '')
621
+ if is_eol_stage(stage) and len(result['os']) < limit:
622
+ seen.add(aid)
623
+ result['os'].append({
624
+ 'assetId': aid,
625
+ 'address': a.get('address', ''),
626
+ 'hostname': a.get('dnsHostName', '') or a.get('dnsName', ''),
627
+ 'os': os_info.get('osName', '') or 'Unknown',
628
+ 'stage': stage,
629
+ 'criticality': get_criticality(a),
630
+ 'riskScore': a.get('assetRiskScore') or 0
631
+ })
632
+
633
+ seen.clear()
634
+ for a in get_eol_assets_by_qql("hardware.lifecycle.stage:`EOL/EOS`", limit * 10):
635
+ aid = a.get('assetId')
636
+ if aid in seen:
637
+ continue
638
+ hw_info = a.get('hardware', {}) or {}
639
+ lifecycle = hw_info.get('lifecycle', {}) or {}
640
+ stage = lifecycle.get('stage', '')
641
+ if is_eol_stage(stage) and len(result['hardware']) < limit:
642
+ seen.add(aid)
643
+ result['hardware'].append({
644
+ 'assetId': aid,
645
+ 'address': a.get('address', ''),
646
+ 'hostname': a.get('dnsHostName', '') or a.get('dnsName', ''),
647
+ 'hardware': hw_info.get('model', '') or 'Unknown',
648
+ 'stage': stage,
649
+ 'criticality': get_criticality(a),
650
+ 'riskScore': a.get('assetRiskScore') or 0
651
+ })
652
+
653
+ result['os'].sort(key=lambda x: (-x['criticality'], -x['riskScore']))
654
+ result['hardware'].sort(key=lambda x: (-x['criticality'], -x['riskScore']))
655
+ result['summary'] = {'osEOL': len(result['os']), 'hardwareEOL': len(result['hardware'])}
661
656
 
662
657
  return result
663
658
 
File without changes
File without changes
File without changes