@bugmole/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -4
- package/package.json +7 -4
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +494 -173
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +87 -7
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -14,7 +14,7 @@ import { launcherFor } from "./browser-matrix.js";
|
|
|
14
14
|
|
|
15
15
|
export type DiscoveryEvent = {
|
|
16
16
|
at: string;
|
|
17
|
-
kind: "visit" | "click" | "form" | "page" | "flows" | "done" | "skip" | "error";
|
|
17
|
+
kind: "visit" | "click" | "form" | "page" | "flows" | "done" | "skip" | "error" | "limit";
|
|
18
18
|
text: string;
|
|
19
19
|
route?: string;
|
|
20
20
|
};
|
|
@@ -29,8 +29,15 @@ export type DiscoveredPage = {
|
|
|
29
29
|
heading?: string;
|
|
30
30
|
/** The link labels a visitor clicks, from the home page, to reach this page. */
|
|
31
31
|
path: string[];
|
|
32
|
-
|
|
32
|
+
/** `menu` names the button that must be opened first to see the link. */
|
|
33
|
+
links: Array<{ label: string; route: string; menu?: string }>;
|
|
33
34
|
forms: DiscoveredForm[];
|
|
35
|
+
/**
|
|
36
|
+
* Visible buttons outside forms ("Continue with Aegis", "Add to cart"), by
|
|
37
|
+
* name. Discovery never presses them; they tell AI exploration what is
|
|
38
|
+
* really on the page, so it doesn't invent controls.
|
|
39
|
+
*/
|
|
40
|
+
controls?: string[];
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
export type DiscoveredFlow = {
|
|
@@ -45,13 +52,45 @@ export type DiscoveredFlow = {
|
|
|
45
52
|
steps: string[];
|
|
46
53
|
};
|
|
47
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The bounds this exploration ran with and whether they cut it short. A crawl
|
|
57
|
+
* that stops at a limit has seen part of the app, and says how much it left.
|
|
58
|
+
*/
|
|
59
|
+
export type DiscoveryLimits = {
|
|
60
|
+
maxPages: number;
|
|
61
|
+
maxDepth: number;
|
|
62
|
+
/** It stopped with pages still queued because it reached maxPages. */
|
|
63
|
+
stoppedAtPageLimit: boolean;
|
|
64
|
+
/** Pages it found links to but never opened: queued past maxPages, or deeper than maxDepth. */
|
|
65
|
+
notFollowed: string[];
|
|
66
|
+
};
|
|
67
|
+
|
|
48
68
|
export type DiscoveryResult = {
|
|
49
69
|
baseUrl: string;
|
|
50
70
|
pages: DiscoveredPage[];
|
|
51
71
|
flows: DiscoveredFlow[];
|
|
52
72
|
events: DiscoveryEvent[];
|
|
73
|
+
limits?: DiscoveryLimits;
|
|
53
74
|
};
|
|
54
75
|
|
|
76
|
+
/** Discovery's bounds when nobody sets them: the Free plan's. */
|
|
77
|
+
export const DEFAULT_DISCOVERY_LIMITS = { maxPages: 12, maxDepth: 2 } as const;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Limits from the environment of the machine that explores
|
|
81
|
+
* (BUGMOLE_DISCOVERY_MAX_PAGES / BUGMOLE_DISCOVERY_MAX_DEPTH), for a project's
|
|
82
|
+
* own worker. Anything not a positive whole number is ignored.
|
|
83
|
+
*/
|
|
84
|
+
export function discoveryLimitsFromEnv(env: Record<string, string | undefined> = process.env): { maxPages?: number; maxDepth?: number } {
|
|
85
|
+
const read = (name: string) => {
|
|
86
|
+
const value = Number(env[name]);
|
|
87
|
+
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
88
|
+
};
|
|
89
|
+
const maxPages = read("BUGMOLE_DISCOVERY_MAX_PAGES");
|
|
90
|
+
const maxDepth = read("BUGMOLE_DISCOVERY_MAX_DEPTH");
|
|
91
|
+
return { ...(maxPages ? { maxPages } : {}), ...(maxDepth ? { maxDepth } : {}) };
|
|
92
|
+
}
|
|
93
|
+
|
|
55
94
|
export type DiscoveryOptions = {
|
|
56
95
|
maxPages?: number;
|
|
57
96
|
maxDepth?: number;
|
|
@@ -62,10 +101,21 @@ export type DiscoveryOptions = {
|
|
|
62
101
|
* on the app gentle. Defaults to 600ms (BUGMOLE_DISCOVERY_DWELL_MS).
|
|
63
102
|
*/
|
|
64
103
|
dwellMs?: number;
|
|
104
|
+
/**
|
|
105
|
+
* A Playwright storage-state file to start from, for exploring an app that
|
|
106
|
+
* needs sign-in. Links that sign out are already never followed.
|
|
107
|
+
*/
|
|
108
|
+
storageState?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Discovered flows open each page at its address instead of repeating the
|
|
111
|
+
* clicks that reached it. Use it for apps whose navigation hides behind a
|
|
112
|
+
* menu on phones, where the desktop clicks would not exist.
|
|
113
|
+
*/
|
|
114
|
+
openEachPage?: boolean;
|
|
65
115
|
onEvent?: (event: DiscoveryEvent, snapshot: DiscoveryResult) => void | Promise<void>;
|
|
66
116
|
};
|
|
67
117
|
|
|
68
|
-
const UNSAFE_LINK = /\b(log ?out|sign ?out|delete|remove|destroy|unsubscribe|deactivate|cancel (?:my )?(?:account|subscription))\b/i;
|
|
118
|
+
export const UNSAFE_LINK = /\b(log ?out|sign ?out|delete|remove|destroy|unsubscribe|deactivate|cancel (?:my )?(?:account|subscription))\b/i;
|
|
69
119
|
|
|
70
120
|
export function slugify(value: string): string {
|
|
71
121
|
return value.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "home";
|
|
@@ -97,7 +147,7 @@ function sampleValue(field: DiscoveredField): string {
|
|
|
97
147
|
if (hint.includes("email")) return "qa@example.com";
|
|
98
148
|
if (hint.includes("phone") || field.type === "tel") return "+1 555 010 0100";
|
|
99
149
|
if (field.type === "number") return "1";
|
|
100
|
-
if (field.type === "url" ||
|
|
150
|
+
if (field.type === "url" || /\b(url|website|link)\b/.test(hint)) return "https://example.com";
|
|
101
151
|
if (hint.includes("name")) return "Bugmole";
|
|
102
152
|
return "Test";
|
|
103
153
|
}
|
|
@@ -105,7 +155,11 @@ function sampleValue(field: DiscoveredField): string {
|
|
|
105
155
|
const FILLABLE = new Set(["text", "email", "password", "tel", "number", "url", "search", "textarea"]);
|
|
106
156
|
|
|
107
157
|
/** Flows a visitor could run for each discovered page and form. */
|
|
108
|
-
export function flowsFor(
|
|
158
|
+
export function flowsFor(
|
|
159
|
+
baseUrl: string,
|
|
160
|
+
pages: readonly DiscoveredPage[],
|
|
161
|
+
options: { openEachPage?: boolean } = {},
|
|
162
|
+
): DiscoveredFlow[] {
|
|
109
163
|
const flows: DiscoveredFlow[] = [];
|
|
110
164
|
const used = new Set<string>();
|
|
111
165
|
const uniqueId = (base: string) => {
|
|
@@ -114,22 +168,27 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
|
|
|
114
168
|
used.add(id);
|
|
115
169
|
return id;
|
|
116
170
|
};
|
|
117
|
-
|
|
118
|
-
|
|
171
|
+
// A query on the base URL is app context (a selected project, say) that
|
|
172
|
+
// every page needs, so each opened page carries it.
|
|
173
|
+
const context = new URL(baseUrl).search;
|
|
174
|
+
const header = (name: string, page: DiscoveredPage) =>
|
|
175
|
+
`url: ${options.openEachPage ? `${new URL(page.route, baseUrl).origin}${page.route}${context}` : baseUrl}\nname: ${JSON.stringify(name)}\n---\n`;
|
|
176
|
+
const pathSteps = (page: DiscoveredPage) => (options.openEachPage ? [] : page.path.map((label) => ({ tapOn: label })));
|
|
119
177
|
|
|
120
178
|
for (const page of pages) {
|
|
121
179
|
const pageName = page.route === "/" ? "Open the home page" : `Visit ${describeRoute(page)}`;
|
|
122
180
|
const landmark = page.heading ?? page.title;
|
|
123
181
|
if (landmark) {
|
|
124
182
|
const id = uniqueId(`discovered-${slugify(page.route === "/" ? "home" : describeRoute(page))}`);
|
|
125
|
-
|
|
183
|
+
// Then the page itself: it fits the screen and its links lead somewhere.
|
|
184
|
+
const steps: unknown[] = ["launchApp", ...pathSteps(page), { assertVisible: landmark }, "assertLayout", "assertLinks"];
|
|
126
185
|
flows.push({
|
|
127
186
|
id,
|
|
128
187
|
name: pageName,
|
|
129
188
|
kind: "page",
|
|
130
189
|
route: page.route,
|
|
131
190
|
file: `flows/discovered/${id}.yaml`,
|
|
132
|
-
content: header(pageName) + YAML.stringify(steps),
|
|
191
|
+
content: header(pageName, page) + YAML.stringify(steps),
|
|
133
192
|
steps: steps.map(stepLabel),
|
|
134
193
|
});
|
|
135
194
|
}
|
|
@@ -141,7 +200,9 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
|
|
|
141
200
|
const steps: unknown[] = [
|
|
142
201
|
"launchApp",
|
|
143
202
|
...pathSteps(page),
|
|
144
|
-
|
|
203
|
+
// Cleared first: a field may already hold a value (an app's own URL,
|
|
204
|
+
// say), and typing into the middle of it makes nonsense.
|
|
205
|
+
...fields.flatMap((field) => [{ tapOn: field.label }, "eraseText", { inputText: sampleValue(field) }]),
|
|
145
206
|
// Discovery never submits; the flow proves the form is ready to.
|
|
146
207
|
...(form.submit ? [{ assertClickable: form.submit }] : []),
|
|
147
208
|
];
|
|
@@ -151,7 +212,7 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
|
|
|
151
212
|
kind: "form",
|
|
152
213
|
route: page.route,
|
|
153
214
|
file: `flows/discovered/${id}.yaml`,
|
|
154
|
-
content: header(name) + YAML.stringify(steps),
|
|
215
|
+
content: header(name, page) + YAML.stringify(steps),
|
|
155
216
|
steps: steps.map(stepLabel),
|
|
156
217
|
});
|
|
157
218
|
}
|
|
@@ -160,7 +221,13 @@ export function flowsFor(baseUrl: string, pages: readonly DiscoveredPage[]): Dis
|
|
|
160
221
|
}
|
|
161
222
|
|
|
162
223
|
function stepLabel(step: unknown): string {
|
|
163
|
-
if (typeof step === "string")
|
|
224
|
+
if (typeof step === "string") {
|
|
225
|
+
if (step === "launchApp") return "Open the app";
|
|
226
|
+
if (step === "assertLayout") return "Nothing spills off the screen";
|
|
227
|
+
if (step === "assertLinks") return "Every link on the site works";
|
|
228
|
+
if (step === "eraseText") return "Clear the field";
|
|
229
|
+
return step;
|
|
230
|
+
}
|
|
164
231
|
const [[key, value]] = Object.entries(step as Record<string, unknown>);
|
|
165
232
|
const text = String(value);
|
|
166
233
|
switch (key) {
|
|
@@ -186,7 +253,10 @@ const pageProbe = `() => {
|
|
|
186
253
|
for (const link of document.querySelectorAll("a[href]")) {
|
|
187
254
|
if (!visible(link)) continue;
|
|
188
255
|
const label = text(link) || link.getAttribute("aria-label") || "";
|
|
189
|
-
|
|
256
|
+
// Card-style links carry a title and a sentence; only whole paragraphs
|
|
257
|
+
// wrapped in a link are left out.
|
|
258
|
+
// A download (an export, a report file) isn't a page a visitor reads.
|
|
259
|
+
if (label && label.length <= 200) links.push({ label, href: link.href, ...(link.hasAttribute("download") ? { download: true } : {}) });
|
|
190
260
|
}
|
|
191
261
|
const labelOf = (field) => {
|
|
192
262
|
const own = field.labels && field.labels[0] ? text(field.labels[0]) : "";
|
|
@@ -208,24 +278,106 @@ const pageProbe = `() => {
|
|
|
208
278
|
const legend = form.getAttribute("aria-label") || (form.querySelector("legend, h1, h2, h3") ? text(form.querySelector("legend, h1, h2, h3")) : "");
|
|
209
279
|
forms.push({ name: legend || submitText || "form", fields, submit: submitText || undefined });
|
|
210
280
|
}
|
|
211
|
-
|
|
281
|
+
const controls = [];
|
|
282
|
+
for (const control of document.querySelectorAll("button, [role=button], input[type=submit], input[type=button]")) {
|
|
283
|
+
if (!visible(control) || control.closest("form") || control.closest("a[href]")) continue;
|
|
284
|
+
const name = (text(control) || control.getAttribute("aria-label") || control.value || "").trim();
|
|
285
|
+
if (name && name.length <= 60 && !controls.includes(name)) controls.push(name);
|
|
286
|
+
if (controls.length >= 30) break;
|
|
287
|
+
}
|
|
288
|
+
return { title: document.title, heading: heading ? text(heading) : undefined, links, forms, controls };
|
|
212
289
|
}`;
|
|
213
290
|
|
|
214
|
-
type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string }>; forms: DiscoveredForm[] };
|
|
291
|
+
type Probe = { title: string; heading?: string; links: Array<{ label: string; href: string; download?: boolean }>; forms: DiscoveredForm[]; controls?: string[] };
|
|
215
292
|
|
|
216
293
|
async function readPage(page: Page): Promise<Probe> {
|
|
217
294
|
return page.evaluate((source: string) => (new Function(`return (${source})`)() as () => Probe)(), pageProbe);
|
|
218
295
|
}
|
|
219
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Menus a person can open — a closed disclosure button (aria-expanded="false")
|
|
299
|
+
* such as an account menu — and the links inside each. Opening one only
|
|
300
|
+
* reveals links; it is closed again before moving on.
|
|
301
|
+
*/
|
|
302
|
+
async function linksInMenus(page: Page, known: ReadonlySet<string>): Promise<Array<{ menu: string; label: string; href: string }>> {
|
|
303
|
+
const found: Array<{ menu: string; label: string; href: string }> = [];
|
|
304
|
+
const startUrl = page.url();
|
|
305
|
+
const buttons = page.locator('button[aria-expanded="false"]');
|
|
306
|
+
const count = Math.min(await buttons.count(), 8);
|
|
307
|
+
const names: string[] = [];
|
|
308
|
+
for (let index = 0; index < count; index += 1) {
|
|
309
|
+
const button = buttons.nth(index);
|
|
310
|
+
if (!(await button.isVisible().catch(() => false))) continue;
|
|
311
|
+
// The accessible name, exactly as getByRole matches it, from the
|
|
312
|
+
// accessibility tree itself.
|
|
313
|
+
const snapshot = await button.ariaSnapshot().catch(() => "");
|
|
314
|
+
const quoted = /^- button "((?:[^"\\]|\\.)*)"/.exec(snapshot.trim());
|
|
315
|
+
const name = quoted ? (JSON.parse(`"${quoted[1]}"`) as string) : "";
|
|
316
|
+
if (name && name.length <= 60 && !UNSAFE_LINK.test(name) && !names.includes(name)) names.push(name);
|
|
317
|
+
}
|
|
318
|
+
for (const name of names) {
|
|
319
|
+
const button = page.getByRole("button", { name, exact: true }).first();
|
|
320
|
+
try {
|
|
321
|
+
await button.click({ timeout: 3_000 });
|
|
322
|
+
await page.waitForTimeout(200);
|
|
323
|
+
if (page.url() !== startUrl) {
|
|
324
|
+
// Not a menu after all: put the page back and stop opening things.
|
|
325
|
+
await page.goto(startUrl, { waitUntil: "load" });
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
for (const link of (await readPage(page)).links) {
|
|
329
|
+
if (!known.has(link.href) && !found.some((item) => item.href === link.href)) found.push({ menu: name, ...link });
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// A button that can't be pressed hides nothing we can reach.
|
|
333
|
+
} finally {
|
|
334
|
+
await page.keyboard.press("Escape").catch(() => undefined);
|
|
335
|
+
if ((await button.getAttribute("aria-expanded").catch(() => null)) === "true") await button.click({ timeout: 3_000 }).catch(() => undefined);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return found;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The first visible h1/h2, to tell when a client-side navigation has rendered. */
|
|
342
|
+
async function currentHeading(page: Page): Promise<string | undefined> {
|
|
343
|
+
return (await readPage(page).catch(() => undefined))?.heading;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const SIGN_IN_WORDS = /\b(sign ?in|log ?in|welcome back)\b/i;
|
|
347
|
+
const SIGN_IN_BUTTON = /^(continue|sign in|log in) with\b/i;
|
|
348
|
+
|
|
349
|
+
function looksLikeSignIn(page: DiscoveredPage): boolean {
|
|
350
|
+
if (page.forms.some((form) => form.fields.some((field) => field.type === "password"))) return true;
|
|
351
|
+
if (SIGN_IN_WORDS.test(page.heading ?? "") || SIGN_IN_WORDS.test(page.title)) return true;
|
|
352
|
+
return Boolean(page.controls?.some((control) => SIGN_IN_BUTTON.test(control)));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The crawl stopped at a sign-in page: everything it could reach is a sign-in
|
|
357
|
+
* screen, so what lies behind it is unexplored. Discovery doesn't sign in on
|
|
358
|
+
* its own; the caller should say so rather than present the one page it saw
|
|
359
|
+
* as the whole app.
|
|
360
|
+
*/
|
|
361
|
+
export function stoppedAtSignIn(result: Pick<DiscoveryResult, "pages">): boolean {
|
|
362
|
+
return result.pages.length > 0 && result.pages.length <= 2 && result.pages.every(looksLikeSignIn);
|
|
363
|
+
}
|
|
364
|
+
|
|
220
365
|
export async function discoverSite(baseUrl: string, options: DiscoveryOptions = {}): Promise<DiscoveryResult> {
|
|
221
366
|
const start = new URL(baseUrl);
|
|
222
367
|
const root = `${start.origin}${start.pathname}`;
|
|
223
|
-
const maxPages = options.maxPages ??
|
|
224
|
-
const maxDepth = options.maxDepth ??
|
|
368
|
+
const maxPages = options.maxPages ?? DEFAULT_DISCOVERY_LIMITS.maxPages;
|
|
369
|
+
const maxDepth = options.maxDepth ?? DEFAULT_DISCOVERY_LIMITS.maxDepth;
|
|
225
370
|
const timeout = options.navigationTimeoutMs ?? 15_000;
|
|
226
371
|
const envDwell = Number(process.env.BUGMOLE_DISCOVERY_DWELL_MS);
|
|
227
372
|
const dwellMs = options.dwellMs ?? (Number.isFinite(envDwell) && envDwell >= 0 ? envDwell : 600);
|
|
228
373
|
const result: DiscoveryResult = { baseUrl: root, pages: [], flows: [], events: [] };
|
|
374
|
+
// The same link in a shared menu is skipped on every page; say so once.
|
|
375
|
+
const skipped = new Set<string>();
|
|
376
|
+
const skipOnce = async (text: string, route: string) => {
|
|
377
|
+
if (skipped.has(text)) return;
|
|
378
|
+
skipped.add(text);
|
|
379
|
+
await emit("skip", text, route);
|
|
380
|
+
};
|
|
229
381
|
const emit = async (kind: DiscoveryEvent["kind"], text: string, route?: string) => {
|
|
230
382
|
const event: DiscoveryEvent = { at: new Date().toISOString(), kind, text, ...(route ? { route } : {}) };
|
|
231
383
|
result.events.push(event);
|
|
@@ -235,12 +387,17 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
235
387
|
const { type, channel } = launcherFor("chromium");
|
|
236
388
|
const browser = await type.launch({ headless: options.headless ?? true, ...(channel ? { channel } : {}) });
|
|
237
389
|
try {
|
|
238
|
-
const context = await browser.newContext({
|
|
390
|
+
const context = await browser.newContext({
|
|
391
|
+
viewport: { width: 1280, height: 800 },
|
|
392
|
+
...(options.storageState ? { storageState: options.storageState } : {}),
|
|
393
|
+
});
|
|
239
394
|
const page = await context.newPage();
|
|
240
395
|
page.setDefaultTimeout(timeout);
|
|
241
396
|
const seen = new Set<string>();
|
|
397
|
+
// Links seen on a page at the depth limit, so never queued.
|
|
398
|
+
const tooDeep = new Set<string>();
|
|
242
399
|
// Breadth-first, so pages close to home are found first.
|
|
243
|
-
const queue: Array<{ route: string; path: string[]; parent?: string }> = [{ route: discoverableRoute(root, root) ?? "/", path: [] }];
|
|
400
|
+
const queue: Array<{ route: string; path: string[]; parent?: string; menu?: string }> = [{ route: discoverableRoute(root, root) ?? "/", path: [] }];
|
|
244
401
|
|
|
245
402
|
while (queue.length > 0 && result.pages.length < maxPages) {
|
|
246
403
|
const next = queue.shift()!;
|
|
@@ -249,30 +406,68 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
249
406
|
try {
|
|
250
407
|
if (next.path.length === 0) {
|
|
251
408
|
await emit("visit", "Visiting home page", next.route);
|
|
252
|
-
await page.goto(root
|
|
409
|
+
await page.goto(`${root}${start.search}`, { waitUntil: "load" });
|
|
253
410
|
} else {
|
|
254
411
|
// Reach the page the way a visitor would: from its parent, by clicking.
|
|
255
|
-
const parentUrl = new URL(next.parent ?? "/", root).toString()
|
|
412
|
+
const parentUrl = `${new URL(next.parent ?? "/", root).toString()}${start.search}`;
|
|
256
413
|
if (discoverableRoute(page.url(), root) !== next.parent) await page.goto(parentUrl, { waitUntil: "load" });
|
|
257
414
|
const label = next.path[next.path.length - 1];
|
|
415
|
+
const before = await currentHeading(page);
|
|
416
|
+
if (next.menu) {
|
|
417
|
+
await emit("click", `Opening “${next.menu}”`, next.route);
|
|
418
|
+
// A disclosure can remember being open (the dashboard's Advanced
|
|
419
|
+
// group does); clicking it then would close it and hide the link.
|
|
420
|
+
const menuButton = page.getByRole("button", { name: next.menu, exact: true }).first();
|
|
421
|
+
if ((await menuButton.getAttribute("aria-expanded").catch(() => null)) !== "true") await menuButton.click();
|
|
422
|
+
}
|
|
258
423
|
await emit("click", `Clicking “${label}”`, next.route);
|
|
259
424
|
await page.getByRole("link", { name: label, exact: true }).first().click();
|
|
425
|
+
// Apps that route on the client change the address and then render,
|
|
426
|
+
// some time after the click returns: wait for both, bounded.
|
|
427
|
+
await page.waitForURL((url) => discoverableRoute(url.toString(), root) !== next.parent, { timeout }).catch(() => undefined);
|
|
260
428
|
await page.waitForLoadState("load").catch(() => undefined);
|
|
429
|
+
await page.waitForFunction(
|
|
430
|
+
([source, previous]) => ((new Function(`return (${source})`)() as () => { heading?: string })().heading ?? "") !== previous,
|
|
431
|
+
[pageProbe, before ?? ""] as const,
|
|
432
|
+
{ timeout: 5_000 },
|
|
433
|
+
).catch(() => undefined);
|
|
261
434
|
await page.waitForTimeout(250);
|
|
262
435
|
}
|
|
263
|
-
const
|
|
436
|
+
const landed = discoverableRoute(page.url(), root);
|
|
437
|
+
if (!landed) {
|
|
438
|
+
// A same-origin link that hands off elsewhere (an OAuth or install
|
|
439
|
+
// redirect, say) is not a page of this app.
|
|
440
|
+
await emit("skip", `“${next.path[next.path.length - 1] ?? next.route}” leaves the app`, next.route);
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const route = landed;
|
|
444
|
+
if (next.path.length > 0 && route === next.parent) {
|
|
445
|
+
await emit("skip", `“${next.path[next.path.length - 1]}” did not lead anywhere`, next.route);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
264
448
|
if (route !== next.route && seen.has(route)) continue;
|
|
265
449
|
seen.add(route);
|
|
450
|
+
// A link to an API endpoint or a file opens JSON or a viewer, not a page of the app.
|
|
451
|
+
const contentType = await page.evaluate("document.contentType").then(String).catch(() => "text/html");
|
|
452
|
+
if (!/html/i.test(contentType)) {
|
|
453
|
+
await emit("skip", `“${next.path[next.path.length - 1] ?? route}” opens a file (${contentType}), not a page`, route);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
266
456
|
const probe = await readPage(page);
|
|
457
|
+
const inMenus = await linksInMenus(page, new Set(probe.links.map((link) => link.href)));
|
|
267
458
|
const links: DiscoveredPage["links"] = [];
|
|
268
|
-
for (const link of probe.links) {
|
|
459
|
+
for (const link of [...probe.links, ...inMenus] as Array<{ label: string; href: string; menu?: string; download?: boolean }>) {
|
|
269
460
|
const target = discoverableRoute(link.href, root);
|
|
270
461
|
if (!target || target === route || links.some((existing) => existing.route === target)) continue;
|
|
271
|
-
if (UNSAFE_LINK.test(link.label)) {
|
|
272
|
-
await
|
|
462
|
+
if (UNSAFE_LINK.test(link.label) || UNSAFE_LINK.test(target.replace(/[/_-]+/g, " "))) {
|
|
463
|
+
await skipOnce(`Skipping “${link.label}” (it could change data)`, target);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (link.download || /^\/api(\/|$)/.test(target)) {
|
|
467
|
+
await skipOnce(`Skipping “${link.label}” (it downloads a file or calls the API)`, target);
|
|
273
468
|
continue;
|
|
274
469
|
}
|
|
275
|
-
links.push({ label: link.label, route: target });
|
|
470
|
+
links.push({ label: link.label, route: target, ...(link.menu ? { menu: link.menu } : {}) });
|
|
276
471
|
}
|
|
277
472
|
const discovered: DiscoveredPage = {
|
|
278
473
|
route,
|
|
@@ -282,6 +477,7 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
282
477
|
path: next.path,
|
|
283
478
|
links,
|
|
284
479
|
forms: probe.forms,
|
|
480
|
+
...(probe.controls?.length ? { controls: probe.controls } : {}),
|
|
285
481
|
};
|
|
286
482
|
result.pages.push(discovered);
|
|
287
483
|
await emit("page", `Found ${describeRoute(discovered)} (${links.length} link${links.length === 1 ? "" : "s"})`, route);
|
|
@@ -289,16 +485,39 @@ export async function discoverSite(baseUrl: string, options: DiscoveryOptions =
|
|
|
289
485
|
await emit("form", `Found a form: ${form.name} (${form.fields.map((field) => field.label).join(", ")})`, route);
|
|
290
486
|
}
|
|
291
487
|
if (dwellMs > 0) await page.waitForTimeout(dwellMs);
|
|
292
|
-
if (next.path.length
|
|
488
|
+
if (next.path.length >= maxDepth) {
|
|
489
|
+
for (const link of links) tooDeep.add(link.route);
|
|
490
|
+
} else {
|
|
293
491
|
for (const link of links) {
|
|
294
|
-
if (!seen.has(link.route))
|
|
492
|
+
if (!seen.has(link.route)) {
|
|
493
|
+
queue.push({
|
|
494
|
+
route: link.route,
|
|
495
|
+
// A flow replays the path as taps, so a menu's button is a step too.
|
|
496
|
+
path: [...next.path, ...(link.menu ? [link.menu] : []), link.label],
|
|
497
|
+
parent: route,
|
|
498
|
+
...(link.menu ? { menu: link.menu } : {}),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
295
501
|
}
|
|
296
502
|
}
|
|
297
503
|
} catch (error) {
|
|
298
504
|
await emit("error", `Could not open ${next.route}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`, next.route);
|
|
299
505
|
}
|
|
300
506
|
}
|
|
301
|
-
|
|
507
|
+
const opened = new Set(result.pages.map((found) => found.route));
|
|
508
|
+
const queued = [...new Set(queue.map((item) => item.route))].filter((route) => !seen.has(route) && !opened.has(route));
|
|
509
|
+
const notFollowed = [...new Set([...queued, ...tooDeep])].filter((route) => !opened.has(route) && !seen.has(route));
|
|
510
|
+
const stoppedAtPageLimit = result.pages.length >= maxPages && queued.length > 0;
|
|
511
|
+
result.limits = { maxPages, maxDepth, stoppedAtPageLimit, notFollowed };
|
|
512
|
+
if (notFollowed.length) {
|
|
513
|
+
await emit(
|
|
514
|
+
"limit",
|
|
515
|
+
stoppedAtPageLimit
|
|
516
|
+
? `Stopped at the ${maxPages}-page limit; ${notFollowed.length} linked page${notFollowed.length === 1 ? " was" : "s were"} not opened`
|
|
517
|
+
: `${notFollowed.length} linked page${notFollowed.length === 1 ? " is" : "s are"} deeper than ${maxDepth} click${maxDepth === 1 ? "" : "s"} from home and ${notFollowed.length === 1 ? "was" : "were"} not opened`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
result.flows = flowsFor(`${root}${start.search}`, result.pages, { openEachPage: options.openEachPage });
|
|
302
521
|
await emit("flows", `Discovered ${result.flows.length} flow${result.flows.length === 1 ? "" : "s"} across ${result.pages.length} page${result.pages.length === 1 ? "" : "s"}`);
|
|
303
522
|
await emit("done", "Exploration complete");
|
|
304
523
|
return result;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { awsWebIdentityCredentials, googleFederatedToken, registryIdentityTokens } from "./vault-federation.js";
|
|
4
|
+
|
|
5
|
+
test("asks the registry for a token for the runner's own job, with the audience given", async () => {
|
|
6
|
+
const seen: Array<{ url: string; auth: string | null }> = [];
|
|
7
|
+
const source = registryIdentityTokens((async (url: string, init?: RequestInit) => {
|
|
8
|
+
seen.push({ url, auth: new Headers(init?.headers).get("authorization") });
|
|
9
|
+
return new Response(JSON.stringify({ token: "jwt" }), { status: 200 });
|
|
10
|
+
}) as typeof fetch, "https://api.bugmole.com", "vrt_runner", { kind: "task", id: "task 1" });
|
|
11
|
+
assert.equal(await source("sts.amazonaws.com"), "jwt");
|
|
12
|
+
assert.deepEqual(seen, [{ url: "https://api.bugmole.com/api/runner/tasks/task%201/identity-token?audience=sts.amazonaws.com", auth: "Bearer vrt_runner" }]);
|
|
13
|
+
const off = registryIdentityTokens((async () => new Response("{}", { status: 503 })) as unknown as typeof fetch, "https://r", "t", { kind: "run", id: "r" });
|
|
14
|
+
await assert.rejects(off("x"), /doesn't issue identity tokens/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("refuses settings that aren't a Google provider or an IAM role, before asking anyone", async () => {
|
|
18
|
+
const never = async () => { throw new Error("not called"); };
|
|
19
|
+
await assert.rejects(googleFederatedToken({ provider: "my-pool", identityToken: never }), /must look like projects\/NUMBER/);
|
|
20
|
+
await assert.rejects(awsWebIdentityCredentials({ roleArn: "bugmole-reader", sessionName: "s", identityToken: never }), /must be an IAM role ARN/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("passes on what the cloud said when it refuses", async () => {
|
|
24
|
+
const google = googleFederatedToken({
|
|
25
|
+
provider: "projects/1/locations/global/workloadIdentityPools/p/providers/q",
|
|
26
|
+
identityToken: async () => "jwt",
|
|
27
|
+
fetch: (async () => new Response(JSON.stringify({ error: "invalid_grant", error_description: "The audience in ID Token does not match" }), { status: 400 })) as unknown as typeof fetch,
|
|
28
|
+
});
|
|
29
|
+
await assert.rejects(google, /refused Bugmole's token \(400\): The audience in ID Token does not match/);
|
|
30
|
+
const aws = awsWebIdentityCredentials({
|
|
31
|
+
roleArn: "arn:aws:iam::123456789012:role/r",
|
|
32
|
+
sessionName: "bugmole run/1",
|
|
33
|
+
identityToken: async () => "jwt",
|
|
34
|
+
fetch: (async () => new Response("<ErrorResponse><Error><Message>Not authorized to perform sts:AssumeRoleWithWebIdentity</Message></Error></ErrorResponse>", { status: 403 })) as unknown as typeof fetch,
|
|
35
|
+
});
|
|
36
|
+
await assert.rejects(aws, /\(403\): Not authorized to perform sts:AssumeRoleWithWebIdentity/);
|
|
37
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A Bugmole Cloud runner reading a project's own vault without a stored key.
|
|
2
|
+
// The registry signs a short-lived identity token for the runner's job
|
|
3
|
+
// (registry-worker/identity/oidc.ts); the project's cloud trusts Bugmole as an
|
|
4
|
+
// identity provider and trades that token for credentials limited to what
|
|
5
|
+
// the project allowed:
|
|
6
|
+
//
|
|
7
|
+
// Google: Workload Identity Federation (STS token exchange), optionally
|
|
8
|
+
// impersonating a service account
|
|
9
|
+
// AWS: an IAM role assumed with AssumeRoleWithWebIdentity
|
|
10
|
+
//
|
|
11
|
+
// What to trust is configured with three non-secret project settings, kept
|
|
12
|
+
// with the project's secrets: VAULT_GCP_WORKLOAD_PROVIDER (the provider's
|
|
13
|
+
// resource name), VAULT_GCP_SERVICE_ACCOUNT (optional) and VAULT_AWS_ROLE_ARN.
|
|
14
|
+
|
|
15
|
+
import type { AwsCredentials } from "../vendor/byok.js";
|
|
16
|
+
|
|
17
|
+
/** Asks the registry for an identity token with this audience, for the runner's job. */
|
|
18
|
+
export type IdentityTokenSource = (audience: string) => Promise<string>;
|
|
19
|
+
|
|
20
|
+
export function registryIdentityTokens(doFetch: typeof fetch, registryUrl: string, token: string, job: { kind: "run" | "task"; id: string }): IdentityTokenSource {
|
|
21
|
+
return async (audience) => {
|
|
22
|
+
const reply = await doFetch(`${registryUrl}/api/runner/${job.kind}s/${encodeURIComponent(job.id)}/identity-token?audience=${encodeURIComponent(audience)}`, {
|
|
23
|
+
headers: { authorization: `Bearer ${token}` },
|
|
24
|
+
});
|
|
25
|
+
if (reply.status === 503) throw new Error("This Bugmole registry doesn't issue identity tokens");
|
|
26
|
+
if (!reply.ok) throw new Error(`The registry refused an identity token (${reply.status})`);
|
|
27
|
+
const body = await reply.json() as { token?: unknown };
|
|
28
|
+
if (typeof body.token !== "string") throw new Error("The registry returned no identity token");
|
|
29
|
+
return body.token;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function failure(reply: Response, what: string): Promise<Error> {
|
|
34
|
+
const text = (await reply.text().catch(() => "")).slice(0, 400);
|
|
35
|
+
let detail = text;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(text) as { error_description?: string; error?: { message?: string } | string };
|
|
38
|
+
detail = parsed.error_description ?? (typeof parsed.error === "string" ? parsed.error : parsed.error?.message) ?? text;
|
|
39
|
+
} catch {
|
|
40
|
+
// Not JSON (AWS answers in XML).
|
|
41
|
+
detail = /<Message>([^<]*)<\/Message>/.exec(text)?.[1] ?? text;
|
|
42
|
+
}
|
|
43
|
+
return new Error(`${what} (${reply.status}): ${detail}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A Workload Identity provider's resource name, as Google shows it. */
|
|
47
|
+
const PROVIDER = /^projects\/\d+\/locations\/global\/workloadIdentityPools\/[a-z0-9-]+\/providers\/[a-z0-9-]+$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A Google access token for the project's Workload Identity provider: the
|
|
51
|
+
* federated token itself, or a service account's when one is named.
|
|
52
|
+
*/
|
|
53
|
+
export async function googleFederatedToken(options: {
|
|
54
|
+
provider: string;
|
|
55
|
+
serviceAccount?: string;
|
|
56
|
+
identityToken: IdentityTokenSource;
|
|
57
|
+
fetch?: typeof fetch;
|
|
58
|
+
}): Promise<string> {
|
|
59
|
+
const doFetch = options.fetch ?? fetch;
|
|
60
|
+
const provider = options.provider.replace(/^\/\/iam\.googleapis\.com\//, "");
|
|
61
|
+
if (!PROVIDER.test(provider)) {
|
|
62
|
+
throw new Error("VAULT_GCP_WORKLOAD_PROVIDER must look like projects/NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER");
|
|
63
|
+
}
|
|
64
|
+
const audience = `//iam.googleapis.com/${provider}`;
|
|
65
|
+
const subjectToken = await options.identityToken(audience);
|
|
66
|
+
const exchanged = await doFetch("https://sts.googleapis.com/v1/token", {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
grantType: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
71
|
+
audience,
|
|
72
|
+
scope: "https://www.googleapis.com/auth/cloud-platform",
|
|
73
|
+
requestedTokenType: "urn:ietf:params:oauth:token-type:access_token",
|
|
74
|
+
subjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
|
|
75
|
+
subjectToken,
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
if (!exchanged.ok) throw await failure(exchanged, "Google's Workload Identity Federation refused Bugmole's token");
|
|
79
|
+
const federated = (await exchanged.json() as { access_token?: string }).access_token;
|
|
80
|
+
if (!federated) throw new Error("Google's token exchange returned no access token");
|
|
81
|
+
if (!options.serviceAccount) return federated;
|
|
82
|
+
const impersonated = await doFetch(
|
|
83
|
+
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(options.serviceAccount)}:generateAccessToken`,
|
|
84
|
+
{
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { authorization: `Bearer ${federated}`, "content-type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ scope: ["https://www.googleapis.com/auth/cloud-platform"], lifetime: "600s" }),
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
if (!impersonated.ok) throw await failure(impersonated, `Google refused impersonating ${options.serviceAccount}`);
|
|
91
|
+
const token = (await impersonated.json() as { accessToken?: string }).accessToken;
|
|
92
|
+
if (!token) throw new Error("Google returned no service account token");
|
|
93
|
+
return token;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Temporary AWS credentials for the project's role, assumed with Bugmole's token. */
|
|
97
|
+
export async function awsWebIdentityCredentials(options: {
|
|
98
|
+
roleArn: string;
|
|
99
|
+
sessionName: string;
|
|
100
|
+
identityToken: IdentityTokenSource;
|
|
101
|
+
fetch?: typeof fetch;
|
|
102
|
+
}): Promise<AwsCredentials> {
|
|
103
|
+
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/[\w+=,.@/-]+$/.test(options.roleArn)) {
|
|
104
|
+
throw new Error("VAULT_AWS_ROLE_ARN must be an IAM role ARN (arn:aws:iam::ACCOUNT:role/NAME)");
|
|
105
|
+
}
|
|
106
|
+
const webIdentityToken = await options.identityToken("sts.amazonaws.com");
|
|
107
|
+
// AssumeRoleWithWebIdentity is authorized by the token itself: no signature.
|
|
108
|
+
const reply = await (options.fetch ?? fetch)("https://sts.amazonaws.com/", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
111
|
+
body: new URLSearchParams({
|
|
112
|
+
Action: "AssumeRoleWithWebIdentity",
|
|
113
|
+
Version: "2011-06-15",
|
|
114
|
+
RoleArn: options.roleArn,
|
|
115
|
+
RoleSessionName: options.sessionName.replace(/[^\w+=,.@-]/g, "-").slice(0, 64),
|
|
116
|
+
WebIdentityToken: webIdentityToken,
|
|
117
|
+
DurationSeconds: "900",
|
|
118
|
+
}).toString(),
|
|
119
|
+
});
|
|
120
|
+
if (!reply.ok) throw await failure(reply, `AWS refused assuming ${options.roleArn} with Bugmole's token`);
|
|
121
|
+
const xml = await reply.text();
|
|
122
|
+
const field = (name: string) => new RegExp(`<${name}>([^<]+)</${name}>`).exec(xml)?.[1];
|
|
123
|
+
const accessKeyId = field("AccessKeyId");
|
|
124
|
+
const secretAccessKey = field("SecretAccessKey");
|
|
125
|
+
const sessionToken = field("SessionToken");
|
|
126
|
+
if (!accessKeyId || !secretAccessKey || !sessionToken) throw new Error("AWS returned no credentials for the role");
|
|
127
|
+
return { accessKeyId, secretAccessKey, sessionToken };
|
|
128
|
+
}
|