qualys-mcp 0.1.0 → 0.2.0
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.
- package/README.md +5 -1
- package/dist/index.js +62 -2
- package/dist/qualys-client.js +80 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,8 @@ Read-only MCP server for the Qualys VMDR API. Exposes host assets, vulnerability
|
|
|
6
6
|
|
|
7
7
|
| Tool | Purpose |
|
|
8
8
|
|---|---|
|
|
9
|
-
| `
|
|
9
|
+
| `qualys_asset_inventory` | Full CSAM/Global AssetView inventory: all assets with criticality, tags, last logged-on user, agent check-in, sources (QQL filterable) |
|
|
10
|
+
| `qualys_list_hosts` | Scanned host assets (ID, IP, DNS, OS, last scan dates, optional tags) |
|
|
10
11
|
| `qualys_host_detections` | Vulnerabilities detected per host (QID, severity, status, evidence) |
|
|
11
12
|
| `qualys_knowledgebase` | QID details: title, CVEs, threat, impact, solution |
|
|
12
13
|
| `qualys_list_scans` | Scan history and status |
|
|
@@ -48,6 +49,7 @@ Three required environment variables:
|
|
|
48
49
|
| `QUALYS_USERNAME` | Qualys API user login |
|
|
49
50
|
| `QUALYS_PASSWORD` | Qualys API user password |
|
|
50
51
|
| `QUALYS_BASE_URL` | Your platform's API server (see below) |
|
|
52
|
+
| `QUALYS_GATEWAY_URL` | Optional — the gateway host used by `qualys_asset_inventory` (CSAM/GAV API). Derived automatically from `QUALYS_BASE_URL` (e.g. `https://gateway.qg1.apps.qualys.co.uk` for UK1); only set this if your platform's gateway differs. |
|
|
51
53
|
|
|
52
54
|
The API server depends on which Qualys platform your subscription lives on — check your Qualys login URL:
|
|
53
55
|
|
|
@@ -78,6 +80,8 @@ npm run build # compile to dist/
|
|
|
78
80
|
|
|
79
81
|
## Notes
|
|
80
82
|
|
|
83
|
+
- `qualys_asset_inventory` uses the CSAM/Global AssetView gateway API (JWT auth, JSON) and answers "show me all machines" — including agent-tracked assets, criticality scores and tags. Filter with QQL, e.g. `tags.name:"Client A"`, `operatingSystem.category1:"Windows"`, `criticality.score>=3`. Paginate with `hasMore`/`lastSeenAssetId`.
|
|
84
|
+
- For per-machine critical vulnerability counts, combine it with `qualys_host_detections` using `severities="5"` (or `"4,5"`).
|
|
81
85
|
- The Qualys VM API v2 returns XML; this server converts it to JSON.
|
|
82
86
|
- Rate/concurrency limits (HTTP 409/429) are retried automatically using the `X-RateLimit-ToWait-Sec` header, up to 3 times.
|
|
83
87
|
- Large result sets are truncated by Qualys; the response then contains a `WARNING` element with the URL for the next page — pass its `id_min` value back to the same tool to continue.
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
-
import { QualysClient, simplify } from './qualys-client.js';
|
|
5
|
+
import { QualysClient, simplify, stripEmpty } from './qualys-client.js';
|
|
6
6
|
const username = process.env.QUALYS_USERNAME || '';
|
|
7
7
|
const password = process.env.QUALYS_PASSWORD || '';
|
|
8
8
|
const baseUrl = process.env.QUALYS_BASE_URL || '';
|
|
@@ -12,7 +12,7 @@ if (!username || !password || !baseUrl) {
|
|
|
12
12
|
console.error('https://qualysapi.qualys.eu (EU1), https://qualysapi.qg2.apps.qualys.eu (EU2), https://qualysapi.qualys.com (US1).');
|
|
13
13
|
process.exit(1);
|
|
14
14
|
}
|
|
15
|
-
const client = new QualysClient({ username, password, baseUrl });
|
|
15
|
+
const client = new QualysClient({ username, password, baseUrl, gatewayUrl: process.env.QUALYS_GATEWAY_URL });
|
|
16
16
|
const server = new Server({ name: 'qualys-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
17
17
|
const truncationProperties = {
|
|
18
18
|
truncation_limit: {
|
|
@@ -25,6 +25,19 @@ const truncationProperties = {
|
|
|
25
25
|
},
|
|
26
26
|
};
|
|
27
27
|
const tools = [
|
|
28
|
+
{
|
|
29
|
+
name: 'qualys_asset_inventory',
|
|
30
|
+
description: 'Full asset inventory from Qualys CSAM/Global AssetView — the same data as the Asset Management UI: every asset (agent-tracked and scanned) with name, IP, OS, asset criticality score, TruRisk score, tags, last logged-on user, agent last check-in and last scan dates. This is the primary tool for "show me all machines/assets". Filter with Qualys QQL, e.g. tags.name:"Client A", operatingSystem.category1:"Windows", or criticality.score>=3. Returns a compact summary per asset by default; set full_details=true for complete raw records (large — software, ports, volumes). For counts of critical vulnerabilities per machine, combine with qualys_host_detections (severities="5").',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
filter: { type: 'string', description: 'QQL filter, e.g. tags.name:"Superfast IT" or lastLoggedOnUser:"jsmith". Omit to return all assets.' },
|
|
35
|
+
page_size: { type: 'number', description: 'Assets per page (default 100, max 300)' },
|
|
36
|
+
last_seen_asset_id: { type: 'number', description: 'Pagination cursor: pass the lastSeenAssetId from the previous response to get the next page (response also has hasMore=1/0)' },
|
|
37
|
+
full_details: { type: 'boolean', description: 'Return complete raw asset records instead of the compact summary. Default false.' },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
28
41
|
{
|
|
29
42
|
name: 'qualys_list_hosts',
|
|
30
43
|
description: 'List scanned host assets in Qualys VMDR with their ID, IP, DNS name, OS and last scan dates. Use this to find host IDs before pulling their vulnerability detections. Filter by IPs, asset group or scan recency.',
|
|
@@ -42,6 +55,7 @@ const tools = [
|
|
|
42
55
|
os_pattern: { type: 'string', description: 'PCRE regex to match against host operating system, e.g. "Windows Server 2012"' },
|
|
43
56
|
no_vm_scan_since: { type: 'string', description: 'Only hosts NOT scanned since this date (YYYY-MM-DD) — useful to find stale/unscanned assets' },
|
|
44
57
|
vm_scan_since: { type: 'string', description: 'Only hosts scanned since this date (YYYY-MM-DD)' },
|
|
58
|
+
show_tags: { type: 'boolean', description: 'Include asset tags for each host. Default false.' },
|
|
45
59
|
...truncationProperties,
|
|
46
60
|
},
|
|
47
61
|
},
|
|
@@ -152,8 +166,53 @@ const tools = [
|
|
|
152
166
|
},
|
|
153
167
|
];
|
|
154
168
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
169
|
+
function toIso(ms) {
|
|
170
|
+
return typeof ms === 'number' ? new Date(ms).toISOString() : undefined;
|
|
171
|
+
}
|
|
172
|
+
function compactAsset(a) {
|
|
173
|
+
return stripEmpty({
|
|
174
|
+
assetId: a.assetId,
|
|
175
|
+
assetName: a.assetName,
|
|
176
|
+
address: a.address,
|
|
177
|
+
dnsName: a.dnsName,
|
|
178
|
+
operatingSystem: a.operatingSystem?.fullName ?? a.operatingSystem?.osName,
|
|
179
|
+
criticality: a.criticality?.score,
|
|
180
|
+
riskScore: a.riskScore,
|
|
181
|
+
lastLoggedOnUser: a.lastLoggedOnUser,
|
|
182
|
+
tags: a.tagList?.tag?.map((t) => t.tagName),
|
|
183
|
+
agent: a.agent
|
|
184
|
+
? {
|
|
185
|
+
status: a.agent.status,
|
|
186
|
+
version: a.agent.version,
|
|
187
|
+
lastCheckedIn: toIso(a.agent.lastCheckedIn) ?? a.agent.lastCheckedIn,
|
|
188
|
+
lastActivity: toIso(a.agent.lastActivity) ?? a.agent.lastActivity,
|
|
189
|
+
}
|
|
190
|
+
: undefined,
|
|
191
|
+
lastVMScan: toIso(a.sensor?.lastVMScan) ?? a.sensor?.lastVMScan,
|
|
192
|
+
lastComplianceScan: toIso(a.sensor?.lastComplianceScan) ?? a.sensor?.lastComplianceScan,
|
|
193
|
+
sources: a.inventory?.source ?? a.assetType,
|
|
194
|
+
lastModifiedDate: a.lastModifiedDate,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const INVENTORY_FIELDS = 'assetId,assetName,address,dnsName,operatingSystem,criticality,riskScore,lastLoggedOnUser,tag,agent,sensor,inventory,assetType,lastModifiedDate';
|
|
155
198
|
async function handleTool(name, args) {
|
|
156
199
|
switch (name) {
|
|
200
|
+
case 'qualys_asset_inventory': {
|
|
201
|
+
const fullDetails = args.full_details === true;
|
|
202
|
+
const result = (await client.gatewayRequest('/rest/2.0/search/am/asset', {
|
|
203
|
+
filter: args.filter,
|
|
204
|
+
pageSize: Math.min(args.page_size ?? 100, 300),
|
|
205
|
+
lastSeenAssetId: args.last_seen_asset_id,
|
|
206
|
+
includeFields: fullDetails ? undefined : INVENTORY_FIELDS,
|
|
207
|
+
}));
|
|
208
|
+
const assets = result?.assetListData?.asset ?? [];
|
|
209
|
+
return JSON.stringify({
|
|
210
|
+
count: result?.count,
|
|
211
|
+
hasMore: result?.hasMore,
|
|
212
|
+
lastSeenAssetId: result?.lastSeenAssetId,
|
|
213
|
+
assets: fullDetails ? assets.map((a) => stripEmpty(a)) : assets.map(compactAsset),
|
|
214
|
+
}, null, 2);
|
|
215
|
+
}
|
|
157
216
|
case 'qualys_list_hosts': {
|
|
158
217
|
const result = await client.request('/api/2.0/fo/asset/host/', {
|
|
159
218
|
action: 'list',
|
|
@@ -166,6 +225,7 @@ async function handleTool(name, args) {
|
|
|
166
225
|
os_pattern: args.os_pattern,
|
|
167
226
|
no_vm_scan_since: args.no_vm_scan_since,
|
|
168
227
|
vm_scan_since: args.vm_scan_since,
|
|
228
|
+
show_tags: args.show_tags ? 1 : undefined,
|
|
169
229
|
truncation_limit: args.truncation_limit ?? 100,
|
|
170
230
|
id_min: args.id_min,
|
|
171
231
|
});
|
package/dist/qualys-client.js
CHANGED
|
@@ -26,12 +26,27 @@ const parser = new XMLParser({
|
|
|
26
26
|
trimValues: true,
|
|
27
27
|
isArray: (name) => ARRAY_TAGS.has(name),
|
|
28
28
|
});
|
|
29
|
+
export function deriveGatewayUrl(baseUrl) {
|
|
30
|
+
const url = new URL(baseUrl);
|
|
31
|
+
if (url.hostname.includes('.apps.')) {
|
|
32
|
+
return 'https://' + url.hostname.replace(/^qualysapi\./, 'gateway.');
|
|
33
|
+
}
|
|
34
|
+
return 'https://' + url.hostname.replace(/^qualysapi\./, 'gateway.qg1.apps.');
|
|
35
|
+
}
|
|
29
36
|
export class QualysClient {
|
|
37
|
+
username;
|
|
38
|
+
password;
|
|
30
39
|
authHeader;
|
|
31
40
|
baseUrl;
|
|
41
|
+
gatewayUrl;
|
|
42
|
+
token = null;
|
|
43
|
+
tokenExpiresAt = 0;
|
|
32
44
|
constructor(config) {
|
|
45
|
+
this.username = config.username;
|
|
46
|
+
this.password = config.password;
|
|
33
47
|
this.authHeader = 'Basic ' + Buffer.from(`${config.username}:${config.password}`).toString('base64');
|
|
34
48
|
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
49
|
+
this.gatewayUrl = (config.gatewayUrl || deriveGatewayUrl(this.baseUrl)).replace(/\/+$/, '');
|
|
35
50
|
}
|
|
36
51
|
async request(path, params = {}, method = 'GET') {
|
|
37
52
|
const url = new URL(this.baseUrl + (path.startsWith('/') ? path : '/' + path));
|
|
@@ -75,6 +90,55 @@ export class QualysClient {
|
|
|
75
90
|
return { text, contentType };
|
|
76
91
|
}
|
|
77
92
|
}
|
|
93
|
+
async getToken(forceRefresh = false) {
|
|
94
|
+
if (!forceRefresh && this.token && Date.now() < this.tokenExpiresAt) {
|
|
95
|
+
return this.token;
|
|
96
|
+
}
|
|
97
|
+
const response = await fetch(this.gatewayUrl + '/auth', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
100
|
+
body: new URLSearchParams({ username: this.username, password: this.password, token: 'true' }),
|
|
101
|
+
});
|
|
102
|
+
const text = await response.text();
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
throw new Error(`Qualys gateway auth failed (${response.status}) at ${this.gatewayUrl}/auth: ${text.slice(0, 500)}. ` +
|
|
105
|
+
'If your platform gateway differs, set QUALYS_GATEWAY_URL.');
|
|
106
|
+
}
|
|
107
|
+
this.token = text.trim();
|
|
108
|
+
this.tokenExpiresAt = Date.now() + 3.5 * 60 * 60 * 1000;
|
|
109
|
+
return this.token;
|
|
110
|
+
}
|
|
111
|
+
async gatewayRequest(path, query = {}, method = 'POST') {
|
|
112
|
+
const url = new URL(this.gatewayUrl + (path.startsWith('/') ? path : '/' + path));
|
|
113
|
+
for (const [key, value] of Object.entries(query)) {
|
|
114
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
115
|
+
url.searchParams.set(key, String(value));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (let attempt = 0;; attempt++) {
|
|
119
|
+
const token = await this.getToken(attempt > 0);
|
|
120
|
+
const response = await fetch(url, {
|
|
121
|
+
method,
|
|
122
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
123
|
+
});
|
|
124
|
+
if (response.status === 401 && attempt < 1)
|
|
125
|
+
continue;
|
|
126
|
+
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 10_000 * (attempt + 1)));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const text = await response.text();
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
throw new Error(`Qualys gateway API error ${response.status} for ${method} ${url.pathname}: ${text.slice(0, 2000)}`);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(text);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return text;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
78
142
|
}
|
|
79
143
|
export function simplify(parsed) {
|
|
80
144
|
if (!parsed || typeof parsed !== 'object')
|
|
@@ -90,3 +154,19 @@ export function simplify(parsed) {
|
|
|
90
154
|
}
|
|
91
155
|
return obj;
|
|
92
156
|
}
|
|
157
|
+
export function stripEmpty(value) {
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
const arr = value.map(stripEmpty).filter((v) => v !== undefined);
|
|
160
|
+
return arr.length > 0 ? arr : undefined;
|
|
161
|
+
}
|
|
162
|
+
if (value && typeof value === 'object') {
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const [k, v] of Object.entries(value)) {
|
|
165
|
+
const stripped = stripEmpty(v);
|
|
166
|
+
if (stripped !== undefined)
|
|
167
|
+
out[k] = stripped;
|
|
168
|
+
}
|
|
169
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
170
|
+
}
|
|
171
|
+
return value === null || value === undefined ? undefined : value;
|
|
172
|
+
}
|
package/package.json
CHANGED