@zeroroot-ai/zerocool 0.3.1 → 0.4.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 +53 -1
- package/dist/dispatch.js +149 -3
- package/dist/findings.d.ts +9 -1
- package/dist/findings.js +36 -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/package.json +4 -3
- package/semgrep/rules.yaml +178 -0
package/dist/dispatch.d.ts
CHANGED
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
* GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
|
|
47
47
|
* GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
|
|
48
48
|
* ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
|
|
49
|
+
* ZEROCOOL_TASK force a task kind ("source-analysis"); the Task's
|
|
50
|
+
* own context `zerocool.task` is the normal way
|
|
49
51
|
* GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
|
|
50
52
|
* GIBSON_TIMEOUT_MS hard deadline for the run, in ms
|
|
51
53
|
*
|
|
@@ -53,8 +55,11 @@
|
|
|
53
55
|
* {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
|
|
54
56
|
* the GIBSON_CG_JWT this process reads.
|
|
55
57
|
*/
|
|
56
|
-
import type
|
|
58
|
+
import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
|
|
59
|
+
import { type FindingsBackend } from "./findings.js";
|
|
57
60
|
import { runOpencode } from "./opencode-run.js";
|
|
61
|
+
import type { SemgrepRunner } from "./semgrep.js";
|
|
62
|
+
import { type TriageModel } from "./source-analysis.js";
|
|
58
63
|
/**
|
|
59
64
|
* The per-dispatch grant, as the sandbox injects it. The child authenticates its
|
|
60
65
|
* harness callbacks with this; {@link dispatchChildEnv} renders it back to env.
|
|
@@ -75,6 +80,14 @@ export interface CallbackGrant {
|
|
|
75
80
|
export interface DispatchContext extends CallbackGrant {
|
|
76
81
|
/** `task.goal` — the natural-language objective the sandbox handed over. */
|
|
77
82
|
goal: string;
|
|
83
|
+
/**
|
|
84
|
+
* `task.context` — the string map the mission node set. Selects the task kind
|
|
85
|
+
* (`zerocool.task`) and carries what a task needs beyond a goal: the
|
|
86
|
+
* repository url and commit, a sub-path, the target id.
|
|
87
|
+
*/
|
|
88
|
+
taskContext: Record<string, string>;
|
|
89
|
+
/** `GIBSON_MISSION_ID`, so a Finding names the mission it belongs to. */
|
|
90
|
+
missionId?: string;
|
|
78
91
|
/** opencode edits files here; it is the run's workspace. */
|
|
79
92
|
workspace: string;
|
|
80
93
|
/** `provider/model`, e.g. `gibson/default`. Omitted lets opencode choose. */
|
|
@@ -101,6 +114,26 @@ export declare function dispatchChildEnv(g: CallbackGrant): NodeJS.ProcessEnv;
|
|
|
101
114
|
* here rather than run opencode against an empty or malformed prompt.
|
|
102
115
|
*/
|
|
103
116
|
export declare function decodeTaskGoal(taskB64: string | undefined): string;
|
|
117
|
+
/** The parts of a `gibson.types.v1.Task` a dispatch reads. */
|
|
118
|
+
export interface DecodedTask {
|
|
119
|
+
goal: string;
|
|
120
|
+
context: Record<string, string>;
|
|
121
|
+
metadata: Record<string, string>;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Decode the whole Task: the goal, and the `context` and `metadata` string
|
|
125
|
+
* maps a mission node sets. protojson renders both as plain objects, so a
|
|
126
|
+
* value that is not a string is dropped rather than guessed at.
|
|
127
|
+
*/
|
|
128
|
+
export declare function decodeTask(taskB64: string | undefined): DecodedTask;
|
|
129
|
+
/** The task kinds a sandboxed dispatch can serve. */
|
|
130
|
+
export type DispatchTaskKind = "opencode" | "source-analysis";
|
|
131
|
+
/**
|
|
132
|
+
* Which task this dispatch runs. The mission node says so in the Task's
|
|
133
|
+
* context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
|
|
134
|
+
* Anything unrecognized is the default: drive opencode on the goal.
|
|
135
|
+
*/
|
|
136
|
+
export declare function dispatchTaskKind(ctx: Pick<DispatchContext, "taskContext">, env?: NodeJS.ProcessEnv): DispatchTaskKind;
|
|
104
137
|
/**
|
|
105
138
|
* Read one sandboxed dispatch's context from the environment.
|
|
106
139
|
*
|
|
@@ -123,6 +156,14 @@ export interface DispatchDeps {
|
|
|
123
156
|
run?: typeof runOpencode;
|
|
124
157
|
/** Live tap for each NDJSON event opencode prints. */
|
|
125
158
|
onEvent?: (line: string) => void;
|
|
159
|
+
/** Source-analysis seams: no semgrep binary, no model, no daemon in a test. */
|
|
160
|
+
semgrep?: SemgrepRunner;
|
|
161
|
+
model?: TriageModel;
|
|
162
|
+
findings?: FindingsBackend;
|
|
163
|
+
/** Opens the task harness. Defaults to `openTaskHarness` on the dispatch grant. */
|
|
164
|
+
harness?: (ctx: DispatchContext) => TaskHarness;
|
|
165
|
+
/** Force the task kind, over the Task's own context. */
|
|
166
|
+
taskKind?: DispatchTaskKind;
|
|
126
167
|
}
|
|
127
168
|
/**
|
|
128
169
|
* Run one dispatched Task headless and map it to a terminal outcome.
|
|
@@ -133,6 +174,17 @@ export interface DispatchDeps {
|
|
|
133
174
|
* into a failed terminal result and a non-zero exit.
|
|
134
175
|
*/
|
|
135
176
|
export declare function runDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
177
|
+
/**
|
|
178
|
+
* The source-analysis task, sandboxed (zerocool-plugins#87). opencode is not
|
|
179
|
+
* involved: semgrep produces candidates, the model triages them through the
|
|
180
|
+
* harness `LLMComplete` on the task grant, and real ones are submitted through
|
|
181
|
+
* the harness `SubmitFinding` on the same grant. The task reads the checkout
|
|
182
|
+
* (the workspace, or `source.path` under it) and changes nothing.
|
|
183
|
+
*
|
|
184
|
+
* Task context keys it reads: `zerocool.task` (selector), `source.path`,
|
|
185
|
+
* `repository.url`, `repository.commit`, `target.id`.
|
|
186
|
+
*/
|
|
187
|
+
export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
136
188
|
/**
|
|
137
189
|
* Render a terminal outcome as the last NDJSON line on stdout.
|
|
138
190
|
*
|
package/dist/dispatch.js
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* zerocool-dispatch — one sandboxed dispatched run, then exit.
|
|
4
|
+
*
|
|
5
|
+
* The third dispatched shape, and the one gibson launches (zerocool-plugins#57,
|
|
6
|
+
* gibson#1596, ADR-0016). `zerocool-agent` and `zerocool-serve` are long-lived
|
|
7
|
+
* hosts that enroll once, keep a host key, and POLL the daemon for work. This is
|
|
8
|
+
* the opposite: gibson runs an untrusted agent by launching an ephemeral setec
|
|
9
|
+
* sandbox per mission run and injecting the run's credentials as environment. So
|
|
10
|
+
* there is no enrollment, no host key, and no poll loop — the task and the grant
|
|
11
|
+
* are already in the environment when the process starts, and the process serves
|
|
12
|
+
* exactly one dispatch and exits so the sandbox is torn down.
|
|
13
|
+
*
|
|
14
|
+
* WHAT REPLACES THE BOOTSTRAP TOKEN. The poll-loop shapes authenticate as a
|
|
15
|
+
* long-lived COMPONENT: a human mints a one-time `GIBSON_BOOTSTRAP_TOKEN`, the
|
|
16
|
+
* first run trades it for a persistent host key, and every later run re-registers
|
|
17
|
+
* with that key (ADR-0045). A sandboxed run has neither. It authenticates with
|
|
18
|
+
* the PER-DISPATCH capability grant gibson mints for this run and injects as
|
|
19
|
+
* `GIBSON_CALLBACK_ENDPOINT` + `GIBSON_CG_JWT`. That grant carries exactly the
|
|
20
|
+
* authority of this dispatch, so this process never reads a bootstrap token and
|
|
21
|
+
* never touches a host key.
|
|
22
|
+
*
|
|
23
|
+
* THE CALLBACKS ARE GRANT-AUTHENTICATED. This process drives `opencode run`
|
|
24
|
+
* headless and passes the per-dispatch grant to the child (see
|
|
25
|
+
* {@link dispatchChildEnv}). The child's mission callbacks — findings, tool
|
|
26
|
+
* calls, knowledge reads — then reach the daemon's harness callback listener as
|
|
27
|
+
* the TASK, on the injected grant, not as some enrolled component (see
|
|
28
|
+
* `knowledge-source.ts`).
|
|
29
|
+
*
|
|
30
|
+
* THE OUTPUT IS A LIVE NDJSON STREAM. Every event opencode prints is forwarded
|
|
31
|
+
* to this process's stdout as it arrives, so the daemon can stream the run to the
|
|
32
|
+
* read-only console (a later slice consumes it). The last line is the terminal
|
|
33
|
+
* result. stdout is therefore a clean event stream — the driver's own logs go to
|
|
34
|
+
* stderr — and a clean exit lets the sandbox tear down.
|
|
35
|
+
*
|
|
36
|
+
* Environment (injected by the sandbox launch):
|
|
37
|
+
*
|
|
38
|
+
* These names are gibson's canonical dispatch contract — the constants in gibson
|
|
39
|
+
* `internal/engine/harness/sandboxed/agent.go` (envAgent*). This process conforms
|
|
40
|
+
* to them; it does not define its own.
|
|
41
|
+
*
|
|
42
|
+
* GIBSON_CALLBACK_ENDPOINT per-dispatch harness callback dial target (required)
|
|
43
|
+
* GIBSON_CG_JWT per-dispatch capability grant (CG-JWT) (required)
|
|
44
|
+
* GIBSON_AGENT_TASK_B64 base64 protojson gibson.types.v1.Task (required)
|
|
45
|
+
* GIBSON_MODEL provider/model resolved for the tenant at dispatch
|
|
46
|
+
* GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
|
|
47
|
+
* GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
|
|
48
|
+
* ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
|
|
49
|
+
* ZEROCOOL_TASK force a task kind ("source-analysis"); the Task's
|
|
50
|
+
* own context `zerocool.task` is the normal way
|
|
51
|
+
* GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
|
|
52
|
+
* GIBSON_TIMEOUT_MS hard deadline for the run, in ms
|
|
53
|
+
*
|
|
54
|
+
* The child `opencode run` still receives the grant as GIBSON_CALLBACK_TOKEN via
|
|
55
|
+
* {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
|
|
56
|
+
* the GIBSON_CG_JWT this process reads.
|
|
57
|
+
*/
|
|
58
|
+
import { openTaskHarness } from "@zeroroot-ai/sdk";
|
|
59
|
+
import { join } from "node:path";
|
|
60
|
+
import { taskFindingsBackend } from "./findings.js";
|
|
2
61
|
import { runOpencode } from "./opencode-run.js";
|
|
62
|
+
import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
|
|
3
63
|
/**
|
|
4
64
|
* Render the per-dispatch grant back to environment for the opencode child, so
|
|
5
65
|
* the child's harness callbacks travel on the task grant rather than on any
|
|
@@ -26,6 +86,14 @@ export function dispatchChildEnv(g) {
|
|
|
26
86
|
* here rather than run opencode against an empty or malformed prompt.
|
|
27
87
|
*/
|
|
28
88
|
export function decodeTaskGoal(taskB64) {
|
|
89
|
+
return decodeTask(taskB64).goal;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Decode the whole Task: the goal, and the `context` and `metadata` string
|
|
93
|
+
* maps a mission node sets. protojson renders both as plain objects, so a
|
|
94
|
+
* value that is not a string is dropped rather than guessed at.
|
|
95
|
+
*/
|
|
96
|
+
export function decodeTask(taskB64) {
|
|
29
97
|
if (!taskB64) {
|
|
30
98
|
throw new Error("GIBSON_AGENT_TASK_B64 is not set. The sandbox launch injects the task to pursue as the " +
|
|
31
99
|
"base64 protojson of a gibson.types.v1.Task; without it there is nothing to run.");
|
|
@@ -43,7 +111,26 @@ export function decodeTaskGoal(taskB64) {
|
|
|
43
111
|
throw new Error("GIBSON_AGENT_TASK_B64 decoded to a Task with no goal. Running opencode against an empty " +
|
|
44
112
|
"prompt would report whatever it says back as a mission result.");
|
|
45
113
|
}
|
|
46
|
-
return goal;
|
|
114
|
+
return { goal, context: stringMap(task.context), metadata: stringMap(task.metadata) };
|
|
115
|
+
}
|
|
116
|
+
function stringMap(v) {
|
|
117
|
+
if (!v || typeof v !== "object")
|
|
118
|
+
return {};
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const [k, val] of Object.entries(v)) {
|
|
121
|
+
if (typeof val === "string")
|
|
122
|
+
out[k] = val;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Which task this dispatch runs. The mission node says so in the Task's
|
|
128
|
+
* context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
|
|
129
|
+
* Anything unrecognized is the default: drive opencode on the goal.
|
|
130
|
+
*/
|
|
131
|
+
export function dispatchTaskKind(ctx, env = {}) {
|
|
132
|
+
const kind = env.ZEROCOOL_TASK ?? ctx.taskContext["zerocool.task"] ?? "";
|
|
133
|
+
return kind === "source-analysis" ? "source-analysis" : "opencode";
|
|
47
134
|
}
|
|
48
135
|
/**
|
|
49
136
|
* Read one sandboxed dispatch's context from the environment.
|
|
@@ -71,7 +158,7 @@ export function readDispatchContext(env, opts = {}) {
|
|
|
71
158
|
"grant to authenticate with; a sandboxed run must not fall back to any other identity, " +
|
|
72
159
|
"so this fails instead.");
|
|
73
160
|
}
|
|
74
|
-
const
|
|
161
|
+
const task = decodeTask(env.GIBSON_AGENT_TASK_B64);
|
|
75
162
|
const timeoutRaw = env.GIBSON_TIMEOUT_MS ? Number(env.GIBSON_TIMEOUT_MS) : 0;
|
|
76
163
|
return {
|
|
77
164
|
callbackEndpoint: endpoint,
|
|
@@ -80,7 +167,9 @@ export function readDispatchContext(env, opts = {}) {
|
|
|
80
167
|
missionRunId: env.GIBSON_MISSION_RUN_ID ?? "",
|
|
81
168
|
agentRunId: env.GIBSON_AGENT_RUN_ID ?? "",
|
|
82
169
|
traceId: env.GIBSON_TRACE_ID ?? "",
|
|
83
|
-
goal,
|
|
170
|
+
goal: task.goal,
|
|
171
|
+
taskContext: task.context,
|
|
172
|
+
...(env.GIBSON_MISSION_ID ? { missionId: env.GIBSON_MISSION_ID } : {}),
|
|
84
173
|
workspace: env.ZEROCOOL_WORKSPACE ?? opts.cwd ?? process.cwd(),
|
|
85
174
|
...(env.GIBSON_MODEL ? { model: env.GIBSON_MODEL } : {}),
|
|
86
175
|
...(env.GIBSON_OPENCODE_SESSION_ID ? { sessionId: env.GIBSON_OPENCODE_SESSION_ID } : {}),
|
|
@@ -96,6 +185,9 @@ export function readDispatchContext(env, opts = {}) {
|
|
|
96
185
|
* into a failed terminal result and a non-zero exit.
|
|
97
186
|
*/
|
|
98
187
|
export async function runDispatch(ctx, deps = {}) {
|
|
188
|
+
if ((deps.taskKind ?? dispatchTaskKind(ctx, process.env)) === "source-analysis") {
|
|
189
|
+
return runSourceAnalysisDispatch(ctx, deps);
|
|
190
|
+
}
|
|
99
191
|
const run = deps.run ?? runOpencode;
|
|
100
192
|
const result = await run({
|
|
101
193
|
goal: ctx.goal,
|
|
@@ -118,6 +210,60 @@ export async function runDispatch(ctx, deps = {}) {
|
|
|
118
210
|
},
|
|
119
211
|
};
|
|
120
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* The source-analysis task, sandboxed (zerocool-plugins#87). opencode is not
|
|
215
|
+
* involved: semgrep produces candidates, the model triages them through the
|
|
216
|
+
* harness `LLMComplete` on the task grant, and real ones are submitted through
|
|
217
|
+
* the harness `SubmitFinding` on the same grant. The task reads the checkout
|
|
218
|
+
* (the workspace, or `source.path` under it) and changes nothing.
|
|
219
|
+
*
|
|
220
|
+
* Task context keys it reads: `zerocool.task` (selector), `source.path`,
|
|
221
|
+
* `repository.url`, `repository.commit`, `target.id`.
|
|
222
|
+
*/
|
|
223
|
+
export async function runSourceAnalysisDispatch(ctx, deps = {}) {
|
|
224
|
+
const opened = !deps.harness && !(deps.model && deps.findings);
|
|
225
|
+
const harness = deps.model && deps.findings
|
|
226
|
+
? undefined
|
|
227
|
+
: (deps.harness ?? ((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx);
|
|
228
|
+
const model = deps.model ?? harnessTriageModel(harness);
|
|
229
|
+
const findings = deps.findings ?? taskFindingsBackend(harness);
|
|
230
|
+
const sub = ctx.taskContext["source.path"];
|
|
231
|
+
const dir = sub ? join(ctx.workspace, sub) : ctx.workspace;
|
|
232
|
+
try {
|
|
233
|
+
const summary = await runSourceAnalysis({
|
|
234
|
+
dir,
|
|
235
|
+
model,
|
|
236
|
+
findings,
|
|
237
|
+
...(deps.semgrep ? { semgrep: deps.semgrep } : {}),
|
|
238
|
+
...(ctx.timeoutMs > 0 ? { semgrepTimeoutMs: ctx.timeoutMs } : {}),
|
|
239
|
+
provenance: {
|
|
240
|
+
...(ctx.missionId ? { missionId: ctx.missionId } : {}),
|
|
241
|
+
...(ctx.taskContext["repository.url"] ? { repository: ctx.taskContext["repository.url"] } : {}),
|
|
242
|
+
...(ctx.taskContext["repository.commit"] ? { commit: ctx.taskContext["repository.commit"] } : {}),
|
|
243
|
+
...(ctx.taskContext["target.id"] ? { targetId: ctx.taskContext["target.id"] } : {}),
|
|
244
|
+
},
|
|
245
|
+
...(deps.onEvent ? { onEvent: (event) => deps.onEvent?.(JSON.stringify(event)) } : {}),
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
output: formatSourceAnalysis(summary),
|
|
249
|
+
success: true,
|
|
250
|
+
findingIds: summary.findingIds,
|
|
251
|
+
metadata: {
|
|
252
|
+
task: "source-analysis",
|
|
253
|
+
matches: String(summary.matches),
|
|
254
|
+
candidates: String(summary.candidates),
|
|
255
|
+
real: String(summary.real),
|
|
256
|
+
noise: String(summary.noise),
|
|
257
|
+
submitted: String(summary.findingIds.length),
|
|
258
|
+
failed: String(summary.failed.length),
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
if (opened)
|
|
264
|
+
harness?.stop();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
121
267
|
/**
|
|
122
268
|
* Render a terminal outcome as the last NDJSON line on stdout.
|
|
123
269
|
*
|
package/dist/findings.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition } from "@opencode-ai/plugin";
|
|
2
|
-
import { type Finding, type GibsonSession } from "@zeroroot-ai/sdk";
|
|
2
|
+
import { type Finding, type GibsonSession, type TaskHarness } from "@zeroroot-ai/sdk";
|
|
3
3
|
export interface FindingsBackend {
|
|
4
4
|
/** Submit a finding; returns the identifier it was recorded under. */
|
|
5
5
|
submit(f: Finding): Promise<string>;
|
|
@@ -8,6 +8,14 @@ export interface FindingsBackend {
|
|
|
8
8
|
}
|
|
9
9
|
/** Platform backend — findings land in the tenant knowledge graph. */
|
|
10
10
|
export declare function gibsonFindingsBackend(session: GibsonSession): FindingsBackend;
|
|
11
|
+
/**
|
|
12
|
+
* Task backend — the callback service's typed `SubmitFinding` under the
|
|
13
|
+
* per-dispatch grant, so a sandboxed run's findings are attributed to the TASK
|
|
14
|
+
* (mission and run), never to a component identity the sandbox does not hold.
|
|
15
|
+
* Fields map from the SDK's JSON finding onto `gibson.types.v1.Finding`; the
|
|
16
|
+
* daemon keeps the id `newFinding` minted. Same mapping as claude-gibson's.
|
|
17
|
+
*/
|
|
18
|
+
export declare function taskFindingsBackend(harness: TaskHarness): FindingsBackend;
|
|
11
19
|
/**
|
|
12
20
|
* Standalone backend — findings are appended as JSONL. The same Finding shape is
|
|
13
21
|
* written, so a local log can be replayed into Gibson later.
|
package/dist/findings.js
CHANGED
|
@@ -26,6 +26,42 @@ export function gibsonFindingsBackend(session) {
|
|
|
26
26
|
describe: () => "the tenant Gibson graph",
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Task backend — the callback service's typed `SubmitFinding` under the
|
|
31
|
+
* per-dispatch grant, so a sandboxed run's findings are attributed to the TASK
|
|
32
|
+
* (mission and run), never to a component identity the sandbox does not hold.
|
|
33
|
+
* Fields map from the SDK's JSON finding onto `gibson.types.v1.Finding`; the
|
|
34
|
+
* daemon keeps the id `newFinding` minted. Same mapping as claude-gibson's.
|
|
35
|
+
*/
|
|
36
|
+
export function taskFindingsBackend(harness) {
|
|
37
|
+
const severity = (s) => ({ critical: 1, high: 2, medium: 3, low: 4, info: 5 })[s] ?? 0;
|
|
38
|
+
return {
|
|
39
|
+
submit: async (f) => {
|
|
40
|
+
const res = await harness.client.submitFinding({
|
|
41
|
+
context: harness.context,
|
|
42
|
+
finding: {
|
|
43
|
+
id: f.id,
|
|
44
|
+
missionId: f.mission_id,
|
|
45
|
+
agentName: f.agent_name,
|
|
46
|
+
title: f.title,
|
|
47
|
+
description: f.description,
|
|
48
|
+
category: f.category,
|
|
49
|
+
severity: severity(f.severity),
|
|
50
|
+
confidence: f.confidence,
|
|
51
|
+
remediation: f.remediation ?? "",
|
|
52
|
+
targetId: f.target_id ?? "",
|
|
53
|
+
tags: f.tags ?? [],
|
|
54
|
+
references: f.references ?? [],
|
|
55
|
+
evidence: (f.evidence ?? []).map((e) => ({ type: e.type, title: e.title, content: e.content })),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (res.error)
|
|
59
|
+
throw new Error(`SubmitFinding refused: ${res.error.message}`);
|
|
60
|
+
return f.id;
|
|
61
|
+
},
|
|
62
|
+
describe: () => "the tenant Gibson graph (dispatch grant)",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
29
65
|
/**
|
|
30
66
|
* Standalone backend — findings are appended as JSONL. The same Finding shape is
|
|
31
67
|
* written, so a local log can be replayed into Gibson later.
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroroot-ai/zerocool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "opencode plugin: Gibson platform integration (zerocool). Zero-config LLM, tools, findings, knowledge, delegate/missions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"main": "dist/index.js",
|
|
15
15
|
"types": "dist/index.d.ts",
|
|
16
16
|
"files": [
|
|
17
|
-
"dist"
|
|
17
|
+
"dist",
|
|
18
|
+
"semgrep"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"build": "tsc -p tsconfig.json",
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
"test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\""
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
|
-
"@zeroroot-ai/sdk": "^0.
|
|
26
|
+
"@zeroroot-ai/sdk": "^0.9.0"
|
|
26
27
|
},
|
|
27
28
|
"peerDependencies": {
|
|
28
29
|
"@opencode-ai/plugin": "*"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# The pinned semgrep ruleset the zerocool source-analysis task runs
|
|
2
|
+
# (zerocool-plugins#87). Vendored, not fetched: the sandbox must produce the
|
|
3
|
+
# same candidate list for the same checkout on every run, with no registry
|
|
4
|
+
# call and no telemetry. Every rule names the CWE it detects in metadata.cwe;
|
|
5
|
+
# the task turns that into the Finding's Vulnerability id. A rule without a
|
|
6
|
+
# CWE gets a platform id instead (zerocool:semgrep:<rule id>).
|
|
7
|
+
#
|
|
8
|
+
# Rules are candidates, not verdicts. The model triages each match; a rule
|
|
9
|
+
# here may be noisy on purpose as long as the match carries enough context
|
|
10
|
+
# to be judged. Keep the set small and cross-language; a rule that never
|
|
11
|
+
# yields a real finding in the demo fixture or a real repository is removed.
|
|
12
|
+
rules:
|
|
13
|
+
- id: js-eval-of-request-input
|
|
14
|
+
languages: [javascript, typescript]
|
|
15
|
+
severity: ERROR
|
|
16
|
+
message: >-
|
|
17
|
+
eval() runs a string that comes from request input. An attacker who
|
|
18
|
+
controls that input runs arbitrary code in the server process.
|
|
19
|
+
patterns:
|
|
20
|
+
- pattern-either:
|
|
21
|
+
- pattern: eval($ARG)
|
|
22
|
+
- pattern: new Function($ARG)
|
|
23
|
+
- metavariable-pattern:
|
|
24
|
+
metavariable: $ARG
|
|
25
|
+
pattern-either:
|
|
26
|
+
- pattern: $REQ.body.$X
|
|
27
|
+
- pattern: $REQ.query.$X
|
|
28
|
+
- pattern: $REQ.params.$X
|
|
29
|
+
- pattern: $REQ.body
|
|
30
|
+
- pattern: $REQ.query
|
|
31
|
+
metadata:
|
|
32
|
+
cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code"
|
|
33
|
+
category: injection
|
|
34
|
+
|
|
35
|
+
- id: js-eval-any
|
|
36
|
+
languages: [javascript, typescript]
|
|
37
|
+
severity: WARNING
|
|
38
|
+
message: >-
|
|
39
|
+
eval() on a value. Real when the value can be influenced by a caller;
|
|
40
|
+
noise when it is a constant.
|
|
41
|
+
pattern: eval($ARG)
|
|
42
|
+
metadata:
|
|
43
|
+
cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code"
|
|
44
|
+
category: injection
|
|
45
|
+
|
|
46
|
+
- id: js-child-process-shell-concat
|
|
47
|
+
languages: [javascript, typescript]
|
|
48
|
+
severity: ERROR
|
|
49
|
+
message: >-
|
|
50
|
+
A shell command is built by string concatenation and run through
|
|
51
|
+
child_process. Input in the string becomes shell syntax.
|
|
52
|
+
patterns:
|
|
53
|
+
- pattern-either:
|
|
54
|
+
- pattern: $CP.exec($CMD, ...)
|
|
55
|
+
- pattern: $CP.execSync($CMD, ...)
|
|
56
|
+
- pattern: exec($CMD, ...)
|
|
57
|
+
- pattern: execSync($CMD, ...)
|
|
58
|
+
- metavariable-pattern:
|
|
59
|
+
metavariable: $CMD
|
|
60
|
+
pattern-either:
|
|
61
|
+
- pattern: $A + $B
|
|
62
|
+
- pattern: "`...${$X}...`"
|
|
63
|
+
metadata:
|
|
64
|
+
cwe: "CWE-78: Improper Neutralization of Special Elements used in an OS Command"
|
|
65
|
+
category: injection
|
|
66
|
+
|
|
67
|
+
- id: js-sql-string-concat
|
|
68
|
+
languages: [javascript, typescript]
|
|
69
|
+
severity: ERROR
|
|
70
|
+
message: >-
|
|
71
|
+
A SQL query is built by string concatenation or interpolation and passed
|
|
72
|
+
to a query function. Use a parameterized query.
|
|
73
|
+
patterns:
|
|
74
|
+
- pattern-either:
|
|
75
|
+
- pattern: $DB.query($SQL, ...)
|
|
76
|
+
- pattern: $DB.raw($SQL, ...)
|
|
77
|
+
- pattern: $DB.execute($SQL, ...)
|
|
78
|
+
- metavariable-pattern:
|
|
79
|
+
metavariable: $SQL
|
|
80
|
+
pattern-either:
|
|
81
|
+
- pattern: $A + $B
|
|
82
|
+
- pattern: "`...${$X}...`"
|
|
83
|
+
metadata:
|
|
84
|
+
cwe: "CWE-89: Improper Neutralization of Special Elements used in an SQL Command"
|
|
85
|
+
category: injection
|
|
86
|
+
|
|
87
|
+
- id: js-hardcoded-jwt-secret
|
|
88
|
+
languages: [javascript, typescript]
|
|
89
|
+
severity: ERROR
|
|
90
|
+
message: >-
|
|
91
|
+
A JWT is signed or verified with a string literal secret. Anyone who
|
|
92
|
+
reads the source can forge tokens.
|
|
93
|
+
patterns:
|
|
94
|
+
- pattern-either:
|
|
95
|
+
- pattern: $JWT.sign($P, "$SECRET", ...)
|
|
96
|
+
- pattern: $JWT.verify($T, "$SECRET", ...)
|
|
97
|
+
metadata:
|
|
98
|
+
cwe: "CWE-798: Use of Hard-coded Credentials"
|
|
99
|
+
category: secrets-exposure
|
|
100
|
+
|
|
101
|
+
- id: js-express-res-send-request-input
|
|
102
|
+
languages: [javascript, typescript]
|
|
103
|
+
severity: WARNING
|
|
104
|
+
message: >-
|
|
105
|
+
Request input is written straight into the response body. If the
|
|
106
|
+
response is HTML this is reflected cross-site scripting.
|
|
107
|
+
patterns:
|
|
108
|
+
- pattern-either:
|
|
109
|
+
- pattern: $RES.send($ARG)
|
|
110
|
+
- pattern: $RES.write($ARG)
|
|
111
|
+
- metavariable-pattern:
|
|
112
|
+
metavariable: $ARG
|
|
113
|
+
pattern-either:
|
|
114
|
+
- pattern: $REQ.query.$X
|
|
115
|
+
- pattern: $REQ.params.$X
|
|
116
|
+
- pattern: $REQ.body.$X
|
|
117
|
+
- pattern: "`...${$REQ.query.$X}...`"
|
|
118
|
+
- pattern: "`...${$REQ.params.$X}...`"
|
|
119
|
+
- pattern: $A + $REQ.query.$X
|
|
120
|
+
- pattern: $A + $REQ.params.$X
|
|
121
|
+
metadata:
|
|
122
|
+
cwe: "CWE-79: Improper Neutralization of Input During Web Page Generation"
|
|
123
|
+
category: injection
|
|
124
|
+
|
|
125
|
+
- id: py-subprocess-shell-true
|
|
126
|
+
languages: [python]
|
|
127
|
+
severity: ERROR
|
|
128
|
+
message: >-
|
|
129
|
+
subprocess is called with shell=True and a command that is not a
|
|
130
|
+
constant. Input in the command becomes shell syntax.
|
|
131
|
+
patterns:
|
|
132
|
+
- pattern-either:
|
|
133
|
+
- pattern: subprocess.$F($CMD, ..., shell=True, ...)
|
|
134
|
+
- pattern-not: subprocess.$F("...", ..., shell=True, ...)
|
|
135
|
+
metadata:
|
|
136
|
+
cwe: "CWE-78: Improper Neutralization of Special Elements used in an OS Command"
|
|
137
|
+
category: injection
|
|
138
|
+
|
|
139
|
+
- id: py-yaml-unsafe-load
|
|
140
|
+
languages: [python]
|
|
141
|
+
severity: ERROR
|
|
142
|
+
message: yaml.load without a safe Loader deserializes arbitrary objects.
|
|
143
|
+
patterns:
|
|
144
|
+
- pattern: yaml.load($X)
|
|
145
|
+
metadata:
|
|
146
|
+
cwe: "CWE-502: Deserialization of Untrusted Data"
|
|
147
|
+
category: deserialization
|
|
148
|
+
|
|
149
|
+
- id: go-sql-string-concat
|
|
150
|
+
languages: [go]
|
|
151
|
+
severity: ERROR
|
|
152
|
+
message: >-
|
|
153
|
+
A SQL query is built with fmt.Sprintf or concatenation and passed to the
|
|
154
|
+
database. Use placeholders.
|
|
155
|
+
patterns:
|
|
156
|
+
- pattern-either:
|
|
157
|
+
- pattern: $DB.Query(fmt.Sprintf(...), ...)
|
|
158
|
+
- pattern: $DB.Exec(fmt.Sprintf(...), ...)
|
|
159
|
+
- pattern: $DB.QueryRow(fmt.Sprintf(...), ...)
|
|
160
|
+
- pattern: $DB.Query($A + $B, ...)
|
|
161
|
+
- pattern: $DB.Exec($A + $B, ...)
|
|
162
|
+
metadata:
|
|
163
|
+
cwe: "CWE-89: Improper Neutralization of Special Elements used in an SQL Command"
|
|
164
|
+
category: injection
|
|
165
|
+
|
|
166
|
+
- id: go-exec-command-shell
|
|
167
|
+
languages: [go]
|
|
168
|
+
severity: WARNING
|
|
169
|
+
message: >-
|
|
170
|
+
A shell is invoked with -c and a command that is not a constant.
|
|
171
|
+
patterns:
|
|
172
|
+
- pattern-either:
|
|
173
|
+
- pattern: exec.Command("sh", "-c", $CMD, ...)
|
|
174
|
+
- pattern: exec.Command("bash", "-c", $CMD, ...)
|
|
175
|
+
- pattern-not: exec.Command("...", "-c", "...", ...)
|
|
176
|
+
metadata:
|
|
177
|
+
cwe: "CWE-78: Improper Neutralization of Special Elements used in an OS Command"
|
|
178
|
+
category: injection
|