@sagentix-inc/evidence-collector 0.3.5 → 0.4.1

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.
Files changed (49) hide show
  1. package/README.md +137 -9
  2. package/apps/sos-app/src/data/checks/aws.json +12 -0
  3. package/apps/sos-app/src/data/checks/azure.json +18 -0
  4. package/apps/sos-app/src/data/checks/bamboohr.json +40 -0
  5. package/apps/sos-app/src/data/checks/entra.json +1 -1
  6. package/apps/sos-app/src/data/checks/gcp.json +12 -0
  7. package/apps/sos-app/src/data/checks/intune.json +7 -1
  8. package/apps/sos-app/src/data/checks/jamf.json +8 -2
  9. package/apps/sos-app/src/data/checks/kandji.json +7 -1
  10. package/apps/sos-app/src/data/checks/rippling.json +40 -0
  11. package/apps/sos-app/src/data/checks/vulnscan.json +27 -0
  12. package/apps/sos-app/src/data/checks/web.json +31 -1
  13. package/collector/package.json +1 -1
  14. package/collector/src/cli.js +344 -37
  15. package/collector/src/config.js +12 -0
  16. package/collector/src/doctor.js +382 -0
  17. package/collector/src/policies.js +160 -0
  18. package/collector/src/pool.js +19 -0
  19. package/collector/src/report.js +88 -16
  20. package/collector/src/sources/aws/checks.js +64 -0
  21. package/collector/src/sources/aws.js +6 -1
  22. package/collector/src/sources/azure/api.js +4 -0
  23. package/collector/src/sources/azure/checks.js +91 -1
  24. package/collector/src/sources/azure.js +8 -2
  25. package/collector/src/sources/bamboohr.js +133 -0
  26. package/collector/src/sources/cloudflare/api.js +24 -4
  27. package/collector/src/sources/device_common.js +42 -0
  28. package/collector/src/sources/directory.js +198 -0
  29. package/collector/src/sources/engines.js +2 -1
  30. package/collector/src/sources/entra.js +4 -3
  31. package/collector/src/sources/gcp/project.js +47 -3
  32. package/collector/src/sources/gcp.js +5 -2
  33. package/collector/src/sources/github.js +46 -8
  34. package/collector/src/sources/google_oauth.js +3 -3
  35. package/collector/src/sources/google_workspace.js +4 -3
  36. package/collector/src/sources/hris_common.js +189 -0
  37. package/collector/src/sources/index.js +3 -0
  38. package/collector/src/sources/intune.js +40 -3
  39. package/collector/src/sources/jamf.js +65 -8
  40. package/collector/src/sources/kandji.js +46 -7
  41. package/collector/src/sources/oauth.js +22 -0
  42. package/collector/src/sources/prowler.js +178 -52
  43. package/collector/src/sources/readonly_http.js +27 -5
  44. package/collector/src/sources/rippling.js +114 -0
  45. package/collector/src/sources/shared-cloud.js +14 -6
  46. package/collector/src/sources/vulnscan.js +386 -0
  47. package/collector/src/sources/web.js +106 -11
  48. package/collector/src/sources/web_privacy.js +266 -0
  49. package/package.json +1 -1
package/README.md CHANGED
@@ -30,6 +30,7 @@ Node 24 or later. The source is TypeScript run by Node's type stripping, so ther
30
30
  cd collector
31
31
  npm ci # ajv 8.20.0 and ajv-formats 3.0.1 (v1 SDR validation); typescript 5.9.3, @types/node 24.13.6
32
32
  npm test # node:test, fakes only, no network
33
+ npm run coverage # the same, with coverage floors
33
34
  ```
34
35
 
35
36
  The OPA sub-source needs `opa` on PATH (`brew install opa`, or the `open-policy-agent/setup-opa` action) and OpenTofu 1.8 or later to produce the state.
@@ -91,6 +92,38 @@ node src/cli.ts run --source cloudflare \
91
92
 
92
93
  Exit codes: 0 sent (or dry run); 1 error (unreadable key, network failure, or a source that failed: its checks went as `skipped` and everything else was still pushed and reported); 2 usage; 3 nothing to send, because every check was skipped; 4 sos-app refused the push, with its message printed (401 bad signature or revoked key, 409 replay, 400 unknown check or stale `issuedAt`); 75 `agent` only, after it wrote a signed update (below). `run` never exits 75.
93
94
 
95
+ ### Check the setup first: `collector doctor` (0.4.1)
96
+
97
+ Run the doctor before the first real run, and again whenever a source starts coming back `skipped`. It reads the same config file and options as `run`, and the same vault, then for each source checks that its credentials are there and makes one read per permission the source needs. It collects nothing, signs nothing and sends nothing to sos-app: no check-in, no push, no run report.
98
+
99
+ ```sh
100
+ node src/cli.ts doctor --config sagentix-collector.config.json # every source in the file
101
+ node src/cli.ts doctor --config sagentix-collector.config.json --source okta
102
+ ```
103
+
104
+ Each line is `PASS`, `FAIL` or `SKIP`, and a `FAIL` carries its fix:
105
+
106
+ ```
107
+ okta
108
+ PASS credentials OKTA_DOMAIN with OKTA_API_TOKEN (a Read-Only Administrator's token) or OKTA_ACCESS_TOKEN: present
109
+ PASS GET /api/v1/users HTTP 200 (needs okta.users.read)
110
+ FAIL GET /api/v1/logs HTTP 403: The access token provided does not contain the required scopes.
111
+ fix: grant okta.logs.read
112
+ doctor: 6 passed, 1 failed, 1 skipped; fix the lines marked FAIL, then run the doctor again
113
+ ```
114
+
115
+ A 403 names the scope, role or permission to grant, as the source's own header cites it from the provider's documentation; a 401 names the variables that hold the credential; a 404 asks to check the organization, team, project or URL given. Every read is one the source itself makes, on the same path, with a page size of 1 where the API takes one, so a pass means the run's call will be allowed too. The reads are GET only, except Google Cloud's `:getIamPolicy`, which the GCP source also sends as a POST and which reads. A source that reads a file checks the file is there and readable; Prowler and Cinc Auditor check their executable is on `PATH` without running it; the `macos` and `windows` sources check the platform; `web` checks each URL has its proof token. The collector key is parsed locally and its public key shown, never used. The doctor exits 0 when nothing failed and 1 otherwise, so a workflow can run it as a step (`collector selftest` is the same command).
116
+
117
+ ### Speed, timeouts and what a run prints (0.4.1)
118
+
119
+ Sources run side by side since 0.4.1, four at once by default; `--concurrency 1` restores the old order of one after another, and up to 16 are allowed. The results are taken in the order the sources were named, so the payload and its signature are byte for byte those of a serial run (`test/e2e.test.ts` holds a golden copy recorded before the change). On the recorded replies of every source, with 20 ms added to each call to stand in for a provider's round trip, a full dry run took 6.6 s one source at a time and 1.7 s four at a time (`node test/bench.ts --latency-ms 20`, 2026-11-07). Prowler runs up to two providers at once, and its OCSF output is now read as a stream, one finding at a time, so a file larger than what one string can hold (about 512 MiB in Node) no longer fails.
120
+
121
+ Within one run, Entra ID, Intune and an HR source joined with Entra ID share one token request per app and scope (they used to make three), the Google Workspace source and the HR join share theirs, and BambooHR and Rippling look each leaver up in the identity provider once.
122
+
123
+ Every call a source makes is abandoned after `--http-timeout` seconds (default 60; 0 never). A 429 is retried up to four times; a 502, 503 or 504, a network failure or a timeout up to twice more; each wait follows the answer's `Retry-After` when it has one, else it doubles from 1 second (0.5 second for the clouds, Jira and LMS), capped at 60 seconds (20 for those). The Cloudflare client retries too since 0.4.1, and cuts the token out of any answer that echoes it, as the other clients already did.
124
+
125
+ `--verbose` prints one line per outbound call (`[okta] GET acme.okta.com/api/v1/logs -> 403 in 85 ms`): the method, the host and the path with ids replaced by `:id`, never a query string, a header or a body, and each retry with its wait. `--debug` adds the stack of a source that failed. Every run, verbose or not, ends its stderr with a table, one row per source: results, pass, fail and skipped counts, outbound calls, retries, answers that were errors, and time. Below it, each check skipped because a call was refused (HTTP 401 or 403) is listed with its source, its check, the status and the doctor command that names the missing permission. Neither the call log nor the table goes into the run report, which already holds every call by its shape and every source's counts and time; both pass through the same scrubbing as the report.
126
+
94
127
  ### Run report, check-in and resident mode (0.3.0)
95
128
 
96
129
  Since 0.3.0 the collector keeps a channel to sos-app beside the push, defined in `apps/sos-app/docs/COLLECTOR_CHANNEL.md`. Every message is signed with the collector key, exactly as a push is, and every connection still starts on the client's side.
@@ -168,6 +201,30 @@ them into the vault, after which the collector removes those lines.
168
201
 
169
202
  `--fedramp-target <id> [--class A|B|C|D]` sends the v1 format (`sagentix-fedramp-collect/1`) to `/api/fedramp/collect` instead. It carries the original 11 checks grouped into five KSIs with a Security Decision Record, validated locally against FedRAMP's schema. New installs should use the default v2 push; v1 stays for targets already wired to it.
170
203
 
204
+ ## Policies from where you keep them (`collector policies`)
205
+
206
+ Point the collector at the folder that holds your policies and it sends each one to sos-app, which matches it to its NIST SP 800-53 control family and files it as that family's draft policy, for an owner or admin to read and approve. Add a `policies` block to the config file:
207
+
208
+ ```json
209
+ "policies": { "dir": "/path/to/policies", "keyId": "<documents key id>", "organization": "<organization id>", "keyFile": "documents.pem" }
210
+ ```
211
+
212
+ then run `node src/cli.ts policies --config sagentix-collector.config.json` after `run` (the generated runner scripts do). `dir` may be a list. The collector reads the PDF, Word (`.docx`) and text (`.txt`, `.md`) files at the top of each folder; hidden files, Word lock files and sub-folders (an archive folder, say) are left out, and a policy kept in several formats under one name is sent once: the PDF, else the Word file, else the text. Nothing is moved or deleted.
213
+
214
+ The key is a **documents** key, not the collector key: make it with `collector keygen --out documents.pem` and register it in sos-app with the purpose "documents". A documents key opens only the document door, and a collector key cannot open it, so a key kept in CI never files documents. `keyFile` may be left out when `SAGENTIX_DOCUMENTS_KEY` holds the PEM.
215
+
216
+ After the push, the same command reads the organization's **approved** policies back and writes them into an `approved/` sub-folder of the first folder, one file per approved version (`approved/POL-06_access-control-policy.v3.md`). A version approved in sos-app (an editable copy, a NIST update) arrives as its text; an approved uploaded file is copied from the folder. Names carry the version, so nothing is overwritten, and the push never reads sub-folders, so these copies are never sent back. Edit from the newest copy in `approved/`: a folder file still built on an older text than the version approved in sos-app is filed as a draft and flagged on that draft, and the approved version stays in force until a person approves the new one. Sending an unchanged file again files nothing.
217
+
218
+ sos-app decides the family from the file name or the document's title ("Access Control Policy" is AC; a few common titles such as "Business Continuity" are aliases), else from the control ids the text cites when one family clearly leads. Each file gets one line back:
219
+
220
+ | Outcome | Meaning |
221
+ | --- | --- |
222
+ | `filed` | the family's next draft version, waiting for approval |
223
+ | `duplicate` | the same bytes were filed before; nothing new |
224
+ | `evidence` | matched only by its control citations: filed as policy evidence on the family's first control, never as the family's policy |
225
+ | `unmatched` | no family could be told; name it in the title |
226
+ | `taken` | the family already holds a policy from another file; a new version must keep that file name |
227
+
171
228
  ## Run on a schedule from GitHub Actions
172
229
 
173
230
  ```yaml
@@ -294,6 +351,8 @@ node src/cli.ts run --source aws --aws-regions us-east-1,ca-central-1 \
294
351
  | `s3_bucket_not_public` | No bucket is public through its policy (`GetBucketPolicyStatus`) or ACL (`GetBucketAcl`), unless Block Public Access already restricts both |
295
352
  | `guardduty_malware_protection` | An enabled GuardDuty detector has `EBS_MALWARE_PROTECTION` on in the region |
296
353
  | `ssm_patch_compliance` | Each running EC2 instance is `COMPLIANT` in Patch Manager (`ListResourceComplianceSummaries`, ComplianceType Patch); no Patch summary fails |
354
+ | `backup_jobs_succeeded` | Each resource's latest AWS Backup job in the last `--aws-backup-window-days` (default 7) is `COMPLETED` (`ListBackupJobs`, `backup:ListBackupJobs`); no finished job fails |
355
+ | `restore_tested` | At least one `COMPLETED` restore job in the last `--aws-restore-test-days` (default 365) (`ListRestoreJobs`, `backup:ListRestoreJobs`; restore testing plans write these too) |
297
356
 
298
357
  The four checks added on 2026-10-26 need no new permission: SecurityAudit (policy v94) grants `s3:GetBucket*`, `ec2:Describe*` and `ssm:ListResourceComplianceSummaries`. `iam_users_inactive`, `s3_bucket_not_public` and `ssm_patch_compliance` overlap Prowler checks on purpose: they serve a client whose role stays at SecurityAudit alone, without the runner image or Prowler's extra read policy.
299
358
 
@@ -326,6 +385,9 @@ node src/cli.ts run --source azure --azure-subscription 00000000-0000-0000-0000-
326
385
  | `backup_protected_items` | Each Recovery Services vault protects at least one item, and the subscription has a vault |
327
386
  | `sql_firewall_not_open` | No Azure SQL server-level firewall rule spans more than a /8, unless public network access is Disabled; Allow Azure services is reported |
328
387
  | `vm_patches_current` | Each running VM's last patch assessment (instance view `patchStatus`) succeeded within 7 days with 0 critical or security patches pending |
388
+ | `backup_jobs_succeeded` | Each protected item's latest Backup job in a vault (Backup Jobs - List, last `--azure-backup-window-days`, default 7) is `Completed` or `CompletedWithWarnings` |
389
+ | `restore_tested` | A `Restore` job with status `Completed` in any vault within `--azure-restore-test-days` (default 365) |
390
+ | `log_analytics_retention` | Each Log Analytics workspace's `retentionInDays` is at least `--azure-log-retention-days` (default 365; Microsoft Sentinel keeps its data there) |
329
391
 
330
392
  The 2026-10-26 additions read `/resources` (filtered by type), `Microsoft.Sql/servers/{s}/firewallRules`, `Microsoft.Compute/virtualMachines` and `{vm}/instanceView`, all GETs that Reader grants; no role changes.
331
393
 
@@ -355,6 +417,8 @@ node src/cli.ts run --source gcp --gcp-project acme-prod \
355
417
  | `kms_rotation` | Each symmetric Cloud KMS key rotates automatically every 365 days or less |
356
418
  | `vpc_flow_logs` | Each private subnetwork has VPC flow logs on, in its own `logConfig` or through a Network Management API configuration for it, its network or the organization |
357
419
  | `vm_os_vulnerabilities` | Each running Compute Engine VM has an OS Config vulnerability report under 7 days old whose `highestUpgradableCveSeverity` is below HIGH; no report fails |
420
+ | `cloudsql_backup_runs_succeeded` | Each Cloud SQL instance's latest backup run in the last `--gcp-backup-window-days` (default 7) is `SUCCESSFUL` (`backupRuns.list`, `cloudsql.backupRuns.list`) |
421
+ | `log_bucket_retention` | `_Default` and every bucket the organization made keep logs at least `--gcp-log-retention-days` (default 365) (`locations/-/buckets`, `logging.buckets.list` in `roles/logging.viewer`); `_Required` is not judged |
358
422
 
359
423
  ### Jira
360
424
 
@@ -395,6 +459,52 @@ node src/cli.ts run --source lms --training-csv lms-export.csv --roster-csv rost
395
459
  | `security_awareness_current` | Everyone on the roster completed security awareness training within 12 months |
396
460
  | `role_based_training_current` | People in privileged roles completed role-based training within 12 months |
397
461
 
462
+ ### HR systems: BambooHR and Rippling
463
+
464
+ Two sources read joiners, leavers and the org chart from the HR system (2026-11-07), and one shared module judges both (`src/sources/hris_common.ts`): one result per person in the period, named by the HR system's employee id, so an auditor's population of leavers or hires is the list of results. Names, photos and compensation are never asked for; the work email is used only to look the person up in the identity provider and in the LMS export.
465
+
466
+ The leaver check joins each person who left with the identity provider named by `--bamboohr-idp` or `--rippling-idp` (`okta`, `entra`, `google_workspace` or `jumpcloud`), through `src/sources/directory.ts` and that provider's own read-only variables, so it adds no permission and no second copy of the identity checks. Okta's `statusChanged`, Google's `suspensionTime`, the Entra directory audit (7 or 30 days) and deleted-items list (30 days), and Google's `DELETE_USER` events (6 months) date the removal; JumpCloud records no time. A removal without a time, or no account under the email, passes marked incomplete (`complete: false`), which sos-app does not count as a pass.
467
+
468
+ **BambooHR.** An API key the customer makes under a user whose access level reads, and edits nothing in, the fields below (BambooHR has no read-only key; the key carries its user's access). `BAMBOOHR_API_KEY`; `--bamboohr-company` is the subdomain. Reads `GET https://<company>.bamboohr.com/api/v1/employees?filter[status]=active|inactive&fields=workEmail,hireDate,terminationDate,supervisorEId,jobTitle[,<ack field>,<background field>]`, following `_links.next`. A field the key's user cannot read (`_restrictedFields`) is reported as unreadable. Docs read 2026-11-07: https://documentation.bamboohr.com/docs/api-details, https://documentation.bamboohr.com/docs/getting-started, https://documentation.bamboohr.com/reference/list-employees, https://documentation.bamboohr.com/docs/list-of-field-names.
469
+
470
+ **Rippling.** An API token made under Developer > API credentials with the single scope `workers.read` (`RIPPLING_API_TOKEN`). Reads `GET https://rest.ripplingapis.com/workers/?limit=100`, following `next_link`. Rippling revokes a token unused for 30 days. Docs read 2026-11-07: https://developer.rippling.com/documentation/rest-api/essentials/api-tokens, https://developer.rippling.com/documentation/rest-api/reference/list-workers.md, https://developer.rippling.com/documentation/rest-api/essentials/pagination, https://developer.rippling.com/documentation/rest-api/essentials/api-limits.
471
+
472
+ **Options** (`bamboohr-` or `rippling-` prefix): `-idp`, `-since` (default 365 days ago), `-access-hours` (default 24), `-training-days` (default 30; reads the `lms` source's `--training-csv`), `-max-without-manager` (default 1), `-ack-field` and `-background-field` (the date fields that record the code of conduct or acceptable-use acknowledgement and a background check; without one, its check is skipped).
473
+
474
+ ```sh
475
+ node src/cli.ts run --source bamboohr,lms --bamboohr-company acme --bamboohr-idp okta --bamboohr-ack-field customCodeOfConduct \
476
+ --training-csv lms-export.csv --roster-csv roster.csv --endpoint https://<sos-app host> --key-id <key id> --key sagentix-collector.pem --dry-run
477
+ ```
478
+
479
+ | Check (`bamboohr.` or `rippling.`…) | What passes |
480
+ | --- | --- |
481
+ | `leaver_access_removed` | Each leaver of the period lost identity-provider access within `-access-hours` of the end of the last day |
482
+ | `hire_acknowledgement` | Each hire of the period has the acknowledgement date recorded |
483
+ | `hire_training` | Each hire completed a security awareness course within `-training-days` of starting |
484
+ | `hire_background_check` | Each hire has the background-check date recorded |
485
+ | `org_chart_complete` | Every active person has a manager in the HR system and a job title; at most `-max-without-manager` have none |
486
+
487
+ Workday and ADP are not sources yet. ADP's Workforce Now API needs a paid API Central subscription and a certificate (https://apps.adp.com/en-US/apps/410612/adp-api-central-for-adp-workforce-now/features, read 2026-11-07). Workday's public workers API documents no hire or termination date and needs an API client registered for an Integration System User; whether a customer without Workday Extend can register one was not confirmed (https://developer.workday.com/doc/dan1370797991225.md, read 2026-11-07).
488
+
489
+ ### Vulnerability scanner results (`vulnscan`)
490
+
491
+ Reads export files the customer's scanner already wrote; it never runs a scan and holds no scanner credential. Formats: a `.nessus` file (NessusClientData_v2), a Tenable Vulnerability Management vulnerability or asset export chunk (`.json`), or a Qualys VMDR host list detection (`.xml`). The exports need the scanner's read-only role: Tenable, "at least the Basic [16] user permission" with "Can View" on the assets; Qualys, Reader. Wiz is not read: its documentation sits behind a sign-in (2026-11-07).
492
+
493
+ **Options:** `--vulnscan-report <file>` (repeatable), `--vulnscan-inventory <file>` (CSV with a hostname, fqdn, name, ip or address column; a JSON array; or one name per line), `--vulnscan-max-age-days` (default 30: monthly, as FedRAMP's Continuous Monitoring Playbook asks), `--vulnscan-sla critical=30,high=30,medium=90,low=180` (the default, FedRAMP's POA&M windows; FedRAMP's 2026 Vulnerability Detection and Response rules replace them with impact-based timeframes, so set yours).
494
+
495
+ ```sh
496
+ node src/cli.ts run --source vulnscan --vulnscan-report scans/monthly.nessus --vulnscan-report scans/assets.json \
497
+ --vulnscan-inventory cmdb.csv --endpoint https://<sos-app host> --key-id <key id> --key sagentix-collector.pem --dry-run
498
+ ```
499
+
500
+ | Check (`vulnscan.`…) | What passes |
501
+ | --- | --- |
502
+ | `authenticated_coverage` | Every inventory asset appears in a scan whose credentials worked (plugin 19506, `last_authenticated_scan_date` at the last scan, or Qualys `LAST_VM_AUTH_SCANNED_DATE` at `LAST_VM_SCANNED_DATE`); skipped without an inventory |
503
+ | `scan_recent` | Every asset (the inventory's, else every scanned host) was scanned within `--vulnscan-max-age-days` |
504
+ | `findings_within_sla` | No open finding (Tenable OPEN or REOPENED, Qualys confirmed and not Fixed) older than its severity's window since first found; skipped for a `.nessus` file, which has no first-found date |
505
+
506
+ [Unverified] The `.nessus` tags `host-ip`, `host-fqdn` and `HOST_END_TIMESTAMP`, and plugin 19506's output line `Credentialed checks : yes`, are read but are not shown in Tenable's public documents (read 2026-11-07); a host without a credential record counts as not covered. Results carry counts, ages, severities and plugin or QID numbers, never a finding's output; inventory names appear only for the assets that fail.
507
+
398
508
  ### Stripe
399
509
 
400
510
  **Create:** in the Stripe Dashboard (live mode), Developers > API keys > **Create restricted key**, with every permission left at **None** except **Webhook Endpoints: Read**. A secret key (`sk_live_…`, `sk_test_…`) is refused before any request: it can do anything in the account and cannot be narrowed (docs.stripe.com/keys, read 2026-11-02).
@@ -572,11 +682,11 @@ Left out: enforcement of security keys as the only second factor. The Directory
572
682
 
573
683
  ### GitHub
574
684
 
575
- **Create:** a fine-grained personal access token owned by an organization owner, scoped to the organization and all its repositories, with *read* on Organization **Administration** and **Members**, and Repository **Metadata**, **Administration**, **Contents**, **Dependabot alerts** and **Code scanning alerts**. A GitHub App installation token with the same read permissions works too. Some fields show only to privileged callers: `two_factor_requirement_enabled`, `default_repository_permission`, `members_can_create_public_repositories` and `members_can_change_repo_visibility` to organization owners ("To see the full details about an organization, the authenticated user must be an organization owner", docs.github.com/rest/orgs/orgs#get-an-organization, read 2026-10-20), `security_and_analysis` to repository admins. A token whose owner lacks the role gets `skipped` for those checks.
685
+ **Create:** a fine-grained personal access token owned by an organization owner, scoped to the organization and all its repositories, with *read* on Organization **Administration** and **Members**, and Repository **Metadata**, **Administration**, **Contents**, **Dependabot alerts** and **Code scanning alerts**. A GitHub App installation token with the same read permissions works too. A fine-grained token belongs to one owner, so a repository named with `--github-also-repo` reads with `GITHUB_TOKEN_<OWNER>` (the owner in upper case, other characters `_`, for example `GITHUB_TOKEN_STEPHANERABY1`) when that variable is set, else with `GITHUB_TOKEN`; give it the same repository permissions, read-only. Some fields show only to privileged callers: `two_factor_requirement_enabled`, `default_repository_permission`, `members_can_create_public_repositories` and `members_can_change_repo_visibility` to organization owners ("To see the full details about an organization, the authenticated user must be an organization owner", docs.github.com/rest/orgs/orgs#get-an-organization, read 2026-10-20), `security_and_analysis` to repository admins. A token whose owner lacks the role gets `skipped` for those checks.
576
686
 
577
687
  **Env:** `GITHUB_TOKEN`.
578
688
 
579
- **Options:** `--github-org` (required), `--github-repo <name>` (repeatable; default every active repository), `--github-max-repos` (default 200), `--github-max-owners` (default 3), `--github-max-outside-collaborators` (default 0), `--github-alert-days` (default 30).
689
+ **Options:** `--github-org` (required), `--github-repo <name>` (repeatable; default every active repository), `--github-also-repo <owner/name>` (repeatable: a repository outside the organization, such as a personal one, checked as well), `--github-max-repos` (default 200), `--github-max-owners` (default 3), `--github-max-outside-collaborators` (default 0), `--github-alert-days` (default 30).
580
690
 
581
691
  Branch protection is read two ways: classic protection (needs Administration) and the active ruleset rules on the default branch (needs only Metadata). A setting passes when either shows it, and fails only when both were readable and neither does.
582
692
 
@@ -658,16 +768,19 @@ Screen lock, OS currency, firewall and EDR come from the customer's own Fleet po
658
768
  | `device_encrypted` | `isEncrypted` is true |
659
769
  | `device_os_minimum` | `osVersion` meets `--intune-min-os`, or else the highest `osMinimumVersion` among the platform's assigned policies |
660
770
  | `device_last_sync` | `lastSyncDateTime` within `--intune-sync-days` |
771
+ | `device_wipe_recorded` | Each device with a wipe, retire or delete in the remote action audit within `--intune-wipe-days` (default 365) has a `done` wipe or retire; `failed`, `canceled`, `notSupported`, pending over 7 days, or a `delete` alone fails |
772
+
773
+ `device_wipe_recorded` reads `GET /beta/deviceManagement/remoteActionAudits` (Graph beta only; the v1.0 page redirects there) with the same `DeviceManagementManagedDevices.Read.All`, read 2026-11-07 in https://learn.microsoft.com/en-us/graph/api/intune-devices-remoteactionaudit-list?view=graph-rest-beta. A wipe is `factoryReset` or `factoryResetKeepEnrollmentData`, a retire `removeCompanyData`, a Fresh Start `cleanWindowsDevice`. Device names and user principal names in the audit are never read into a result.
661
774
 
662
775
  Policy results report once per platform (`intune tenant <id> Windows`). Settings pushed by configuration profiles instead of compliance policies are not read.
663
776
 
664
777
  ### Jamf Pro
665
778
 
666
- **Create:** in Jamf Pro, an API role holding the single privilege **Read Computers**, and an API client with only that role. The collector exchanges its id and secret at `POST /api/v1/oauth/token` (then `/api/oauth/token` on a server that answers 404) and reads `GET /api/v4/computers-inventory` with the `GENERAL`, `DISK_ENCRYPTION`, `OPERATING_SYSTEM` and `HARDWARE` sections. Jamf marks v1, v2 and v3 of that endpoint deprecated; a server without v4 answers on the next older one.
779
+ **Create:** in Jamf Pro, an API role holding two privileges, **Read Computers** and **View MDM command information in Jamf Pro API** (the second since 2026-11-07, for `device_wipe_recorded`), and an API client with only that role. The collector exchanges its id and secret at `POST /api/v1/oauth/token` (then `/api/oauth/token` on a server that answers 404) and reads `GET /api/v4/computers-inventory` with the `GENERAL`, `DISK_ENCRYPTION`, `OPERATING_SYSTEM` and `HARDWARE` sections. Jamf marks v1, v2 and v3 of that endpoint deprecated; a server without v4 answers on the next older one.
667
780
 
668
781
  **Env:** `JAMF_URL` (https only, e.g. `https://acme.jamfcloud.com`), `JAMF_CLIENT_ID`, `JAMF_CLIENT_SECRET`.
669
782
 
670
- **Options:** `--jamf-min-os` (e.g. `15.0`; without it `os_minimum` is skipped), `--jamf-inventory-days` (default 14), `--jamf-max-computers` (default 5000).
783
+ **Options:** `--jamf-min-os` (e.g. `15.0`; without it `os_minimum` is skipped), `--jamf-inventory-days` (default 14), `--jamf-max-computers` (default 5000), `--jamf-wipe-days` (default 365).
671
784
 
672
785
  | Check (`jamf.`…) | What passes |
673
786
  | --- | --- |
@@ -675,18 +788,21 @@ Policy results report once per platform (`intune tenant <id> Windows`). Settings
675
788
  | `os_minimum` | `operatingSystem.version` at or above `--jamf-min-os` |
676
789
  | `last_inventory` | `general.reportDate` within `--jamf-inventory-days` |
677
790
  | `managed` | `general.remoteManagement.managed` is true |
791
+ | `device_wipe_recorded` | Each device with an `ERASE_DEVICE` command sent within `--jamf-wipe-days` has it `ACKNOWLEDGED`; `ERROR`, `COMMAND_FORMAT_ERROR`, or `PENDING`/`NOT_NOW` over 7 days fails |
792
+
793
+ `device_wipe_recorded` reads `GET /api/v2/mdm/commands?filter=command==ERASE_DEVICE;dateSent=ge=<start>` (https://developer.jamf.com/jamf-pro/reference/get_v2-mdm-commands, read 2026-11-07). Jamf Pro has no unmanage command type, so a Mac taken out of management without an erase is the `managed` check's to find.
678
794
 
679
795
  ### Iru Endpoint Management (formerly Kandji)
680
796
 
681
797
  Kandji renamed itself Iru; the API reference is now titled "Iru Endpoint Management API" (https://api-docs.kandji.io/ redirects to https://api-docs.iru.com/), while tenant API hosts stay on kandji.io. Endpoints and fields below were read in that reference on 2026-10-20, Prism category meanings in https://support.kandji.io/kb/prism and token creation in https://support.kandji.io/kb/kandji-api, same date.
682
798
 
683
- **Create:** under Settings → Access, an API token (Add API Token) whose permissions are these five GET endpoints and nothing else: `GET /api/v1/devices` (List Devices), `GET /api/v1/devices/{device_id}/status` (Get Device Status), and the Prism categories `GET /api/v1/prism/application_firewall`, `/gatekeeper_and_xprotect` and `/startup_settings`. Token permissions are granted per endpoint and method. A permission left off skips only the checks that read it: without `startup_settings`, `sip` reports one `skipped` result naming HTTP 403. The token belongs to the admin who made it and is removed with that admin.
799
+ **Create:** under Settings → Access, an API token (Add API Token) whose permissions are these six GET endpoints and nothing else: `GET /api/v1/devices` (List Devices), `GET /api/v1/devices/{device_id}/status` (Get Device Status), `GET /api/v1/devices/{device_id}/commands` (Get Device Commands, since 2026-11-07), and the Prism categories `GET /api/v1/prism/application_firewall`, `/gatekeeper_and_xprotect` and `/startup_settings`. Token permissions are granted per endpoint and method. A permission left off skips only the checks that read it: without `startup_settings`, `sip` reports one `skipped` result naming HTTP 403. The token belongs to the admin who made it and is removed with that admin.
684
800
 
685
801
  **Env:** `KANDJI_API_URL`, the tenant's API URL from the same page (https only: `https://<subdomain>.api.kandji.io` in the US, `https://<subdomain>.api.eu.kandji.io` in the EU), and `KANDJI_API_TOKEN`.
686
802
 
687
- **Options:** `--kandji-min-os` (e.g. `15.0`; without it `os_minimum` is skipped), `--kandji-checkin-days` (default 14), `--kandji-max-devices` (default 1000).
803
+ **Options:** `--kandji-min-os` (e.g. `15.0`; without it `os_minimum` is skipped), `--kandji-checkin-days` (default 14), `--kandji-max-devices` (default 1000), `--kandji-wipe-days` (default 365).
688
804
 
689
- Only Macs are read (`platform=Mac`, Prism `device_families=Mac`). List Devices pages by `limit=300` and `offset`, ordered by `device_id`; Prism pages by its opaque `cursor`, starting from an empty one and stopping when the answer's cursor is null. The tenant may make 10,000 requests an hour, and each Mac costs one Get Device Status call, so 1,000 Macs take about 1,010 requests. The screen saver and lock settings are left out: Prism's Desktop and Screensaver category documents no idle-time or password field. Device names, assigned users, Prism's `device__user_*` fields and library item logs are never read into a result.
805
+ Only Macs are read (`platform=Mac`, Prism `device_families=Mac`). List Devices pages by `limit=300` and `offset`, ordered by `device_id`; Prism pages by its opaque `cursor`, starting from an empty one and stopping when the answer's cursor is null. The tenant may make 10,000 requests an hour, and each Mac costs one Get Device Status call, so 1,000 Macs take about 1,010 requests (about 2,010 with Get Device Commands). The screen saver and lock settings are left out: Prism's Desktop and Screensaver category documents no idle-time or password field. Device names, assigned users, Prism's `device__user_*` fields and library item logs are never read into a result.
690
806
 
691
807
  | Check (`kandji.`…) | What passes |
692
808
  | --- | --- |
@@ -699,6 +815,7 @@ Only Macs are read (`platform=Mac`, Prism `device_families=Mac`). List Devices p
699
815
  | `firewall` | Prism Application firewall `status` is true |
700
816
  | `gatekeeper` | Prism Gatekeeper and XProtect `gatekeeper_status` is true |
701
817
  | `sip` | Prism Startup settings `sip` is true |
818
+ | `device_wipe_recorded` | Each listed Mac with an `EraseDevice` command (Apple's request type) within `--kandji-wipe-days` has it completed (status 3); failed (4), or pending, running or Not Now over 7 days, fails |
702
819
 
703
820
  ```sh
704
821
  node src/cli.ts run --source fleet --fleet-min-os darwin=15.0 \
@@ -902,7 +1019,7 @@ sos-app's "Connect your environment" screen generates `sagentix-collector.ps1` a
902
1019
 
903
1020
  ## Websites and APIs by URL (`web`)
904
1021
 
905
- The `web` source looks at a site the way a browser does, for URLs the organization declared and proved it controls. It reads only the exact URLs it is given: no crawling, no link or redirect followed, no form, no POST, no vulnerability probe. Each URL gets two TLS handshakes to port 443, one GET of the URL and one GET of its `http://` form; each mail domain gets DNS lookups and one DNS-over-HTTPS query. Requests to one host are at least a second apart by default, each times out after 15 s, and each carries the User-Agent `sagentix-evidence-collector/<version> (+https://github.com/Sagentix-Advisors-Inc/sagentix-collector)`.
1022
+ The `web` source looks at a site the way a browser does, for URLs the organization declared and proved it controls. It reads only the exact URLs it is given: no crawling, no link or redirect followed, no form, no POST, no vulnerability probe. Each URL gets two TLS handshakes to port 443, one GET of the URL and one GET of its `http://` form, and each host one GET of `robots.txt`; each mail domain gets DNS lookups and one DNS-over-HTTPS query. Requests to one host are at least a second apart by default, each times out after 15 s, and each carries the User-Agent `sagentix-evidence-collector/<version> (+https://github.com/Sagentix-Advisors-Inc/sagentix-collector)`.
906
1023
 
907
1024
  **Prove control first.** sos-app issues a token per host when the URL is declared. Publish it one of two ways:
908
1025
 
@@ -910,7 +1027,7 @@ The `web` source looks at a site the way a browser does, for URLs the organizati
910
1027
  _sagentix-collector.www.example.ca. TXT "sagentix-collector-verification=<token>"
911
1028
  ```
912
1029
 
913
- or serve `https://www.example.ca/.well-known/sagentix-collector.txt` with the line `sagentix-collector-verification=<token>`. Then pass `--web-proof www.example.ca=<token>`. The collector checks the proof itself before anything else goes to the host. A host with no `--web-proof` gets no request at all, and all nine URL checks come back `skipped` with the record to publish; a wrong token costs one TXT lookup and one GET of the well-known file (redirect not followed, first 4 KB read). `--web-skip-proof` runs the checks without proof, every detail then starts "control not proven (dry run only)", and the CLI accepts it only with `--dry-run`, which sends nothing.
1030
+ or serve `https://www.example.ca/.well-known/sagentix-collector.txt` with the line `sagentix-collector-verification=<token>`. Then pass `--web-proof www.example.ca=<token>`. The collector checks the proof itself before anything else goes to the host. A host with no `--web-proof` gets no request at all, and all ten URL checks come back `skipped` with the record to publish; a wrong token costs one TXT lookup and one GET of the well-known file (redirect not followed, first 4 KB read). `--web-skip-proof` runs the checks without proof, every detail then starts "control not proven (dry run only)", and the CLI accepts it only with `--dry-run`, which sends nothing.
914
1031
 
915
1032
  **Accepted URLs:** https only, the default port, no user name or password, a host name rather than an IP address (an IP cannot be proven by DNS).
916
1033
 
@@ -940,6 +1057,13 @@ Each check was written against a page read on 2026-10-18: CCCS ITSP.40.062 v3 (J
940
1057
  | `dmarc` | `_dmarc` of the domain, or else of its parents down to two labels, has `p`, `sp` and `np` each `quarantine` or `reject` (RFC 9989 §4.7; `sp` and `np` default to `p`, `t=y` lowers each a level), and pct 100; a parent's record governs the domain by its `sp` |
941
1058
  | `dnssec` | A DS record exists for the domain (recommended) |
942
1059
  | `caa` | A CAA record set at the domain or its nearest parent (recommended) |
1060
+ | `consent_banner` | The declared page loads a known consent-management platform (OneTrust, Osano, Cookiebot, Usercentrics, TrustArc, Termly, iubenda, Didomi, CookieYes, Transcend, by the script host each vendor's install page names); a page with no script from another site is skipped |
1061
+ | `privacy_notice_published` | `--web-privacy-url` answers 200 with an HTML page whose title or first heading names privacy or personal information |
1062
+ | `privacy_notice_dated` | A "Last updated", "Effective date" or "Dernière mise à jour" date at most `--web-privacy-max-age-days` old (default 365) |
1063
+ | `privacy_notice_topics` | Seven topics found, in English or French: what is collected, purposes, disclosure, retention, access and correction, contact, consent and withdrawal |
1064
+ | `privacy_request_channel` | A privacy mailbox (privacy@, dpo@, …) or a link to a privacy-request form on the notice; only the mail domain is reported |
1065
+
1066
+ **Privacy notice and consent (2026-11-07).** `--web-privacy-url <https URL>` (repeatable, on a proven host) names the notice; `--web-privacy-max-age-days` sets its review period. The page bodies these checks need (the declared page for `consent_banner`, the notice for the others) are read only where the host's `robots.txt` lets this User-Agent read them (RFC 9309: the group naming `sagentix-evidence-collector` wins over `*`, the longest rule wins; a 4xx means no rule, a 5xx or network failure means do not read). `robots.txt` is fetched once per host, and each body is cut at 2 MB. The topic list follows NIST SP 800-53 Rev. 5 PT-5, PIPEDA Schedule 1 clauses 4.5 and 4.8.2 and the Office of the Privacy Commissioner of Canada's Guidelines for obtaining meaningful consent; the citations sit in `src/sources/web_privacy.ts`.
943
1067
 
944
1068
  A TLS 1.1 probe that this machine's OpenSSL cannot make, or a network failure, gives `skipped`, never a pass. The certificate handshake reads the certificate without trusting it (`rejectUnauthorized: false`) and judges the verification result itself; nothing is sent on that connection. The DMARC walk approximates RFC 7489's Organizational Domain without the Public Suffix List. A result names the URL or the domain, header values, protocol versions and dates; never page content, a cookie or a person.
945
1069
 
@@ -955,8 +1079,12 @@ A TLS 1.1 probe that this machine's OpenSSL cannot make, or a network failure, g
955
1079
  - `src/sources/cloudflare.ts` and `src/sources/cloudflare/`: the API client (GET only), zone and account checks, and the OPA sub-source.
956
1080
  - `src/sources/entra.ts`, `okta.ts`, `jumpcloud.ts`, `google_workspace.ts`, `github.ts`: the identity and code sources (JumpCloud also reads devices, with `device_common.ts`); `readonly_http.ts` (GET-only client, retry, pagination, redaction) serves all five and `oauth.ts` (token requests) serves Entra ID and Google; `google_oauth.ts` and `src/google_login.ts` are Google's browser sign-in and its token file (`test/google_oauth.test.ts`). Their tests are `test/{entra,okta,jumpcloud,google_workspace,github}.test.ts` over the fakes and shared contract suite in `test/rest_fakes.ts`.
957
1081
  - `src/sources/fleet.ts`, `intune.ts`, `jamf.ts`, `kandji.ts`, `defender_endpoint.ts`, `workspace_one.ts`, `configmgr.ts`, `google_endpoints.ts`, `ivanti_mdm.ts`, `ninjaone.ts`, `endpoint_central.ts`: the device sources, listed through `device_sources.ts`, with helpers in `device_common.ts`; tests in `test/{fleet,intune,jamf,kandji,defender_endpoint,workspace_one,configmgr,google_endpoints,ivanti_mdm,ninjaone,endpoint_central}.test.ts`.
1082
+ - `src/sources/bamboohr.ts` and `rippling.ts`: the HR sources, judged by `hris_common.ts` and joined with an identity provider through `directory.ts`; tests in `test/hris.test.ts`.
958
1083
  - `src/sources/macos.ts` and `windows.ts`: the Mac or Windows PC the collector runs on, read with the operating system's own tools through an injectable command runner; also listed through `device_sources.ts`, tested in `test/{macos,windows}.test.ts` with fake runners.
959
1084
  - `src/v1/`: the v1 FedRAMP push behind `--fedramp-target`.
960
1085
  - `src/sources/prowler.ts` and `runner/`: Prowler as an engine (`--source prowler`) and the container that runs it; see `runner/README.md`. More than 5,000 results (or 4 MB) go out as several signed pushes, each with its own nonce.
961
1086
  - `src/sources/stig.ts`, `kubescape.ts`, `checkov.ts`: Cinc Auditor with MITRE's DISA STIG profiles (`--source stig`, which runs `cinc-auditor` over SSH or WinRM, or reads an InSpec report), and the Kubescape and Checkov report readers; listed through `engines.ts`, documented in `runner/README.md`, tested in `test/host_iac_engines.test.ts` with hand-built reports. Their catalogues (`stig_<profile>.json`, `kubescape.json`, `checkov.json`) come from `apps/sos-app/scripts/build_{stig,kubescape,checkov}_catalog.py`.
962
1087
  - `test/`: node:test suites with a fake Cloudflare API, a fake `opa` and a fake sos-app; one suite runs the real `opa` when it is installed.
1088
+ - `src/doctor.ts`: `collector doctor`, one self-test per source (a test fails when a source has none); `src/pool.ts`: the bounded pool behind `--concurrency`.
1089
+ - `test/e2e.test.ts` and `test/e2e_fixtures.ts`: every source in one run against recorded replies, no network: the payload's shape, the signature over the exact bytes, and the results against the golden file `test/fixtures/e2e/golden-results.jsonl` (`UPDATE_GOLDEN=1 node --test test/e2e.test.ts` rewrites it after a deliberate change). The replies are the ones each source's own suite uses, kept in `test/fixtures/rest/`, `cloud/` and `local/`. `test/run_qa.test.ts` covers concurrency, retries, timeouts, the call log, the doctor, and a check that no known secret reaches stdout, stderr, the payload or the run report even when a provider echoes it. `node test/bench.ts --latency-ms 20` times a full dry run.
1090
+ - `npm run coverage` runs every suite with Node's coverage and fails below 98% of lines, 82% of branches or 94% of functions (the level of 2026-11-07); `.github/workflows/collector-qa.yml` runs it, the doctor, a dry run as a process and the timing on every push that touches the collector.
@@ -154,6 +154,18 @@
154
154
  "title": "Each running EC2 instance is COMPLIANT in Systems Manager Patch Manager",
155
155
  "scope": "Running EC2 instances in each region given (DescribeInstances), against ListResourceComplianceSummaries with ComplianceType Patch (AWS Systems Manager API Reference, read 2026-10-26). An instance with no Patch summary fails: it is not a managed node or Patch Manager never scanned it. The patch baseline decides what COMPLIANT means; it is not read. On-premises managed nodes, containers and Lambda are not judged. Prowler's ssm_managed_compliant_patching covers managed instances for a client that runs Prowler; this one also fails a running instance Systems Manager does not know, and serves a role kept at SecurityAudit alone.",
156
156
  "evidenceType": "Report"
157
+ },
158
+ {
159
+ "id": "aws.backup_jobs_succeeded",
160
+ "title": "Each resource's latest AWS Backup job in the window completed",
161
+ "scope": "ListBackupJobs (GET /backup-jobs/?createdAfter=, backup:ListBackupJobs) in each region given, over --aws-backup-window-days (default 7; AWS keeps 30 days): the latest finished job per resource ARN must be COMPLETED; FAILED, ABORTED, EXPIRED or PARTIAL fails, and no finished job at all fails. Backups taken outside AWS Backup are not seen.",
162
+ "evidenceType": "Log"
163
+ },
164
+ {
165
+ "id": "aws.restore_tested",
166
+ "title": "A restore from AWS Backup completed within the restore-test period (default one year)",
167
+ "scope": "ListRestoreJobs (GET /restore-jobs/?createdAfter=, backup:ListRestoreJobs) in each region given, over --aws-restore-test-days (default 365): at least one COMPLETED restore job, which AWS Backup restore testing plans also write. A restore test done another way is recorded with the restore-test record.",
168
+ "evidenceType": "Log"
157
169
  }
158
170
  ]
159
171
  }
@@ -89,6 +89,24 @@
89
89
  "title": "Each running virtual machine has a recent patch assessment with no critical or security update pending",
90
90
  "scope": "Running Azure VMs, from the instance view's patchStatus.availablePatchSummary (criticalAndSecurityPatchCount, status, lastModifiedTime; Virtual Machines - Instance View, read 2026-10-26). Fails when the VM has no assessment, the last one is older than 7 days or did not succeed, or a critical or security patch is pending; stopped VMs are skipped. The patch mode is reported. Arc-enabled servers and scale-set instances are not read.",
91
91
  "evidenceType": "Report"
92
+ },
93
+ {
94
+ "id": "azure.backup_jobs_succeeded",
95
+ "title": "Each protected item's latest Azure Backup job in the window completed",
96
+ "scope": "Backup Jobs - List on each Recovery Services vault (GET {vault}/backupJobs with a startTime and endTime $filter, Reader or Backup Reader), over --azure-backup-window-days (default 7): the latest Backup operation per item must be Completed (CompletedWithWarnings counts, and is noted); Failed or Cancelled fails, and no finished job fails.",
97
+ "evidenceType": "Log"
98
+ },
99
+ {
100
+ "id": "azure.restore_tested",
101
+ "title": "A restore from Azure Backup completed within the restore-test period (default one year)",
102
+ "scope": "The same Backup Jobs - List read, across the subscription's vaults: at least one Restore operation with status Completed within --azure-restore-test-days (default 365). A restore test done another way is recorded with the restore-test record.",
103
+ "evidenceType": "Log"
104
+ },
105
+ {
106
+ "id": "azure.log_analytics_retention",
107
+ "title": "Each Log Analytics workspace keeps logs for at least the required period (default 365 days)",
108
+ "scope": "Workspaces - List (GET /providers/Microsoft.OperationalInsights/workspaces, Reader): properties.retentionInDays against --azure-log-retention-days (default 365). Tables that set their own retention, and long-term (archive) retention, are not read. Microsoft Sentinel keeps its data in such a workspace, so this check covers it.",
109
+ "evidenceType": "Configuration"
92
110
  }
93
111
  ]
94
112
  }
@@ -0,0 +1,40 @@
1
+ {
2
+ "source": "bamboohr",
3
+ "title": "BambooHR (API v1, API key of a read-only user)",
4
+ "readScopes": [
5
+ "A BambooHR API key (made under the user's own account, My Account > API Keys) of a user whose access level reads, and edits nothing in, these fields: status, workEmail, hireDate, terminationDate, supervisorEId, jobTitle, and the acknowledgement and background-check date fields if named. BambooHR has no read-only key: the key carries that user's access. Used only with GET /api/v1/employees?filter[status]=active|inactive. The key is BAMBOOHR_API_KEY; the company subdomain is --bamboohr-company.",
6
+ "For the leaver check, the identity provider's own read-only credential, already granted to its source: Okta okta.users.read; Entra ID User.Read.All and AuditLog.Read.All; Google Workspace admin.directory.user.readonly and admin.reports.audit.readonly; JumpCloud users.readonly."
7
+ ],
8
+ "checks": [
9
+ {
10
+ "id": "bamboohr.leaver_access_removed",
11
+ "title": "Each person who left in the period lost identity-provider access within 24 hours of their last day",
12
+ "scope": "One result per BambooHR record whose employment ended in the period (--bamboohr-since, default 365 days), joined by work email with the identity provider named by --bamboohr-idp (Okta, Entra ID, Google Workspace or JumpCloud) through that provider's own read-only credential. A disable or delete time comes from Okta statusChanged, the Entra directory audit (kept 7 or 30 days) or deleted-items list (30 days), or Google suspensionTime and DELETE_USER events (6 months); JumpCloud records no time. Without a time, or with no account under the email, the pass is marked incomplete. The window is --bamboohr-access-hours.",
13
+ "evidenceType": "Audit Record"
14
+ },
15
+ {
16
+ "id": "bamboohr.hire_acknowledgement",
17
+ "title": "Each person hired in the period has a recorded code of conduct or acceptable-use acknowledgement",
18
+ "scope": "One result per BambooHR record whose start date falls in the period, from the date field the customer names with --bamboohr-ack-field (for example a custom field such as \"Code of conduct signed\"); skipped when none is named. Shows a date was recorded, not what was signed.",
19
+ "evidenceType": "Report"
20
+ },
21
+ {
22
+ "id": "bamboohr.hire_training",
23
+ "title": "Each person hired in the period completed security awareness training within 30 days of starting",
24
+ "scope": "One result per hire in the period, joined by employee id or work email with the LMS export the lms source reads (--training-csv; course text --lms-awareness-course); the window is --bamboohr-training-days. Skipped without the export.",
25
+ "evidenceType": "Report"
26
+ },
27
+ {
28
+ "id": "bamboohr.hire_background_check",
29
+ "title": "Each person hired in the period has a background check recorded",
30
+ "scope": "One result per hire in the period, from the date field the customer names with --bamboohr-background-field (BambooHR has no standard one; usually a custom field or table); skipped when none is named, because which positions need a check is the organization's policy.",
31
+ "evidenceType": "Report"
32
+ },
33
+ {
34
+ "id": "bamboohr.org_chart_complete",
35
+ "title": "Each active person has a manager and a job title in the HR system",
36
+ "scope": "Every active BambooHR record: no manager (at most --bamboohr-max-without-manager, default 1, the top of the chart), a manager not in the HR system, or no job title fails. Shows reporting lines and titles exist, not that position descriptions carry security duties.",
37
+ "evidenceType": "Report"
38
+ }
39
+ ]
40
+ }
@@ -37,7 +37,7 @@
37
37
  {
38
38
  "id": "entra.security_defaults",
39
39
  "title": "Security defaults are on, or off because Conditional Access requires MFA for everyone",
40
- "scope": "Tenant-wide baseline; per-application exceptions are not visible here.",
40
+ "scope": "Tenant-wide baseline: MFA for all users and admins, legacy authentication and device code flow blocked (learn.microsoft.com/entra/fundamentals/security-defaults, updated 2026-10-16, read 2026-11-03); per-application exceptions are not visible here.",
41
41
  "evidenceType": "Configuration"
42
42
  },
43
43
  {
@@ -74,6 +74,18 @@
74
74
  "title": "Each running Compute Engine VM has a recent OS Config vulnerability report with no high or critical CVE left unpatched",
75
75
  "scope": "Running VMs of the project (compute aggregated/instances), against OS Config vulnerabilityReports per zone (OS Config API v1, read 2026-10-26): fails when highestUpgradableCveSeverity is HIGH or CRITICAL (a fix exists and is not installed), when the report is older than 7 days, or when the VM has none (no OS Config agent reporting, or VM Manager off). Needs roles/osconfig.vulnerabilityReportViewer. GKE nodes, containers and VMs whose OS VM Manager does not support are seen only as having no report. Prowler has no equivalent check.",
76
76
  "evidenceType": "Report"
77
+ },
78
+ {
79
+ "id": "gcp.cloudsql_backup_runs_succeeded",
80
+ "title": "Each Cloud SQL instance's latest backup run in the window succeeded",
81
+ "scope": "Cloud SQL Admin API backupRuns.list per instance (cloudsql.backupRuns.list, in roles/viewer), over --gcp-backup-window-days (default 7): the latest finished run must be SUCCESSFUL. Whether automated backups are configured is Prowler's cloudsql_instance_automated_backups; Backup and DR Service jobs are not read (no public job list endpoint was found on 2026-11-07).",
82
+ "evidenceType": "Log"
83
+ },
84
+ {
85
+ "id": "gcp.log_bucket_retention",
86
+ "title": "Each Cloud Logging bucket keeps logs for at least the required period (default 365 days)",
87
+ "scope": "Cloud Logging API projects.locations.buckets.list (logging.buckets.list, in roles/logging.viewer): retentionDays of _Default and of every bucket the organization created, against --gcp-log-retention-days (default 365). _Required is fixed by Google and not judged.",
88
+ "evidenceType": "Configuration"
77
89
  }
78
90
  ]
79
91
  }
@@ -2,7 +2,7 @@
2
2
  "source": "intune",
3
3
  "title": "Microsoft Intune (Microsoft Graph v1.0, read-only application permissions)",
4
4
  "readScopes": [
5
- "Microsoft Graph application permission DeviceManagementManagedDevices.Read.All (GET /deviceManagement/managedDevices), with admin consent.",
5
+ "Microsoft Graph application permission DeviceManagementManagedDevices.Read.All (GET /deviceManagement/managedDevices, and since 2026-11-07 GET /beta/deviceManagement/remoteActionAudits), with admin consent.",
6
6
  "Microsoft Graph application permission DeviceManagementConfiguration.Read.All (GET /deviceManagement/deviceCompliancePolicies and its /assignments), with admin consent."
7
7
  ],
8
8
  "checks": [
@@ -47,6 +47,12 @@
47
47
  "title": "Each managed device has synced with Intune within the last 14 days",
48
48
  "scope": "Every Intune managed device, from lastSyncDateTime; the window is --intune-sync-days.",
49
49
  "evidenceType": "Configuration"
50
+ },
51
+ {
52
+ "id": "intune.device_wipe_recorded",
53
+ "title": "Each device wiped, retired or deleted in the period has a finished wipe or retire recorded",
54
+ "scope": "One result per device with a factoryReset, factoryResetKeepEnrollmentData, removeCompanyData, cleanWindowsDevice or delete action in the remote action audit within --intune-wipe-days (default 365): a done wipe or retire passes; failed, canceled or not supported fails; pending for more than 7 days fails; a delete with no wipe or retire fails. A device removed with no recorded action is not seen.",
55
+ "evidenceType": "Audit Record"
50
56
  }
51
57
  ]
52
58
  }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "source": "jamf",
3
- "title": "Jamf Pro (Jamf Pro API, API client with Read Computers)",
3
+ "title": "Jamf Pro (Jamf Pro API, API client with Read Computers and View MDM command information)",
4
4
  "readScopes": [
5
- "A Jamf Pro API client whose only API role holds the single privilege \"Read Computers\" (GET /api/v4/computers-inventory, falling back to v3, v2 or v1); its client id and secret are JAMF_CLIENT_ID and JAMF_CLIENT_SECRET, exchanged at POST /api/v1/oauth/token."
5
+ "A Jamf Pro API client whose only API role holds two privileges: \"Read Computers\" (GET /api/v4/computers-inventory, falling back to v3, v2 or v1) and, since 2026-11-07, \"View MDM command information in Jamf Pro API\" (GET /api/v2/mdm/commands); its client id and secret are JAMF_CLIENT_ID and JAMF_CLIENT_SECRET, exchanged at POST /api/v1/oauth/token."
6
6
  ],
7
7
  "checks": [
8
8
  {
@@ -28,6 +28,12 @@
28
28
  "title": "Each Mac is under Jamf Pro remote management",
29
29
  "scope": "Every computer, from general.remoteManagement.managed.",
30
30
  "evidenceType": "Configuration"
31
+ },
32
+ {
33
+ "id": "jamf.device_wipe_recorded",
34
+ "title": "Each Mac or mobile device erased in the period has a finished erase recorded",
35
+ "scope": "One result per device with an ERASE_DEVICE command sent within --jamf-wipe-days (default 365): ACKNOWLEDGED passes; ERROR or COMMAND_FORMAT_ERROR fails; PENDING or NOT_NOW for more than 7 days fails. A computer is named by its Jamf id and serial when the inventory carries its management id. A device retired without an erase is not seen.",
36
+ "evidenceType": "Audit Record"
31
37
  }
32
38
  ]
33
39
  }
@@ -2,7 +2,7 @@
2
2
  "source": "kandji",
3
3
  "title": "Iru Endpoint Management, formerly Kandji (Enterprise API, API token with GET permissions only)",
4
4
  "readScopes": [
5
- "An Iru Endpoint Management API token (Settings > Access > Add API Token) whose permissions are these five GET endpoints only: GET /api/v1/devices (List Devices), GET /api/v1/devices/{device_id}/status (Get Device Status), GET /api/v1/prism/application_firewall, GET /api/v1/prism/gatekeeper_and_xprotect and GET /api/v1/prism/startup_settings. The token is KANDJI_API_TOKEN; the tenant API URL (https://<subdomain>.api.kandji.io, or .api.eu.kandji.io in the EU) is KANDJI_API_URL."
5
+ "An Iru Endpoint Management API token (Settings > Access > Add API Token) whose permissions are these six GET endpoints only: GET /api/v1/devices (List Devices), GET /api/v1/devices/{device_id}/status (Get Device Status), GET /api/v1/devices/{device_id}/commands (Get Device Commands, since 2026-11-07), GET /api/v1/prism/application_firewall, GET /api/v1/prism/gatekeeper_and_xprotect and GET /api/v1/prism/startup_settings. The token is KANDJI_API_TOKEN; the tenant API URL (https://<subdomain>.api.kandji.io, or .api.eu.kandji.io in the EU) is KANDJI_API_URL."
6
6
  ],
7
7
  "checks": [
8
8
  {
@@ -58,6 +58,12 @@
58
58
  "title": "System Integrity Protection is enabled",
59
59
  "scope": "Every Mac with a row in Prism's Startup settings category, from sip.",
60
60
  "evidenceType": "Configuration"
61
+ },
62
+ {
63
+ "id": "kandji.device_wipe_recorded",
64
+ "title": "Each Mac erased in the period has a finished erase recorded",
65
+ "scope": "One result per listed Mac with an EraseDevice command requested within --kandji-wipe-days (default 365): completed passes; failed fails; pending, running or Not Now for more than 7 days fails. A Mac deleted from the tenant after its erase is no longer listed, so its erase is not seen.",
66
+ "evidenceType": "Audit Record"
61
67
  }
62
68
  ]
63
69
  }
@@ -0,0 +1,40 @@
1
+ {
2
+ "source": "rippling",
3
+ "title": "Rippling (REST API, API token with workers.read)",
4
+ "readScopes": [
5
+ "A Rippling API token made under Developer > API credentials (https://app.rippling.com/api-tokens/tokens) with the single scope workers.read, used only with GET https://rest.ripplingapis.com/workers/. The token is RIPPLING_API_TOKEN. Rippling revokes a token after 30 days unused or when its owner leaves, so the collector runs at least monthly.",
6
+ "For the leaver check, the identity provider's own read-only credential, already granted to its source: Okta okta.users.read; Entra ID User.Read.All and AuditLog.Read.All; Google Workspace admin.directory.user.readonly and admin.reports.audit.readonly; JumpCloud users.readonly."
7
+ ],
8
+ "checks": [
9
+ {
10
+ "id": "rippling.leaver_access_removed",
11
+ "title": "Each person who left in the period lost identity-provider access within 24 hours of their last day",
12
+ "scope": "One result per Rippling worker record whose employment ended in the period (--rippling-since, default 365 days), joined by work email with the identity provider named by --rippling-idp (Okta, Entra ID, Google Workspace or JumpCloud) through that provider's own read-only credential. A disable or delete time comes from Okta statusChanged, the Entra directory audit (kept 7 or 30 days) or deleted-items list (30 days), or Google suspensionTime and DELETE_USER events (6 months); JumpCloud records no time. Without a time, or with no account under the email, the pass is marked incomplete. The window is --rippling-access-hours.",
13
+ "evidenceType": "Audit Record"
14
+ },
15
+ {
16
+ "id": "rippling.hire_acknowledgement",
17
+ "title": "Each person hired in the period has a recorded code of conduct or acceptable-use acknowledgement",
18
+ "scope": "One result per Rippling worker record whose start date falls in the period, from the date field the customer names with --rippling-ack-field (a Rippling custom field holding the date); skipped when none is named. Shows a date was recorded, not what was signed.",
19
+ "evidenceType": "Report"
20
+ },
21
+ {
22
+ "id": "rippling.hire_training",
23
+ "title": "Each person hired in the period completed security awareness training within 30 days of starting",
24
+ "scope": "One result per hire in the period, joined by employee id or work email with the LMS export the lms source reads (--training-csv; course text --lms-awareness-course); the window is --rippling-training-days. Skipped without the export.",
25
+ "evidenceType": "Report"
26
+ },
27
+ {
28
+ "id": "rippling.hire_background_check",
29
+ "title": "Each person hired in the period has a background check recorded",
30
+ "scope": "One result per hire in the period, from the date field the customer names with --rippling-background-field (a Rippling custom field holding the date); skipped when none is named, because which positions need a check is the organization's policy.",
31
+ "evidenceType": "Report"
32
+ },
33
+ {
34
+ "id": "rippling.org_chart_complete",
35
+ "title": "Each active person has a manager and a job title in the HR system",
36
+ "scope": "Every active Rippling worker record: no manager (at most --rippling-max-without-manager, default 1, the top of the chart), a manager not in the HR system, or no job title fails. Shows reporting lines and titles exist, not that position descriptions carry security duties.",
37
+ "evidenceType": "Report"
38
+ }
39
+ ]
40
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "source": "vulnscan",
3
+ "title": "Vulnerability scanner results (Tenable Nessus, Tenable Vulnerability Management, Qualys VMDR), held to the asset inventory",
4
+ "readScopes": [
5
+ "None for the collector: it reads export files. The exports need the scanner's read-only role: Tenable, 'at least the Basic [16] user permission' and 'Can View' on the assets; Qualys, the Reader role ('Scanners and Readers view VM scanned hosts')."
6
+ ],
7
+ "checks": [
8
+ {
9
+ "id": "vulnscan.authenticated_coverage",
10
+ "title": "Every asset in the inventory was scanned with working credentials (100% authenticated coverage)",
11
+ "scope": "Each inventory asset (--vulnscan-inventory: CSV, JSON or one per line) matched by name, short name or IP to a scanned host whose credential record says yes: plugin 19506 in a .nessus or Tenable export, last_authenticated_scan_date at the last scan in a Tenable asset export, or LAST_VM_AUTH_SCANNED_DATE at LAST_VM_SCANNED_DATE in a Qualys detection. Without an inventory the check is skipped.",
12
+ "evidenceType": "Report"
13
+ },
14
+ {
15
+ "id": "vulnscan.scan_recent",
16
+ "title": "Every asset was scanned within the scan period (default 30 days: monthly)",
17
+ "scope": "The last scan date of each inventory asset (or of each scanned host when no inventory is given): HOST_END in a .nessus file, plugin 19506's last_found or last_scan_time in a Tenable export, LAST_VM_SCANNED_DATE in a Qualys detection; --vulnscan-max-age-days sets the period.",
18
+ "evidenceType": "Report"
19
+ },
20
+ {
21
+ "id": "vulnscan.findings_within_sla",
22
+ "title": "No open vulnerability is older than its severity's remediation window (default 30 days critical and high, 90 medium, 180 low)",
23
+ "scope": "Open findings (Tenable state OPEN or REOPENED; Qualys confirmed detections not Fixed) aged from their first-found date against --vulnscan-sla. A .nessus file is one scan with no first-found date, so the check is skipped for it.",
24
+ "evidenceType": "Report"
25
+ }
26
+ ]
27
+ }