qualys-mcp 2.1.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qualys-mcp
3
- Version: 2.1.9
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.1.9"
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 get_eol_assets(stage_filter="EOL,EOL/EOS", limit=500):
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
- data = json.loads(resp.read())
137
- assets = []
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,78 +561,49 @@ 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 software across your environment. Use days_until_eol to find software approaching end-of-life (e.g., 90 for next 90 days). Returns current EOL/EOS plus upcoming."""
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': {'total': 0, 'currentEOL': 0, 'currentEOS': 0, 'approachingEOL': 0},
543
- 'currentEOL': [],
544
- 'currentEOS': [],
545
- 'approachingEOL': [],
546
- 'byOS': []
566
+ 'stats': {'osEOL': 0, 'osEOS': 0, 'hardwareEOL': 0, 'softwareEOL': 0, 'total': 0},
567
+ 'os': [],
568
+ 'hardware': [],
569
+ 'software': [],
570
+ 'byCategory': {}
547
571
  }
548
572
 
549
- assets = get_eol_assets("EOL,EOL/EOS,EOS", 500)
550
- result['stats']['total'] = len(assets)
573
+ all_eol = get_all_eol_assets(300)
551
574
 
552
- today = datetime.utcnow().date()
553
- cutoff = today + timedelta(days=days_until_eol) if days_until_eol > 0 else None
554
-
555
- os_data = {}
556
-
557
- for a in assets:
558
- os_info = a.get('operatingSystem', {})
559
- if not isinstance(os_info, dict):
560
- continue
561
-
562
- os_name = os_info.get('osName', 'Unknown')
563
- lc = os_info.get('lifecycle', {})
564
- if not isinstance(lc, dict):
565
- continue
566
-
567
- stage = lc.get('stage', '')
568
- eol_date = lc.get('eolDate', '')
569
- eos_date = lc.get('eosDate', '')
570
-
571
- asset_info = {
572
- 'assetId': a.get('assetId'),
573
- 'ip': a.get('address', ''),
574
- 'hostname': a.get('dnsName', ''),
575
- 'os': os_name,
576
- 'eolDate': eol_date,
577
- 'eosDate': eos_date
578
- }
579
-
580
- if os_name not in os_data:
581
- os_data[os_name] = {'eol': 0, 'eos': 0, 'approaching': 0, 'eolDate': eol_date, 'eosDate': eos_date}
582
-
583
- if stage == 'EOL':
584
- result['stats']['currentEOL'] += 1
585
- os_data[os_name]['eol'] += 1
586
- if len(result['currentEOL']) < 20:
587
- result['currentEOL'].append(asset_info)
588
- elif stage in ('EOS', 'EOL/EOS'):
589
- result['stats']['currentEOS'] += 1
590
- os_data[os_name]['eos'] += 1
591
- if len(result['currentEOS']) < 20:
592
- result['currentEOS'].append(asset_info)
593
- elif cutoff and eol_date:
594
- try:
595
- eol = datetime.strptime(eol_date[:10], '%Y-%m-%d').date()
596
- if today < eol <= cutoff:
597
- result['stats']['approachingEOL'] += 1
598
- os_data[os_name]['approaching'] += 1
599
- days_left = (eol - today).days
600
- asset_info['daysUntilEOL'] = days_left
601
- if len(result['approachingEOL']) < 20:
602
- result['approachingEOL'].append(asset_info)
603
- except:
604
- pass
605
-
606
- result['byOS'] = [
607
- {'os': k, 'eolCount': v['eol'], 'eosCount': v['eos'], 'approachingCount': v['approaching'],
608
- 'eolDate': v['eolDate'], 'eosDate': v['eosDate']}
609
- for k, v in sorted(os_data.items(), key=lambda x: x[1]['eol'] + x[1]['eos'] + x[1]['approaching'], reverse=True)[:15]
610
- if v['eol'] + v['eos'] + v['approaching'] > 0
611
- ]
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
+ }
612
607
 
613
608
  return result
614
609
 
File without changes
File without changes
File without changes