@sagentix-inc/evidence-collector 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -5
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/apps/sos-app/src/data/checks/configmgr.json +45 -0
- package/apps/sos-app/src/data/checks/endpoint_central.json +45 -0
- package/apps/sos-app/src/data/checks/google_endpoints.json +39 -0
- package/apps/sos-app/src/data/checks/ivanti_mdm.json +51 -0
- package/apps/sos-app/src/data/checks/ninjaone.json +39 -0
- package/apps/sos-app/src/data/checks/workspace_one.json +45 -0
- package/collector/package.json +1 -1
- package/collector/src/agent.js +312 -0
- package/collector/src/channel.js +220 -0
- package/collector/src/cli.js +160 -89
- package/collector/src/release_keys.js +9 -0
- package/collector/src/report.js +300 -0
- package/collector/src/sources/azure/checks.js +4 -2
- package/collector/src/sources/configmgr.js +286 -0
- package/collector/src/sources/device_sources.js +11 -1
- package/collector/src/sources/endpoint_central.js +345 -0
- package/collector/src/sources/gcp/api.js +38 -2
- package/collector/src/sources/gcp.js +6 -4
- package/collector/src/sources/google_endpoints.js +171 -0
- package/collector/src/sources/ivanti_mdm.js +276 -0
- package/collector/src/sources/ninjaone.js +271 -0
- package/collector/src/sources/oauth.js +4 -2
- package/collector/src/sources/workspace_one.js +226 -0
- package/package.json +6 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Ivanti Neurons for MDM (formerly MobileIron Cloud), read through its Customer Integration API with GET only, as a
|
|
2
|
+
// dedicated admin user that holds the Device Read Only role and nothing more. No token request is made: the API
|
|
3
|
+
// takes HTTP Basic authentication on every call.
|
|
4
|
+
//
|
|
5
|
+
// Pages read 2026-10-28 (help.ivanti.com, through r.jina.ai because the Mac's direct requests met a Cloudflare block):
|
|
6
|
+
// Customer Integration API Guide, MDMCustomerIntegrationAPIGuide/ (revision history last entry 2026-10-15):
|
|
7
|
+
// "MobileIron Cloud API Basics.htm": Basic Authentication on every call; when the admin portal requires a password
|
|
8
|
+
// and PIN (MFA), Basic does not work and Token-Based Authentication must be used. Rows: at most 500 per page
|
|
9
|
+
// (rows=, start=); device searches page efficiently with sortFields[0].name=id&sortFields[0].order=ASC and
|
|
10
|
+
// searchAfterValues[0]=<last id>, and start must not be used at 10,000 or more. 401 means credentials missing or
|
|
11
|
+
// wrong, 403 "a resource that does not belong to you". The device space id ("dmPartitionId", spaces were once
|
|
12
|
+
// called partitions) is needed for device calls; the default one is defaultDmPartitionId in Get Tenant Metadata.
|
|
13
|
+
// A GDPR profile on the API user obfuscates fields such as the serial number.
|
|
14
|
+
// default.htm: integrations must handle 429 (rate limited) and 503 and retry later. No numeric limit is published.
|
|
15
|
+
// "Device API Calls.htm": Get Device Details, GET /api/v1/device/{id} -> { errors, result: { id, platformType,
|
|
16
|
+
// platformVersion, lastCheckin ("Last client checkin"), registrationState, serialNumber, complianceState,
|
|
17
|
+
// passcodeCompliant (iOS only, null elsewhere), passcodeCompliantWithProfiles ("compliant with EMM profiles",
|
|
18
|
+
// iOS and Android only), osxFdeEnabled, osxFirewallSettings, osxSystemIntegrityProtectionEnabled, ... } };
|
|
19
|
+
// "Get Device without Signature Parameter", GET /api/v1/device -> { errors, result: { searchResults: [ ... ] } }
|
|
20
|
+
// with bitLockerEncryptionStatus and an epoch-millisecond lastCheckin in its example. Both say "This call
|
|
21
|
+
// requires Tenant Admin credentials"; "Key Concepts.htm" defines Tenant Admin as the API role "used for all other
|
|
22
|
+
// API calls" (System Admin being for tenant creation and licensing).
|
|
23
|
+
// "Token-Based Authentication.htm": POST /oauth2/token with an OIDC client's id and secret in a Basic header. Not
|
|
24
|
+
// used here: oauth.ts sends the client secret in the form body only.
|
|
25
|
+
// OpenAPI file https://help.ivanti.com/mi/help/en_us/CLD/8x/api/test.json (info.version NA2), linked from
|
|
26
|
+
// "Test the APIs.htm": GET /api/v1/device (dmPartitionId required; rows, start, sortFields[0].name/order,
|
|
27
|
+
// searchAfterValues), GET /api/v1/device/{id} (DeviceDetailView), GET /api/v1/metadata/tenant ("safe for
|
|
28
|
+
// consumption by any authenticated user"; defaultDmPartitionId). Types: complianceState, osxFdeEnabled,
|
|
29
|
+
// osxSystemIntegrityProtectionEnabled, passcodeCompliant(WithProfiles) boolean; osxFirewallSettings.firewallEnabled
|
|
30
|
+
// boolean; bitLockerEncryptionStatus NOT_ENCRYPTED | FULLY_ENCRYPTED | PARTIALLY_ENCRYPTED | UNKNOWN; platformType
|
|
31
|
+
// ANDROID | BLACKBERRY | IOS | OSX | WINDOWS_PHONE | CHROME_OS | UNKNOWN; registrationState ACTIVE, RETIRE_*, WIPE_*,
|
|
32
|
+
// PROVISIONING, RELINQ_*; lastCheckin "date-time" (the guide's example is epoch milliseconds, so both are read).
|
|
33
|
+
// Administrator Guide 128 (help.ivanti.com/mi/help/en_US/CLD/admin/ivanti/128/all/en-us/): User_Roles.htm, "Device
|
|
34
|
+
// Read Only: Allows an administrator to view device groups, configurations and policies" (space-specific);
|
|
35
|
+
// Assigning_Roles.htm (Users > Users > Actions > Assign Roles); Roles_Management.htm (a custom space-specific role
|
|
36
|
+
// may hold only the "View Device" permission).
|
|
37
|
+
//
|
|
38
|
+
// [Unverified] The guide does not say which admin role GET /api/v1/device and GET /api/v1/device/{id} accept beyond
|
|
39
|
+
// "Tenant Admin credentials"; it names Device Read Only only for other device GETs (custom attributes, certificates).
|
|
40
|
+
// The collector only ever sends GET, so a role that falls short gives HTTP 403, reported as skipped.
|
|
41
|
+
// [Unverified] The platformType a Windows 10/11 desktop reports is not in the enum, so Windows encryption is read from
|
|
42
|
+
// bitLockerEncryptionStatus wherever a device reports one, whatever its platformType.
|
|
43
|
+
//
|
|
44
|
+
// Left out: Windows firewall (the example's firewallStatus -1 has no documented meaning), pending OS updates
|
|
45
|
+
// (availableOSUpdates is a bare string list with no description), and the idle time before lock (no field).
|
|
46
|
+
//
|
|
47
|
+
// Personal data: a device is named by its Ivanti id and serial number. deviceName, displayName, firstName, lastName,
|
|
48
|
+
// uid, emailAddress, phone numbers, IMEI, ICCID, location and IP addresses are never read into a result.
|
|
49
|
+
import { Emitter, enc, fetchRest, isObj, ok, realSleep, redact, str, withRetry, listRead, stampRead } from "./readonly_http.js";
|
|
50
|
+
import { ageDays, cmpVersion, deviceResource, minOsOption, posInt, showVersion, versionIn } from "./device_common.js";
|
|
51
|
+
export const IVANTI_MDM_ENV = { url: "IVANTI_MDM_URL", user: "IVANTI_MDM_USERNAME", password: "IVANTI_MDM_PASSWORD" };
|
|
52
|
+
export const IVANTI_MDM_CHECKS = ["device_compliant", "disk_encryption", "os_minimum", "last_checkin", "passcode_compliant", "firewall", "system_integrity"];
|
|
53
|
+
export const DEFAULTS = { checkinDays: 14, maxDevices: 5000 };
|
|
54
|
+
/** The documented ceiling for rows. */
|
|
55
|
+
export const PAGE = 500;
|
|
56
|
+
const SORT = "sortFields%5B0%5D.name=id&sortFields%5B0%5D.order=ASC";
|
|
57
|
+
export const metadataPath = "/metadata/tenant";
|
|
58
|
+
export const devicesPath = (space, after) => `/device?q=&rows=${PAGE}&dmPartitionId=${enc(space)}&${SORT}&nav=true${after === undefined ? "" : `&searchAfterValues%5B0%5D=${enc(after)}`}`;
|
|
59
|
+
export const detailPath = (id) => `/device/${enc(id)}`;
|
|
60
|
+
/** An Ivanti Neurons for MDM API client from IVANTI_MDM_* variables, or what is missing. */
|
|
61
|
+
export function ivantiMdmFromEnv(env, fetchFn = fetch) {
|
|
62
|
+
const url = env[IVANTI_MDM_ENV.url], user = env[IVANTI_MDM_ENV.user], password = env[IVANTI_MDM_ENV.password];
|
|
63
|
+
if (!url || !user || !password)
|
|
64
|
+
return `${IVANTI_MDM_ENV.url}, ${IVANTI_MDM_ENV.user} and ${IVANTI_MDM_ENV.password} are not all set`;
|
|
65
|
+
let u;
|
|
66
|
+
try {
|
|
67
|
+
u = new URL(url);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return `${IVANTI_MDM_ENV.url} is not a URL`;
|
|
71
|
+
}
|
|
72
|
+
if (u.protocol !== "https:")
|
|
73
|
+
return `${IVANTI_MDM_ENV.url} must be https`;
|
|
74
|
+
const basic = Buffer.from(`${user}:${password}`).toString("base64");
|
|
75
|
+
return fetchRest({ base: `${u.origin}/api/v1`, fetchFn, headers: async () => ({ authorization: `Basic ${basic}` }) });
|
|
76
|
+
}
|
|
77
|
+
/** The guide wraps answers in { errors, result }; the OpenAPI file shows them bare. Either is read. */
|
|
78
|
+
const unwrap = (b) => (isObj(b) ? (isObj(b.result) ? b.result : b) : undefined);
|
|
79
|
+
/** Every device in the space, sorted by id and paged with searchAfterValues, up to `max`. */
|
|
80
|
+
async function listDevices(api, space, max) {
|
|
81
|
+
const items = [];
|
|
82
|
+
let after;
|
|
83
|
+
for (let page = 0;; page++) {
|
|
84
|
+
const r = await api.get(devicesPath(space, after));
|
|
85
|
+
const body = ok(r) ? unwrap(r.body) : undefined;
|
|
86
|
+
const got = body && Array.isArray(body.searchResults) ? body.searchResults.filter(isObj) : undefined;
|
|
87
|
+
if (!got)
|
|
88
|
+
return { error: r };
|
|
89
|
+
items.push(...got);
|
|
90
|
+
const total = Number(body.totalCount);
|
|
91
|
+
const last = got.length ? got[got.length - 1].id : undefined;
|
|
92
|
+
const done = !got.length || last === undefined || (Number.isFinite(total) ? items.length >= total : got.length < PAGE);
|
|
93
|
+
if (items.length >= max)
|
|
94
|
+
return { items: items.slice(0, max), truncated: items.length > max || !done, pages: page + 1 };
|
|
95
|
+
if (done)
|
|
96
|
+
return { items, truncated: false, pages: page + 1 };
|
|
97
|
+
if (page >= Math.ceil(max / PAGE) + 1)
|
|
98
|
+
return { items, truncated: true, pages: page + 1 };
|
|
99
|
+
after = String(last);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** lastCheckin as epoch milliseconds (the guide's example) or an ISO date-time (the OpenAPI type), as ISO. */
|
|
103
|
+
function checkinIso(v) {
|
|
104
|
+
const n = typeof v === "number" ? v : typeof v === "string" && /^\d{10,}$/.test(v) ? Number(v) : NaN;
|
|
105
|
+
if (Number.isFinite(n))
|
|
106
|
+
return new Date(n).toISOString();
|
|
107
|
+
return typeof v === "string" ? v : undefined;
|
|
108
|
+
}
|
|
109
|
+
const BITLOCKER_FAIL = new Set(["NOT_ENCRYPTED", "PARTIALLY_ENCRYPTED"]);
|
|
110
|
+
export async function ivantiMdmChecks(api, host, o, now) {
|
|
111
|
+
const out = new Emitter("ivanti_mdm", `ivanti_mdm ${host}`);
|
|
112
|
+
// The device space: --ivanti-mdm-space-id, else the tenant's default from Get Tenant Metadata.
|
|
113
|
+
let space = o.space;
|
|
114
|
+
if (!space) {
|
|
115
|
+
const m = await api.get(metadataPath);
|
|
116
|
+
const id = ok(m) ? unwrap(m.body)?.defaultDmPartitionId : undefined;
|
|
117
|
+
if (id === undefined || id === null || id === "") {
|
|
118
|
+
if (!ok(m))
|
|
119
|
+
for (const c of IVANTI_MDM_CHECKS)
|
|
120
|
+
out.skip(c, m, "tenant metadata (default device space)");
|
|
121
|
+
else
|
|
122
|
+
out.skipAll(IVANTI_MDM_CHECKS, "tenant metadata gives no defaultDmPartitionId; set --ivanti-mdm-space-id");
|
|
123
|
+
return out.results;
|
|
124
|
+
}
|
|
125
|
+
space = String(id);
|
|
126
|
+
}
|
|
127
|
+
const tenant = `ivanti_mdm ${host} space ${space}`;
|
|
128
|
+
const tout = new Emitter("ivanti_mdm", tenant);
|
|
129
|
+
const got = await listDevices(api, space, o.maxDevices);
|
|
130
|
+
if ("error" in got) {
|
|
131
|
+
for (const c of IVANTI_MDM_CHECKS)
|
|
132
|
+
tout.skip(c, got.error, "device search");
|
|
133
|
+
return tout.results;
|
|
134
|
+
}
|
|
135
|
+
const active = got.items.filter((d) => d.registrationState === undefined || d.registrationState === null || d.registrationState === "ACTIVE");
|
|
136
|
+
const inactive = got.items.length - active.length;
|
|
137
|
+
const note = inactive ? `; ${inactive} retired, wiped or pending device(s) not judged` : "";
|
|
138
|
+
if (!active.length) {
|
|
139
|
+
tout.skipAll(IVANTI_MDM_CHECKS, `the device space lists no active device${note}; nothing to judge`);
|
|
140
|
+
return tout.results;
|
|
141
|
+
}
|
|
142
|
+
const cap = got.truncated ? `; only the first ${o.maxDevices} devices read (--ivanti-mdm-max-devices)` : "";
|
|
143
|
+
const from = tout.results.length;
|
|
144
|
+
if (!o.minOs.size)
|
|
145
|
+
tout.add("os_minimum", "skipped", `no minimum OS version given (--ivanti-mdm-min-os, e.g. osx=15.0 or ios=18.0); ${active.length} device(s) not judged`);
|
|
146
|
+
const isMac = (d) => d.platformType === "OSX";
|
|
147
|
+
const macs = active.filter(isMac).length;
|
|
148
|
+
if (!macs)
|
|
149
|
+
for (const c of ["firewall", "system_integrity"])
|
|
150
|
+
tout.add(c, "skipped", `no active macOS (platformType OSX) device; the API documents ${c === "firewall" ? "firewall settings" : "System Integrity Protection"} for macOS only${note}`);
|
|
151
|
+
const mobiles = active.filter((d) => d.platformType === "IOS" || d.platformType === "ANDROID").length;
|
|
152
|
+
if (!mobiles)
|
|
153
|
+
tout.add("passcode_compliant", "skipped", `no active iOS or Android device; passcodeCompliantWithProfiles is documented for those platforms only${note}`);
|
|
154
|
+
let encJudged = 0;
|
|
155
|
+
let detailDenied;
|
|
156
|
+
for (const d of active) {
|
|
157
|
+
const id = String(d.id ?? "?");
|
|
158
|
+
const res = deviceResource("ivanti device", id, d.serialNumber);
|
|
159
|
+
const plat = str(d.platformType) ?? "UNKNOWN";
|
|
160
|
+
const ver = str(d.platformVersion);
|
|
161
|
+
const where = ` (${plat} ${ver ?? "version not reported"})`;
|
|
162
|
+
if (typeof d.complianceState === "boolean")
|
|
163
|
+
tout.add("device_compliant", d.complianceState ? "pass" : "fail", `complianceState is ${d.complianceState}${where}${cap}${note}`, res);
|
|
164
|
+
else
|
|
165
|
+
tout.add("device_compliant", "skipped", `complianceState not reported${where}${note}`, res);
|
|
166
|
+
const age = ageDays(checkinIso(d.lastCheckin), now);
|
|
167
|
+
if (age === undefined)
|
|
168
|
+
tout.add("last_checkin", "skipped", `lastCheckin not reported${where}`, res);
|
|
169
|
+
else
|
|
170
|
+
tout.add("last_checkin", age <= o.checkinDays ? "pass" : "fail", `last client check-in ${age} day(s) ago; at most ${o.checkinDays} allowed (--ivanti-mdm-checkin-days)${where}`, res);
|
|
171
|
+
if (o.minOs.size) {
|
|
172
|
+
const key = plat.toLowerCase();
|
|
173
|
+
const floor = o.minOs.get(key) ?? (key === "osx" ? o.minOs.get("macos") : undefined) ?? o.minOs.get("*");
|
|
174
|
+
const have = versionIn(ver);
|
|
175
|
+
if (!floor)
|
|
176
|
+
tout.add("os_minimum", "skipped", `--ivanti-mdm-min-os names no floor for platformType ${plat}${where}`, res);
|
|
177
|
+
else if (!have)
|
|
178
|
+
tout.add("os_minimum", "skipped", `platformVersion not reported${where}`, res);
|
|
179
|
+
else {
|
|
180
|
+
const okv = cmpVersion(have, floor) >= 0;
|
|
181
|
+
tout.add("os_minimum", okv ? "pass" : "fail", `platformVersion ${showVersion(have)} ${okv ? "meets" : "is below"} the --ivanti-mdm-min-os floor ${showVersion(floor)}${where}`, res);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (plat === "IOS" || plat === "ANDROID") {
|
|
185
|
+
const p = typeof d.passcodeCompliantWithProfiles === "boolean" ? ["passcodeCompliantWithProfiles", d.passcodeCompliantWithProfiles]
|
|
186
|
+
: typeof d.passcodeCompliant === "boolean" ? ["passcodeCompliant", d.passcodeCompliant] : undefined;
|
|
187
|
+
if (p)
|
|
188
|
+
tout.add("passcode_compliant", p[1] ? "pass" : "fail", `${p[0]} is ${p[1]}${where}`, res);
|
|
189
|
+
else
|
|
190
|
+
tout.add("passcode_compliant", "skipped", `neither passcodeCompliantWithProfiles nor passcodeCompliant reported${where}`, res);
|
|
191
|
+
}
|
|
192
|
+
// Not a Mac: BitLocker from the search row, wherever a device reports it.
|
|
193
|
+
if (!isMac(d)) {
|
|
194
|
+
const bl = str(d.bitLockerEncryptionStatus);
|
|
195
|
+
if (bl) {
|
|
196
|
+
encJudged++;
|
|
197
|
+
if (bl === "UNKNOWN")
|
|
198
|
+
tout.add("disk_encryption", "skipped", `bitLockerEncryptionStatus is UNKNOWN${where}`, res);
|
|
199
|
+
else
|
|
200
|
+
tout.add("disk_encryption", bl === "FULLY_ENCRYPTED" ? "pass" : BITLOCKER_FAIL.has(bl) ? "fail" : "skipped", `bitLockerEncryptionStatus is ${bl}; FULLY_ENCRYPTED required${where}`, res);
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// macOS: FileVault, firewall and SIP come from Get Device Details, one call per Mac. A 401 or 403 stops the calls.
|
|
205
|
+
encJudged++;
|
|
206
|
+
if (detailDenied) {
|
|
207
|
+
for (const c of ["disk_encryption", "firewall", "system_integrity"])
|
|
208
|
+
tout.skip(c, detailDenied, "device details", res);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const r = await api.get(detailPath(id));
|
|
212
|
+
const det = ok(r) ? unwrap(r.body) : undefined;
|
|
213
|
+
if (!det) {
|
|
214
|
+
if (r.status === 401 || r.status === 403)
|
|
215
|
+
detailDenied = r;
|
|
216
|
+
for (const c of ["disk_encryption", "firewall", "system_integrity"])
|
|
217
|
+
tout.skip(c, r, "device details", res);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (typeof det.osxFdeEnabled === "boolean")
|
|
221
|
+
tout.add("disk_encryption", det.osxFdeEnabled ? "pass" : "fail", `osxFdeEnabled (FileVault) is ${det.osxFdeEnabled}${where}`, res);
|
|
222
|
+
else
|
|
223
|
+
tout.add("disk_encryption", "skipped", `osxFdeEnabled not reported${where}`, res);
|
|
224
|
+
const fw = isObj(det.osxFirewallSettings) ? det.osxFirewallSettings.firewallEnabled : undefined;
|
|
225
|
+
if (typeof fw === "boolean")
|
|
226
|
+
tout.add("firewall", fw ? "pass" : "fail", `osxFirewallSettings.firewallEnabled is ${fw}${where}`, res);
|
|
227
|
+
else
|
|
228
|
+
tout.add("firewall", "skipped", `osxFirewallSettings.firewallEnabled not reported${where}`, res);
|
|
229
|
+
const sip = det.osxSystemIntegrityProtectionEnabled;
|
|
230
|
+
if (typeof sip === "boolean")
|
|
231
|
+
tout.add("system_integrity", sip ? "pass" : "fail", `osxSystemIntegrityProtectionEnabled is ${sip}${where}`, res);
|
|
232
|
+
else
|
|
233
|
+
tout.add("system_integrity", "skipped", `osxSystemIntegrityProtectionEnabled not reported${where}`, res);
|
|
234
|
+
}
|
|
235
|
+
if (!encJudged)
|
|
236
|
+
tout.add("disk_encryption", "skipped", `no active device reports an encryption field the API documents (osxFdeEnabled for macOS, bitLockerEncryptionStatus for BitLocker); iOS and Android expose none${note}`);
|
|
237
|
+
stampRead(tout.results, from, listRead(got)); // a capped list leaves devices unseen: format 3 says so
|
|
238
|
+
return tout.results;
|
|
239
|
+
}
|
|
240
|
+
export const ivanti_mdm = {
|
|
241
|
+
name: "ivanti_mdm",
|
|
242
|
+
options: {
|
|
243
|
+
"ivanti-mdm-space-id": { type: "string", help: "device space id (dmPartitionId) to read (default: the tenant's defaultDmPartitionId)" },
|
|
244
|
+
"ivanti-mdm-min-os": { type: "string", multiple: true, help: "<platformType>=<version> floor, e.g. osx=15.0, ios=18.0, android=14 (repeatable; without it os_minimum is skipped)" },
|
|
245
|
+
"ivanti-mdm-checkin-days": { type: "string", help: `days since a device's last client check-in before last_checkin fails (default ${DEFAULTS.checkinDays})` },
|
|
246
|
+
"ivanti-mdm-max-devices": { type: "string", help: `devices read, at most (default ${DEFAULTS.maxDevices}); each Mac costs one Get Device Details call` },
|
|
247
|
+
},
|
|
248
|
+
emits: IVANTI_MDM_CHECKS.map((c) => `ivanti_mdm.${c}`),
|
|
249
|
+
describe: () => "Ivanti Neurons for MDM (formerly MobileIron Cloud) tenant in IVANTI_MDM_URL (Customer Integration API, GET only, Device Read Only admin)",
|
|
250
|
+
async run(ctx) {
|
|
251
|
+
const host = (() => { try {
|
|
252
|
+
return new URL(ctx.env[IVANTI_MDM_ENV.url] ?? "").host || "(unknown)";
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return "(unknown)";
|
|
256
|
+
} })();
|
|
257
|
+
const given = ctx.clients.ivanti_mdm;
|
|
258
|
+
const built = given !== undefined ? given : ivantiMdmFromEnv(ctx.env);
|
|
259
|
+
if (!built || typeof built === "string") {
|
|
260
|
+
const out = new Emitter("ivanti_mdm", `ivanti_mdm ${host}`);
|
|
261
|
+
out.skipAll(IVANTI_MDM_CHECKS, typeof built === "string" ? built : `${IVANTI_MDM_ENV.password} is not set`);
|
|
262
|
+
return out.results;
|
|
263
|
+
}
|
|
264
|
+
const api = withRetry(built, ctx.clients.sleep ?? realSleep);
|
|
265
|
+
const space = typeof ctx.opts["ivanti-mdm-space-id"] === "string" && /^\d+$/.test(ctx.opts["ivanti-mdm-space-id"]) ? ctx.opts["ivanti-mdm-space-id"] : undefined;
|
|
266
|
+
const o = {
|
|
267
|
+
space,
|
|
268
|
+
checkinDays: posInt(ctx.opts["ivanti-mdm-checkin-days"], DEFAULTS.checkinDays),
|
|
269
|
+
maxDevices: posInt(ctx.opts["ivanti-mdm-max-devices"], DEFAULTS.maxDevices),
|
|
270
|
+
minOs: minOsOption(ctx.opts["ivanti-mdm-min-os"]),
|
|
271
|
+
};
|
|
272
|
+
const user = ctx.env[IVANTI_MDM_ENV.user], password = ctx.env[IVANTI_MDM_ENV.password];
|
|
273
|
+
const basic = user && password ? Buffer.from(`${user}:${password}`).toString("base64") : undefined;
|
|
274
|
+
return redact(await ivantiMdmChecks(api, host, o, ctx.now), [password, basic]);
|
|
275
|
+
},
|
|
276
|
+
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// NinjaOne (RMM and endpoint management) over the NinjaOne Public API v2 (GET only), as an API Services
|
|
2
|
+
// (machine-to-machine) client app whose only scope is Monitoring. The client-credentials token request is the only POST.
|
|
3
|
+
//
|
|
4
|
+
// Auth (NinjaOne Public API reference, app.ninjarmm.com/apidocs-beta/, articles read 2026-10-28):
|
|
5
|
+
// /authorization/create-applications/machine-to-machine-apps: Configuration > Apps > API > Client App IDs > Add >
|
|
6
|
+
// "API Services (machine-to-machine)". Scopes: "Monitoring: grants read-only access to monitoring data and
|
|
7
|
+
// organization structure"; Management and Control modify devices or open remote access and are never asked for.
|
|
8
|
+
// /authorization/flows/client-credentials-flow: POST /ws/oauth/token (form) grant_type=client_credentials, client_id,
|
|
9
|
+
// client_secret, scope -> { access_token, expires_in (3600 in the example), scope, token_type: "Bearer" }.
|
|
10
|
+
// Instances: app, us2, eu, ca and oc .ninjarmm.com each served this same reference (NinjaRMM-API-v2.yaml, 794,720
|
|
11
|
+
// bytes, "NinjaOne Public API 2.0" version 2.0.9-draft) and answered /ws/oauth/token on 2026-10-28; the collector
|
|
12
|
+
// takes the instance as NINJAONE_URL and refuses any other host.
|
|
13
|
+
// The spec's global security requirement names the scopes monitoring, management and control and no operation below
|
|
14
|
+
// overrides it, so that Monitoring alone answers these calls rests on the M2M page's wording: a 401 or 403 is skipped.
|
|
15
|
+
//
|
|
16
|
+
// Endpoints and fields (NinjaRMM-API-v2.yaml, read 2026-10-28):
|
|
17
|
+
// GET /v2/devices?pageSize&after List devices; `after` is "Last Node ID from previous page".
|
|
18
|
+
// -> [ { id, nodeClass, approvalStatus (PENDING, STAGED, APPROVED, DECOMMISSIONED), offline, lastContact,
|
|
19
|
+
// lastUpdate, ... } ]
|
|
20
|
+
// GET /v2/queries/computer-systems?pageSize&cursor -> { cursor: { name, offset, count, expires }, results: [ { deviceId, serialNumber, ... } ] }
|
|
21
|
+
// GET /v2/queries/volumes?include=bl&pageSize&cursor ("bl - BitLocker status")
|
|
22
|
+
// -> results: [ { deviceId, driveLetter, bitLockerStatus: { conversionStatus (FULLY_DECRYPTED, FULLY_ENCRYPTED,
|
|
23
|
+
// ENCRYPTION_IN_PROGRESS, DECRYPTION_IN_PROGRESS, ENCRYPTION_PAUSED, DECRYPTION_PAUSED, UNKNOWN),
|
|
24
|
+
// protectionStatus (UNPROTECTED, PROTECTED, UNKNOWN, PENDING) } } ]
|
|
25
|
+
// GET /v2/queries/operating-systems?pageSize&cursor -> results: [ { deviceId, name, buildNumber, releaseId, needsReboot } ]
|
|
26
|
+
// GET /v2/queries/device-health?pageSize&cursor -> results: [ { deviceId, failedOSPatchesCount, pendingOSPatchesCount, ... } ]
|
|
27
|
+
// The query reports answer with a named server-side cursor; the spec describes the `cursor` parameter only as
|
|
28
|
+
// "Cursor name", so the collector passes the last answer's cursor.name back and stops at an empty page. lastContact is
|
|
29
|
+
// a number whose unit the spec does not state: a value below 1e11 is read as epoch seconds, above as milliseconds.
|
|
30
|
+
// The spec documents a 429 "Rate limit exceeded" answer; no request budget is published, and a 429 is retried after
|
|
31
|
+
// its Retry-After.
|
|
32
|
+
//
|
|
33
|
+
// Left out, because no endpoint or field of the reference reports them: the firewall, the screen lock or passcode, and
|
|
34
|
+
// FileVault (the volume report's encryption block is BitLocker's, so Macs and Linux hosts are skipped for encryption).
|
|
35
|
+
//
|
|
36
|
+
// Personal data: a device is named by its NinjaOne id and the serial of the computer-systems report. displayName,
|
|
37
|
+
// systemName, dnsName, netbiosName, the assigned owner, userData, the logged-on user and tags are never read into a result.
|
|
38
|
+
import { cachedToken, tokenRequest } from "./oauth.js";
|
|
39
|
+
import { Emitter, enc, fetchRest, isObj, ok, realSleep, redact, str, withRetry } from "./readonly_http.js";
|
|
40
|
+
import { cmpVersion, deviceResource, minOsOption, posInt, showVersion, versionIn } from "./device_common.js";
|
|
41
|
+
export const NINJAONE_ENV = { url: "NINJAONE_URL", client: "NINJAONE_CLIENT_ID", secret: "NINJAONE_CLIENT_SECRET" };
|
|
42
|
+
/** The instances whose hosts served the NinjaOne Public API reference on 2026-10-28. */
|
|
43
|
+
export const NINJAONE_HOSTS = ["app.ninjarmm.com", "us2.ninjarmm.com", "eu.ninjarmm.com", "ca.ninjarmm.com", "oc.ninjarmm.com"];
|
|
44
|
+
export const NINJAONE_CHECKS = ["disk_encryption", "os_minimum", "os_patches", "last_contact", "approved"];
|
|
45
|
+
export const DEFAULTS = { contactDays: 14, maxDevices: 5000, maxPendingPatches: 0 };
|
|
46
|
+
export const PAGE = 500;
|
|
47
|
+
const WORKSTATIONS = new Set(["WINDOWS_WORKSTATION", "MAC", "LINUX_WORKSTATION"]);
|
|
48
|
+
const SERVERS = new Set(["WINDOWS_SERVER", "MAC_SERVER", "LINUX_SERVER"]);
|
|
49
|
+
export const devicesPath = (after) => `/v2/devices?pageSize=${PAGE}${after === undefined ? "" : `&after=${after}`}`;
|
|
50
|
+
export const queryPath = (report, cursor) => `/v2/queries/${report}?${report === "volumes" ? "include=bl&" : ""}pageSize=${PAGE}${cursor ? `&cursor=${enc(cursor)}` : ""}`;
|
|
51
|
+
/** A NinjaOne Public API client from NINJAONE_* variables, or what is missing. */
|
|
52
|
+
export function ninjaoneFromEnv(env, fetchFn = fetch) {
|
|
53
|
+
const url = env[NINJAONE_ENV.url], client = env[NINJAONE_ENV.client], secret = env[NINJAONE_ENV.secret];
|
|
54
|
+
if (!url || !client || !secret)
|
|
55
|
+
return `${NINJAONE_ENV.url}, ${NINJAONE_ENV.client} and ${NINJAONE_ENV.secret} are not all set`;
|
|
56
|
+
let u;
|
|
57
|
+
try {
|
|
58
|
+
u = new URL(url);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return `${NINJAONE_ENV.url} is not a URL`;
|
|
62
|
+
}
|
|
63
|
+
if (u.protocol !== "https:")
|
|
64
|
+
return `${NINJAONE_ENV.url} must be https`;
|
|
65
|
+
if (!NINJAONE_HOSTS.includes(u.host))
|
|
66
|
+
return `${NINJAONE_ENV.url} must be one of ${NINJAONE_HOSTS.map((h) => `https://${h}`).join(", ")}`;
|
|
67
|
+
const form = { grant_type: "client_credentials", client_id: client, client_secret: secret, scope: "monitoring" };
|
|
68
|
+
const token = cachedToken(() => tokenRequest(`${u.origin}/ws/oauth/token`, form, fetchFn));
|
|
69
|
+
return fetchRest({ base: u.origin, fetchFn, headers: async () => ({ authorization: `Bearer ${await token()}` }) });
|
|
70
|
+
}
|
|
71
|
+
/** List devices, paged by the last node id, until an empty page or `max` devices. */
|
|
72
|
+
async function listDevices(api, max) {
|
|
73
|
+
const items = [];
|
|
74
|
+
let after;
|
|
75
|
+
for (let page = 0;; page++) {
|
|
76
|
+
const r = await api.get(devicesPath(after));
|
|
77
|
+
if (!ok(r) || !Array.isArray(r.body))
|
|
78
|
+
return { error: r };
|
|
79
|
+
const got = r.body.filter(isObj);
|
|
80
|
+
items.push(...got);
|
|
81
|
+
const last = got.length ? Number(got[got.length - 1].id) : NaN;
|
|
82
|
+
if (!got.length || !Number.isFinite(last) || (after !== undefined && last <= after))
|
|
83
|
+
return { items: items.slice(0, max), truncated: items.length > max, pages: page + 1 };
|
|
84
|
+
if (items.length >= max)
|
|
85
|
+
return { items: items.slice(0, max), truncated: true, pages: page + 1 };
|
|
86
|
+
after = last;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** One query report's rows by deviceId (several rows per device for volumes), following cursor.name until an empty page. */
|
|
90
|
+
async function report(api, name, maxPages) {
|
|
91
|
+
const rows = new Map();
|
|
92
|
+
let cursor;
|
|
93
|
+
for (let page = 0; page < maxPages; page++) {
|
|
94
|
+
const r = await api.get(queryPath(name, cursor));
|
|
95
|
+
if (!ok(r) || !isObj(r.body) || !Array.isArray(r.body.results))
|
|
96
|
+
return { error: r };
|
|
97
|
+
const got = r.body.results.filter(isObj);
|
|
98
|
+
for (const row of got) {
|
|
99
|
+
const id = Number(row.deviceId);
|
|
100
|
+
if (Number.isFinite(id))
|
|
101
|
+
rows.set(id, [...(rows.get(id) ?? []), row]);
|
|
102
|
+
}
|
|
103
|
+
const next = isObj(r.body.cursor) ? str(r.body.cursor.name) : undefined;
|
|
104
|
+
if (!got.length || !next)
|
|
105
|
+
break;
|
|
106
|
+
cursor = next;
|
|
107
|
+
}
|
|
108
|
+
return { rows };
|
|
109
|
+
}
|
|
110
|
+
/** lastContact as a Date: epoch seconds below 1e11, milliseconds above (the spec gives no unit). */
|
|
111
|
+
export function contactDate(v) {
|
|
112
|
+
const n = typeof v === "number" ? v : typeof v === "string" && v.trim() ? Number(v) : NaN;
|
|
113
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
return new Date(n < 1e11 ? n * 1000 : n);
|
|
116
|
+
}
|
|
117
|
+
const platformOf = (nodeClass) => (nodeClass.startsWith("WINDOWS") ? "windows" : nodeClass.startsWith("MAC") ? "macos" : nodeClass.startsWith("LINUX") ? "linux" : "other");
|
|
118
|
+
/** The OS version a floor is compared with: 10.0.<build> on Windows (buildNumber), else the dotted version in the name. */
|
|
119
|
+
export function osVersionOf(platform, os) {
|
|
120
|
+
if (!os)
|
|
121
|
+
return undefined;
|
|
122
|
+
if (platform === "windows") {
|
|
123
|
+
const b = String(os.buildNumber ?? "").match(/^\d+/)?.[0];
|
|
124
|
+
return b ? [10, 0, Number(b)] : undefined;
|
|
125
|
+
}
|
|
126
|
+
return versionIn(os.name);
|
|
127
|
+
}
|
|
128
|
+
export async function ninjaoneChecks(api, instance, o, now) {
|
|
129
|
+
const out = new Emitter("ninjaone", `ninjaone ${instance}`);
|
|
130
|
+
const got = await listDevices(api, o.maxDevices);
|
|
131
|
+
if ("error" in got) {
|
|
132
|
+
for (const c of NINJAONE_CHECKS)
|
|
133
|
+
out.skip(c, got.error, "device list");
|
|
134
|
+
return out.results;
|
|
135
|
+
}
|
|
136
|
+
const classes = o.servers ? new Set([...WORKSTATIONS, ...SERVERS]) : WORKSTATIONS;
|
|
137
|
+
const devices = got.items.filter((d) => classes.has(String(d.nodeClass)));
|
|
138
|
+
if (!devices.length) {
|
|
139
|
+
out.skipAll(NINJAONE_CHECKS, `NinjaOne lists no ${o.servers ? "workstation or server" : "workstation (Windows, Mac or Linux)"} among ${got.items.length} device(s); nothing to judge`);
|
|
140
|
+
return out.results;
|
|
141
|
+
}
|
|
142
|
+
const cap = got.truncated ? `; only the first ${o.maxDevices} devices read (--ninjaone-max-devices)` : "";
|
|
143
|
+
const maxPages = Math.ceil(o.maxDevices / PAGE) * 4 + 4; // volumes give several rows per device
|
|
144
|
+
const reports = new Map();
|
|
145
|
+
const failed = new Map();
|
|
146
|
+
for (const name of ["computer-systems", "volumes", "operating-systems", "device-health"]) {
|
|
147
|
+
const r = await report(api, name, maxPages);
|
|
148
|
+
if ("error" in r)
|
|
149
|
+
failed.set(name, r.error);
|
|
150
|
+
else
|
|
151
|
+
reports.set(name, r.rows);
|
|
152
|
+
}
|
|
153
|
+
const floorsGiven = o.minOs.size > 0;
|
|
154
|
+
if (!floorsGiven)
|
|
155
|
+
out.add("os_minimum", "skipped", `no minimum OS version given (--ninjaone-min-os, e.g. windows=10.0.26100 or macos=15.0); ${devices.length} device(s) not judged`);
|
|
156
|
+
for (const [check, name] of [["disk_encryption", "volumes"], ["os_minimum", "operating-systems"], ["os_patches", "device-health"]]) {
|
|
157
|
+
const e = failed.get(name);
|
|
158
|
+
if (e && (check !== "os_minimum" || floorsGiven))
|
|
159
|
+
out.skip(check, e, `query report ${name}`);
|
|
160
|
+
}
|
|
161
|
+
const from = out.results.length;
|
|
162
|
+
for (const d of devices) {
|
|
163
|
+
const id = Number(d.id);
|
|
164
|
+
const nodeClass = String(d.nodeClass);
|
|
165
|
+
const platform = platformOf(nodeClass);
|
|
166
|
+
const serial = reports.get("computer-systems")?.get(id)?.[0]?.serialNumber;
|
|
167
|
+
const res = deviceResource("ninjaone device", d.id, serial);
|
|
168
|
+
const os = reports.get("operating-systems")?.get(id)?.[0];
|
|
169
|
+
const where = ` (${nodeClass}${os?.buildNumber ? `, build ${String(os.buildNumber).slice(0, 30)}` : ""})`;
|
|
170
|
+
// Disk encryption: BitLocker on the C: volume of a Windows device.
|
|
171
|
+
const vols = reports.get("volumes");
|
|
172
|
+
if (vols) {
|
|
173
|
+
if (platform !== "windows")
|
|
174
|
+
out.add("disk_encryption", "skipped", `NinjaOne reports BitLocker only; no FileVault or LUKS field for ${nodeClass}${where}`, res);
|
|
175
|
+
else {
|
|
176
|
+
const c = (vols.get(id) ?? []).find((v) => /^c:?\\?$/i.test(String(v.driveLetter ?? "").trim()));
|
|
177
|
+
const bl = c && isObj(c.bitLockerStatus) ? c.bitLockerStatus : undefined;
|
|
178
|
+
const conv = str(bl?.conversionStatus), prot = str(bl?.protectionStatus);
|
|
179
|
+
if (!c)
|
|
180
|
+
out.add("disk_encryption", "skipped", `no C: volume in the volumes report${where}`, res);
|
|
181
|
+
else if (!bl || !conv || conv === "UNKNOWN")
|
|
182
|
+
out.add("disk_encryption", "skipped", `C: has no BitLocker conversionStatus (${conv ?? "not reported"})${where}`, res);
|
|
183
|
+
else {
|
|
184
|
+
const pass = conv === "FULLY_ENCRYPTED" && prot === "PROTECTED";
|
|
185
|
+
out.add("disk_encryption", pass ? "pass" : "fail", `C: BitLocker conversionStatus ${conv}, protectionStatus ${prot ?? "not reported"}; FULLY_ENCRYPTED and PROTECTED required${where}${cap}`, res);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Minimum OS version.
|
|
190
|
+
if (floorsGiven && reports.has("operating-systems")) {
|
|
191
|
+
const floor = o.minOs.get(platform) ?? o.minOs.get("*");
|
|
192
|
+
const have = osVersionOf(platform, os);
|
|
193
|
+
if (!floor)
|
|
194
|
+
out.add("os_minimum", "skipped", `no --ninjaone-min-os floor for ${platform}${where}`, res);
|
|
195
|
+
else if (!have)
|
|
196
|
+
out.add("os_minimum", "skipped", `${platform === "windows" ? "buildNumber" : "a version in the OS name"} not reported${where}`, res);
|
|
197
|
+
else {
|
|
198
|
+
const okv = cmpVersion(have, floor) >= 0;
|
|
199
|
+
out.add("os_minimum", okv ? "pass" : "fail", `${platform} ${showVersion(have)} ${okv ? "meets" : "is below"} the --ninjaone-min-os floor ${showVersion(floor)}${where}`, res);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// OS patches: failed and pending counts from the device health report.
|
|
203
|
+
const health = reports.get("device-health");
|
|
204
|
+
if (health) {
|
|
205
|
+
const h = health.get(id)?.[0];
|
|
206
|
+
const failedN = h?.failedOSPatchesCount, pendingN = h?.pendingOSPatchesCount;
|
|
207
|
+
if (typeof failedN !== "number" || typeof pendingN !== "number")
|
|
208
|
+
out.add("os_patches", "skipped", `${h ? "failedOSPatchesCount or pendingOSPatchesCount not reported" : "no device health row"}${where}`, res);
|
|
209
|
+
else {
|
|
210
|
+
const pass = failedN === 0 && pendingN <= o.maxPendingPatches;
|
|
211
|
+
out.add("os_patches", pass ? "pass" : "fail", `${failedN} failed and ${pendingN} pending OS patch(es); 0 failed and at most ${o.maxPendingPatches} pending allowed (--ninjaone-max-pending-patches)${where}`, res);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const seen = contactDate(d.lastContact);
|
|
215
|
+
if (!seen)
|
|
216
|
+
out.add("last_contact", "skipped", `lastContact not reported${where}`, res);
|
|
217
|
+
else {
|
|
218
|
+
const age = Math.floor((now.getTime() - seen.getTime()) / 86_400_000);
|
|
219
|
+
out.add("last_contact", age <= o.contactDays ? "pass" : "fail", `last contact ${age} day(s) ago; at most ${o.contactDays} allowed (--ninjaone-contact-days)${where}`, res);
|
|
220
|
+
}
|
|
221
|
+
const st = str(d.approvalStatus);
|
|
222
|
+
if (st === "APPROVED")
|
|
223
|
+
out.add("approved", "pass", `approvalStatus is APPROVED${where}`, res);
|
|
224
|
+
else if (st === "PENDING" || st === "STAGED")
|
|
225
|
+
out.add("approved", "fail", `approvalStatus is ${st}; APPROVED required${where}`, res);
|
|
226
|
+
else
|
|
227
|
+
out.add("approved", "skipped", `approvalStatus is ${st ?? "not reported"}${st === "DECOMMISSIONED" ? " (no longer managed, not judged)" : ""}${where}`, res);
|
|
228
|
+
}
|
|
229
|
+
const read = { method: "config-read", itemsRead: devices.length, pagesRead: got.pages, complete: !got.truncated };
|
|
230
|
+
for (let i = from; i < out.results.length; i++)
|
|
231
|
+
if (out.results[i].method === undefined)
|
|
232
|
+
Object.assign(out.results[i], read);
|
|
233
|
+
return out.results;
|
|
234
|
+
}
|
|
235
|
+
export const ninjaone = {
|
|
236
|
+
name: "ninjaone",
|
|
237
|
+
options: {
|
|
238
|
+
"ninjaone-contact-days": { type: "string", help: `days since a device's last contact before last_contact fails (default ${DEFAULTS.contactDays})` },
|
|
239
|
+
"ninjaone-min-os": { type: "string", multiple: true, help: "<platform>=<version> floor for os_minimum, platform windows, macos or linux, e.g. windows=10.0.26100 (repeatable; without it the check is skipped)" },
|
|
240
|
+
"ninjaone-max-pending-patches": { type: "string", help: `pending OS patches a device may have before os_patches fails (default ${DEFAULTS.maxPendingPatches})` },
|
|
241
|
+
"ninjaone-max-devices": { type: "string", help: `devices read, at most (default ${DEFAULTS.maxDevices})` },
|
|
242
|
+
"ninjaone-include-servers": { type: "boolean", help: "also judge Windows, Mac and Linux servers (default: workstations only)" },
|
|
243
|
+
},
|
|
244
|
+
emits: NINJAONE_CHECKS.map((c) => `ninjaone.${c}`),
|
|
245
|
+
describe: () => "NinjaOne instance in NINJAONE_URL (Public API v2, client credentials, Monitoring scope only)",
|
|
246
|
+
async run(ctx) {
|
|
247
|
+
const instance = (() => { try {
|
|
248
|
+
return new URL(ctx.env[NINJAONE_ENV.url] ?? "").host || "(unknown)";
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return "(unknown)";
|
|
252
|
+
} })();
|
|
253
|
+
const given = ctx.clients.ninjaone;
|
|
254
|
+
const built = given !== undefined ? given : ninjaoneFromEnv(ctx.env);
|
|
255
|
+
if (!built || typeof built === "string") {
|
|
256
|
+
const out = new Emitter("ninjaone", `ninjaone ${instance}`);
|
|
257
|
+
out.skipAll(NINJAONE_CHECKS, typeof built === "string" ? built : `${NINJAONE_ENV.secret} is not set`);
|
|
258
|
+
return out.results;
|
|
259
|
+
}
|
|
260
|
+
const api = withRetry(built, ctx.clients.sleep ?? realSleep);
|
|
261
|
+
const pending = Number(ctx.opts["ninjaone-max-pending-patches"]);
|
|
262
|
+
const o = {
|
|
263
|
+
contactDays: posInt(ctx.opts["ninjaone-contact-days"], DEFAULTS.contactDays),
|
|
264
|
+
maxDevices: posInt(ctx.opts["ninjaone-max-devices"], DEFAULTS.maxDevices),
|
|
265
|
+
maxPendingPatches: Number.isInteger(pending) && pending >= 0 && ctx.opts["ninjaone-max-pending-patches"] !== undefined ? pending : DEFAULTS.maxPendingPatches,
|
|
266
|
+
minOs: minOsOption(ctx.opts["ninjaone-min-os"]),
|
|
267
|
+
servers: ctx.opts["ninjaone-include-servers"] === true,
|
|
268
|
+
};
|
|
269
|
+
return redact(await ninjaoneChecks(api, instance, o, ctx.now), [ctx.env[NINJAONE_ENV.secret]]);
|
|
270
|
+
},
|
|
271
|
+
};
|
|
@@ -30,9 +30,11 @@ export function entraClientAssertion(pem, tenant, clientId, nowSec) {
|
|
|
30
30
|
const thumb = createHash("sha256").update(new X509Certificate(certPem).raw).digest("base64url");
|
|
31
31
|
return jwt({ alg: "PS256", typ: "JWT", "x5t#S256": thumb }, { aud: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, iss: clientId, sub: clientId, jti: randomUUID(), nbf: nowSec, iat: nowSec, exp: nowSec + 600 }, pem, true);
|
|
32
32
|
}
|
|
33
|
-
/** The signed assertion a Google service account exchanges for an access token as `subject
|
|
33
|
+
/** The signed assertion a Google service account exchanges for an access token: as `subject` (domain-wide delegation),
|
|
34
|
+
* or as itself when `subject` is undefined. Google: `sub` is "the email address of the user for which the application
|
|
35
|
+
* is requesting delegated access" (developers.google.com/identity/protocols/oauth2/service-account, read 2026-10-28). */
|
|
34
36
|
export function googleAssertion(key, subject, scopes, nowSec) {
|
|
35
|
-
return jwt({ alg: "RS256", typ: "JWT" }, { iss: key.client_email, sub: subject, scope: scopes.join(" "), aud: key.token_uri ?? "https://oauth2.googleapis.com/token", iat: nowSec, exp: nowSec + 3600 }, key.private_key, false);
|
|
37
|
+
return jwt({ alg: "RS256", typ: "JWT" }, { iss: key.client_email, ...(subject ? { sub: subject } : {}), scope: scopes.join(" "), aud: key.token_uri ?? "https://oauth2.googleapis.com/token", iat: nowSec, exp: nowSec + 3600 }, key.private_key, false);
|
|
36
38
|
}
|
|
37
39
|
/** Posts a form to a token endpoint and returns the whole answer, which holds an access_token. */
|
|
38
40
|
export async function tokenGrant(url, form, fetchFn = fetch) {
|