@sagentix-inc/evidence-collector 0.2.1 → 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.
@@ -0,0 +1,345 @@
1
+ // ManageEngine Endpoint Central (cloud edition) over its REST API (GET only), with a Zoho self client whose refresh
2
+ // token was generated for READ operations only: DesktopCentralCloud.Inventory.READ and DesktopCentralCloud.PatchMgmt.READ.
3
+ // The refresh-token exchange at the Zoho accounts server is the only POST.
4
+ //
5
+ // Auth (manageengine.com/products/desktop-central/help/api/cloud/, read 2026-10-28):
6
+ // oauth-authentication-self-client-authorization-code-flow.html: a self client in the Zoho API console generates a
7
+ // code for comma-separated scopes; POST {accounts-server-url}/oauth/v2/token with the query parameters client_id,
8
+ // client_secret, grant_type=authorization_code, code gives a refresh_token that "won't expire"; the same endpoint with
9
+ // grant_type=refresh_token and refresh_token gives an access_token valid one hour (expires_in). The parameters are
10
+ // sent as the page shows them, in the query string, over https.
11
+ // Scopes are service.scope.operation; every endpoint below lists its scope with the operation READ (api-index.html).
12
+ // Header: "Authorization: Zoho-oauthtoken <access token>".
13
+ // oauth-authentication-endpoint-domain.md: nine data centres, EndpointCentral_Server_URI
14
+ // https://endpointcentral.manageengine.{com,eu,in,com.au,cn,jp,ca,uk,sa} with ZohoAccounts_Server_URI
15
+ // https://accounts.zoho.{com,eu,in,com.au,cn,jp,ca,uk,sa}. accounts.zoho.com/oauth/serverinfo (read the same day)
16
+ // gives https://accounts.zohocloud.ca for Canada instead, so ENDPOINT_CENTRAL_ACCOUNTS_URL can override the default.
17
+ // The token acts as the user who generated it, and "UAC_UNAUTHORIZED - user lacks the required uem-roles" is a 401: the
18
+ // predefined Guest role has "Read Access" to Inventory and Patch Management (help/access-management/
19
+ // user_role_and_permission.html, read 2026-10-28).
20
+ //
21
+ // Endpoints and fields (each page under help/api/cloud/, read 2026-10-28):
22
+ // GET /dcapi/inventory/viewData/scanComputers?toolID=2131 Accept: application/InventoryMobileAPI.v1+json
23
+ // -> { metadata: { limit, page }, totalRecords, totalPages, Links: { next, prev }, messageResponse: [ { resource_id,
24
+ // servicetag, os_platform (1 Windows, 2 Mac, 3 Linux), os_version, build_number, installation_status
25
+ // (21 yet to install, 22 installed, 23 uninstalled, 24 yet to uninstall, 29 installation failure),
26
+ // agent_last_contact_time (epoch ms), resource_name, domain_netbios_name, ... } ] } Threshold 120 a minute.
27
+ // GET /dcapi/inventory/computers/{resourceID}/diskEncryptionStatus Accept: application/diskEncryptionStatus.v1+json
28
+ // -> { diskEncryptionStatusDetails: [ { logicalDriveName, encryptionStatus ("Encrypted", "Not Encrypted"),
29
+ // logicalDiskType, encryptionMethod } ] } Threshold 120 a minute.
30
+ // GET /dcapi/inventory/computers/{resourceID}/firewall Accept: application/firewallInfo.v1+json
31
+ // -> { windowsFirewallDetails: [ { domainProfileStatus, privateProfileStatus, publicProfileStatus,
32
+ // isExternalFirewallEnabled, externalFirewallName } ], externalFirewallDetails: [ { firewallName,
33
+ // protectionStatus, ... } ] } Threshold 120 a minute.
34
+ // GET /dcapi/patch/reports/systemHealth?page&pageLimit (scope DesktopCentralCloud.PatchMgmt.READ)
35
+ // -> { totalPages, Links: { next }, messageResponse: [ { ComputerName, Domain, Health, MissingOsPatches,
36
+ // Critical, Important, ... } ] } Threshold 50 a minute.
37
+ // Each page states "Lock period: 5 minutes" once a threshold is passed, so the per-computer calls are spaced by
38
+ // --endpoint-central-call-gap-ms (default 600 ms: 100 computers a minute, under 120). Pages follow Links.next, a path
39
+ // such as "/dcapi/patch/reports/systemHealth?page=2&pageLimit=25", until it is null.
40
+ // The patch report carries no resource id, so a row is matched to a computer by ComputerName and Domain (the scan
41
+ // list's resource_name and domain_netbios_name); a name that two computers share is skipped, never guessed.
42
+ //
43
+ // Left out: the screen lock (no Inventory or Patch endpoint reports it), and the on-premises edition: its documented API
44
+ // (/api/1.4/desktop/authentication) signs in with a console user's password, and no page documents the /dcapi
45
+ // endpoints or a read-only API key for it.
46
+ //
47
+ // Personal data: a computer is named by its resource_id and service tag. resource_name, the logged-on user (name), FQDN,
48
+ // IP and MAC addresses, and the patch report's owner and user fields are never put in a result.
49
+ import { cachedToken, tokenRequest } from "./oauth.js";
50
+ import { Emitter, enc, fetchRest, isObj, ok, realSleep, redact, str, why, withRetry, listRead, stampRead } from "./readonly_http.js";
51
+ import { cmpVersion, deviceResource, minOsOption, posInt, showVersion, versionIn } from "./device_common.js";
52
+ export const EC_ENV = {
53
+ url: "ENDPOINT_CENTRAL_URL", client: "ENDPOINT_CENTRAL_CLIENT_ID", secret: "ENDPOINT_CENTRAL_CLIENT_SECRET",
54
+ refresh: "ENDPOINT_CENTRAL_REFRESH_TOKEN", accounts: "ENDPOINT_CENTRAL_ACCOUNTS_URL",
55
+ };
56
+ /** Data centre suffixes from the "Multiple data centres" page: endpointcentral.manageengine.<suffix>, accounts.zoho.<suffix>. */
57
+ export const EC_SUFFIXES = ["com", "eu", "in", "com.au", "cn", "jp", "ca", "uk", "sa"];
58
+ export const EC_SCOPES = ["DesktopCentralCloud.Inventory.READ", "DesktopCentralCloud.PatchMgmt.READ"];
59
+ export const EC_CHECKS = ["disk_encryption", "firewall", "os_minimum", "os_patches", "last_contact", "agent_installed"];
60
+ export const DEFAULTS = { contactDays: 14, maxComputers: 500, gapMs: 600, maxMissingOsPatches: 0 };
61
+ export const PATCH_PAGE = 100;
62
+ export const SCAN_PATH = "/dcapi/inventory/viewData/scanComputers?toolID=2131";
63
+ export const healthPath = (page) => `/dcapi/patch/reports/systemHealth?page=${page}&pageLimit=${PATCH_PAGE}`;
64
+ export const encryptionPath = (id) => `/dcapi/inventory/computers/${enc(id)}/diskEncryptionStatus`;
65
+ export const firewallPath = (id) => `/dcapi/inventory/computers/${enc(id)}/firewall`;
66
+ /** The Accept header each endpoint's page marks Mandatory. */
67
+ export function acceptFor(path) {
68
+ if (path.includes("/viewData/scanComputers"))
69
+ return "application/InventoryMobileAPI.v1+json";
70
+ if (path.includes("/diskEncryptionStatus"))
71
+ return "application/diskEncryptionStatus.v1+json";
72
+ if (path.includes("/firewall"))
73
+ return "application/firewallInfo.v1+json";
74
+ return "application/json";
75
+ }
76
+ /** The data centre suffix of an Endpoint Central cloud URL, or undefined for any other host. */
77
+ export function suffixOf(host) {
78
+ const m = host.match(/^endpointcentral\.manageengine\.(.+)$/);
79
+ return m && EC_SUFFIXES.includes(m[1]) ? m[1] : undefined;
80
+ }
81
+ /** An Endpoint Central cloud client from ENDPOINT_CENTRAL_* variables, or what is missing. */
82
+ export function endpointCentralFromEnv(env, fetchFn = fetch) {
83
+ const url = env[EC_ENV.url], client = env[EC_ENV.client], secret = env[EC_ENV.secret], refresh = env[EC_ENV.refresh];
84
+ if (!url || !client || !secret || !refresh)
85
+ return `${EC_ENV.url}, ${EC_ENV.client}, ${EC_ENV.secret} and ${EC_ENV.refresh} are not all set`;
86
+ let u;
87
+ try {
88
+ u = new URL(url);
89
+ }
90
+ catch {
91
+ return `${EC_ENV.url} is not a URL`;
92
+ }
93
+ if (u.protocol !== "https:")
94
+ return `${EC_ENV.url} must be https`;
95
+ const suffix = suffixOf(u.host);
96
+ if (!suffix)
97
+ return `${EC_ENV.url} must be https://endpointcentral.manageengine.<${EC_SUFFIXES.join("|")}> (the cloud edition; on-premises servers are not supported)`;
98
+ let accounts = `https://accounts.zoho.${suffix}`;
99
+ if (env[EC_ENV.accounts]) {
100
+ let a;
101
+ try {
102
+ a = new URL(env[EC_ENV.accounts]);
103
+ }
104
+ catch {
105
+ return `${EC_ENV.accounts} is not a URL`;
106
+ }
107
+ if (a.protocol !== "https:" || !/^accounts\.(zoho|zohocloud)\.[a-z.]+$/.test(a.host))
108
+ return `${EC_ENV.accounts} must be a https Zoho accounts server, e.g. https://accounts.zoho.eu`;
109
+ accounts = a.origin;
110
+ }
111
+ const qs = new URLSearchParams({ client_id: client, client_secret: secret, grant_type: "refresh_token", refresh_token: refresh }).toString();
112
+ let current = "";
113
+ const token = cachedToken(async () => { const t = await tokenRequest(`${accounts}/oauth/v2/token?${qs}`, {}, fetchFn); current = t.token; return t; });
114
+ const clients = new Map();
115
+ const clientFor = (accept) => {
116
+ let c = clients.get(accept);
117
+ if (!c) {
118
+ c = fetchRest({ base: u.origin, fetchFn, headers: async () => ({ authorization: `Zoho-oauthtoken ${await token()}`, accept }) });
119
+ clients.set(accept, c);
120
+ }
121
+ return c;
122
+ };
123
+ return {
124
+ async get(path) {
125
+ const r = await clientFor(acceptFor(path)).get(path);
126
+ // fetchRest cuts the whole header value out of an answer; the bare access token is cut here.
127
+ if (current.length >= 8 && JSON.stringify(r.body ?? "").includes(current)) {
128
+ const text = JSON.stringify(r.body).split(current).join("[redacted]");
129
+ return { ...r, body: JSON.parse(text) };
130
+ }
131
+ return r;
132
+ },
133
+ };
134
+ }
135
+ const rowsOf = (b) => (isObj(b) && Array.isArray(b.messageResponse) ? b.messageResponse.filter(isObj) : undefined);
136
+ const nextOf = (b) => (isObj(b) && isObj(b.Links) ? str(b.Links.next) : undefined);
137
+ /** The scan computers list, following Links.next, up to `max` computers. */
138
+ async function listComputers(api, max) {
139
+ const items = [];
140
+ let url = SCAN_PATH;
141
+ let pages = 0;
142
+ const seen = new Set();
143
+ while (url && !seen.has(url)) {
144
+ seen.add(url);
145
+ const r = await api.get(url);
146
+ const got = ok(r) ? rowsOf(r.body) : undefined;
147
+ if (!got)
148
+ return { error: r };
149
+ pages++;
150
+ items.push(...got);
151
+ if (items.length >= max)
152
+ return { items: items.slice(0, max), truncated: items.length > max || !!nextOf(r.body), pages };
153
+ url = nextOf(r.body);
154
+ }
155
+ return { items, truncated: false, pages };
156
+ }
157
+ /** The system health report's rows, by lower-cased "ComputerName|Domain"; a key two rows share maps to null. */
158
+ async function systemHealth(api, maxPages) {
159
+ const rows = new Map();
160
+ for (let page = 1; page <= maxPages; page++) {
161
+ const r = await api.get(healthPath(page));
162
+ const got = ok(r) ? rowsOf(r.body) : undefined;
163
+ if (!got)
164
+ return { error: r };
165
+ for (const row of got) {
166
+ const k = `${String(row.ComputerName ?? "").toLowerCase()}|${String(row.Domain ?? "").toLowerCase()}`;
167
+ rows.set(k, rows.has(k) ? null : row);
168
+ }
169
+ const total = isObj(r.body) ? Number(r.body.totalPages) : NaN;
170
+ if (!got.length || !nextOf(r.body) || (Number.isFinite(total) && page >= total))
171
+ break;
172
+ }
173
+ return { rows };
174
+ }
175
+ const PLATFORM = { "1": "windows", "2": "macos", "3": "linux" };
176
+ const INSTALL = { "21": "yet to install", "22": "installed", "23": "uninstalled", "24": "yet to uninstall", "29": "installation failure" };
177
+ const n = (v) => { const x = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN; return Number.isFinite(x) ? x : undefined; };
178
+ function judgeEncryption(body) {
179
+ const list = isObj(body) && Array.isArray(body.diskEncryptionStatusDetails) ? body.diskEncryptionStatusDetails.filter(isObj) : undefined;
180
+ if (!list)
181
+ return { result: "skipped", detail: "diskEncryptionStatusDetails not reported" };
182
+ const known = list.filter((d) => d.encryptionStatus === "Encrypted" || d.encryptionStatus === "Not Encrypted");
183
+ if (!known.length)
184
+ return { result: "skipped", detail: `no drive reports an encryptionStatus of Encrypted or Not Encrypted (${list.length} listed)` };
185
+ const off = known.filter((d) => d.encryptionStatus === "Not Encrypted");
186
+ const name = (d) => String(d.logicalDriveName ?? "?").replace(/[^A-Za-z0-9:/\\._-]/g, "").slice(0, 40);
187
+ if (off.length)
188
+ return { result: "fail", detail: `${off.length} of ${known.length} drive(s) Not Encrypted: ${off.slice(0, 5).map(name).join(", ")}; every reported drive must be Encrypted` };
189
+ return { result: "pass", detail: `all ${known.length} drive(s) report Encrypted (${known.slice(0, 5).map((d) => `${name(d)} ${String(d.encryptionMethod ?? "").slice(0, 20)}`.trim()).join(", ")})` };
190
+ }
191
+ function judgeFirewall(body) {
192
+ if (!isObj(body))
193
+ return { result: "skipped", detail: "firewall details not reported" };
194
+ const win = Array.isArray(body.windowsFirewallDetails) ? body.windowsFirewallDetails.filter(isObj)[0] : undefined;
195
+ const ext = Array.isArray(body.externalFirewallDetails) ? body.externalFirewallDetails.filter(isObj) : [];
196
+ const extOn = ext.filter((e) => e.protectionStatus === "Enabled");
197
+ if (extOn.length)
198
+ return { result: "pass", detail: `a third-party firewall reports protectionStatus Enabled (${extOn.length} of ${ext.length} listed)` };
199
+ if (!win)
200
+ return { result: "skipped", detail: `no Windows Firewall profile and no enabled third-party firewall reported (${ext.length} third-party listed)` };
201
+ const prof = { domain: str(win.domainProfileStatus), private: str(win.privateProfileStatus), public: str(win.publicProfileStatus) };
202
+ const shown = Object.entries(prof).map(([k, v]) => `${k} ${v ?? "not reported"}`).join(", ");
203
+ if (Object.values(prof).every((v) => v === "Enabled"))
204
+ return { result: "pass", detail: `Windows Firewall profiles: ${shown}` };
205
+ if (Object.values(prof).some((v) => v === undefined))
206
+ return { result: "skipped", detail: `Windows Firewall profiles not all reported: ${shown}` };
207
+ return { result: "fail", detail: `Windows Firewall profiles: ${shown}; all three must be Enabled, and no third-party firewall reports Enabled` };
208
+ }
209
+ export async function endpointCentralChecks(api, server, o, now, sleep) {
210
+ const out = new Emitter("endpoint_central", `endpoint_central ${server}`);
211
+ const got = await listComputers(api, o.maxComputers);
212
+ if ("error" in got) {
213
+ for (const c of EC_CHECKS)
214
+ out.skip(c, got.error, "scan computers list");
215
+ return out.results;
216
+ }
217
+ if (!got.items.length) {
218
+ out.skipAll(EC_CHECKS, "Endpoint Central lists no computer; nothing to judge");
219
+ return out.results;
220
+ }
221
+ const cap = got.truncated ? `; only the first ${o.maxComputers} computers read (--endpoint-central-max-computers)` : "";
222
+ const from = out.results.length;
223
+ if (!o.minOs.size)
224
+ out.add("os_minimum", "skipped", `no minimum OS version given (--endpoint-central-min-os, e.g. windows=10.0.26100 or macos=15.0); ${got.items.length} computer(s) not judged`);
225
+ const health = await systemHealth(api, Math.ceil(o.maxComputers / PATCH_PAGE) + 2);
226
+ if ("error" in health)
227
+ out.skip("os_patches", health.error, "patch system health report");
228
+ const denied = new Map();
229
+ let calls = 0;
230
+ for (const c of got.items) {
231
+ const id = String(c.resource_id ?? "");
232
+ const res = deviceResource("endpoint_central computer", id || "?", c.servicetag);
233
+ const platform = PLATFORM[String(c.os_platform)] ?? "unknown";
234
+ const ver = str(c.os_version);
235
+ const where = ` (${platform}${ver ? ` ${ver.slice(0, 30)}` : ""})`;
236
+ const inst = String(c.installation_status ?? "");
237
+ if (inst === "22")
238
+ out.add("agent_installed", "pass", `installation_status 22 (installed)${where}${cap}`, res);
239
+ else if (INSTALL[inst])
240
+ out.add("agent_installed", "fail", `installation_status ${inst} (${INSTALL[inst]}); 22 (installed) required${where}`, res);
241
+ else
242
+ out.add("agent_installed", "skipped", `installation_status ${inst || "not reported"}${where}`, res);
243
+ const ms = n(c.agent_last_contact_time);
244
+ if (ms === undefined || ms <= 0)
245
+ out.add("last_contact", "skipped", `agent_last_contact_time not reported${where}`, res);
246
+ else {
247
+ const age = Math.floor((now.getTime() - ms) / 86_400_000);
248
+ out.add("last_contact", age <= o.contactDays ? "pass" : "fail", `agent last contact ${age} day(s) ago; at most ${o.contactDays} allowed (--endpoint-central-contact-days)${where}`, res);
249
+ }
250
+ if (o.minOs.size) {
251
+ const floor = o.minOs.get(platform) ?? o.minOs.get("*");
252
+ const have = versionIn(ver);
253
+ if (!floor)
254
+ out.add("os_minimum", "skipped", `no --endpoint-central-min-os floor for ${platform}${where}`, res);
255
+ else if (!have)
256
+ out.add("os_minimum", "skipped", `os_version not reported${where}`, res);
257
+ else {
258
+ const okv = cmpVersion(have, floor) >= 0;
259
+ out.add("os_minimum", okv ? "pass" : "fail", `${platform} ${showVersion(have)} ${okv ? "meets" : "is below"} the --endpoint-central-min-os floor ${showVersion(floor)}${where}`, res);
260
+ }
261
+ }
262
+ if (!("error" in health)) {
263
+ const key = `${String(c.resource_name ?? "").toLowerCase()}|${String(c.domain_netbios_name ?? "").toLowerCase()}`;
264
+ const row = health.rows.get(key);
265
+ const missing = row ? n(row.MissingOsPatches) : undefined;
266
+ if (row === null)
267
+ out.add("os_patches", "skipped", `two or more patch report rows share this computer's name and domain; not matched${where}`, res);
268
+ else if (!row)
269
+ out.add("os_patches", "skipped", `no row for this computer in the patch system health report${where}`, res);
270
+ else if (missing === undefined)
271
+ out.add("os_patches", "skipped", `MissingOsPatches not reported (Health ${String(row.Health ?? "not reported").slice(0, 30)})${where}`, res);
272
+ else {
273
+ const pass = missing <= o.maxMissingOsPatches;
274
+ out.add("os_patches", pass ? "pass" : "fail", `${missing} missing OS patch(es) (Critical ${String(row.Critical ?? "?").slice(0, 8)}, Important ${String(row.Important ?? "?").slice(0, 8)}, Health ${String(row.Health ?? "?").slice(0, 30)}); at most ${o.maxMissingOsPatches} allowed (--endpoint-central-max-missing-os-patches)${where}`, res);
275
+ }
276
+ }
277
+ // One disk-encryption and one firewall call per computer with an installed agent, spaced under the 120-a-minute threshold.
278
+ for (const [check, path, judge] of [["disk_encryption", encryptionPath, judgeEncryption], ["firewall", firewallPath, judgeFirewall]]) {
279
+ const stop = denied.get(check);
280
+ if (stop) {
281
+ out.skip(check, stop, check === "firewall" ? "computer firewall details" : "disk encryption status", res);
282
+ continue;
283
+ }
284
+ if (!id) {
285
+ out.add(check, "skipped", `no resource_id${where}`, res);
286
+ continue;
287
+ }
288
+ if (inst !== "22") {
289
+ out.add(check, "skipped", `the agent is not installed (installation_status ${inst || "not reported"}), so inventory has no current value${where}`, res);
290
+ continue;
291
+ }
292
+ if (calls++ > 0 && o.gapMs > 0)
293
+ await sleep(o.gapMs);
294
+ const r = await api.get(path(id));
295
+ if (!ok(r)) {
296
+ if (r.status === 401 || r.status === 403)
297
+ denied.set(check, r);
298
+ out.add(check, "skipped", `${check === "firewall" ? "computer firewall details" : "disk encryption status"} not readable: ${why(r)}${where}`, res, { method: "config-read", apiError: { status: r.status, message: why(r) } });
299
+ continue;
300
+ }
301
+ const v = judge(r.body);
302
+ out.add(check, v.result, `${v.detail}${where}`, res);
303
+ }
304
+ }
305
+ stampRead(out.results, from, listRead(got));
306
+ return out.results;
307
+ }
308
+ export const endpoint_central = {
309
+ name: "endpoint_central",
310
+ options: {
311
+ "endpoint-central-contact-days": { type: "string", help: `days since a computer's last agent contact before last_contact fails (default ${DEFAULTS.contactDays})` },
312
+ "endpoint-central-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)" },
313
+ "endpoint-central-max-missing-os-patches": { type: "string", help: `missing OS patches a computer may have before os_patches fails (default ${DEFAULTS.maxMissingOsPatches})` },
314
+ "endpoint-central-max-computers": { type: "string", help: `computers read, at most (default ${DEFAULTS.maxComputers}); each costs two inventory calls` },
315
+ "endpoint-central-call-gap-ms": { type: "string", help: `pause between per-computer calls, in ms (default ${DEFAULTS.gapMs}: under the documented 120 calls a minute)` },
316
+ },
317
+ emits: EC_CHECKS.map((c) => `endpoint_central.${c}`),
318
+ describe: () => "ManageEngine Endpoint Central cloud server in ENDPOINT_CENTRAL_URL (Zoho self client, Inventory and PatchMgmt READ scopes)",
319
+ async run(ctx) {
320
+ const server = (() => { try {
321
+ return new URL(ctx.env[EC_ENV.url] ?? "").host || "(unknown)";
322
+ }
323
+ catch {
324
+ return "(unknown)";
325
+ } })();
326
+ const given = ctx.clients.endpoint_central;
327
+ const built = given !== undefined ? given : endpointCentralFromEnv(ctx.env);
328
+ if (!built || typeof built === "string") {
329
+ const out = new Emitter("endpoint_central", `endpoint_central ${server}`);
330
+ out.skipAll(EC_CHECKS, typeof built === "string" ? built : `${EC_ENV.refresh} is not set`);
331
+ return out.results;
332
+ }
333
+ const sleep = ctx.clients.sleep ?? realSleep;
334
+ const api = withRetry(built, sleep);
335
+ const nonNeg = (v, d) => { const x = typeof v === "string" && v.trim() !== "" ? Number(v) : NaN; return Number.isInteger(x) && x >= 0 ? x : d; };
336
+ const o = {
337
+ contactDays: posInt(ctx.opts["endpoint-central-contact-days"], DEFAULTS.contactDays),
338
+ maxComputers: posInt(ctx.opts["endpoint-central-max-computers"], DEFAULTS.maxComputers),
339
+ gapMs: nonNeg(ctx.opts["endpoint-central-call-gap-ms"], DEFAULTS.gapMs),
340
+ maxMissingOsPatches: nonNeg(ctx.opts["endpoint-central-max-missing-os-patches"], DEFAULTS.maxMissingOsPatches),
341
+ minOs: minOsOption(ctx.opts["endpoint-central-min-os"]),
342
+ };
343
+ return redact(await endpointCentralChecks(api, server, o, ctx.now, sleep), [ctx.env[EC_ENV.secret], ctx.env[EC_ENV.refresh]]);
344
+ },
345
+ };
@@ -1,9 +1,45 @@
1
1
  // Read-only Google Cloud REST client. GET only, plus POST to `:getIamPolicy` and `:getAncestry` (reads that Google
2
2
  // exposes as POST); the fetch client refuses any other POST, so a bug here cannot change a project.
3
3
  // The bearer token comes from GOOGLE_OAUTH_ACCESS_TOKEN (google-github-actions/auth with
4
- // token_format: access_token). Tests pass a fake transport; nothing in tests touches the network.
4
+ // token_format: access_token), or, on a server or in a container (2026-10-28: Docker is the default runner, and an
5
+ // hour-long token cannot be scheduled), from a read-only service account's key file named by GCP_SA_KEY_FILE: the
6
+ // collector signs a JWT as the service account itself (no `sub`) and exchanges it for an hour's token, scope
7
+ // cloud-platform (the default of google-github-actions/auth); the service account's read-only roles bound what it reads.
8
+ // Tests pass a fake transport; nothing in tests touches the network.
9
+ import { readFileSync } from "node:fs";
5
10
  import { isObj, str, withRetry, httpWhy } from "../shared-cloud.js";
11
+ import { cachedToken, googleAssertion, tokenRequest } from "../oauth.js";
12
+ import { isGoogleTokenUri } from "../google_workspace.js";
6
13
  export const GCP_TOKEN_ENV = "GOOGLE_OAUTH_ACCESS_TOKEN";
14
+ export const GCP_KEY_ENV = "GCP_SA_KEY_FILE";
15
+ export const GCP_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
16
+ const GOOGLE_TOKEN = "https://oauth2.googleapis.com/token";
17
+ /** An access token from the key file in GCP_SA_KEY_FILE, renewed when it ends; why not, as text; undefined when unset. */
18
+ export function gcpKeyToken(env, fetchFn = fetch) {
19
+ const file = env[GCP_KEY_ENV];
20
+ if (!file)
21
+ return undefined;
22
+ let key;
23
+ try {
24
+ key = JSON.parse(readFileSync(file, "utf8"));
25
+ }
26
+ catch (e) {
27
+ return `${GCP_KEY_ENV} is not a readable service account key (${e.code ?? "bad JSON"})`;
28
+ }
29
+ if (typeof key.client_email !== "string" || typeof key.private_key !== "string")
30
+ return `${GCP_KEY_ENV} has no client_email or private_key`;
31
+ // Google's key format page shows token_uri "https://accounts.google.com/o/oauth2/token", and its OAuth page says the
32
+ // assertion's aud "is always https://oauth2.googleapis.com/token" (both read 2026-10-28): the older address goes there.
33
+ const named = key.token_uri ?? GOOGLE_TOKEN;
34
+ const tokenUri = named === "https://accounts.google.com/o/oauth2/token" ? GOOGLE_TOKEN : named;
35
+ // The key file names its own token endpoint; a tampered file could send the signed assertion elsewhere.
36
+ if (!isGoogleTokenUri(tokenUri))
37
+ return `${GCP_KEY_ENV} names a token_uri outside googleapis.com over HTTPS`;
38
+ return cachedToken(() => tokenRequest(tokenUri, {
39
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
40
+ assertion: googleAssertion({ ...key, token_uri: tokenUri }, undefined, [GCP_SCOPE], Math.floor(Date.now() / 1000)),
41
+ }, fetchFn));
42
+ }
7
43
  /**
8
44
  * The two reads Google exposes as POST: :getIamPolicy, and Resource Manager v1 projects.getAncestry ("Gets a list of
9
45
  * ancestors in the resource hierarchy for the Project ... The caller must have read permissions for this Project",
@@ -18,7 +54,7 @@ export function fetchGcpTransport(token, fetchFn = fetch) {
18
54
  }
19
55
  const res = await fetchFn(url, {
20
56
  method,
21
- headers: { authorization: `Bearer ${token}`, accept: "application/json", ...(body !== undefined ? { "content-type": "application/json" } : {}) },
57
+ headers: { authorization: `Bearer ${typeof token === "string" ? token : await token()}`, accept: "application/json", ...(body !== undefined ? { "content-type": "application/json" } : {}) },
22
58
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
23
59
  });
24
60
  const headers = {};
@@ -1,7 +1,7 @@
1
1
  // Google Cloud: per-project checks over Google's REST APIs with a read-only service account's access
2
- // token (GOOGLE_OAUTH_ACCESS_TOKEN). Every call is a GET, except POST :getIamPolicy and :getAncestry, which read.
2
+ // token (GOOGLE_OAUTH_ACCESS_TOKEN, or minted from the key file in GCP_SA_KEY_FILE; see gcp/api.ts). Every call is a GET, except POST :getIamPolicy and :getAncestry, which read.
3
3
  import { Results, allSkipped, list, scrub } from "./shared-cloud.js";
4
- import { GCP_TOKEN_ENV, Gcp, fetchGcpTransport } from "./gcp/api.js";
4
+ import { GCP_KEY_ENV, GCP_TOKEN_ENV, Gcp, fetchGcpTransport, gcpKeyToken } from "./gcp/api.js";
5
5
  import { DEFAULT_KEY_MAX_AGE_DAYS, DEFAULT_ORG_CONSTRAINTS, GCP_CHECKS, projectChecks } from "./gcp/project.js";
6
6
  export const gcp = {
7
7
  name: "gcp",
@@ -18,11 +18,13 @@ export const gcp = {
18
18
  async run(ctx) {
19
19
  const projects = list(ctx.opts["gcp-project"]);
20
20
  const token = ctx.env[GCP_TOKEN_ENV];
21
- const t = ctx.clients.gcp !== undefined ? ctx.clients.gcp : token ? fetchGcpTransport(token) : null;
21
+ const keyed = token ? undefined : gcpKeyToken(ctx.env);
22
+ const t = ctx.clients.gcp !== undefined ? ctx.clients.gcp
23
+ : token ? fetchGcpTransport(token) : typeof keyed === "function" ? fetchGcpTransport(keyed) : null;
22
24
  if (!projects.length)
23
25
  return allSkipped("gcp", GCP_CHECKS, "project (none listed)", "no --gcp-project given");
24
26
  if (!t)
25
- return projects.flatMap((p) => allSkipped("gcp", GCP_CHECKS, `project ${p}`, `${GCP_TOKEN_ENV} is not set`));
27
+ return projects.flatMap((p) => allSkipped("gcp", GCP_CHECKS, `project ${p}`, typeof keyed === "string" ? keyed : `${GCP_TOKEN_ENV} or ${GCP_KEY_ENV} is not set`));
26
28
  const constraints = list(ctx.opts["gcp-org-constraints"]);
27
29
  const maxAge = Number(ctx.opts["gcp-key-max-age-days"] ?? DEFAULT_KEY_MAX_AGE_DAYS);
28
30
  const api = new Gcp(t);
@@ -0,0 +1,171 @@
1
+ // Google endpoint management: the devices a Google Workspace or Cloud Identity customer manages (Windows, macOS,
2
+ // Linux and ChromeOS desktops reporting through Endpoint Verification, and Android and iOS phones), read through the
3
+ // Cloud Identity Devices API with GET only. Same credential as google_workspace: the service account in
4
+ // GOOGLE_SA_KEY_FILE acting as GOOGLE_ADMIN_EMAIL through domain-wide delegation, with one more read-only scope.
5
+ //
6
+ // Pages, all read 2026-10-28:
7
+ // GET https://cloudidentity.googleapis.com/v1/devices devices.list (docs.cloud.google.com/identity/docs/
8
+ // reference/rest/v1/devices/list, last updated 2025-12-16): customer "customers/my_customer" for your own
9
+ // organization; pageSize "at most 20 ... The maximum value is 100"; pageToken, "Empty if there are no more
10
+ // results" in nextPageToken; view USER_ASSIGNED_DEVICES "all devices with at least one user registered on the
11
+ // device ... all device information, except for asset tags". Scopes: cloud-identity.devices.readonly (or the
12
+ // read-write ones, never asked for here).
13
+ // Device fields (discovery document cloudidentity v1, revision 20261026): name "devices/{device}", serialNumber,
14
+ // deviceType (ANDROID, IOS, GOOGLE_SYNC, WINDOWS, MAC_OS, LINUX, CHROME_OS, GOOGLEBOOK), osVersion ("Android
15
+ // 8.1.0"), lastSyncTime ("Most recent time when device synced with this service"), encryptionState
16
+ // (ENCRYPTED, NOT_ENCRYPTED, UNSUPPORTED_BY_DEVICE, ENCRYPTION_STATE_UNSPECIFIED), compromisedState (COMPROMISED,
17
+ // UNCOMPROMISED, COMPROMISED_STATE_UNSPECIFIED), endpointVerificationSpecificAttributes.additionalSignals, whose
18
+ // non-configurable attributes include is_os_native_firewall_enabled. The `name` description says "Device API
19
+ // scopes require that you use domain-wide delegation to access the API".
20
+ // docs.cloud.google.com/identity/docs/how-to/setup-devices (last updated 2026-10-26): "Authenticating directly to
21
+ // the Devices API using a service account isn't supported", so a service account with domain-wide delegation
22
+ // acting as an admin (with_subject) is the documented path.
23
+ // docs.cloud.google.com/endpoint-verification/docs/device-information (last updated 2026-10-26):
24
+ // is_os_native_firewall_enabled "indicates whether the operating system's built-in firewall is enabled", on
25
+ // macOS, ChromeOS, Windows and Linux; device os_version examples "MacOS 13.4.0", "ChromeOs 14541.0.0",
26
+ // "Windows 10.0.19045".
27
+ // Left out, with the reason:
28
+ // Screen lock: is_secured_with_screenlock is an access-level attribute on that page, but the Devices API Device
29
+ // resource has no such field, so it is not readable here.
30
+ // Android security patch (securityPatchTime): Android only; not judged.
31
+ // Rate limits: no Devices API quota page was found; a 429 or 403 rateLimitExceeded is retried (readonly_http.ts).
32
+ // The browser sign-in of google_workspace (google_oauth.ts) asks for the Admin SDK scopes only, so this source
33
+ // needs the service-account path.
34
+ //
35
+ // Personal data: a device is named by its id (from name) and serial. hostname, otherAccounts, wifiMacAddresses,
36
+ // imei, meid, assetTag and every browser profile attribute are never read into a result.
37
+ import { readFileSync } from "node:fs";
38
+ import { cachedToken, googleAssertion, tokenRequest } from "./oauth.js";
39
+ import { OAUTH_ENV } from "./google_oauth.js";
40
+ import { GOOGLE_ENV, isGoogleTokenUri } from "./google_workspace.js";
41
+ import { Emitter, enc, fetchRest, getAll, isObj, realSleep, redact, str, withRetry, listRead, stampRead } from "./readonly_http.js";
42
+ import { ageDays, cmpVersion, deviceResource, minOsOption, posInt, showVersion, versionIn } from "./device_common.js";
43
+ export const DEVICES_BASE = "https://cloudidentity.googleapis.com";
44
+ export const DEVICES_SCOPE = "https://www.googleapis.com/auth/cloud-identity.devices.readonly";
45
+ export const GE_CHECKS = ["disk_encryption", "firewall", "os_minimum", "last_sync", "compromised"];
46
+ export const DEFAULTS = { syncDays: 14, maxDevices: 5000 };
47
+ export const PAGE = 100;
48
+ export const devicesPath = (customer, pageToken) => `/v1/devices?customer=${enc(`customers/${customer}`)}&pageSize=${PAGE}&view=USER_ASSIGNED_DEVICES${pageToken ? `&pageToken=${enc(pageToken)}` : ""}`;
49
+ /** deviceType → the names --google-endpoints-min-os accepts for it (the lower-cased enum value always works). */
50
+ const ALIASES = { MAC_OS: ["macos"], CHROME_OS: ["chromeos"], WINDOWS: [], LINUX: [], ANDROID: [], IOS: [] };
51
+ /** A Cloud Identity client whose token holds only DEVICES_SCOPE, as GOOGLE_ADMIN_EMAIL, or what is missing. */
52
+ export function googleDevicesFromEnv(env, fetchFn = fetch) {
53
+ const file = env[GOOGLE_ENV.keyFile], admin = env[GOOGLE_ENV.admin];
54
+ if (!file && env[OAUTH_ENV.clientFile])
55
+ return `the browser sign-in does not ask for ${DEVICES_SCOPE}; this source needs the service-account path (${GOOGLE_ENV.keyFile} with domain-wide delegation)`;
56
+ if (!file || !admin)
57
+ return `${GOOGLE_ENV.keyFile} and ${GOOGLE_ENV.admin} are not both set`;
58
+ let key;
59
+ try {
60
+ key = JSON.parse(readFileSync(file, "utf8"));
61
+ }
62
+ catch (e) {
63
+ return `${GOOGLE_ENV.keyFile} is not a readable service account key (${e.code ?? "bad JSON"})`;
64
+ }
65
+ if (typeof key.client_email !== "string" || typeof key.private_key !== "string")
66
+ return `${GOOGLE_ENV.keyFile} has no client_email or private_key`;
67
+ const tokenUri = key.token_uri ?? "https://oauth2.googleapis.com/token";
68
+ // The key file names its own token endpoint; a tampered file could send the signed assertion elsewhere.
69
+ if (!isGoogleTokenUri(tokenUri))
70
+ return `${GOOGLE_ENV.keyFile} names a token_uri outside googleapis.com over HTTPS`;
71
+ const token = cachedToken(() => tokenRequest(tokenUri, {
72
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
73
+ assertion: googleAssertion(key, admin, [DEVICES_SCOPE], Math.floor(Date.now() / 1000)),
74
+ }, fetchFn));
75
+ return fetchRest({ base: DEVICES_BASE, fetchFn, headers: async () => ({ authorization: `Bearer ${await token()}` }) });
76
+ }
77
+ const sub = (o, k) => (isObj(o[k]) ? o[k] : {});
78
+ export async function googleEndpointChecks(api, customer, o, now) {
79
+ const out = new Emitter("google_endpoints", `google endpoints customer ${customer}`);
80
+ const maxPages = Math.ceil(o.maxDevices / PAGE);
81
+ const got = await getAll(api, devicesPath(customer), (b) => (isObj(b) ? (Array.isArray(b.devices) ? b.devices : []) : undefined), (r) => { const t = isObj(r.body) ? str(r.body.nextPageToken) : undefined; return t ? devicesPath(customer, t) : undefined; }, maxPages);
82
+ const need = `the service account's domain-wide delegation needs ${DEVICES_SCOPE}, acting as ${GOOGLE_ENV.admin}`;
83
+ if ("error" in got) {
84
+ for (const c of GE_CHECKS)
85
+ out.skip(c, got.error, `Cloud Identity devices (${need})`);
86
+ return out.results;
87
+ }
88
+ const devices = got.items.filter(isObj).slice(0, o.maxDevices);
89
+ const truncated = got.truncated || got.items.length > o.maxDevices;
90
+ if (!devices.length) {
91
+ out.skipAll(GE_CHECKS, "Cloud Identity lists no device with a registered user; nothing to judge");
92
+ return out.results;
93
+ }
94
+ const cap = truncated ? `; only the first ${o.maxDevices} devices read (--google-endpoints-max-devices)` : "";
95
+ const from = out.results.length;
96
+ if (!o.minOs.size)
97
+ out.add("os_minimum", "skipped", `no minimum OS version given (--google-endpoints-min-os, e.g. mac_os=15.0, windows=10.0.26100, chrome_os=16000); ${devices.length} device(s) not judged`);
98
+ for (const d of devices) {
99
+ const id = String(d.name ?? "?").replace(/^devices\//, "");
100
+ const res = deviceResource("google device", id, d.serialNumber);
101
+ const type = str(d.deviceType) ?? "DEVICE_TYPE_UNSPECIFIED";
102
+ const where = ` (${type}, ${str(d.osVersion) ?? "OS version not reported"})`;
103
+ const es = str(d.encryptionState);
104
+ if (es === "ENCRYPTED")
105
+ out.add("disk_encryption", "pass", `encryptionState is ENCRYPTED${where}${cap}`, res);
106
+ else if (es === "NOT_ENCRYPTED")
107
+ out.add("disk_encryption", "fail", `encryptionState is NOT_ENCRYPTED; ENCRYPTED required${where}`, res);
108
+ else
109
+ out.add("disk_encryption", "skipped", `encryptionState is ${es ?? "not reported"}, which does not show encryption${where}`, res);
110
+ // Firewall: only Endpoint Verification reports it, in additionalSignals.
111
+ const ev = d.endpointVerificationSpecificAttributes;
112
+ const fw = isObj(ev) ? sub(ev, "additionalSignals").is_os_native_firewall_enabled : undefined;
113
+ if (typeof fw === "boolean")
114
+ out.add("firewall", fw ? "pass" : "fail", `Endpoint Verification is_os_native_firewall_enabled is ${fw}${where}`, res);
115
+ else
116
+ out.add("firewall", "skipped", `${isObj(ev) ? "Endpoint Verification reports no is_os_native_firewall_enabled" : "not an Endpoint Verification device, so no firewall signal"}${where}`, res);
117
+ if (o.minOs.size) {
118
+ const floor = [type.toLowerCase(), ...(ALIASES[type] ?? [])].map((k) => o.minOs.get(k)).find(Boolean) ?? o.minOs.get("*");
119
+ const have = versionIn(d.osVersion);
120
+ if (!floor)
121
+ out.add("os_minimum", "skipped", `no --google-endpoints-min-os floor for ${type}${where}`, res);
122
+ else if (!have)
123
+ out.add("os_minimum", "skipped", `osVersion not reported${where}`, res);
124
+ else {
125
+ const okv = cmpVersion(have, floor) >= 0;
126
+ out.add("os_minimum", okv ? "pass" : "fail", `osVersion ${showVersion(have)} ${okv ? "meets" : "is below"} the --google-endpoints-min-os floor ${showVersion(floor)}${where}`, res);
127
+ }
128
+ }
129
+ const age = ageDays(d.lastSyncTime, now);
130
+ if (age === undefined)
131
+ out.add("last_sync", "skipped", `lastSyncTime not reported${where}`, res);
132
+ else
133
+ out.add("last_sync", age <= o.syncDays ? "pass" : "fail", `last synced ${age} day(s) ago; at most ${o.syncDays} allowed (--google-endpoints-sync-days)${where}`, res);
134
+ const cs = str(d.compromisedState);
135
+ if (cs === "UNCOMPROMISED")
136
+ out.add("compromised", "pass", `compromisedState is UNCOMPROMISED${where}`, res);
137
+ else if (cs === "COMPROMISED")
138
+ out.add("compromised", "fail", `compromisedState is COMPROMISED${where}`, res);
139
+ else
140
+ out.add("compromised", "skipped", `compromisedState is ${cs ?? "not reported"}${where}`, res);
141
+ }
142
+ stampRead(out.results, from, listRead({ items: devices, truncated, pages: got.pages })); // a capped list leaves devices unseen
143
+ return out.results;
144
+ }
145
+ export const googleEndpoints = {
146
+ name: "google_endpoints",
147
+ options: {
148
+ "google-endpoints-sync-days": { type: "string", help: `days since a device last synced before last_sync fails (default ${DEFAULTS.syncDays})` },
149
+ "google-endpoints-min-os": { type: "string", multiple: true, help: "<deviceType>=<version> floor for os_minimum, e.g. mac_os=15.0, windows=10.0.26100, chrome_os=16000, android=15, ios=18.0 (repeatable; without it the check is skipped)" },
150
+ "google-endpoints-max-devices": { type: "string", help: `devices read, at most (default ${DEFAULTS.maxDevices})` },
151
+ },
152
+ emits: GE_CHECKS.map((c) => `google_endpoints.${c}`),
153
+ describe: () => "Google endpoint management for the customer in GOOGLE_CUSTOMER_ID (default my_customer), Cloud Identity Devices API",
154
+ async run(ctx) {
155
+ const customer = ctx.env[GOOGLE_ENV.customer] || "my_customer";
156
+ const given = ctx.clients.google_endpoints;
157
+ const built = given !== undefined ? given : googleDevicesFromEnv(ctx.env);
158
+ if (!built || typeof built === "string") {
159
+ const out = new Emitter("google_endpoints", `google endpoints customer ${customer}`);
160
+ out.skipAll(GE_CHECKS, typeof built === "string" ? built : "no Cloud Identity client");
161
+ return out.results;
162
+ }
163
+ const api = withRetry(built, ctx.clients.sleep ?? realSleep);
164
+ const o = {
165
+ syncDays: posInt(ctx.opts["google-endpoints-sync-days"], DEFAULTS.syncDays),
166
+ maxDevices: posInt(ctx.opts["google-endpoints-max-devices"], DEFAULTS.maxDevices),
167
+ minOs: minOsOption(ctx.opts["google-endpoints-min-os"]),
168
+ };
169
+ return redact(await googleEndpointChecks(api, customer, o, ctx.now), []);
170
+ },
171
+ };