@sagentix-inc/evidence-collector 0.3.3 → 0.3.5
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 +27 -0
- package/apps/sos-app/src/data/checks/resend.json +27 -0
- package/apps/sos-app/src/data/checks/stripe.json +21 -0
- package/apps/sos-app/src/data/checks/vercel.json +51 -0
- package/collector/package.json +1 -1
- package/collector/src/cli.js +18 -11
- package/collector/src/deliveries.js +27 -1
- package/collector/src/refs.js +228 -0
- package/collector/src/sources/index.js +5 -1
- package/collector/src/sources/resend.js +137 -0
- package/collector/src/sources/stripe.js +95 -0
- package/collector/src/sources/vercel.js +195 -0
- package/collector/src/vault.js +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -394,6 +394,33 @@ node src/cli.ts run --source lms --training-csv lms-export.csv --roster-csv rost
|
|
|
394
394
|
| --- | --- |
|
|
395
395
|
| `security_awareness_current` | Everyone on the roster completed security awareness training within 12 months |
|
|
396
396
|
| `role_based_training_current` | People in privileged roles completed role-based training within 12 months |
|
|
397
|
+
|
|
398
|
+
### Stripe
|
|
399
|
+
|
|
400
|
+
**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).
|
|
401
|
+
|
|
402
|
+
**Credentials:** `STRIPE_API_KEY` (`rk_live_…`; an `rk_test_…` sandbox key works and marks each resource "(sandbox)"). The collector calls one endpoint, `GET /v1/webhook_endpoints`, paging with `starting_after`, and sends endpoint ids and URL hosts only, never a path or a signing secret.
|
|
403
|
+
|
|
404
|
+
Stripe's API does not show team members, their two-step authentication, the API key inventory, key access policies or Radar rules. Those stay manual evidence.
|
|
405
|
+
|
|
406
|
+
| Check (`stripe.`…) | What passes |
|
|
407
|
+
| --- | --- |
|
|
408
|
+
| `webhooks_https` | Every enabled webhook endpoint receives events over HTTPS |
|
|
409
|
+
| `webhook_events_scoped` | No enabled webhook endpoint subscribes to every event (`*`) |
|
|
410
|
+
|
|
411
|
+
### Resend
|
|
412
|
+
|
|
413
|
+
**Create:** in Resend, API Keys > **Create API key** with **Full access**. Resend has no read-only permission, and a **Sending access** key cannot list domains or keys (resend.com/docs/api-reference/api-keys/create-api-key, read 2026-11-02). The collector only sends `GET` to `https://api.resend.com`; give the key a name of its own, keep it in a secrets store, and expire it if the machine that holds it is lost.
|
|
414
|
+
|
|
415
|
+
**Credentials:** `RESEND_API_KEY` (`re_…`). The collector calls `GET /domains`, `GET /domains/{id}` and `GET /api-keys`, with a User-Agent header as Resend requires, and sends domain names and key ids only: never a key's name or value, nor a DNS record's value.
|
|
416
|
+
|
|
417
|
+
**Options:** `--resend-key-max-age-days` (default 365), `--resend-inactive-days` (default 90). A domain's TLS mode is not returned by `GET /domains/{id}`, so it is not judged; DMARC is a DNS record Resend does not manage (the `web` source's `--web-mail-domain` reads it).
|
|
418
|
+
|
|
419
|
+
| Check (`resend.`…) | What passes |
|
|
420
|
+
| --- | --- |
|
|
421
|
+
| `sending_domains_authenticated` | Each sending domain is verified, with every SPF and DKIM record verified |
|
|
422
|
+
| `api_keys_rotated` | Every API key was created within the allowed age |
|
|
423
|
+
| `api_keys_in_use` | Every API key was used in the last 90 days; a key never used fails |
|
|
397
424
|
| `training_records_complete` | Every person on the roster has a training record |
|
|
398
425
|
|
|
399
426
|
## Identity and code sources
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "resend",
|
|
3
|
+
"title": "Resend (REST API, full_access key used for GET only)",
|
|
4
|
+
"readScopes": [
|
|
5
|
+
"A Resend API key with full_access (Resend has no read-only permission; a sending_access key cannot list domains or keys). The collector sends GET requests only, to https://api.resend.com."
|
|
6
|
+
],
|
|
7
|
+
"checks": [
|
|
8
|
+
{
|
|
9
|
+
"id": "resend.sending_domains_authenticated",
|
|
10
|
+
"title": "Every Resend sending domain is verified, with its SPF and DKIM records verified",
|
|
11
|
+
"scope": "Domains whose sending capability is not disabled; the domain status and the status Resend reports for each SPF and DKIM record.",
|
|
12
|
+
"evidenceType": "Configuration"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "resend.api_keys_rotated",
|
|
16
|
+
"title": "Every Resend API key was created within the allowed age (365 days by default)",
|
|
17
|
+
"scope": "Every API key of the team, by created_at; --resend-key-max-age-days sets the age.",
|
|
18
|
+
"evidenceType": "Configuration"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "resend.api_keys_in_use",
|
|
22
|
+
"title": "Every Resend API key was used in the last 90 days",
|
|
23
|
+
"scope": "Every API key of the team, by last_used_at; a key never used fails. --resend-inactive-days sets the window.",
|
|
24
|
+
"evidenceType": "Configuration"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "stripe",
|
|
3
|
+
"title": "Stripe (REST API, restricted key with Webhook Endpoints: Read)",
|
|
4
|
+
"readScopes": [
|
|
5
|
+
"A restricted API key (rk_live_...) whose only permission is Webhook Endpoints: Read (Dashboard > Developers > API keys > Create restricted key). A secret key (sk_...) is refused."
|
|
6
|
+
],
|
|
7
|
+
"checks": [
|
|
8
|
+
{
|
|
9
|
+
"id": "stripe.webhooks_https",
|
|
10
|
+
"title": "Every enabled Stripe webhook endpoint receives events over HTTPS",
|
|
11
|
+
"scope": "The enabled webhook endpoints of the account (or sandbox) the restricted key belongs to; whether the receiver checks the Stripe-Signature header is not visible.",
|
|
12
|
+
"evidenceType": "Configuration"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "stripe.webhook_events_scoped",
|
|
16
|
+
"title": "No enabled Stripe webhook endpoint subscribes to every event",
|
|
17
|
+
"scope": "enabled_events of each enabled endpoint; '*' (all events, per Stripe's API reference) fails. Which events an endpoint truly needs is not judged.",
|
|
18
|
+
"evidenceType": "Configuration"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "vercel",
|
|
3
|
+
"title": "Vercel (team-scoped access token; the collector sends GET only)",
|
|
4
|
+
"readScopes": [
|
|
5
|
+
"Team-scoped access token (Scope: the team, All Projects), created by a team member whose role can read team settings, members and project settings; Vercel tokens have no read-only setting"
|
|
6
|
+
],
|
|
7
|
+
"checks": [
|
|
8
|
+
{
|
|
9
|
+
"id": "vercel.team_saml_enforced",
|
|
10
|
+
"title": "Team sign-in is enforced through SAML single sign-on",
|
|
11
|
+
"scope": "Members of the Vercel team; the factors the identity provider asks for are not visible here.",
|
|
12
|
+
"evidenceType": "Configuration"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "vercel.team_owners_limited",
|
|
16
|
+
"title": "At most --vercel-max-owners members are team owners",
|
|
17
|
+
"scope": "Team-level roles; project-level roles are not counted.",
|
|
18
|
+
"evidenceType": "Configuration"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "vercel.activity_log_available",
|
|
22
|
+
"title": "The team activity log is readable and not empty",
|
|
23
|
+
"scope": "Vercel's activity events for the team; retention and export are not judged.",
|
|
24
|
+
"evidenceType": "Audit Record"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "vercel.deployment_protection",
|
|
28
|
+
"title": "Deployment Protection covers preview deployments",
|
|
29
|
+
"scope": "Each project's Vercel Authentication or Password Protection scope; production custom domains may stay public by design.",
|
|
30
|
+
"evidenceType": "Configuration"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "vercel.git_fork_protection",
|
|
34
|
+
"title": "Pull requests from forks need a team member's authorization before they deploy",
|
|
35
|
+
"scope": "Projects connected to a Git repository.",
|
|
36
|
+
"evidenceType": "Configuration"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "vercel.no_plain_secret_envs",
|
|
40
|
+
"title": "No credential is stored as a plain environment variable",
|
|
41
|
+
"scope": "Variable names and types only; a credential with an innocent name is not found.",
|
|
42
|
+
"evidenceType": "Configuration"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "vercel.firewall_enabled",
|
|
46
|
+
"title": "The Vercel Firewall is on for the project",
|
|
47
|
+
"scope": "Each project's firewall switch; the rules it runs are not judged.",
|
|
48
|
+
"evidenceType": "Configuration"
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
package/collector/package.json
CHANGED
package/collector/src/cli.js
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Exit codes: 0 sent (or dry run), 1 error (bad key, network, a source that failed), 2 usage, 3 nothing to send,
|
|
18
18
|
// 4 sos-app refused the push (its message is printed); `agent` only: 75 a signed update was written, restart me.
|
|
19
|
-
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
20
|
-
import { dirname, resolve } from "node:path";
|
|
19
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { parseArgs } from "node:util";
|
|
23
23
|
import { DEFAULT_CATALOG_DIR, loadCatalog } from "./catalog.js";
|
|
@@ -31,7 +31,8 @@ import { CLASSES } from "./v1/rules.js";
|
|
|
31
31
|
import { EXIT_UPDATE, checkin, detectRunner, modeOf } from "./channel.js";
|
|
32
32
|
import { RunRecorder, buildReport, deliverReport } from "./report.js";
|
|
33
33
|
import { agent } from "./agent.js";
|
|
34
|
-
import { VAULT_FILE, keyHolderFor, runEnv } from "./vault.js";
|
|
34
|
+
import { VAULT_FILE, Vault, keyHolderFor, runEnv } from "./vault.js";
|
|
35
|
+
import { applyRefs } from "./refs.js";
|
|
35
36
|
import { credentialsIn, settingsOf, vaultBridge } from "./deliveries.js";
|
|
36
37
|
export const COLLECT_PATH = "/api/collect";
|
|
37
38
|
export const KEY_ENV = "SAGENTIX_COLLECTOR_KEY";
|
|
@@ -236,15 +237,21 @@ export async function run(argv, deps) {
|
|
|
236
237
|
if (configPath && keyId) {
|
|
237
238
|
try {
|
|
238
239
|
const creds = credentialsIn(resolve(configPath));
|
|
239
|
-
const
|
|
240
|
-
(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
240
|
+
const holder = deps.vaultKey?.(dir, keyId) ?? keyHolderFor(dir, keyId, { env: deps.env, platform: process.platform, runner });
|
|
241
|
+
const r = runEnv(dir, holder, deps.env, (name) => settingsOf(creds, name));
|
|
242
|
+
// Connectors read from the client's own vault (src/refs.ts) take all their names from there, after the local vault.
|
|
243
|
+
const refs = existsSync(join(dir, VAULT_FILE)) ? new Vault(dir, holder).refs() : {};
|
|
244
|
+
const f = Object.keys(refs).length ? await applyRefs(dir, refs, creds, r.env, deps.channel ?? deps.fetch) : null;
|
|
245
|
+
env = f?.env ?? r.env;
|
|
246
|
+
cleanup = () => { r.cleanup(); f?.cleanup(); };
|
|
247
|
+
vaultSecrets = [...r.secrets, ...(f?.secrets ?? [])];
|
|
248
|
+
const used = r.used.length + (f?.used.length ?? 0), warnings = [...r.warnings, ...(f?.warnings ?? [])];
|
|
249
|
+
if (used)
|
|
250
|
+
deps.err(`vault: ${used} value(s) from ${VAULT_FILE}${f ? " and your own vault" : ""}`);
|
|
251
|
+
for (const w of warnings)
|
|
245
252
|
deps.err(`warning: vault: ${w}`);
|
|
246
|
-
if (
|
|
247
|
-
rec.note("vault",
|
|
253
|
+
if (warnings.length)
|
|
254
|
+
rec.note("vault", warnings);
|
|
248
255
|
}
|
|
249
256
|
catch (e) {
|
|
250
257
|
const w = `the vault could not be opened: ${String(e?.message ?? e)}`;
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// remove the vault's value for such a name
|
|
7
7
|
// import-env move such names' values from sagentix-collector.env into the vault, then drop those lines
|
|
8
8
|
// test run one connector of the config dry (resident mode only), and report its counts
|
|
9
|
+
// set-ref read one connector from the client's own vault (src/refs.ts): a reference, never a value
|
|
10
|
+
// remove-ref read that connector from this vault again
|
|
9
11
|
//
|
|
10
12
|
// A delivery for a name the config does not list, or with another kind, is refused and reported, never applied: what
|
|
11
13
|
// sos-app says cannot widen what this collector reads. No delivery can name a file, a command or a source option.
|
|
@@ -14,6 +16,7 @@ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } fr
|
|
|
14
16
|
import { dirname, isAbsolute, join } from "node:path";
|
|
15
17
|
import { signedPost, writePrivate } from "./channel.js";
|
|
16
18
|
import { scrubText } from "./report.js";
|
|
19
|
+
import { refProblem } from "./refs.js";
|
|
17
20
|
import { KINDS, MAX_VALUE, NAME, Vault, canonicalBind } from "./vault.js";
|
|
18
21
|
export const ACK_PATH = "/api/collect/vault/ack";
|
|
19
22
|
export const ENV_FILE = "sagentix-collector.env";
|
|
@@ -106,16 +109,18 @@ export function vaultBridge(o) {
|
|
|
106
109
|
function summary() {
|
|
107
110
|
const c = creds(), known = knownNames(c);
|
|
108
111
|
let sealKey = null, entries = [], error = null, protection = null;
|
|
112
|
+
let refs = {};
|
|
109
113
|
try {
|
|
110
114
|
protection = v().protection;
|
|
111
115
|
// A setting's value goes along (a URL, a tenant id), so sos-app shows it and the browser binds a secret to it.
|
|
112
116
|
entries = v().list().map((e) => e.kind === "setting" ? { ...e, value: v().get(e.name) } : e);
|
|
117
|
+
refs = v().refs();
|
|
113
118
|
sealKey = Object.keys(c).length ? v().sealPublicKey() : null;
|
|
114
119
|
}
|
|
115
120
|
catch (e) {
|
|
116
121
|
error = String(e?.message ?? e).slice(0, 300);
|
|
117
122
|
}
|
|
118
|
-
return { sealKey, protection, error, entries, envFile: [...new Set(envLines(known).map((l) => l.name))], credentials: c };
|
|
123
|
+
return { sealKey, protection, error, entries, refs, envFile: [...new Set(envLines(known).map((l) => l.name))], credentials: c };
|
|
119
124
|
}
|
|
120
125
|
function importEnv(known) {
|
|
121
126
|
const moved = [], leftFiles = [], problems = [], c = creds();
|
|
@@ -203,6 +208,27 @@ export function vaultBridge(o) {
|
|
|
203
208
|
o.log(`vault: ${name} set`);
|
|
204
209
|
out.push({ id: d.id, ok: true });
|
|
205
210
|
}
|
|
211
|
+
else if (d.op === "set-ref" || d.op === "remove-ref") {
|
|
212
|
+
const id = String(d.connector ?? "");
|
|
213
|
+
if (!c[id]) {
|
|
214
|
+
refuse(`${id.slice(0, 40)} is not a connector of this collector`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (d.op === "remove-ref") {
|
|
218
|
+
v().removeRef(id);
|
|
219
|
+
o.log(`vault: ${id} reads its own settings again`);
|
|
220
|
+
out.push({ id: d.id, ok: true });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const problem = refProblem(d.ref);
|
|
224
|
+
if (problem) {
|
|
225
|
+
refuse(`${id}: ${problem}`);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
v().setRef(id, d.ref, o.now());
|
|
229
|
+
o.log(`vault: ${id} reads from your own vault (${d.ref.provider})`);
|
|
230
|
+
out.push({ id: d.id, ok: true });
|
|
231
|
+
}
|
|
206
232
|
else if (d.op === "import-env") {
|
|
207
233
|
const r = importEnv(known);
|
|
208
234
|
o.log(`vault: ${r.moved?.length ?? 0} value(s) moved from ${ENV_FILE}`);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// A connector read from the client's own vault (apps/sos-app/docs/COLLECTOR_VAULT.md, section 5). sos-app stores a
|
|
2
|
+
// reference, never a value: one secret in the client's vault, a JSON object holding every name the connector reads
|
|
3
|
+
// (its settings and its secrets together). The collector reads it at run time with the identity the computer already has.
|
|
4
|
+
//
|
|
5
|
+
// aws AWS Secrets Manager: arn:aws:secretsmanager:<region>:<account>:secret:<name>, GetSecretValue, signed with
|
|
6
|
+
// the AWS credentials in the environment (keys, or a role through a web identity token)
|
|
7
|
+
// azure Azure Key Vault: https://<vault>.vault.azure.net/secrets/<name>, with a token for https://vault.azure.net
|
|
8
|
+
// from AZURE_TENANT_ID + AZURE_CLIENT_ID (+ secret or federated token), else the managed identity
|
|
9
|
+
// gcp Google Cloud Secret Manager: projects/<project>/secrets/<name> (latest version), with GCP_SA_KEY_FILE,
|
|
10
|
+
// else the metadata server's service account
|
|
11
|
+
// hashicorp HashiCorp Vault, key/value version 2: <mount>/<path>, at VAULT_ADDR with VAULT_TOKEN or an AppRole
|
|
12
|
+
// (VAULT_ROLE_ID, VAULT_SECRET_ID); VAULT_NAMESPACE when set
|
|
13
|
+
//
|
|
14
|
+
// Why the whole connector, settings included: the destination then comes from the client's vault too. sos-app (or anyone
|
|
15
|
+
// who changed its data) can only point a connector at another secret; it can never pair a real secret with an address of
|
|
16
|
+
// its choosing. For the same reason a connector read by reference takes ALL its names from that one object and nothing
|
|
17
|
+
// else (not the local vault, not the environment), and is refused when the object lacks one it needs. The addresses a
|
|
18
|
+
// token goes to are fixed here or come from this computer's own environment (VAULT_ADDR), never from sos-app.
|
|
19
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { writePrivate } from "./channel.js";
|
|
22
|
+
import { envCredentials, fetchAwsTransport } from "./sources/aws/api.js";
|
|
23
|
+
import { gcpKeyToken } from "./sources/gcp/api.js";
|
|
24
|
+
export const PROVIDERS = ["aws", "azure", "gcp", "hashicorp"];
|
|
25
|
+
const AWS_ARN = /^arn:aws(-[a-z]+)*:secretsmanager:([a-z]{2}(-[a-z]+)+-\d):(\d{12}):secret:[A-Za-z0-9/_+=.@-]{1,512}$/;
|
|
26
|
+
const AZURE_ID = /^https:\/\/([a-z0-9-]{3,24})\.vault\.azure\.net\/secrets\/([A-Za-z0-9-]{1,127})(\/[0-9a-f]{32})?$/;
|
|
27
|
+
const GCP_ID = /^projects\/([a-z][a-z0-9-]{4,28}[a-z0-9]|\d{1,20})\/secrets\/([A-Za-z0-9_-]{1,255})$/;
|
|
28
|
+
const VAULT_PATH = /^[A-Za-z0-9_-]{1,64}\/[A-Za-z0-9_./-]{1,512}$/;
|
|
29
|
+
/** Why a reference is not usable, or null. Checked on both sides: sos-app before queueing, the collector before reading. */
|
|
30
|
+
export function refProblem(r) {
|
|
31
|
+
if (typeof r !== "object" || r === null)
|
|
32
|
+
return "a reference is an object";
|
|
33
|
+
const { provider, id } = r;
|
|
34
|
+
if (!PROVIDERS.includes(provider))
|
|
35
|
+
return `provider must be one of ${PROVIDERS.join(", ")}`;
|
|
36
|
+
if (typeof id !== "string")
|
|
37
|
+
return "id must be text";
|
|
38
|
+
const ok = provider === "aws" ? AWS_ARN.test(id) : provider === "azure" ? AZURE_ID.test(id) : provider === "gcp" ? GCP_ID.test(id)
|
|
39
|
+
: VAULT_PATH.test(id) && !id.split("/").includes("..");
|
|
40
|
+
return ok ? null : { aws: "an AWS Secrets Manager ARN (arn:aws:secretsmanager:<region>:<account>:secret:<name>)",
|
|
41
|
+
azure: "an Azure Key Vault secret URL (https://<vault>.vault.azure.net/secrets/<name>)",
|
|
42
|
+
gcp: "a Secret Manager name (projects/<project>/secrets/<name>)",
|
|
43
|
+
hashicorp: "a key/value path (<mount>/<path>)" }[provider];
|
|
44
|
+
}
|
|
45
|
+
const json = async (res, what) => {
|
|
46
|
+
const text = await res.text();
|
|
47
|
+
if (!res.ok)
|
|
48
|
+
throw new Error(`${what}: HTTP ${res.status}`);
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error(`${what}: the answer is not JSON`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
async function aws(id, env, f) {
|
|
57
|
+
const region = AWS_ARN.exec(id)[2];
|
|
58
|
+
const creds = await envCredentials(env, region, f);
|
|
59
|
+
if (!creds)
|
|
60
|
+
throw new Error("no AWS credentials in the environment (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or AWS_ROLE_ARN with AWS_WEB_IDENTITY_TOKEN_FILE)");
|
|
61
|
+
if ("error" in creds)
|
|
62
|
+
throw new Error(creds.error);
|
|
63
|
+
const host = `secretsmanager.${region}.amazonaws.com`;
|
|
64
|
+
const r = await fetchAwsTransport(creds, f).send({ service: "secretsmanager", region, method: "POST", host, path: "/", query: {}, action: "GetSecretValue",
|
|
65
|
+
headers: { host, "content-type": "application/x-amz-json-1.1", "x-amz-target": "secretsmanager.GetSecretValue" }, body: JSON.stringify({ SecretId: id }) });
|
|
66
|
+
if (r.status !== 200)
|
|
67
|
+
throw new Error(`AWS Secrets Manager GetSecretValue: HTTP ${r.status}`);
|
|
68
|
+
const b = JSON.parse(r.body);
|
|
69
|
+
if (typeof b.SecretString === "string")
|
|
70
|
+
return b.SecretString;
|
|
71
|
+
if (typeof b.SecretBinary === "string")
|
|
72
|
+
return Buffer.from(b.SecretBinary, "base64").toString("utf8");
|
|
73
|
+
throw new Error("AWS Secrets Manager returned no secret");
|
|
74
|
+
}
|
|
75
|
+
async function azureToken(env, f) {
|
|
76
|
+
const scope = "https://vault.azure.net/.default";
|
|
77
|
+
if (env.AZURE_TENANT_ID && env.AZURE_CLIENT_ID && (env.AZURE_CLIENT_SECRET || env.AZURE_FEDERATED_TOKEN_FILE)) {
|
|
78
|
+
const form = new URLSearchParams({ grant_type: "client_credentials", client_id: env.AZURE_CLIENT_ID, scope });
|
|
79
|
+
if (env.AZURE_FEDERATED_TOKEN_FILE) {
|
|
80
|
+
const { readFileSync } = await import("node:fs");
|
|
81
|
+
form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
82
|
+
form.set("client_assertion", readFileSync(env.AZURE_FEDERATED_TOKEN_FILE, "utf8").trim());
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
form.set("client_secret", env.AZURE_CLIENT_SECRET);
|
|
86
|
+
const b = await json(await f(`https://login.microsoftonline.com/${encodeURIComponent(env.AZURE_TENANT_ID)}/oauth2/v2.0/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form.toString() }), "Azure token");
|
|
87
|
+
if (typeof b.access_token !== "string")
|
|
88
|
+
throw new Error("Azure token: no access_token");
|
|
89
|
+
return b.access_token;
|
|
90
|
+
}
|
|
91
|
+
// The managed identity of the virtual machine or container the collector runs on.
|
|
92
|
+
const b = await json(await f("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net", { headers: { Metadata: "true" } }), "Azure managed identity");
|
|
93
|
+
if (typeof b.access_token !== "string")
|
|
94
|
+
throw new Error("Azure managed identity: no access_token");
|
|
95
|
+
return b.access_token;
|
|
96
|
+
}
|
|
97
|
+
async function azure(id, env, f) {
|
|
98
|
+
const token = await azureToken(env, f);
|
|
99
|
+
// AZURE_ID allows only https://<vault>.vault.azure.net, so the token goes to Key Vault and nowhere else.
|
|
100
|
+
const b = await json(await f(`${id}?api-version=7.4`, { headers: { authorization: `Bearer ${token}` } }), "Azure Key Vault");
|
|
101
|
+
if (typeof b.value !== "string")
|
|
102
|
+
throw new Error("Azure Key Vault returned no value");
|
|
103
|
+
return b.value;
|
|
104
|
+
}
|
|
105
|
+
async function gcp(id, env, f) {
|
|
106
|
+
let token;
|
|
107
|
+
const key = gcpKeyToken(env, f);
|
|
108
|
+
if (typeof key === "string")
|
|
109
|
+
throw new Error(key);
|
|
110
|
+
if (key)
|
|
111
|
+
token = await key();
|
|
112
|
+
else {
|
|
113
|
+
const b = await json(await f("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", { headers: { "Metadata-Flavor": "Google" } }), "Google metadata server");
|
|
114
|
+
if (typeof b.access_token !== "string")
|
|
115
|
+
throw new Error("Google metadata server: no access_token");
|
|
116
|
+
token = b.access_token;
|
|
117
|
+
}
|
|
118
|
+
const b = await json(await f(`https://secretmanager.googleapis.com/v1/${id}/versions/latest:access`, { headers: { authorization: `Bearer ${token}` } }), "Secret Manager");
|
|
119
|
+
const data = b.payload?.data;
|
|
120
|
+
if (typeof data !== "string")
|
|
121
|
+
throw new Error("Secret Manager returned no payload");
|
|
122
|
+
return Buffer.from(data, "base64").toString("utf8");
|
|
123
|
+
}
|
|
124
|
+
async function hashicorp(id, env, f) {
|
|
125
|
+
const addr = env.VAULT_ADDR;
|
|
126
|
+
if (!addr || !/^https:\/\/[^/]+/.test(addr))
|
|
127
|
+
throw new Error("VAULT_ADDR must be set on this computer, over https");
|
|
128
|
+
const base = addr.replace(/\/+$/, "");
|
|
129
|
+
const ns = env.VAULT_NAMESPACE ? { "X-Vault-Namespace": env.VAULT_NAMESPACE } : {};
|
|
130
|
+
let token = env.VAULT_TOKEN;
|
|
131
|
+
if (!token && env.VAULT_ROLE_ID && env.VAULT_SECRET_ID) {
|
|
132
|
+
const b = await json(await f(`${base}/v1/auth/approle/login`, { method: "POST", headers: { "content-type": "application/json", ...ns },
|
|
133
|
+
body: JSON.stringify({ role_id: env.VAULT_ROLE_ID, secret_id: env.VAULT_SECRET_ID }) }), "Vault AppRole login");
|
|
134
|
+
token = b.auth?.client_token;
|
|
135
|
+
}
|
|
136
|
+
if (!token)
|
|
137
|
+
throw new Error("no Vault token (VAULT_TOKEN, or VAULT_ROLE_ID and VAULT_SECRET_ID)");
|
|
138
|
+
const [mount, ...rest] = id.split("/");
|
|
139
|
+
const b = await json(await f(`${base}/v1/${encodeURIComponent(mount)}/data/${rest.map(encodeURIComponent).join("/")}`, { headers: { "X-Vault-Token": token, ...ns } }), "Vault read");
|
|
140
|
+
const data = b.data?.data;
|
|
141
|
+
if (typeof data !== "object" || data === null)
|
|
142
|
+
throw new Error("Vault returned no key/value data");
|
|
143
|
+
return JSON.stringify(data);
|
|
144
|
+
}
|
|
145
|
+
/** The object the reference names, as name to value. */
|
|
146
|
+
export async function readRef(r, env, f = fetch) {
|
|
147
|
+
const p = refProblem(r);
|
|
148
|
+
if (p)
|
|
149
|
+
throw new Error(`the reference is not usable: ${p}`);
|
|
150
|
+
const raw = await ({ aws, azure, gcp, hashicorp }[r.provider])(r.id, env, f);
|
|
151
|
+
let o;
|
|
152
|
+
try {
|
|
153
|
+
o = JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw new Error("the secret is not a JSON object of names and values");
|
|
157
|
+
}
|
|
158
|
+
if (typeof o !== "object" || o === null || Array.isArray(o))
|
|
159
|
+
throw new Error("the secret is not a JSON object of names and values");
|
|
160
|
+
const out = {};
|
|
161
|
+
for (const [k, v] of Object.entries(o))
|
|
162
|
+
if (typeof v === "string")
|
|
163
|
+
out[k] = v;
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/** The names a connector must find in its object: its settings, and each secret or its alternative. */
|
|
167
|
+
export function required(c) {
|
|
168
|
+
const instead = c.instead ?? {};
|
|
169
|
+
const all = [...(c.secrets ?? []), ...(c.files ?? []), ...(c.settings ?? []), ...Object.keys(c.optional ?? {}), ...Object.values(instead)];
|
|
170
|
+
return {
|
|
171
|
+
all: [...new Set(all)],
|
|
172
|
+
missing: (have) => [
|
|
173
|
+
...(c.settings ?? []).filter((k) => !have[k]),
|
|
174
|
+
...(c.secrets ?? []).filter((k) => !have[k] && !(instead[k] && have[instead[k]])),
|
|
175
|
+
...(c.files ?? []).filter((k) => !have[k] && !Object.values(instead).includes(k)),
|
|
176
|
+
],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A run's environment after its references: for each connector read from the client's vault, every name it reads comes
|
|
181
|
+
* from that object and nothing else (a file name's value written to a private temporary file); a connector whose
|
|
182
|
+
* object cannot be read, or lacks a name it needs, gets none of its names, so its source fails on its own and says why.
|
|
183
|
+
*/
|
|
184
|
+
export async function applyRefs(dir, refs, creds, base, f = fetch) {
|
|
185
|
+
const env = { ...base }, secrets = [], warnings = [], used = [];
|
|
186
|
+
let tmp;
|
|
187
|
+
for (const [connector, ref] of Object.entries(refs)) {
|
|
188
|
+
const c = creds[connector];
|
|
189
|
+
if (!c)
|
|
190
|
+
continue;
|
|
191
|
+
const need = required(c);
|
|
192
|
+
for (const k of need.all)
|
|
193
|
+
delete env[k];
|
|
194
|
+
let got;
|
|
195
|
+
try {
|
|
196
|
+
got = await readRef(ref, env, f);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
warnings.push(`${connector}: its secret in your vault could not be read: ${String(e?.message ?? e).slice(0, 300)}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const missing = need.missing(got);
|
|
203
|
+
if (missing.length) {
|
|
204
|
+
warnings.push(`${connector}: its secret in your vault lacks ${missing.join(", ")}; none of its names are used`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const files = new Set([...(c.files ?? []), ...Object.values(c.instead ?? {}).filter((n) => n.endsWith("_FILE"))]);
|
|
208
|
+
const settings = new Set([...(c.settings ?? []), ...Object.keys(c.optional ?? {})]);
|
|
209
|
+
for (const k of need.all) {
|
|
210
|
+
const v = got[k];
|
|
211
|
+
if (v === undefined || v === "")
|
|
212
|
+
continue;
|
|
213
|
+
if (files.has(k)) {
|
|
214
|
+
tmp ??= mkdtempSync(join(dir, ".sagentix-run-"));
|
|
215
|
+
const p = join(tmp, k.toLowerCase());
|
|
216
|
+
writePrivate(p, v);
|
|
217
|
+
env[k] = p;
|
|
218
|
+
}
|
|
219
|
+
else
|
|
220
|
+
env[k] = v;
|
|
221
|
+
if (!settings.has(k))
|
|
222
|
+
secrets.push(v, ...v.split(/\r?\n/).filter((l) => l.length >= 16));
|
|
223
|
+
used.push(k);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { env, secrets, warnings, used, cleanup: () => { if (tmp)
|
|
227
|
+
rmSync(tmp, { recursive: true, force: true }); } };
|
|
228
|
+
}
|
|
@@ -13,12 +13,16 @@ import { prowler } from "./prowler.js";
|
|
|
13
13
|
import { ENGINE_SOURCES } from "./engines.js";
|
|
14
14
|
import { DEVICE_SOURCES } from "./device_sources.js";
|
|
15
15
|
import { web } from "./web.js";
|
|
16
|
+
import { vercel } from "./vercel.js";
|
|
17
|
+
import { stripe } from "./stripe.js";
|
|
18
|
+
import { resend } from "./resend.js";
|
|
16
19
|
export const SOURCES = [
|
|
17
20
|
cloudflare,
|
|
18
21
|
entra, okta, jumpcloud, googleWorkspace, github,
|
|
19
|
-
aws, azure, gcp, jira, lms,
|
|
22
|
+
aws, azure, gcp, vercel, jira, lms,
|
|
20
23
|
prowler,
|
|
21
24
|
...ENGINE_SOURCES,
|
|
22
25
|
...DEVICE_SOURCES,
|
|
23
26
|
web,
|
|
27
|
+
stripe, resend,
|
|
24
28
|
];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Resend (transactional email), read-only use of its REST API: the sending domains' authentication and the API
|
|
2
|
+
// keys' age and use. GET only, with RESEND_API_KEY.
|
|
3
|
+
//
|
|
4
|
+
// Resend has no read-only key. Its API key permissions are full_access ("Can create, delete, get, and update any
|
|
5
|
+
// resource") and sending_access ("Can only send emails"), and a sending_access key cannot list domains or keys
|
|
6
|
+
// (resend.com/docs/api-reference/api-keys/create-api-key, read 2026-11-02). So this source needs a full_access key.
|
|
7
|
+
// The collector itself only ever sends GET (fetchRest refuses anything else and any other origin), but the key could
|
|
8
|
+
// do more in other hands: give it a name of its own (for example "Sagentix collector, read only"), keep it in the
|
|
9
|
+
// Keychain or a secrets vault, and expire it if the machine is lost. The checks below then flag it like any other key.
|
|
10
|
+
//
|
|
11
|
+
// Pages and schema, all read 2026-11-02:
|
|
12
|
+
// resend.com/docs/api-reference/introduction: base https://api.resend.com, "Authorization: Bearer re_xxxxxxxxx", a
|
|
13
|
+
// User-Agent header on every request (a request without one is refused with 403), 10 requests per second per team
|
|
14
|
+
// by default, 429 above it.
|
|
15
|
+
// resend.com/docs/api-reference/domains/list-domains: GET /domains?limit=1..100&after=<id> -> { object: "list",
|
|
16
|
+
// has_more, data: [ { id, name, status, created_at, region, open_tracking, click_tracking, capabilities: { sending,
|
|
17
|
+
// receiving } } ] }.
|
|
18
|
+
// resend.com/docs/api-reference/domains/get-domain: GET /domains/{id} -> the domain with records: [ { record (SPF,
|
|
19
|
+
// DKIM, Tracking, ...), name, type, ttl, status, value, priority } ].
|
|
20
|
+
// resend.com/docs/api-reference/api-keys/list-api-keys: GET /api-keys?limit&after -> { has_more, data: [ { id, name,
|
|
21
|
+
// created_at, last_used_at } ] }; the permission is not returned.
|
|
22
|
+
// Resend's OpenAPI (github.com/resend/resend-openapi, resend.yaml, info.version 1.5.1, commit 0b2a456): Domain.status
|
|
23
|
+
// enum pending, verified, failed, not_started, partially_verified, partially_failed; DomainRecord.record enum SPF,
|
|
24
|
+
// DKIM, Receiving, Tracking, TrackingCAA; DomainRecord.status enum pending, verified, failed, temporary_failure,
|
|
25
|
+
// not_started. A domain's TLS mode (opportunistic or enforced) can be set (PATCH /domains/{id}) but is not in the
|
|
26
|
+
// Domain schema GET returns, so no check reads it; DMARC is a DNS record Resend does not manage (the web source's
|
|
27
|
+
// --web-mail-domain reads it).
|
|
28
|
+
//
|
|
29
|
+
// Results carry domain names and key ids; never a key's name, value or a DNS record's value.
|
|
30
|
+
import { COLLECTOR_VERSION } from "../payload.js";
|
|
31
|
+
import { DAY_MS, Emitter, arr, enc, fetchRest, firstIds, getAll, isObj, listRead, ok, realSleep, redact, str, withRetry, } from "./readonly_http.js";
|
|
32
|
+
import { posNum } from "./report_files.js";
|
|
33
|
+
export const RESEND_CHECKS = ["sending_domains_authenticated", "api_keys_rotated", "api_keys_in_use"];
|
|
34
|
+
export const RESEND_BASE = "https://api.resend.com";
|
|
35
|
+
export const RESEND_ENV = { token: "RESEND_API_KEY" };
|
|
36
|
+
export const RESEND_DEFAULTS = { keyMaxAgeDays: 365, inactiveDays: 90 };
|
|
37
|
+
const UA = `sagentix-evidence-collector/${COLLECTOR_VERSION} (+https://github.com/Sagentix-Advisors-Inc/sagentix-collector)`;
|
|
38
|
+
const PAGE = 100;
|
|
39
|
+
export function resendFromEnv(env, fetchFn = fetch) {
|
|
40
|
+
const token = env[RESEND_ENV.token];
|
|
41
|
+
if (!token)
|
|
42
|
+
return `${RESEND_ENV.token} is not set`;
|
|
43
|
+
if (!token.startsWith("re_"))
|
|
44
|
+
return `${RESEND_ENV.token} does not look like a Resend API key (re_...)`;
|
|
45
|
+
return fetchRest({ base: RESEND_BASE, fetchFn, headers: async () => ({ authorization: `Bearer ${token}`, "user-agent": UA }) });
|
|
46
|
+
}
|
|
47
|
+
/** Resend's timestamps look like "2026-04-08 00:11:13.110779+00"; Date.parse wants an ISO 8601 offset. */
|
|
48
|
+
export function resendTime(v) {
|
|
49
|
+
const s = str(v);
|
|
50
|
+
if (!s)
|
|
51
|
+
return undefined;
|
|
52
|
+
const iso = s.trim().replace(" ", "T").replace(/([+-]\d{2})$/, "$1:00").replace(/(\.\d{3})\d+/, "$1");
|
|
53
|
+
const t = Date.parse(iso);
|
|
54
|
+
return Number.isFinite(t) ? t : undefined;
|
|
55
|
+
}
|
|
56
|
+
const list = (api, path) => getAll(api, `${path}?limit=${PAGE}`, (b) => (isObj(b) && Array.isArray(b.data) ? b.data : undefined), (r) => {
|
|
57
|
+
const b = r.body;
|
|
58
|
+
if (!isObj(b) || b.has_more !== true)
|
|
59
|
+
return undefined;
|
|
60
|
+
const last = arr(b.data).filter(isObj).at(-1);
|
|
61
|
+
return last && str(last.id) ? `${path}?limit=${PAGE}&after=${enc(String(last.id))}` : undefined;
|
|
62
|
+
}, 50);
|
|
63
|
+
export const resend = {
|
|
64
|
+
name: "resend",
|
|
65
|
+
options: {
|
|
66
|
+
"resend-key-max-age-days": { type: "string", help: `largest age of a Resend API key, in days (default ${RESEND_DEFAULTS.keyMaxAgeDays})` },
|
|
67
|
+
"resend-inactive-days": { type: "string", help: `days without use after which a Resend API key counts as unused (default ${RESEND_DEFAULTS.inactiveDays})` },
|
|
68
|
+
},
|
|
69
|
+
emits: RESEND_CHECKS.map((c) => `resend.${c}`),
|
|
70
|
+
describe: () => "resend team (the API key's)",
|
|
71
|
+
async run(ctx) {
|
|
72
|
+
const out = new Emitter("resend", "resend team");
|
|
73
|
+
const given = ctx.clients.resend;
|
|
74
|
+
const built = given !== undefined ? given : resendFromEnv(ctx.env);
|
|
75
|
+
if (!built || typeof built === "string") {
|
|
76
|
+
out.skipAll(RESEND_CHECKS, typeof built === "string" ? built : `${RESEND_ENV.token} is not set`);
|
|
77
|
+
return out.results;
|
|
78
|
+
}
|
|
79
|
+
const api = withRetry(built, ctx.clients.sleep ?? realSleep);
|
|
80
|
+
const now = ctx.now.getTime();
|
|
81
|
+
// Sending domains: verified, with every SPF and DKIM record verified.
|
|
82
|
+
const doms = await list(api, "/domains");
|
|
83
|
+
if ("error" in doms)
|
|
84
|
+
out.skip("sending_domains_authenticated", doms.error, "domains");
|
|
85
|
+
else {
|
|
86
|
+
const sending = doms.items.filter(isObj).filter((d) => !isObj(d.capabilities) || d.capabilities.sending !== "disabled");
|
|
87
|
+
if (!sending.length)
|
|
88
|
+
out.add("sending_domains_authenticated", "skipped", `${doms.items.length} domain(s), none sending; nothing to judge`, "resend team", listRead(doms));
|
|
89
|
+
for (const d of sending) {
|
|
90
|
+
const name = str(d.name) ?? String(d.id);
|
|
91
|
+
const r = await api.get(`/domains/${enc(String(d.id))}`);
|
|
92
|
+
if (!ok(r) || !isObj(r.body)) {
|
|
93
|
+
out.skip("sending_domains_authenticated", r, `domain ${name}`, `resend domain ${name}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const recs = arr(r.body.records).filter(isObj).filter((x) => x.record === "SPF" || x.record === "DKIM");
|
|
97
|
+
const bad = recs.filter((x) => x.status !== "verified").map((x) => `${String(x.record)} ${String(x.type ?? "")} ${String(x.status)}`.replace(/\s+/g, " "));
|
|
98
|
+
const kinds = new Set(recs.map((x) => x.record));
|
|
99
|
+
const missing = ["SPF", "DKIM"].filter((k) => !kinds.has(k));
|
|
100
|
+
const status = String(r.body.status ?? d.status);
|
|
101
|
+
const pass = status === "verified" && !bad.length && !missing.length;
|
|
102
|
+
out.add("sending_domains_authenticated", pass ? "pass" : "fail", `domain status ${status}; ${recs.length} SPF/DKIM record(s), ${bad.length} not verified${firstIds(bad)}${missing.length ? `; no ${missing.join(" or ")} record listed` : ""}; status verified and every SPF and DKIM record verified required`, `resend domain ${name}`, { method: "config-read", itemsRead: recs.length, complete: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// API keys: age and last use.
|
|
106
|
+
const keys = await list(api, "/api-keys");
|
|
107
|
+
if ("error" in keys) {
|
|
108
|
+
out.skip("api_keys_rotated", keys.error, "API keys");
|
|
109
|
+
out.skip("api_keys_in_use", keys.error, "API keys");
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const ks = keys.items.filter(isObj);
|
|
113
|
+
const read = listRead(keys);
|
|
114
|
+
if (!ks.length) {
|
|
115
|
+
out.add("api_keys_rotated", "skipped", "the team has no API key; nothing to judge", "resend team", read);
|
|
116
|
+
out.add("api_keys_in_use", "skipped", "the team has no API key; nothing to judge", "resend team", read);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const maxAge = posNum(ctx.opts["resend-key-max-age-days"], RESEND_DEFAULTS.keyMaxAgeDays);
|
|
120
|
+
const inactive = posNum(ctx.opts["resend-inactive-days"], RESEND_DEFAULTS.inactiveDays);
|
|
121
|
+
const old = [], idle = [];
|
|
122
|
+
for (const k of ks) {
|
|
123
|
+
const id = String(k.id);
|
|
124
|
+
const created = resendTime(k.created_at);
|
|
125
|
+
if (created === undefined || (now - created) / DAY_MS > maxAge)
|
|
126
|
+
old.push(created === undefined ? `${id}:unknown age` : `${id}:${Math.floor((now - created) / DAY_MS)}d`);
|
|
127
|
+
const used = resendTime(k.last_used_at);
|
|
128
|
+
if (used === undefined || (now - used) / DAY_MS > inactive)
|
|
129
|
+
idle.push(used === undefined ? `${id}:never used` : `${id}:${Math.floor((now - used) / DAY_MS)}d`);
|
|
130
|
+
}
|
|
131
|
+
out.add("api_keys_rotated", old.length ? "fail" : "pass", `${ks.length} API key(s), ${old.length} older than ${maxAge} days${firstIds(old)}; every key created within ${maxAge} days required (--resend-key-max-age-days)`, "resend team", read);
|
|
132
|
+
out.add("api_keys_in_use", idle.length ? "fail" : "pass", `${ks.length} API key(s), ${idle.length} not used in the last ${inactive} days${firstIds(idle)}; every key used within ${inactive} days required, or expire it (--resend-inactive-days)`, "resend team", read);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return redact(out.results, [ctx.env[RESEND_ENV.token]]);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Stripe, read-only: the webhook endpoints that receive the account's events. GET only, with a RESTRICTED key
|
|
2
|
+
// (rk_live_... or rk_test_...) in STRIPE_API_KEY whose only permission is Webhook Endpoints: Read. A secret key
|
|
3
|
+
// (sk_...) is refused before any request: it "has unrestricted permissions on all Stripe APIs" and cannot be narrowed.
|
|
4
|
+
//
|
|
5
|
+
// Pages, all read 2026-11-02:
|
|
6
|
+
// docs.stripe.com/keys: key types; restricted keys rk_live_/rk_test_, secret keys sk_live_/sk_test_; Stripe
|
|
7
|
+
// recommends migrating secret key usage to restricted keys.
|
|
8
|
+
// docs.stripe.com/keys/restricted-api-keys: per-resource permission None, Read or Write; "GET" maps to read; write
|
|
9
|
+
// implies read.
|
|
10
|
+
// docs.stripe.com/api/webhook_endpoints/list (Stripe-Version 2026-10-28.endive on the page): GET /v1/webhook_endpoints
|
|
11
|
+
// ?limit=1..100&starting_after=<id> -> { object: "list", has_more, data: [ { id, url, status, enabled_events,
|
|
12
|
+
// livemode, api_version, application } ] }.
|
|
13
|
+
// Stripe's OpenAPI (github.com/stripe/openapi, openapi/spec3.json, info.version 2026-09-30.endive, commit 51610f5):
|
|
14
|
+
// webhook_endpoint.status "The status of the webhook. It can be `enabled` or `disabled`."; enabled_events
|
|
15
|
+
// "`['*']` indicates that all events are enabled, except those that require explicit selection."
|
|
16
|
+
//
|
|
17
|
+
// What the API does not show, and so stays manual evidence: team members and their two-step authentication, the API
|
|
18
|
+
// key inventory, key access policies (docs.stripe.com/keys#access-policies, Dashboard only) and Radar rules.
|
|
19
|
+
// Results carry webhook endpoint ids and URL hosts; never a path, query or signing secret.
|
|
20
|
+
import { Emitter, arr, enc, fetchRest, firstIds, getAll, isObj, listRead, realSleep, redact, str, withRetry, } from "./readonly_http.js";
|
|
21
|
+
export const STRIPE_CHECKS = ["webhooks_https", "webhook_events_scoped"];
|
|
22
|
+
export const STRIPE_BASE = "https://api.stripe.com";
|
|
23
|
+
export const STRIPE_ENV = { token: "STRIPE_API_KEY" };
|
|
24
|
+
const PAGE = 100;
|
|
25
|
+
/** Why a key is refused, or undefined for a restricted key. */
|
|
26
|
+
export function stripeKeyProblem(key) {
|
|
27
|
+
if (!key)
|
|
28
|
+
return `${STRIPE_ENV.token} is not set`;
|
|
29
|
+
if (/^sk_(live|test)_/.test(key))
|
|
30
|
+
return `${STRIPE_ENV.token} is a secret key (sk_), which can do anything in the account; create a restricted key (rk_) with only Webhook Endpoints: Read`;
|
|
31
|
+
if (!/^rk_(live|test)_/.test(key))
|
|
32
|
+
return `${STRIPE_ENV.token} is not a restricted Stripe key (rk_live_ or rk_test_)`;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
export function stripeFromEnv(env, fetchFn = fetch) {
|
|
36
|
+
const key = env[STRIPE_ENV.token];
|
|
37
|
+
const bad = stripeKeyProblem(key);
|
|
38
|
+
if (bad)
|
|
39
|
+
return bad;
|
|
40
|
+
return fetchRest({ base: STRIPE_BASE, fetchFn, headers: async () => ({ authorization: `Bearer ${key}` }) });
|
|
41
|
+
}
|
|
42
|
+
const host = (u) => { try {
|
|
43
|
+
return new URL(String(u)).host || "(no host)";
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return "(not a URL)";
|
|
47
|
+
} };
|
|
48
|
+
export const stripe = {
|
|
49
|
+
name: "stripe",
|
|
50
|
+
options: {},
|
|
51
|
+
emits: STRIPE_CHECKS.map((c) => `stripe.${c}`),
|
|
52
|
+
describe: () => "stripe account (the restricted key's)",
|
|
53
|
+
async run(ctx) {
|
|
54
|
+
const key = ctx.env[STRIPE_ENV.token];
|
|
55
|
+
const mode = key?.startsWith("rk_test_") ? " (sandbox)" : "";
|
|
56
|
+
const out = new Emitter("stripe", `stripe account${mode}`);
|
|
57
|
+
// The refusal of a secret key holds even when a client is handed in.
|
|
58
|
+
const keyBad = key !== undefined ? stripeKeyProblem(key) : undefined;
|
|
59
|
+
if (keyBad) {
|
|
60
|
+
out.skipAll(STRIPE_CHECKS, keyBad);
|
|
61
|
+
return redact(out.results, [key]);
|
|
62
|
+
}
|
|
63
|
+
const given = ctx.clients.stripe;
|
|
64
|
+
const built = given !== undefined ? given : stripeFromEnv(ctx.env);
|
|
65
|
+
if (!built || typeof built === "string") {
|
|
66
|
+
out.skipAll(STRIPE_CHECKS, typeof built === "string" ? built : `${STRIPE_ENV.token} is not set`);
|
|
67
|
+
return out.results;
|
|
68
|
+
}
|
|
69
|
+
const api = withRetry(built, ctx.clients.sleep ?? realSleep);
|
|
70
|
+
const got = await getAll(api, `/v1/webhook_endpoints?limit=${PAGE}`, (b) => (isObj(b) && Array.isArray(b.data) ? b.data : undefined), (r) => {
|
|
71
|
+
const b = r.body;
|
|
72
|
+
if (!isObj(b) || b.has_more !== true)
|
|
73
|
+
return undefined;
|
|
74
|
+
const last = arr(b.data).filter(isObj).at(-1);
|
|
75
|
+
return last && str(last.id) ? `/v1/webhook_endpoints?limit=${PAGE}&starting_after=${enc(String(last.id))}` : undefined;
|
|
76
|
+
}, 20);
|
|
77
|
+
if ("error" in got) {
|
|
78
|
+
for (const c of STRIPE_CHECKS)
|
|
79
|
+
out.skip(c, got.error, "webhook endpoints");
|
|
80
|
+
return redact(out.results, [key]);
|
|
81
|
+
}
|
|
82
|
+
const read = listRead(got);
|
|
83
|
+
const on = got.items.filter(isObj).filter((w) => w.status !== "disabled");
|
|
84
|
+
if (!on.length) {
|
|
85
|
+
for (const c of STRIPE_CHECKS)
|
|
86
|
+
out.add(c, "skipped", `${got.items.length} webhook endpoint(s), none enabled; nothing to judge`, undefined, read);
|
|
87
|
+
return redact(out.results, [key]);
|
|
88
|
+
}
|
|
89
|
+
const plain = on.filter((w) => !String(w.url ?? "").startsWith("https://")).map((w) => `${String(w.id)}:${host(w.url)}`);
|
|
90
|
+
out.add("webhooks_https", plain.length ? "fail" : "pass", `${on.length} enabled webhook endpoint(s), ${plain.length} not on https${firstIds(plain)}; every endpoint URL on https required`, undefined, read);
|
|
91
|
+
const all = on.filter((w) => arr(w.enabled_events).includes("*")).map((w) => `${String(w.id)}:${host(w.url)}`);
|
|
92
|
+
out.add("webhook_events_scoped", all.length ? "fail" : "pass", `${on.length} enabled webhook endpoint(s), ${all.length} subscribed to every event ('*')${firstIds(all)}; each endpoint limited to the events it handles required`, undefined, read);
|
|
93
|
+
return redact(out.results, [key]);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Vercel: a team (or a personal account) and its projects over the REST API (GET only), with an access token
|
|
2
|
+
// in VERCEL_TOKEN. Vercel has no read-only token: a token carries a scope (Full Account, a team with All
|
|
3
|
+
// Projects, or one project) and acts with its creator's role ("How do I use a Vercel API Access Token?",
|
|
4
|
+
// vercel.com/kb/guide/how-do-i-use-a-vercel-api-access-token, last updated 2026-10-01, read 2026-11-02). Use a
|
|
5
|
+
// team-scoped token with a short expiration, created by a member with the least role that can read the team's
|
|
6
|
+
// settings; the collector sends GET requests only and never asks Vercel to decrypt a value.
|
|
7
|
+
//
|
|
8
|
+
// Endpoints, each checked on 2026-11-02 in Vercel's OpenAPI (openapi.vercel.sh, info.version 0.0.1, 12,070,294
|
|
9
|
+
// bytes, sha256 8e3342453cdf0a81…) and on its reference page under https://vercel.com/docs/rest-api (the page named
|
|
10
|
+
// below, each last updated 2026-11-02). A team is named with ?teamId=team_… or ?slug=… on every call.
|
|
11
|
+
// GET /v2/teams/{teamId} teams/get-a-team: saml.enforced ("When `true`, interactions with the Team
|
|
12
|
+
// **must** be done with an authentication token that has been authenticated
|
|
13
|
+
// with the Team's SAML Single Sign-On provider"), saml.connection.
|
|
14
|
+
// GET /v3/teams/{teamId}/members teams/list-team-members: members[].role (OWNER, MEMBER, DEVELOPER, SECURITY,
|
|
15
|
+
// BILLING, VIEWER, VIEWER_FOR_PLUS, CONTRIBUTOR), members[].uid; pages by
|
|
16
|
+
// ?until=<pagination.next> while pagination.hasNext.
|
|
17
|
+
// GET /v3/events?limit=1 user/list-user-events: events[].createdAt, the team's activity log.
|
|
18
|
+
// GET /v10/projects?limit=100 projects/retrieve-a-list-of-projects: projects[].id/name; ?from=<pagination.next>
|
|
19
|
+
// ("Query only projects updated after the given timestamp or continuation token").
|
|
20
|
+
// GET /v9/projects/{idOrName} projects/find-a-project-by-id-or-name: ssoProtection.deploymentType and
|
|
21
|
+
// passwordProtection.deploymentType (all, all_except_custom_domains, preview,
|
|
22
|
+
// prod_deployment_urls_and_all_previews; null when off), gitForkProtection,
|
|
23
|
+
// security.firewallEnabled.
|
|
24
|
+
// GET /v10/projects/{idOrName}/env projects/retrieve-the-environment-variables-of-a-project-by-id-or-name:
|
|
25
|
+
// envs[].key and envs[].type (encrypted, plain, secret, sensitive, system).
|
|
26
|
+
// Only key and type are read; `decrypt` is never sent, and no value leaves memory.
|
|
27
|
+
//
|
|
28
|
+
// What the settings mean, read 2026-11-02:
|
|
29
|
+
// Deployment Protection (vercel.com/docs/deployment-protection, last updated 2026-09-15): every protection scope,
|
|
30
|
+
// Standard Protection included, covers preview URLs; none set means anyone with a preview URL can open it.
|
|
31
|
+
// Git Fork Protection (vercel.com/docs/git/vercel-for-github, last updated 2026-09-17): "If you receive a pull request
|
|
32
|
+
// from a fork of your repository, Vercel will require authorization from you or a team member to deploy".
|
|
33
|
+
// Vercel WAF (vercel.com/docs/vercel-firewall/vercel-waf, last updated 2026-07-29): "available on all plans".
|
|
34
|
+
// SAML SSO (vercel.com/docs/saml, last updated 2026-08-28): "available on Enterprise and Pro plans". Two-factor
|
|
35
|
+
// enforcement for a team exists (vercel.com/docs/two-factor-enforcement, last updated 2026-08-28) but the team
|
|
36
|
+
// object in the OpenAPI has no field for it, so a team without SAML gets team_saml_enforced skipped, never failed.
|
|
37
|
+
// The team "Enforce Sensitive Environment Variables" policy is deprecated (last updated 2026-08-28,
|
|
38
|
+
// https://vercel.com/docs/environment-variables/sensitive-environment-variables), so no check reads it.
|
|
39
|
+
//
|
|
40
|
+
// Personal data: counts and Vercel user ids only, never an email.
|
|
41
|
+
import { Emitter, enc, fetchRest, firstIds, getAll, intOpt, isObj, ok, realSleep, redact, str, withRetry, } from "./readonly_http.js";
|
|
42
|
+
export const VERCEL_BASE = "https://api.vercel.com";
|
|
43
|
+
export const VERCEL_TOKEN_ENV = "VERCEL_TOKEN";
|
|
44
|
+
export const DEFAULTS = { maxOwners: 3, maxProjects: 50 };
|
|
45
|
+
export const TEAM_CHECKS = ["team_saml_enforced", "team_owners_limited", "activity_log_available"];
|
|
46
|
+
export const PROJECT_CHECKS = ["deployment_protection", "git_fork_protection", "no_plain_secret_envs", "firewall_enabled"];
|
|
47
|
+
/** Names that say the value is a credential. A plain variable is shown to anyone who can open the project's settings. */
|
|
48
|
+
export const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY|SIGNING_?KEY|DATABASE_URL|_DSN$|WEBHOOK)/i;
|
|
49
|
+
export function vercelFromEnv(env, fetchFn = fetch) {
|
|
50
|
+
const token = env[VERCEL_TOKEN_ENV];
|
|
51
|
+
if (!token)
|
|
52
|
+
return `${VERCEL_TOKEN_ENV} is not set`;
|
|
53
|
+
return fetchRest({ base: VERCEL_BASE, fetchFn, headers: async () => ({ authorization: `Bearer ${token}` }) });
|
|
54
|
+
}
|
|
55
|
+
/** The query naming the team: teamId for an id (team_…), slug for anything else; nothing for a personal account. */
|
|
56
|
+
export const teamQuery = (team) => (!team ? "" : /^team_/.test(team) ? `teamId=${enc(team)}` : `slug=${enc(team)}`);
|
|
57
|
+
const withTeam = (path, tq) => (tq ? `${path}${path.includes("?") ? "&" : "?"}${tq}` : path);
|
|
58
|
+
async function teamChecks(api, team, tq, o, out) {
|
|
59
|
+
const t = await api.get(withTeam(`/v2/teams/${enc(team)}`, tq));
|
|
60
|
+
if (!ok(t) || !isObj(t.body))
|
|
61
|
+
out.skip("team_saml_enforced", t, "team");
|
|
62
|
+
else {
|
|
63
|
+
const saml = isObj(t.body.saml) ? t.body.saml : undefined;
|
|
64
|
+
const enforced = saml?.enforced;
|
|
65
|
+
const connected = !!saml && isObj(saml.connection);
|
|
66
|
+
if (enforced === true)
|
|
67
|
+
out.add("team_saml_enforced", "pass", "saml.enforced is true; every sign-in to the team goes through its SAML identity provider");
|
|
68
|
+
else if (connected)
|
|
69
|
+
out.add("team_saml_enforced", "fail", "a SAML connection is configured but saml.enforced is not true; members may still sign in without it");
|
|
70
|
+
else
|
|
71
|
+
out.add("team_saml_enforced", "skipped", "SAML SSO is not configured (available on Pro and Enterprise); Vercel's REST API does not show the team's two-factor enforcement, so sign-in strength is not judged here");
|
|
72
|
+
}
|
|
73
|
+
const members = await getAll(api, withTeam(`/v3/teams/${enc(team)}/members?limit=100`, tq), (b) => (isObj(b) && Array.isArray(b.members) ? b.members : undefined), (r) => {
|
|
74
|
+
const p = isObj(r.body) && isObj(r.body.pagination) ? r.body.pagination : undefined;
|
|
75
|
+
const next = p && p.hasNext === true && p.next !== undefined && p.next !== null ? String(p.next) : undefined;
|
|
76
|
+
return next ? withTeam(`/v3/teams/${enc(team)}/members?limit=100&until=${enc(next)}`, tq) : undefined;
|
|
77
|
+
}, 20);
|
|
78
|
+
if ("error" in members)
|
|
79
|
+
out.skip("team_owners_limited", members.error, "team members");
|
|
80
|
+
else {
|
|
81
|
+
const owners = members.items.filter(isObj).filter((m) => m.role === "OWNER").map((m) => String(m.uid ?? "?"));
|
|
82
|
+
const over = owners.length > o.maxOwners;
|
|
83
|
+
out.add("team_owners_limited", over ? "fail" : "pass", `${owners.length} owner(s) among ${members.items.length} member(s)${over ? `; user ids${firstIds(owners)}` : ""}; at most ${o.maxOwners} allowed (--vercel-max-owners)${members.truncated ? "; the member list was cut at 20 pages" : ""}`);
|
|
84
|
+
}
|
|
85
|
+
const ev = await api.get(withTeam("/v3/events?limit=1", tq));
|
|
86
|
+
if (!ok(ev) || !isObj(ev.body) || !Array.isArray(ev.body.events))
|
|
87
|
+
out.skip("activity_log_available", ev, "team activity log");
|
|
88
|
+
else {
|
|
89
|
+
const e = ev.body.events[0];
|
|
90
|
+
const at = isObj(e) && Number.isFinite(Number(e.createdAt)) ? new Date(Number(e.createdAt)).toISOString() : undefined;
|
|
91
|
+
out.add("activity_log_available", e ? "pass" : "fail", e ? `team activity log readable; latest event at ${at ?? "?"}` : "team activity log readable but empty");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const PROTECTED = new Set(["all", "all_except_custom_domains", "preview", "prod_deployment_urls_and_all_previews"]);
|
|
95
|
+
const scopeOf = (v) => (isObj(v) ? str(v.deploymentType) : undefined);
|
|
96
|
+
async function projectChecks(api, id, tq, out) {
|
|
97
|
+
const p = await api.get(withTeam(`/v9/projects/${enc(id)}`, tq));
|
|
98
|
+
const res = `vercel project ${id}`;
|
|
99
|
+
if (!ok(p) || !isObj(p.body)) {
|
|
100
|
+
for (const c of PROJECT_CHECKS)
|
|
101
|
+
out.skip(c, p, "project", res);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const b = p.body;
|
|
105
|
+
const name = str(b.name) ?? id;
|
|
106
|
+
const r = `vercel project ${name}`;
|
|
107
|
+
const sso = scopeOf(b.ssoProtection), pw = scopeOf(b.passwordProtection);
|
|
108
|
+
const on = [sso && `Vercel Authentication (${sso})`, pw && `Password Protection (${pw})`].filter(Boolean);
|
|
109
|
+
const covers = [sso, pw].some((s) => !!s && PROTECTED.has(s));
|
|
110
|
+
out.add("deployment_protection", covers ? "pass" : "fail", `${on.length ? on.join(", ") : "no Deployment Protection"}; a protection scope that covers preview deployments required`, r);
|
|
111
|
+
const fork = b.gitForkProtection;
|
|
112
|
+
if (typeof fork !== "boolean")
|
|
113
|
+
out.add("git_fork_protection", "skipped", "gitForkProtection is not shown for this project (no Git repository connected?)", r);
|
|
114
|
+
else
|
|
115
|
+
out.add("git_fork_protection", fork ? "pass" : "fail", `gitForkProtection is ${fork}; true required, so a pull request from a fork needs a team member's authorization before it deploys`, r);
|
|
116
|
+
const fw = isObj(b.security) ? b.security.firewallEnabled : undefined;
|
|
117
|
+
if (typeof fw !== "boolean")
|
|
118
|
+
out.add("firewall_enabled", "skipped", "security.firewallEnabled is not shown for this project", r);
|
|
119
|
+
else
|
|
120
|
+
out.add("firewall_enabled", fw ? "pass" : "fail", `security.firewallEnabled is ${fw}; true required`, r);
|
|
121
|
+
const env = await api.get(withTeam(`/v10/projects/${enc(id)}/env`, tq));
|
|
122
|
+
if (!ok(env) || !isObj(env.body) || !Array.isArray(env.body.envs)) {
|
|
123
|
+
out.skip("no_plain_secret_envs", env, "environment variables", r);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const vars = env.body.envs.filter(isObj);
|
|
127
|
+
const plain = vars.filter((v) => v.type === "plain" && SECRET_NAME.test(String(v.key ?? ""))).map((v) => String(v.key));
|
|
128
|
+
out.add("no_plain_secret_envs", plain.length ? "fail" : "pass", `${vars.length} environment variable(s), ${vars.filter((v) => v.type === "plain").length} of type plain, ${plain.length} of them named like a credential${firstIds(plain)}; a credential stored as encrypted, sensitive or secret required (names only were read)`, r);
|
|
129
|
+
}
|
|
130
|
+
export async function vercelChecks(api, o, warn = () => { }) {
|
|
131
|
+
const tq = teamQuery(o.team);
|
|
132
|
+
const out = new Emitter("vercel", o.team ? `vercel team ${o.team}` : "vercel personal account");
|
|
133
|
+
if (o.team)
|
|
134
|
+
await teamChecks(api, o.team, tq, o, out);
|
|
135
|
+
else
|
|
136
|
+
out.skipAll(TEAM_CHECKS, "no --vercel-team given; a personal account has no team settings");
|
|
137
|
+
let ids = o.projects;
|
|
138
|
+
if (!ids.length) {
|
|
139
|
+
const l = await getAll(api, withTeam("/v10/projects?limit=100", tq), (b) => (isObj(b) && Array.isArray(b.projects) ? b.projects : Array.isArray(b) ? b : undefined), (r) => {
|
|
140
|
+
const p = isObj(r.body) && isObj(r.body.pagination) ? r.body.pagination : undefined;
|
|
141
|
+
const next = p && p.next !== undefined && p.next !== null ? String(p.next) : undefined;
|
|
142
|
+
return next ? withTeam(`/v10/projects?limit=100&from=${enc(next)}`, tq) : undefined;
|
|
143
|
+
}, Math.ceil(o.maxProjects / 100) + 1);
|
|
144
|
+
if ("error" in l) {
|
|
145
|
+
for (const c of PROJECT_CHECKS)
|
|
146
|
+
out.skip(c, l.error, "the project list", "vercel project (list)");
|
|
147
|
+
return out.results;
|
|
148
|
+
}
|
|
149
|
+
const all = l.items.filter(isObj).map((p) => String(p.id ?? p.name ?? "?"));
|
|
150
|
+
if (all.length > o.maxProjects)
|
|
151
|
+
warn(`vercel: ${all.length} projects; checking the first ${o.maxProjects} (--vercel-max-projects, or name them with --vercel-project)`);
|
|
152
|
+
ids = all.slice(0, o.maxProjects);
|
|
153
|
+
if (!ids.length)
|
|
154
|
+
for (const c of PROJECT_CHECKS)
|
|
155
|
+
out.add(c, "skipped", "no project the token can see", "vercel project (none)");
|
|
156
|
+
}
|
|
157
|
+
for (const id of ids)
|
|
158
|
+
await projectChecks(api, id, tq, out);
|
|
159
|
+
return out.results;
|
|
160
|
+
}
|
|
161
|
+
const listOpt = (v) => (Array.isArray(v) ? v : typeof v === "string" ? [v] : []).flatMap((s) => String(s).split(",")).map((s) => s.trim()).filter(Boolean);
|
|
162
|
+
export const vercel = {
|
|
163
|
+
name: "vercel",
|
|
164
|
+
options: {
|
|
165
|
+
"vercel-team": { type: "string", help: "Vercel team id (team_…) or slug; omit for a personal account (team checks are then skipped)" },
|
|
166
|
+
"vercel-project": { type: "string", multiple: true, help: "project id or name; default: every project the token can see" },
|
|
167
|
+
"vercel-max-projects": { type: "string", help: `projects checked when none is named (default ${DEFAULTS.maxProjects})` },
|
|
168
|
+
"vercel-max-owners": { type: "string", help: `team owners allowed (default ${DEFAULTS.maxOwners})` },
|
|
169
|
+
},
|
|
170
|
+
emits: [...TEAM_CHECKS, ...PROJECT_CHECKS].map((c) => `vercel.${c}`),
|
|
171
|
+
describe(opts) {
|
|
172
|
+
const t = typeof opts["vercel-team"] === "string" ? `team ${opts["vercel-team"]}` : "personal account";
|
|
173
|
+
const p = listOpt(opts["vercel-project"]);
|
|
174
|
+
return `${t} / ${p.length ? `projects ${p.join(",")}` : "every project"}`;
|
|
175
|
+
},
|
|
176
|
+
async run(ctx) {
|
|
177
|
+
const team = typeof ctx.opts["vercel-team"] === "string" && ctx.opts["vercel-team"].trim() ? ctx.opts["vercel-team"].trim() : undefined;
|
|
178
|
+
const given = ctx.clients.vercel;
|
|
179
|
+
const built = given !== undefined ? given : vercelFromEnv(ctx.env);
|
|
180
|
+
if (!built || typeof built === "string") {
|
|
181
|
+
const out = new Emitter("vercel", team ? `vercel team ${team}` : "vercel personal account");
|
|
182
|
+
const reason = typeof built === "string" ? built : "no Vercel client";
|
|
183
|
+
out.skipAll(TEAM_CHECKS, reason);
|
|
184
|
+
out.skipAll(PROJECT_CHECKS, reason, "vercel project (none)");
|
|
185
|
+
return out.results;
|
|
186
|
+
}
|
|
187
|
+
const api = withRetry(built, ctx.clients.sleep ?? realSleep);
|
|
188
|
+
const o = {
|
|
189
|
+
team, projects: listOpt(ctx.opts["vercel-project"]),
|
|
190
|
+
maxOwners: intOpt(ctx.opts["vercel-max-owners"], DEFAULTS.maxOwners),
|
|
191
|
+
maxProjects: intOpt(ctx.opts["vercel-max-projects"], DEFAULTS.maxProjects),
|
|
192
|
+
};
|
|
193
|
+
return redact(await vercelChecks(api, o, ctx.warn), [ctx.env[VERCEL_TOKEN_ENV]]);
|
|
194
|
+
},
|
|
195
|
+
};
|
package/collector/src/vault.js
CHANGED
|
@@ -249,6 +249,21 @@ export class Vault {
|
|
|
249
249
|
throw new Error(`the sealed value for ${v.name} does not open with this collector's key`);
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
|
+
/** The connectors read from the client's own vault. */
|
|
253
|
+
refs() { return { ...(this.doc().refs ?? {}) }; }
|
|
254
|
+
setRef(connector, ref, now) {
|
|
255
|
+
const d = this.doc();
|
|
256
|
+
d.refs = { ...(d.refs ?? {}), [connector]: { provider: ref.provider, id: ref.id, setAt: now.toISOString() } };
|
|
257
|
+
this.write(d);
|
|
258
|
+
}
|
|
259
|
+
removeRef(connector) {
|
|
260
|
+
const d = this.doc();
|
|
261
|
+
if (!d.refs?.[connector])
|
|
262
|
+
return false;
|
|
263
|
+
delete d.refs[connector];
|
|
264
|
+
this.write(d);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
252
267
|
/** Every entry with its value, for a run's environment. */
|
|
253
268
|
all() {
|
|
254
269
|
return this.list().map(({ name, kind, bind }) => ({ name, kind, value: this.get(name), ...(bind ? { bind } : {}) }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sagentix-inc/evidence-collector",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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": {
|