qualys-mcp 2.2.2__tar.gz → 2.2.4__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.
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.4}/PKG-INFO +1 -1
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.4}/pyproject.toml +1 -1
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.4}/qualys_mcp.py +60 -111
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.4}/.gitignore +0 -0
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.4}/LICENSE +0 -0
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.4}/README.md +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.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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qualys-mcp"
|
|
7
|
-
version = "2.2.
|
|
7
|
+
version = "2.2.4"
|
|
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
|
|
162
|
-
"""Get
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
619
|
-
result = {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|