@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.
- package/README.md +166 -5
- 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 +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Omnissa Workspace ONE UEM (formerly VMware Workspace ONE, earlier AirWatch) over its REST API, GET only, as an
|
|
2
|
+
// OAuth client whose role holds the single REST permission "REST API Devices Read". The client-credentials token
|
|
3
|
+
// request is the only POST.
|
|
4
|
+
//
|
|
5
|
+
// Auth, regions and the role, from "Using UEM Functionality With a REST API" (docs.omnissa.com/WorkspaceONE-UEM-Console-
|
|
6
|
+
// Basics-VSaaS/UsingUEMFunctionalityWithRESTAPI, page dated 2026-08-25), read 2026-10-28:
|
|
7
|
+
// OAuth 2.0 is "supported only in SaaS environments". Token URLs by data centre: United States and Canada
|
|
8
|
+
// https://na.uemauth.workspaceone.com/connect/token; United Kingdom and Germany https://emea.uemauth.workspaceone.com/
|
|
9
|
+
// connect/token; India, Japan, Singapore, Australia and Hong Kong https://apac.uemauth.workspaceone.com/connect/token;
|
|
10
|
+
// UAT https://uat.uemauth.workspaceone.com/connect/token.
|
|
11
|
+
// The client is made under Groups & Settings > Configurations > OAuth Client Management (Name, Description,
|
|
12
|
+
// Organization Group, Role); the secret is shown once. grant_type=client_credentials, form-encoded.
|
|
13
|
+
// Role permissions: REST > Devices > "REST API Devices Read", "Enables access to all READ only APIs in Devices
|
|
14
|
+
// collection", marked Read Only. No other permission is needed.
|
|
15
|
+
// The request shapes, from the Omnissa Developer Portal "Getting started with the Bruno collection"
|
|
16
|
+
// (developer.omnissa.com/workspace-one-uem-apis/bruno/), read 2026-10-28: the REST API host comes from Groups and
|
|
17
|
+
// Settings > All Settings > System > Advanced > API > REST API (e.g. as1234.awmdm.com); the token is sent as
|
|
18
|
+
// "Authorization: Bearer <access_token>" with "Accept: application/json;version=<n>"; the token request sends
|
|
19
|
+
// client_id and client_secret in the form body.
|
|
20
|
+
// Endpoints and fields, from the OpenAPI documents of release 2607 (developer.omnissa.com/workspace-one-uem-apis/
|
|
21
|
+
// versions/2607/mdmv3.json and mdmv1.json), read 2026-10-28:
|
|
22
|
+
// GET /api/mdm/devices/search?page=N&page_size=500 MDM API V3, Accept application/json;version=3
|
|
23
|
+
// "maximum pagesize limit of 500"; page is "0 based"; answer { devices: [...], page, page_size, total }.
|
|
24
|
+
// Device fields used: Uuid, Id.Value, serial_number, device_type (APPLE, ANDROID, WINDOWS_PC, APPLE_OS_X,
|
|
25
|
+
// CHROME_OS, LINUX ...), operating_system, last_seen ("Time the device last reported any status"),
|
|
26
|
+
// enrollment_status (ENROLLED, RETIRED, UNENROLLED ...), compliance_status (COMPLIANT, NOT_COMPLIANT, BLOCKED,
|
|
27
|
+
// QUARANTINED, PENDING_COMPLIANCE_CHECK, NOT_AVAILABLE, UNKNOWN ...), compromised_status (boolean).
|
|
28
|
+
// GET /api/mdm/devices/{uuid}/security/encryption-status MDM API V1, Accept application/json;version=1
|
|
29
|
+
// -> { encryption_status: boolean } "Encryption status of an enrolled device." One call per device.
|
|
30
|
+
// Left out, with the reason:
|
|
31
|
+
// Firewall: no device firewall field or endpoint in any of the four MDM documents.
|
|
32
|
+
// Screen lock / passcode: the only per-device passcode fields (IsPasscodePresent, IsPasscodeCompliant) come from
|
|
33
|
+
// GET /devices/{id}/security, whose same answer carries PersonalRecoveryKey, StaticRecoveryKey and UnlockPin. The
|
|
34
|
+
// collector does not request an answer that holds recovery keys, so screen lock is not checked here; a passcode
|
|
35
|
+
// rule set in a compliance policy still shows in compliant.
|
|
36
|
+
// Rate limits: none is stated on the pages read; a 429 is retried after its Retry-After (readonly_http.ts).
|
|
37
|
+
// On-premises consoles: OAuth is SaaS-only on the page above, so an on-premises UEM is not supported.
|
|
38
|
+
//
|
|
39
|
+
// Personal data: a device is named by its Uuid and serial. Device names, enrollment user names and email
|
|
40
|
+
// addresses, phone numbers, IMEI, MAC addresses and Wi-Fi SSIDs come back in the search answer and are never read
|
|
41
|
+
// into a result.
|
|
42
|
+
import { cachedToken, tokenRequest } from "./oauth.js";
|
|
43
|
+
import { Emitter, enc, fetchRest, isObj, ok, realSleep, redact, str, withRetry, listRead, stampRead } from "./readonly_http.js";
|
|
44
|
+
import { ageDays, cmpVersion, deviceResource, minOsOption, posInt, showVersion, versionIn } from "./device_common.js";
|
|
45
|
+
export const WS1_ENV = { url: "WS1_API_URL", tokenUrl: "WS1_TOKEN_URL", client: "WS1_CLIENT_ID", secret: "WS1_CLIENT_SECRET" };
|
|
46
|
+
export const WS1_CHECKS = ["disk_encryption", "os_minimum", "last_seen", "enrolled", "compliant", "compromised"];
|
|
47
|
+
export const DEFAULTS = { seenDays: 14, maxDevices: 2000 };
|
|
48
|
+
export const PAGE = 500;
|
|
49
|
+
/** The four documented token hosts: na, emea, apac and uat. */
|
|
50
|
+
export const TOKEN_HOSTS = ["na", "emea", "apac", "uat"].map((r) => `${r}.uemauth.workspaceone.com`);
|
|
51
|
+
export const searchPath = (page) => `/mdm/devices/search?page=${page}&page_size=${PAGE}`;
|
|
52
|
+
export const encryptionPath = (uuid) => `/mdm/devices/${enc(uuid)}/security/encryption-status`;
|
|
53
|
+
/** device_type → the names --workspace-one-min-os accepts for it (the enum value itself always works). */
|
|
54
|
+
const PLATFORM_ALIASES = {
|
|
55
|
+
APPLE_OS_X: ["macos"], WINDOWS_PC: ["windows"], APPLE: ["ios"], ANDROID: ["android"], CHROME_OS: ["chromeos"], CHROME_BOOK: ["chromeos"], LINUX: ["linux"],
|
|
56
|
+
};
|
|
57
|
+
/** Enrollment states of a device no longer in service: reported once as enrolled skipped, and not judged further. */
|
|
58
|
+
const GONE = new Set(["RETIRED", "UNENROLLED", "UNENROLLED_BY_FEEDBACK_SERVICE", "DEVICE_NOT_FOUND", "ENTERPRISE_WIPE_PENDING", "DEVICE_WIPE_PENDING", "WIPE_INITIATED", "BLACKLISTED"]);
|
|
59
|
+
const NOT_YET = new Set(["DISCOVERED", "REGISTERED", "ENROLLMENT_IN_PROGRESS", "PENDING_AGENT", "APP_CATALOG_ONLY"]);
|
|
60
|
+
const BAD_COMPLIANCE = new Set(["NOT_COMPLIANT", "BLOCKED", "QUARANTINED"]);
|
|
61
|
+
/** Workspace ONE UEM clients (search on version 3, encryption on version 1) from WS1_* variables, or what is missing. */
|
|
62
|
+
export function ws1FromEnv(env, fetchFn = fetch) {
|
|
63
|
+
const url = env[WS1_ENV.url], tokenUrl = env[WS1_ENV.tokenUrl], client = env[WS1_ENV.client], secret = env[WS1_ENV.secret];
|
|
64
|
+
if (!url || !tokenUrl || !client || !secret)
|
|
65
|
+
return `${WS1_ENV.url}, ${WS1_ENV.tokenUrl}, ${WS1_ENV.client} and ${WS1_ENV.secret} are not all set`;
|
|
66
|
+
let u, t;
|
|
67
|
+
try {
|
|
68
|
+
u = new URL(url);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return `${WS1_ENV.url} is not a URL`;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
t = new URL(tokenUrl);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return `${WS1_ENV.tokenUrl} is not a URL`;
|
|
78
|
+
}
|
|
79
|
+
if (u.protocol !== "https:")
|
|
80
|
+
return `${WS1_ENV.url} must be https`;
|
|
81
|
+
// The secret goes to the token URL, so only Omnissa's four documented token hosts are accepted.
|
|
82
|
+
if (t.protocol !== "https:" || !TOKEN_HOSTS.includes(t.hostname) || t.pathname !== "/connect/token")
|
|
83
|
+
return `${WS1_ENV.tokenUrl} must be one of ${TOKEN_HOSTS.map((h) => `https://${h}/connect/token`).join(", ")}`;
|
|
84
|
+
const token = cachedToken(() => tokenRequest(t.href, { grant_type: "client_credentials", client_id: client, client_secret: secret }, fetchFn));
|
|
85
|
+
const make = (version) => fetchRest({ base: `${u.origin}/api`, fetchFn,
|
|
86
|
+
headers: async () => ({ authorization: `Bearer ${await token()}`, accept: `application/json;version=${version}` }) });
|
|
87
|
+
return { v3: make(3), v1: make(1) };
|
|
88
|
+
}
|
|
89
|
+
async function listDevices(api, max) {
|
|
90
|
+
const items = [];
|
|
91
|
+
for (let page = 0;; page++) {
|
|
92
|
+
const r = await api.get(searchPath(page));
|
|
93
|
+
const got = ok(r) && isObj(r.body) && Array.isArray(r.body.devices) ? r.body.devices.filter(isObj) : undefined;
|
|
94
|
+
if (!got)
|
|
95
|
+
return { error: r };
|
|
96
|
+
items.push(...got);
|
|
97
|
+
const total = isObj(r.body) ? Number(r.body.total) : NaN;
|
|
98
|
+
if (items.length >= max)
|
|
99
|
+
return { items: items.slice(0, max), truncated: items.length > max || (Number.isFinite(total) && total > max), pages: page + 1 };
|
|
100
|
+
if (got.length < PAGE || (Number.isFinite(total) && items.length >= total))
|
|
101
|
+
return { items, truncated: false, pages: page + 1 };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const uuidOf = (d) => str(d.Uuid) ?? str(d.uuid);
|
|
105
|
+
const idOf = (d) => uuidOf(d) ?? (isObj(d.Id) ? String(d.Id.Value ?? "?") : "?");
|
|
106
|
+
export async function ws1Checks(api, host, o, now) {
|
|
107
|
+
const out = new Emitter("workspace_one", `workspace one ${host}`);
|
|
108
|
+
const got = await listDevices(api.v3, o.maxDevices);
|
|
109
|
+
if ("error" in got) {
|
|
110
|
+
for (const c of WS1_CHECKS)
|
|
111
|
+
out.skip(c, got.error, "device search (MDM API V3)");
|
|
112
|
+
return out.results;
|
|
113
|
+
}
|
|
114
|
+
if (!got.items.length) {
|
|
115
|
+
out.skipAll(WS1_CHECKS, "Workspace ONE UEM lists no device in this organization group; nothing to judge");
|
|
116
|
+
return out.results;
|
|
117
|
+
}
|
|
118
|
+
const cap = got.truncated ? `; only the first ${o.maxDevices} devices read (--workspace-one-max-devices)` : "";
|
|
119
|
+
const from = out.results.length;
|
|
120
|
+
if (!o.minOs.size)
|
|
121
|
+
out.add("os_minimum", "skipped", `no minimum OS version given (--workspace-one-min-os, e.g. macos=15.0 or windows=10.0.26100); ${got.items.length} device(s) not judged`);
|
|
122
|
+
let encDenied;
|
|
123
|
+
for (const d of got.items) {
|
|
124
|
+
const id = idOf(d);
|
|
125
|
+
const res = deviceResource("workspace one device", id, d.serial_number);
|
|
126
|
+
const type = str(d.device_type) ?? "UNKNOWN";
|
|
127
|
+
const where = ` (${type}, ${str(d.operating_system) ?? "OS version not reported"})`;
|
|
128
|
+
const status = str(d.enrollment_status);
|
|
129
|
+
if (status && GONE.has(status)) {
|
|
130
|
+
out.skipAll(WS1_CHECKS.filter((c) => c !== "os_minimum" || o.minOs.size), `enrollment_status is ${status}: no longer in service, not judged${where}`, res);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (status === "ENROLLED")
|
|
134
|
+
out.add("enrolled", "pass", `enrollment_status is ENROLLED${where}${cap}`, res);
|
|
135
|
+
else if (status && NOT_YET.has(status))
|
|
136
|
+
out.add("enrolled", "fail", `enrollment_status is ${status}; ENROLLED required${where}`, res);
|
|
137
|
+
else
|
|
138
|
+
out.add("enrolled", "skipped", `enrollment_status is ${status ?? "not reported"}, which does not show enrollment${where}`, res);
|
|
139
|
+
const cs = str(d.compliance_status);
|
|
140
|
+
if (cs === "COMPLIANT")
|
|
141
|
+
out.add("compliant", "pass", `compliance_status is COMPLIANT${where}`, res);
|
|
142
|
+
else if (cs && BAD_COMPLIANCE.has(cs))
|
|
143
|
+
out.add("compliant", "fail", `compliance_status is ${cs}; COMPLIANT required${where}`, res);
|
|
144
|
+
else
|
|
145
|
+
out.add("compliant", "skipped", `compliance_status is ${cs ?? "not reported"}, which does not show compliance (no policy assigned, or not yet evaluated)${where}`, res);
|
|
146
|
+
if (typeof d.compromised_status === "boolean")
|
|
147
|
+
out.add("compromised", d.compromised_status ? "fail" : "pass", `compromised_status is ${d.compromised_status}${where}`, res);
|
|
148
|
+
else
|
|
149
|
+
out.add("compromised", "skipped", `compromised_status not reported${where}`, res);
|
|
150
|
+
if (o.minOs.size) {
|
|
151
|
+
const floor = [type.toLowerCase(), ...(PLATFORM_ALIASES[type] ?? [])].map((k) => o.minOs.get(k)).find(Boolean) ?? o.minOs.get("*");
|
|
152
|
+
const have = versionIn(d.operating_system);
|
|
153
|
+
if (!floor)
|
|
154
|
+
out.add("os_minimum", "skipped", `no --workspace-one-min-os floor for ${type}${where}`, res);
|
|
155
|
+
else if (!have)
|
|
156
|
+
out.add("os_minimum", "skipped", `operating_system not reported${where}`, res);
|
|
157
|
+
else {
|
|
158
|
+
const okv = cmpVersion(have, floor) >= 0;
|
|
159
|
+
out.add("os_minimum", okv ? "pass" : "fail", `operating_system ${showVersion(have)} ${okv ? "meets" : "is below"} the --workspace-one-min-os floor ${showVersion(floor)}${where}`, res);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const age = ageDays(d.last_seen, now);
|
|
163
|
+
if (age === undefined)
|
|
164
|
+
out.add("last_seen", "skipped", `last_seen not reported${where}`, res);
|
|
165
|
+
else
|
|
166
|
+
out.add("last_seen", age <= o.seenDays ? "pass" : "fail", `last reported ${age} day(s) ago; at most ${o.seenDays} allowed (--workspace-one-seen-days)${where}`, res);
|
|
167
|
+
// Encryption: one call per device; a 401 or 403 stops the calls.
|
|
168
|
+
const uuid = uuidOf(d);
|
|
169
|
+
if (encDenied) {
|
|
170
|
+
out.skip("disk_encryption", encDenied, "encryption status", res);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!uuid) {
|
|
174
|
+
out.add("disk_encryption", "skipped", `no Uuid in the search answer, so the encryption status cannot be asked${where}`, res);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const e = await api.v1.get(encryptionPath(uuid));
|
|
178
|
+
if (!ok(e) || !isObj(e.body)) {
|
|
179
|
+
if (e.status === 401 || e.status === 403)
|
|
180
|
+
encDenied = e;
|
|
181
|
+
out.skip("disk_encryption", e, "encryption status (MDM API V1)", res);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const v = e.body.encryption_status;
|
|
185
|
+
if (typeof v === "boolean")
|
|
186
|
+
out.add("disk_encryption", v ? "pass" : "fail", `encryption_status is ${v}${where}`, res);
|
|
187
|
+
else
|
|
188
|
+
out.add("disk_encryption", "skipped", `encryption_status not reported${where}`, res);
|
|
189
|
+
}
|
|
190
|
+
stampRead(out.results, from, listRead(got)); // a capped list leaves devices unseen: format 3 says so
|
|
191
|
+
return out.results;
|
|
192
|
+
}
|
|
193
|
+
export const workspaceOne = {
|
|
194
|
+
name: "workspace_one",
|
|
195
|
+
options: {
|
|
196
|
+
"workspace-one-seen-days": { type: "string", help: `days since a device last reported before last_seen fails (default ${DEFAULTS.seenDays})` },
|
|
197
|
+
"workspace-one-min-os": { type: "string", multiple: true, help: "<platform>=<version> floor for os_minimum, e.g. macos=15.0, windows=10.0.26100, ios=18.0 (or a device_type such as APPLE_OS_X; repeatable; without it the check is skipped)" },
|
|
198
|
+
"workspace-one-max-devices": { type: "string", help: `devices read, at most (default ${DEFAULTS.maxDevices}); each costs one encryption-status call` },
|
|
199
|
+
},
|
|
200
|
+
emits: WS1_CHECKS.map((c) => `workspace_one.${c}`),
|
|
201
|
+
describe: () => "Omnissa Workspace ONE UEM (SaaS) in WS1_API_URL (REST API, OAuth client with REST API Devices Read)",
|
|
202
|
+
async run(ctx) {
|
|
203
|
+
const host = (() => { try {
|
|
204
|
+
return new URL(ctx.env[WS1_ENV.url] ?? "").host || "(unknown)";
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return "(unknown)";
|
|
208
|
+
} })();
|
|
209
|
+
const given = ctx.clients.workspace_one;
|
|
210
|
+
const built = given !== undefined ? (given && "get" in given ? { v3: given, v1: given } : given) : ws1FromEnv(ctx.env);
|
|
211
|
+
if (!built || typeof built === "string") {
|
|
212
|
+
const out = new Emitter("workspace_one", `workspace one ${host}`);
|
|
213
|
+
out.skipAll(WS1_CHECKS, typeof built === "string" ? built : `${WS1_ENV.secret} is not set`);
|
|
214
|
+
return out.results;
|
|
215
|
+
}
|
|
216
|
+
const sleep = ctx.clients.sleep ?? realSleep;
|
|
217
|
+
const api = { v3: withRetry(built.v3, sleep), v1: withRetry(built.v1, sleep) };
|
|
218
|
+
const o = {
|
|
219
|
+
seenDays: posInt(ctx.opts["workspace-one-seen-days"], DEFAULTS.seenDays),
|
|
220
|
+
maxDevices: posInt(ctx.opts["workspace-one-max-devices"], DEFAULTS.maxDevices),
|
|
221
|
+
minOs: minOsOption(ctx.opts["workspace-one-min-os"]),
|
|
222
|
+
};
|
|
223
|
+
const secret = ctx.env[WS1_ENV.secret];
|
|
224
|
+
return redact(await ws1Checks(api, host, o, ctx.now), [secret, ctx.env[WS1_ENV.client] && secret ? Buffer.from(`${ctx.env[WS1_ENV.client]}:${secret}`).toString("base64") : undefined]);
|
|
225
|
+
},
|
|
226
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sagentix-inc/evidence-collector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Customer-side evidence collector: reads systems read-only, runs checks, signs the results and pushes them outbound to sos-app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|