qualys-mcp 2.2.2__tar.gz → 2.2.5__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.5}/PKG-INFO +1 -1
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.5}/pyproject.toml +1 -1
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.5}/qualys_mcp.py +106 -111
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.5}/.gitignore +0 -0
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.5}/LICENSE +0 -0
- {qualys_mcp-2.2.2 → qualys_mcp-2.2.5}/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.5
|
|
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.5"
|
|
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
|
|
|
@@ -613,51 +597,62 @@ def get_asset_risk(asset_id: str) -> dict:
|
|
|
613
597
|
return result
|
|
614
598
|
|
|
615
599
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
'hardware': [],
|
|
623
|
-
'software': [],
|
|
624
|
-
'byCategory': {}
|
|
625
|
-
}
|
|
600
|
+
def get_criticality(asset):
|
|
601
|
+
"""Extract criticality score from asset"""
|
|
602
|
+
crit = asset.get('criticality')
|
|
603
|
+
if isinstance(crit, dict):
|
|
604
|
+
return crit.get('score', 0) or 0
|
|
605
|
+
return crit or 0
|
|
626
606
|
|
|
627
|
-
all_eol = get_all_eol_assets(300)
|
|
628
607
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
608
|
+
@mcp.tool()
|
|
609
|
+
def get_tech_debt(limit: int = 100) -> dict:
|
|
610
|
+
"""Get all EOL/EOS operating systems and hardware, sorted by criticality and risk score."""
|
|
611
|
+
result = {'os': [], 'hardware': []}
|
|
612
|
+
seen = set()
|
|
613
|
+
|
|
614
|
+
for a in get_eol_assets_by_qql("operatingSystem.lifecycle.stage:`EOL/EOS`", limit * 10):
|
|
615
|
+
aid = a.get('assetId')
|
|
616
|
+
if aid in seen:
|
|
617
|
+
continue
|
|
618
|
+
os_info = a.get('operatingSystem', {}) or {}
|
|
619
|
+
lifecycle = os_info.get('lifecycle', {}) or {}
|
|
620
|
+
stage = lifecycle.get('stage', '')
|
|
621
|
+
if is_eol_stage(stage) and len(result['os']) < limit:
|
|
622
|
+
seen.add(aid)
|
|
623
|
+
result['os'].append({
|
|
624
|
+
'assetId': aid,
|
|
625
|
+
'address': a.get('address', ''),
|
|
626
|
+
'hostname': a.get('dnsHostName', '') or a.get('dnsName', ''),
|
|
627
|
+
'os': os_info.get('osName', '') or 'Unknown',
|
|
628
|
+
'stage': stage,
|
|
629
|
+
'criticality': get_criticality(a),
|
|
630
|
+
'riskScore': a.get('assetRiskScore') or 0
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
seen.clear()
|
|
634
|
+
for a in get_eol_assets_by_qql("hardware.lifecycle.stage:`EOL/EOS`", limit * 10):
|
|
635
|
+
aid = a.get('assetId')
|
|
636
|
+
if aid in seen:
|
|
637
|
+
continue
|
|
638
|
+
hw_info = a.get('hardware', {}) or {}
|
|
639
|
+
lifecycle = hw_info.get('lifecycle', {}) or {}
|
|
640
|
+
stage = lifecycle.get('stage', '')
|
|
641
|
+
if is_eol_stage(stage) and len(result['hardware']) < limit:
|
|
642
|
+
seen.add(aid)
|
|
643
|
+
result['hardware'].append({
|
|
644
|
+
'assetId': aid,
|
|
645
|
+
'address': a.get('address', ''),
|
|
646
|
+
'hostname': a.get('dnsHostName', '') or a.get('dnsName', ''),
|
|
647
|
+
'hardware': hw_info.get('model', '') or 'Unknown',
|
|
648
|
+
'stage': stage,
|
|
649
|
+
'criticality': get_criticality(a),
|
|
650
|
+
'riskScore': a.get('assetRiskScore') or 0
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
result['os'].sort(key=lambda x: (-x['criticality'], -x['riskScore']))
|
|
654
|
+
result['hardware'].sort(key=lambda x: (-x['criticality'], -x['riskScore']))
|
|
655
|
+
result['summary'] = {'osEOL': len(result['os']), 'hardwareEOL': len(result['hardware'])}
|
|
661
656
|
|
|
662
657
|
return result
|
|
663
658
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|