@zeroroot-ai/zerocool 0.3.1 → 0.5.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/dispatch.d.ts +116 -1
- package/dist/dispatch.js +319 -3
- package/dist/findings.d.ts +9 -1
- package/dist/findings.js +36 -0
- package/dist/gitlab.d.ts +74 -0
- package/dist/gitlab.js +82 -0
- package/dist/knowledge-source.js +2 -2
- package/dist/semgrep.d.ts +76 -0
- package/dist/semgrep.js +111 -0
- package/dist/source-analysis.d.ts +134 -0
- package/dist/source-analysis.js +251 -0
- package/dist/watch.d.ts +214 -0
- package/dist/watch.js +341 -0
- package/package.json +4 -3
- package/semgrep/rules.yaml +178 -0
package/dist/gitlab.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
|
|
3
|
+
*
|
|
4
|
+
* The agent polls one project's pipelines on one branch. It never receives a
|
|
5
|
+
* webhook and GitLab holds no Gibson credential: the poll runs outbound from
|
|
6
|
+
* the sandbox on the tenant's own project access token, resolved through the
|
|
7
|
+
* harness `GetCredential` under the dispatch grant. That direction is the point
|
|
8
|
+
* — a CI job cannot originate a Gibson mission (ADR-0063 admits a component
|
|
9
|
+
* only from inside a mission it was dispatched to), so the agent watches
|
|
10
|
+
* GitLab rather than GitLab calling Gibson.
|
|
11
|
+
*
|
|
12
|
+
* FINISHED MEANS `success`. A pipeline that failed published no image and left
|
|
13
|
+
* `main` unchanged, so there is nothing new to scan; treating it as a trigger
|
|
14
|
+
* would run a Scan mission against the image the previous pipeline built and
|
|
15
|
+
* report its findings against the new commit. `canceled` and `skipped` are the
|
|
16
|
+
* same case. The status this polls for is therefore `success`, named once in
|
|
17
|
+
* {@link TRIGGER_STATUS} rather than spelled at each call site.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is one `fetch` away from pure: {@link gitlabRest} takes the
|
|
20
|
+
* fetch it uses, so `watch.test.ts` drives a whole loop against a stub with no
|
|
21
|
+
* network and no token.
|
|
22
|
+
*/
|
|
23
|
+
/** The pipeline status that triggers a Scan mission. See the module note. */
|
|
24
|
+
export const TRIGGER_STATUS = "success";
|
|
25
|
+
/** GitLab's public host, when a tenant names no other instance. */
|
|
26
|
+
export const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
|
27
|
+
/**
|
|
28
|
+
* Read one pipeline out of GitLab's JSON. A row without an id or a sha is
|
|
29
|
+
* dropped rather than guessed at: a Scan mission keyed on a missing commit
|
|
30
|
+
* would scan whatever the workspace happened to hold.
|
|
31
|
+
*/
|
|
32
|
+
export function parsePipeline(raw) {
|
|
33
|
+
if (!raw || typeof raw !== "object")
|
|
34
|
+
return undefined;
|
|
35
|
+
const p = raw;
|
|
36
|
+
const id = typeof p.id === "number" ? p.id : Number.NaN;
|
|
37
|
+
const sha = typeof p.sha === "string" ? p.sha : "";
|
|
38
|
+
if (!Number.isInteger(id) || id <= 0 || !sha)
|
|
39
|
+
return undefined;
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
sha,
|
|
43
|
+
status: typeof p.status === "string" ? p.status : "",
|
|
44
|
+
ref: typeof p.ref === "string" ? p.ref : "",
|
|
45
|
+
webUrl: typeof p.web_url === "string" ? p.web_url : "",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A {@link GitLabClient} over GitLab's REST API.
|
|
50
|
+
*
|
|
51
|
+
* The token travels in `PRIVATE-TOKEN`, which is what a project access token
|
|
52
|
+
* authenticates with. It is never logged and never placed in a URL, so it
|
|
53
|
+
* cannot reach the console stream through an error message that quotes the
|
|
54
|
+
* request.
|
|
55
|
+
*/
|
|
56
|
+
export function gitlabRest(opts) {
|
|
57
|
+
const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
|
|
58
|
+
const project = encodeURIComponent(opts.projectPath);
|
|
59
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
60
|
+
return {
|
|
61
|
+
async latestFinishedPipeline(ref) {
|
|
62
|
+
const url = `${base}/api/v4/projects/${project}/pipelines` +
|
|
63
|
+
`?ref=${encodeURIComponent(ref)}&status=${TRIGGER_STATUS}&order_by=id&sort=desc&per_page=1`;
|
|
64
|
+
const res = await doFetch(url, {
|
|
65
|
+
headers: { "PRIVATE-TOKEN": opts.token, Accept: "application/json" },
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${res.status} ${res.statusText}`);
|
|
69
|
+
}
|
|
70
|
+
const body = await res.json();
|
|
71
|
+
if (!Array.isArray(body)) {
|
|
72
|
+
throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${typeof body}, expected an array`);
|
|
73
|
+
}
|
|
74
|
+
for (const row of body) {
|
|
75
|
+
const p = parsePipeline(row);
|
|
76
|
+
if (p)
|
|
77
|
+
return p;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/dist/knowledge-source.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { componentKnowledge,
|
|
1
|
+
import { componentKnowledge, openTaskHarness, taskKnowledge, } from "@zeroroot-ai/sdk";
|
|
2
2
|
/**
|
|
3
3
|
* Pick the grant this process should read the knowledge graph with.
|
|
4
4
|
*
|
|
@@ -27,7 +27,7 @@ export function selectKnowledgeSource(session) {
|
|
|
27
27
|
"widen this run's authority.");
|
|
28
28
|
}
|
|
29
29
|
return {
|
|
30
|
-
knowledge: taskKnowledge(
|
|
30
|
+
knowledge: taskKnowledge(openTaskHarness({
|
|
31
31
|
endpoint,
|
|
32
32
|
token,
|
|
33
33
|
// A local or kind daemon may serve the callback listener without TLS.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* semgrep — the candidate producer for the source-analysis task
|
|
3
|
+
* (zerocool-plugins#87).
|
|
4
|
+
*
|
|
5
|
+
* semgrep runs the vendored ruleset (`semgrep/rules.yaml`, pinned with the
|
|
6
|
+
* package, never fetched) over a checkout and prints one JSON document. The
|
|
7
|
+
* parser below is pure and is tested against bytes recorded from semgrep
|
|
8
|
+
* 1.175.0 over the fixture repository, so a change in semgrep's output fails a
|
|
9
|
+
* unit test rather than a Scan mission.
|
|
10
|
+
*
|
|
11
|
+
* A match is a CANDIDATE, not a Finding. The model triages each one in
|
|
12
|
+
* `source-analysis.ts`; nothing here decides.
|
|
13
|
+
*/
|
|
14
|
+
/** One semgrep match, reduced to what triage and a Finding need. */
|
|
15
|
+
export interface SemgrepCandidate {
|
|
16
|
+
/** The rule id, e.g. `rules.js-eval-of-request-input`. */
|
|
17
|
+
checkId: string;
|
|
18
|
+
/** Repository-relative path, as semgrep printed it. */
|
|
19
|
+
path: string;
|
|
20
|
+
line: number;
|
|
21
|
+
endLine: number;
|
|
22
|
+
message: string;
|
|
23
|
+
/** semgrep's own level: ERROR, WARNING or INFO. */
|
|
24
|
+
severity: string;
|
|
25
|
+
/** CWE strings from the rule's metadata, verbatim (`CWE-79: ...`). */
|
|
26
|
+
cwe: string[];
|
|
27
|
+
/** The rule's `metadata.category`, when it set one. */
|
|
28
|
+
category?: string;
|
|
29
|
+
/** The matched source lines. */
|
|
30
|
+
snippet: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse the JSON `semgrep scan --json` writes. Throws when the document is not
|
|
34
|
+
* JSON or carries an error semgrep itself marked as an error, because a partial
|
|
35
|
+
* candidate list would look like a clean checkout.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseSemgrepOutput(stdout: string): SemgrepCandidate[];
|
|
38
|
+
/**
|
|
39
|
+
* semgrep prefixes a local rule's id with the config file's path, dot-joined
|
|
40
|
+
* (`app.semgrep.js-eval-any` in the image, `rules.js-eval-any` from a relative
|
|
41
|
+
* config). The rule is the last segment; the prefix is where the file sat. A
|
|
42
|
+
* Finding must name the rule, not the install path, so the prefix is dropped.
|
|
43
|
+
*/
|
|
44
|
+
export declare function bareRuleId(checkId: string): string;
|
|
45
|
+
/** The vendored ruleset, resolved from the package it ships with. */
|
|
46
|
+
export declare function defaultRulesPath(): string;
|
|
47
|
+
export interface SemgrepRunOptions {
|
|
48
|
+
/** The checkout to scan. semgrep runs with this as its cwd and `.` as the target. */
|
|
49
|
+
dir: string;
|
|
50
|
+
/** Ruleset path. Defaults to the vendored one. */
|
|
51
|
+
rules?: string;
|
|
52
|
+
/** Binary to run. Overridable for a pinned install and for tests. */
|
|
53
|
+
bin?: string;
|
|
54
|
+
/** Hard deadline. The child is killed when it elapses. */
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the argv. Exported so a test can assert it.
|
|
59
|
+
*
|
|
60
|
+
* The target is `.` and the cwd is the checkout, on purpose: semgrep reports
|
|
61
|
+
* paths relative to the target, so a Finding's `file` is repository-relative
|
|
62
|
+
* rather than a sandbox path, and semgrep's default ignore list (which skips
|
|
63
|
+
* `test/` directories anywhere in the path) is judged against the checkout,
|
|
64
|
+
* not against wherever the sandbox mounted it.
|
|
65
|
+
*/
|
|
66
|
+
export declare function semgrepArgs(opts: SemgrepRunOptions): string[];
|
|
67
|
+
/** A function that yields candidates for a checkout. The seam tests stub. */
|
|
68
|
+
export type SemgrepRunner = (opts: SemgrepRunOptions) => Promise<SemgrepCandidate[]>;
|
|
69
|
+
/**
|
|
70
|
+
* Run semgrep once over a checkout and return the candidates.
|
|
71
|
+
*
|
|
72
|
+
* semgrep exits 0 when the scan ran, with or without matches; anything else is
|
|
73
|
+
* a failed scan and throws with semgrep's stderr tail, so a task never reports
|
|
74
|
+
* "no findings" for a scan that did not happen.
|
|
75
|
+
*/
|
|
76
|
+
export declare const runSemgrep: SemgrepRunner;
|
package/dist/semgrep.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
/**
|
|
4
|
+
* Parse the JSON `semgrep scan --json` writes. Throws when the document is not
|
|
5
|
+
* JSON or carries an error semgrep itself marked as an error, because a partial
|
|
6
|
+
* candidate list would look like a clean checkout.
|
|
7
|
+
*/
|
|
8
|
+
export function parseSemgrepOutput(stdout) {
|
|
9
|
+
let doc;
|
|
10
|
+
try {
|
|
11
|
+
doc = JSON.parse(stdout);
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
throw new Error(`semgrep output is not JSON: ${e.message}`);
|
|
15
|
+
}
|
|
16
|
+
const fatal = (doc.errors ?? []).filter((err) => (err.level ?? "error") === "error");
|
|
17
|
+
if (fatal.length > 0) {
|
|
18
|
+
throw new Error(`semgrep reported ${fatal.length} error(s): ${fatal.map((err) => err.message ?? "?").join("; ")}`);
|
|
19
|
+
}
|
|
20
|
+
return (doc.results ?? []).map((r) => {
|
|
21
|
+
const cweRaw = r.extra?.metadata?.cwe;
|
|
22
|
+
const cwe = Array.isArray(cweRaw) ? cweRaw : cweRaw ? [cweRaw] : [];
|
|
23
|
+
return {
|
|
24
|
+
checkId: bareRuleId(r.check_id ?? ""),
|
|
25
|
+
path: r.path ?? "",
|
|
26
|
+
line: r.start?.line ?? 0,
|
|
27
|
+
endLine: r.end?.line ?? r.start?.line ?? 0,
|
|
28
|
+
message: (r.extra?.message ?? "").trim(),
|
|
29
|
+
severity: r.extra?.severity ?? "",
|
|
30
|
+
cwe,
|
|
31
|
+
...(r.extra?.metadata?.category ? { category: r.extra.metadata.category } : {}),
|
|
32
|
+
snippet: r.extra?.lines ?? "",
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* semgrep prefixes a local rule's id with the config file's path, dot-joined
|
|
38
|
+
* (`app.semgrep.js-eval-any` in the image, `rules.js-eval-any` from a relative
|
|
39
|
+
* config). The rule is the last segment; the prefix is where the file sat. A
|
|
40
|
+
* Finding must name the rule, not the install path, so the prefix is dropped.
|
|
41
|
+
*/
|
|
42
|
+
export function bareRuleId(checkId) {
|
|
43
|
+
const i = checkId.lastIndexOf(".");
|
|
44
|
+
return i >= 0 ? checkId.slice(i + 1) : checkId;
|
|
45
|
+
}
|
|
46
|
+
/** The vendored ruleset, resolved from the package it ships with. */
|
|
47
|
+
export function defaultRulesPath() {
|
|
48
|
+
return fileURLToPath(new URL("../semgrep/rules.yaml", import.meta.url));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the argv. Exported so a test can assert it.
|
|
52
|
+
*
|
|
53
|
+
* The target is `.` and the cwd is the checkout, on purpose: semgrep reports
|
|
54
|
+
* paths relative to the target, so a Finding's `file` is repository-relative
|
|
55
|
+
* rather than a sandbox path, and semgrep's default ignore list (which skips
|
|
56
|
+
* `test/` directories anywhere in the path) is judged against the checkout,
|
|
57
|
+
* not against wherever the sandbox mounted it.
|
|
58
|
+
*/
|
|
59
|
+
export function semgrepArgs(opts) {
|
|
60
|
+
return [
|
|
61
|
+
"scan",
|
|
62
|
+
"--json",
|
|
63
|
+
"--quiet",
|
|
64
|
+
"--metrics=off",
|
|
65
|
+
"--disable-version-check",
|
|
66
|
+
"--config",
|
|
67
|
+
opts.rules ?? defaultRulesPath(),
|
|
68
|
+
".",
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Run semgrep once over a checkout and return the candidates.
|
|
73
|
+
*
|
|
74
|
+
* semgrep exits 0 when the scan ran, with or without matches; anything else is
|
|
75
|
+
* a failed scan and throws with semgrep's stderr tail, so a task never reports
|
|
76
|
+
* "no findings" for a scan that did not happen.
|
|
77
|
+
*/
|
|
78
|
+
export const runSemgrep = async (opts) => {
|
|
79
|
+
const bin = opts.bin ?? process.env.ZEROCOOL_SEMGREP_BIN ?? "semgrep";
|
|
80
|
+
const args = semgrepArgs(opts);
|
|
81
|
+
return await new Promise((resolve, reject) => {
|
|
82
|
+
const child = spawn(bin, args, {
|
|
83
|
+
cwd: opts.dir,
|
|
84
|
+
env: { ...process.env, SEMGREP_SEND_METRICS: "off", SEMGREP_ENABLE_VERSION_CHECK: "0" },
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
...(opts.timeoutMs && opts.timeoutMs > 0 ? { timeout: opts.timeoutMs, killSignal: "SIGTERM" } : {}),
|
|
87
|
+
});
|
|
88
|
+
let stdout = "";
|
|
89
|
+
let stderr = "";
|
|
90
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
91
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
92
|
+
child.on("error", (e) => reject(new Error(`could not run ${bin}: ${e.message}`)));
|
|
93
|
+
child.on("close", (code, signal) => {
|
|
94
|
+
if (signal) {
|
|
95
|
+
reject(new Error(`semgrep timed out after ${opts.timeoutMs}ms (killed with ${signal})`));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (code !== 0) {
|
|
99
|
+
const tail = stderr.trim().split("\n").slice(-5).join("; ");
|
|
100
|
+
reject(new Error(`semgrep exited ${code}${tail ? `: ${tail}` : ""}`));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
resolve(parseSemgrepOutput(stdout));
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
reject(e);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type Finding, type Severity, type TaskHarness } from "@zeroroot-ai/sdk";
|
|
2
|
+
import type { FindingsBackend } from "./findings.js";
|
|
3
|
+
import { type SemgrepCandidate, type SemgrepRunner } from "./semgrep.js";
|
|
4
|
+
/**
|
|
5
|
+
* The source-analysis task (zerocool-plugins#87).
|
|
6
|
+
*
|
|
7
|
+
* semgrep produces candidates from a checkout; the model triages each one as
|
|
8
|
+
* real or noise; every real one is submitted as a Finding that names its
|
|
9
|
+
* Vulnerability (a CWE, or a platform id when the rule carries none), the file
|
|
10
|
+
* and line, and the model's reason. The task reads the checkout and writes
|
|
11
|
+
* Findings. It changes no file and pushes nothing — that is the Fix's job
|
|
12
|
+
* (zerocool-plugins#89), a different task with a different grant posture.
|
|
13
|
+
*
|
|
14
|
+
* Deterministic first, model second. semgrep decides what is a candidate; the
|
|
15
|
+
* model only decides whether a candidate is real, given the rule's message and
|
|
16
|
+
* the code around the match. A rule that never yields a real finding is
|
|
17
|
+
* removed from the ruleset rather than argued with in a prompt.
|
|
18
|
+
*
|
|
19
|
+
* Every seam is injectable — the semgrep runner, the model, the findings
|
|
20
|
+
* backend, the file reader — so the test in `source-analysis.test.ts` drives a
|
|
21
|
+
* whole pass over the fixture repository with no semgrep binary, no network,
|
|
22
|
+
* and no daemon.
|
|
23
|
+
*/
|
|
24
|
+
/** What the model said about one candidate. */
|
|
25
|
+
export interface Triage {
|
|
26
|
+
verdict: "real" | "noise";
|
|
27
|
+
reason: string;
|
|
28
|
+
}
|
|
29
|
+
/** The model seam: one candidate in, one verdict out. */
|
|
30
|
+
export type TriageModel = (c: AnalysisCandidate, snippet: string) => Promise<Triage>;
|
|
31
|
+
/** A deduplicated candidate: one place, one Vulnerability, the rules that hit it. */
|
|
32
|
+
export interface AnalysisCandidate extends SemgrepCandidate {
|
|
33
|
+
/** `CWE-95`, or `zerocool:semgrep:<rule>` when the rule names no CWE. */
|
|
34
|
+
vulnerabilityId: string;
|
|
35
|
+
/** Every rule that matched this place for this Vulnerability. */
|
|
36
|
+
ruleIds: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The Vulnerability id for a candidate. A CWE from the rule's metadata wins,
|
|
40
|
+
* normalized to `CWE-<n>` so the same weakness from two rules is one node in
|
|
41
|
+
* the graph. A rule with no CWE gets a platform id keyed by the rule, which is
|
|
42
|
+
* still stable across runs and repositories.
|
|
43
|
+
*/
|
|
44
|
+
export declare function vulnerabilityId(c: SemgrepCandidate): string;
|
|
45
|
+
/**
|
|
46
|
+
* Collapse matches to one candidate per (path, line, Vulnerability). Two rules
|
|
47
|
+
* that flag the same eval on the same line are one weakness, and two Findings
|
|
48
|
+
* for it would be one duplicate for a person to close. The strongest rule's
|
|
49
|
+
* level and message are kept; every rule id is recorded.
|
|
50
|
+
*/
|
|
51
|
+
export declare function dedupeCandidates(matches: SemgrepCandidate[]): AnalysisCandidate[];
|
|
52
|
+
/** Lines around a match, numbered, for the model and for the Finding's evidence. */
|
|
53
|
+
export declare function readSnippet(dir: string, c: SemgrepCandidate, read?: (path: string) => Promise<string>, context?: number): Promise<string>;
|
|
54
|
+
/**
|
|
55
|
+
* The triage prompt. The model gets the rule's message, the weakness, and the
|
|
56
|
+
* code; it answers with one JSON object. Exported so the wording is testable
|
|
57
|
+
* and so a reader can see exactly what the model is asked.
|
|
58
|
+
*/
|
|
59
|
+
export declare function triageMessages(c: AnalysisCandidate, snippet: string): {
|
|
60
|
+
role: string;
|
|
61
|
+
content: string;
|
|
62
|
+
}[];
|
|
63
|
+
/**
|
|
64
|
+
* Parse the model's answer. Tolerant of prose around the object, strict about
|
|
65
|
+
* the verdict: anything but a clear "real" is noise, because a Finding the
|
|
66
|
+
* model did not clearly confirm is exactly the noise this task exists to drop.
|
|
67
|
+
*/
|
|
68
|
+
export declare function parseTriage(text: string): Triage;
|
|
69
|
+
/**
|
|
70
|
+
* The model behind the task grant: one `LLMComplete` on the harness per
|
|
71
|
+
* candidate, on the tenant's own provider, through the harness so the call is
|
|
72
|
+
* traced and budgeted like every other model call the run makes.
|
|
73
|
+
*/
|
|
74
|
+
export declare function harnessTriageModel(harness: TaskHarness, slot?: string): TriageModel;
|
|
75
|
+
/** semgrep's level mapped onto the Finding severity scale. */
|
|
76
|
+
export declare function findingSeverity(level: string): Severity;
|
|
77
|
+
/** What the Finding needs to say where it came from. */
|
|
78
|
+
export interface AnalysisProvenance {
|
|
79
|
+
missionId?: string;
|
|
80
|
+
/** `https://gitlab.com/examplebank/customer-portal`, when the task knows it. */
|
|
81
|
+
repository?: string;
|
|
82
|
+
commit?: string;
|
|
83
|
+
targetId?: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the Finding for a real candidate. The Vulnerability id, the file and
|
|
87
|
+
* the line travel in the title, the tags and the evidence, so they survive the
|
|
88
|
+
* typed `SubmitFinding` mapping, which carries no free-form metadata.
|
|
89
|
+
*/
|
|
90
|
+
export declare function candidateFinding(c: AnalysisCandidate, triage: Triage, snippet: string, prov: AnalysisProvenance): Finding;
|
|
91
|
+
export interface SourceAnalysisOptions {
|
|
92
|
+
/** The checkout to analyze. */
|
|
93
|
+
dir: string;
|
|
94
|
+
model: TriageModel;
|
|
95
|
+
findings: FindingsBackend;
|
|
96
|
+
provenance?: AnalysisProvenance;
|
|
97
|
+
/** Seams. */
|
|
98
|
+
semgrep?: SemgrepRunner;
|
|
99
|
+
readFile?: (path: string) => Promise<string>;
|
|
100
|
+
rules?: string;
|
|
101
|
+
semgrepTimeoutMs?: number;
|
|
102
|
+
/** Progress events, one object per step, for the console stream. */
|
|
103
|
+
onEvent?: (event: Record<string, unknown>) => void;
|
|
104
|
+
}
|
|
105
|
+
/** What one pass produced. */
|
|
106
|
+
export interface SourceAnalysisSummary {
|
|
107
|
+
matches: number;
|
|
108
|
+
candidates: number;
|
|
109
|
+
real: number;
|
|
110
|
+
noise: number;
|
|
111
|
+
/** Ids of the Findings submitted, in order. */
|
|
112
|
+
findingIds: string[];
|
|
113
|
+
/** Candidates the model or the backend failed on, with the error. Never silent. */
|
|
114
|
+
failed: {
|
|
115
|
+
candidate: string;
|
|
116
|
+
error: string;
|
|
117
|
+
}[];
|
|
118
|
+
/** Every verdict, for the terminal result and the MR note. */
|
|
119
|
+
verdicts: {
|
|
120
|
+
candidate: string;
|
|
121
|
+
vulnerabilityId: string;
|
|
122
|
+
verdict: Triage["verdict"];
|
|
123
|
+
reason: string;
|
|
124
|
+
}[];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* One pass: scan, dedupe, triage, submit. A candidate the model or the backend
|
|
128
|
+
* fails on is recorded in `failed` and the pass continues, so one bad answer
|
|
129
|
+
* costs one candidate rather than the whole scan. The pass never throws for a
|
|
130
|
+
* clean checkout; it throws only when semgrep itself could not run.
|
|
131
|
+
*/
|
|
132
|
+
export declare function runSourceAnalysis(opts: SourceAnalysisOptions): Promise<SourceAnalysisSummary>;
|
|
133
|
+
/** The human-readable result the mission node records. */
|
|
134
|
+
export declare function formatSourceAnalysis(s: SourceAnalysisSummary): string;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { newFinding } from "@zeroroot-ai/sdk";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { runSemgrep } from "./semgrep.js";
|
|
5
|
+
/** semgrep's level, ranked so the strongest rule wins a deduplicated place. */
|
|
6
|
+
const LEVEL_RANK = { ERROR: 3, WARNING: 2, INFO: 1 };
|
|
7
|
+
/**
|
|
8
|
+
* The Vulnerability id for a candidate. A CWE from the rule's metadata wins,
|
|
9
|
+
* normalized to `CWE-<n>` so the same weakness from two rules is one node in
|
|
10
|
+
* the graph. A rule with no CWE gets a platform id keyed by the rule, which is
|
|
11
|
+
* still stable across runs and repositories.
|
|
12
|
+
*/
|
|
13
|
+
export function vulnerabilityId(c) {
|
|
14
|
+
for (const raw of c.cwe) {
|
|
15
|
+
const m = /CWE-(\d+)/i.exec(raw);
|
|
16
|
+
if (m)
|
|
17
|
+
return `CWE-${m[1]}`;
|
|
18
|
+
}
|
|
19
|
+
return `zerocool:semgrep:${c.checkId}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Collapse matches to one candidate per (path, line, Vulnerability). Two rules
|
|
23
|
+
* that flag the same eval on the same line are one weakness, and two Findings
|
|
24
|
+
* for it would be one duplicate for a person to close. The strongest rule's
|
|
25
|
+
* level and message are kept; every rule id is recorded.
|
|
26
|
+
*/
|
|
27
|
+
export function dedupeCandidates(matches) {
|
|
28
|
+
const byKey = new Map();
|
|
29
|
+
for (const m of matches) {
|
|
30
|
+
const id = vulnerabilityId(m);
|
|
31
|
+
const key = `${m.path}:${m.line}:${id}`;
|
|
32
|
+
const seen = byKey.get(key);
|
|
33
|
+
if (!seen) {
|
|
34
|
+
byKey.set(key, { ...m, vulnerabilityId: id, ruleIds: [m.checkId] });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (!seen.ruleIds.includes(m.checkId))
|
|
38
|
+
seen.ruleIds.push(m.checkId);
|
|
39
|
+
if ((LEVEL_RANK[m.severity] ?? 0) > (LEVEL_RANK[seen.severity] ?? 0)) {
|
|
40
|
+
seen.severity = m.severity;
|
|
41
|
+
seen.message = m.message;
|
|
42
|
+
seen.checkId = m.checkId;
|
|
43
|
+
seen.endLine = Math.max(seen.endLine, m.endLine);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...byKey.values()].sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
|
|
47
|
+
}
|
|
48
|
+
/** Lines around a match, numbered, for the model and for the Finding's evidence. */
|
|
49
|
+
export async function readSnippet(dir, c, read = (p) => readFile(p, "utf8"), context = 4) {
|
|
50
|
+
let text;
|
|
51
|
+
try {
|
|
52
|
+
text = await read(join(dir, c.path));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
const lines = text.split("\n");
|
|
58
|
+
const from = Math.max(1, c.line - context);
|
|
59
|
+
const to = Math.min(lines.length, c.endLine + context);
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let n = from; n <= to; n++) {
|
|
62
|
+
const mark = n >= c.line && n <= c.endLine ? ">" : " ";
|
|
63
|
+
out.push(`${mark} ${String(n).padStart(4)} | ${lines[n - 1] ?? ""}`);
|
|
64
|
+
}
|
|
65
|
+
return out.join("\n");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The triage prompt. The model gets the rule's message, the weakness, and the
|
|
69
|
+
* code; it answers with one JSON object. Exported so the wording is testable
|
|
70
|
+
* and so a reader can see exactly what the model is asked.
|
|
71
|
+
*/
|
|
72
|
+
export function triageMessages(c, snippet) {
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
role: "system",
|
|
76
|
+
content: "You triage static-analysis matches in application source code. For the match below, " +
|
|
77
|
+
"decide whether it is a real weakness an attacker or a caller could exploit, or noise " +
|
|
78
|
+
"(a constant, dead code, a test, a value no caller can influence). Answer with exactly " +
|
|
79
|
+
'one JSON object and nothing else: {"verdict":"real"|"noise","reason":"<one sentence>"}.',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
role: "user",
|
|
83
|
+
content: `Rule: ${c.ruleIds.join(", ")}\n` +
|
|
84
|
+
`Weakness: ${c.vulnerabilityId}\n` +
|
|
85
|
+
`Rule message: ${c.message}\n` +
|
|
86
|
+
`File: ${c.path} lines ${c.line}-${c.endLine}\n\n` +
|
|
87
|
+
`${snippet || "(source not available)"}`,
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parse the model's answer. Tolerant of prose around the object, strict about
|
|
93
|
+
* the verdict: anything but a clear "real" is noise, because a Finding the
|
|
94
|
+
* model did not clearly confirm is exactly the noise this task exists to drop.
|
|
95
|
+
*/
|
|
96
|
+
export function parseTriage(text) {
|
|
97
|
+
const m = /\{[\s\S]*\}/.exec(text);
|
|
98
|
+
if (m) {
|
|
99
|
+
try {
|
|
100
|
+
const obj = JSON.parse(m[0]);
|
|
101
|
+
const verdict = String(obj.verdict ?? "").toLowerCase() === "real" ? "real" : "noise";
|
|
102
|
+
const reason = typeof obj.reason === "string" && obj.reason.trim() ? obj.reason.trim() : "no reason given";
|
|
103
|
+
return { verdict, reason };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// fall through
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { verdict: "noise", reason: `model answer was not the expected JSON: ${text.slice(0, 120)}` };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The model behind the task grant: one `LLMComplete` on the harness per
|
|
113
|
+
* candidate, on the tenant's own provider, through the harness so the call is
|
|
114
|
+
* traced and budgeted like every other model call the run makes.
|
|
115
|
+
*/
|
|
116
|
+
export function harnessTriageModel(harness, slot = "primary") {
|
|
117
|
+
return async (c, snippet) => {
|
|
118
|
+
const res = await harness.client.lLMComplete({
|
|
119
|
+
context: harness.context,
|
|
120
|
+
slot,
|
|
121
|
+
messages: triageMessages(c, snippet).map((m) => ({ role: m.role, content: m.content })),
|
|
122
|
+
temperature: 0,
|
|
123
|
+
stop: [],
|
|
124
|
+
});
|
|
125
|
+
if (res.error)
|
|
126
|
+
throw new Error(`LLMComplete refused: ${res.error.message}`);
|
|
127
|
+
return parseTriage(res.content);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** semgrep's level mapped onto the Finding severity scale. */
|
|
131
|
+
export function findingSeverity(level) {
|
|
132
|
+
if (level === "ERROR")
|
|
133
|
+
return "high";
|
|
134
|
+
if (level === "WARNING")
|
|
135
|
+
return "medium";
|
|
136
|
+
return "low";
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Build the Finding for a real candidate. The Vulnerability id, the file and
|
|
140
|
+
* the line travel in the title, the tags and the evidence, so they survive the
|
|
141
|
+
* typed `SubmitFinding` mapping, which carries no free-form metadata.
|
|
142
|
+
*/
|
|
143
|
+
export function candidateFinding(c, triage, snippet, prov) {
|
|
144
|
+
const where = `${c.path}:${c.line}`;
|
|
145
|
+
return newFinding({
|
|
146
|
+
title: `${c.vulnerabilityId} at ${where}: ${c.message.split(".")[0] ?? c.message}`.slice(0, 200),
|
|
147
|
+
description: `${c.message}\n\n` +
|
|
148
|
+
`Vulnerability: ${c.vulnerabilityId}\n` +
|
|
149
|
+
`File: ${c.path}\nLines: ${c.line}-${c.endLine}\n` +
|
|
150
|
+
`Rules: ${c.ruleIds.join(", ")}\n` +
|
|
151
|
+
(prov.repository ? `Repository: ${prov.repository}${prov.commit ? ` @ ${prov.commit}` : ""}\n` : "") +
|
|
152
|
+
`\nTriage: ${triage.reason}`,
|
|
153
|
+
category: c.category ?? "source",
|
|
154
|
+
severity: findingSeverity(c.severity),
|
|
155
|
+
confidence: 0.8,
|
|
156
|
+
missionID: prov.missionId ?? "",
|
|
157
|
+
agentName: "zerocool",
|
|
158
|
+
...(prov.targetId ? { targetID: prov.targetId } : {}),
|
|
159
|
+
tags: [
|
|
160
|
+
"source-analysis",
|
|
161
|
+
`vulnerability:${c.vulnerabilityId}`,
|
|
162
|
+
`file:${c.path}`,
|
|
163
|
+
`line:${c.line}`,
|
|
164
|
+
...c.ruleIds.map((r) => `semgrep:${r}`),
|
|
165
|
+
...(prov.commit ? [`commit:${prov.commit}`] : []),
|
|
166
|
+
],
|
|
167
|
+
evidence: [
|
|
168
|
+
{
|
|
169
|
+
type: "code",
|
|
170
|
+
title: where,
|
|
171
|
+
content: snippet || "(source not available)",
|
|
172
|
+
timestamp: new Date().toISOString(),
|
|
173
|
+
metadata: { path: c.path, line: c.line, end_line: c.endLine, rules: c.ruleIds },
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* One pass: scan, dedupe, triage, submit. A candidate the model or the backend
|
|
180
|
+
* fails on is recorded in `failed` and the pass continues, so one bad answer
|
|
181
|
+
* costs one candidate rather than the whole scan. The pass never throws for a
|
|
182
|
+
* clean checkout; it throws only when semgrep itself could not run.
|
|
183
|
+
*/
|
|
184
|
+
export async function runSourceAnalysis(opts) {
|
|
185
|
+
const emit = opts.onEvent ?? (() => { });
|
|
186
|
+
const semgrep = opts.semgrep ?? runSemgrep;
|
|
187
|
+
emit({ type: "source_analysis", phase: "semgrep", dir: opts.dir });
|
|
188
|
+
const matches = await semgrep({
|
|
189
|
+
dir: opts.dir,
|
|
190
|
+
...(opts.rules ? { rules: opts.rules } : {}),
|
|
191
|
+
...(opts.semgrepTimeoutMs ? { timeoutMs: opts.semgrepTimeoutMs } : {}),
|
|
192
|
+
});
|
|
193
|
+
const candidates = dedupeCandidates(matches);
|
|
194
|
+
emit({ type: "source_analysis", phase: "candidates", matches: matches.length, candidates: candidates.length });
|
|
195
|
+
const summary = {
|
|
196
|
+
matches: matches.length,
|
|
197
|
+
candidates: candidates.length,
|
|
198
|
+
real: 0,
|
|
199
|
+
noise: 0,
|
|
200
|
+
findingIds: [],
|
|
201
|
+
failed: [],
|
|
202
|
+
verdicts: [],
|
|
203
|
+
};
|
|
204
|
+
for (const c of candidates) {
|
|
205
|
+
const label = `${c.path}:${c.line} ${c.vulnerabilityId}`;
|
|
206
|
+
const snippet = await readSnippet(opts.dir, c, opts.readFile);
|
|
207
|
+
let triage;
|
|
208
|
+
try {
|
|
209
|
+
triage = await opts.model(c, snippet);
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
summary.failed.push({ candidate: label, error: `triage: ${e.message}` });
|
|
213
|
+
emit({ type: "source_analysis", phase: "triage", candidate: label, error: e.message });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
summary.verdicts.push({ candidate: label, vulnerabilityId: c.vulnerabilityId, verdict: triage.verdict, reason: triage.reason });
|
|
217
|
+
emit({ type: "source_analysis", phase: "triage", candidate: label, verdict: triage.verdict, reason: triage.reason });
|
|
218
|
+
if (triage.verdict !== "real") {
|
|
219
|
+
summary.noise += 1;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
summary.real += 1;
|
|
223
|
+
const finding = candidateFinding(c, triage, snippet, opts.provenance ?? {});
|
|
224
|
+
try {
|
|
225
|
+
const id = await opts.findings.submit(finding);
|
|
226
|
+
summary.findingIds.push(id);
|
|
227
|
+
emit({ type: "source_analysis", phase: "submit", candidate: label, finding_id: id });
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
summary.failed.push({ candidate: label, error: `submit: ${e.message}` });
|
|
231
|
+
emit({ type: "source_analysis", phase: "submit", candidate: label, error: e.message });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
emit({ type: "source_analysis", phase: "done", ...summaryCounts(summary) });
|
|
235
|
+
return summary;
|
|
236
|
+
}
|
|
237
|
+
function summaryCounts(s) {
|
|
238
|
+
return { matches: s.matches, candidates: s.candidates, real: s.real, noise: s.noise, submitted: s.findingIds.length, failed: s.failed.length };
|
|
239
|
+
}
|
|
240
|
+
/** The human-readable result the mission node records. */
|
|
241
|
+
export function formatSourceAnalysis(s) {
|
|
242
|
+
const lines = [
|
|
243
|
+
`source analysis: ${s.matches} semgrep matches, ${s.candidates} candidates, ` +
|
|
244
|
+
`${s.real} real, ${s.noise} noise, ${s.findingIds.length} findings submitted, ${s.failed.length} failed`,
|
|
245
|
+
];
|
|
246
|
+
for (const v of s.verdicts)
|
|
247
|
+
lines.push(`- ${v.verdict.padEnd(5)} ${v.candidate}: ${v.reason}`);
|
|
248
|
+
for (const f of s.failed)
|
|
249
|
+
lines.push(`- FAILED ${f.candidate}: ${f.error}`);
|
|
250
|
+
return lines.join("\n");
|
|
251
|
+
}
|