quoxcli 0.4.0 → 0.6.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 +110 -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/governance.js +7 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +58 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/actions.js +51 -13
- 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/contacts.js +2 -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 +7 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +427 -25
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +69 -144
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +617 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +52 -12
- package/lib/commands/plan.js +487 -52
- 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/volt.js +6 -1
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +880 -0
- package/lib/commands/wazuhIntake.js +358 -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 +9 -0
- package/lib/core/config.js +42 -3
- 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 +120 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +30 -5
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/selfUpdate.js +2 -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 +189 -34
- package/lib/quoxon/app.js +1277 -188
- 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 +72 -12
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +8 -2
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +8 -1
- package/lib/quoxon/commands.js +42 -12
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +118 -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 +55 -8
- package/lib/quoxon/extensions.js +400 -38
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +7 -2
- 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 +27 -6
- 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/dockerOffer.js +3 -1
- 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 +68 -16
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +88 -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 +48 -11
- package/lib/quoxon/slash/broadcastRun.js +24 -7
- package/lib/quoxon/slash/cliLeaves.json +98 -1
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +63 -1
- package/lib/quoxon/slash/paletteController.js +15 -5
- package/lib/quoxon/slash/restart.js +9 -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 +24 -3
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/slotRun.js +2 -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/webPty.js +15 -0
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +42 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +61 -3
- 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,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quox wazuh hooks ... / quox wazuh incidents ... : Wazuh alerts become witnessed Quox incidents (WAZ P6).
|
|
3
|
+
* The sibling of `quox kuma hooks` / `quox kuma incidents` (kuma.js).
|
|
4
|
+
*
|
|
5
|
+
* quox wazuh hooks list this org's alert hooks (never shows a secret)
|
|
6
|
+
* quox wazuh hooks create [--label <text>] new hook; prints the URL path, the secret ONCE and the ossec.conf block
|
|
7
|
+
* quox wazuh hooks rotate <hookId> new secret for a hook, shown ONCE; the old one stops working
|
|
8
|
+
* quox wazuh hooks delete <hookId> Wazuh posts to it are refused from then on
|
|
9
|
+
* quox wazuh hooks install --collector-url <url> [--level 7] [--label <l>]
|
|
10
|
+
* one governed step (held for approval): hook + custom-quox + ossec.conf + restart;
|
|
11
|
+
* the secret goes to the manager on Bastion stdin and is never shown
|
|
12
|
+
* quox wazuh incidents [--open] [--since <24h|ISO>] [--limit <n>]
|
|
13
|
+
* quox wazuh incidents show <id> context (Kuma maintenance, open Kuma incidents, deploys), diagnosis, escalation
|
|
14
|
+
* quox wazuh incidents close <id> --reason <text>
|
|
15
|
+
* quox wazuh incidents diagnose <id> read-only host reads; off-list reads are held for approval (WAZ P7)
|
|
16
|
+
* quox wazuh incidents escalate <id> [--engagement <id|name>] file it as a QuoxSecurity finding (WAZ P7)
|
|
17
|
+
*
|
|
18
|
+
* Hook management needs an org admin or owner (a signed-in person). Incidents: any org member.
|
|
19
|
+
* Backend: /api/v1/wazuh/hooks, /api/v1/wazuh/incidents (services/collector/lib/wazuhAlerts.js).
|
|
20
|
+
* Install on the Wazuh manager: docs/wazuh/custom-quox-integration.md.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
24
|
+
import { relDuration } from './kuma.js';
|
|
25
|
+
|
|
26
|
+
const RUNBOOK = 'docs/wazuh/custom-quox-integration.md';
|
|
27
|
+
|
|
28
|
+
// WAZ-11: ARGUS's routing proposal from the verdict: what it proposes and the command a person runs to accept it.
|
|
29
|
+
function proposalCell(p) {
|
|
30
|
+
if (!p || typeof p !== 'object' || !p.proposed_action) return 'none';
|
|
31
|
+
return `${p.proposed_action}${p.verdict ? ` (ARGUS verdict ${p.verdict})` : ''}${p.command ? `: ${p.command}` : ''}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// WAZ P7: compact context flags. A source that could not answer shows as SRC?, never as "nothing".
|
|
35
|
+
// WAZ-11: a source the org never set up (context.not_configured, e.g. no Uptime Kuma) is left out, not SRC? on every row.
|
|
36
|
+
const UNKNOWN_FLAG = { maintenance: 'MAINT?', kuma: 'KUMA?', deploys: 'DEPLOY?' };
|
|
37
|
+
export function contextFlags(ctx) {
|
|
38
|
+
if (!ctx || typeof ctx !== 'object') return '-';
|
|
39
|
+
const skip = new Set(Array.isArray(ctx.not_configured) ? ctx.not_configured : []);
|
|
40
|
+
const parts = [...(ctx.flags || []), ...(ctx.unavailable || []).filter((s) => !skip.has(s)).map((s) => UNKNOWN_FLAG[s] || `${String(s).toUpperCase()}?`)];
|
|
41
|
+
return parts.length ? parts.join(' ') : '-';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const yesNo = (v) => (v === true ? 'yes' : v === false ? 'no' : 'unknown');
|
|
45
|
+
function contextRows(c) {
|
|
46
|
+
if (!c || typeof c !== 'object') return [];
|
|
47
|
+
const m = c.maintenance || {};
|
|
48
|
+
const k = c.kuma || {};
|
|
49
|
+
const d = c.deploys || {};
|
|
50
|
+
const unav = (x) => `unavailable: ${x.reason || 'no answer'}`;
|
|
51
|
+
const maint = m.status !== 'ok' ? unav(m)
|
|
52
|
+
: (m.now ? `in a Kuma maintenance window now (${(m.monitors || []).join(', ') || 'monitor'}); at the last alert: ${yesNo(m.at_last_seen)}`
|
|
53
|
+
: `not in a Kuma maintenance window now; at the last alert: ${yesNo(m.at_last_seen)}`);
|
|
54
|
+
const open = k.open || [];
|
|
55
|
+
const kuma = k.status !== 'ok' ? unav(k)
|
|
56
|
+
: (open.length ? `${open.length} open Kuma incident${open.length === 1 ? '' : 's'}: ${open.slice(0, 3).map((x) => `${x.monitor_name} (${x.id})`).join(', ')}` : 'no open Kuma incident for this host');
|
|
57
|
+
const recent = d.recent || [];
|
|
58
|
+
const dep = d.status !== 'ok' ? unav(d)
|
|
59
|
+
: (recent.length ? `${recent.length} Wazuh deploy${recent.length === 1 ? '' : 's'} to this host in the 24h before the last alert (latest ${recent[0].at}${recent[0].success === false ? ', failed' : ''})` : 'no Wazuh deploy to this host in the 24h before the last alert');
|
|
60
|
+
return [['Maintenance', maint], ['Kuma', kuma], ['Deploys', dep]];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printChecks(d, pad) {
|
|
64
|
+
const rows = [['CHECK', 'STATUS', 'OK', 'DETAIL']];
|
|
65
|
+
for (const c of d.checks || []) rows.push([c.name, c.status, c.ok === true ? 'yes' : c.ok === false ? 'NO' : '-', `${c.detail || '-'}${c.approval_id ? ` (approval ${c.approval_id})` : ''}`]);
|
|
66
|
+
if (rows.length > 1) { console.log(''); console.log(pad(rows).join('\n')); }
|
|
67
|
+
for (const c of d.checks || []) for (const l of c.lines || []) console.log(` ${c.name}: ${l}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ossecBlock(path, secret, level = 7) {
|
|
71
|
+
return [
|
|
72
|
+
'<integration>',
|
|
73
|
+
' <name>custom-quox</name>',
|
|
74
|
+
` <hook_url>COLLECTOR_BASE_URL${path}</hook_url>`,
|
|
75
|
+
` <api_key>${secret}</api_key>`,
|
|
76
|
+
` <level>${level}</level>`,
|
|
77
|
+
' <alert_format>json</alert_format>',
|
|
78
|
+
'</integration>',
|
|
79
|
+
].join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerWazuhIntake(wazuh, { client, auth, jsonOut, guard, WazFail, pad, head, failFrom, example, callTool, failIfToolFailed }) {
|
|
83
|
+
const call = async (verb, key, params, fn, id, extra = {}) => {
|
|
84
|
+
const ep = resolveEndpoint(key, params);
|
|
85
|
+
try { return { ep, data: await fn(ep) }; } catch (err) {
|
|
86
|
+
const status = err && (err.status ?? err.statusCode);
|
|
87
|
+
const code = err && err.body && err.body.error_code;
|
|
88
|
+
const base = `quox wazuh ${verb}: ${ep.method} ${ep.path} failed${status ? ` with HTTP ${status}` : ''}`;
|
|
89
|
+
if (status === 403 && (code === 'admin_required' || /admin role required/i.test((err && err.message) || ''))) throw new WazFail(`${base}: managing Wazuh alert hooks needs an org admin or owner.`);
|
|
90
|
+
if (status === 403 && code === 'human_required') throw new WazFail(`${base}: this needs a signed-in person, not a service key.`);
|
|
91
|
+
if (status === 404 && code === 'engagement_not_found') throw new WazFail(`${base}: no engagement ${extra.engagement || ''} in this org. List them with \`quox engagement list\`, or leave out --engagement to create one.`);
|
|
92
|
+
if (status === 409 && code === 'already_escalated') {
|
|
93
|
+
const e = (err.body && err.body.escalation) || {};
|
|
94
|
+
throw new WazFail(`${base}: incident${id ? ` ${id}` : ''} is already escalated${e.finding_id ? ` as finding ${e.finding_id}` : ''}${e.engagement_id ? ` in engagement ${e.engagement_id}` : ''}.`);
|
|
95
|
+
}
|
|
96
|
+
if (status === 503 && code === 'store_unavailable') throw new WazFail(`${base}: the QuoxSecurity engagement store is not available on this collector.`);
|
|
97
|
+
if (status === 404 && /^hooks/.test(verb)) throw new WazFail(`${base}: no such hook${id ? ` (${id})` : ''} in this org. List them with \`quox wazuh hooks list\`.`);
|
|
98
|
+
if (status === 404) throw new WazFail(`${base}: no such incident${id ? ` (${id})` : ''} in this org. List them with \`quox wazuh incidents\`.`);
|
|
99
|
+
if (status === 409 && code === 'already_closed') throw new WazFail(`${base}: incident${id ? ` ${id}` : ''} is already closed.`);
|
|
100
|
+
throw failFrom(verb, ep.method, ep.path, err);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const errBody = (verb, ep, data, what) => new WazFail(`quox wazuh ${verb}: ${ep.method} ${ep.path} returned an error body: ${(data && (data.error || data.message)) || what}`);
|
|
104
|
+
const bad = (data) => !data || data.success === false || data.error;
|
|
105
|
+
|
|
106
|
+
// ---- hooks ----
|
|
107
|
+
const hooks = wazuh.command('hooks')
|
|
108
|
+
.description('The per-org signed hook Wazuh integratord posts alerts to (list, create, rotate, delete; org admin)');
|
|
109
|
+
|
|
110
|
+
example(hooks.command('list')
|
|
111
|
+
.description('This org\'s Wazuh alert hooks (never shows secrets)')
|
|
112
|
+
.action(guard(async () => {
|
|
113
|
+
auth.requireAuth();
|
|
114
|
+
const { ep, data } = await call('hooks list', 'mon.wazuh.hooks.list', {}, (e) => client.get(e.path, { service: e.service }));
|
|
115
|
+
if (bad(data) || !Array.isArray(data.hooks)) throw errBody('hooks list', ep, data, 'response had no hooks array');
|
|
116
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
117
|
+
head('hooks list', `${data.hooks.length} ${data.hooks.length === 1 ? 'hook' : 'hooks'}`);
|
|
118
|
+
if (!data.hooks.length) { console.log('No hooks yet. Create one: quox wazuh hooks create'); return; }
|
|
119
|
+
const rows = [['HOOK', 'LABEL', 'CREATED', 'ROTATED', 'LAST USED', 'PATH']];
|
|
120
|
+
for (const h of data.hooks) rows.push([h.hookId, h.label || '-', h.created_at || '-', h.rotated_at || '-', h.last_used_at || 'never', h.path || '-']);
|
|
121
|
+
console.log(pad(rows).join('\n'));
|
|
122
|
+
})), 'quox wazuh hooks list');
|
|
123
|
+
|
|
124
|
+
const printSecret = (data, verb) => {
|
|
125
|
+
console.log([
|
|
126
|
+
`URL path : ${data.path} (append to your Quox collector base URL)`,
|
|
127
|
+
`Secret : ${data.secret}`,
|
|
128
|
+
'',
|
|
129
|
+
'The secret is shown ONCE and is stored only as a hash. Copy it now.',
|
|
130
|
+
'',
|
|
131
|
+
`Set it up on the Wazuh manager (full steps and the integration script: ${RUNBOOK}):`,
|
|
132
|
+
' 1. Install /var/ossec/integrations/custom-quox (root:wazuh, 750) from the runbook.',
|
|
133
|
+
' 2. Add this block inside <ossec_config> in /var/ossec/etc/ossec.conf:',
|
|
134
|
+
'',
|
|
135
|
+
ossecBlock(data.path, data.secret).split('\n').map((l) => ` ${l}`).join('\n'),
|
|
136
|
+
'',
|
|
137
|
+
' 3. Restart the manager (systemctl restart wazuh-manager). Alerts at the chosen level and above',
|
|
138
|
+
` open incidents: quox wazuh incidents --open. Level 12+ also alert in the inbox; ${verb === 'rotate' ? 'the old secret stops working now, update <api_key>.' : 'the rest go in the daily digest.'}`,
|
|
139
|
+
].join('\n'));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
example(hooks.command('create')
|
|
143
|
+
.description('Create a Wazuh alert hook; the secret is shown ONCE (org admin)')
|
|
144
|
+
.option('--label <text>', 'a name for this hook (for example the manager name)')
|
|
145
|
+
.action(guard(async (options) => {
|
|
146
|
+
auth.requireAuth();
|
|
147
|
+
const { ep, data } = await call('hooks create', 'mon.wazuh.hooks.create', {}, (e) => client.post(e.path, { service: e.service, body: options.label ? { label: options.label } : {} }));
|
|
148
|
+
if (bad(data) || !data.hookId || !data.secret) throw errBody('hooks create', ep, data, 'response had no hook');
|
|
149
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
150
|
+
head('hooks create', data.hookId);
|
|
151
|
+
printSecret(data, 'create');
|
|
152
|
+
})), 'quox wazuh hooks create --label wz-mgr-01');
|
|
153
|
+
|
|
154
|
+
example(hooks.command('rotate')
|
|
155
|
+
.description('Issue a new secret for a hook, shown ONCE; the old secret stops working at once (org admin)')
|
|
156
|
+
.argument('<hookId>', 'hook id from `quox wazuh hooks list`')
|
|
157
|
+
.action(guard(async (hookId) => {
|
|
158
|
+
auth.requireAuth();
|
|
159
|
+
const { ep, data } = await call('hooks rotate', 'mon.wazuh.hooks.rotate', { id: hookId }, (e) => client.post(e.path, { service: e.service, body: {} }), hookId);
|
|
160
|
+
if (bad(data) || !data.secret) throw errBody('hooks rotate', ep, data, 'response had no secret');
|
|
161
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
162
|
+
head('hooks rotate', `${data.hookId || hookId} · old secret stops working now`, 'warn');
|
|
163
|
+
printSecret({ ...data, path: data.path || ep.path.replace(/\/rotate$/, '') }, 'rotate');
|
|
164
|
+
})), 'quox wazuh hooks rotate wh_0123456789abcdef');
|
|
165
|
+
|
|
166
|
+
example(hooks.command('delete')
|
|
167
|
+
.description('Delete a Wazuh alert hook; posts to it are refused from then on (org admin)')
|
|
168
|
+
.argument('<hookId>', 'hook id from `quox wazuh hooks list`')
|
|
169
|
+
.action(guard(async (hookId) => {
|
|
170
|
+
auth.requireAuth();
|
|
171
|
+
const { ep, data } = await call('hooks delete', 'mon.wazuh.hooks.delete', { id: hookId }, (e) => client.delete(e.path, { service: e.service }), hookId);
|
|
172
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
173
|
+
if (bad(data)) throw errBody('hooks delete', ep, data, 'no body');
|
|
174
|
+
head('hooks delete', `deleted ${hookId}`);
|
|
175
|
+
})), 'quox wazuh hooks delete wh_0123456789abcdef');
|
|
176
|
+
|
|
177
|
+
if (callTool) {
|
|
178
|
+
// WAZ-6: the governed one-step install. Validated here so a typo never files an approval; the collector re-validates.
|
|
179
|
+
example(hooks.command('install')
|
|
180
|
+
.description('Wire the Wazuh manager to Quox alert intake: hook, custom-quox integration, ossec.conf (backed up), restart (held for human approval; the secret is never shown)')
|
|
181
|
+
.requiredOption('--collector-url <url>', 'base URL the Wazuh manager reaches Quox on, http(s)://host[:port], no path')
|
|
182
|
+
.option('--level <n>', 'lowest Wazuh rule level sent (3-15)', '7')
|
|
183
|
+
.option('--allow-http', 'allow plain http to a private LAN IP (the hook secret then crosses the LAN unencrypted; shown on the approval card)')
|
|
184
|
+
.option('--label <label>', 'hook label; an existing hook with this label is rotated, not duplicated (default wazuh-<host>)')
|
|
185
|
+
.action(guard(async (options) => {
|
|
186
|
+
const verb = 'hooks install';
|
|
187
|
+
const raw = String(options.collectorUrl || '').trim();
|
|
188
|
+
let u = null;
|
|
189
|
+
try { u = new URL(raw); } catch { u = null; }
|
|
190
|
+
if (!u || !/^https?:$/.test(u.protocol) || u.username || u.password || raw.includes('@') || /[?#\s]/.test(raw) || (u.pathname !== '/' && u.pathname !== '')) {
|
|
191
|
+
throw new WazFail(`quox wazuh ${verb}: --collector-url must be an http(s) base URL with no credentials, path or query, for example https://quox.example:9848`);
|
|
192
|
+
}
|
|
193
|
+
const lv = String(options.level).trim();
|
|
194
|
+
if (!/^\d{1,2}$/.test(lv) || Number(lv) < 3 || Number(lv) > 15) throw new WazFail(`quox wazuh ${verb}: --level must be a whole number from 3 to 15`);
|
|
195
|
+
const input = { collector_url: raw.replace(/\/$/, ''), level: Number(lv) };
|
|
196
|
+
if (options.allowHttp) input.allow_http = true;
|
|
197
|
+
if (options.label) input.label = String(options.label);
|
|
198
|
+
auth.requireAuth();
|
|
199
|
+
const { data, pendingId } = await callTool(verb, 'wazuh_hooks_install', input);
|
|
200
|
+
if (jsonOut()) {
|
|
201
|
+
console.log(JSON.stringify(data, null, 2));
|
|
202
|
+
if (!pendingId) { try { failIfToolFailed(verb, 'wazuh_hooks_install', data); } catch { process.exitCode = 1; } }
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
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; }
|
|
206
|
+
const r = failIfToolFailed(verb, 'wazuh_hooks_install', data);
|
|
207
|
+
head(verb, `${r.hook_id} on ${r.host}`, 'ok');
|
|
208
|
+
console.log(pad([
|
|
209
|
+
['Hook', `${r.hook_id}${r.rotated ? ' (rotated: new secret)' : ' (new)'}`],
|
|
210
|
+
['Host', r.host],
|
|
211
|
+
['Level', `${r.level} and above`],
|
|
212
|
+
['Sends to', r.hook_url || '-'],
|
|
213
|
+
['Backup', r.backup || '-'],
|
|
214
|
+
['Manager', 'wazuh-manager restart requested'],
|
|
215
|
+
]).join('\n'));
|
|
216
|
+
console.log('Check it: quox wazuh hooks list (LAST USED fills in at the first alert), quox wazuh incidents --open');
|
|
217
|
+
})), 'quox wazuh hooks install --collector-url https://quox.example:9848 --level 7 --label wz-mgr-01');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- incidents ----
|
|
221
|
+
// WAZ P11: the governance incident a level >= notify-level Wazuh incident opened (shared lifecycle: status, assignee, tickets).
|
|
222
|
+
const governanceCell = (i) => (i.governance_incident_id
|
|
223
|
+
? `${i.governance_incident_id} (${i.governance_incident && i.governance_incident.status ? i.governance_incident.status : 'status unavailable'})`
|
|
224
|
+
: '-');
|
|
225
|
+
const ago = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? relDuration(Date.now() - t).replace(/ /g, '') : (iso || '-'); };
|
|
226
|
+
|
|
227
|
+
const incidents = example(wazuh.command('incidents')
|
|
228
|
+
.description('Wazuh alerts that became Quox incidents (one per rule and agent; repeats counted, not re-alerted)')
|
|
229
|
+
.option('--open', 'only incidents not yet closed')
|
|
230
|
+
.option('--since <when>', 'last seen since a duration (30m, 24h, 7d) or an ISO time')
|
|
231
|
+
.option('--limit <n>', 'max rows (default 50, max 200)')
|
|
232
|
+
.action(guard(async (options) => {
|
|
233
|
+
auth.requireAuth();
|
|
234
|
+
const { ep, data } = await call('incidents', 'mon.wazuh.incidents.list', { open: !!options.open, since: options.since, limit: options.limit }, (e) => client.get(e.path, { service: e.service }));
|
|
235
|
+
if (bad(data) || !Array.isArray(data.incidents)) throw errBody('incidents', ep, data, 'response had no incidents array');
|
|
236
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
237
|
+
const openN = data.incidents.filter((i) => i.open).length;
|
|
238
|
+
// WAZ-11: governance incidents still open/investigating after their Wazuh side went quiet (additive; the same
|
|
239
|
+
// section ARGUS's wazuh_incidents returns, so the CLI and chat count the same).
|
|
240
|
+
const q = data.quiet_still_open && typeof data.quiet_still_open === 'object' ? data.quiet_still_open : null;
|
|
241
|
+
const qRows = q && Array.isArray(q.incidents) ? q.incidents : [];
|
|
242
|
+
const qUnknown = !!(q && q.count === null);
|
|
243
|
+
const qN = qRows.length;
|
|
244
|
+
const qHead = qUnknown ? ' · gov-open: unknown' : (qN ? ` · ${qN} gov-open (quiet)` : '');
|
|
245
|
+
head('incidents', `${data.incidents.length} shown · ${openN} open${qHead}`, openN || qN || qUnknown ? 'warn' : 'ok');
|
|
246
|
+
const printQuiet = () => {
|
|
247
|
+
if (qUnknown) { console.log(`\nstill open in governance: unknown (${q.error || 'the collector could not read it'})`); return; }
|
|
248
|
+
if (!qN) return;
|
|
249
|
+
const qr = [['GOVERNANCE', 'STATUS', 'SEVERITY', 'ASSIGNED', 'WAZUH', 'CLOSED AS', 'CLOSED', 'AGENT', 'RULE']];
|
|
250
|
+
for (const g of qRows) {
|
|
251
|
+
qr.push([g.id, g.status, g.severity, g.assigned_to || '-', g.wazuh_incident_id || '-', g.wazuh_close_reason || '-', ago(g.wazuh_closed_at),
|
|
252
|
+
g.agent_name || g.agent_id || '-', g.rule_id || '-']);
|
|
253
|
+
}
|
|
254
|
+
console.log(`\n${q.label || 'Wazuh went quiet; still open in governance'} (${qN}):`);
|
|
255
|
+
console.log(pad(qr).join('\n'));
|
|
256
|
+
console.log('Resolve or claim: quox monitoring incidents claim <id> | quox monitoring incidents status <id> resolved --resolution "<what was done>"');
|
|
257
|
+
};
|
|
258
|
+
if (!data.incidents.length) { printQuiet(); return; }
|
|
259
|
+
// WAZ P7: CONTEXT (MAINT / KUMA-DOWN / DEPLOYED, SRC? when a source could not answer) when the collector sent it.
|
|
260
|
+
const withCtx = data.incidents.some((i) => i.context);
|
|
261
|
+
// WAZ P11: GOVERNANCE (the linked governance incident id + its status) when any incident is linked.
|
|
262
|
+
const withGov = data.incidents.some((i) => i.governance_incident_id);
|
|
263
|
+
// WAZ P11 1c: VERDICT (ARGUS's rule-based first pass: noise / fix_needed / security_event) when any incident has one.
|
|
264
|
+
const withVerdict = data.incidents.some((i) => i.triage && i.triage.verdict);
|
|
265
|
+
const rows = [['ID', 'STATE', 'LEVEL', 'AGENT', 'RULE', 'DESCRIPTION', 'COUNT', 'LAST SEEN', 'MITRE', ...(withCtx ? ['CONTEXT'] : []), ...(withGov ? ['GOVERNANCE'] : []), ...(withVerdict ? ['VERDICT'] : [])]];
|
|
266
|
+
for (const i of data.incidents) {
|
|
267
|
+
rows.push([i.id, i.open ? 'open' : `closed${i.close_reason === 'quiet' ? ' (quiet)' : ''}`, i.level, i.agent_name || i.agent_id, i.rule_id,
|
|
268
|
+
String(i.rule_description || '-').slice(0, 60), i.count ?? '-', ago(i.last_seen), (i.mitre_ids || []).join(',') || '-', ...(withCtx ? [contextFlags(i.context)] : []),
|
|
269
|
+
...(withGov ? [governanceCell(i)] : []), ...(withVerdict ? [(i.triage && i.triage.verdict) || '-'] : [])]);
|
|
270
|
+
}
|
|
271
|
+
console.log(pad(rows).join('\n'));
|
|
272
|
+
printQuiet();
|
|
273
|
+
})), 'quox wazuh incidents --open --since 24h');
|
|
274
|
+
|
|
275
|
+
example(incidents.command('show')
|
|
276
|
+
.description('One Wazuh incident, including its stored (truncated) log line')
|
|
277
|
+
.argument('<id>', 'incident id from `quox wazuh incidents`')
|
|
278
|
+
.action(guard(async (id) => {
|
|
279
|
+
auth.requireAuth();
|
|
280
|
+
const { ep, data } = await call('incidents show', 'mon.wazuh.incidents.get', { id }, (e) => client.get(e.path, { service: e.service }), id);
|
|
281
|
+
if (bad(data) || !data.incident) throw errBody('incidents show', ep, data, 'response had no incident');
|
|
282
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
283
|
+
const i = data.incident;
|
|
284
|
+
head('incidents show', `${i.id} · ${i.open ? 'open' : 'closed'}`, i.open ? 'warn' : 'ok');
|
|
285
|
+
const rows = [
|
|
286
|
+
['Rule', `${i.rule_id} (level ${i.level}) ${i.rule_description || ''}`],
|
|
287
|
+
['Agent', `${i.agent_name || '-'} (agent ${i.agent_id}${i.agent_ip ? `, ${i.agent_ip}` : ''})`],
|
|
288
|
+
['Groups', (i.rule_groups || []).join(', ') || '-'],
|
|
289
|
+
['MITRE', [...(i.mitre_ids || []), ...(i.mitre_techniques || []), ...(i.mitre_tactics || [])].join(', ') || '-'],
|
|
290
|
+
['Location', i.location || '-'],
|
|
291
|
+
['Seen', `${i.count} time(s), first ${i.first_seen}, last ${i.last_seen}`],
|
|
292
|
+
['Alerted', i.notified ? 'yes (inbox)' : (i.suppressed && i.suppressed.reason === 'maintenance'
|
|
293
|
+
? `no: suppressed, host was in a Kuma maintenance window (${(i.suppressed.monitors || []).join(', ') || 'monitor'})`
|
|
294
|
+
: 'no (digest only)')],
|
|
295
|
+
['Log', i.log_snippet || '-'],
|
|
296
|
+
['Governance', governanceCell(i)],
|
|
297
|
+
['Verdict', i.triage && i.triage.verdict ? `${i.triage.verdict} (ARGUS rule-based first pass, no model)` : 'not triaged (set when a governance incident is opened)'],
|
|
298
|
+
...(i.triage && Array.isArray(i.triage.reasons) && i.triage.reasons.length ? [['Why', i.triage.reasons.join('; ')]] : []),
|
|
299
|
+
['Proposal', proposalCell(i.proposal)],
|
|
300
|
+
...contextRows(i.context),
|
|
301
|
+
];
|
|
302
|
+
const dg = i.diagnosis;
|
|
303
|
+
rows.push(['Diagnosis', dg ? `${dg.status} (${dg.trigger || dg.mode || 'manual'}) ${dg.summary || ''}`.trim() : `not run (quox wazuh incidents diagnose ${i.id})`]);
|
|
304
|
+
const es = i.escalation;
|
|
305
|
+
rows.push(['Escalated', es && es.finding_id ? `finding ${es.finding_id} in engagement ${es.engagement_id}${es.engagement_name ? ` (${es.engagement_name})` : ''}` : `no (quox wazuh incidents escalate ${i.id})`]);
|
|
306
|
+
if (!i.open) rows.push(['Closed', `${i.closed_at}${i.closed_by ? ` by ${i.closed_by}` : ''}${i.close_reason ? `: ${i.close_reason}` : ''}`]);
|
|
307
|
+
console.log(pad(rows).join('\n'));
|
|
308
|
+
if (dg) printChecks(dg, pad);
|
|
309
|
+
})), 'quox wazuh incidents show WINC_abc');
|
|
310
|
+
|
|
311
|
+
example(incidents.command('close')
|
|
312
|
+
.description('Close a Wazuh incident with what you found (witnessed)')
|
|
313
|
+
.argument('<id>', 'incident id from `quox wazuh incidents`')
|
|
314
|
+
.requiredOption('--reason <text>', 'what you found, for the record')
|
|
315
|
+
.action(guard(async (id, options) => {
|
|
316
|
+
auth.requireAuth();
|
|
317
|
+
const { ep, data } = await call('incidents close', 'mon.wazuh.incidents.close', { id }, (e) => client.post(e.path, { service: e.service, body: { reason: String(options.reason) } }), id);
|
|
318
|
+
if (bad(data) || !data.incident) throw errBody('incidents close', ep, data, 'response had no incident');
|
|
319
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
320
|
+
head('incidents close', `closed ${data.incident.id || id}`);
|
|
321
|
+
})), 'quox wazuh incidents close WINC_abc --reason "known scanner, blocked at edge-02"');
|
|
322
|
+
|
|
323
|
+
// ---- WAZ P7: diagnose + escalate ----
|
|
324
|
+
example(incidents.command('diagnose')
|
|
325
|
+
.description('Read-only host diagnosis for an incident (auth failures, sessions, ports, processes, logins, the unit): allowlisted reads run, the rest are held for a human approval')
|
|
326
|
+
.argument('<id>', 'incident id from `quox wazuh incidents`')
|
|
327
|
+
.action(guard(async (id) => {
|
|
328
|
+
auth.requireAuth();
|
|
329
|
+
const { ep, data } = await call('incidents diagnose', 'mon.wazuh.incidents.diagnose', { id }, (e) => client.post(e.path, { service: e.service, body: {} }), id);
|
|
330
|
+
if (bad(data) || !data.incident || !data.incident.diagnosis) throw errBody('incidents diagnose', ep, data, 'response had no diagnosis');
|
|
331
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
332
|
+
const d = data.incident.diagnosis;
|
|
333
|
+
head('incidents diagnose', `${id} · ${d.status}${d.host ? ` on ${d.host}` : ''}`, d.status === 'ran' ? 'ok' : (d.status === 'refused' || d.status === 'unavailable' ? 'fail' : 'warn'));
|
|
334
|
+
console.log(d.summary || '-');
|
|
335
|
+
printChecks(d, pad);
|
|
336
|
+
const held = (d.checks || []).filter((c) => c.approval_id);
|
|
337
|
+
if (held.length) {
|
|
338
|
+
console.log('');
|
|
339
|
+
for (const c of held) console.log(`◐ ${c.name} is held for approval ${c.approval_id}: a person approves it in the inbox; read the output with quox approvals result ${c.approval_id}`);
|
|
340
|
+
}
|
|
341
|
+
})), 'quox wazuh incidents diagnose WINC_abc');
|
|
342
|
+
|
|
343
|
+
example(incidents.command('escalate')
|
|
344
|
+
.description('File the incident as a finding in a QuoxSecurity engagement (a new wazuh-incident-<id> engagement unless --engagement is given); the finding id is linked on the incident')
|
|
345
|
+
.argument('<id>', 'incident id from `quox wazuh incidents`')
|
|
346
|
+
.option('--engagement <idOrName>', 'an existing engagement of this org (id or exact name)')
|
|
347
|
+
.action(guard(async (id, options) => {
|
|
348
|
+
auth.requireAuth();
|
|
349
|
+
const body = options.engagement ? { engagement: String(options.engagement) } : {};
|
|
350
|
+
const { ep, data } = await call('incidents escalate', 'mon.wazuh.incidents.escalate', { id }, (e) => client.post(e.path, { service: e.service, body }), id, { engagement: options.engagement });
|
|
351
|
+
if (bad(data) || !data.finding || !data.engagement) throw errBody('incidents escalate', ep, data, 'response had no finding');
|
|
352
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
353
|
+
const f = data.finding;
|
|
354
|
+
head('incidents escalate', `finding ${f.id} filed`);
|
|
355
|
+
console.log(`Filed finding ${f.id} (${f.severity}, ${f.status}) in ${data.created_engagement ? 'new ' : ''}engagement ${data.engagement.id}.`);
|
|
356
|
+
console.log(`Engagement: ${data.engagement.name || data.engagement.id}. Next: quox engagement findings ${data.engagement.id}; verify it: quox engagement verify-finding ${f.id} confirmed|refuted --reason "..."`);
|
|
357
|
+
})), 'quox wazuh incidents escalate WINC_abc --engagement soc-week-45');
|
|
358
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quox wazuh report ... : signed, offline-verifiable Wazuh security posture reports (WAZ P5).
|
|
3
|
+
* The sibling of `quox kuma sla` (kumaSla.js): same canonical form, same signer, same bundle shape.
|
|
4
|
+
*
|
|
5
|
+
* quox wazuh report generate [--agent <id|name>] [--out <file>] build + sign + store a posture report
|
|
6
|
+
* quox wazuh report list this org's stored reports
|
|
7
|
+
* quox wazuh report show <id> [--out <file>] one stored report (and its signed bundle)
|
|
8
|
+
* quox wazuh report verify <id|file> [--public-key <pem|base64|file>]
|
|
9
|
+
*
|
|
10
|
+
* `verify <id>` asks the collector, which checks against its pinned WARD key (trust anchored).
|
|
11
|
+
* `verify <file>` is fully OFFLINE: it recomputes sha256 over the canonical report and checks the Ed25519
|
|
12
|
+
* signature with node:crypto. Against the key embedded in the file this proves integrity only (anyone can
|
|
13
|
+
* mint a keypair); pass --public-key (a PEM, a PEM file, or the raw 32 byte base64 WARD key) to pin the signer.
|
|
14
|
+
*
|
|
15
|
+
* Exit codes: 0 ok / verified, 1 error, 2 verification FAILED.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
20
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
21
|
+
import { canonicalize } from './kumaSla.js';
|
|
22
|
+
|
|
23
|
+
const KIND = 'quox.wazuh.posture.report';
|
|
24
|
+
const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
25
|
+
|
|
26
|
+
/** A public key from a PEM string, a path to a PEM file, or a raw 32 byte base64 Ed25519 key. null if none of those. */
|
|
27
|
+
export function parsePublicKey(value) {
|
|
28
|
+
if (!value) return null;
|
|
29
|
+
let text = String(value);
|
|
30
|
+
try { if (!/BEGIN PUBLIC KEY/.test(text) && existsSync(text)) text = readFileSync(text, 'utf-8'); } catch { /* not a file */ }
|
|
31
|
+
try {
|
|
32
|
+
if (/BEGIN PUBLIC KEY/.test(text)) {
|
|
33
|
+
const k = createPublicKey(text);
|
|
34
|
+
return k.asymmetricKeyType === 'ed25519' ? k : null;
|
|
35
|
+
}
|
|
36
|
+
const raw = Buffer.from(text.trim(), 'base64');
|
|
37
|
+
if (raw.length !== 32) return null;
|
|
38
|
+
return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
|
|
39
|
+
} catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Offline verification of an export bundle. Mirrors services/collector/lib/wazuhPostureReports.js verifyBundle. */
|
|
43
|
+
export function verifyBundleOffline(bundle, { publicKey } = {}) {
|
|
44
|
+
const out = { valid: false, hash_ok: false, binding_ok: false, signature_ok: false, key_source: 'none', trust_anchored: false };
|
|
45
|
+
if (!bundle || typeof bundle !== 'object' || !bundle.report || typeof bundle.report !== 'object') return { ...out, reason: 'malformed_bundle' };
|
|
46
|
+
const sig = bundle.signature;
|
|
47
|
+
if (!sig || !sig.signature || !sig.message) return { ...out, reason: 'missing_signature' };
|
|
48
|
+
const recomputed = createHash('sha256').update(canonicalize(bundle.report), 'utf-8').digest('hex');
|
|
49
|
+
out.recomputed_sha256 = recomputed;
|
|
50
|
+
out.hash_ok = recomputed === bundle.report_sha256;
|
|
51
|
+
if (!out.hash_ok) return { ...out, reason: 'report_hash_mismatch' };
|
|
52
|
+
const r = bundle.report;
|
|
53
|
+
const expected = { kind: KIND, report_id: bundle.report_id, org_id: r.org_id, scope: r.scope, generated_at: r.generated_at, report_sha256: recomputed };
|
|
54
|
+
out.binding_ok = canonicalize(expected) === canonicalize(sig.message);
|
|
55
|
+
if (!out.binding_ok) return { ...out, reason: 'signature_not_bound_to_report' };
|
|
56
|
+
if (sig.sig_type !== 'ed25519') return { ...out, reason: `unverifiable_sig_type:${sig.sig_type}` };
|
|
57
|
+
let key = null; let source = 'none';
|
|
58
|
+
if (publicKey) { key = parsePublicKey(publicKey); source = 'pinned'; if (!key) return { ...out, reason: 'bad_public_key' }; }
|
|
59
|
+
else { key = parsePublicKey(sig.public_key); source = 'embedded'; }
|
|
60
|
+
if (!key) return { ...out, reason: 'no_verification_key' };
|
|
61
|
+
out.key_source = source;
|
|
62
|
+
try {
|
|
63
|
+
out.signature_ok = cryptoVerify(null, Buffer.from(canonicalize(sig.message), 'utf-8'), key, Buffer.from(String(sig.signature), 'base64'));
|
|
64
|
+
} catch (err) { return { ...out, reason: `verify_error:${err.message}` }; }
|
|
65
|
+
if (!out.signature_ok) return { ...out, reason: 'signature_mismatch' };
|
|
66
|
+
out.valid = true;
|
|
67
|
+
out.trust_anchored = source === 'pinned' && !sig.warning;
|
|
68
|
+
if (sig.warning) out.warning = sig.warning;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const bundleOf = (d) => ({ report_id: d.report_id, report: d.report, report_sha256: d.report_sha256, signature: d.signature });
|
|
73
|
+
|
|
74
|
+
/** Human summary of a report: one line per section with its source status. Unavailable is said, never shown as zero. */
|
|
75
|
+
function printReport(d, pad) {
|
|
76
|
+
const r = d.report || {};
|
|
77
|
+
const s = r.sections || {};
|
|
78
|
+
console.log(`Report ${d.report_id} sha256 ${d.report_sha256}`);
|
|
79
|
+
console.log(`Org ${r.org_id} scope ${r.scope} manager ${r.wazuh_manager || 'unknown'} generated ${r.generated_at}`);
|
|
80
|
+
const st = (x) => (x ? x.status : 'missing');
|
|
81
|
+
const rows = [['SECTION', 'SOURCE', 'SUMMARY']];
|
|
82
|
+
const m = s.manager;
|
|
83
|
+
if (m) rows.push(['manager', st(m), m.status === 'unavailable' ? m.error : `${m.running} daemons running${m.stopped && m.stopped.length ? `, stopped: ${m.stopped.join(', ')}` : ''}; agents ${m.agent_counts ? `${m.agent_counts.active ?? '?'} active of ${m.agent_counts.total ?? '?'}` : 'unknown'}`]);
|
|
84
|
+
const c = s.coverage;
|
|
85
|
+
if (c) rows.push(['coverage', st(c), c.status === 'unavailable' ? c.error : `${c.summary.covered ?? 'unknown'} of ${c.summary.fleet_hosts ?? 'unknown'} fleet hosts covered, ${c.summary.missing_agent ?? 'unknown'} missing an agent, ${c.summary.disconnected} disconnected`]);
|
|
86
|
+
const v = s.vulnerabilities;
|
|
87
|
+
if (v) rows.push(['vulnerabilities', st(v), v.status === 'unavailable' ? v.error : `${v.summary.cves} CVEs, ${v.summary.in_kev} in CISA KEV, top ${v.items.length} listed`]);
|
|
88
|
+
const t = s.alert_triage;
|
|
89
|
+
if (t) rows.push(['alert triage', st(t), t.status === 'unavailable' ? t.error : `${t.total_alerts} alerts level ${t.min_level}+ in ${t.window}, ${t.groups.length} rule groups`]);
|
|
90
|
+
const sca = s.sca;
|
|
91
|
+
if (sca) rows.push(['sca', st(sca), sca.status === 'unavailable' ? sca.error : `${sca.policies.length} policies on agent ${sca.agent_id}`]);
|
|
92
|
+
console.log(`\n${pad(rows).join('\n')}`);
|
|
93
|
+
if (v && v.status !== 'unavailable' && v.items.length) {
|
|
94
|
+
const vr = [['CVE', 'KEV', 'EPSS', 'CVSS', 'HOSTS']];
|
|
95
|
+
for (const it of v.items) vr.push([it.cve, it.kev ? (it.kev_ransomware ? 'yes (ransomware)' : 'yes') : 'no', typeof it.epss === 'number' ? `${(it.epss * 100).toFixed(1)}%` : '-', it.cvss ?? '-', it.agents_affected ?? '-']);
|
|
96
|
+
console.log(`\nTop CVEs\n${pad(vr).join('\n')}`);
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(r.not_included) && r.not_included.length) console.log(`\nNot included: ${r.not_included.map((n) => `${n.section} (${n.reason})`).join('; ')}`);
|
|
99
|
+
if (Array.isArray(r.cannot_establish) && r.cannot_establish.length) console.log(`Cannot establish:\n${r.cannot_establish.map((x) => ` - ${x}`).join('\n')}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function registerWazuhReport(wazuh, { client, auth, jsonOut, guard, WazFail, pad, head, failFrom, example }) {
|
|
103
|
+
const call = async (verb, key, params, fn) => {
|
|
104
|
+
const ep = resolveEndpoint(key, params);
|
|
105
|
+
try { return { ep, data: await fn(ep) }; } catch (err) { throw failFrom(verb, ep.method, ep.path, err); }
|
|
106
|
+
};
|
|
107
|
+
const writeOut = (file, d) => writeFileSync(file, `${JSON.stringify(bundleOf(d), null, 2)}\n`);
|
|
108
|
+
|
|
109
|
+
const report = wazuh.command('report')
|
|
110
|
+
.description('Signed, offline-verifiable Wazuh security posture report: generate, list, show, verify')
|
|
111
|
+
.addHelpText('after', '\nExit codes: 0 ok, 1 error, 2 `report verify` found the report does NOT verify.');
|
|
112
|
+
|
|
113
|
+
example(report.command('generate')
|
|
114
|
+
.description('Build, sign and store a posture report (manager, coverage, CVE priority, 24h alert triage; SCA with --agent)')
|
|
115
|
+
.option('--agent <id|name>', 'scope the vulnerabilities (and add SCA) to one agent')
|
|
116
|
+
.option('--out <file>', 'also write the signed bundle here for offline verification')
|
|
117
|
+
.action(guard(async (options) => {
|
|
118
|
+
auth.requireAuth();
|
|
119
|
+
const body = {};
|
|
120
|
+
if (options.agent) body.agent = String(options.agent);
|
|
121
|
+
const { ep, data } = await call('report generate', 'mon.wazuh.reports.create', {}, (e) => client.post(e.path, { service: e.service, body, timeout: 180000, retries: 0 }));
|
|
122
|
+
if (!data || data.success === false || data.error || !data.report || !data.signature) {
|
|
123
|
+
throw new WazFail(`quox wazuh report generate: ${ep.method} ${ep.path} returned an error body: ${(data && (data.error || data.message)) || 'response had no report'}`);
|
|
124
|
+
}
|
|
125
|
+
if (options.out) writeOut(options.out, data);
|
|
126
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
127
|
+
const un = data.report.sections_unavailable || [];
|
|
128
|
+
head('report generate', `${data.report.scope}${un.length ? ` · ${un.length} section${un.length === 1 ? '' : 's'} unavailable` : ''}`, un.length || !data.trust_anchored ? 'warn' : 'ok');
|
|
129
|
+
printReport(data, pad);
|
|
130
|
+
console.log(data.trust_anchored ? '\nSigned with the WARD key and witnessed.' : '\nWARNING: signed with an ephemeral key (no persistent WARD key on this collector): integrity only, not trust anchored.');
|
|
131
|
+
console.log(`Verify: quox wazuh report verify ${options.out || data.report_id}`);
|
|
132
|
+
})), 'quox wazuh report generate --out posture.json');
|
|
133
|
+
|
|
134
|
+
example(report.command('list')
|
|
135
|
+
.description('This org\'s stored posture reports')
|
|
136
|
+
.action(guard(async () => {
|
|
137
|
+
auth.requireAuth();
|
|
138
|
+
const { ep, data } = await call('report list', 'mon.wazuh.reports.list', {}, (e) => client.get(e.path, { service: e.service }));
|
|
139
|
+
if (!data || data.success === false || data.error || !Array.isArray(data.reports)) {
|
|
140
|
+
throw new WazFail(`quox wazuh report list: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no reports array'}`);
|
|
141
|
+
}
|
|
142
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
143
|
+
head('report list', `${data.reports.length} ${data.reports.length === 1 ? 'report' : 'reports'}`);
|
|
144
|
+
if (!data.reports.length) { console.log('No reports yet. Generate one: quox wazuh report generate'); return; }
|
|
145
|
+
const rows = [['REPORT', 'SCOPE', 'CREATED', 'SHA256']];
|
|
146
|
+
for (const r of data.reports) rows.push([r.report_id, r.scope, r.created_at, String(r.report_sha256).slice(0, 16)]);
|
|
147
|
+
console.log(pad(rows).join('\n'));
|
|
148
|
+
})), 'quox wazuh report list');
|
|
149
|
+
|
|
150
|
+
example(report.command('show')
|
|
151
|
+
.description('One stored posture report')
|
|
152
|
+
.argument('<id>', 'a report id from `quox wazuh report list`')
|
|
153
|
+
.option('--out <file>', 'write the signed bundle here for offline verification')
|
|
154
|
+
.action(guard(async (id, options) => {
|
|
155
|
+
auth.requireAuth();
|
|
156
|
+
const { ep, data } = await call('report show', 'mon.wazuh.reports.get', { id }, (e) => client.get(e.path, { service: e.service }));
|
|
157
|
+
if (!data || data.success === false || data.error || !data.report || !data.signature) {
|
|
158
|
+
throw new WazFail(`quox wazuh report show: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no report'}`);
|
|
159
|
+
}
|
|
160
|
+
if (options.out) writeOut(options.out, data);
|
|
161
|
+
if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
162
|
+
head('report show', data.report.scope);
|
|
163
|
+
printReport(data, pad);
|
|
164
|
+
})), 'quox wazuh report show WPR_ABC --out posture.json');
|
|
165
|
+
|
|
166
|
+
example(report.command('verify')
|
|
167
|
+
.description('Recompute the report hash and check the signature (id: via the collector, pinned WARD key; file: fully offline)')
|
|
168
|
+
.argument('<id|file>', 'a report id, or a bundle file written by `report generate --out` / `report show --out`')
|
|
169
|
+
.option('--public-key <pem|base64|file>', 'offline: pin this Ed25519 WARD public key (PEM, PEM file, or raw 32 byte base64) instead of trusting the key in the file')
|
|
170
|
+
.action(guard(async (target, options) => {
|
|
171
|
+
let result;
|
|
172
|
+
if (existsSync(target)) {
|
|
173
|
+
let bundle;
|
|
174
|
+
try { bundle = JSON.parse(readFileSync(target, 'utf-8')); } catch (err) { throw new WazFail(`quox wazuh report verify: ${target} is not valid JSON: ${err.message}`); }
|
|
175
|
+
result = { report_id: bundle && bundle.report_id, mode: 'offline', ...verifyBundleOffline(bundle, { publicKey: options.publicKey }) };
|
|
176
|
+
} else {
|
|
177
|
+
auth.requireAuth();
|
|
178
|
+
const { ep, data } = await call('report verify', 'mon.wazuh.reports.verify', { id: target }, (e) => client.get(e.path, { service: e.service }));
|
|
179
|
+
if (!data || data.success === false || data.error || typeof data.valid !== 'boolean') {
|
|
180
|
+
throw new WazFail(`quox wazuh report verify: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no verdict'}`);
|
|
181
|
+
}
|
|
182
|
+
result = { mode: 'collector', ...data };
|
|
183
|
+
}
|
|
184
|
+
if (jsonOut()) { console.log(JSON.stringify(result, null, 2)); } else {
|
|
185
|
+
head('report verify', result.valid ? `verified (${result.mode})` : `NOT verified: ${result.reason}`, result.valid ? 'ok' : 'fail');
|
|
186
|
+
console.log(`hash ${result.hash_ok ? 'matches' : 'DOES NOT match'} binding ${result.binding_ok ? 'ok' : 'FAILED'} signature ${result.signature_ok ? 'valid' : 'INVALID'} key ${result.key_source}${result.trust_anchored ? ' (trust anchored)' : ' (not trust anchored)'}`);
|
|
187
|
+
if (result.valid && !result.trust_anchored) console.log('Integrity holds, but the signer was not checked against the WARD key. Pin it with --public-key, or verify by id through the collector.');
|
|
188
|
+
if (result.warning) console.log(`Warning: ${result.warning}`);
|
|
189
|
+
}
|
|
190
|
+
if (!result.valid) process.exitCode = 2;
|
|
191
|
+
})), 'quox wazuh report verify posture.json --public-key ward-pub.pem');
|
|
192
|
+
}
|