@zeroroot-ai/zerocool 0.5.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/dist/fix.d.ts +207 -0
- package/dist/fix.js +255 -0
- package/dist/gitlab.d.ts +44 -0
- package/dist/gitlab.js +94 -0
- package/dist/watch.d.ts +12 -7
- package/dist/watch.js +12 -7
- package/package.json +1 -1
package/dist/fix.d.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { FileChange, GitLabWriter, MergeRequest } from "./gitlab.js";
|
|
2
|
+
/**
|
|
3
|
+
* The Fix — what the always-on agent does with a Finding (zerocool-plugins#89).
|
|
4
|
+
*
|
|
5
|
+
* After a Scan mission lands, the agent works the Application's open Findings
|
|
6
|
+
* in priority order. For each one it can act on it rewrites the repository,
|
|
7
|
+
* runs the repository's own tests, and only then opens a merge request set to
|
|
8
|
+
* merge itself when the pipeline succeeds. It records the scan verdict on the
|
|
9
|
+
* commit as `gibson/scan` and posts one note saying what it fixed, what it did
|
|
10
|
+
* not, and why.
|
|
11
|
+
*
|
|
12
|
+
* THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
|
|
13
|
+
* Finding: it stops every other fix behind it. So the change is applied to a
|
|
14
|
+
* workspace, the tests run there, and a failure resets the workspace and leaves
|
|
15
|
+
* the Finding `open` with its reason recorded. Nothing unproven reaches GitLab.
|
|
16
|
+
*
|
|
17
|
+
* THE FIX NEVER MARKS A FINDING `verified`. A merge is not evidence that a
|
|
18
|
+
* rescan did not see the weakness again — only the rescan is, and absence is
|
|
19
|
+
* not an observation, so it is reconciled once at a scan's completion
|
|
20
|
+
* (gibson#1686). The Fix moves a Finding `open` → `fixing` when its merge
|
|
21
|
+
* request opens and `fixing` → `fixed` when GitLab reports that request merged.
|
|
22
|
+
*
|
|
23
|
+
* THE TOKEN NEVER APPEARS IN ANYTHING THIS WRITES. It reaches GitLab only as a
|
|
24
|
+
* `PRIVATE-TOKEN` header inside {@link gitlabRestWriter}. No branch name, merge
|
|
25
|
+
* request title, description, note or status description is built from it, and
|
|
26
|
+
* `fix.test.ts` proves that with a sentinel that would show up in every
|
|
27
|
+
* recorded output if it ever did.
|
|
28
|
+
*
|
|
29
|
+
* TWO SEAMS ARE DELIBERATELY UNIMPLEMENTED HERE. Reading an Application's
|
|
30
|
+
* Findings and writing a Finding's status both exist on the Go SDK as of
|
|
31
|
+
* v0.174.0, and on neither the TypeScript SDK nor this package: `@zeroroot-ai/sdk`
|
|
32
|
+
* carries no `applicationFindings` and its `observe` admits only host, domain,
|
|
33
|
+
* subdomain, credential, account and memory. That is sdk-ts#51. Until it lands,
|
|
34
|
+
* {@link FindingSource} and {@link FindingStatusWriter} have no platform
|
|
35
|
+
* implementation, and the Fix is driven by whatever supplies them. This is a
|
|
36
|
+
* seam, not a second code path: when the wire arrives there is one
|
|
37
|
+
* implementation to add and nothing here changes.
|
|
38
|
+
*/
|
|
39
|
+
/** Severities, worst first. Used for the note's counts and for a stable order. */
|
|
40
|
+
export declare const SEVERITIES: readonly ["critical", "high", "medium", "low", "info"];
|
|
41
|
+
/** A Finding as the Fix needs it — the read half of sdk-ts#51. */
|
|
42
|
+
export interface FixableFinding {
|
|
43
|
+
/** The graph's `brain_id`, so a write lands on the node that was read. */
|
|
44
|
+
id: string;
|
|
45
|
+
/** `open`, `fixing`, `fixed` or `verified`. */
|
|
46
|
+
status: string;
|
|
47
|
+
severity: string;
|
|
48
|
+
/** The CVE, GHSA or platform id this Finding is an instance of. */
|
|
49
|
+
vulnerabilityId: string;
|
|
50
|
+
/** Where it is: a `Package`, a `Repository` file, or a `Service`. */
|
|
51
|
+
placeLabel: string;
|
|
52
|
+
placeKey: string;
|
|
53
|
+
/** `P1`..`P4` once the read carries what a triage pass decided (gibson#1684). */
|
|
54
|
+
priority?: string;
|
|
55
|
+
/** The merge request already opened for this Finding, when it is `fixing`. */
|
|
56
|
+
mergeRequestIid?: number;
|
|
57
|
+
}
|
|
58
|
+
/** Reads an Application's Findings. Platform implementation pending sdk-ts#51. */
|
|
59
|
+
export interface FindingSource {
|
|
60
|
+
/**
|
|
61
|
+
* The Application's Findings in the given statuses, **in the order they
|
|
62
|
+
* should be worked**. Rejects when the graph is unreachable; it must never
|
|
63
|
+
* resolve empty to signal failure, because an empty list reads as a healthy
|
|
64
|
+
* Application and the Fix would report success over a live backlog.
|
|
65
|
+
*/
|
|
66
|
+
findings(application: string, statuses: string[]): Promise<FixableFinding[]>;
|
|
67
|
+
}
|
|
68
|
+
/** Moves a Finding through its statuses. Platform implementation pending sdk-ts#51. */
|
|
69
|
+
export interface FindingStatusWriter {
|
|
70
|
+
/** Record that a merge request now proposes this Finding's fix. */
|
|
71
|
+
markFixing(findingId: string, mr: MergeRequest): Promise<void>;
|
|
72
|
+
/** Record that the merge request merged. Never `verified` — the rescan does that. */
|
|
73
|
+
markFixed(findingId: string): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
/** A checkout the Fix can change and test before anything is pushed. */
|
|
76
|
+
export interface Workspace {
|
|
77
|
+
/** File contents, or `undefined` when the path does not exist. */
|
|
78
|
+
read(path: string): Promise<string | undefined>;
|
|
79
|
+
/** Stage a rewrite in the workspace. Not a push. */
|
|
80
|
+
write(path: string, content: string): Promise<void>;
|
|
81
|
+
/** Run the repository's own tests. `output` is quoted in the note on failure. */
|
|
82
|
+
test(): Promise<{
|
|
83
|
+
ok: boolean;
|
|
84
|
+
output: string;
|
|
85
|
+
}>;
|
|
86
|
+
/** Discard staged rewrites, so a failed fix does not leak into the next one. */
|
|
87
|
+
reset(): Promise<void>;
|
|
88
|
+
/** What was staged, in commit order. */
|
|
89
|
+
staged(): FileChange[];
|
|
90
|
+
}
|
|
91
|
+
/** What the agent proposes to do about one Finding. */
|
|
92
|
+
export interface FixPlan {
|
|
93
|
+
/** Which of the three the owner's scope named. Reported in the note. */
|
|
94
|
+
kind: "dependency" | "source" | "config";
|
|
95
|
+
/** One line, for the merge request title. */
|
|
96
|
+
summary: string;
|
|
97
|
+
/** The commit message body. */
|
|
98
|
+
detail: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Decides what to change for a Finding, and stages it in the workspace.
|
|
102
|
+
*
|
|
103
|
+
* Returning `undefined` means "no strategy for this Finding" — reported in the
|
|
104
|
+
* note as unfixed with that reason, never silently skipped. A planner that
|
|
105
|
+
* throws is treated the same way, with its message as the reason: one Finding
|
|
106
|
+
* with no strategy must not stop the ones behind it.
|
|
107
|
+
*/
|
|
108
|
+
export interface FixPlanner {
|
|
109
|
+
plan(finding: FixableFinding, ws: Workspace): Promise<FixPlan | undefined>;
|
|
110
|
+
}
|
|
111
|
+
/** What happened to one Finding in a Fix pass. */
|
|
112
|
+
export interface FixOutcome {
|
|
113
|
+
finding: FixableFinding;
|
|
114
|
+
/** `fixing` when a merge request opened, `merged` when one already had, else `unfixed`. */
|
|
115
|
+
result: "fixing" | "merged" | "unfixed";
|
|
116
|
+
/** Why it is unfixed. Empty otherwise. Quoted verbatim in the note. */
|
|
117
|
+
reason: string;
|
|
118
|
+
mergeRequest?: MergeRequest;
|
|
119
|
+
plan?: FixPlan;
|
|
120
|
+
}
|
|
121
|
+
/** One line of the console stream, so the ops wall shows the Fix working. */
|
|
122
|
+
export type FixEvent = {
|
|
123
|
+
type: "fix.start";
|
|
124
|
+
application: string;
|
|
125
|
+
commit: string;
|
|
126
|
+
open: number;
|
|
127
|
+
fixing: number;
|
|
128
|
+
} | {
|
|
129
|
+
type: "fix.plan";
|
|
130
|
+
findingId: string;
|
|
131
|
+
kind: FixPlan["kind"];
|
|
132
|
+
summary: string;
|
|
133
|
+
} | {
|
|
134
|
+
type: "fix.tests";
|
|
135
|
+
findingId: string;
|
|
136
|
+
ok: boolean;
|
|
137
|
+
} | {
|
|
138
|
+
type: "fix.mr";
|
|
139
|
+
findingId: string;
|
|
140
|
+
iid: number;
|
|
141
|
+
webUrl: string;
|
|
142
|
+
} | {
|
|
143
|
+
type: "fix.merged";
|
|
144
|
+
findingId: string;
|
|
145
|
+
iid: number;
|
|
146
|
+
} | {
|
|
147
|
+
type: "fix.skip";
|
|
148
|
+
findingId: string;
|
|
149
|
+
reason: string;
|
|
150
|
+
} | {
|
|
151
|
+
type: "fix.error";
|
|
152
|
+
stage: "read" | "status" | "note" | "plan";
|
|
153
|
+
message: string;
|
|
154
|
+
};
|
|
155
|
+
export interface FixSummary {
|
|
156
|
+
application: string;
|
|
157
|
+
commit: string;
|
|
158
|
+
outcomes: FixOutcome[];
|
|
159
|
+
/** True when every Finding that had a strategy ended `fixing` or `merged`. */
|
|
160
|
+
clean: boolean;
|
|
161
|
+
}
|
|
162
|
+
export interface RunFixOptions {
|
|
163
|
+
application: string;
|
|
164
|
+
/** The commit the Scan mission ran against — what the status is set on. */
|
|
165
|
+
commit: string;
|
|
166
|
+
/** The branch merge requests target. */
|
|
167
|
+
targetRef: string;
|
|
168
|
+
findings: FindingSource;
|
|
169
|
+
status: FindingStatusWriter;
|
|
170
|
+
planner: FixPlanner;
|
|
171
|
+
workspace: Workspace;
|
|
172
|
+
gitlab: GitLabWriter;
|
|
173
|
+
/** The pipeline page, linked from the commit status. */
|
|
174
|
+
pipelineUrl?: string;
|
|
175
|
+
/** Cap on merge requests opened in one pass, so a backlog cannot flood review. */
|
|
176
|
+
maxMergeRequests?: number;
|
|
177
|
+
onEvent?: (e: FixEvent) => void;
|
|
178
|
+
}
|
|
179
|
+
/** Default ceiling on merge requests opened by one pass. */
|
|
180
|
+
export declare const DEFAULT_MAX_MERGE_REQUESTS = 10;
|
|
181
|
+
/**
|
|
182
|
+
* Run one Fix pass over an Application.
|
|
183
|
+
*
|
|
184
|
+
* Reconcile first, then fix. A Finding already `fixing` whose merge request has
|
|
185
|
+
* merged becomes `fixed` before anything new is opened, so a pass never opens a
|
|
186
|
+
* second merge request for a Finding the previous pass already resolved.
|
|
187
|
+
*/
|
|
188
|
+
export declare function runFix(opts: RunFixOptions): Promise<FixSummary>;
|
|
189
|
+
/**
|
|
190
|
+
* The branch one Finding's fix lands on. Stable, so a retry reuses it rather
|
|
191
|
+
* than opening a second merge request for the same Finding.
|
|
192
|
+
*
|
|
193
|
+
* The slug keeps only `[a-z0-9-]`. Dots are dropped rather than preserved:
|
|
194
|
+
* git refuses a ref containing `..`, and a vulnerability id is attacker-
|
|
195
|
+
* adjacent text that ends up interpolated into a URL path, so a dot run is
|
|
196
|
+
* exactly the shape not to carry through. The Finding id is appended so two
|
|
197
|
+
* occurrences of one CVE get two branches.
|
|
198
|
+
*/
|
|
199
|
+
export declare function fixBranch(finding: FixableFinding): string;
|
|
200
|
+
/** Counts by severity, worst first, for the note and the status line. */
|
|
201
|
+
export declare function countsBySeverity(findings: FixableFinding[]): Record<string, number>;
|
|
202
|
+
/** The one-line verdict GitLab shows beside the commit. */
|
|
203
|
+
export declare function statusDescription(summary: FixSummary): string;
|
|
204
|
+
/** The merge request description. Names the Finding it closes and how. */
|
|
205
|
+
export declare function mergeRequestBody(finding: FixableFinding, plan: FixPlan): string;
|
|
206
|
+
/** The note posted on each merge request: what this pass did, in full. */
|
|
207
|
+
export declare function noteBody(summary: FixSummary): string;
|
package/dist/fix.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Fix — what the always-on agent does with a Finding (zerocool-plugins#89).
|
|
3
|
+
*
|
|
4
|
+
* After a Scan mission lands, the agent works the Application's open Findings
|
|
5
|
+
* in priority order. For each one it can act on it rewrites the repository,
|
|
6
|
+
* runs the repository's own tests, and only then opens a merge request set to
|
|
7
|
+
* merge itself when the pipeline succeeds. It records the scan verdict on the
|
|
8
|
+
* commit as `gibson/scan` and posts one note saying what it fixed, what it did
|
|
9
|
+
* not, and why.
|
|
10
|
+
*
|
|
11
|
+
* THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
|
|
12
|
+
* Finding: it stops every other fix behind it. So the change is applied to a
|
|
13
|
+
* workspace, the tests run there, and a failure resets the workspace and leaves
|
|
14
|
+
* the Finding `open` with its reason recorded. Nothing unproven reaches GitLab.
|
|
15
|
+
*
|
|
16
|
+
* THE FIX NEVER MARKS A FINDING `verified`. A merge is not evidence that a
|
|
17
|
+
* rescan did not see the weakness again — only the rescan is, and absence is
|
|
18
|
+
* not an observation, so it is reconciled once at a scan's completion
|
|
19
|
+
* (gibson#1686). The Fix moves a Finding `open` → `fixing` when its merge
|
|
20
|
+
* request opens and `fixing` → `fixed` when GitLab reports that request merged.
|
|
21
|
+
*
|
|
22
|
+
* THE TOKEN NEVER APPEARS IN ANYTHING THIS WRITES. It reaches GitLab only as a
|
|
23
|
+
* `PRIVATE-TOKEN` header inside {@link gitlabRestWriter}. No branch name, merge
|
|
24
|
+
* request title, description, note or status description is built from it, and
|
|
25
|
+
* `fix.test.ts` proves that with a sentinel that would show up in every
|
|
26
|
+
* recorded output if it ever did.
|
|
27
|
+
*
|
|
28
|
+
* TWO SEAMS ARE DELIBERATELY UNIMPLEMENTED HERE. Reading an Application's
|
|
29
|
+
* Findings and writing a Finding's status both exist on the Go SDK as of
|
|
30
|
+
* v0.174.0, and on neither the TypeScript SDK nor this package: `@zeroroot-ai/sdk`
|
|
31
|
+
* carries no `applicationFindings` and its `observe` admits only host, domain,
|
|
32
|
+
* subdomain, credential, account and memory. That is sdk-ts#51. Until it lands,
|
|
33
|
+
* {@link FindingSource} and {@link FindingStatusWriter} have no platform
|
|
34
|
+
* implementation, and the Fix is driven by whatever supplies them. This is a
|
|
35
|
+
* seam, not a second code path: when the wire arrives there is one
|
|
36
|
+
* implementation to add and nothing here changes.
|
|
37
|
+
*/
|
|
38
|
+
/** Severities, worst first. Used for the note's counts and for a stable order. */
|
|
39
|
+
export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
40
|
+
/** Default ceiling on merge requests opened by one pass. */
|
|
41
|
+
export const DEFAULT_MAX_MERGE_REQUESTS = 10;
|
|
42
|
+
/**
|
|
43
|
+
* Run one Fix pass over an Application.
|
|
44
|
+
*
|
|
45
|
+
* Reconcile first, then fix. A Finding already `fixing` whose merge request has
|
|
46
|
+
* merged becomes `fixed` before anything new is opened, so a pass never opens a
|
|
47
|
+
* second merge request for a Finding the previous pass already resolved.
|
|
48
|
+
*/
|
|
49
|
+
export async function runFix(opts) {
|
|
50
|
+
const emit = (e) => opts.onEvent?.(e);
|
|
51
|
+
const cap = opts.maxMergeRequests ?? DEFAULT_MAX_MERGE_REQUESTS;
|
|
52
|
+
const outcomes = [];
|
|
53
|
+
const all = await opts.findings.findings(opts.application, ["open", "fixing"]);
|
|
54
|
+
const fixing = all.filter((f) => f.status === "fixing");
|
|
55
|
+
const open = all.filter((f) => f.status === "open");
|
|
56
|
+
emit({ type: "fix.start", application: opts.application, commit: opts.commit, open: open.length, fixing: fixing.length });
|
|
57
|
+
// Reconcile: a merge request that merged moves its Finding to `fixed`.
|
|
58
|
+
for (const finding of fixing) {
|
|
59
|
+
if (finding.mergeRequestIid === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
const mr = await opts.gitlab.mergeRequestState(finding.mergeRequestIid);
|
|
63
|
+
if (mr.state !== "merged")
|
|
64
|
+
continue;
|
|
65
|
+
await opts.status.markFixed(finding.id);
|
|
66
|
+
emit({ type: "fix.merged", findingId: finding.id, iid: mr.iid });
|
|
67
|
+
outcomes.push({ finding, result: "merged", reason: "", mergeRequest: mr });
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
emit({ type: "fix.error", stage: "status", message: message(e) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let opened = 0;
|
|
74
|
+
for (const finding of open) {
|
|
75
|
+
if (opened >= cap) {
|
|
76
|
+
outcomes.push({ finding, result: "unfixed", reason: `merge request cap of ${cap} reached in this pass` });
|
|
77
|
+
emit({ type: "fix.skip", findingId: finding.id, reason: "cap reached" });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let plan;
|
|
81
|
+
try {
|
|
82
|
+
plan = await opts.planner.plan(finding, opts.workspace);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
await safeReset(opts.workspace);
|
|
86
|
+
outcomes.push({ finding, result: "unfixed", reason: `planning failed: ${message(e)}` });
|
|
87
|
+
emit({ type: "fix.error", stage: "plan", message: message(e) });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!plan) {
|
|
91
|
+
await safeReset(opts.workspace);
|
|
92
|
+
outcomes.push({ finding, result: "unfixed", reason: "no fix strategy for this finding" });
|
|
93
|
+
emit({ type: "fix.skip", findingId: finding.id, reason: "no fix strategy" });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const changes = opts.workspace.staged();
|
|
97
|
+
if (changes.length === 0) {
|
|
98
|
+
await safeReset(opts.workspace);
|
|
99
|
+
outcomes.push({ finding, result: "unfixed", reason: "the planner changed nothing" });
|
|
100
|
+
emit({ type: "fix.skip", findingId: finding.id, reason: "no change staged" });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
emit({ type: "fix.plan", findingId: finding.id, kind: plan.kind, summary: plan.summary });
|
|
104
|
+
const tests = await opts.workspace.test();
|
|
105
|
+
emit({ type: "fix.tests", findingId: finding.id, ok: tests.ok });
|
|
106
|
+
if (!tests.ok) {
|
|
107
|
+
await safeReset(opts.workspace);
|
|
108
|
+
outcomes.push({ finding, result: "unfixed", plan, reason: `the repository's tests failed: ${firstLines(tests.output)}` });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const branch = fixBranch(finding);
|
|
113
|
+
await opts.gitlab.commitToBranch(branch, opts.targetRef, `${plan.summary}\n\n${plan.detail}`, changes);
|
|
114
|
+
const mr = await opts.gitlab.openMergeRequest(branch, opts.targetRef, plan.summary, mergeRequestBody(finding, plan));
|
|
115
|
+
emit({ type: "fix.mr", findingId: finding.id, iid: mr.iid, webUrl: mr.webUrl });
|
|
116
|
+
opened++;
|
|
117
|
+
await opts.status.markFixing(finding.id, mr);
|
|
118
|
+
outcomes.push({ finding, result: "fixing", reason: "", mergeRequest: mr, plan });
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
outcomes.push({ finding, result: "unfixed", plan, reason: `GitLab refused the change: ${message(e)}` });
|
|
122
|
+
emit({ type: "fix.error", stage: "status", message: message(e) });
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await safeReset(opts.workspace);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const summary = {
|
|
129
|
+
application: opts.application,
|
|
130
|
+
commit: opts.commit,
|
|
131
|
+
outcomes,
|
|
132
|
+
clean: outcomes.every((o) => o.result !== "unfixed"),
|
|
133
|
+
};
|
|
134
|
+
// The verdict lands on the commit that was scanned, whatever happened above.
|
|
135
|
+
// A pass that could not reach GitLab for the status still reports its work
|
|
136
|
+
// through the return value, so the caller is never told the pass did nothing.
|
|
137
|
+
try {
|
|
138
|
+
await opts.gitlab.setCommitStatus(opts.commit, summary.clean ? "success" : "failed", statusDescription(summary), opts.pipelineUrl);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
emit({ type: "fix.error", stage: "note", message: message(e) });
|
|
142
|
+
}
|
|
143
|
+
for (const outcome of outcomes) {
|
|
144
|
+
if (outcome.result !== "fixing" || !outcome.mergeRequest)
|
|
145
|
+
continue;
|
|
146
|
+
try {
|
|
147
|
+
await opts.gitlab.comment(outcome.mergeRequest.iid, noteBody(summary));
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
emit({ type: "fix.error", stage: "note", message: message(e) });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return summary;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The branch one Finding's fix lands on. Stable, so a retry reuses it rather
|
|
157
|
+
* than opening a second merge request for the same Finding.
|
|
158
|
+
*
|
|
159
|
+
* The slug keeps only `[a-z0-9-]`. Dots are dropped rather than preserved:
|
|
160
|
+
* git refuses a ref containing `..`, and a vulnerability id is attacker-
|
|
161
|
+
* adjacent text that ends up interpolated into a URL path, so a dot run is
|
|
162
|
+
* exactly the shape not to carry through. The Finding id is appended so two
|
|
163
|
+
* occurrences of one CVE get two branches.
|
|
164
|
+
*/
|
|
165
|
+
export function fixBranch(finding) {
|
|
166
|
+
const slug = `${finding.vulnerabilityId || finding.id}`
|
|
167
|
+
.toLowerCase()
|
|
168
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
169
|
+
.replace(/^-+|-+$/g, "");
|
|
170
|
+
return `gibson/fix-${slug || "unclassified"}-${finding.id.slice(0, 8)}`;
|
|
171
|
+
}
|
|
172
|
+
/** Counts by severity, worst first, for the note and the status line. */
|
|
173
|
+
export function countsBySeverity(findings) {
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const s of SEVERITIES) {
|
|
176
|
+
const n = findings.filter((f) => f.severity.toLowerCase() === s).length;
|
|
177
|
+
if (n > 0)
|
|
178
|
+
out[s] = n;
|
|
179
|
+
}
|
|
180
|
+
const known = new Set(SEVERITIES);
|
|
181
|
+
const other = findings.filter((f) => !known.has(f.severity.toLowerCase())).length;
|
|
182
|
+
if (other > 0)
|
|
183
|
+
out.other = other;
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/** The one-line verdict GitLab shows beside the commit. */
|
|
187
|
+
export function statusDescription(summary) {
|
|
188
|
+
const fixing = summary.outcomes.filter((o) => o.result === "fixing").length;
|
|
189
|
+
const merged = summary.outcomes.filter((o) => o.result === "merged").length;
|
|
190
|
+
const unfixed = summary.outcomes.filter((o) => o.result === "unfixed").length;
|
|
191
|
+
if (summary.outcomes.length === 0)
|
|
192
|
+
return "no open findings";
|
|
193
|
+
return `${fixing} merge request(s) opened, ${merged} merged, ${unfixed} left for a human`;
|
|
194
|
+
}
|
|
195
|
+
/** The merge request description. Names the Finding it closes and how. */
|
|
196
|
+
export function mergeRequestBody(finding, plan) {
|
|
197
|
+
return [
|
|
198
|
+
plan.detail,
|
|
199
|
+
"",
|
|
200
|
+
`Finding: \`${finding.id}\``,
|
|
201
|
+
`Vulnerability: \`${finding.vulnerabilityId || "unclassified"}\``,
|
|
202
|
+
`Where: ${finding.placeLabel} \`${finding.placeKey}\``,
|
|
203
|
+
`Severity: ${finding.severity}${finding.priority ? ` · priority ${finding.priority}` : ""}`,
|
|
204
|
+
"",
|
|
205
|
+
"Opened by the Gibson always-on agent. It merges itself when the pipeline",
|
|
206
|
+
"succeeds. The finding is marked fixed on merge and verified only by a",
|
|
207
|
+
"later rescan.",
|
|
208
|
+
].join("\n");
|
|
209
|
+
}
|
|
210
|
+
/** The note posted on each merge request: what this pass did, in full. */
|
|
211
|
+
export function noteBody(summary) {
|
|
212
|
+
const lines = [
|
|
213
|
+
`### Gibson scan of \`${summary.commit.slice(0, 12)}\` on ${summary.application}`,
|
|
214
|
+
"",
|
|
215
|
+
];
|
|
216
|
+
const counts = countsBySeverity(summary.outcomes.map((o) => o.finding));
|
|
217
|
+
const rendered = Object.entries(counts)
|
|
218
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
219
|
+
.join(", ");
|
|
220
|
+
lines.push(rendered ? `Findings this pass: ${rendered}.` : "No open findings this pass.");
|
|
221
|
+
lines.push("");
|
|
222
|
+
const fixed = summary.outcomes.filter((o) => o.result === "fixing" || o.result === "merged");
|
|
223
|
+
if (fixed.length > 0) {
|
|
224
|
+
lines.push("**Fixed**");
|
|
225
|
+
for (const o of fixed) {
|
|
226
|
+
const where = `${o.finding.placeLabel} \`${o.finding.placeKey}\``;
|
|
227
|
+
const link = o.mergeRequest?.webUrl ? ` — ${o.mergeRequest.webUrl}` : "";
|
|
228
|
+
lines.push(`- ${o.finding.vulnerabilityId || o.finding.id} in ${where}${link}`);
|
|
229
|
+
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
}
|
|
232
|
+
const unfixed = summary.outcomes.filter((o) => o.result === "unfixed");
|
|
233
|
+
if (unfixed.length > 0) {
|
|
234
|
+
lines.push("**Left for a human**");
|
|
235
|
+
for (const o of unfixed) {
|
|
236
|
+
const where = `${o.finding.placeLabel} \`${o.finding.placeKey}\``;
|
|
237
|
+
lines.push(`- ${o.finding.vulnerabilityId || o.finding.id} in ${where} — ${o.reason}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
242
|
+
async function safeReset(ws) {
|
|
243
|
+
try {
|
|
244
|
+
await ws.reset();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// A reset that fails is reported by the next planner call changing nothing.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function firstLines(output, n = 3) {
|
|
251
|
+
return output.split("\n").slice(0, n).join(" ").trim();
|
|
252
|
+
}
|
|
253
|
+
function message(e) {
|
|
254
|
+
return e instanceof Error ? e.message : String(e);
|
|
255
|
+
}
|
package/dist/gitlab.d.ts
CHANGED
|
@@ -72,3 +72,47 @@ export declare function parsePipeline(raw: unknown): Pipeline | undefined;
|
|
|
72
72
|
* request.
|
|
73
73
|
*/
|
|
74
74
|
export declare function gitlabRest(opts: GitLabRestOptions): GitLabClient;
|
|
75
|
+
/** The `gibson/scan` commit status states GitLab accepts, narrowed to what we set. */
|
|
76
|
+
export type CommitStatusState = "success" | "failed" | "running";
|
|
77
|
+
/** The context name the scan status is reported under, named once. */
|
|
78
|
+
export declare const SCAN_STATUS_CONTEXT = "gibson/scan";
|
|
79
|
+
/** A merge request the Fix opened, reduced to what the Fix and a human need. */
|
|
80
|
+
export interface MergeRequest {
|
|
81
|
+
/** The project-scoped iid, which is what every later GitLab call takes. */
|
|
82
|
+
iid: number;
|
|
83
|
+
/** The page a human opens. Also what the Finding records. */
|
|
84
|
+
webUrl: string;
|
|
85
|
+
/** GitLab's own state: `opened`, `merged`, `closed`, `locked`. */
|
|
86
|
+
state: string;
|
|
87
|
+
}
|
|
88
|
+
/** One file the Fix rewrote, as the commit API wants it. */
|
|
89
|
+
export interface FileChange {
|
|
90
|
+
path: string;
|
|
91
|
+
content: string;
|
|
92
|
+
}
|
|
93
|
+
/** The GitLab writes the Fix makes. Kept behind an interface so a test needs no network. */
|
|
94
|
+
export interface GitLabWriter {
|
|
95
|
+
/**
|
|
96
|
+
* Commit `changes` onto a new branch off `startRef`. Returns the commit sha.
|
|
97
|
+
* The branch is created by the same call, so a half-made branch with no
|
|
98
|
+
* commit is not a state this can leave behind.
|
|
99
|
+
*/
|
|
100
|
+
commitToBranch(branch: string, startRef: string, message: string, changes: FileChange[]): Promise<string>;
|
|
101
|
+
/** Open a merge request set to merge itself when its pipeline succeeds. */
|
|
102
|
+
openMergeRequest(branch: string, targetRef: string, title: string, description: string): Promise<MergeRequest>;
|
|
103
|
+
/** The current state of a merge request, so a later pass can see it merged. */
|
|
104
|
+
mergeRequestState(iid: number): Promise<MergeRequest>;
|
|
105
|
+
/** Post a note on a merge request. */
|
|
106
|
+
comment(iid: number, body: string): Promise<void>;
|
|
107
|
+
/** Set the `gibson/scan` status on a commit. */
|
|
108
|
+
setCommitStatus(sha: string, state: CommitStatusState, description: string, targetUrl?: string): Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A {@link GitLabWriter} over GitLab's REST API.
|
|
112
|
+
*
|
|
113
|
+
* Every request carries the token in `PRIVATE-TOKEN` and never in a URL or a
|
|
114
|
+
* body, so a failure message that quotes the request cannot carry it into the
|
|
115
|
+
* console stream. The error text names the project, the branch and the status
|
|
116
|
+
* code — never a header.
|
|
117
|
+
*/
|
|
118
|
+
export declare function gitlabRestWriter(opts: GitLabRestOptions): GitLabWriter;
|
package/dist/gitlab.js
CHANGED
|
@@ -80,3 +80,97 @@ export function gitlabRest(opts) {
|
|
|
80
80
|
},
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
|
+
/** The context name the scan status is reported under, named once. */
|
|
84
|
+
export const SCAN_STATUS_CONTEXT = "gibson/scan";
|
|
85
|
+
/**
|
|
86
|
+
* A {@link GitLabWriter} over GitLab's REST API.
|
|
87
|
+
*
|
|
88
|
+
* Every request carries the token in `PRIVATE-TOKEN` and never in a URL or a
|
|
89
|
+
* body, so a failure message that quotes the request cannot carry it into the
|
|
90
|
+
* console stream. The error text names the project, the branch and the status
|
|
91
|
+
* code — never a header.
|
|
92
|
+
*/
|
|
93
|
+
export function gitlabRestWriter(opts) {
|
|
94
|
+
const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
|
|
95
|
+
const project = encodeURIComponent(opts.projectPath);
|
|
96
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
97
|
+
const api = `${base}/api/v4/projects/${project}`;
|
|
98
|
+
async function call(method, path, body) {
|
|
99
|
+
const res = await doFetch(`${api}${path}`, {
|
|
100
|
+
method,
|
|
101
|
+
headers: {
|
|
102
|
+
"PRIVATE-TOKEN": opts.token,
|
|
103
|
+
Accept: "application/json",
|
|
104
|
+
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
105
|
+
},
|
|
106
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
throw new Error(`GitLab ${method} ${path} on ${opts.projectPath} returned ${res.status} ${res.statusText}`);
|
|
110
|
+
}
|
|
111
|
+
return res.json();
|
|
112
|
+
}
|
|
113
|
+
const asMergeRequest = (raw, branch) => {
|
|
114
|
+
const mr = (raw ?? {});
|
|
115
|
+
const iid = typeof mr.iid === "number" ? mr.iid : Number.NaN;
|
|
116
|
+
if (!Number.isInteger(iid) || iid <= 0) {
|
|
117
|
+
throw new Error(`GitLab returned a merge request for ${branch} with no iid`);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
iid,
|
|
121
|
+
webUrl: typeof mr.web_url === "string" ? mr.web_url : "",
|
|
122
|
+
state: typeof mr.state === "string" ? mr.state : "",
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
async commitToBranch(branch, startRef, message, changes) {
|
|
127
|
+
if (changes.length === 0) {
|
|
128
|
+
throw new Error(`refusing to commit an empty change set to ${branch}`);
|
|
129
|
+
}
|
|
130
|
+
const raw = await call("POST", "/repository/commits", {
|
|
131
|
+
branch,
|
|
132
|
+
start_branch: startRef,
|
|
133
|
+
commit_message: message,
|
|
134
|
+
actions: changes.map((c) => ({ action: "update", file_path: c.path, content: c.content })),
|
|
135
|
+
});
|
|
136
|
+
const sha = raw.id;
|
|
137
|
+
if (typeof sha !== "string" || !sha) {
|
|
138
|
+
throw new Error(`GitLab accepted the commit on ${branch} but returned no sha`);
|
|
139
|
+
}
|
|
140
|
+
return sha;
|
|
141
|
+
},
|
|
142
|
+
async openMergeRequest(branch, targetRef, title, description) {
|
|
143
|
+
const created = asMergeRequest(await call("POST", "/merge_requests", {
|
|
144
|
+
source_branch: branch,
|
|
145
|
+
target_branch: targetRef,
|
|
146
|
+
title,
|
|
147
|
+
description,
|
|
148
|
+
remove_source_branch: true,
|
|
149
|
+
}), branch);
|
|
150
|
+
// Auto-merge is a separate call, and a refusal is not fatal: the merge
|
|
151
|
+
// request is open and correct either way, and a human can merge it. A
|
|
152
|
+
// thrown error here would lose a good merge request over a race with the
|
|
153
|
+
// pipeline that has not started yet.
|
|
154
|
+
try {
|
|
155
|
+
await call("PUT", `/merge_requests/${created.iid}/merge`, { merge_when_pipeline_succeeds: true });
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Reported by the caller through the note, not swallowed silently.
|
|
159
|
+
}
|
|
160
|
+
return created;
|
|
161
|
+
},
|
|
162
|
+
mergeRequestState: async (iid) => asMergeRequest(await call("GET", `/merge_requests/${iid}`), `!${iid}`),
|
|
163
|
+
comment: async (iid, body) => {
|
|
164
|
+
await call("POST", `/merge_requests/${iid}/notes`, { body });
|
|
165
|
+
},
|
|
166
|
+
setCommitStatus: async (sha, state, description, targetUrl) => {
|
|
167
|
+
await call("POST", `/statuses/${encodeURIComponent(sha)}`, {
|
|
168
|
+
state,
|
|
169
|
+
name: SCAN_STATUS_CONTEXT,
|
|
170
|
+
context: SCAN_STATUS_CONTEXT,
|
|
171
|
+
description,
|
|
172
|
+
...(targetUrl ? { target_url: targetUrl } : {}),
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
package/dist/watch.d.ts
CHANGED
|
@@ -170,13 +170,18 @@ export interface ScanMissionInputs {
|
|
|
170
170
|
/**
|
|
171
171
|
* The Scan mission definition, as canonical proto JSON.
|
|
172
172
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
173
|
+
* STILL BUILT HERE, AND IT SHOULD NOT BE. gibson#1685 landed the Scan mission
|
|
174
|
+
* as a checked-in definition in gibson's `missioncatalog`, and ADR-0018 makes
|
|
175
|
+
* that copy authoritative: this function is meant to become a mission name plus
|
|
176
|
+
* seven parameters, not a second description of the same graph.
|
|
177
|
+
*
|
|
178
|
+
* It cannot yet. `missioncatalog.Render` has no production caller, and
|
|
179
|
+
* `CreateMission` carries only `mission_definition_json` — there is no field
|
|
180
|
+
* naming a checked-in mission and none carrying its parameters, so an agent has
|
|
181
|
+
* no way to ask for the catalog's copy. That is gibson#1688. Until it lands the
|
|
182
|
+
* only way to originate a Scan is to send a graph, so this one stays, with the
|
|
183
|
+
* shape the catalog definition expects: everything the fan-out needs is carried
|
|
184
|
+
* in the node's task context, and the swap is this function's body alone.
|
|
180
185
|
*
|
|
181
186
|
* The definition is built as protojson by hand rather than through the
|
|
182
187
|
* generated schema because the SDK's package exports only its index, and the
|
package/dist/watch.js
CHANGED
|
@@ -225,13 +225,18 @@ export function checkpointQueryText(application) {
|
|
|
225
225
|
/**
|
|
226
226
|
* The Scan mission definition, as canonical proto JSON.
|
|
227
227
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
228
|
+
* STILL BUILT HERE, AND IT SHOULD NOT BE. gibson#1685 landed the Scan mission
|
|
229
|
+
* as a checked-in definition in gibson's `missioncatalog`, and ADR-0018 makes
|
|
230
|
+
* that copy authoritative: this function is meant to become a mission name plus
|
|
231
|
+
* seven parameters, not a second description of the same graph.
|
|
232
|
+
*
|
|
233
|
+
* It cannot yet. `missioncatalog.Render` has no production caller, and
|
|
234
|
+
* `CreateMission` carries only `mission_definition_json` — there is no field
|
|
235
|
+
* naming a checked-in mission and none carrying its parameters, so an agent has
|
|
236
|
+
* no way to ask for the catalog's copy. That is gibson#1688. Until it lands the
|
|
237
|
+
* only way to originate a Scan is to send a graph, so this one stays, with the
|
|
238
|
+
* shape the catalog definition expects: everything the fan-out needs is carried
|
|
239
|
+
* in the node's task context, and the swap is this function's body alone.
|
|
235
240
|
*
|
|
236
241
|
* The definition is built as protojson by hand rather than through the
|
|
237
242
|
* generated schema because the SDK's package exports only its index, and the
|
package/package.json
CHANGED