@edgehero/pi-dispatch-receiver 0.1.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/package.json +52 -0
- package/src/azure-members.mjs +119 -0
- package/src/azure-subset.mjs +166 -0
- package/src/cli.mjs +82 -0
- package/src/config.mjs +257 -0
- package/src/filter-azure.mjs +273 -0
- package/src/filter-forgejo.mjs +206 -0
- package/src/filter-gitlab.mjs +252 -0
- package/src/filter.mjs +224 -0
- package/src/forgejo-members.mjs +90 -0
- package/src/forgejo-subset.mjs +113 -0
- package/src/gitlab-members.mjs +91 -0
- package/src/gitlab-subset.mjs +76 -0
- package/src/http-body.mjs +73 -0
- package/src/poller-config.mjs +100 -0
- package/src/poller.mjs +700 -0
- package/src/predicate.mjs +62 -0
- package/src/receiver.mjs +330 -0
- package/src/start.mjs +176 -0
- package/src/verify-azure.mjs +109 -0
- package/src/verify-gitlab.mjs +193 -0
- package/src/verify.mjs +98 -0
package/src/filter.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trigger gate: decide whether a verified webhook becomes a paid agent job, and if so with what
|
|
3
|
+
* shape. This is CONST-TRIGGER-AUTHOR-GATE made executable -- the independent controls it names (label
|
|
4
|
+
* allowlist, comment author_association, pull_request author gate, bot-loop guard) plus the
|
|
5
|
+
* INT-WEBHOOK-PAYLOAD-SUBSET extraction that keeps the job carrying only the named fields.
|
|
6
|
+
*
|
|
7
|
+
* Pure and total: imports nothing, touches no I/O, and NEVER throws. A rejected event returns
|
|
8
|
+
* `{ enqueue: false, reason }`; an accepted one returns `{ enqueue: true, job }`. Purity is the point --
|
|
9
|
+
* the whole gate is decidable offline from its inputs, so the security-critical decision is unit testable
|
|
10
|
+
* without a server, a socket, or a queue.
|
|
11
|
+
*
|
|
12
|
+
* The evaluation ORDER is fail-closed and load-bearing:
|
|
13
|
+
* 0. A non-numeric `sender.id` is rejected FIRST. It must precede the `=== selfId` compare, because
|
|
14
|
+
* `undefined === selfId` is `false` and would fall through to enqueue -- failing OPEN on a
|
|
15
|
+
* malformed payload. Missing identity is a reject, never a pass.
|
|
16
|
+
* 1. The bot-loop guard (`sender.id === selfId`) runs UNCONDITIONALLY, before any author/label check.
|
|
17
|
+
* Under a PAT the harness is repo OWNER and would clear the author gate, so a completion comment the
|
|
18
|
+
* harness posts -- or the flow's own push to a PR head, which fires `pull_request.synchronize` -- is
|
|
19
|
+
* an event that passes the gate: an unbounded paid recursion. Gating this drop behind the author
|
|
20
|
+
* check would reintroduce exactly that loop.
|
|
21
|
+
* 2. Only then route on event + action: issue label, author-gated comment, or pull_request.
|
|
22
|
+
*
|
|
23
|
+
* PR AUTHOR GATE (security-critical): auto actions (`opened|synchronize|reopened`) fire ONLY when the PR
|
|
24
|
+
* `author_association` is a collaborator. This is hard-coded here, never config-optional -- a fork PR from
|
|
25
|
+
* a stranger would otherwise launch an unbounded paid run (CONST-TRIGGER-AUTHOR-GATE, job-budget rules).
|
|
26
|
+
* PR labeling is self-gating: only collaborators can apply labels, so the label predicate IS the approval,
|
|
27
|
+
* exactly as on the issue label path.
|
|
28
|
+
*
|
|
29
|
+
* `selfId` is the numeric id of whichever identity posts as the harness (the App's bot user, or the PAT
|
|
30
|
+
* user); `deliveryId` is the `X-GitHub-Delivery` GUID, carried into the job for downstream dedup.
|
|
31
|
+
*/
|
|
32
|
+
import { escapeRegExp, firstMatchingRule, labelSet, matchedLabel, matchesRule } from "./predicate.mjs";
|
|
33
|
+
|
|
34
|
+
const AUTHOR_ALLOWLIST = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
35
|
+
const LABEL_ACTIONS = new Set(["opened", "labeled", "reopened"]);
|
|
36
|
+
const PR_ACTIONS = new Set(["labeled", "opened", "synchronize", "reopened"]);
|
|
37
|
+
const PR_AUTO_ACTIONS = new Set(["opened", "synchronize", "reopened"]);
|
|
38
|
+
|
|
39
|
+
export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
40
|
+
// (0) Fail-closed on identity. MUST precede the self compare -- see header, ordering constraint.
|
|
41
|
+
if (typeof subset?.sender?.id !== "number") {
|
|
42
|
+
return { enqueue: false, reason: "missing-sender-id" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// (1) Bot-loop guard: unconditional, independent of the author/label outcome below.
|
|
46
|
+
if (subset.sender.id === selfId) {
|
|
47
|
+
return { enqueue: false, reason: "self" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// (2) Route on event + action -> resolve { flow, target } or a drop reason.
|
|
51
|
+
// Only the GITHUB rule group is ever read here: rules are grouped per forge at load
|
|
52
|
+
// (receiver/src/config.mjs), so a rule an operator wrote for another forge is not merely unmatched,
|
|
53
|
+
// it is unreachable from this gate.
|
|
54
|
+
const action = subset.action;
|
|
55
|
+
const triggers = cfg?.triggers?.github ?? {};
|
|
56
|
+
let resolved;
|
|
57
|
+
|
|
58
|
+
if (eventName === "issues" && LABEL_ACTIONS.has(action)) {
|
|
59
|
+
resolved = routeIssueLabel(subset, triggers);
|
|
60
|
+
} else if (eventName === "issue_comment" && action === "created") {
|
|
61
|
+
resolved = routeComment(subset, triggers, cfg?.triggers?.knownFlows);
|
|
62
|
+
} else if (eventName === "pull_request" && PR_ACTIONS.has(action)) {
|
|
63
|
+
resolved = routePullRequest(subset, triggers, action);
|
|
64
|
+
} else {
|
|
65
|
+
return { enqueue: false, reason: "unhandled-event" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!resolved.enqueue) return resolved; // carries the drop reason
|
|
69
|
+
|
|
70
|
+
// (3) Build the job from the INT-WEBHOOK-PAYLOAD-SUBSET fields only. No sender.login (not in the
|
|
71
|
+
// subset), no provider/model/maxTurns (the worker fills defaults), no field outside the subset --
|
|
72
|
+
// with two trigger-context additions (issue #49): `matched` is harness-computed, the filter's own
|
|
73
|
+
// decision record naming the triggers.json entry that fired (not payload data at all); `comment`,
|
|
74
|
+
// present only on the comment route, carries the invoking comment's body/author_association, both
|
|
75
|
+
// fields INT-WEBHOOK-PAYLOAD-SUBSET already names.
|
|
76
|
+
//
|
|
77
|
+
// `packages` and `image` are harness-computed EXECUTION knobs read off the matched triggers.json entry
|
|
78
|
+
// (like `matched`), never payload data -- REQ-GLOBAL-PI-OVERLAY's per-trigger control over loading the
|
|
79
|
+
// operator-staged pi packages, and INT-TRIGGERS-FILE-CONTRACT's per-trigger container image. Both sit at
|
|
80
|
+
// JOB level, NOT inside `trigger`: `trigger` is the descriptive context object carried verbatim into
|
|
81
|
+
// /job/event.json (INT-CONTAINER-JOB-INPUTS) and must stay descriptive, so an execution switch has no
|
|
82
|
+
// business there. Both are omitted entirely when the matched rule did not set them, so an unflagged
|
|
83
|
+
// trigger's job literal is byte-identical to today's.
|
|
84
|
+
const job = {
|
|
85
|
+
repo: subset.repository?.full_name,
|
|
86
|
+
target: resolved.target,
|
|
87
|
+
flow: resolved.flow,
|
|
88
|
+
...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
|
|
89
|
+
...(resolved.image !== undefined ? { image: resolved.image } : {}),
|
|
90
|
+
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
91
|
+
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
92
|
+
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
93
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level like the rest, never
|
|
94
|
+
// inside `trigger`, and for the sharpest version of that reason: this is the caller's fanout count --
|
|
95
|
+
// receiver.mjs reads it to decide how many times to enqueue -- and a descriptive context object copied
|
|
96
|
+
// into /job/event.json is no place for the number of jobs to create.
|
|
97
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
98
|
+
trigger: {
|
|
99
|
+
event: eventName,
|
|
100
|
+
action,
|
|
101
|
+
deliveryId,
|
|
102
|
+
sender: { id: subset.sender.id },
|
|
103
|
+
matched: resolved.matched,
|
|
104
|
+
...(resolved.comment ? { comment: resolved.comment } : {}),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
return { enqueue: true, job };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Issue label path: the label allowlist IS the human approval gate -- only collaborators can label. */
|
|
111
|
+
function routeIssueLabel(subset, triggers) {
|
|
112
|
+
const L = labelSet(subset.issue?.labels);
|
|
113
|
+
const rule = firstMatchingRule(triggers.label, L);
|
|
114
|
+
if (rule === undefined) {
|
|
115
|
+
return { enqueue: false, reason: "no-allowlisted-label" };
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
enqueue: true,
|
|
119
|
+
flow: rule.flow,
|
|
120
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
121
|
+
image: rule.image,
|
|
122
|
+
resume: rule.resume,
|
|
123
|
+
replicas: rule.replicas,
|
|
124
|
+
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
125
|
+
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Comment path: author_association is the approval gate (no label event to carry it).
|
|
131
|
+
*
|
|
132
|
+
* `knownFlows` is passed separately because it is NOT a per-forge rule -- it is the whole file's flow
|
|
133
|
+
* vocabulary, and it bounds which names a comment may summon rather than which rules may match.
|
|
134
|
+
*/
|
|
135
|
+
function routeComment(subset, triggers, knownFlows) {
|
|
136
|
+
if (!AUTHOR_ALLOWLIST.has(subset.comment?.author_association)) {
|
|
137
|
+
return { enqueue: false, reason: "author-not-allowed" };
|
|
138
|
+
}
|
|
139
|
+
const phrase = triggers.comment?.phrase;
|
|
140
|
+
const body = subset.comment?.body;
|
|
141
|
+
if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
|
|
142
|
+
return { enqueue: false, reason: "no-trigger-phrase" };
|
|
143
|
+
}
|
|
144
|
+
// Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
|
|
145
|
+
// known flow name, so a comment cannot summon an unlisted flow.
|
|
146
|
+
let flow = triggers.comment?.defaultFlow;
|
|
147
|
+
const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
|
|
148
|
+
if (match && knownFlows?.has(match[1])) {
|
|
149
|
+
flow = match[1];
|
|
150
|
+
}
|
|
151
|
+
if (flow === null || flow === undefined || flow === "") {
|
|
152
|
+
return { enqueue: false, reason: "no-flow" };
|
|
153
|
+
}
|
|
154
|
+
// An issue_comment on a PR carries issue.pull_request; its issue.number IS the PR number and the
|
|
155
|
+
// issue title/body are the PR's. Route it as a pull_request target so the flow gets PR context and
|
|
156
|
+
// does not mint a fresh pi/issue-<n> branch. head/base are absent here (not in the comment payload);
|
|
157
|
+
// the flow resolves them from the number via `gh`.
|
|
158
|
+
const isPR = subset.issue?.pull_request === true;
|
|
159
|
+
return {
|
|
160
|
+
enqueue: true,
|
|
161
|
+
flow,
|
|
162
|
+
// The single comment trigger IS the matched rule here, so its opt-in is the job's. A `<phrase>
|
|
163
|
+
// <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
|
|
164
|
+
packages: triggers.comment.packages,
|
|
165
|
+
image: triggers.comment.image,
|
|
166
|
+
resume: triggers.comment.resume,
|
|
167
|
+
replicas: triggers.comment.replicas,
|
|
168
|
+
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
169
|
+
// The invoking comment rides on the trigger: body and author_association are both named by
|
|
170
|
+
// INT-WEBHOOK-PAYLOAD-SUBSET, and the body stays DATA all the way down (CONST-ISSUE-TEXT-IS-DATA).
|
|
171
|
+
comment: { body: subset.comment.body, author_association: subset.comment.author_association },
|
|
172
|
+
target: { type: isPR ? "pull_request" : "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pull-request path. `labeled` is gated by the label predicate (collaborator-applied label = approval);
|
|
178
|
+
* auto actions (`opened|synchronize|reopened`) are gated by the PR author_association (hard-coded, never
|
|
179
|
+
* config-optional). A trigger's optional predicate only narrows an auto action; an empty predicate is
|
|
180
|
+
* vacuously true. First matching rule (in file order) wins.
|
|
181
|
+
*/
|
|
182
|
+
function routePullRequest(subset, triggers, action) {
|
|
183
|
+
const pr = subset.pull_request;
|
|
184
|
+
if (pr === null || typeof pr !== "object") {
|
|
185
|
+
return { enqueue: false, reason: "missing-pull-request" };
|
|
186
|
+
}
|
|
187
|
+
const L = labelSet(pr.labels);
|
|
188
|
+
const authorOk = AUTHOR_ALLOWLIST.has(pr.author_association);
|
|
189
|
+
|
|
190
|
+
for (const rule of triggers.pullRequest ?? []) {
|
|
191
|
+
if (!rule.actions.has(action)) continue;
|
|
192
|
+
if (action === "labeled") {
|
|
193
|
+
if (!matchesRule(L, rule.predicate)) continue;
|
|
194
|
+
} else {
|
|
195
|
+
// Auto action -- author_association is the hard gate; the predicate (if any) narrows scope.
|
|
196
|
+
if (!authorOk) continue;
|
|
197
|
+
if (!matchesRule(L, rule.predicate)) continue;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
enqueue: true,
|
|
201
|
+
flow: rule.flow,
|
|
202
|
+
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
203
|
+
image: rule.image,
|
|
204
|
+
resume: rule.resume,
|
|
205
|
+
replicas: rule.replicas,
|
|
206
|
+
matched: { index: rule.index, type: "pull_request", action },
|
|
207
|
+
target: buildPrTarget(pr),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Surface the security-relevant author drop distinctly from a plain no-match so it is observable.
|
|
212
|
+
if (PR_AUTO_ACTIONS.has(action) && !authorOk) {
|
|
213
|
+
return { enqueue: false, reason: "pr-author-not-allowed" };
|
|
214
|
+
}
|
|
215
|
+
return { enqueue: false, reason: "no-matching-pr-trigger" };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Build the pull_request target. head/base are carried as DATA only -- never a clone ref (see header). */
|
|
219
|
+
function buildPrTarget(pr) {
|
|
220
|
+
const target = { type: "pull_request", number: pr.number, title: pr.title, body: pr.body };
|
|
221
|
+
if (pr.head) target.head = { ref: pr.head.ref, sha: pr.head.sha, repo: pr.head.repo?.full_name };
|
|
222
|
+
if (pr.base) target.base = { ref: pr.base.ref };
|
|
223
|
+
return target;
|
|
224
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a Forgejo actor's repository permission -- the enforcement half of `CONST-TRIGGER-AUTHOR-GATE`
|
|
3
|
+
* on the Forgejo side.
|
|
4
|
+
*
|
|
5
|
+
* Forgejo has no `author_association`; the concept is GitHub's. What it has instead is
|
|
6
|
+
* `GET /repos/{owner}/{repo}/collaborators/{collaborator}/permission`, which answers
|
|
7
|
+
* `{ permission: "admin"|"write"|"read"|"none" }`. `admin` and `write` are the honest analogue of
|
|
8
|
+
* `OWNER|MEMBER|COLLABORATOR`: they are exactly the levels that can push a branch, which is the property
|
|
9
|
+
* the constitution actually requires.
|
|
10
|
+
*
|
|
11
|
+
* The shape of this module is the GitLab resolver's, for the same three reasons, and they are worth
|
|
12
|
+
* restating rather than cross-referencing:
|
|
13
|
+
* - it runs in the RECEIVER, between verification and the gate, so `filterForgejo` stays pure, total and
|
|
14
|
+
* offline-testable -- a fetch inside the gate would make the security-critical decision untestable
|
|
15
|
+
* without a server;
|
|
16
|
+
* - it runs AFTER verification, so an unauthenticated flood cannot make this project call Forgejo;
|
|
17
|
+
* - it returns a two-armed verdict, because "not a collaborator" and "could not tell" are different
|
|
18
|
+
* answers. A 404 is determinate and refuses; anything else is INDETERMINATE and the receiver answers
|
|
19
|
+
* 503, so Forgejo redelivers and the stable `X-GitHub-Delivery` GUID dedups the retry. Collapsing
|
|
20
|
+
* indeterminate to "deny" would drop real work during an outage behind a 204 that looks exactly like a
|
|
21
|
+
* stranger being correctly refused.
|
|
22
|
+
*
|
|
23
|
+
* The username is required (Forgejo's endpoint takes no numeric id) and is never logged or returned.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { fetchFailureReason } from "@edgehero/pi-dispatch/gitlab-identity";
|
|
27
|
+
|
|
28
|
+
/** Forgejo/Gitea's API path prefix. `apiUrl` is the instance root, e.g. `https://codeberg.org`. */
|
|
29
|
+
const API_PREFIX = "/api/v1";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The permission levels that can push to the repository. `read` and `none` cannot, so a job started on
|
|
33
|
+
* their say-so would be doing work the actor could not do themselves -- the line CONST-TRIGGER-AUTHOR-GATE
|
|
34
|
+
* draws.
|
|
35
|
+
*/
|
|
36
|
+
const WRITE_PERMISSIONS = new Set(["admin", "write"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the resolver. `token` is the same operator-supplied access token the worker uses; `fetchFn` is
|
|
40
|
+
* injected so the whole module is testable offline.
|
|
41
|
+
*
|
|
42
|
+
* Returns `resolveAuthority(repoFullName, login)` -> `{ authorized: boolean }` | `{ indeterminate: string }`
|
|
43
|
+
* -- the same shape every forge's resolver returns.
|
|
44
|
+
*/
|
|
45
|
+
export function makeResolveForgejoAuthority({ apiUrl, token, fetchFn = fetch }) {
|
|
46
|
+
return async function resolveAuthority(repoFullName, login) {
|
|
47
|
+
// Both halves have to be present AND well-formed before they become path segments. A slash or a `..`
|
|
48
|
+
// in either would reach a different endpoint than the one this function believes it is asking, and
|
|
49
|
+
// the answer would be attributed to the wrong repository or the wrong person.
|
|
50
|
+
const repo = typeof repoFullName === "string" ? repoFullName.split("/") : [];
|
|
51
|
+
if (repo.length !== 2 || !repo[0] || !repo[1] || typeof login !== "string" || login === "" || /[/?#]/.test(login)) {
|
|
52
|
+
// Not a lookup failure -- the payload never named a repository and an actor we could ask about.
|
|
53
|
+
// Determinate, and refused.
|
|
54
|
+
return { authorized: false };
|
|
55
|
+
}
|
|
56
|
+
const root = String(apiUrl).replace(/\/+$/, "") + API_PREFIX;
|
|
57
|
+
const url = `${root}/repos/${encodeURIComponent(repo[0])}/${encodeURIComponent(repo[1])}/collaborators/${encodeURIComponent(login)}/permission`;
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
// `redirect: "error"` so an instance that 30x-es this path cannot silently send the token
|
|
61
|
+
// somewhere else -- the same rule the GitLab host applies.
|
|
62
|
+
res = await fetchFn(url, { headers: { Authorization: `token ${token}` }, redirect: "error" });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { indeterminate: fetchFailureReason(err) };
|
|
65
|
+
}
|
|
66
|
+
if (res.status === 404) {
|
|
67
|
+
// Forgejo's answer for "no such collaborator on this repository". The one status that may read as
|
|
68
|
+
// a refusal rather than a failure.
|
|
69
|
+
return { authorized: false };
|
|
70
|
+
}
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
// Note what is NOT here: the response body. A Forgejo error body can echo the request, and the
|
|
73
|
+
// request carried the token.
|
|
74
|
+
return { indeterminate: `collaborator permission lookup returned ${res.status}` };
|
|
75
|
+
}
|
|
76
|
+
let body;
|
|
77
|
+
try {
|
|
78
|
+
body = await res.json();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { indeterminate: `collaborator permission lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
|
|
81
|
+
}
|
|
82
|
+
const permission = body?.permission;
|
|
83
|
+
if (typeof permission !== "string" || permission === "") {
|
|
84
|
+
// A 200 whose shape we do not recognise is not a refusal. Answering `false` here would turn an
|
|
85
|
+
// upstream schema change into a silent, permanent refusal of every trigger.
|
|
86
|
+
return { indeterminate: "collaborator permission lookup returned no permission string" };
|
|
87
|
+
}
|
|
88
|
+
return { authorized: WRITE_PERMISSIONS.has(permission) };
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project ONLY the fields the Forgejo gate and job are allowed to see (`INT-FORGEJO-PAYLOAD-SUBSET`).
|
|
3
|
+
*
|
|
4
|
+
* A sibling of `parseSubset` (GitHub) and `parseGitLabSubset`, not a reuse of either. Forgejo's payload is
|
|
5
|
+
* the closest of the three to GitHub's -- `modules/structs/hook.go` carries the same JSON tags for
|
|
6
|
+
* `action`, `issue.{number,title,body}`, `issue.labels[].name`, `comment.body`, `repository.full_name` and
|
|
7
|
+
* `pull_request.{number,title,body,labels,head,base}` -- which is exactly why it gets its own projection
|
|
8
|
+
* rather than sharing GitHub's. "Almost the same" is the shape that breaks quietly, and there are three
|
|
9
|
+
* differences, each of which is a wrong job rather than an error:
|
|
10
|
+
*
|
|
11
|
+
* 1. NO `author_association`. Forgejo has no such concept anywhere. GitHub's gate reads that field, so
|
|
12
|
+
* against a Forgejo body it is `undefined` and the gate denies everything -- which fails closed, and
|
|
13
|
+
* is therefore the RIGHT accident, but it means the comment and PR-auto paths are simply dead until
|
|
14
|
+
* an authority verdict is resolved from the API instead (forgejo-members.mjs).
|
|
15
|
+
* 2. `is_pull` AT TOP LEVEL, not `issue.pull_request`. Get this wrong and every comment on a pull request
|
|
16
|
+
* routes as an ISSUE target, so the envelope tells the agent to open `pi/issue-<n>` for something that
|
|
17
|
+
* is already a pull request. Wrong work, no error, and a run that looks like it succeeded.
|
|
18
|
+
* 3. `sender.login` IS CARRIED, where GitHub's subset deliberately excludes it. It is personal data, and
|
|
19
|
+
* it is here for exactly one consumer -- the collaborator-permission lookup, which is by username
|
|
20
|
+
* because that is the only key Forgejo's endpoint takes. Same justification, and same obligation, as
|
|
21
|
+
* `user.username` in the GitLab subset: it exists to have been asked about, and `no-pii-in-logs`
|
|
22
|
+
* applies to everything the resolution path writes down.
|
|
23
|
+
*
|
|
24
|
+
* Everything else in the delivery is ignored.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Forgejo's issue/PR action vocabulary, mapped to the words this codebase's gate is written in.
|
|
29
|
+
*
|
|
30
|
+
* This map is why the module exists in the shape it does. Forgejo's `HookEventType.Event()` reports
|
|
31
|
+
* `issue_label` as `X-GitHub-Event: issues` and `pull_request_label` as `pull_request`, so a label change
|
|
32
|
+
* arrives looking exactly like a GitHub label event -- and then says `"action": "label_updated"`. Against
|
|
33
|
+
* GitHub's vocabulary that passes the event check, fails the action check, and falls out as
|
|
34
|
+
* `unhandled-event`: HTTP 200, no job, no error, and nothing that says why. An operator watching a trigger
|
|
35
|
+
* that never fires has nothing to look at.
|
|
36
|
+
*
|
|
37
|
+
* So the mapping is explicit and tested, never incidental. Two rules it encodes:
|
|
38
|
+
* - `label_cleared` maps to NOTHING, permanently. It has no GitHub counterpart and must not acquire one:
|
|
39
|
+
* REMOVING a label must never start a paid run.
|
|
40
|
+
* - An action that is recognised but not actionable drops under its OWN reason, so "Forgejo sent
|
|
41
|
+
* something we understand and chose to ignore" is distinguishable from "we did not recognise this".
|
|
42
|
+
*/
|
|
43
|
+
const ISSUE_ACTIONS = {
|
|
44
|
+
opened: "opened",
|
|
45
|
+
reopened: "reopened",
|
|
46
|
+
label_updated: "labeled",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const PR_ACTIONS = {
|
|
50
|
+
opened: "opened",
|
|
51
|
+
reopened: "reopened",
|
|
52
|
+
label_updated: "labeled",
|
|
53
|
+
synchronized: "synchronize",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Recognised, and deliberately not actionable. Named so the drop reason can say which it was. */
|
|
57
|
+
const IGNORED_ACTIONS = new Set([
|
|
58
|
+
"label_cleared", // removing a label must never start a paid run
|
|
59
|
+
"closed",
|
|
60
|
+
"edited",
|
|
61
|
+
"assigned",
|
|
62
|
+
"unassigned",
|
|
63
|
+
"milestoned",
|
|
64
|
+
"demilestoned",
|
|
65
|
+
"reviewed",
|
|
66
|
+
"review_requested",
|
|
67
|
+
"review_request_removed",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Forgejo's word for this event's action, in this codebase's vocabulary, or `null` when it maps to none. */
|
|
71
|
+
export function mapAction(eventName, action) {
|
|
72
|
+
const table = eventName === "pull_request" ? PR_ACTIONS : ISSUE_ACTIONS;
|
|
73
|
+
return Object.hasOwn(table, action) ? table[action] : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether Forgejo names this action at all -- distinguishes "ignored on purpose" from "never heard of it". */
|
|
77
|
+
export function isRecognizedAction(action) {
|
|
78
|
+
return IGNORED_ACTIONS.has(action) || Object.hasOwn(ISSUE_ACTIONS, action) || Object.hasOwn(PR_ACTIONS, action);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseForgejoSubset(payload) {
|
|
82
|
+
const pr = payload.pull_request;
|
|
83
|
+
return {
|
|
84
|
+
// Forgejo's raw action word, kept verbatim: the gate maps it, and the job's trigger records what the
|
|
85
|
+
// forge actually said rather than our translation of it.
|
|
86
|
+
action: payload.action,
|
|
87
|
+
sender: { id: payload.sender?.id, login: payload.sender?.login },
|
|
88
|
+
issue: {
|
|
89
|
+
number: payload.issue?.number,
|
|
90
|
+
title: payload.issue?.title,
|
|
91
|
+
body: payload.issue?.body,
|
|
92
|
+
labels: Array.isArray(payload.issue?.labels) ? payload.issue.labels.map((l) => ({ name: l?.name })) : [],
|
|
93
|
+
},
|
|
94
|
+
comment: { body: payload.comment?.body },
|
|
95
|
+
// TOP-LEVEL on Forgejo, and a boolean rather than a presence marker for an object. This is the field
|
|
96
|
+
// that decides whether a comment routes as a pull request or an issue.
|
|
97
|
+
isPull: payload.is_pull === true,
|
|
98
|
+
pull_request: pr
|
|
99
|
+
? {
|
|
100
|
+
number: pr.number,
|
|
101
|
+
title: pr.title,
|
|
102
|
+
body: pr.body,
|
|
103
|
+
labels: Array.isArray(pr.labels) ? pr.labels.map((l) => ({ name: l?.name })) : [],
|
|
104
|
+
// head/base are attacker-controlled fork DATA, projected for the flow's event.json and NEVER
|
|
105
|
+
// used as a clone ref -- the worker clones the base default-branch SHA
|
|
106
|
+
// (INT-WEBHOOK-PAYLOAD-SUBSET's acceptance clause, which holds here for the same reason).
|
|
107
|
+
head: { ref: pr.head?.ref, sha: pr.head?.sha, repo: { full_name: pr.head?.repo?.full_name } },
|
|
108
|
+
base: { ref: pr.base?.ref },
|
|
109
|
+
}
|
|
110
|
+
: undefined,
|
|
111
|
+
repository: { full_name: payload.repository?.full_name },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a GitLab actor's project access level -- the enforcement half of `CONST-TRIGGER-AUTHOR-GATE` on
|
|
3
|
+
* the GitLab side.
|
|
4
|
+
*
|
|
5
|
+
* GitHub puts `author_association` in the payload, so its gate is decidable from the delivery alone.
|
|
6
|
+
* GitLab puts nothing equivalent anywhere in a webhook body, so the level has to be ASKED FOR. That single
|
|
7
|
+
* fact shapes everything here:
|
|
8
|
+
*
|
|
9
|
+
* - It runs in the receiver, between verification and `filterGitLab`, and only its VERDICT is passed into
|
|
10
|
+
* the gate. The gate stays pure, total and offline-testable; a fetch inside it would make the
|
|
11
|
+
* security-critical decision untestable without a server.
|
|
12
|
+
* - It runs AFTER verification, never before, so an unauthenticated flood cannot make this project issue
|
|
13
|
+
* API calls on an attacker's behalf.
|
|
14
|
+
* - It distinguishes "not a member" from "could not tell", and those are different answers. A 404 is
|
|
15
|
+
* determinate and yields `authorized: false`, which the gate refuses. Anything else -- a 5xx, a dead
|
|
16
|
+
* socket, a revoked token -- is INDETERMINATE, and the receiver answers 503 so GitLab redelivers.
|
|
17
|
+
* Collapsing indeterminate to "deny" would silently drop legitimate work during an outage, with a 204
|
|
18
|
+
* that reads exactly like a correctly-refused stranger.
|
|
19
|
+
*
|
|
20
|
+
* `members/all` and not `members`: the `all` variant includes membership inherited from a parent group,
|
|
21
|
+
* which is how most real GitLab organisations grant access. The plain endpoint reports only direct members
|
|
22
|
+
* and would refuse a maintainer who holds their role at the group level -- a denial that looks like a
|
|
23
|
+
* policy decision and is really a wrong question.
|
|
24
|
+
*
|
|
25
|
+
* The username is never sent, logged or returned; the lookup is by numeric user id.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { fetchFailureReason } from "@edgehero/pi-dispatch/gitlab-identity";
|
|
29
|
+
|
|
30
|
+
/** GitLab's API path prefix. `apiUrl` is the instance root, e.g. `https://gitlab.com`. */
|
|
31
|
+
const API_PREFIX = "/api/v4";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* GitLab's Developer role. At or above it, an actor can push to the project, which is the property
|
|
35
|
+
* CONST-TRIGGER-AUTHOR-GATE actually requires -- "write access or above", by whatever mechanism the forge
|
|
36
|
+
* offers.
|
|
37
|
+
*
|
|
38
|
+
* The threshold lives HERE, with the lookup that produces the number, rather than in the gate. The gate's
|
|
39
|
+
* job is "is this actor authorised", and every forge answers that differently: GitLab with an integer role,
|
|
40
|
+
* Forgejo with a string enum, GitHub with a payload field, Azure DevOps with a group membership. Only the
|
|
41
|
+
* VERDICT is common, so only the verdict crosses into the filter.
|
|
42
|
+
*/
|
|
43
|
+
const DEVELOPER = 30;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the resolver. `token` is the same operator-supplied access token the worker uses; `fetchFn` is
|
|
47
|
+
* injected so the whole module is testable offline.
|
|
48
|
+
*
|
|
49
|
+
* Returns `resolveAuthority(projectId, userId)` -> `{ authorized: boolean }` | `{ indeterminate: string }`.
|
|
50
|
+
*
|
|
51
|
+
* That two-armed shape is the one every forge's resolver returns, and it is deliberately not a bare
|
|
52
|
+
* boolean: `false` and "could not tell" are different answers with different HTTP responses, and a type
|
|
53
|
+
* that cannot express the difference would collapse them at the first call site that forgot.
|
|
54
|
+
*/
|
|
55
|
+
export function makeResolveAuthority({ apiUrl, token, fetchFn = fetch }) {
|
|
56
|
+
return async function resolveAuthority(projectId, userId) {
|
|
57
|
+
if (!Number.isInteger(projectId) || !Number.isInteger(userId)) {
|
|
58
|
+
// Not a lookup failure -- the payload never named a project or an actor, so there is nothing to
|
|
59
|
+
// ask about. Determinate, and refused.
|
|
60
|
+
return { authorized: false };
|
|
61
|
+
}
|
|
62
|
+
const url = `${String(apiUrl).replace(/\/+$/, "")}${API_PREFIX}/projects/${projectId}/members/all/${userId}`;
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = await fetchFn(url, { headers: { "PRIVATE-TOKEN": token }, redirect: "error" });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { indeterminate: fetchFailureReason(err) };
|
|
68
|
+
}
|
|
69
|
+
if (res.status === 404) {
|
|
70
|
+
// GitLab's documented answer for "this user is not a member of this project", including via any
|
|
71
|
+
// ancestor group. The one status this may read as a refusal rather than a failure.
|
|
72
|
+
return { authorized: false };
|
|
73
|
+
}
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
return { indeterminate: `members lookup returned ${res.status}` };
|
|
76
|
+
}
|
|
77
|
+
let body;
|
|
78
|
+
try {
|
|
79
|
+
body = await res.json();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { indeterminate: `members lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
|
|
82
|
+
}
|
|
83
|
+
const level = body?.access_level;
|
|
84
|
+
if (!Number.isInteger(level)) {
|
|
85
|
+
// A 200 whose shape we do not recognise is not a refusal. Reporting `authorized: false` here would
|
|
86
|
+
// turn an upstream schema change into a silent, permanent refusal of every trigger.
|
|
87
|
+
return { indeterminate: "members lookup returned no integer access_level" };
|
|
88
|
+
}
|
|
89
|
+
return { authorized: level >= DEVELOPER };
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INT-GITLAB-PAYLOAD-SUBSET: the exact set of GitLab webhook fields this project reads, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Naming the subset IS the contract, for the same reason `parseSubset` does it on the GitHub side: because
|
|
5
|
+
* everything unlisted is ignored by construction, an upstream schema addition cannot change our behaviour,
|
|
6
|
+
* and a reviewer sees the whole attack surface as one list rather than inferring it from destructuring
|
|
7
|
+
* scattered across a handler. Every field here is attacker-controlled except the headers, and the headers
|
|
8
|
+
* are only trustworthy after verify-gitlab.mjs has run.
|
|
9
|
+
*
|
|
10
|
+
* Three fields have no GitHub counterpart and are the reason this is a separate projection rather than a
|
|
11
|
+
* rename of the other one:
|
|
12
|
+
*
|
|
13
|
+
* - `changes.labels` -- on GitLab, adding a label is not an action. It arrives as `action: "update"`
|
|
14
|
+
* with a before/after diff, so the DIFF is the trigger and the current label set is not. Carrying only
|
|
15
|
+
* the set would make every later edit of an already-labelled issue re-fire (filter-gitlab.mjs).
|
|
16
|
+
* - `noteable_type` -- how a comment says whether it is on an issue or a merge request. GitHub uses the
|
|
17
|
+
* presence of `issue.pull_request`; GitLab states it.
|
|
18
|
+
* - `user.username` -- carried, where GitHub's `sender.login` is deliberately dropped, because GitLab
|
|
19
|
+
* puts no access level in the payload and the member lookup needs an identity. It is PERSONAL DATA: it
|
|
20
|
+
* exists to be handed to the resolver, and must never enter a log line, the job, or the run record.
|
|
21
|
+
*
|
|
22
|
+
* `iid` and not `id`: `iid` is the per-project number a human sees and an API path takes. `id` is a global
|
|
23
|
+
* database key that would produce a valid-looking URL to somebody else's issue.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Map one label array (`[{ title }]`) to `[{ name }]`, so the shared predicate helpers read one shape. */
|
|
27
|
+
function labelNames(labels) {
|
|
28
|
+
return Array.isArray(labels) ? labels.map((l) => ({ name: l?.title })) : [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Project a verified GitLab payload down to the subset above. Total: a malformed or partial payload yields
|
|
33
|
+
* a record with missing fields, never a throw -- the gate is what refuses it, and it does so by finding
|
|
34
|
+
* nothing to match rather than by crashing the endpoint.
|
|
35
|
+
*/
|
|
36
|
+
export function parseGitLabSubset(payload) {
|
|
37
|
+
const oa = payload?.object_attributes ?? {};
|
|
38
|
+
const changes = payload?.changes ?? {};
|
|
39
|
+
return {
|
|
40
|
+
objectKind: payload?.object_kind,
|
|
41
|
+
action: oa.action,
|
|
42
|
+
user: { id: payload?.user?.id, username: payload?.user?.username },
|
|
43
|
+
project: {
|
|
44
|
+
id: payload?.project?.id,
|
|
45
|
+
path: payload?.project?.path_with_namespace,
|
|
46
|
+
defaultBranch: payload?.project?.default_branch,
|
|
47
|
+
},
|
|
48
|
+
// The issue or merge request the event is about. A note event carries the noteable under its own
|
|
49
|
+
// key (`issue` / `merge_request`) instead of in object_attributes, which holds the note itself.
|
|
50
|
+
target: targetOf(payload, oa),
|
|
51
|
+
note: oa.note,
|
|
52
|
+
noteableType: oa.noteable_type,
|
|
53
|
+
labels: labelNames(oa.labels),
|
|
54
|
+
// Present ONLY when this event changed the labels. Absent is meaningful: it says no label moved,
|
|
55
|
+
// which is what stops an unrelated `update` from re-firing a label rule.
|
|
56
|
+
labelChanges: changes.labels
|
|
57
|
+
? { previous: labelNames(changes.labels.previous), current: labelNames(changes.labels.current) }
|
|
58
|
+
: undefined,
|
|
59
|
+
// A merge-request `update` carrying `oldrev` is a push to the source branch -- GitLab's analogue of
|
|
60
|
+
// GitHub's `synchronize`, which it has no distinct action for.
|
|
61
|
+
oldrev: oa.oldrev,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function targetOf(payload, oa) {
|
|
66
|
+
if (payload?.object_kind === "note") {
|
|
67
|
+
const noteable = oa.noteable_type === "MergeRequest" ? payload?.merge_request : payload?.issue;
|
|
68
|
+
return {
|
|
69
|
+
iid: noteable?.iid,
|
|
70
|
+
title: noteable?.title,
|
|
71
|
+
description: noteable?.description,
|
|
72
|
+
labels: labelNames(noteable?.labels),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { iid: oa.iid, title: oa.title, description: oa.description, labels: labelNames(oa.labels) };
|
|
76
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The forge-neutral half of a webhook endpoint: read a bounded raw body, recognise a JSON content type,
|
|
3
|
+
* and write a small JSON response.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from verify.mjs when a second forge arrived (issue #42), and extracted rather than copied for
|
|
6
|
+
* one reason: `readRawBody`'s per-chunk accounting is a memory bound on attacker-controlled input. Two
|
|
7
|
+
* copies of a security limit drift -- one gets the fix, the other keeps the bug -- and the copy that keeps
|
|
8
|
+
* it is the one nobody is looking at. Verification itself is NOT here; each forge's gate owns its own,
|
|
9
|
+
* because that is exactly the part that differs.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here reads a header that identifies a forge, and nothing here decides whether a request is
|
|
12
|
+
* trusted. A caller must still verify before parsing a single field (CONST-HMAC-OVER-RAW-BODY).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Accept `application/json`, ignoring any parameters and case. */
|
|
16
|
+
export function isJsonContentType(contentType) {
|
|
17
|
+
return String(contentType ?? "").split(";")[0].trim().toLowerCase() === "application/json";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Collect the request stream into a Buffer, bounded by `limit`. Accumulation is checked on every
|
|
22
|
+
* chunk so the buffer can never grow past the cap: exceeding it destroys the request and rejects
|
|
23
|
+
* with `code: "E_PAYLOAD_TOO_LARGE"` rather than reading an unbounded body into memory.
|
|
24
|
+
*/
|
|
25
|
+
export function readRawBody(req, limit) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let size = 0;
|
|
29
|
+
let settled = false;
|
|
30
|
+
|
|
31
|
+
const cleanup = () => {
|
|
32
|
+
req.removeListener("data", onData);
|
|
33
|
+
req.removeListener("end", onEnd);
|
|
34
|
+
req.removeListener("error", onError);
|
|
35
|
+
};
|
|
36
|
+
const onData = (chunk) => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
size += chunk.length;
|
|
39
|
+
if (size > limit) {
|
|
40
|
+
settled = true;
|
|
41
|
+
cleanup();
|
|
42
|
+
req.destroy();
|
|
43
|
+
const err = new Error("payload too large");
|
|
44
|
+
err.code = "E_PAYLOAD_TOO_LARGE";
|
|
45
|
+
reject(err);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
chunks.push(chunk);
|
|
49
|
+
};
|
|
50
|
+
const onEnd = () => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
cleanup();
|
|
54
|
+
resolve(Buffer.concat(chunks));
|
|
55
|
+
};
|
|
56
|
+
const onError = (err) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
cleanup();
|
|
60
|
+
reject(err);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
req.on("data", onData);
|
|
64
|
+
req.on("end", onEnd);
|
|
65
|
+
req.on("error", onError);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Write a small JSON body with an explicit status. `writeHead` sets the status code on the response. */
|
|
70
|
+
export function respond(res, status, body) {
|
|
71
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
72
|
+
res.end(JSON.stringify(body));
|
|
73
|
+
}
|