qualys-mcp 2.2.0__tar.gz → 2.2.1__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.0 → qualys_mcp-2.2.1}/PKG-INFO +1 -1
- {qualys_mcp-2.2.0 → qualys_mcp-2.2.1}/pyproject.toml +1 -1
- {qualys_mcp-2.2.0 → qualys_mcp-2.2.1}/qualys_mcp.py +91 -102
- {qualys_mcp-2.2.0 → qualys_mcp-2.2.1}/.gitignore +0 -0
- {qualys_mcp-2.2.0 → qualys_mcp-2.2.1}/LICENSE +0 -0
- {qualys_mcp-2.2.0 → qualys_mcp-2.2.1}/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.1
|
|
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.1"
|
|
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,15 +116,12 @@ def get_assets(limit=100, qql=None):
|
|
|
116
116
|
return []
|
|
117
117
|
|
|
118
118
|
|
|
119
|
-
def
|
|
119
|
+
def get_eol_assets_by_qql(qql_filter, limit=500):
|
|
120
|
+
"""Query assets using QQL filter syntax"""
|
|
120
121
|
url = f"{GATEWAY_URL}/rest/2.0/search/am/asset?pageSize={limit}"
|
|
121
122
|
token = get_bearer_token()
|
|
122
123
|
|
|
123
|
-
filter_body = json.dumps({
|
|
124
|
-
"filters": [
|
|
125
|
-
{"field": "operatingSystem.lifecycle.stage", "operator": "IN", "value": stage_filter}
|
|
126
|
-
]
|
|
127
|
-
})
|
|
124
|
+
filter_body = json.dumps({"filter": qql_filter})
|
|
128
125
|
|
|
129
126
|
req = Request(url, data=filter_body.encode(), method='POST')
|
|
130
127
|
req.add_header('Authorization', f'Bearer {token}' if token else f'Basic {BASIC_AUTH}')
|
|
@@ -133,32 +130,59 @@ def get_eol_assets(stage_filter="EOL,EOL/EOS", limit=500):
|
|
|
133
130
|
|
|
134
131
|
try:
|
|
135
132
|
with urlopen(req, timeout=60) as resp:
|
|
136
|
-
|
|
137
|
-
|
|
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:
|
|
133
|
+
return json.loads(resp.read()).get('assetListData', {}).get('asset', [])
|
|
134
|
+
except:
|
|
159
135
|
return []
|
|
160
136
|
|
|
161
137
|
|
|
138
|
+
def get_all_eol_assets(limit=300):
|
|
139
|
+
"""Get all EOL/EOS assets across OS, hardware, and software"""
|
|
140
|
+
results = {'os': [], 'hardware': [], 'software': []}
|
|
141
|
+
|
|
142
|
+
os_assets = get_eol_assets_by_qql("operatingSystem.lifecycle.stage:EOL or operatingSystem.lifecycle.stage:EOS or operatingSystem.lifecycle.stage:`EOL/EOS`", limit)
|
|
143
|
+
for a in os_assets:
|
|
144
|
+
os_info = a.get('operatingSystem', {}) or {}
|
|
145
|
+
lifecycle = os_info.get('lifecycle', {}) or {}
|
|
146
|
+
results['os'].append({
|
|
147
|
+
'assetId': a.get('assetId'),
|
|
148
|
+
'address': a.get('address', ''),
|
|
149
|
+
'dnsName': a.get('dnsHostName', '') or a.get('dnsName', ''),
|
|
150
|
+
'type': 'os',
|
|
151
|
+
'name': os_info.get('osName', '') or os_info.get('fullName', '') or 'Unknown',
|
|
152
|
+
'stage': lifecycle.get('stage', ''),
|
|
153
|
+
'eolDate': lifecycle.get('eolDate', ''),
|
|
154
|
+
'eosDate': lifecycle.get('eosDate', '')
|
|
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:
|
|
159
|
+
hw_info = a.get('hardware', {}) or {}
|
|
160
|
+
lifecycle = hw_info.get('lifecycle', {}) or {}
|
|
161
|
+
results['hardware'].append({
|
|
162
|
+
'assetId': a.get('assetId'),
|
|
163
|
+
'address': a.get('address', ''),
|
|
164
|
+
'dnsName': a.get('dnsHostName', '') or a.get('dnsName', ''),
|
|
165
|
+
'type': 'hardware',
|
|
166
|
+
'name': hw_info.get('model', '') or hw_info.get('name', '') or 'Unknown',
|
|
167
|
+
'stage': lifecycle.get('stage', ''),
|
|
168
|
+
'eolDate': lifecycle.get('eolDate', ''),
|
|
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
|
+
})
|
|
182
|
+
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
162
186
|
def get_images(limit=100, severity=None):
|
|
163
187
|
url = f"{GATEWAY_URL}/csapi/v1.3/images?pageSize={limit}"
|
|
164
188
|
if severity:
|
|
@@ -537,85 +561,50 @@ def get_asset_risk(asset_id: str) -> dict:
|
|
|
537
561
|
|
|
538
562
|
@mcp.tool()
|
|
539
563
|
def get_tech_debt(days_until_eol: int = 0) -> dict:
|
|
540
|
-
"""Get EOL/EOS
|
|
564
|
+
"""Get EOL/EOS across OS, hardware, and software. Use days_until_eol to find items approaching end-of-life."""
|
|
541
565
|
result = {
|
|
542
|
-
'stats': {'
|
|
543
|
-
'
|
|
544
|
-
'
|
|
545
|
-
'
|
|
546
|
-
'
|
|
547
|
-
'debug': {'stages_seen': set(), 'parse_errors': 0}
|
|
566
|
+
'stats': {'osEOL': 0, 'osEOS': 0, 'hardwareEOL': 0, 'softwareEOL': 0, 'total': 0},
|
|
567
|
+
'os': [],
|
|
568
|
+
'hardware': [],
|
|
569
|
+
'software': [],
|
|
570
|
+
'byCategory': {}
|
|
548
571
|
}
|
|
549
572
|
|
|
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}
|
|
573
|
+
all_eol = get_all_eol_assets(300)
|
|
587
574
|
|
|
575
|
+
for item in all_eol['os']:
|
|
576
|
+
stage = (item.get('stage', '') or '').upper()
|
|
588
577
|
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
|
-
|
|
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
|
+
}
|
|
617
607
|
|
|
618
|
-
result['debug']['stages_seen'] = list(result['debug']['stages_seen'])
|
|
619
608
|
return result
|
|
620
609
|
|
|
621
610
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|