qualys-mcp 2.2.1__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.1 → qualys_mcp-2.2.4}/PKG-INFO +1 -1
- {qualys_mcp-2.2.1 → qualys_mcp-2.2.4}/pyproject.toml +1 -1
- {qualys_mcp-2.2.1 → qualys_mcp-2.2.4}/qualys_mcp.py +90 -87
- {qualys_mcp-2.2.1 → qualys_mcp-2.2.4}/.gitignore +0 -0
- {qualys_mcp-2.2.1 → qualys_mcp-2.2.4}/LICENSE +0 -0
- {qualys_mcp-2.2.1 → 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,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
|
-
|
|
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=
|
|
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=
|
|
139
|
-
"""Get
|
|
140
|
-
results = {'os': [], 'hardware': []
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
'
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
'
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
565
|
-
result = {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|