@cirvix_ai/agent-control 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- package/src/testing.mjs +145 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cirvix policy` — check, test, explain, list.
|
|
3
|
+
*
|
|
4
|
+
* cirvix policy check does this file load, and is it sane
|
|
5
|
+
* cirvix policy test run the test cases the file declares
|
|
6
|
+
* cirvix policy explain --tool … why would this call be decided that way
|
|
7
|
+
* cirvix policy list the active rules
|
|
8
|
+
*
|
|
9
|
+
* WHY `explain` IS A FIRST-CLASS COMMAND
|
|
10
|
+
*
|
|
11
|
+
* The single most common failure of a policy engine in production is not a
|
|
12
|
+
* wrong decision — it is a rule that everyone believes is protecting them and
|
|
13
|
+
* which has never matched anything. It loads, it validates, it appears in the
|
|
14
|
+
* list, and it is dead. `explain` prints every rule that was considered and
|
|
15
|
+
* whether it matched, so a dead rule is visible the first time somebody looks
|
|
16
|
+
* rather than during the incident it failed to prevent.
|
|
17
|
+
*
|
|
18
|
+
* WHY `test` MATTERS MORE THAN `check`
|
|
19
|
+
*
|
|
20
|
+
* `check` proves the file parses. `test` proves it does what the author meant.
|
|
21
|
+
* Only the second one survives a refactor, and a rule set nobody dares change
|
|
22
|
+
* ossifies until it is bypassed rather than updated.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile } from "node:fs/promises";
|
|
26
|
+
|
|
27
|
+
import { evaluate, parseRules, validateRules } from "../core/policy.mjs";
|
|
28
|
+
import { compile, toSource, PolicySyntaxError } from "../core/policy-dsl.mjs";
|
|
29
|
+
import { DECISION, toDecision } from "../core/decisions.mjs";
|
|
30
|
+
import { normalize, policyRequest } from "../core/normalize.mjs";
|
|
31
|
+
import { classify } from "../core/risk.mjs";
|
|
32
|
+
import { bold, dim, green, red, amber, blue, plural } from "../core/format.mjs";
|
|
33
|
+
|
|
34
|
+
/* -------------------------------------------------------------------------- */
|
|
35
|
+
/* Loading */
|
|
36
|
+
/* -------------------------------------------------------------------------- */
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Loads a policy from either format.
|
|
40
|
+
*
|
|
41
|
+
* `.json` is the engine's on-disk shape; anything else is the DSL. Sniffing the
|
|
42
|
+
* content rather than trusting the extension, because a `.policy` file
|
|
43
|
+
* containing JSON should still work — the alternative is a confusing parse
|
|
44
|
+
* error about an unexpected `{`.
|
|
45
|
+
*/
|
|
46
|
+
export async function loadPolicyFile(path, { cwd = process.cwd() } = {}) {
|
|
47
|
+
const source = await readFile(path, "utf8");
|
|
48
|
+
const looksJson = /^\s*[[{]/.test(source);
|
|
49
|
+
|
|
50
|
+
if (looksJson) {
|
|
51
|
+
const rules = parseRules(JSON.parse(source));
|
|
52
|
+
return { rules, tests: [], format: "json", source, path };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { rules, tests } = compile(source, { cwd, origin: path });
|
|
56
|
+
return { rules, tests, format: "dsl", source, path };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* -------------------------------------------------------------------------- */
|
|
60
|
+
/* check */
|
|
61
|
+
/* -------------------------------------------------------------------------- */
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parses and validates. Exit code 1 on an error, 0 on warnings only — warnings
|
|
65
|
+
* are things that load and probably should not, and failing CI on them would
|
|
66
|
+
* teach people to stop reading them.
|
|
67
|
+
*/
|
|
68
|
+
export async function check({ path, cwd = process.cwd(), json = false, strict = false }) {
|
|
69
|
+
let loaded;
|
|
70
|
+
try {
|
|
71
|
+
loaded = await loadPolicyFile(path, { cwd });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const result = {
|
|
74
|
+
ok: false,
|
|
75
|
+
path,
|
|
76
|
+
errors: [{ rule: null, message: err.message }],
|
|
77
|
+
warnings: [],
|
|
78
|
+
rules: 0,
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
result,
|
|
82
|
+
code: 1,
|
|
83
|
+
output: json
|
|
84
|
+
? JSON.stringify(result, null, 2)
|
|
85
|
+
: `\n ${red(bold("policy did not load"))}\n\n ${err instanceof PolicySyntaxError ? err.message.split("\n").join("\n ") : err.message}\n\n`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const validation = validateRules(loaded.rules);
|
|
90
|
+
const result = {
|
|
91
|
+
ok: validation.ok,
|
|
92
|
+
path,
|
|
93
|
+
format: loaded.format,
|
|
94
|
+
rules: validation.rules,
|
|
95
|
+
tests: loaded.tests.length,
|
|
96
|
+
errors: validation.errors,
|
|
97
|
+
warnings: validation.warnings,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const code = validation.ok ? (strict && validation.warnings.length ? 1 : 0) : 1;
|
|
101
|
+
|
|
102
|
+
if (json) return { result, code, output: JSON.stringify(result, null, 2) };
|
|
103
|
+
|
|
104
|
+
const lines = [""];
|
|
105
|
+
if (validation.ok) {
|
|
106
|
+
lines.push(
|
|
107
|
+
` ${green(bold("policy is valid"))} ${dim(`${plural(validation.rules, "rule")}, ${plural(loaded.tests.length, "test case")} · ${loaded.format}`)}`,
|
|
108
|
+
);
|
|
109
|
+
} else {
|
|
110
|
+
lines.push(` ${red(bold("policy is invalid"))} ${dim(`${plural(validation.errors.length, "error")}`)}`);
|
|
111
|
+
}
|
|
112
|
+
lines.push("");
|
|
113
|
+
|
|
114
|
+
for (const e of validation.errors) {
|
|
115
|
+
lines.push(` ${red("error")} ${e.rule ? bold(e.rule) + " " : ""}${e.message}`);
|
|
116
|
+
}
|
|
117
|
+
for (const w of validation.warnings) {
|
|
118
|
+
lines.push(` ${amber("warning")} ${w.rule ? bold(w.rule) + " " : ""}${dim(w.message)}`);
|
|
119
|
+
}
|
|
120
|
+
if (validation.errors.length || validation.warnings.length) lines.push("");
|
|
121
|
+
if (validation.ok && loaded.tests.length) {
|
|
122
|
+
lines.push(` ${dim("Run the test cases:")} ${blue("cirvix policy test")}`);
|
|
123
|
+
lines.push("");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { result, code, output: lines.join("\n") };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* -------------------------------------------------------------------------- */
|
|
130
|
+
/* test */
|
|
131
|
+
/* -------------------------------------------------------------------------- */
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Runs the `test` blocks declared in the policy file.
|
|
135
|
+
*
|
|
136
|
+
* Every case is evaluated through the same normalization the runtime uses, so a
|
|
137
|
+
* test passing means the *runtime* would decide that way — not that the rule
|
|
138
|
+
* matcher would, given a hand-built request the runtime never constructs.
|
|
139
|
+
*/
|
|
140
|
+
export async function test({ path, cwd = process.cwd(), json = false, filter = null }) {
|
|
141
|
+
const loaded = await loadPolicyFile(path, { cwd });
|
|
142
|
+
|
|
143
|
+
if (!loaded.tests.length) {
|
|
144
|
+
const result = { ok: true, total: 0, passed: 0, failed: 0, cases: [] };
|
|
145
|
+
return {
|
|
146
|
+
result,
|
|
147
|
+
code: 0,
|
|
148
|
+
output: json
|
|
149
|
+
? JSON.stringify(result, null, 2)
|
|
150
|
+
: `\n ${amber("no test cases")} ${dim(`${path} declares no \`test\` blocks.`)}\n\n ${dim("A policy file that ships its own tests is one you can change safely. Add:")}\n\n${dim(' test "dotenv is not readable":\n tool = filesystem.read\n path = .env\n expect deny')}\n\n`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const cases = [];
|
|
155
|
+
for (const t of loaded.tests) {
|
|
156
|
+
if (filter && !t.name.toLowerCase().includes(String(filter).toLowerCase())) continue;
|
|
157
|
+
|
|
158
|
+
const call = normalize(
|
|
159
|
+
{ tool: t.call.tool, server: t.call.server ?? null, arguments: t.call.arguments },
|
|
160
|
+
{ agent: t.call.agent, environment: t.call.environment, cwd },
|
|
161
|
+
);
|
|
162
|
+
const decision = evaluate(policyRequest(call), loaded.rules, { cwd });
|
|
163
|
+
const actual = decision.decision ?? toDecision(decision.verdict);
|
|
164
|
+
const expected = normalizeExpectation(t.expect);
|
|
165
|
+
|
|
166
|
+
cases.push({
|
|
167
|
+
name: t.name,
|
|
168
|
+
line: t.line,
|
|
169
|
+
expected,
|
|
170
|
+
actual,
|
|
171
|
+
passed: actual === expected,
|
|
172
|
+
rule: decision.rule,
|
|
173
|
+
risk: call.risk,
|
|
174
|
+
tool: call.tool,
|
|
175
|
+
resource: call.resource,
|
|
176
|
+
reason: decision.reason,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const passed = cases.filter((c) => c.passed).length;
|
|
181
|
+
const failed = cases.length - passed;
|
|
182
|
+
const result = { ok: failed === 0, total: cases.length, passed, failed, cases };
|
|
183
|
+
|
|
184
|
+
if (json) return { result, code: failed ? 1 : 0, output: JSON.stringify(result, null, 2) };
|
|
185
|
+
|
|
186
|
+
const lines = ["", ` ${bold(path)}`, ""];
|
|
187
|
+
for (const c of cases) {
|
|
188
|
+
if (c.passed) {
|
|
189
|
+
lines.push(` ${green("✓")} ${c.name} ${dim(`→ ${c.actual}${c.rule ? ` (${c.rule})` : ""}`)}`);
|
|
190
|
+
} else {
|
|
191
|
+
lines.push(` ${red("✗")} ${bold(c.name)} ${dim(`line ${c.line}`)}`);
|
|
192
|
+
lines.push(` ${dim("expected")} ${green(c.expected)}`);
|
|
193
|
+
lines.push(` ${dim("actual")} ${red(c.actual)}${c.rule ? dim(` by ${c.rule}`) : dim(" by default-deny")}`);
|
|
194
|
+
lines.push(` ${dim("call")} ${c.tool} ${dim(c.resource || "")} ${dim(`risk ${String(c.risk).toUpperCase()}`)}`);
|
|
195
|
+
lines.push(` ${dim(c.reason ?? "")}`);
|
|
196
|
+
lines.push("");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
lines.push("");
|
|
200
|
+
lines.push(
|
|
201
|
+
failed === 0
|
|
202
|
+
? ` ${green(bold(`${passed} passed`))}`
|
|
203
|
+
: ` ${red(bold(`${failed} failed`))} ${dim(`${passed} passed`)}`,
|
|
204
|
+
);
|
|
205
|
+
lines.push("");
|
|
206
|
+
|
|
207
|
+
return { result, code: failed ? 1 : 0, output: lines.join("\n") };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `allow`/`permit` are the same expectation; so are `deny`/`forbid`. */
|
|
211
|
+
function normalizeExpectation(value) {
|
|
212
|
+
const v = String(value).toLowerCase();
|
|
213
|
+
return (
|
|
214
|
+
{
|
|
215
|
+
allow: DECISION.ALLOW,
|
|
216
|
+
permit: DECISION.ALLOW,
|
|
217
|
+
deny: DECISION.DENY,
|
|
218
|
+
forbid: DECISION.DENY,
|
|
219
|
+
denied: DECISION.DENY,
|
|
220
|
+
hold: DECISION.REQUIRE_APPROVAL,
|
|
221
|
+
require_approval: DECISION.REQUIRE_APPROVAL,
|
|
222
|
+
approval: DECISION.REQUIRE_APPROVAL,
|
|
223
|
+
sanitize: DECISION.SANITIZE,
|
|
224
|
+
audit_only: DECISION.AUDIT_ONLY,
|
|
225
|
+
audit: DECISION.AUDIT_ONLY,
|
|
226
|
+
}[v] ?? v
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* -------------------------------------------------------------------------- */
|
|
231
|
+
/* explain */
|
|
232
|
+
/* -------------------------------------------------------------------------- */
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Why would this call be decided this way?
|
|
236
|
+
*
|
|
237
|
+
* Prints the normalized call, the risk classification with the signals that
|
|
238
|
+
* fired, the decision, and every rule that was considered with whether it
|
|
239
|
+
* matched. The considered-list is the part that finds dead rules.
|
|
240
|
+
*/
|
|
241
|
+
export async function explain({
|
|
242
|
+
path,
|
|
243
|
+
rules = null,
|
|
244
|
+
cwd = process.cwd(),
|
|
245
|
+
json = false,
|
|
246
|
+
tool,
|
|
247
|
+
args = {},
|
|
248
|
+
agent = "local",
|
|
249
|
+
environment = "local",
|
|
250
|
+
}) {
|
|
251
|
+
// `rules` lets this run against the built-in starter set, which has no file.
|
|
252
|
+
const loaded = rules ? { rules } : await loadPolicyFile(path, { cwd });
|
|
253
|
+
|
|
254
|
+
const call = normalize({ tool, arguments: args }, { agent, environment, cwd });
|
|
255
|
+
const risk = classify(call);
|
|
256
|
+
call.risk = risk.level;
|
|
257
|
+
|
|
258
|
+
const decision = evaluate(policyRequest(call), loaded.rules, { cwd });
|
|
259
|
+
const final = decision.decision ?? toDecision(decision.verdict);
|
|
260
|
+
|
|
261
|
+
const result = {
|
|
262
|
+
call: {
|
|
263
|
+
tool: call.tool,
|
|
264
|
+
action: call.action,
|
|
265
|
+
resource: call.resource,
|
|
266
|
+
destination: call.destination,
|
|
267
|
+
command: call.command,
|
|
268
|
+
agent: call.agent,
|
|
269
|
+
environment: call.environment,
|
|
270
|
+
insideWorkspace: call.insideWorkspace,
|
|
271
|
+
egress: call.egress,
|
|
272
|
+
},
|
|
273
|
+
risk: { level: risk.level, signals: risk.signals, reason: risk.reason, posture: risk.posture },
|
|
274
|
+
decision: final,
|
|
275
|
+
rule: decision.rule,
|
|
276
|
+
reason: decision.reason,
|
|
277
|
+
remediation: decision.remediation ?? null,
|
|
278
|
+
approvers: decision.approvers ?? [],
|
|
279
|
+
considered: decision.considered,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
if (json) return { result, code: final === DECISION.DENY ? 1 : 0, output: JSON.stringify(result, null, 2) };
|
|
283
|
+
|
|
284
|
+
const tone = { allow: green, sanitize: blue, require_approval: amber, deny: red, audit_only: dim }[final] ?? dim;
|
|
285
|
+
const riskTone = { low: dim, medium: blue, high: amber, critical: red }[risk.level] ?? dim;
|
|
286
|
+
|
|
287
|
+
const lines = [
|
|
288
|
+
"",
|
|
289
|
+
` ${tone(bold(String(final).toUpperCase().replace(/_/g, " ")))} ${bold(call.tool)} ${dim(call.resource || call.command || "")}`,
|
|
290
|
+
"",
|
|
291
|
+
` ${dim("action")} ${call.action}`,
|
|
292
|
+
` ${dim("resource")} ${call.resource || dim("—")}`,
|
|
293
|
+
call.command ? ` ${dim("command")} ${call.command}` : "",
|
|
294
|
+
call.destination ? ` ${dim("destination")} ${call.destination}` : "",
|
|
295
|
+
` ${dim("workspace")} ${call.insideWorkspace ? "inside" : red("outside")}`,
|
|
296
|
+
` ${dim("egress")} ${call.egress}`,
|
|
297
|
+
"",
|
|
298
|
+
` ${dim("risk")} ${riskTone(bold(risk.level.toUpperCase()))} ${dim(`default posture: ${risk.posture}`)}`,
|
|
299
|
+
...risk.signals.map((s) => ` ${dim("·")} ${s.id.padEnd(28)} ${dim(s.why)}`),
|
|
300
|
+
"",
|
|
301
|
+
` ${dim("rule")} ${decision.rule ?? dim("— no rule matched (default deny)")}`,
|
|
302
|
+
` ${dim("reason")} ${decision.reason}`,
|
|
303
|
+
decision.remediation ? ` ${dim("fix")} ${blue(decision.remediation)}` : "",
|
|
304
|
+
decision.approvers?.length ? ` ${dim("waits on")} ${decision.approvers.join(", ")}` : "",
|
|
305
|
+
"",
|
|
306
|
+
` ${dim("considered")} ${dim(`${decision.considered.filter((c) => c.matched).length} of ${decision.considered.length} matched`)}`,
|
|
307
|
+
...decision.considered.map(
|
|
308
|
+
(c) =>
|
|
309
|
+
` ${c.matched ? bold("→") : dim(" ")} ${dim(String(c.effect).padEnd(11))} ${c.matched ? c.rule : dim(c.rule)}`,
|
|
310
|
+
),
|
|
311
|
+
"",
|
|
312
|
+
].filter((l) => l !== "");
|
|
313
|
+
|
|
314
|
+
return { result, code: final === DECISION.DENY ? 1 : 0, output: lines.join("\n") + "\n" };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* -------------------------------------------------------------------------- */
|
|
318
|
+
/* list */
|
|
319
|
+
/* -------------------------------------------------------------------------- */
|
|
320
|
+
|
|
321
|
+
export function list(rules, { json = false, source = false, cwd = process.cwd() } = {}) {
|
|
322
|
+
if (json) return { output: JSON.stringify(rules, null, 2), code: 0 };
|
|
323
|
+
if (source) return { output: "\n" + toSource(rules, { cwd }) + "\n", code: 0 };
|
|
324
|
+
|
|
325
|
+
const tone = { permit: green, forbid: red, hold: amber, sanitize: blue, audit_only: dim };
|
|
326
|
+
const label = { permit: "allow", forbid: "deny", hold: "approval", sanitize: "sanitize", audit_only: "audit" };
|
|
327
|
+
|
|
328
|
+
const lines = ["", ` ${bold(plural(rules.length, "rule"))}`, ""];
|
|
329
|
+
for (const r of rules) {
|
|
330
|
+
const paint = tone[r.effect] ?? dim;
|
|
331
|
+
lines.push(` ${paint((label[r.effect] ?? r.effect).padEnd(9))} ${bold(r.name)}`);
|
|
332
|
+
const scope = [
|
|
333
|
+
r.actions?.length ? r.actions.join(", ") : null,
|
|
334
|
+
r.resources?.length ? r.resources.join(", ") : null,
|
|
335
|
+
r.when?.length ? r.when.map((c) => `${c.path} ${c.op} ${Array.isArray(c.value) ? `[${c.value.join("|")}]` : c.value}`).join(" and ") : null,
|
|
336
|
+
]
|
|
337
|
+
.filter(Boolean)
|
|
338
|
+
.join(" · ");
|
|
339
|
+
if (scope) lines.push(` ${dim(scope)}`);
|
|
340
|
+
if (r.reason) lines.push(` ${dim(r.reason)}`);
|
|
341
|
+
lines.push("");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { output: lines.join("\n"), code: 0 };
|
|
345
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SARIF 2.1.0 output for `cirvix scan`.
|
|
3
|
+
*
|
|
4
|
+
* The point is not the file format. It is that a finding uploaded as SARIF
|
|
5
|
+
* lands in GitHub's Security tab and on the pull request diff, where somebody
|
|
6
|
+
* will see it — rather than in a CI log, where a red X means "the build broke"
|
|
7
|
+
* and gets retried. A security tool whose findings only appear in logs is a
|
|
8
|
+
* security tool people learn to ignore.
|
|
9
|
+
*
|
|
10
|
+
* TWO THINGS THAT MAKE THE UPLOAD BEHAVE
|
|
11
|
+
*
|
|
12
|
+
* 1. `partialFingerprints` is stable across runs. Without it, GitHub treats
|
|
13
|
+
* every scan as a fresh set of findings, so an issue somebody triaged and
|
|
14
|
+
* dismissed reappears on the next push and the whole feature becomes noise.
|
|
15
|
+
* The fingerprint is over the rule and the subject, not over the message,
|
|
16
|
+
* so rewording a `detail` string does not resurrect a dismissal.
|
|
17
|
+
*
|
|
18
|
+
* 2. Paths are repository-relative. SARIF's `uri` is resolved against the
|
|
19
|
+
* checkout root, and an absolute path from the scanning machine points at
|
|
20
|
+
* nothing on GitHub's side — the finding uploads successfully and then
|
|
21
|
+
* appears attached to no file at all.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { relative } from "node:path";
|
|
26
|
+
|
|
27
|
+
const VERSION = "0.1.0";
|
|
28
|
+
|
|
29
|
+
/** SARIF has three levels; ours has three severities. They do not line up 1:1. */
|
|
30
|
+
const LEVEL = { high: "error", medium: "warning", low: "note" };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* GitHub sorts and filters on `security-severity`, a CVSS-like number, and
|
|
34
|
+
* shows nothing useful without it.
|
|
35
|
+
*/
|
|
36
|
+
const SECURITY_SEVERITY = { high: "8.0", medium: "5.0", low: "3.0" };
|
|
37
|
+
|
|
38
|
+
const HELP = {
|
|
39
|
+
"runtime-ungoverned":
|
|
40
|
+
"This agent runtime routes tool calls directly to MCP servers. Point it at `cirvix gateway` so every call is evaluated against policy first.",
|
|
41
|
+
"framework-unguarded":
|
|
42
|
+
"This framework's tool boundary has no guard on it. Wrap the executor with `guard.wrap()` from `@cirvix_ai/agent-control`.",
|
|
43
|
+
"server-broad-scope":
|
|
44
|
+
"This MCP server is configured with a scope far wider than a workspace. Narrow it, or add a policy rule bounding what an agent may reach through it.",
|
|
45
|
+
"server-inline-secrets":
|
|
46
|
+
"Credentials sit in a plaintext MCP configuration file. Move them behind a secret handle so the agent never holds the value.",
|
|
47
|
+
"server-duplicated":
|
|
48
|
+
"The same server is configured independently in several runtimes, so a change in one leaves the others behind.",
|
|
49
|
+
"env-readable":
|
|
50
|
+
"A .env file is readable by any agent with filesystem access. This is the most common path from a prompt injection to a live credential.",
|
|
51
|
+
"credential-readable":
|
|
52
|
+
"Cloud, SSH, or registry credentials are readable from agent context. Deny reads of this path and broker the value instead.",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Converts a scan result to a SARIF log.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} result the object `scan()` returns
|
|
59
|
+
* @param {object} [opts]
|
|
60
|
+
* @param {string} [opts.root] repository root, for relative paths
|
|
61
|
+
*/
|
|
62
|
+
export function toSarif(result, { root = process.cwd() } = {}) {
|
|
63
|
+
const findings = result.findings ?? [];
|
|
64
|
+
|
|
65
|
+
// One rule per code, not per finding — SARIF's model is "rules produce
|
|
66
|
+
// results", and emitting a rule per result makes GitHub's rule filter
|
|
67
|
+
// useless.
|
|
68
|
+
const codes = [...new Set(findings.map((f) => f.code))];
|
|
69
|
+
const rules = codes.map((code) => ({
|
|
70
|
+
id: code,
|
|
71
|
+
name: code.replace(/(^|-)(\w)/g, (_, dash, c) => (dash ? "" : "") + c.toUpperCase()),
|
|
72
|
+
shortDescription: { text: describe(code) },
|
|
73
|
+
fullDescription: { text: HELP[code] ?? describe(code) },
|
|
74
|
+
help: { text: HELP[code] ?? describe(code) },
|
|
75
|
+
defaultConfiguration: {
|
|
76
|
+
level: LEVEL[severityOf(findings, code)] ?? "warning",
|
|
77
|
+
},
|
|
78
|
+
properties: {
|
|
79
|
+
tags: ["security", "ai-agents", "cirvix"],
|
|
80
|
+
"security-severity": SECURITY_SEVERITY[severityOf(findings, code)] ?? "5.0",
|
|
81
|
+
},
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
86
|
+
version: "2.1.0",
|
|
87
|
+
runs: [
|
|
88
|
+
{
|
|
89
|
+
tool: {
|
|
90
|
+
driver: {
|
|
91
|
+
name: "Cirvix AgentControl",
|
|
92
|
+
informationUri: "https://www.cirvix.com",
|
|
93
|
+
version: VERSION,
|
|
94
|
+
semanticVersion: VERSION,
|
|
95
|
+
rules,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
// A scan of a developer machine finds things outside the repository —
|
|
99
|
+
// an SSH key, a globally configured MCP server. Those are real
|
|
100
|
+
// findings and they are reported, but they cannot be attached to a
|
|
101
|
+
// line of code, so they are anchored at the repository root rather
|
|
102
|
+
// than at a path GitHub cannot resolve.
|
|
103
|
+
results: findings.map((finding) => ({
|
|
104
|
+
ruleId: finding.code,
|
|
105
|
+
level: LEVEL[finding.severity] ?? "warning",
|
|
106
|
+
message: { text: `${finding.subject}: ${finding.detail}` },
|
|
107
|
+
locations: [
|
|
108
|
+
{
|
|
109
|
+
physicalLocation: {
|
|
110
|
+
artifactLocation: { uri: locationFor(finding, root) },
|
|
111
|
+
region: { startLine: 1 },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
partialFingerprints: {
|
|
116
|
+
cirvixFindingV1: fingerprint(finding),
|
|
117
|
+
},
|
|
118
|
+
properties: {
|
|
119
|
+
severity: finding.severity,
|
|
120
|
+
remediation: finding.fix ?? null,
|
|
121
|
+
},
|
|
122
|
+
})),
|
|
123
|
+
invocations: [
|
|
124
|
+
{
|
|
125
|
+
executionSuccessful: true,
|
|
126
|
+
endTimeUtc: result.scannedAt,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stable across runs, so a dismissal sticks.
|
|
136
|
+
*
|
|
137
|
+
* Over the rule and the subject only — rewording a `detail` string must not
|
|
138
|
+
* resurrect something a human already triaged.
|
|
139
|
+
*/
|
|
140
|
+
function fingerprint(finding) {
|
|
141
|
+
return createHash("sha256")
|
|
142
|
+
.update(`${finding.code}\u0000${finding.subject}`)
|
|
143
|
+
.digest("hex")
|
|
144
|
+
.slice(0, 32);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function locationFor(finding, root) {
|
|
148
|
+
const raw = finding.path ?? finding.file ?? null;
|
|
149
|
+
if (!raw) return ".";
|
|
150
|
+
const rel = relative(root, raw).split("\\").join("/");
|
|
151
|
+
// A path outside the checkout resolves to nothing on GitHub's side, and a
|
|
152
|
+
// result attached to nothing is worse than one attached to the root.
|
|
153
|
+
return rel && !rel.startsWith("..") ? rel : ".";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function severityOf(findings, code) {
|
|
157
|
+
const order = { high: 0, medium: 1, low: 2 };
|
|
158
|
+
return findings
|
|
159
|
+
.filter((f) => f.code === code)
|
|
160
|
+
.map((f) => f.severity)
|
|
161
|
+
.sort((a, b) => order[a] - order[b])[0];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function describe(code) {
|
|
165
|
+
return (
|
|
166
|
+
{
|
|
167
|
+
"runtime-ungoverned": "An agent runtime is not routed through a control plane",
|
|
168
|
+
"framework-unguarded": "An agent framework has no guard on its tool boundary",
|
|
169
|
+
"server-broad-scope": "An MCP server is configured with a scope wider than a workspace",
|
|
170
|
+
"server-inline-secrets": "An MCP configuration carries credentials inline",
|
|
171
|
+
"server-duplicated": "One MCP server is configured independently in several runtimes",
|
|
172
|
+
"env-readable": "A .env file is readable from agent context",
|
|
173
|
+
"credential-readable": "Cloud or SSH credentials are readable from agent context",
|
|
174
|
+
}[code] ?? code
|
|
175
|
+
);
|
|
176
|
+
}
|