quoxcli 0.4.0 → 0.5.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/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +8 -0
- package/lib/core/config.js +28 -2
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +185 -34
- package/lib/quoxon/app.js +1032 -152
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +69 -11
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +20 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wazuh commands (WAZ-3): first-class `quox wazuh`, the sibling of `quox kuma`.
|
|
3
|
+
*
|
|
4
|
+
* quox wazuh | status manager healthy? stopped daemons (optional ones labelled), agent counts by state
|
|
5
|
+
* quox wazuh agents [--status --os --limit] enrolled agents
|
|
6
|
+
* quox wazuh agents get <id|name> one agent
|
|
7
|
+
* quox wazuh alerts [--level --since --agent --rule --mitre --limit]
|
|
8
|
+
* quox wazuh triage [--since --level --limit] alerts grouped by rule (indexer aggregation; WAZ P7: CONTEXT flags per group)
|
|
9
|
+
* quox wazuh coverage fleet hosts vs Wazuh agents: gaps
|
|
10
|
+
* quox wazuh vulns [--severity --agent --limit]
|
|
11
|
+
* quox wazuh vulns --priority [--kev-only --agent --limit] CVEs ranked by CISA KEV, EPSS, CVSS, hosts affected
|
|
12
|
+
* quox wazuh fim|sca|rootcheck [--agent] syscheck, compliance policies (pass/fail), rootcheck
|
|
13
|
+
* quox wazuh deploy-history
|
|
14
|
+
* Writes (always the governed wazuh_* tool path, held for a human approval, the approval id is printed):
|
|
15
|
+
* quox wazuh deploy <host> | deploy-bulk <hosts...> | remove <agentId> | active-response <agentId> --command <c>
|
|
16
|
+
* quox wazuh indexer install-credential
|
|
17
|
+
* quox wazuh report generate|list|show|verify signed, offline-verifiable posture report (wazuhReport.js)
|
|
18
|
+
* quox wazuh hooks create|list|rotate|delete|install per-org signed hook Wazuh integratord posts alerts to (wazuhIntake.js);
|
|
19
|
+
* install wires the manager to it in one governed step (wazuh_hooks_install)
|
|
20
|
+
* quox wazuh incidents [--open] [--since] | show | close alerts that became witnessed incidents (wazuhIntake.js)
|
|
21
|
+
*
|
|
22
|
+
* Backend (existing collector routes): GET /api/v1/wazuh/{manager/status,agents,alerts,vulnerabilities,deploy-history};
|
|
23
|
+
* POST /api/v1/wazuh/proxy (read-only allowlist: /syscheck/:id, /sca/:id) for fim and sca.
|
|
24
|
+
* Tools it files through POST /api/v1/tools/execute: wazuh_rootcheck, wazuh_deploy_agent, wazuh_agent_remove,
|
|
25
|
+
* wazuh_active_response, wazuh_indexer_credential_install, wazuh_hooks_install.
|
|
26
|
+
*
|
|
27
|
+
* Honesty: a non-2xx, a success:false body, or a Wazuh error body inside a 200 is an ERROR (exit 1), reported with
|
|
28
|
+
* the verb, endpoint and status. It is never rendered as "0 agents". `status` exits 3 when the manager is unhealthy.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
32
|
+
import { line as glyphLine } from '../core/quoxglyph.js';
|
|
33
|
+
import { resolveOrgId } from './helpers.js';
|
|
34
|
+
import { execResultFailed, execResultPending, pendingBodyOfError } from './agentAttach.js';
|
|
35
|
+
import { relDuration } from './kuma.js';
|
|
36
|
+
import { registerWazuhReport } from './wazuhReport.js';
|
|
37
|
+
import { registerWazuhIntake, contextFlags } from './wazuhIntake.js';
|
|
38
|
+
|
|
39
|
+
class WazFail extends Error {
|
|
40
|
+
constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The daemons a manager cannot be called healthy without. Explicit on purpose: a stopped daemon is "unhealthy"
|
|
45
|
+
* only if it is in this set.
|
|
46
|
+
*/
|
|
47
|
+
export const REQUIRED_DAEMONS = new Set(['wazuh-analysisd', 'wazuh-remoted', 'wazuh-apid', 'wazuh-db', 'wazuh-modulesd', 'wazuh-execd', 'wazuh-authd', 'wazuh-monitord', 'wazuh-logcollector', 'wazuh-syscheckd']);
|
|
48
|
+
/**
|
|
49
|
+
* Daemons Wazuh ships stopped unless the feature is switched on: a stopped one is not an unhealthy manager.
|
|
50
|
+
* wazuh-reportd is deprecated in 4.x (reports live in the dashboard), so it is stopped on a healthy manager.
|
|
51
|
+
*/
|
|
52
|
+
export const OPTIONAL_DAEMONS = new Set(['wazuh-clusterd', 'wazuh-maild', 'wazuh-agentlessd', 'wazuh-integratord', 'wazuh-dbd', 'wazuh-csyslogd', 'wazuh-reportd']);
|
|
53
|
+
const AGENT_STATES = ['active', 'disconnected', 'never_connected', 'pending'];
|
|
54
|
+
const PROXY_AGENT_CAP = 200; // fleet-wide reads batch 50 agents per tool call (WAZ-4), so 200 is at most 4 calls
|
|
55
|
+
|
|
56
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${Number(n) === 1 ? one : many}`;
|
|
57
|
+
const stateText = (s) => String(s || 'unknown').replace(/_/g, ' ');
|
|
58
|
+
|
|
59
|
+
function pad(rows) {
|
|
60
|
+
const w = [];
|
|
61
|
+
for (const r of rows) r.forEach((c, i) => { w[i] = Math.max(w[i] || 0, [...String(c)].length); });
|
|
62
|
+
return rows.map((r) => r.map((c, i) => String(c) + ' '.repeat(w[i] - [...String(c)].length)).join(' ').trimEnd());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const toMs = (v) => {
|
|
66
|
+
if (v === null || v === undefined || v === '') return null;
|
|
67
|
+
const t = typeof v === 'number' ? v : Date.parse(String(v));
|
|
68
|
+
return Number.isFinite(t) ? t : null;
|
|
69
|
+
};
|
|
70
|
+
/** "5m ago", or "-" when there is no usable time. */
|
|
71
|
+
export function agoText(v, now = Date.now()) {
|
|
72
|
+
const t = toMs(v);
|
|
73
|
+
if (t === null) return '-';
|
|
74
|
+
return t > now ? 'in the future' : `${relDuration(now - t)} ago`;
|
|
75
|
+
}
|
|
76
|
+
/** Wazuh stamps the manager's own agent 000 with a keep-alive of 9999-12-31. */
|
|
77
|
+
const isFarFuture = (v) => { const t = toMs(v); return t !== null && t > Date.now() + 365 * 864e5; };
|
|
78
|
+
/** "YYYY-MM-DD HH:MM UTC". */
|
|
79
|
+
export function utcText(v) {
|
|
80
|
+
const t = toMs(v);
|
|
81
|
+
return t === null ? '-' : `${new Date(t).toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** "24h", "7d", "30m", "2w" or an ISO date -> epoch ms. null when it is none of those. */
|
|
85
|
+
export function parseSince(value, now = Date.now()) {
|
|
86
|
+
const m = /^(\d+)\s*([smhdw])$/i.exec(String(value).trim());
|
|
87
|
+
if (m) return now - Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[m[2].toLowerCase()];
|
|
88
|
+
if (/^\d{4}-\d\d-\d\d/.test(String(value).trim())) { const t = Date.parse(value); return Number.isFinite(t) ? t : null; }
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const HINTS = {
|
|
93
|
+
wazuh_indexer_credential_missing: 'Fix: quox wazuh indexer install-credential (files an approval; a person approves it, then retry).',
|
|
94
|
+
wazuh_sudo_refused: 'The Bastion exec user on the Wazuh host cannot run `sudo -n`, so it cannot read the indexer credential file. An owner grants that user a narrow NOPASSWD rule for the indexer read on that host.',
|
|
95
|
+
wazuh_not_configured: 'Wazuh is not configured for this org. An org admin connects it: quox wazuh connect --url <url> --user <user>; an instance superadmin who is also an org admin can import the collector\'s own Wazuh with: quox wazuh connect --from-instance-env',
|
|
96
|
+
wazuh_lease_blocked: 'The Wazuh credential lease is blocked, so the collector may not read it right now.',
|
|
97
|
+
wazuh_host_not_in_fleet: 'Check: quox fleet list (the Wazuh host must be a registered fleet host).',
|
|
98
|
+
wazuh_fleet_not_loaded: 'Check: quox fleet list; retry in a minute if the collector just restarted.',
|
|
99
|
+
wazuh_fleet_unavailable: 'Check: quox fleet list.',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Failure for a thrown API error: verb, endpoint, HTTP status, the server's own words, and a TRUE next step if known. */
|
|
103
|
+
function failFrom(verb, method, path, err, via = '') {
|
|
104
|
+
const status = err && (err.status ?? err.statusCode);
|
|
105
|
+
const body = err && err.body && typeof err.body === 'object' ? err.body : null;
|
|
106
|
+
const code = (body && (body.code || (typeof body.error === 'string' && /^wazuh_/.test(body.error) ? body.error : null))) || (err && typeof err.code === 'string' ? err.code : null);
|
|
107
|
+
const said = (body && (body.message || (typeof body.error === 'string' ? body.error : null))) || (err && err.message) || String(err);
|
|
108
|
+
const hint = HINTS[code];
|
|
109
|
+
return new WazFail(`quox wazuh ${verb}: ${method} ${path}${via} failed${status ? ` with HTTP ${status}` : ''}: ${said}${hint ? `\n ${hint}` : ''}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The Wazuh body behind a collector route: { success, data: <Wazuh API body>, status }. A Wazuh error (401/404)
|
|
114
|
+
* arrives as a 200 from the collector with the failure inside, so check it here before reading anything.
|
|
115
|
+
*/
|
|
116
|
+
function wazuhItems(verb, method, path, res) {
|
|
117
|
+
if (!res || typeof res !== 'object') throw new WazFail(`quox wazuh ${verb}: ${method} ${path} returned no body`);
|
|
118
|
+
const up = res.data;
|
|
119
|
+
const st = res.status;
|
|
120
|
+
const upDetail = up && typeof up === 'object' ? [up.title, up.detail].filter(Boolean).join(': ') : '';
|
|
121
|
+
if ((typeof st === 'number' && st >= 400) || (up && typeof up === 'object' && up.title && !up.data)) {
|
|
122
|
+
throw new WazFail(`quox wazuh ${verb}: ${method} ${path} failed: Wazuh answered ${st || 'an error'}${upDetail ? ` (${upDetail})` : ''}`);
|
|
123
|
+
}
|
|
124
|
+
if (res.success === false) throw new WazFail(`quox wazuh ${verb}: ${method} ${path} returned an error body: ${res.error || (typeof up === 'string' ? up.slice(0, 200) : 'success false')}`);
|
|
125
|
+
if (up && typeof up === 'object' && up.error && up.error !== 0) throw new WazFail(`quox wazuh ${verb}: ${method} ${path} failed: Wazuh error ${up.error}${upDetail ? ` (${upDetail})` : ''}`);
|
|
126
|
+
const inner = up && (up.data && typeof up.data === 'object' ? up.data : up);
|
|
127
|
+
if (!inner || !Array.isArray(inner.affected_items)) throw new WazFail(`quox wazuh ${verb}: ${method} ${path} returned an unexpected body (no affected_items); use --json to see it`);
|
|
128
|
+
return { items: inner.affected_items, total: inner.total_affected_items ?? inner.affected_items.length };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function registerWazuhCommands(program, ctx) {
|
|
132
|
+
const wazuh = program
|
|
133
|
+
.command('wazuh')
|
|
134
|
+
.description('Wazuh security monitoring. Bare `quox wazuh` runs status. Verbs: status, agents, alerts, incidents, hooks, triage, coverage, vulns, fim, sca, rootcheck, report, deploy-history, deploy, deploy-bulk, remove, active-response, indexer')
|
|
135
|
+
.addHelpText('after', '\nExample: quox wazuh\nExit codes: 0 ok, 1 error, 3 `quox wazuh status` found the manager unhealthy. Writes are held for human approval.');
|
|
136
|
+
attachWazuhVerbs(wazuh, program, ctx, { writes: true });
|
|
137
|
+
attachWazuhConnect(wazuh, program, ctx);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Attach the wazuh verbs to `wazuh`. `quox mon wazuh` shares the read verbs (one code path);
|
|
142
|
+
* its three legacy write verbs (deploy-agent, deploy-bulk, active-response) live in mon.js and run through the
|
|
143
|
+
* governed helpers this returns ({ guard, writeTool, deployOne, WazFail }), the same path `quox wazuh` writes take.
|
|
144
|
+
*/
|
|
145
|
+
export function attachWazuhVerbs(wazuh, program, ctx, { writes = true } = {}) {
|
|
146
|
+
const { client, auth } = ctx;
|
|
147
|
+
const jsonOut = () => program.opts().output === 'json';
|
|
148
|
+
const head = (verb, result, status = 'ok') => console.log(glyphLine('quox', `wazuh ${verb}`, { result, status }));
|
|
149
|
+
|
|
150
|
+
const example = (cmd, text) => cmd.addHelpText('after', `\nExample: ${text}`);
|
|
151
|
+
|
|
152
|
+
const guard = (fn) => async (...args) => {
|
|
153
|
+
try { await fn(...args); } catch (err) {
|
|
154
|
+
if (!(err instanceof WazFail)) throw err;
|
|
155
|
+
console.error(err.message);
|
|
156
|
+
process.exitCode = err.exitCode || 1;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// A full-fleet CVE ranking runs two indexer passes plus the KEV/EPSS feeds and can pass 30 s (live 2026-11-05).
|
|
161
|
+
const SLOW_READS = { 'mon.wazuh.vulnerabilities.priority': 180000 };
|
|
162
|
+
async function getRoute(verb, key, query) {
|
|
163
|
+
const ep = resolveEndpoint(key);
|
|
164
|
+
let data;
|
|
165
|
+
try {
|
|
166
|
+
data = await client.get(ep.path, { service: ep.service, ...(query && Object.keys(query).length ? { query } : {}), ...(SLOW_READS[key] ? { timeout: SLOW_READS[key] } : {}) });
|
|
167
|
+
} catch (err) { throw failFrom(verb, ep.method, ep.path, err); }
|
|
168
|
+
return { ep, data };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function proxyGet(verb, endpoint) {
|
|
172
|
+
const ep = resolveEndpoint('mon.wazuh.proxy');
|
|
173
|
+
let data;
|
|
174
|
+
try {
|
|
175
|
+
data = await client.post(ep.path, { service: ep.service, body: { endpoint, method: 'GET' } });
|
|
176
|
+
} catch (err) { throw failFrom(verb, ep.method, ep.path, err, ` (GET ${endpoint.split('?')[0]})`); }
|
|
177
|
+
return wazuhItems(verb, ep.method, `${ep.path} (GET ${endpoint.split('?')[0]})`, data);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function allAgents(verb, query = { limit: '500' }) {
|
|
181
|
+
const { ep, data } = await getRoute(verb, 'mon.wazuh.agents.list', query);
|
|
182
|
+
return { ...wazuhItems(verb, ep.method, ep.path, data) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const countStates = (agents) => {
|
|
186
|
+
const c = { active: 0, disconnected: 0, never_connected: 0, pending: 0, other: 0, total: agents.length };
|
|
187
|
+
for (const a of agents) { if (AGENT_STATES.includes(a.status)) c[a.status]++; else c.other++; }
|
|
188
|
+
return c;
|
|
189
|
+
};
|
|
190
|
+
const countText = (c) => {
|
|
191
|
+
const parts = [];
|
|
192
|
+
for (const s of AGENT_STATES) if (c[s]) parts.push(`${c[s]} ${stateText(s)}`);
|
|
193
|
+
if (c.other) parts.push(`${c.other} other`);
|
|
194
|
+
return parts.length ? parts.join(' · ') : 'none enrolled';
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// ---- status ----
|
|
198
|
+
const statusAction = guard(async () => {
|
|
199
|
+
auth.requireAuth();
|
|
200
|
+
const m = await getRoute('status', 'mon.wazuh.manager-status.get');
|
|
201
|
+
const { items } = wazuhItems('status', m.ep.method, m.ep.path, m.data);
|
|
202
|
+
const daemons = items[0] && typeof items[0] === 'object' ? items[0] : {};
|
|
203
|
+
const names = Object.keys(daemons).sort();
|
|
204
|
+
if (!names.length) throw new WazFail(`quox wazuh status: ${m.ep.method} ${m.ep.path} returned no daemons`);
|
|
205
|
+
const running = names.filter((n) => daemons[n] === 'running');
|
|
206
|
+
const stopped = names.filter((n) => daemons[n] !== 'running');
|
|
207
|
+
const brokenCore = stopped.filter((n) => REQUIRED_DAEMONS.has(n) || !OPTIONAL_DAEMONS.has(n));
|
|
208
|
+
const healthy = brokenCore.length === 0;
|
|
209
|
+
const a = await allAgents('status');
|
|
210
|
+
const counts = countStates(a.items);
|
|
211
|
+
if (a.total > a.items.length) counts.total = a.total;
|
|
212
|
+
if (jsonOut()) {
|
|
213
|
+
console.log(JSON.stringify({ healthy, daemons, stopped_core: brokenCore, agents: counts }, null, 2));
|
|
214
|
+
} else {
|
|
215
|
+
head('status', `manager ${healthy ? 'healthy' : 'unhealthy'} · ${plural(counts.total, 'agent')}`, healthy ? 'ok' : 'fail');
|
|
216
|
+
console.log(`Manager ${healthy ? 'healthy' : 'unhealthy'}: ${running.length} running${stopped.length ? `, ${stopped.length} stopped` : ''}`);
|
|
217
|
+
if (stopped.length) {
|
|
218
|
+
console.log(pad(stopped.map((n) => [` ${n}`, OPTIONAL_DAEMONS.has(n) ? `${daemons[n]} (optional)` : String(daemons[n])])).join('\n'));
|
|
219
|
+
}
|
|
220
|
+
console.log(`Agents: ${countText(counts)}${a.total > a.items.length ? ` (counted the first ${a.items.length} of ${a.total})` : ''}`);
|
|
221
|
+
if (!healthy) console.log(`Not running: ${brokenCore.join(', ')}. Check the manager host's service logs.`);
|
|
222
|
+
}
|
|
223
|
+
if (!healthy) process.exitCode = 3;
|
|
224
|
+
});
|
|
225
|
+
example(wazuh.command('status').description('Manager health (daemons) and agent counts by state').action(statusAction), 'quox wazuh status');
|
|
226
|
+
wazuh.action(statusAction);
|
|
227
|
+
|
|
228
|
+
// ---- agents ----
|
|
229
|
+
const agentOs = (a) => (a.os && (a.os.name || a.os.platform)) || '-';
|
|
230
|
+
const agents = wazuh.command('agents').description('Enrolled Wazuh agents')
|
|
231
|
+
.option('--status <status>', 'active|disconnected|never_connected|pending')
|
|
232
|
+
.option('--os <text>', 'only agents whose OS name or platform contains this')
|
|
233
|
+
.option('--limit <n>', 'max agents (default 100)');
|
|
234
|
+
example(agents, 'quox wazuh agents --status disconnected');
|
|
235
|
+
agents.action(guard(async (options) => {
|
|
236
|
+
auth.requireAuth();
|
|
237
|
+
const limit = options.limit ? String(options.limit) : '100'; // display cap; the route only gets a limit when you give one
|
|
238
|
+
if (options.limit && !/^\d+$/.test(limit)) throw new WazFail(`quox wazuh agents: --limit needs a whole number, got "${options.limit}"`);
|
|
239
|
+
const query = {};
|
|
240
|
+
if (options.os) query.limit = '500'; else if (options.limit) query.limit = limit;
|
|
241
|
+
if (options.status) query.status = options.status;
|
|
242
|
+
const { ep, data } = await getRoute('agents', 'mon.wazuh.agents.list', query);
|
|
243
|
+
let { items, total } = wazuhItems('agents', ep.method, ep.path, data);
|
|
244
|
+
if (options.os) {
|
|
245
|
+
const needle = String(options.os).toLowerCase();
|
|
246
|
+
items = items.filter((a) => `${(a.os && a.os.name) || ''} ${(a.os && a.os.platform) || ''}`.toLowerCase().includes(needle));
|
|
247
|
+
total = items.length;
|
|
248
|
+
}
|
|
249
|
+
const shown = items.slice(0, Number(limit));
|
|
250
|
+
if (jsonOut()) { console.log(JSON.stringify({ agents: shown, total }, null, 2)); return; }
|
|
251
|
+
head('agents', total > shown.length ? `showing ${shown.length} of ${total}` : plural(shown.length, 'agent'));
|
|
252
|
+
if (!shown.length) { console.log(options.status || options.os ? 'No agents match.' : 'No agents enrolled.'); return; }
|
|
253
|
+
const rows = [['ID', 'NAME', 'IP', 'STATUS', 'OS', 'VERSION', 'LAST SEEN']];
|
|
254
|
+
for (const a of shown) rows.push([a.id, a.name || '-', a.ip || '-', a.status || '-', agentOs(a), String(a.version || '-').replace(/^Wazuh /, ''), a.id === '000' && isFarFuture(a.lastKeepAlive) ? 'manager (local)' : agoText(a.lastKeepAlive)]);
|
|
255
|
+
console.log(pad(rows).join('\n'));
|
|
256
|
+
}));
|
|
257
|
+
example(agents.command('get').description('One agent by id or name (reads the wazuh_agent_get tool)')
|
|
258
|
+
.argument('<id|name>', 'agent id (1 or 001) or name')
|
|
259
|
+
.action(guard(async (key) => {
|
|
260
|
+
auth.requireAuth();
|
|
261
|
+
const input = /^\d+$/.test(key) ? { agent_id: String(Number(key)).padStart(3, '0') } : { name: key };
|
|
262
|
+
const { data } = await callTool('agents get', 'wazuh_agent_get', input);
|
|
263
|
+
const r = failIfToolFailed('agents get', 'wazuh_agent_get', data);
|
|
264
|
+
let hit = r.agent && typeof r.agent === 'object' ? r.agent : (r.id ? r : null);
|
|
265
|
+
// A caller whose hat lacks the tool gets it auto-delegated to its single holder, which answers in prose (no record).
|
|
266
|
+
// The org-scoped agents list route carries the same manager record, so read the agent from there and say so.
|
|
267
|
+
const hop = delegatedTo(data);
|
|
268
|
+
if (!hit && hop) {
|
|
269
|
+
const { items } = await allAgents('agents get');
|
|
270
|
+
const want = /^\d+$/.test(key) ? String(Number(key)).padStart(3, '0') : key;
|
|
271
|
+
hit = items.find((a) => (/^\d+$/.test(key) ? String(a.id) === want : a.name === want)) || null;
|
|
272
|
+
if (hit) console.error(`wazuh_agent_get was auto-delegated to agent ${hop} (a prose answer, no record); read from the agents list route instead.`);
|
|
273
|
+
}
|
|
274
|
+
if (!hit) throw new WazFail(`quox wazuh agents get: no agent "${key}". List them: quox wazuh agents`);
|
|
275
|
+
if (jsonOut()) { console.log(JSON.stringify(hit, null, 2)); return; }
|
|
276
|
+
head(`agents get ${hit.id}`, `${hit.name} ${hit.status}`, hit.status === 'active' ? 'ok' : 'warn');
|
|
277
|
+
const lastSeen = hit.lastKeepAlive || hit.last_keep_alive;
|
|
278
|
+
const rows = [['ID', hit.id], ['NAME', hit.name], ['IP', hit.ip], ['STATUS', hit.status], ['OS', typeof hit.os === 'string' ? hit.os : agentOs(hit)], ['VERSION', hit.version],
|
|
279
|
+
['GROUPS', Array.isArray(hit.group) ? hit.group.join(', ') : hit.group], ['NODE', hit.node_name],
|
|
280
|
+
['LAST SEEN', lastSeen ? (String(hit.id) === '000' && isFarFuture(lastSeen) ? 'manager (local)' : `${agoText(lastSeen)} (${utcText(lastSeen)})`) : null],
|
|
281
|
+
['ENROLLED', hit.dateAdd ? `${agoText(hit.dateAdd)} (${utcText(hit.dateAdd)})` : null]];
|
|
282
|
+
console.log(pad(rows.map(([k, v]) => [k, v ?? '-'])).join('\n'));
|
|
283
|
+
})), 'quox wazuh agents get web01');
|
|
284
|
+
|
|
285
|
+
// ---- alerts ----
|
|
286
|
+
example(wazuh.command('alerts').description('Recent alerts, newest first')
|
|
287
|
+
.option('--q <text>', 'free text matched in rule description, agent name or rule groups')
|
|
288
|
+
.option('--level <n>', 'only rule level N and above (12+ is high severity)')
|
|
289
|
+
.option('--since <window>', 'only alerts newer than this: 30m, 24h, 7d or an ISO date')
|
|
290
|
+
.option('--agent <id|name>', 'only this agent')
|
|
291
|
+
.option('--rule <id|text>', 'rule id, or text in the rule description')
|
|
292
|
+
.option('--mitre <id>', 'MITRE technique id, e.g. T1110')
|
|
293
|
+
.option('--limit <n>', 'max alerts shown (default 50, capped at 500)')
|
|
294
|
+
.action(guard(async (options) => {
|
|
295
|
+
auth.requireAuth();
|
|
296
|
+
let sinceMs = null;
|
|
297
|
+
if (options.since) {
|
|
298
|
+
sinceMs = parseSince(options.since);
|
|
299
|
+
if (sinceMs === null) throw new WazFail(`quox wazuh alerts: --since needs a window like 30m, 24h, 7d or an ISO date, got "${options.since}"`);
|
|
300
|
+
}
|
|
301
|
+
if (options.level && !/^\d+$/.test(String(options.level))) throw new WazFail(`quox wazuh alerts: --level needs a whole number, got "${options.level}"`);
|
|
302
|
+
if (options.limit && !/^\d+$/.test(String(options.limit))) throw new WazFail(`quox wazuh alerts: --limit needs a whole number, got "${options.limit}"`);
|
|
303
|
+
const want = Math.min(Number(options.limit) || 50, 500);
|
|
304
|
+
const clientFilter = Boolean(options.since || options.agent || options.rule || options.mitre);
|
|
305
|
+
const query = {};
|
|
306
|
+
if (clientFilter) query.limit = '500'; else if (options.limit) query.limit = String(want);
|
|
307
|
+
if (options.q) query.q = options.q;
|
|
308
|
+
if (options.level) query.min_level = String(options.level);
|
|
309
|
+
const { ep, data } = await getRoute('alerts', 'mon.wazuh.alerts.list', query);
|
|
310
|
+
if (!data || data.success === false || !Array.isArray(data.data)) throw new WazFail(`quox wazuh alerts: ${ep.method} ${ep.path} returned an error body: ${(data && (data.message || data.error)) || 'no alerts array'}`);
|
|
311
|
+
const scanned = data.data.length;
|
|
312
|
+
const lc = (v) => String(v ?? '').toLowerCase();
|
|
313
|
+
const list = data.data.filter((al) => {
|
|
314
|
+
if (sinceMs !== null) { const t = toMs(al.timestamp); if (t === null || t < sinceMs) return false; }
|
|
315
|
+
if (options.agent && ![al.agent && al.agent.id, al.agent && al.agent.name].some((x) => lc(x) === lc(options.agent))) return false;
|
|
316
|
+
if (options.rule) {
|
|
317
|
+
const r = al.rule || {};
|
|
318
|
+
if (!(lc(r.id) === lc(options.rule) || lc(r.description).includes(lc(options.rule)))) return false;
|
|
319
|
+
}
|
|
320
|
+
if (options.mitre) {
|
|
321
|
+
const ids = [].concat((al.rule && al.rule.mitre && al.rule.mitre.id) || []).map(lc);
|
|
322
|
+
if (!ids.includes(lc(options.mitre))) return false;
|
|
323
|
+
}
|
|
324
|
+
return true;
|
|
325
|
+
}).slice(0, want);
|
|
326
|
+
if (jsonOut()) { console.log(JSON.stringify({ alerts: list, scanned, total: data.total ?? scanned }, null, 2)); return; }
|
|
327
|
+
const scope = [options.level ? `level ${options.level}+` : '', options.since ? `last ${options.since}` : ''].filter(Boolean).join(', ');
|
|
328
|
+
head('alerts', `${plural(list.length, 'alert')}${scope ? ` (${scope})` : ''}`, list.some((a) => (a.rule && a.rule.level) >= 12) ? 'warn' : 'ok');
|
|
329
|
+
if (!list.length) { console.log(clientFilter ? `No alerts match in the ${plural(scanned, 'newest alert')} scanned.` : 'No alerts found.'); return; }
|
|
330
|
+
const rows = [['WHEN', 'UTC', 'LVL', 'AGENT', 'RULE', 'DESCRIPTION']];
|
|
331
|
+
for (const al of list) rows.push([agoText(al.timestamp), utcText(al.timestamp), (al.rule && al.rule.level) ?? '-', (al.agent && al.agent.name) || '-', (al.rule && al.rule.id) || '-', (al.rule && al.rule.description) || '-']);
|
|
332
|
+
console.log(pad(rows).join('\n'));
|
|
333
|
+
if (!clientFilter && (data.total ?? 0) > list.length) console.log(`Showing ${list.length} of ${data.total}; raise --limit to see more.`);
|
|
334
|
+
})), 'quox wazuh alerts --level 10 --since 24h');
|
|
335
|
+
|
|
336
|
+
// ---- triage ----
|
|
337
|
+
example(wazuh.command('triage').description('Alerts grouped by rule: count, top hosts, MITRE, highest level first')
|
|
338
|
+
.option('--since <window>', 'window: 30m, 24h, 7d (default 24h)')
|
|
339
|
+
.option('--level <n>', 'only rule level N and above (default 7; 12+ is high severity)')
|
|
340
|
+
.option('--limit <n>', 'max rule groups (default 20, capped at 100)')
|
|
341
|
+
.action(guard(async (options) => {
|
|
342
|
+
auth.requireAuth();
|
|
343
|
+
if (options.since && !/^\d{1,4}[mhd]$/.test(String(options.since))) throw new WazFail(`quox wazuh triage: --since needs a window like 30m, 24h or 7d, got "${options.since}"`);
|
|
344
|
+
if (options.level && !/^\d+$/.test(String(options.level))) throw new WazFail(`quox wazuh triage: --level needs a whole number, got "${options.level}"`);
|
|
345
|
+
if (options.limit && !/^\d+$/.test(String(options.limit))) throw new WazFail(`quox wazuh triage: --limit needs a whole number, got "${options.limit}"`);
|
|
346
|
+
const query = {};
|
|
347
|
+
if (options.since) query.since = String(options.since);
|
|
348
|
+
if (options.level) query.level = String(options.level);
|
|
349
|
+
if (options.limit) query.limit = String(options.limit);
|
|
350
|
+
const { ep, data } = await getRoute('triage', 'mon.wazuh.alerts.triage', query);
|
|
351
|
+
if (!data || data.success === false || !Array.isArray(data.groups)) throw new WazFail(`quox wazuh triage: ${ep.method} ${ep.path} returned an error body: ${(data && (data.message || data.error)) || 'no groups array'}`);
|
|
352
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
353
|
+
const groups = data.groups;
|
|
354
|
+
head('triage', `${plural(groups.length, 'rule group')}, ${plural(data.total_alerts ?? 0, 'alert')} (level ${data.min_level}+, last ${data.window})`, groups.some((g) => g.level >= 12) ? 'warn' : 'ok');
|
|
355
|
+
if (!groups.length) { console.log('No alert groups in this window.'); return; }
|
|
356
|
+
// WAZ P7: CONTEXT per group (MAINT / KUMA-DOWN / DEPLOYED across its hosts; SRC? when a source could not answer).
|
|
357
|
+
const withCtx = groups.some((g) => g.context);
|
|
358
|
+
const rows = [['LVL', 'COUNT', 'RULE', 'MITRE', 'AGENTS', 'LAST SEEN', ...(withCtx ? ['CONTEXT'] : []), 'DESCRIPTION']];
|
|
359
|
+
for (const g of groups) {
|
|
360
|
+
rows.push([g.level ?? '-', g.count, g.rule_id, ((g.mitre && g.mitre.ids) || []).join(',') || '-',
|
|
361
|
+
(g.agents || []).slice(0, 3).map((a) => `${a.name}(${a.count})`).join(', ') || '-', agoText(g.last_seen), ...(withCtx ? [contextFlags(g.context)] : []), g.description || '-']);
|
|
362
|
+
}
|
|
363
|
+
console.log(pad(rows).join('\n'));
|
|
364
|
+
})), 'quox wazuh triage --since 24h --level 10');
|
|
365
|
+
|
|
366
|
+
// ---- coverage ----
|
|
367
|
+
example(wazuh.command('coverage').description('Which fleet hosts Wazuh does not cover: missing agents, disconnected agents, no vulnerability inventory')
|
|
368
|
+
.action(guard(async () => {
|
|
369
|
+
auth.requireAuth();
|
|
370
|
+
const { ep, data } = await getRoute('coverage', 'mon.wazuh.coverage');
|
|
371
|
+
if (!data || data.success === false || !data.summary) throw new WazFail(`quox wazuh coverage: ${ep.method} ${ep.path} returned an error body: ${(data && (data.message || data.error)) || 'no summary'}`);
|
|
372
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
373
|
+
const s = data.summary;
|
|
374
|
+
const n = (v, label) => (v === null || v === undefined ? `${label} unknown` : `${v} ${label}`);
|
|
375
|
+
const degraded = Boolean(data.fleet_error || data.vuln_error);
|
|
376
|
+
head('coverage', s.covered === null || s.covered === undefined ? 'fleet unknown' : `${s.covered} of ${s.fleet_hosts} covered`,
|
|
377
|
+
degraded || s.missing_agent > 0 || s.disconnected > 0 ? 'warn' : 'ok');
|
|
378
|
+
console.log([n(s.fleet_hosts, 'fleet hosts'), `${s.wazuh_agents} Wazuh agents`, n(s.covered, 'covered'), n(s.missing_agent, 'missing agent'), `${s.disconnected} disconnected`, n(s.no_vuln_inventory, 'without vulnerability inventory')].join(', '));
|
|
379
|
+
if (data.fleet_error) console.error(`Fleet list could not be read (${data.fleet_error}); fleet-side numbers are unknown, not zero.`);
|
|
380
|
+
if (data.vuln_error) console.error(`Vulnerability inventory could not be read (${data.vuln_error}); that section is unknown, not empty.`);
|
|
381
|
+
const section = (title, list, cols, row) => {
|
|
382
|
+
if (!Array.isArray(list) || !list.length) return;
|
|
383
|
+
console.log(`\n${title} (${list.length})`);
|
|
384
|
+
console.log(pad([cols, ...list.map(row)]).join('\n'));
|
|
385
|
+
};
|
|
386
|
+
section('Fleet hosts with no Wazuh agent', data.missing_agent, ['HOST', 'IP'], (h) => [h.host || '-', h.ip || '-']);
|
|
387
|
+
section('Disconnected Wazuh agents', data.disconnected, ['ID', 'NAME', 'LAST SEEN'], (a) => [a.id, a.name, a.last_seen ? agoText(a.last_seen) : 'never']);
|
|
388
|
+
section('Agents with no vulnerability inventory', data.no_vuln_inventory, ['ID', 'NAME'], (a) => [a.id, a.name]);
|
|
389
|
+
section('Wazuh agents unknown to fleet', data.unknown_to_fleet, ['ID', 'NAME', 'IP'], (a) => [a.id, a.name, a.ip || '-']);
|
|
390
|
+
})), 'quox wazuh coverage');
|
|
391
|
+
|
|
392
|
+
// ---- vulns ----
|
|
393
|
+
example(wazuh.command('vulns').description('Vulnerabilities on monitored hosts, highest score first')
|
|
394
|
+
.option('--severity <s>', 'Critical|High|Medium|Low')
|
|
395
|
+
.option('--agent <id|name>', 'only this agent')
|
|
396
|
+
.option('--limit <n>', 'max rows (default 50, capped at 1000; with --priority default 20, capped at 200)')
|
|
397
|
+
.option('--priority', 'rank by real exploit risk: CISA KEV, then EPSS, then CVSS, then hosts affected')
|
|
398
|
+
.option('--kev-only', 'with --priority: only CVEs in the CISA KEV catalogue')
|
|
399
|
+
.action(guard(async (options) => {
|
|
400
|
+
auth.requireAuth();
|
|
401
|
+
if (options.limit && !/^\d+$/.test(String(options.limit))) throw new WazFail(`quox wazuh vulns: --limit needs a whole number, got "${options.limit}"`);
|
|
402
|
+
if (options.kevOnly && !options.priority) throw new WazFail('quox wazuh vulns: --kev-only needs --priority');
|
|
403
|
+
if (options.priority) {
|
|
404
|
+
if (options.severity) throw new WazFail('quox wazuh vulns: --severity does not combine with --priority (the rank already weighs severity)');
|
|
405
|
+
const pq = {};
|
|
406
|
+
if (options.limit) pq.limit = String(options.limit);
|
|
407
|
+
if (options.kevOnly) pq.kev_only = 'true';
|
|
408
|
+
if (options.agent) { if (/^\d+$/.test(String(options.agent))) pq.agent_id = String(options.agent).padStart(3, '0'); else pq.agent_name = options.agent; }
|
|
409
|
+
const { ep, data } = await getRoute('vulns', 'mon.wazuh.vulnerabilities.priority', pq);
|
|
410
|
+
if (!data || data.success === false || !Array.isArray(data.items) || !data.summary) throw new WazFail(`quox wazuh vulns: ${ep.method} ${ep.path} returned an error body: ${(data && (data.message || data.error)) || 'no items array'}`);
|
|
411
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
412
|
+
const sm = data.summary;
|
|
413
|
+
const degraded = (sm.kev_status && sm.kev_status !== 'ok') || (sm.epss_status && sm.epss_status !== 'ok');
|
|
414
|
+
head('vulns', `${plural(sm.cves, 'CVE')}, ${sm.in_kev} in CISA KEV, ${sm.epss_over_0_1} with EPSS over 0.1`, sm.in_kev > 0 || degraded ? 'warn' : 'ok');
|
|
415
|
+
if (sm.kev_status && sm.kev_status !== 'ok') console.error(`⚠ CISA KEV ${sm.kev_status}; KEV membership is unknown, not "none".`);
|
|
416
|
+
if (sm.epss_status && sm.epss_status !== 'ok') console.error(`⚠ EPSS ${sm.epss_status}; exploit probability is unknown, ranked without it.`);
|
|
417
|
+
const cv = data.coverage || {};
|
|
418
|
+
if (cv.truncated) console.error(`⚠ ranked ${cv.cves_ranked} of ${cv.cves_total ?? 'more'} CVEs: the top by CVSS${cv.kev_sweep === 'ok' ? ' plus every CVE in CISA KEV' : `; the CISA KEV sweep is ${cv.kev_sweep}, so a KEV CVE outside the top may be missing`}. Narrow with --agent.`);
|
|
419
|
+
if (!data.items.length) { console.log(options.kevOnly ? 'No CVEs in CISA KEV.' : 'No vulnerabilities.'); return; }
|
|
420
|
+
const prows = [['RANK', 'CVE', 'SEV', 'CVSS', 'EPSS', 'KEV', 'HOSTS', 'PACKAGE']];
|
|
421
|
+
data.items.forEach((it, i) => prows.push([i + 1, it.cve, it.severity || '-', it.cvss ?? '-',
|
|
422
|
+
typeof it.epss === 'number' ? `${(it.epss * 100).toFixed(1)}%` : '-',
|
|
423
|
+
it.kev ? (it.kev.knownRansomwareCampaignUse ? 'yes (ransomware)' : 'yes') : 'no',
|
|
424
|
+
it.agents_affected, (it.packages || []).slice(0, 2).join(', ') || '-']));
|
|
425
|
+
console.log(pad(prows).join('\n'));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const query = {};
|
|
429
|
+
if (options.limit) query.limit = String(options.limit);
|
|
430
|
+
if (options.severity) query.severity = options.severity;
|
|
431
|
+
if (options.agent) { if (/^\d+$/.test(String(options.agent))) query.agent_id = String(options.agent).padStart(3, '0'); else query.agent_name = options.agent; }
|
|
432
|
+
const { ep, data } = await getRoute('vulns', 'mon.wazuh.vulnerabilities.list', query);
|
|
433
|
+
if (!data || data.success === false || !Array.isArray(data.data)) throw new WazFail(`quox wazuh vulns: ${ep.method} ${ep.path} returned an error body: ${(data && (data.message || data.error)) || 'no vulnerabilities array'}`);
|
|
434
|
+
const list = data.data;
|
|
435
|
+
if (jsonOut()) { console.log(JSON.stringify({ vulnerabilities: list, total: data.total ?? list.length }, null, 2)); return; }
|
|
436
|
+
const bySev = {};
|
|
437
|
+
for (const v of list) { const s = (v.vulnerability && v.vulnerability.severity) || 'Unknown'; bySev[s] = (bySev[s] || 0) + 1; }
|
|
438
|
+
const sevText = ['Critical', 'High', 'Medium', 'Low', 'Unknown'].filter((s) => bySev[s]).map((s) => `${bySev[s]} ${s}`).join(' · ');
|
|
439
|
+
head('vulns', `${list.length === 0 ? 'none' : sevText}${(data.total ?? 0) > list.length ? ` (showing ${list.length} of ${data.total})` : ''}`, bySev.Critical ? 'warn' : 'ok');
|
|
440
|
+
if (!list.length) { console.log('No vulnerabilities.'); return; }
|
|
441
|
+
const rows = [['SEVERITY', 'SCORE', 'CVE', 'AGENT', 'PACKAGE']];
|
|
442
|
+
for (const v of list) rows.push([(v.vulnerability && v.vulnerability.severity) || '-', (v.vulnerability && v.vulnerability.score && v.vulnerability.score.base) ?? '-', (v.vulnerability && v.vulnerability.id) || '-', (v.agent && v.agent.name) || '-', v.package ? `${v.package.name || ''} ${v.package.version || ''}`.trim() : '-']);
|
|
443
|
+
console.log(pad(rows).join('\n'));
|
|
444
|
+
})), 'quox wazuh vulns --priority --kev-only');
|
|
445
|
+
|
|
446
|
+
// ---- fim / sca / rootcheck ----
|
|
447
|
+
/** The agent ids a per-agent read covers: --agent, else every active agent (capped). */
|
|
448
|
+
async function targetAgents(verb, agentOpt) {
|
|
449
|
+
if (agentOpt) {
|
|
450
|
+
if (/^\d+$/.test(String(agentOpt))) return { ids: [String(agentOpt).padStart(3, '0')], note: '' };
|
|
451
|
+
const a = await allAgents(verb);
|
|
452
|
+
const hit = a.items.find((x) => String(x.name).toLowerCase() === String(agentOpt).toLowerCase());
|
|
453
|
+
if (!hit) throw new WazFail(`quox wazuh ${verb}: no agent "${agentOpt}". List them: quox wazuh agents`);
|
|
454
|
+
return { ids: [hit.id], note: '' };
|
|
455
|
+
}
|
|
456
|
+
const a = await allAgents(verb);
|
|
457
|
+
const active = a.items.filter((x) => x.status === 'active');
|
|
458
|
+
const ids = active.slice(0, PROXY_AGENT_CAP).map((x) => x.id);
|
|
459
|
+
return { ids, names: new Map(a.items.map((x) => [x.id, x.name])), note: active.length > ids.length ? ` (first ${ids.length} of ${active.length} active agents; use --agent for the rest)` : '' };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---- governed tool path: fim/sca/rootcheck reads (read tools, no approval) and every write ----
|
|
463
|
+
async function callTool(verb, tool, input) {
|
|
464
|
+
const ep = resolveEndpoint('v1.tools.execute');
|
|
465
|
+
let data;
|
|
466
|
+
try {
|
|
467
|
+
data = await client.post(ep.path, { service: ep.service, body: { tool_name: tool, input, org_id: resolveOrgId(auth, program) } });
|
|
468
|
+
} catch (err) {
|
|
469
|
+
// The policy layer answers a held write with HTTP 403 + {pending:true, approvalId}: the approval is filed.
|
|
470
|
+
data = pendingBodyOfError(err);
|
|
471
|
+
if (!data) {
|
|
472
|
+
const f = failFrom(verb, ep.method, ep.path, err, ` (${tool})`);
|
|
473
|
+
const status = err && (err.status ?? err.statusCode);
|
|
474
|
+
if (status === 404 || /unknown tool|not found/i.test((err && err.message) || '')) f.message += `\n The ${tool} tool is not available on this collector yet.`;
|
|
475
|
+
throw f;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return { data, pendingId: execResultPending(data) };
|
|
479
|
+
}
|
|
480
|
+
const resultOf = (data) => (data && data.result && typeof data.result === 'object' ? data.result : data) || {};
|
|
481
|
+
function failIfToolFailed(verb, tool, data) {
|
|
482
|
+
const r = resultOf(data);
|
|
483
|
+
if (execResultFailed(data) || !data || data.error || r.error) {
|
|
484
|
+
const code = (typeof r.error === 'string' && r.error) || (data && typeof data.error === 'string' && data.error) || '';
|
|
485
|
+
const hint = HINTS[code];
|
|
486
|
+
throw new WazFail(`quox wazuh ${verb}: ${tool} failed: ${r.message || (data && data.message) || code || 'tool reported failure'}${hint ? `\n ${hint}` : ''}`);
|
|
487
|
+
}
|
|
488
|
+
return r;
|
|
489
|
+
}
|
|
490
|
+
const listOf = (r, keys) => { for (const k of keys) if (Array.isArray(r[k])) return r[k]; return null; };
|
|
491
|
+
/** The agent a read was auto-delegated to (the gateway's single-holder hop answers in prose), else null. */
|
|
492
|
+
const delegatedTo = (data) => {
|
|
493
|
+
const hop = (data && data.auto_delegated) || resultOf(data).auto_delegated;
|
|
494
|
+
return hop && typeof hop === 'object' ? String(hop.to || 'another agent') : null;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const BATCH_AGENTS = 50;
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* quox#1503: the per-agent reads go over GET /api/v1/wazuh/read/:tool, the same tool body with no auto-delegation hop
|
|
501
|
+
* (through tools/execute a profile whose hat lacks the tool was delegated to argus: prose or a 30 s timeout).
|
|
502
|
+
* A 404 is an older collector without the route, so it falls back to the governed tools path.
|
|
503
|
+
*/
|
|
504
|
+
async function readTool(verb, tool, input) {
|
|
505
|
+
const ep = resolveEndpoint('mon.wazuh.read', { tool });
|
|
506
|
+
const query = {};
|
|
507
|
+
for (const [k, v] of Object.entries(input)) if (v != null) query[k] = Array.isArray(v) ? v.join(',') : String(v);
|
|
508
|
+
let data;
|
|
509
|
+
try {
|
|
510
|
+
data = await client.get(ep.path, { service: ep.service, query });
|
|
511
|
+
} catch (err) {
|
|
512
|
+
if ((err && (err.status ?? err.statusCode)) === 404) return callTool(verb, tool, input);
|
|
513
|
+
throw failFrom(verb, ep.method, ep.path, err);
|
|
514
|
+
}
|
|
515
|
+
return { data };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Run a per-agent read tool for --agent, or for every active agent (capped). Rows are tagged with the agent. */
|
|
519
|
+
async function perAgentTool(verb, tool, options, extra, keys) {
|
|
520
|
+
const t = await targetAgents(verb, options.agent);
|
|
521
|
+
const all = [];
|
|
522
|
+
let total = 0;
|
|
523
|
+
const take = (id, r) => {
|
|
524
|
+
const list = listOf(r, keys);
|
|
525
|
+
const hop = !list && delegatedTo(r);
|
|
526
|
+
if (hop) throw new WazFail(`quox wazuh ${verb}: ${tool} is not in this profile's hat, so it was auto-delegated to agent ${hop}, which answered in prose instead of returning data. Ask ${hop} in chat, or use a profile whose hat holds ${tool}`);
|
|
527
|
+
if (!list) throw new WazFail(`quox wazuh ${verb}: ${tool} returned an unexpected body (expected ${keys.join(' or ')}); use --json to see it`);
|
|
528
|
+
total += Number.isFinite(Number(r.total)) ? Number(r.total) : list.length;
|
|
529
|
+
for (const f of list) all.push({ agent: (t.names && t.names.get(id)) || id, agent_id: id, ...f });
|
|
530
|
+
};
|
|
531
|
+
if (t.ids.length <= 1) {
|
|
532
|
+
for (const id of t.ids) {
|
|
533
|
+
const { data } = await readTool(verb, tool, { agent_id: id, ...extra });
|
|
534
|
+
take(id, failIfToolFailed(verb, tool, data));
|
|
535
|
+
}
|
|
536
|
+
return { all, t, total };
|
|
537
|
+
}
|
|
538
|
+
// Several agents: ONE tool call per chunk of 50 (agent_ids), not one per agent (the tool-call limiter is 60/min).
|
|
539
|
+
for (let i = 0; i < t.ids.length; i += BATCH_AGENTS) {
|
|
540
|
+
const chunk = t.ids.slice(i, i + BATCH_AGENTS);
|
|
541
|
+
const { data } = await readTool(verb, tool, { agent_ids: chunk, ...extra });
|
|
542
|
+
const r = failIfToolFailed(verb, tool, data);
|
|
543
|
+
if (!Array.isArray(r.per_agent) && delegatedTo(r)) take(chunk[0], r);
|
|
544
|
+
if (!Array.isArray(r.per_agent)) throw new WazFail(`quox wazuh ${verb}: ${tool} returned an unexpected body (expected per_agent); use --json to see it`);
|
|
545
|
+
const byId = new Map(r.per_agent.map((row) => [String(row.agent_id), row]));
|
|
546
|
+
for (const id of chunk) {
|
|
547
|
+
const row = byId.get(id);
|
|
548
|
+
if (!row) throw new WazFail(`quox wazuh ${verb}: ${tool} returned no row for agent ${(t.names && t.names.get(id)) || id} (${id})`);
|
|
549
|
+
if (execResultFailed(row) || row.error || row.success === false) {
|
|
550
|
+
const code = typeof row.error === 'string' ? row.error : '';
|
|
551
|
+
throw new WazFail(`quox wazuh ${verb}: ${tool} failed for agent ${(t.names && t.names.get(id)) || id} (${id}): ${row.message || code || 'tool reported failure'}${HINTS[code] ? `\n ${HINTS[code]}` : ''}`);
|
|
552
|
+
}
|
|
553
|
+
take(id, row);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { all, t, total };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
example(wazuh.command('fim').description('File integrity (syscheck): recently changed files (reads the wazuh_fim tool)')
|
|
560
|
+
.option('--agent <id|name>', 'one agent (default: every active agent)')
|
|
561
|
+
.option('--file <path>', 'only this file path')
|
|
562
|
+
.option('--search <text>', 'text to search for')
|
|
563
|
+
.option('--limit <n>', 'files per agent (default 20)')
|
|
564
|
+
.action(guard(async (options) => {
|
|
565
|
+
auth.requireAuth();
|
|
566
|
+
if (options.limit && !/^\d+$/.test(String(options.limit))) throw new WazFail(`quox wazuh fim: --limit needs a whole number, got "${options.limit}"`);
|
|
567
|
+
const extra = { limit: Number(options.limit) || 20 };
|
|
568
|
+
if (options.file) extra.file = options.file;
|
|
569
|
+
if (options.search) extra.search = options.search;
|
|
570
|
+
const { all, t, total } = await perAgentTool('fim', 'wazuh_fim', options, extra, ['events', 'files', 'items', 'affected_items']);
|
|
571
|
+
if (jsonOut()) { console.log(JSON.stringify({ files: all, total, agents: t.ids }, null, 2)); return; }
|
|
572
|
+
head('fim', `${all.length < total ? `showing ${all.length} of ${total}` : plural(all.length, 'file')} across ${plural(t.ids.length, 'agent')}${t.note}`);
|
|
573
|
+
if (!all.length) { console.log('No syscheck entries.'); return; }
|
|
574
|
+
const rows = [['WHEN', 'UTC', 'AGENT', 'TYPE', 'SIZE', 'OWNER', 'PERM', 'FILE']];
|
|
575
|
+
for (const f of all) {
|
|
576
|
+
const when = f.date || f.mtime;
|
|
577
|
+
const owner = f.uname || f.gname ? `${f.uname || '-'}:${f.gname || '-'}` : '-';
|
|
578
|
+
rows.push([agoText(when), utcText(when), f.agent, f.type || '-', f.size ?? '-', owner, f.perm || '-', f.file || '-']);
|
|
579
|
+
}
|
|
580
|
+
console.log(pad(rows).join('\n'));
|
|
581
|
+
if (all.length < total) console.log(`Showing ${all.length} of ${total}; raise --limit to see more.`);
|
|
582
|
+
})), 'quox wazuh fim --agent 001');
|
|
583
|
+
|
|
584
|
+
example(wazuh.command('sca').description('Security configuration assessment: policies with pass/fail; --policy lists its failed checks (reads the wazuh_sca tool)')
|
|
585
|
+
.option('--agent <id|name>', 'one agent (default: every active agent)')
|
|
586
|
+
.option('--policy <id>', "one policy's failed checks")
|
|
587
|
+
.option('--limit <n>', 'max rows per agent')
|
|
588
|
+
.action(guard(async (options) => {
|
|
589
|
+
auth.requireAuth();
|
|
590
|
+
if (options.policy && !options.agent) throw new WazFail('quox wazuh sca: --policy needs --agent (failed checks are per agent)');
|
|
591
|
+
if (options.limit && !/^\d+$/.test(String(options.limit))) throw new WazFail(`quox wazuh sca: --limit needs a whole number, got "${options.limit}"`);
|
|
592
|
+
const extra = {};
|
|
593
|
+
if (options.policy) extra.policy_id = options.policy;
|
|
594
|
+
if (options.limit) extra.limit = Number(options.limit);
|
|
595
|
+
const keys = options.policy ? ['checks', 'failed_checks', 'items', 'affected_items'] : ['policies', 'items', 'affected_items'];
|
|
596
|
+
const { all, t } = await perAgentTool('sca', 'wazuh_sca', options, extra, keys);
|
|
597
|
+
if (jsonOut()) { console.log(JSON.stringify(options.policy ? { checks: all, agents: t.ids } : { policies: all, agents: t.ids }, null, 2)); return; }
|
|
598
|
+
if (options.policy) {
|
|
599
|
+
head(`sca ${options.policy}`, plural(all.length, 'failed check'), all.length ? 'warn' : 'ok');
|
|
600
|
+
if (!all.length) { console.log('No failed checks.'); return; }
|
|
601
|
+
const rows = [['ID', 'RESULT', 'TITLE']];
|
|
602
|
+
for (const c of all) rows.push([c.id ?? '-', c.result || 'failed', c.title || c.description || '-']);
|
|
603
|
+
console.log(pad(rows).join('\n'));
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
head('sca', `${plural(all.length, 'policy', 'policies')} across ${plural(t.ids.length, 'agent')}${t.note}`);
|
|
607
|
+
if (!all.length) { console.log('No SCA policies scanned.'); return; }
|
|
608
|
+
const rows = [['AGENT', 'POLICY', 'PASS', 'FAIL', 'N/A', 'SCORE', 'SCANNED']];
|
|
609
|
+
for (const p of all) rows.push([p.agent, p.name || p.policy_id || '-', p.pass ?? '-', p.fail ?? '-', p.invalid ?? '-', p.score === undefined ? '-' : `${p.score}%`, agoText(p.end_scan)]);
|
|
610
|
+
console.log(pad(rows).join('\n'));
|
|
611
|
+
})), 'quox wazuh sca --agent 001 --policy cis_ubuntu22');
|
|
612
|
+
|
|
613
|
+
example(wazuh.command('rootcheck').description('Rootcheck findings (reads the wazuh_rootcheck tool)')
|
|
614
|
+
.option('--agent <id|name>', 'one agent (default: every active agent)')
|
|
615
|
+
.option('--limit <n>', 'max findings per agent')
|
|
616
|
+
.action(guard(async (options) => {
|
|
617
|
+
auth.requireAuth();
|
|
618
|
+
if (options.limit && !/^\d+$/.test(String(options.limit))) throw new WazFail(`quox wazuh rootcheck: --limit needs a whole number, got "${options.limit}"`);
|
|
619
|
+
const extra = options.limit ? { limit: Number(options.limit) } : {};
|
|
620
|
+
const { all, t, total } = await perAgentTool('rootcheck', 'wazuh_rootcheck', options, extra, ['items', 'findings', 'affected_items', 'results']);
|
|
621
|
+
if (jsonOut()) { console.log(JSON.stringify({ findings: all, total, agents: t.ids }, null, 2)); return; }
|
|
622
|
+
head('rootcheck', `${plural(all.length, 'finding')}${total > all.length ? ` shown of ${total}` : ''}${t.note}`, all.length ? 'warn' : 'ok');
|
|
623
|
+
if (!all.length) { console.log('No rootcheck findings.'); return; }
|
|
624
|
+
const rows = [['WHEN', 'AGENT', 'STATUS', 'FINDING']];
|
|
625
|
+
for (const f of all) rows.push([agoText(f.date_last || f.date_first), f.agent, f.status || '-', f.log || f.description || '-']);
|
|
626
|
+
console.log(pad(rows).join('\n'));
|
|
627
|
+
})), 'quox wazuh rootcheck --agent 001');
|
|
628
|
+
|
|
629
|
+
example(wazuh.command('deploy-history').description('Wazuh agent deploy results')
|
|
630
|
+
.option('--mode <mode>', 'latest|all', 'latest')
|
|
631
|
+
.option('--limit <n>', 'max rows (mode=all)', '200')
|
|
632
|
+
.action(guard(async (options) => {
|
|
633
|
+
auth.requireAuth();
|
|
634
|
+
const { ep, data } = await getRoute('deploy-history', 'mon.wazuh.deploy-history.list', { mode: options.mode, limit: options.limit });
|
|
635
|
+
if (!data || data.success === false || !Array.isArray(data.deploys)) throw new WazFail(`quox wazuh deploy-history: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'no deploys array'}`);
|
|
636
|
+
if (jsonOut()) { console.log(JSON.stringify({ deploys: data.deploys }, null, 2)); return; }
|
|
637
|
+
const bad = data.deploys.filter((d) => !d.success).length;
|
|
638
|
+
head('deploy-history', `${plural(data.deploys.length, 'deploy')}${bad ? ` · ${bad} failed` : ''}`, bad ? 'warn' : 'ok');
|
|
639
|
+
if (!data.deploys.length) { console.log('No deploys recorded.'); return; }
|
|
640
|
+
const rows = [['HOST', 'IP', 'RESULT', 'WHEN']];
|
|
641
|
+
for (const d of data.deploys) rows.push([d.hostname || '-', d.ip || d.hostIp || '-', d.success ? 'ok' : (d.message ? `failed: ${String(d.message).slice(0, 40)}` : 'failed'), agoText(d.deployed_at || d.deployedAt)]);
|
|
642
|
+
console.log(pad(rows).join('\n'));
|
|
643
|
+
})), 'quox wazuh deploy-history');
|
|
644
|
+
|
|
645
|
+
// ---- writes: always the governed tool path; a person approves; the approval id is printed ----
|
|
646
|
+
async function writeTool(verb, tool, input, okText) {
|
|
647
|
+
auth.requireAuth();
|
|
648
|
+
const { data, pendingId } = await callTool(verb, tool, input);
|
|
649
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); if (!pendingId && execResultFailed(data)) process.exitCode = 1; return; }
|
|
650
|
+
if (pendingId) { head(verb, 'awaiting approval', 'busy'); console.log(`◐ awaiting approval ${pendingId}: a person approves it in the inbox; check with quox approvals result ${pendingId}`); return; }
|
|
651
|
+
failIfToolFailed(verb, tool, data);
|
|
652
|
+
head(verb, okText, 'ok');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** One governed wazuh_deploy_agent call for one host (hostname and host_ip are both required by the tool). */
|
|
656
|
+
const deployOne = (host, ip, options) => {
|
|
657
|
+
if (!ip) throw new WazFail(`quox wazuh deploy: ${host} needs an address (--ip <ip>, or name=ip for deploy-bulk); the tool requires hostname and host_ip`);
|
|
658
|
+
const input = { hostname: host, host_ip: ip };
|
|
659
|
+
if (options.sshUser) input.ssh_user = options.sshUser;
|
|
660
|
+
if (options.sshPort) input.ssh_port = Number(options.sshPort);
|
|
661
|
+
return writeTool(`deploy ${host}`, 'wazuh_deploy_agent', input, `deployed to ${host}`);
|
|
662
|
+
};
|
|
663
|
+
// WAZ-10: `quox mon wazuh` keeps its legacy write verbs, mapped onto these same governed helpers (no second path).
|
|
664
|
+
const governed = { guard, writeTool, deployOne, WazFail };
|
|
665
|
+
if (!writes) return governed;
|
|
666
|
+
|
|
667
|
+
example(wazuh.command('deploy').description('Install the Wazuh agent on a host (held for human approval)')
|
|
668
|
+
.argument('<host>', 'hostname')
|
|
669
|
+
.requiredOption('--ip <ip>', 'host address')
|
|
670
|
+
.option('--ssh-user <user>', 'SSH user')
|
|
671
|
+
.option('--ssh-port <n>', 'SSH port')
|
|
672
|
+
.action(guard(async (host, options) => { await deployOne(host, options.ip, options); })), 'quox wazuh deploy web09 --ip 192.0.2.9');
|
|
673
|
+
|
|
674
|
+
example(wazuh.command('deploy-bulk').description('Install the Wazuh agent on several hosts: one governed call and one approval per host')
|
|
675
|
+
.argument('<hosts...>', 'name=ip pairs')
|
|
676
|
+
.option('--ssh-user <user>', 'SSH user')
|
|
677
|
+
.option('--ssh-port <n>', 'SSH port')
|
|
678
|
+
.action(guard(async (hosts, options) => {
|
|
679
|
+
const pairs = hosts.map((h) => String(h).split('='));
|
|
680
|
+
const missing = pairs.filter(([, ip]) => !ip).map(([h]) => h);
|
|
681
|
+
if (missing.length) throw new WazFail(`quox wazuh deploy-bulk: every host needs an address as name=ip; missing for ${missing.join(', ')}`);
|
|
682
|
+
let failed = 0;
|
|
683
|
+
for (const [hostname, ip] of pairs) {
|
|
684
|
+
try { await deployOne(hostname, ip, options); } catch (err) {
|
|
685
|
+
if (!(err instanceof WazFail)) throw err;
|
|
686
|
+
console.error(err.message); failed++;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (failed) process.exitCode = 1;
|
|
690
|
+
})), 'quox wazuh deploy-bulk web01=192.0.2.1 web02=192.0.2.2');
|
|
691
|
+
|
|
692
|
+
example(wazuh.command('remove').description('Remove an agent from the Wazuh manager (held for human approval)')
|
|
693
|
+
.argument('<agentId>', 'agent id, e.g. 007')
|
|
694
|
+
.action(guard(async (id) => {
|
|
695
|
+
await writeTool(`remove ${id}`, 'wazuh_agent_remove', { agent_id: /^\d+$/.test(id) ? id.padStart(3, '0') : id }, `removed agent ${id}`);
|
|
696
|
+
})), 'quox wazuh remove 007');
|
|
697
|
+
|
|
698
|
+
example(wazuh.command('active-response').description('Run an active response on one or more agents (held for human approval)')
|
|
699
|
+
.argument('<agentId...>', 'agent id(s), e.g. 001')
|
|
700
|
+
.requiredOption('--command <cmd>', 'firewall-block|host-isolate|restart-wazuh-agent')
|
|
701
|
+
.option('--reason <text>', 'why, at least 10 characters (shown to the approver)')
|
|
702
|
+
.option('--args <list>', 'comma-separated command arguments')
|
|
703
|
+
.action(guard(async (ids, options) => {
|
|
704
|
+
if (!options.reason || options.reason.trim().length < 10) throw new WazFail('quox wazuh active-response: --reason "<why>" is required, at least 10 characters (the approver sees it)');
|
|
705
|
+
const args = options.args ? String(options.args).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
706
|
+
const input = { command: options.command, agent_ids: ids.map((i) => (/^\d+$/.test(i) ? i.padStart(3, '0') : i)), reason: options.reason.trim() };
|
|
707
|
+
if (args.length) input.args = args;
|
|
708
|
+
await writeTool(`active-response ${ids.join(',')}`, 'wazuh_active_response', input, `${options.command} ran on ${plural(ids.length, 'agent')}`);
|
|
709
|
+
})), 'quox wazuh active-response 001 --command firewall-block --args 203.0.113.9 --reason "ssh brute force"');
|
|
710
|
+
|
|
711
|
+
const indexer = wazuh.command('indexer').description('The Wazuh indexer credential behind alerts and vulns');
|
|
712
|
+
example(indexer.command('install-credential').description('Place the indexer read credential on the Wazuh host (held for human approval)')
|
|
713
|
+
.action(guard(async () => {
|
|
714
|
+
await writeTool('indexer install-credential', 'wazuh_indexer_credential_install', {}, 'indexer credential installed');
|
|
715
|
+
})), 'quox wazuh indexer install-credential');
|
|
716
|
+
|
|
717
|
+
// ---- report (WAZ P5): signed, offline-verifiable posture report. `quox wazuh` only, not the mon read set. ----
|
|
718
|
+
registerWazuhReport(wazuh, { client, auth, jsonOut, guard, WazFail, pad, head, failFrom, example });
|
|
719
|
+
|
|
720
|
+
// ---- alert intake (WAZ P6): integratord hook + the incidents it opens. `quox wazuh` only, like report. ----
|
|
721
|
+
registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFail, pad, head, failFrom, example, callTool, failIfToolFailed });
|
|
722
|
+
return governed;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
726
|
+
// `quox wazuh connect` (WAZ-2): separate block, no shared state with the read verbs above.
|
|
727
|
+
// Creates/updates THIS org's `wazuh` vault credential. Passwords are hidden prompts only, never flags or argv.
|
|
728
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
async function hiddenPrompt(message) {
|
|
731
|
+
if (!process.stdin.isTTY) throw new WazFail(`quox wazuh connect: ${message} needs a terminal (the password is typed hidden, never passed as a flag or piped)`);
|
|
732
|
+
const { default: inquirer } = await import('inquirer');
|
|
733
|
+
const ans = await inquirer.prompt([{ type: 'password', name: 'v', message, mask: '*', validate: (v) => (v && v.length > 0) || 'required' }]);
|
|
734
|
+
return ans.v;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export function attachWazuhConnect(wazuh, program, ctx) {
|
|
738
|
+
const { client, auth } = ctx;
|
|
739
|
+
const promptSecret = ctx.promptSecret || hiddenPrompt;
|
|
740
|
+
async function importInstanceEnv() {
|
|
741
|
+
const ep = resolveEndpoint('mon.wazuh.credential.import-instance-env');
|
|
742
|
+
let res;
|
|
743
|
+
try { res = await client.post(ep.path, { service: ep.service, body: {} }); } catch (err) {
|
|
744
|
+
const f = failFrom('connect', ep.method, ep.path, err);
|
|
745
|
+
const code = err && err.body && typeof err.body === 'object' ? err.body.reason : null;
|
|
746
|
+
const NEXT = {
|
|
747
|
+
not_instance_superadmin: 'Only an instance superadmin can import the instance Wazuh environment. Ask one, or connect with your own details: quox wazuh connect --url <url> --user <user>.',
|
|
748
|
+
not_org_admin: 'You must be an owner or admin of the org you are logged into. Switch org (quox org switch) or ask an org admin.',
|
|
749
|
+
instance_env_empty: 'This collector has no WAZUH_* environment to import. Connect with your own details: quox wazuh connect --url <url> --user <user>.',
|
|
750
|
+
auth_unavailable: 'The auth service did not answer. Retry in a minute.',
|
|
751
|
+
};
|
|
752
|
+
throw new WazFail(`${f.message}${code ? `\n reason: ${code}` : ''}${NEXT[code] ? `\n ${NEXT[code]}` : ''}`);
|
|
753
|
+
}
|
|
754
|
+
if (!res || typeof res !== 'object') throw new WazFail(`quox wazuh connect: ${ep.method} ${ep.path} returned no body`);
|
|
755
|
+
if (res.imported) {
|
|
756
|
+
console.log(glyphLine('quox', 'wazuh connect', { result: `credential imported into org ${res.org_id}`, status: 'ok' }));
|
|
757
|
+
await wazuh.parseAsync(['status'], { from: 'user' });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (res.reason === 'already_present') {
|
|
761
|
+
console.log(glyphLine('quox', 'wazuh connect', { result: `already present in org ${res.org_id}; nothing imported (change it with quox wazuh connect --url <url> --user <user>)`, status: 'ok' }));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
throw new WazFail(`quox wazuh connect: instance env import did not happen: ${res.reason || 'no reason given'}`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
wazuh.command('connect')
|
|
768
|
+
.description('Connect this organisation to its Wazuh: stores the API (and optional indexer) credential in the vault, then runs status. Admin only. Passwords are prompted, never flags.')
|
|
769
|
+
.option('--from-instance-env', 'import the collector\'s own WAZUH_* environment into this org\'s vault (instance superadmin who is also an org admin; no prompts, no other flags)')
|
|
770
|
+
.option('--url <url>', 'Wazuh manager API URL, e.g. https://wazuh-host:55000')
|
|
771
|
+
.option('--user <user>', 'Wazuh API username, e.g. wazuh-wui')
|
|
772
|
+
.option('--indexer-url <url>', 'Wazuh indexer URL, e.g. https://wazuh-host:9200')
|
|
773
|
+
.option('--indexer-user <user>', 'Wazuh indexer username (a password is then prompted)')
|
|
774
|
+
.option('--transport <mode>', 'indexer transport: auto|direct|host', 'auto')
|
|
775
|
+
.addHelpText('after', '\nExample: quox wazuh connect --url https://wazuh.example:55000 --user wazuh-wui --indexer-user admin --transport host')
|
|
776
|
+
.action(async (options) => {
|
|
777
|
+
try {
|
|
778
|
+
auth.requireAuth();
|
|
779
|
+
if (options.fromInstanceEnv) {
|
|
780
|
+
if (options.url || options.user || options.indexerUrl || options.indexerUser) throw new WazFail('quox wazuh connect: --from-instance-env cannot be combined with --url, --user, --indexer-url or --indexer-user');
|
|
781
|
+
await importInstanceEnv();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (!options.url) throw new WazFail('quox wazuh connect: --url <url> and --user <user> are required (or use --from-instance-env to import the collector\'s own Wazuh environment)');
|
|
785
|
+
if (!options.user) throw new WazFail('quox wazuh connect: --user <user> is required with --url');
|
|
786
|
+
if (!/^https:\/\/[^\s/]+/.test(options.url)) throw new WazFail('quox wazuh connect: --url must be an https:// URL (the credential is sent to it)');
|
|
787
|
+
if (options.indexerUrl && !/^https:\/\/[^\s/]+/.test(options.indexerUrl)) throw new WazFail('quox wazuh connect: --indexer-url must be an https:// URL');
|
|
788
|
+
if (!['auto', 'direct', 'host'].includes(options.transport)) throw new WazFail('quox wazuh connect: --transport must be auto, direct or host');
|
|
789
|
+
const orgId = resolveOrgId(auth, program);
|
|
790
|
+
const listEp = resolveEndpoint('vault.list', { orgId });
|
|
791
|
+
let existing;
|
|
792
|
+
try {
|
|
793
|
+
const data = await client.get(listEp.path, { service: listEp.service, query: { type: 'wazuh' } });
|
|
794
|
+
existing = (data.credentials || data || []).find((c) => c && c.integration_type === 'wazuh');
|
|
795
|
+
} catch (err) { throw failFrom('connect', listEp.method, listEp.path, err); }
|
|
796
|
+
|
|
797
|
+
const credentials = { base_url: options.url, username: options.user };
|
|
798
|
+
credentials.password = await promptSecret(`Wazuh API password for ${options.user}`);
|
|
799
|
+
if (options.indexerUser) {
|
|
800
|
+
credentials.indexer_username = options.indexerUser;
|
|
801
|
+
credentials.indexer_password = await promptSecret(`Wazuh indexer password for ${options.indexerUser}`);
|
|
802
|
+
}
|
|
803
|
+
if (options.indexerUrl) credentials.indexer_url = options.indexerUrl;
|
|
804
|
+
credentials.indexer_transport = options.transport;
|
|
805
|
+
|
|
806
|
+
const verb = existing ? 'update' : 'create';
|
|
807
|
+
const ep = resolveEndpoint(existing ? 'vault.update' : 'vault.create', { orgId, ...(existing ? { id: existing.id } : {}) });
|
|
808
|
+
try {
|
|
809
|
+
if (existing) await client.put(ep.path, { service: ep.service, body: { credentials } });
|
|
810
|
+
else await client.post(ep.path, { service: ep.service, body: { integration_type: 'wazuh', label: 'Wazuh', credentials, is_default: true } });
|
|
811
|
+
} catch (err) {
|
|
812
|
+
const f = failFrom('connect', ep.method, ep.path, err);
|
|
813
|
+
const st = err && (err.status ?? err.statusCode);
|
|
814
|
+
throw new WazFail(st === 403 ? `${f.message}\n Saving a vault credential needs an org admin.` : f.message);
|
|
815
|
+
}
|
|
816
|
+
console.log(glyphLine('quox', 'wazuh connect', { result: `credential ${verb === 'update' ? 'updated' : 'saved'} for ${options.url}`, status: 'ok' }));
|
|
817
|
+
await wazuh.parseAsync(['status'], { from: 'user' });
|
|
818
|
+
} catch (err) {
|
|
819
|
+
if (!(err instanceof WazFail)) throw err;
|
|
820
|
+
console.error(err.message);
|
|
821
|
+
process.exitCode = err.exitCode || 1;
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
}
|