facult 1.0.1
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 +21 -0
- package/README.md +383 -0
- package/bin/facult.cjs +302 -0
- package/package.json +78 -0
- package/src/adapters/claude-cli.ts +18 -0
- package/src/adapters/claude-desktop.ts +15 -0
- package/src/adapters/clawdbot.ts +18 -0
- package/src/adapters/codex.ts +19 -0
- package/src/adapters/cursor.ts +18 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.ts +270 -0
- package/src/adapters/reference.ts +9 -0
- package/src/adapters/skills.ts +47 -0
- package/src/adapters/types.ts +42 -0
- package/src/adapters/version.ts +18 -0
- package/src/audit/agent.ts +1071 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/static.ts +1130 -0
- package/src/audit/tui.ts +704 -0
- package/src/audit/types.ts +68 -0
- package/src/audit/update-index.ts +115 -0
- package/src/conflicts.ts +135 -0
- package/src/consolidate-conflict-action.ts +57 -0
- package/src/consolidate.ts +1637 -0
- package/src/enable-disable.ts +349 -0
- package/src/index-builder.ts +562 -0
- package/src/index.ts +589 -0
- package/src/manage.ts +894 -0
- package/src/migrate.ts +272 -0
- package/src/paths.ts +238 -0
- package/src/quarantine.ts +217 -0
- package/src/query.ts +186 -0
- package/src/remote-manifest-integrity.ts +367 -0
- package/src/remote-providers.ts +905 -0
- package/src/remote-source-policy.ts +237 -0
- package/src/remote-sources.ts +162 -0
- package/src/remote-types.ts +136 -0
- package/src/remote.ts +1970 -0
- package/src/scan.ts +2427 -0
- package/src/schema.ts +39 -0
- package/src/self-update.ts +408 -0
- package/src/snippets-cli.ts +293 -0
- package/src/snippets.ts +706 -0
- package/src/source-trust.ts +203 -0
- package/src/trust-list.ts +232 -0
- package/src/trust.ts +170 -0
- package/src/tui.ts +118 -0
- package/src/util/codex-toml.ts +126 -0
- package/src/util/json.ts +32 -0
- package/src/util/skills.ts +55 -0
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { readFacultConfig } from "../paths";
|
|
6
|
+
import type { ScanResult } from "../scan";
|
|
7
|
+
import { scan } from "../scan";
|
|
8
|
+
import {
|
|
9
|
+
extractCodexTomlMcpServerBlocks,
|
|
10
|
+
sanitizeCodexTomlMcpText,
|
|
11
|
+
} from "../util/codex-toml";
|
|
12
|
+
import { parseJsonLenient } from "../util/json";
|
|
13
|
+
import {
|
|
14
|
+
type AuditFinding,
|
|
15
|
+
type AuditItemResult,
|
|
16
|
+
type AuditRule,
|
|
17
|
+
type CompiledAuditRule,
|
|
18
|
+
isAtLeastSeverity,
|
|
19
|
+
parseSeverity,
|
|
20
|
+
SEVERITY_ORDER,
|
|
21
|
+
type Severity,
|
|
22
|
+
type StaticAuditReport,
|
|
23
|
+
} from "./types";
|
|
24
|
+
import { updateIndexFromAuditReport } from "./update-index";
|
|
25
|
+
|
|
26
|
+
const SECRET_ENV_KEY_RE = /(TOKEN|KEY|SECRET|PASSWORD|PASS|BEARER)/i;
|
|
27
|
+
|
|
28
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
29
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function uniqueSorted(values: string[]): string[] {
|
|
33
|
+
return Array.from(new Set(values)).sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requestedNameFromArgv(argv: string[]): string | null {
|
|
37
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
38
|
+
const arg = argv[i];
|
|
39
|
+
if (!arg) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (arg === "--severity") {
|
|
43
|
+
i += 1; // skip its value
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (arg.startsWith("--severity=")) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (arg === "--from") {
|
|
50
|
+
i += 1; // skip its value
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (arg.startsWith("--from=")) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === "--rules") {
|
|
57
|
+
i += 1; // skip its value
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (arg.startsWith("--rules=")) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg === "--json") {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (arg.startsWith("-")) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
return arg;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseSeverityFlag(argv: string[]): Severity | null {
|
|
75
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
76
|
+
const arg = argv[i];
|
|
77
|
+
if (!arg) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg === "--severity") {
|
|
81
|
+
const next = argv[i + 1];
|
|
82
|
+
if (!next) {
|
|
83
|
+
throw new Error("--severity requires low|medium|high|critical");
|
|
84
|
+
}
|
|
85
|
+
const sev = parseSeverity(next);
|
|
86
|
+
if (!sev) {
|
|
87
|
+
throw new Error(`Unknown severity: ${next}`);
|
|
88
|
+
}
|
|
89
|
+
return sev;
|
|
90
|
+
}
|
|
91
|
+
if (arg.startsWith("--severity=")) {
|
|
92
|
+
const raw = arg.slice("--severity=".length);
|
|
93
|
+
const sev = parseSeverity(raw);
|
|
94
|
+
if (!sev) {
|
|
95
|
+
throw new Error(`Unknown severity: ${raw}`);
|
|
96
|
+
}
|
|
97
|
+
return sev;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseRulesPathFlag(argv: string[]): string | null {
|
|
104
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
105
|
+
const arg = argv[i];
|
|
106
|
+
if (!arg) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (arg === "--rules") {
|
|
110
|
+
const next = argv[i + 1];
|
|
111
|
+
if (!next) {
|
|
112
|
+
throw new Error("--rules requires a file path");
|
|
113
|
+
}
|
|
114
|
+
return next;
|
|
115
|
+
}
|
|
116
|
+
if (arg.startsWith("--rules=")) {
|
|
117
|
+
const raw = arg.slice("--rules=".length);
|
|
118
|
+
if (!raw) {
|
|
119
|
+
throw new Error("--rules requires a file path");
|
|
120
|
+
}
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseFromFlags(argv: string[]): string[] {
|
|
128
|
+
const from: string[] = [];
|
|
129
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
130
|
+
const arg = argv[i];
|
|
131
|
+
if (!arg) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (arg === "--from") {
|
|
135
|
+
const next = argv[i + 1];
|
|
136
|
+
if (!next) {
|
|
137
|
+
throw new Error("--from requires a path");
|
|
138
|
+
}
|
|
139
|
+
from.push(next);
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (arg.startsWith("--from=")) {
|
|
144
|
+
const value = arg.slice("--from=".length);
|
|
145
|
+
if (!value) {
|
|
146
|
+
throw new Error("--from requires a path");
|
|
147
|
+
}
|
|
148
|
+
from.push(value);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return from;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function redactInlineSecrets(text: string): string {
|
|
155
|
+
// Minimal redaction to avoid writing obvious tokens to audit output.
|
|
156
|
+
return text
|
|
157
|
+
.replace(/\b(sk-[A-Za-z0-9]{10,})\b/g, "sk-<redacted>")
|
|
158
|
+
.replace(/\b(ghp_[A-Za-z0-9]{10,})\b/g, "ghp_<redacted>")
|
|
159
|
+
.replace(/\b(github_pat_[A-Za-z0-9_]{10,})\b/g, "github_pat_<redacted>");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function lineOfOffset(text: string, offset: number): number {
|
|
163
|
+
if (offset <= 0) {
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
let lines = 1;
|
|
167
|
+
for (let i = 0; i < text.length && i < offset; i += 1) {
|
|
168
|
+
if (text.charCodeAt(i) === 10) {
|
|
169
|
+
lines += 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return lines;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function summarizeEvidence(text: string, offset: number): string {
|
|
176
|
+
const start = Math.max(0, offset - 80);
|
|
177
|
+
const end = Math.min(text.length, offset + 160);
|
|
178
|
+
const snippet = text.slice(start, end).replace(/\s+/g, " ").trim();
|
|
179
|
+
return redactInlineSecrets(snippet);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function compileRules(rules: AuditRule[]): CompiledAuditRule[] {
|
|
183
|
+
const compiled: CompiledAuditRule[] = [];
|
|
184
|
+
for (const r of rules) {
|
|
185
|
+
try {
|
|
186
|
+
compiled.push({
|
|
187
|
+
...r,
|
|
188
|
+
regex: new RegExp(r.pattern, "gi"),
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
// Skip invalid patterns; treat as user config error rather than hard-fail.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return compiled;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const DEFAULT_RULES: AuditRule[] = [
|
|
198
|
+
{
|
|
199
|
+
id: "exfil-instruction",
|
|
200
|
+
severity: "high",
|
|
201
|
+
target: "skill",
|
|
202
|
+
pattern:
|
|
203
|
+
"\\b(send|upload|post|exfiltrat\\w*)\\b.{0,160}\\b(external|server|webhook|pastebin|requestbin|http)\\b",
|
|
204
|
+
message: "Possible data exfiltration instruction",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: "credential-access",
|
|
208
|
+
severity: "critical",
|
|
209
|
+
target: "skill",
|
|
210
|
+
pattern:
|
|
211
|
+
"\\b(read|cat|copy|dump|steal)\\b.{0,160}\\b(\\.ssh|id_rsa|credentials|secrets?|api\\s*key|tokens?)\\b",
|
|
212
|
+
message: "Possible credential/secret access instruction",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: "sensitive-paths",
|
|
216
|
+
severity: "high",
|
|
217
|
+
target: "skill",
|
|
218
|
+
pattern: "\\b(/etc/shadow|/etc/passwd|~/?\\.ssh/id_rsa)\\b",
|
|
219
|
+
message: "Mentions a sensitive file path",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "tmp-binary",
|
|
223
|
+
severity: "critical",
|
|
224
|
+
target: "mcp",
|
|
225
|
+
pattern: "\\/tmp\\/",
|
|
226
|
+
message: "MCP server references an executable under /tmp",
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "non-https-url",
|
|
230
|
+
severity: "medium",
|
|
231
|
+
target: "mcp",
|
|
232
|
+
pattern: "\\bhttp:\\/\\/",
|
|
233
|
+
message: "MCP server URL uses http:// (non-TLS)",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "curl-pipe-shell",
|
|
237
|
+
severity: "critical",
|
|
238
|
+
target: "skill",
|
|
239
|
+
pattern:
|
|
240
|
+
"\\b(curl|wget)\\b[^\\r\\n]{0,200}\\|[^\\r\\n]{0,60}\\b(bash|sh|zsh)\\b",
|
|
241
|
+
message:
|
|
242
|
+
"Possible download-and-execute pattern (curl/wget piped into a shell).",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: "shell-process-subst-curl",
|
|
246
|
+
severity: "critical",
|
|
247
|
+
target: "skill",
|
|
248
|
+
pattern: "\\b(bash|sh|zsh)\\b\\s*<\\s*\\(\\s*(curl|wget)\\b",
|
|
249
|
+
message:
|
|
250
|
+
"Possible download-and-execute pattern (shell reading from curl/wget process substitution).",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
id: "shell-cmd-subst-curl",
|
|
254
|
+
severity: "high",
|
|
255
|
+
target: "skill",
|
|
256
|
+
pattern: "\\b(bash|sh|zsh)\\b[^\\r\\n]{0,80}\\$\\(\\s*(curl|wget)\\b",
|
|
257
|
+
message:
|
|
258
|
+
"Possible download-and-execute pattern (shell executing curl/wget output via command substitution).",
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
id: "base64-pipe-shell",
|
|
262
|
+
severity: "high",
|
|
263
|
+
target: "skill",
|
|
264
|
+
pattern:
|
|
265
|
+
"\\bbase64\\b[^\\r\\n]{0,120}(-d|--decode)\\b[^\\r\\n]{0,200}\\|[^\\r\\n]{0,60}\\b(bash|sh|zsh)\\b",
|
|
266
|
+
message:
|
|
267
|
+
"Possible obfuscated execution pattern (base64 decode piped into a shell).",
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: "eval-cmd-subst",
|
|
271
|
+
severity: "high",
|
|
272
|
+
target: "skill",
|
|
273
|
+
pattern: "\\beval\\b[^\\r\\n]{0,40}(\\$\\(|`)",
|
|
274
|
+
message:
|
|
275
|
+
"Use of eval with command substitution (risky; may execute attacker-controlled text).",
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: "chmod-tmp-exec",
|
|
279
|
+
severity: "high",
|
|
280
|
+
target: "skill",
|
|
281
|
+
pattern: "\\bchmod\\b[^\\r\\n]{0,80}\\+x\\b[^\\r\\n]{0,200}\\b\\/tmp\\/",
|
|
282
|
+
message: "Marks a /tmp path executable (risky executable location).",
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: "powershell-invoke-expression",
|
|
286
|
+
severity: "critical",
|
|
287
|
+
target: "skill",
|
|
288
|
+
pattern:
|
|
289
|
+
"\\b(powershell|pwsh)\\b[^\\r\\n]{0,200}\\b(iex|invoke-expression)\\b",
|
|
290
|
+
message:
|
|
291
|
+
"PowerShell Invoke-Expression usage (risky; may execute attacker-controlled text).",
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: "curl-insecure",
|
|
295
|
+
severity: "medium",
|
|
296
|
+
target: "skill",
|
|
297
|
+
pattern: "\\bcurl\\b[^\\r\\n]{0,80}\\s(--insecure|-k)\\b",
|
|
298
|
+
message: "curl disables TLS verification (MITM risk).",
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: "download-non-https-url",
|
|
302
|
+
severity: "medium",
|
|
303
|
+
target: "skill",
|
|
304
|
+
pattern: "\\b(curl|wget)\\b[^\\r\\n]{0,200}\\bhttp:\\/\\/",
|
|
305
|
+
message: "Download command uses http:// (non-TLS).",
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
function mergeRules(base: AuditRule[], overrides: AuditRule[]): AuditRule[] {
|
|
310
|
+
const byId = new Map<string, AuditRule>();
|
|
311
|
+
for (const r of base) {
|
|
312
|
+
byId.set(r.id, r);
|
|
313
|
+
}
|
|
314
|
+
for (const r of overrides) {
|
|
315
|
+
byId.set(r.id, r);
|
|
316
|
+
}
|
|
317
|
+
return Array.from(byId.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function loadRuleOverrides(path: string): Promise<AuditRule[]> {
|
|
321
|
+
const file = Bun.file(path);
|
|
322
|
+
if (!(await file.exists())) {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const txt = await file.text();
|
|
327
|
+
const parsed = parseYaml(txt) as unknown;
|
|
328
|
+
if (!isPlainObject(parsed)) {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const rulesValue = (parsed as Record<string, unknown>).rules;
|
|
333
|
+
if (!Array.isArray(rulesValue)) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const out: AuditRule[] = [];
|
|
338
|
+
for (const r of rulesValue) {
|
|
339
|
+
if (!isPlainObject(r)) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const id = typeof r.id === "string" ? r.id : null;
|
|
343
|
+
const severity =
|
|
344
|
+
typeof r.severity === "string" ? parseSeverity(r.severity) : null;
|
|
345
|
+
const pattern = typeof r.pattern === "string" ? r.pattern : null;
|
|
346
|
+
const message = typeof r.message === "string" ? r.message : null;
|
|
347
|
+
const target =
|
|
348
|
+
typeof r.target === "string" &&
|
|
349
|
+
(r.target === "skill" || r.target === "mcp" || r.target === "any")
|
|
350
|
+
? (r.target as "skill" | "mcp" | "any")
|
|
351
|
+
: undefined;
|
|
352
|
+
|
|
353
|
+
if (!(id && severity && pattern && message)) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
out.push({ id, severity, pattern, message, target });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function shouldApplyRule(
|
|
364
|
+
rule: CompiledAuditRule,
|
|
365
|
+
target: "skill" | "mcp"
|
|
366
|
+
): boolean {
|
|
367
|
+
const t = rule.target ?? "any";
|
|
368
|
+
if (t === "any") {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
return t === target;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function extractMcpServersObject(
|
|
375
|
+
parsed: unknown
|
|
376
|
+
): Record<string, unknown> | null {
|
|
377
|
+
if (!isPlainObject(parsed)) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const obj = parsed as Record<string, unknown>;
|
|
381
|
+
if (isPlainObject(obj.mcpServers)) {
|
|
382
|
+
return obj.mcpServers as Record<string, unknown>;
|
|
383
|
+
}
|
|
384
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
385
|
+
if (k.endsWith(".mcpServers") && isPlainObject(v)) {
|
|
386
|
+
return v as Record<string, unknown>;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (isPlainObject(obj["mcp.servers"])) {
|
|
390
|
+
return obj["mcp.servers"] as Record<string, unknown>;
|
|
391
|
+
}
|
|
392
|
+
if (isPlainObject(obj.servers)) {
|
|
393
|
+
return obj.servers as Record<string, unknown>;
|
|
394
|
+
}
|
|
395
|
+
if (isPlainObject(obj.mcp)) {
|
|
396
|
+
const mcp = obj.mcp as Record<string, unknown>;
|
|
397
|
+
if (isPlainObject(mcp.servers)) {
|
|
398
|
+
return mcp.servers as Record<string, unknown>;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function mcpSafeAuditText(definition: unknown): string {
|
|
405
|
+
if (!isPlainObject(definition)) {
|
|
406
|
+
return String(definition);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const obj = definition as Record<string, unknown>;
|
|
410
|
+
const out: Record<string, unknown> = {};
|
|
411
|
+
|
|
412
|
+
if (typeof obj.name === "string") {
|
|
413
|
+
out.name = obj.name;
|
|
414
|
+
}
|
|
415
|
+
if (typeof obj.transport === "string") {
|
|
416
|
+
out.transport = obj.transport;
|
|
417
|
+
}
|
|
418
|
+
if (typeof obj.command === "string") {
|
|
419
|
+
out.command = obj.command;
|
|
420
|
+
}
|
|
421
|
+
if (Array.isArray(obj.args)) {
|
|
422
|
+
out.args = obj.args.map(String);
|
|
423
|
+
}
|
|
424
|
+
if (typeof obj.url === "string") {
|
|
425
|
+
out.url = obj.url;
|
|
426
|
+
}
|
|
427
|
+
if (isPlainObject(obj.env)) {
|
|
428
|
+
out.envKeys = Object.keys(obj.env as Record<string, unknown>).sort();
|
|
429
|
+
}
|
|
430
|
+
if (isPlainObject(obj.vendorExtensions)) {
|
|
431
|
+
out.vendorKeys = Object.keys(
|
|
432
|
+
obj.vendorExtensions as Record<string, unknown>
|
|
433
|
+
).sort();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return JSON.stringify(out, null, 2);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isSecretEnvKey(key: string): boolean {
|
|
440
|
+
return SECRET_ENV_KEY_RE.test(key);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function structuredMcpChecks({
|
|
444
|
+
serverName,
|
|
445
|
+
configPath,
|
|
446
|
+
definition,
|
|
447
|
+
}: {
|
|
448
|
+
serverName: string;
|
|
449
|
+
configPath: string;
|
|
450
|
+
definition: unknown;
|
|
451
|
+
}): AuditFinding[] {
|
|
452
|
+
const findings: AuditFinding[] = [];
|
|
453
|
+
|
|
454
|
+
if (!isPlainObject(definition)) {
|
|
455
|
+
return findings;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const obj = definition as Record<string, unknown>;
|
|
459
|
+
|
|
460
|
+
const command = typeof obj.command === "string" ? obj.command : null;
|
|
461
|
+
if (command && command.includes("/tmp/")) {
|
|
462
|
+
findings.push({
|
|
463
|
+
severity: "critical",
|
|
464
|
+
ruleId: "mcp-command-tmp",
|
|
465
|
+
message:
|
|
466
|
+
"MCP server command references /tmp (risky executable location).",
|
|
467
|
+
location: `${configPath}:${serverName}:command`,
|
|
468
|
+
evidence: command,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const url = typeof obj.url === "string" ? obj.url : null;
|
|
473
|
+
if (url && url.startsWith("http://")) {
|
|
474
|
+
findings.push({
|
|
475
|
+
severity: "medium",
|
|
476
|
+
ruleId: "mcp-url-non-https",
|
|
477
|
+
message: "MCP server URL uses http:// (non-TLS).",
|
|
478
|
+
location: `${configPath}:${serverName}:url`,
|
|
479
|
+
evidence: url,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const args = Array.isArray(obj.args) ? obj.args.map(String) : [];
|
|
484
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
485
|
+
const a = args[i];
|
|
486
|
+
if (!a) {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (a === "--allow-write" && args[i + 1] === "/") {
|
|
490
|
+
findings.push({
|
|
491
|
+
severity: "high",
|
|
492
|
+
ruleId: "mcp-allow-write-root",
|
|
493
|
+
message: "MCP server args allow write access to '/'.",
|
|
494
|
+
location: `${configPath}:${serverName}:args`,
|
|
495
|
+
evidence: "--allow-write /",
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
if (
|
|
499
|
+
a.startsWith("--allow-write=") &&
|
|
500
|
+
a.slice("--allow-write=".length) === "/"
|
|
501
|
+
) {
|
|
502
|
+
findings.push({
|
|
503
|
+
severity: "high",
|
|
504
|
+
ruleId: "mcp-allow-write-root",
|
|
505
|
+
message: "MCP server args allow write access to '/'.",
|
|
506
|
+
location: `${configPath}:${serverName}:args`,
|
|
507
|
+
evidence: a,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (isPlainObject(obj.env)) {
|
|
513
|
+
const env = obj.env as Record<string, unknown>;
|
|
514
|
+
const secretKeys = Object.keys(env).filter((k) => isSecretEnvKey(k));
|
|
515
|
+
for (const k of secretKeys) {
|
|
516
|
+
const v = env[k];
|
|
517
|
+
if (typeof v === "string" && v.trim()) {
|
|
518
|
+
findings.push({
|
|
519
|
+
severity: "high",
|
|
520
|
+
ruleId: "mcp-env-inline-secret",
|
|
521
|
+
message:
|
|
522
|
+
"MCP server env includes what looks like a secret value (consider using indirection instead of inlining).",
|
|
523
|
+
location: `${configPath}:${serverName}:env:${k}`,
|
|
524
|
+
evidence: `${k}=<redacted>`,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return findings;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function applyRulesToText({
|
|
534
|
+
rules,
|
|
535
|
+
target,
|
|
536
|
+
itemName,
|
|
537
|
+
path,
|
|
538
|
+
text,
|
|
539
|
+
}: {
|
|
540
|
+
rules: CompiledAuditRule[];
|
|
541
|
+
target: "skill" | "mcp";
|
|
542
|
+
itemName: string;
|
|
543
|
+
path: string;
|
|
544
|
+
text: string;
|
|
545
|
+
}): AuditFinding[] {
|
|
546
|
+
const findings: AuditFinding[] = [];
|
|
547
|
+
for (const rule of rules) {
|
|
548
|
+
if (!shouldApplyRule(rule, target)) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
for (const match of text.matchAll(rule.regex)) {
|
|
552
|
+
const idx = match.index ?? 0;
|
|
553
|
+
const line = lineOfOffset(text, idx);
|
|
554
|
+
const location =
|
|
555
|
+
target === "skill" ? `${path}:SKILL.md:${line}` : `${path}:${itemName}`;
|
|
556
|
+
findings.push({
|
|
557
|
+
severity: rule.severity,
|
|
558
|
+
ruleId: rule.id,
|
|
559
|
+
message: rule.message,
|
|
560
|
+
location,
|
|
561
|
+
evidence: summarizeEvidence(text, idx),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Prefer deterministic output: sort by severity desc, then ruleId, then location.
|
|
567
|
+
return findings.sort((a, b) => {
|
|
568
|
+
const sa = SEVERITY_ORDER[a.severity];
|
|
569
|
+
const sb = SEVERITY_ORDER[b.severity];
|
|
570
|
+
return (
|
|
571
|
+
sb - sa ||
|
|
572
|
+
a.ruleId.localeCompare(b.ruleId) ||
|
|
573
|
+
(a.location ?? "").localeCompare(b.location ?? "")
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function applyRulesToFileText({
|
|
579
|
+
rules,
|
|
580
|
+
filePath,
|
|
581
|
+
text,
|
|
582
|
+
}: {
|
|
583
|
+
rules: CompiledAuditRule[];
|
|
584
|
+
filePath: string;
|
|
585
|
+
text: string;
|
|
586
|
+
}): AuditFinding[] {
|
|
587
|
+
const findings: AuditFinding[] = [];
|
|
588
|
+
for (const rule of rules) {
|
|
589
|
+
// Treat assets like instruction/config text rather than MCP server definitions.
|
|
590
|
+
if (!shouldApplyRule(rule, "skill")) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
for (const match of text.matchAll(rule.regex)) {
|
|
594
|
+
const idx = match.index ?? 0;
|
|
595
|
+
const line = lineOfOffset(text, idx);
|
|
596
|
+
findings.push({
|
|
597
|
+
severity: rule.severity,
|
|
598
|
+
ruleId: rule.id,
|
|
599
|
+
message: rule.message,
|
|
600
|
+
location: `${filePath}:${line}`,
|
|
601
|
+
evidence: summarizeEvidence(text, idx),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return findings.sort((a, b) => {
|
|
607
|
+
const sa = SEVERITY_ORDER[a.severity];
|
|
608
|
+
const sb = SEVERITY_ORDER[b.severity];
|
|
609
|
+
return (
|
|
610
|
+
sb - sa ||
|
|
611
|
+
a.ruleId.localeCompare(b.ruleId) ||
|
|
612
|
+
(a.location ?? "").localeCompare(b.location ?? "")
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function computeAuditStatus(findings: AuditFinding[]): "passed" | "flagged" {
|
|
618
|
+
const hasHighOrCritical = findings.some(
|
|
619
|
+
(f) => f.severity === "high" || f.severity === "critical"
|
|
620
|
+
);
|
|
621
|
+
return hasHighOrCritical ? "flagged" : "passed";
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function filterFindingsByMinSeverity(
|
|
625
|
+
findings: AuditFinding[],
|
|
626
|
+
min?: Severity
|
|
627
|
+
): AuditFinding[] {
|
|
628
|
+
if (!min) {
|
|
629
|
+
return findings;
|
|
630
|
+
}
|
|
631
|
+
return findings.filter((f) => isAtLeastSeverity(f.severity, min));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function ensureDir(p: string) {
|
|
635
|
+
await mkdir(p, { recursive: true });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export async function runStaticAudit(opts?: {
|
|
639
|
+
argv?: string[];
|
|
640
|
+
homeDir?: string;
|
|
641
|
+
cwd?: string;
|
|
642
|
+
rulesPath?: string;
|
|
643
|
+
minSeverity?: Severity;
|
|
644
|
+
from?: string[];
|
|
645
|
+
name?: string;
|
|
646
|
+
includeConfigFrom?: boolean;
|
|
647
|
+
includeGitHooks?: boolean;
|
|
648
|
+
}): Promise<StaticAuditReport> {
|
|
649
|
+
const argv = opts?.argv ?? [];
|
|
650
|
+
const home = opts?.homeDir ?? homedir();
|
|
651
|
+
const rulesPath =
|
|
652
|
+
opts?.rulesPath ?? join(home, ".facult", "audit-rules.yaml");
|
|
653
|
+
|
|
654
|
+
const overrides = await loadRuleOverrides(rulesPath);
|
|
655
|
+
const rules = compileRules(mergeRules(DEFAULT_RULES, overrides));
|
|
656
|
+
|
|
657
|
+
const includeConfigFrom =
|
|
658
|
+
opts?.includeConfigFrom ?? !argv.includes("--no-config-from");
|
|
659
|
+
let from = opts?.from ?? parseFromFlags(argv);
|
|
660
|
+
if (includeConfigFrom && from.length === 0) {
|
|
661
|
+
const cfg = readFacultConfig(home);
|
|
662
|
+
if (!(cfg?.scanFrom && cfg.scanFrom.length > 0)) {
|
|
663
|
+
from = ["~"];
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const res: ScanResult = await scan(argv, {
|
|
667
|
+
homeDir: home,
|
|
668
|
+
cwd: opts?.cwd,
|
|
669
|
+
includeConfigFrom,
|
|
670
|
+
includeGitHooks:
|
|
671
|
+
opts?.includeGitHooks ?? argv.includes("--include-git-hooks"),
|
|
672
|
+
from,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const skillInstances: { name: string; path: string; sourceId: string }[] = [];
|
|
676
|
+
for (const src of res.sources) {
|
|
677
|
+
for (const dir of src.skills.entries) {
|
|
678
|
+
skillInstances.push({ name: basename(dir), path: dir, sourceId: src.id });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// De-duplicate skill instances by (sourceId, path).
|
|
683
|
+
const uniqSkills = new Map<
|
|
684
|
+
string,
|
|
685
|
+
{ name: string; path: string; sourceId: string }
|
|
686
|
+
>();
|
|
687
|
+
for (const s of skillInstances) {
|
|
688
|
+
uniqSkills.set(`${s.sourceId}\0${s.path}`, s);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const mcpConfigs: { path: string; sourceId: string; format: string }[] = [];
|
|
692
|
+
for (const src of res.sources) {
|
|
693
|
+
for (const cfg of src.mcp.configs) {
|
|
694
|
+
mcpConfigs.push({ path: cfg.path, sourceId: src.id, format: cfg.format });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const uniqMcpConfigs = new Map<
|
|
699
|
+
string,
|
|
700
|
+
{ path: string; sourceId: string; format: string }
|
|
701
|
+
>();
|
|
702
|
+
for (const c of mcpConfigs) {
|
|
703
|
+
// Prefer a deterministic sourceId if the same path appears multiple times.
|
|
704
|
+
const prev = uniqMcpConfigs.get(c.path);
|
|
705
|
+
if (!prev || c.sourceId.localeCompare(prev.sourceId) < 0) {
|
|
706
|
+
uniqMcpConfigs.set(c.path, c);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const nameArg = opts?.name ?? requestedNameFromArgv(argv);
|
|
711
|
+
const requested: { kind: "skill" | "mcp"; name: string } | null = nameArg
|
|
712
|
+
? nameArg.startsWith("mcp:")
|
|
713
|
+
? { kind: "mcp", name: nameArg.slice("mcp:".length) }
|
|
714
|
+
: { kind: "skill", name: nameArg }
|
|
715
|
+
: null;
|
|
716
|
+
|
|
717
|
+
const results: AuditItemResult[] = [];
|
|
718
|
+
|
|
719
|
+
for (const skill of Array.from(uniqSkills.values()).sort(
|
|
720
|
+
(a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)
|
|
721
|
+
)) {
|
|
722
|
+
if (
|
|
723
|
+
requested &&
|
|
724
|
+
requested.kind === "skill" &&
|
|
725
|
+
requested.name !== skill.name
|
|
726
|
+
) {
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
const skillMdPath = join(skill.path, "SKILL.md");
|
|
730
|
+
const file = Bun.file(skillMdPath);
|
|
731
|
+
if (!(await file.exists())) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const text = await file.text();
|
|
735
|
+
const findings = applyRulesToText({
|
|
736
|
+
rules,
|
|
737
|
+
target: "skill",
|
|
738
|
+
itemName: skill.name,
|
|
739
|
+
path: skill.path,
|
|
740
|
+
text,
|
|
741
|
+
});
|
|
742
|
+
const status = computeAuditStatus(findings);
|
|
743
|
+
results.push({
|
|
744
|
+
item: skill.name,
|
|
745
|
+
type: "skill",
|
|
746
|
+
sourceId: skill.sourceId,
|
|
747
|
+
path: skill.path,
|
|
748
|
+
passed: status === "passed",
|
|
749
|
+
findings: filterFindingsByMinSeverity(findings, opts?.minSeverity),
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Audit hook/rules assets (Claude/Cursor hooks, .claude settings, husky scripts, etc).
|
|
754
|
+
// Skip when the user requested a single skill/mcp item.
|
|
755
|
+
if (!requested) {
|
|
756
|
+
const assetInstances: {
|
|
757
|
+
item: string;
|
|
758
|
+
path: string;
|
|
759
|
+
sourceId: string;
|
|
760
|
+
format: string;
|
|
761
|
+
kind: string;
|
|
762
|
+
}[] = [];
|
|
763
|
+
|
|
764
|
+
for (const src of res.sources) {
|
|
765
|
+
for (const f of src.assets.files) {
|
|
766
|
+
assetInstances.push({
|
|
767
|
+
item: `${f.kind}:${basename(f.path)}`,
|
|
768
|
+
path: f.path,
|
|
769
|
+
sourceId: src.id,
|
|
770
|
+
format: f.format,
|
|
771
|
+
kind: f.kind,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const uniqAssets = new Map<
|
|
777
|
+
string,
|
|
778
|
+
{
|
|
779
|
+
item: string;
|
|
780
|
+
path: string;
|
|
781
|
+
sourceId: string;
|
|
782
|
+
format: string;
|
|
783
|
+
kind: string;
|
|
784
|
+
}
|
|
785
|
+
>();
|
|
786
|
+
for (const a of assetInstances) {
|
|
787
|
+
uniqAssets.set(`${a.sourceId}\0${a.path}`, a);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const sanitizeJsonForAudit = (value: unknown): unknown => {
|
|
791
|
+
if (typeof value === "string") {
|
|
792
|
+
return redactInlineSecrets(value);
|
|
793
|
+
}
|
|
794
|
+
if (Array.isArray(value)) {
|
|
795
|
+
return value.slice(0, 200).map(sanitizeJsonForAudit);
|
|
796
|
+
}
|
|
797
|
+
if (!isPlainObject(value)) {
|
|
798
|
+
return value;
|
|
799
|
+
}
|
|
800
|
+
const out: Record<string, unknown> = {};
|
|
801
|
+
for (const [k, v] of Object.entries(value)) {
|
|
802
|
+
if (SECRET_ENV_KEY_RE.test(k)) {
|
|
803
|
+
out[k] = "<redacted>";
|
|
804
|
+
} else {
|
|
805
|
+
out[k] = sanitizeJsonForAudit(v);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
for (const asset of Array.from(uniqAssets.values()).sort(
|
|
812
|
+
(a, b) => a.item.localeCompare(b.item) || a.path.localeCompare(b.path)
|
|
813
|
+
)) {
|
|
814
|
+
const file = Bun.file(asset.path);
|
|
815
|
+
if (!(await file.exists())) {
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
let text: string;
|
|
820
|
+
try {
|
|
821
|
+
text = await file.text();
|
|
822
|
+
} catch {
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
let auditText = text;
|
|
827
|
+
if (asset.format === "json") {
|
|
828
|
+
try {
|
|
829
|
+
const parsed = parseJsonLenient(text);
|
|
830
|
+
auditText = JSON.stringify(sanitizeJsonForAudit(parsed), null, 2);
|
|
831
|
+
} catch {
|
|
832
|
+
// keep raw text if parse fails; findings should still be redacted in evidence.
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Avoid huge blobs in output. Most config assets are small.
|
|
837
|
+
const MAX_CHARS = 200_000;
|
|
838
|
+
if (auditText.length > MAX_CHARS) {
|
|
839
|
+
auditText = auditText.slice(0, MAX_CHARS);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const findings = applyRulesToFileText({
|
|
843
|
+
rules,
|
|
844
|
+
filePath: asset.path,
|
|
845
|
+
text: auditText,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const status = computeAuditStatus(findings);
|
|
849
|
+
results.push({
|
|
850
|
+
item: asset.item,
|
|
851
|
+
type: "asset",
|
|
852
|
+
sourceId: asset.sourceId,
|
|
853
|
+
path: asset.path,
|
|
854
|
+
passed: status === "passed",
|
|
855
|
+
findings: filterFindingsByMinSeverity(findings, opts?.minSeverity),
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
for (const cfg of Array.from(uniqMcpConfigs.values()).sort((a, b) =>
|
|
861
|
+
a.path.localeCompare(b.path)
|
|
862
|
+
)) {
|
|
863
|
+
const isToml = cfg.format === "toml" || cfg.path.endsWith(".toml");
|
|
864
|
+
|
|
865
|
+
if (isToml) {
|
|
866
|
+
let txt: string;
|
|
867
|
+
try {
|
|
868
|
+
txt = await Bun.file(cfg.path).text();
|
|
869
|
+
} catch (e: unknown) {
|
|
870
|
+
const err = e as { message?: string } | null;
|
|
871
|
+
results.push({
|
|
872
|
+
item: basename(cfg.path),
|
|
873
|
+
type: "mcp-config",
|
|
874
|
+
sourceId: cfg.sourceId,
|
|
875
|
+
path: cfg.path,
|
|
876
|
+
passed: false,
|
|
877
|
+
findings: filterFindingsByMinSeverity(
|
|
878
|
+
[
|
|
879
|
+
{
|
|
880
|
+
severity: "medium",
|
|
881
|
+
ruleId: "mcp-config-read-error",
|
|
882
|
+
message: "Failed to read MCP config; review manually.",
|
|
883
|
+
location: cfg.path,
|
|
884
|
+
evidence: String(err?.message ?? e),
|
|
885
|
+
},
|
|
886
|
+
],
|
|
887
|
+
opts?.minSeverity
|
|
888
|
+
),
|
|
889
|
+
});
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const blocks = extractCodexTomlMcpServerBlocks(txt);
|
|
894
|
+
const names = Object.keys(blocks).sort();
|
|
895
|
+
for (const serverName of names) {
|
|
896
|
+
if (
|
|
897
|
+
requested &&
|
|
898
|
+
requested.kind === "mcp" &&
|
|
899
|
+
requested.name !== serverName
|
|
900
|
+
) {
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const safeText = sanitizeCodexTomlMcpText(blocks[serverName] ?? "");
|
|
905
|
+
const findings = applyRulesToText({
|
|
906
|
+
rules,
|
|
907
|
+
target: "mcp",
|
|
908
|
+
itemName: serverName,
|
|
909
|
+
path: cfg.path,
|
|
910
|
+
text: safeText,
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
const status = computeAuditStatus(findings);
|
|
914
|
+
results.push({
|
|
915
|
+
item: serverName,
|
|
916
|
+
type: "mcp",
|
|
917
|
+
sourceId: cfg.sourceId,
|
|
918
|
+
path: cfg.path,
|
|
919
|
+
passed: status === "passed",
|
|
920
|
+
findings: filterFindingsByMinSeverity(findings, opts?.minSeverity),
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Default: JSON config file parsing.
|
|
927
|
+
let parsed: unknown;
|
|
928
|
+
try {
|
|
929
|
+
const txt = await Bun.file(cfg.path).text();
|
|
930
|
+
parsed = parseJsonLenient(txt);
|
|
931
|
+
} catch (e: unknown) {
|
|
932
|
+
const err = e as { message?: string } | null;
|
|
933
|
+
results.push({
|
|
934
|
+
item: basename(cfg.path),
|
|
935
|
+
type: "mcp-config",
|
|
936
|
+
sourceId: cfg.sourceId,
|
|
937
|
+
path: cfg.path,
|
|
938
|
+
passed: false,
|
|
939
|
+
findings: filterFindingsByMinSeverity(
|
|
940
|
+
[
|
|
941
|
+
{
|
|
942
|
+
severity: "medium",
|
|
943
|
+
ruleId: "mcp-config-parse-error",
|
|
944
|
+
message: "Failed to parse MCP config; review manually.",
|
|
945
|
+
location: cfg.path,
|
|
946
|
+
evidence: String(err?.message ?? e),
|
|
947
|
+
},
|
|
948
|
+
],
|
|
949
|
+
opts?.minSeverity
|
|
950
|
+
),
|
|
951
|
+
});
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const serversObj = extractMcpServersObject(parsed);
|
|
956
|
+
if (!serversObj) {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
for (const [serverName, definition] of Object.entries(serversObj).sort(
|
|
961
|
+
([a], [b]) => a.localeCompare(b)
|
|
962
|
+
)) {
|
|
963
|
+
if (
|
|
964
|
+
requested &&
|
|
965
|
+
requested.kind === "mcp" &&
|
|
966
|
+
requested.name !== serverName
|
|
967
|
+
) {
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const safeText = mcpSafeAuditText(definition);
|
|
972
|
+
const ruleFindings = applyRulesToText({
|
|
973
|
+
rules,
|
|
974
|
+
target: "mcp",
|
|
975
|
+
itemName: serverName,
|
|
976
|
+
path: cfg.path,
|
|
977
|
+
text: safeText,
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
const structured = structuredMcpChecks({
|
|
981
|
+
serverName,
|
|
982
|
+
configPath: cfg.path,
|
|
983
|
+
definition,
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
const findings = [...ruleFindings, ...structured].sort((a, b) => {
|
|
987
|
+
const sa = SEVERITY_ORDER[a.severity];
|
|
988
|
+
const sb = SEVERITY_ORDER[b.severity];
|
|
989
|
+
return (
|
|
990
|
+
sb - sa ||
|
|
991
|
+
a.ruleId.localeCompare(b.ruleId) ||
|
|
992
|
+
(a.location ?? "").localeCompare(b.location ?? "")
|
|
993
|
+
);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
const status = computeAuditStatus(findings);
|
|
997
|
+
results.push({
|
|
998
|
+
item: serverName,
|
|
999
|
+
type: "mcp",
|
|
1000
|
+
sourceId: cfg.sourceId,
|
|
1001
|
+
path: cfg.path,
|
|
1002
|
+
passed: status === "passed",
|
|
1003
|
+
findings: filterFindingsByMinSeverity(findings, opts?.minSeverity),
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const minSeverity = opts?.minSeverity ?? undefined;
|
|
1009
|
+
const bySeverity: Record<Severity, number> = {
|
|
1010
|
+
low: 0,
|
|
1011
|
+
medium: 0,
|
|
1012
|
+
high: 0,
|
|
1013
|
+
critical: 0,
|
|
1014
|
+
};
|
|
1015
|
+
let totalFindings = 0;
|
|
1016
|
+
let flaggedItems = 0;
|
|
1017
|
+
|
|
1018
|
+
for (const r of results) {
|
|
1019
|
+
const all = r.findings;
|
|
1020
|
+
totalFindings += all.length;
|
|
1021
|
+
if (!r.passed) {
|
|
1022
|
+
flaggedItems += 1;
|
|
1023
|
+
}
|
|
1024
|
+
for (const f of all) {
|
|
1025
|
+
bySeverity[f.severity] += 1;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const report: StaticAuditReport = {
|
|
1030
|
+
timestamp: new Date().toISOString(),
|
|
1031
|
+
mode: "static",
|
|
1032
|
+
minSeverity,
|
|
1033
|
+
rulesPath: (await Bun.file(rulesPath).exists()) ? rulesPath : null,
|
|
1034
|
+
results,
|
|
1035
|
+
summary: {
|
|
1036
|
+
totalItems: results.length,
|
|
1037
|
+
totalFindings,
|
|
1038
|
+
bySeverity,
|
|
1039
|
+
flaggedItems,
|
|
1040
|
+
},
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
const auditDir = join(home, ".facult", "audit");
|
|
1044
|
+
await ensureDir(auditDir);
|
|
1045
|
+
await Bun.write(
|
|
1046
|
+
join(auditDir, "static-latest.json"),
|
|
1047
|
+
`${JSON.stringify(report, null, 2)}\n`
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
return report;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function printHuman(report: StaticAuditReport) {
|
|
1054
|
+
console.log("Static Security Audit");
|
|
1055
|
+
console.log("=====================");
|
|
1056
|
+
console.log("");
|
|
1057
|
+
|
|
1058
|
+
const failures = report.results.filter(
|
|
1059
|
+
(r) => !r.passed && r.findings.length > 0
|
|
1060
|
+
);
|
|
1061
|
+
const passes = report.results.filter(
|
|
1062
|
+
(r) => r.passed || r.findings.length === 0
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
for (const r of [...failures, ...passes]) {
|
|
1066
|
+
const status = r.findings.length === 0 ? "OK" : r.passed ? "WARN" : "FAIL";
|
|
1067
|
+
const label =
|
|
1068
|
+
r.type === "mcp"
|
|
1069
|
+
? `mcp:${r.item}`
|
|
1070
|
+
: r.type === "asset"
|
|
1071
|
+
? `asset:${r.item}`
|
|
1072
|
+
: r.item;
|
|
1073
|
+
const count = r.findings.length;
|
|
1074
|
+
console.log(
|
|
1075
|
+
`${status} ${label} (${count} finding${count === 1 ? "" : "s"})`
|
|
1076
|
+
);
|
|
1077
|
+
if (count) {
|
|
1078
|
+
for (const f of r.findings) {
|
|
1079
|
+
const loc = f.location ? ` @ ${f.location}` : "";
|
|
1080
|
+
console.log(` [${f.severity.toUpperCase()}] ${f.ruleId}${loc}`);
|
|
1081
|
+
console.log(` ${f.message}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
console.log("");
|
|
1087
|
+
console.log(
|
|
1088
|
+
`Summary: ${report.summary.totalFindings} findings across ${report.summary.totalItems} items (flagged: ${report.summary.flaggedItems}).`
|
|
1089
|
+
);
|
|
1090
|
+
console.log(
|
|
1091
|
+
`By severity: critical=${report.summary.bySeverity.critical}, high=${report.summary.bySeverity.high}, medium=${report.summary.bySeverity.medium}, low=${report.summary.bySeverity.low}`
|
|
1092
|
+
);
|
|
1093
|
+
console.log(
|
|
1094
|
+
`Wrote ${join(homedir(), ".facult", "audit", "static-latest.json")}`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
export async function staticAuditCommand(argv: string[]) {
|
|
1099
|
+
const json = argv.includes("--json");
|
|
1100
|
+
const minSeverity = parseSeverityFlag(argv) ?? undefined;
|
|
1101
|
+
const rulesPath = parseRulesPathFlag(argv) ?? undefined;
|
|
1102
|
+
const from = parseFromFlags(argv);
|
|
1103
|
+
|
|
1104
|
+
let report: StaticAuditReport;
|
|
1105
|
+
try {
|
|
1106
|
+
report = await runStaticAudit({
|
|
1107
|
+
argv,
|
|
1108
|
+
rulesPath,
|
|
1109
|
+
minSeverity,
|
|
1110
|
+
from,
|
|
1111
|
+
});
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1114
|
+
process.exitCode = 1;
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Best-effort: update index.json auditStatus/lastAuditAt for canonical items.
|
|
1119
|
+
await updateIndexFromAuditReport({
|
|
1120
|
+
timestamp: report.timestamp,
|
|
1121
|
+
results: report.results,
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
if (json) {
|
|
1125
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
printHuman(report);
|
|
1130
|
+
}
|