qualys-mcp 2.2.0__py3-none-any.whl → 2.2.2__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.
- {qualys_mcp-2.2.0.dist-info → qualys_mcp-2.2.2.dist-info}/METADATA +1 -1
- qualys_mcp-2.2.2.dist-info/RECORD +6 -0
- qualys_mcp.py +153 -110
- qualys_mcp-2.2.0.dist-info/RECORD +0 -6
- {qualys_mcp-2.2.0.dist-info → qualys_mcp-2.2.2.dist-info}/WHEEL +0 -0
- {qualys_mcp-2.2.0.dist-info → qualys_mcp-2.2.2.dist-info}/entry_points.txt +0 -0
- {qualys_mcp-2.2.0.dist-info → qualys_mcp-2.2.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qualys-mcp
|
|
3
|
-
Version: 2.2.
|
|
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
|
|
@@ -0,0 +1,6 @@
|
|
|
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,,
|
qualys_mcp.py
CHANGED
|
@@ -116,47 +116,125 @@ def get_assets(limit=100, qql=None):
|
|
|
116
116
|
return []
|
|
117
117
|
|
|
118
118
|
|
|
119
|
-
def
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
"
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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,85 +615,50 @@ 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
|
|
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': {'
|
|
543
|
-
'
|
|
544
|
-
'
|
|
545
|
-
'
|
|
546
|
-
'
|
|
547
|
-
'debug': {'stages_seen': set(), 'parse_errors': 0}
|
|
620
|
+
'stats': {'osEOL': 0, 'osEOS': 0, 'hardwareEOL': 0, 'softwareEOL': 0, 'total': 0},
|
|
621
|
+
'os': [],
|
|
622
|
+
'hardware': [],
|
|
623
|
+
'software': [],
|
|
624
|
+
'byCategory': {}
|
|
548
625
|
}
|
|
549
626
|
|
|
550
|
-
|
|
551
|
-
result['stats']['total'] = len(assets)
|
|
552
|
-
|
|
553
|
-
today = datetime.utcnow().date()
|
|
554
|
-
cutoff = today + timedelta(days=days_until_eol) if days_until_eol > 0 else None
|
|
555
|
-
|
|
556
|
-
os_data = {}
|
|
557
|
-
|
|
558
|
-
for a in assets:
|
|
559
|
-
os_info = a.get('operatingSystem', {})
|
|
560
|
-
if not isinstance(os_info, dict):
|
|
561
|
-
result['debug']['parse_errors'] += 1
|
|
562
|
-
continue
|
|
563
|
-
|
|
564
|
-
os_name = os_info.get('osName', '') or 'Unknown'
|
|
565
|
-
lc = os_info.get('lifecycle', {})
|
|
566
|
-
if not isinstance(lc, dict):
|
|
567
|
-
result['debug']['parse_errors'] += 1
|
|
568
|
-
continue
|
|
569
|
-
|
|
570
|
-
stage = (lc.get('stage', '') or '').upper()
|
|
571
|
-
result['debug']['stages_seen'].add(stage)
|
|
572
|
-
eol_date = lc.get('eolDate', '') or ''
|
|
573
|
-
eos_date = lc.get('eosDate', '') or ''
|
|
574
|
-
|
|
575
|
-
asset_info = {
|
|
576
|
-
'assetId': a.get('assetId'),
|
|
577
|
-
'ip': a.get('address', ''),
|
|
578
|
-
'hostname': a.get('dnsName', ''),
|
|
579
|
-
'os': os_name,
|
|
580
|
-
'stage': stage,
|
|
581
|
-
'eolDate': eol_date,
|
|
582
|
-
'eosDate': eos_date
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
if os_name not in os_data:
|
|
586
|
-
os_data[os_name] = {'eol': 0, 'eos': 0, 'approaching': 0, 'eolDate': eol_date, 'eosDate': eos_date}
|
|
627
|
+
all_eol = get_all_eol_assets(300)
|
|
587
628
|
|
|
629
|
+
for item in all_eol['os']:
|
|
630
|
+
stage = (item.get('stage', '') or '').upper()
|
|
588
631
|
if 'EOL' in stage and 'EOS' not in stage:
|
|
589
|
-
result['stats']['
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
+
}
|
|
617
661
|
|
|
618
|
-
result['debug']['stages_seen'] = list(result['debug']['stages_seen'])
|
|
619
662
|
return result
|
|
620
663
|
|
|
621
664
|
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
qualys_mcp.py,sha256=1RNCIorwyenlNpxZ0K1nC7Uo3oeyvTnYUn147bDgoxQ,33033
|
|
2
|
-
qualys_mcp-2.2.0.dist-info/METADATA,sha256=-OtX5IZBLBie8-S847DANTnjvoG_ETCxiMheA53uPmE,3295
|
|
3
|
-
qualys_mcp-2.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
4
|
-
qualys_mcp-2.2.0.dist-info/entry_points.txt,sha256=Dc8X0AhJDjGaZOJ0SNpWDWjEX4sYzrYa9FZEbggX0Rs,47
|
|
5
|
-
qualys_mcp-2.2.0.dist-info/licenses/LICENSE,sha256=dW3nC4AX_VbxPAgneSDR-miZPiHgAYw5JhPtdbUEt_E,1091
|
|
6
|
-
qualys_mcp-2.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|