qualys-mcp 2.1.9__tar.gz → 2.2.2__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.1.9
3
+ Version: 2.2.2
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.1.9"
7
+ version = "2.2.2"
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,47 +116,125 @@ def get_assets(limit=100, qql=None):
116
116
  return []
117
117
 
118
118
 
119
- def get_eol_assets(stage_filter="EOL,EOL/EOS", limit=500):
120
- url = f"{GATEWAY_URL}/rest/2.0/search/am/asset?pageSize={limit}"
119
+ def get_eol_assets_by_qql(qql_filter, limit=500):
120
+ """Query assets using QQL filter syntax with pagination"""
121
121
  token = get_bearer_token()
122
+ all_assets = []
123
+ last_seen_id = None
124
+ page_size = min(100, limit)
122
125
 
123
- filter_body = json.dumps({
124
- "filters": [
125
- {"field": "operatingSystem.lifecycle.stage", "operator": "IN", "value": stage_filter}
126
- ]
127
- })
126
+ while len(all_assets) < limit:
127
+ url = f"{GATEWAY_URL}/rest/2.0/search/am/asset?pageSize={page_size}"
128
+ if last_seen_id:
129
+ url += f"&lastSeenAssetId={last_seen_id}"
128
130
 
129
- req = Request(url, data=filter_body.encode(), method='POST')
130
- req.add_header('Authorization', f'Bearer {token}' if token else f'Basic {BASIC_AUTH}')
131
- req.add_header('Content-Type', 'application/json')
132
- req.add_header('X-Requested-With', 'qualys-mcp')
131
+ filter_body = json.dumps({"filter": qql_filter})
132
+ req = Request(url, data=filter_body.encode(), method='POST')
133
+ req.add_header('Authorization', f'Bearer {token}' if token else f'Basic {BASIC_AUTH}')
134
+ req.add_header('Content-Type', 'application/json')
135
+ req.add_header('X-Requested-With', 'qualys-mcp')
133
136
 
134
- try:
135
- with urlopen(req, timeout=60) as resp:
136
- data = json.loads(resp.read())
137
- assets = []
138
- for a in data.get('assetListData', {}).get('asset', []):
139
- os_info = a.get('operatingSystem', {}) or {}
140
- lifecycle = os_info.get('lifecycle', {}) or {}
141
- os_name = os_info.get('osName', '') or os_info.get('fullName', '') or ''
142
- if os_info.get('version'):
143
- os_name = f"{os_name} {os_info.get('version', '')}".strip()
144
- assets.append({
145
- 'assetId': a.get('assetId'),
146
- 'address': a.get('address', ''),
147
- 'dnsName': a.get('dnsHostName', ''),
148
- 'operatingSystem': {
149
- 'osName': os_name,
150
- 'lifecycle': {
151
- 'stage': lifecycle.get('stage', ''),
152
- 'eolDate': lifecycle.get('eolDate', ''),
153
- 'eosDate': lifecycle.get('eosDate', '')
154
- }
155
- }
156
- })
157
- return assets
158
- except Exception as e:
159
- return []
137
+ try:
138
+ with urlopen(req, timeout=60) as resp:
139
+ data = json.loads(resp.read())
140
+ assets = data.get('assetListData', {}).get('asset', [])
141
+ if not assets:
142
+ break
143
+ all_assets.extend(assets)
144
+ if not data.get('hasMore'):
145
+ break
146
+ last_seen_id = assets[-1].get('assetId')
147
+ except:
148
+ break
149
+
150
+ return all_assets[:limit]
151
+
152
+
153
+ def is_eol_stage(stage):
154
+ """Check if stage indicates EOL/EOS status"""
155
+ if not stage:
156
+ return False
157
+ s = stage.upper()
158
+ return ('EOL' in s or 'EOS' in s) and s != 'NOT APPLICABLE'
159
+
160
+
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()
165
+
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:
168
+ os_info = a.get('operatingSystem', {}) or {}
169
+ lifecycle = os_info.get('lifecycle', {}) or {}
170
+ 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:
193
+ hw_info = a.get('hardware', {}) or {}
194
+ lifecycle = hw_info.get('lifecycle', {}) or {}
195
+ 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
236
+
237
+ return results
160
238
 
161
239
 
162
240
  def get_images(limit=100, severity=None):
@@ -537,78 +615,49 @@ def get_asset_risk(asset_id: str) -> dict:
537
615
 
538
616
  @mcp.tool()
539
617
  def get_tech_debt(days_until_eol: int = 0) -> dict:
540
- """Get EOL/EOS software across your environment. Use days_until_eol to find software approaching end-of-life (e.g., 90 for next 90 days). Returns current EOL/EOS plus upcoming."""
618
+ """Get EOL/EOS across OS, hardware, and software. Use days_until_eol to find items approaching end-of-life."""
541
619
  result = {
542
- 'stats': {'total': 0, 'currentEOL': 0, 'currentEOS': 0, 'approachingEOL': 0},
543
- 'currentEOL': [],
544
- 'currentEOS': [],
545
- 'approachingEOL': [],
546
- 'byOS': []
620
+ 'stats': {'osEOL': 0, 'osEOS': 0, 'hardwareEOL': 0, 'softwareEOL': 0, 'total': 0},
621
+ 'os': [],
622
+ 'hardware': [],
623
+ 'software': [],
624
+ 'byCategory': {}
547
625
  }
548
626
 
549
- assets = get_eol_assets("EOL,EOL/EOS,EOS", 500)
550
- result['stats']['total'] = len(assets)
627
+ all_eol = get_all_eol_assets(300)
551
628
 
552
- today = datetime.utcnow().date()
553
- cutoff = today + timedelta(days=days_until_eol) if days_until_eol > 0 else None
554
-
555
- os_data = {}
556
-
557
- for a in assets:
558
- os_info = a.get('operatingSystem', {})
559
- if not isinstance(os_info, dict):
560
- continue
561
-
562
- os_name = os_info.get('osName', 'Unknown')
563
- lc = os_info.get('lifecycle', {})
564
- if not isinstance(lc, dict):
565
- continue
566
-
567
- stage = lc.get('stage', '')
568
- eol_date = lc.get('eolDate', '')
569
- eos_date = lc.get('eosDate', '')
570
-
571
- asset_info = {
572
- 'assetId': a.get('assetId'),
573
- 'ip': a.get('address', ''),
574
- 'hostname': a.get('dnsName', ''),
575
- 'os': os_name,
576
- 'eolDate': eol_date,
577
- 'eosDate': eos_date
578
- }
579
-
580
- if os_name not in os_data:
581
- os_data[os_name] = {'eol': 0, 'eos': 0, 'approaching': 0, 'eolDate': eol_date, 'eosDate': eos_date}
582
-
583
- if stage == 'EOL':
584
- result['stats']['currentEOL'] += 1
585
- os_data[os_name]['eol'] += 1
586
- if len(result['currentEOL']) < 20:
587
- result['currentEOL'].append(asset_info)
588
- elif stage in ('EOS', 'EOL/EOS'):
589
- result['stats']['currentEOS'] += 1
590
- os_data[os_name]['eos'] += 1
591
- if len(result['currentEOS']) < 20:
592
- result['currentEOS'].append(asset_info)
593
- elif cutoff and eol_date:
594
- try:
595
- eol = datetime.strptime(eol_date[:10], '%Y-%m-%d').date()
596
- if today < eol <= cutoff:
597
- result['stats']['approachingEOL'] += 1
598
- os_data[os_name]['approaching'] += 1
599
- days_left = (eol - today).days
600
- asset_info['daysUntilEOL'] = days_left
601
- if len(result['approachingEOL']) < 20:
602
- result['approachingEOL'].append(asset_info)
603
- except:
604
- pass
605
-
606
- result['byOS'] = [
607
- {'os': k, 'eolCount': v['eol'], 'eosCount': v['eos'], 'approachingCount': v['approaching'],
608
- 'eolDate': v['eolDate'], 'eosDate': v['eosDate']}
609
- for k, v in sorted(os_data.items(), key=lambda x: x[1]['eol'] + x[1]['eos'] + x[1]['approaching'], reverse=True)[:15]
610
- if v['eol'] + v['eos'] + v['approaching'] > 0
611
- ]
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
+ }
612
661
 
613
662
  return result
614
663
 
File without changes
File without changes
File without changes