qualys-mcp 2.2.2__py3-none-any.whl → 2.2.4__py3-none-any.whl

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.4
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
@@ -0,0 +1,6 @@
1
+ qualys_mcp.py,sha256=8Lf5HWF7l7VpSwhnz5iAlwcvIna11Glj83iWXwgqgoU,32424
2
+ qualys_mcp-2.2.4.dist-info/METADATA,sha256=tJwDhsTuKqhlUXjMuwlfczvlt-7Cto2nPSAjOknjzDY,3295
3
+ qualys_mcp-2.2.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
4
+ qualys_mcp-2.2.4.dist-info/entry_points.txt,sha256=Dc8X0AhJDjGaZOJ0SNpWDWjEX4sYzrYa9FZEbggX0Rs,47
5
+ qualys_mcp-2.2.4.dist-info/licenses/LICENSE,sha256=dW3nC4AX_VbxPAgneSDR-miZPiHgAYw5JhPtdbUEt_E,1091
6
+ qualys_mcp-2.2.4.dist-info/RECORD,,
qualys_mcp.py CHANGED
@@ -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
 
@@ -615,49 +599,14 @@ def get_asset_risk(asset_id: str) -> dict:
615
599
 
616
600
  @mcp.tool()
617
601
  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
- }
626
-
627
- all_eol = get_all_eol_assets(300)
628
-
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
- }
602
+ """Get EOL/EOS operating systems and hardware. Returns sample assets with EOL/EOS status."""
603
+ result = {'os': [], 'hardware': []}
604
+
605
+ samples = get_all_eol_assets(15)
606
+ result['os'] = samples['os']
607
+ result['hardware'] = samples['hardware']
608
+ result['osCount'] = len(samples['os'])
609
+ result['hardwareCount'] = len(samples['hardware'])
661
610
 
662
611
  return result
663
612
 
@@ -1,6 +0,0 @@
1
- qualys_mcp.py,sha256=L5jSFzPOZWS4D5Tl6x8EbX8mhZQE4OQjnokdKDmNPwU,34292
2
- qualys_mcp-2.2.2.dist-info/METADATA,sha256=W2hHuoK0iePAS8N2c4x272Zpq1IhYP_4-A2DGk91FOc,3295
3
- qualys_mcp-2.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
4
- qualys_mcp-2.2.2.dist-info/entry_points.txt,sha256=Dc8X0AhJDjGaZOJ0SNpWDWjEX4sYzrYa9FZEbggX0Rs,47
5
- qualys_mcp-2.2.2.dist-info/licenses/LICENSE,sha256=dW3nC4AX_VbxPAgneSDR-miZPiHgAYw5JhPtdbUEt_E,1091
6
- qualys_mcp-2.2.2.dist-info/RECORD,,