qualys-mcp 2.2.1__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.1
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,69 +116,107 @@ 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
- """Query assets using QQL filter syntax"""
121
- url = f"{GATEWAY_URL}/rest/2.0/search/am/asset?pageSize={limit}"
136
+ """Query assets using QQL filter syntax with pagination"""
122
137
  token = get_bearer_token()
138
+ all_assets = []
139
+ last_seen_id = None
140
+ page_size = min(100, limit)
123
141
 
124
- filter_body = json.dumps({"filter": qql_filter})
142
+ while len(all_assets) < limit:
143
+ url = f"{GATEWAY_URL}/rest/2.0/search/am/asset?pageSize={page_size}"
144
+ if last_seen_id:
145
+ url += f"&lastSeenAssetId={last_seen_id}"
146
+
147
+ filter_body = json.dumps({"filter": qql_filter})
148
+ req = Request(url, data=filter_body.encode(), method='POST')
149
+ req.add_header('Authorization', f'Bearer {token}' if token else f'Basic {BASIC_AUTH}')
150
+ req.add_header('Content-Type', 'application/json')
151
+ req.add_header('X-Requested-With', 'qualys-mcp')
152
+
153
+ try:
154
+ with urlopen(req, timeout=60) as resp:
155
+ data = json.loads(resp.read())
156
+ assets = data.get('assetListData', {}).get('asset', [])
157
+ if not assets:
158
+ break
159
+ all_assets.extend(assets)
160
+ if not data.get('hasMore'):
161
+ break
162
+ last_seen_id = assets[-1].get('assetId')
163
+ except:
164
+ break
125
165
 
166
+ return all_assets[:limit]
167
+
168
+
169
+ def is_eol_stage(stage):
170
+ """Check if stage indicates EOL/EOS status"""
171
+ if not stage:
172
+ return False
173
+ s = stage.upper()
174
+ return ('EOL' in s or 'EOS' in s) and s != 'NOT APPLICABLE'
175
+
176
+
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})
126
182
  req = Request(url, data=filter_body.encode(), method='POST')
127
183
  req.add_header('Authorization', f'Bearer {token}' if token else f'Basic {BASIC_AUTH}')
128
184
  req.add_header('Content-Type', 'application/json')
129
185
  req.add_header('X-Requested-With', 'qualys-mcp')
130
-
131
186
  try:
132
- with urlopen(req, timeout=60) as resp:
187
+ with urlopen(req, timeout=30) as resp:
133
188
  return json.loads(resp.read()).get('assetListData', {}).get('asset', [])
134
189
  except:
135
190
  return []
136
191
 
137
192
 
138
- def get_all_eol_assets(limit=300):
139
- """Get all EOL/EOS assets across OS, hardware, and software"""
140
- results = {'os': [], 'hardware': [], 'software': []}
193
+ def get_all_eol_assets(limit=15):
194
+ """Get EOL/EOS asset samples (fast, single page per query)"""
195
+ results = {'os': [], 'hardware': []}
141
196
 
142
- os_assets = get_eol_assets_by_qql("operatingSystem.lifecycle.stage:EOL or operatingSystem.lifecycle.stage:EOS or operatingSystem.lifecycle.stage:`EOL/EOS`", limit)
143
- for a in os_assets:
197
+ for a in get_eol_sample("operatingSystem.lifecycle.stage:`EOL/EOS`", 100):
144
198
  os_info = a.get('operatingSystem', {}) or {}
145
199
  lifecycle = os_info.get('lifecycle', {}) or {}
146
- results['os'].append({
147
- 'assetId': a.get('assetId'),
148
- 'address': a.get('address', ''),
149
- 'dnsName': a.get('dnsHostName', '') or a.get('dnsName', ''),
150
- 'type': 'os',
151
- 'name': os_info.get('osName', '') or os_info.get('fullName', '') or 'Unknown',
152
- 'stage': lifecycle.get('stage', ''),
153
- 'eolDate': lifecycle.get('eolDate', ''),
154
- 'eosDate': lifecycle.get('eosDate', '')
155
- })
156
-
157
- hw_assets = get_eol_assets_by_qql("hardware.lifecycle.stage:EOS or hardware.lifecycle.stage:EOL", limit)
158
- for a in hw_assets:
200
+ stage = lifecycle.get('stage', '')
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):
159
210
  hw_info = a.get('hardware', {}) or {}
160
211
  lifecycle = hw_info.get('lifecycle', {}) or {}
161
- results['hardware'].append({
162
- 'assetId': a.get('assetId'),
163
- 'address': a.get('address', ''),
164
- 'dnsName': a.get('dnsHostName', '') or a.get('dnsName', ''),
165
- 'type': 'hardware',
166
- 'name': hw_info.get('model', '') or hw_info.get('name', '') or 'Unknown',
167
- 'stage': lifecycle.get('stage', ''),
168
- 'eolDate': lifecycle.get('eolDate', ''),
169
- 'eosDate': lifecycle.get('eosDate', '')
170
- })
171
-
172
- sw_assets = get_eol_assets_by_qql("software:(lifecycle.stage:EOL)", limit)
173
- for a in sw_assets:
174
- results['software'].append({
175
- 'assetId': a.get('assetId'),
176
- 'address': a.get('address', ''),
177
- 'dnsName': a.get('dnsHostName', '') or a.get('dnsName', ''),
178
- 'type': 'software',
179
- 'name': 'Has EOL software',
180
- 'stage': 'EOL'
181
- })
212
+ stage = lifecycle.get('stage', '')
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
+ })
182
220
 
183
221
  return results
184
222
 
@@ -561,49 +599,14 @@ def get_asset_risk(asset_id: str) -> dict:
561
599
 
562
600
  @mcp.tool()
563
601
  def get_tech_debt(days_until_eol: int = 0) -> dict:
564
- """Get EOL/EOS across OS, hardware, and software. Use days_until_eol to find items approaching end-of-life."""
565
- result = {
566
- 'stats': {'osEOL': 0, 'osEOS': 0, 'hardwareEOL': 0, 'softwareEOL': 0, 'total': 0},
567
- 'os': [],
568
- 'hardware': [],
569
- 'software': [],
570
- 'byCategory': {}
571
- }
572
-
573
- all_eol = get_all_eol_assets(300)
574
-
575
- for item in all_eol['os']:
576
- stage = (item.get('stage', '') or '').upper()
577
- if 'EOL' in stage and 'EOS' not in stage:
578
- result['stats']['osEOL'] += 1
579
- else:
580
- result['stats']['osEOS'] += 1
581
- if len(result['os']) < 20:
582
- result['os'].append(item)
583
-
584
- for item in all_eol['hardware']:
585
- result['stats']['hardwareEOL'] += 1
586
- if len(result['hardware']) < 20:
587
- result['hardware'].append(item)
588
-
589
- sw_by_name = {}
590
- for item in all_eol['software']:
591
- name = item.get('name', 'Unknown')
592
- if name not in sw_by_name:
593
- sw_by_name[name] = {'name': name, 'version': item.get('version', ''), 'count': 0, 'stage': item.get('stage', '')}
594
- sw_by_name[name]['count'] += 1
595
- result['stats']['softwareEOL'] += 1
596
-
597
- result['software'] = sorted(sw_by_name.values(), key=lambda x: x['count'], reverse=True)[:20]
598
-
599
- result['stats']['total'] = (result['stats']['osEOL'] + result['stats']['osEOS'] +
600
- result['stats']['hardwareEOL'] + result['stats']['softwareEOL'])
601
-
602
- result['byCategory'] = {
603
- 'operatingSystem': len(all_eol['os']),
604
- 'hardware': len(all_eol['hardware']),
605
- 'software': len(all_eol['software'])
606
- }
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'])
607
610
 
608
611
  return result
609
612
 
@@ -1,6 +0,0 @@
1
- qualys_mcp.py,sha256=16aUDePxLy7OyDGj3vMl3RwTgwKIYsrrwPSLpJ-Ci1M,32468
2
- qualys_mcp-2.2.1.dist-info/METADATA,sha256=Eeo3u3kkqAvTR_2wI-CvEbw462ywD3b4xCQ6igfK_QA,3295
3
- qualys_mcp-2.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
4
- qualys_mcp-2.2.1.dist-info/entry_points.txt,sha256=Dc8X0AhJDjGaZOJ0SNpWDWjEX4sYzrYa9FZEbggX0Rs,47
5
- qualys_mcp-2.2.1.dist-info/licenses/LICENSE,sha256=dW3nC4AX_VbxPAgneSDR-miZPiHgAYw5JhPtdbUEt_E,1091
6
- qualys_mcp-2.2.1.dist-info/RECORD,,