@sagentix-inc/evidence-collector 0.3.4 → 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/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/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
|
@@ -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/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": {
|