facult 2.6.0 → 2.7.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/README.md +145 -337
- package/package.json +1 -1
- package/src/adapters/codex.ts +1 -1
- package/src/audit/agent.ts +26 -24
- package/src/audit/fix.ts +875 -0
- package/src/audit/index.ts +51 -2
- package/src/audit/safe.ts +596 -0
- package/src/audit/static.ts +151 -34
- package/src/audit/status.ts +21 -0
- package/src/audit/suppressions.ts +266 -0
- package/src/audit/tui.ts +784 -174
- package/src/audit/update-index.ts +4 -17
- package/src/builtin.ts +7 -1
- package/src/cli-ui.ts +375 -0
- package/src/consolidate.ts +151 -55
- package/src/doctor.ts +327 -0
- package/src/global-docs.ts +43 -2
- package/src/index.ts +571 -292
- package/src/manage.ts +931 -88
- package/src/mcp-config.ts +132 -0
- package/src/project-sync.ts +288 -0
- package/src/remote.ts +387 -117
- package/src/trust.ts +119 -11
- package/src/util/git.ts +95 -0
package/src/audit/fix.ts
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { loadManagedState, syncManagedTools } from "../manage";
|
|
5
|
+
import {
|
|
6
|
+
extractServersObject,
|
|
7
|
+
isInlineMcpSecretValue,
|
|
8
|
+
loadCanonicalMcpState,
|
|
9
|
+
stringifyCanonicalMcpServers,
|
|
10
|
+
} from "../mcp-config";
|
|
11
|
+
import { facultContextRootDir, facultStateDir } from "../paths";
|
|
12
|
+
import { getGitPathExposure } from "../util/git";
|
|
13
|
+
import { parseJsonLenient } from "../util/json";
|
|
14
|
+
import type { AgentAuditReport } from "./agent";
|
|
15
|
+
import { computeStoredAuditStatus, isStoredAuditStatusPassed } from "./status";
|
|
16
|
+
import {
|
|
17
|
+
applyAuditSuppressionsToAgentReport,
|
|
18
|
+
applyAuditSuppressionsToStaticReport,
|
|
19
|
+
} from "./suppressions";
|
|
20
|
+
import type { AuditFinding, AuditItemResult, StaticAuditReport } from "./types";
|
|
21
|
+
import { updateIndexFromAuditReport } from "./update-index";
|
|
22
|
+
|
|
23
|
+
type AuditFixSource = "static" | "agent" | "combined";
|
|
24
|
+
const RULE_ID_PREFIX_RE = /^(static|agent):/;
|
|
25
|
+
const INLINE_SECRET_RULE_ID = "mcp-env-inline-secret";
|
|
26
|
+
const ARG_VALUE_SPLIT_RE = /=(.*)/s;
|
|
27
|
+
|
|
28
|
+
interface AuditFixArgs {
|
|
29
|
+
all: boolean;
|
|
30
|
+
dryRun: boolean;
|
|
31
|
+
itemSelectors: string[];
|
|
32
|
+
json: boolean;
|
|
33
|
+
paths: string[];
|
|
34
|
+
source?: AuditFixSource;
|
|
35
|
+
yes: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface FindingSelection {
|
|
39
|
+
result: AuditItemResult;
|
|
40
|
+
finding: AuditFinding;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeRuleId(ruleId: string): string {
|
|
48
|
+
return ruleId.replace(RULE_ID_PREFIX_RE, "");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseSource(value: string): AuditFixSource {
|
|
52
|
+
const normalized = value.trim().toLowerCase();
|
|
53
|
+
if (
|
|
54
|
+
normalized === "static" ||
|
|
55
|
+
normalized === "agent" ||
|
|
56
|
+
normalized === "combined"
|
|
57
|
+
) {
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`Unknown audit fix source: ${value}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseAuditFixArgs(argv: string[]): AuditFixArgs {
|
|
64
|
+
const args: AuditFixArgs = {
|
|
65
|
+
all: false,
|
|
66
|
+
dryRun: false,
|
|
67
|
+
itemSelectors: [],
|
|
68
|
+
json: false,
|
|
69
|
+
paths: [],
|
|
70
|
+
yes: false,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
74
|
+
const arg = argv[i];
|
|
75
|
+
if (!arg) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (arg === "--all") {
|
|
80
|
+
args.all = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (arg === "--dry-run") {
|
|
84
|
+
args.dryRun = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg === "--json") {
|
|
88
|
+
args.json = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (arg === "--yes" || arg === "-y") {
|
|
92
|
+
args.yes = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (arg === "--source" || arg === "--item" || arg === "--path") {
|
|
97
|
+
const next = argv[i + 1];
|
|
98
|
+
if (!next) {
|
|
99
|
+
throw new Error(`${arg} requires a value`);
|
|
100
|
+
}
|
|
101
|
+
if (arg === "--source") {
|
|
102
|
+
args.source = parseSource(next);
|
|
103
|
+
} else if (arg === "--item") {
|
|
104
|
+
args.itemSelectors.push(next);
|
|
105
|
+
} else {
|
|
106
|
+
args.paths.push(next);
|
|
107
|
+
}
|
|
108
|
+
i += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
arg.startsWith("--source=") ||
|
|
114
|
+
arg.startsWith("--item=") ||
|
|
115
|
+
arg.startsWith("--path=")
|
|
116
|
+
) {
|
|
117
|
+
const [flag, rawValue] = arg.split(ARG_VALUE_SPLIT_RE, 2);
|
|
118
|
+
const value = rawValue ?? "";
|
|
119
|
+
if (!value) {
|
|
120
|
+
throw new Error(`${flag} requires a value`);
|
|
121
|
+
}
|
|
122
|
+
if (flag === "--source") {
|
|
123
|
+
args.source = parseSource(value);
|
|
124
|
+
} else if (flag === "--item") {
|
|
125
|
+
args.itemSelectors.push(value);
|
|
126
|
+
} else {
|
|
127
|
+
args.paths.push(value);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (arg.startsWith("-")) {
|
|
133
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
args.itemSelectors.push(arg);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!args.all && args.itemSelectors.length === 0 && args.paths.length === 0) {
|
|
140
|
+
throw new Error("Specify what to fix with --item, --path, or use --all.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return args;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseInlineSecretLocation(location: string): {
|
|
147
|
+
configPath: string;
|
|
148
|
+
serverName: string;
|
|
149
|
+
envKey: string;
|
|
150
|
+
} | null {
|
|
151
|
+
const envMarker = location.lastIndexOf(":env:");
|
|
152
|
+
if (envMarker <= 0) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const envKey = location.slice(envMarker + ":env:".length).trim();
|
|
156
|
+
const left = location.slice(0, envMarker);
|
|
157
|
+
const serverMarker = left.lastIndexOf(":");
|
|
158
|
+
if (serverMarker <= 0 || !envKey) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const configPath = left.slice(0, serverMarker);
|
|
162
|
+
const serverName = left.slice(serverMarker + 1).trim();
|
|
163
|
+
if (!(configPath && serverName)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return { configPath, serverName, envKey };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
|
|
170
|
+
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function ensureServerRecord(
|
|
174
|
+
servers: Record<string, unknown>,
|
|
175
|
+
serverName: string
|
|
176
|
+
): Record<string, unknown> {
|
|
177
|
+
const current = servers[serverName];
|
|
178
|
+
if (isPlainObject(current)) {
|
|
179
|
+
return current;
|
|
180
|
+
}
|
|
181
|
+
const next: Record<string, unknown> = {};
|
|
182
|
+
servers[serverName] = next;
|
|
183
|
+
return next;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readSecretFromServer(
|
|
187
|
+
server: Record<string, unknown> | null,
|
|
188
|
+
envKey: string
|
|
189
|
+
): string | null {
|
|
190
|
+
if (!server) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
const env = server.env;
|
|
194
|
+
if (!isPlainObject(env)) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const value = env[envKey];
|
|
198
|
+
return isInlineMcpSecretValue(value) ? value : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function scrubTrackedServerEnv(
|
|
202
|
+
server: Record<string, unknown>,
|
|
203
|
+
envKey: string
|
|
204
|
+
) {
|
|
205
|
+
const env = server.env;
|
|
206
|
+
if (!isPlainObject(env)) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
delete env[envKey];
|
|
210
|
+
if (Object.keys(env).length === 0) {
|
|
211
|
+
server.env = undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function setLocalServerEnv(args: {
|
|
216
|
+
localServers: Record<string, unknown>;
|
|
217
|
+
serverName: string;
|
|
218
|
+
envKey: string;
|
|
219
|
+
secretValue: string;
|
|
220
|
+
}) {
|
|
221
|
+
const server = ensureServerRecord(args.localServers, args.serverName);
|
|
222
|
+
const env = isPlainObject(server.env)
|
|
223
|
+
? (server.env as Record<string, unknown>)
|
|
224
|
+
: {};
|
|
225
|
+
env[args.envKey] = args.secretValue;
|
|
226
|
+
server.env = env;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function findingKey(args: {
|
|
230
|
+
result: AuditItemResult;
|
|
231
|
+
finding: AuditFinding;
|
|
232
|
+
}): string {
|
|
233
|
+
const parsed = args.finding.location
|
|
234
|
+
? parseInlineSecretLocation(args.finding.location)
|
|
235
|
+
: null;
|
|
236
|
+
return [
|
|
237
|
+
args.result.type,
|
|
238
|
+
args.result.item,
|
|
239
|
+
parsed?.serverName ?? "",
|
|
240
|
+
parsed?.envKey ?? "",
|
|
241
|
+
normalizeRuleId(args.finding.ruleId),
|
|
242
|
+
].join("\0");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function keyForResult(result: AuditItemResult): string {
|
|
246
|
+
return `${result.type}\0${result.item}\0${result.path}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function prefixRuleId(
|
|
250
|
+
finding: AuditFinding,
|
|
251
|
+
prefix: "static" | "agent"
|
|
252
|
+
): AuditFinding {
|
|
253
|
+
return finding.ruleId.startsWith(`${prefix}:`)
|
|
254
|
+
? finding
|
|
255
|
+
: { ...finding, ruleId: `${prefix}:${finding.ruleId}` };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function uniqueByKey<T>(items: T[], key: (value: T) => string): T[] {
|
|
259
|
+
const seen = new Set<string>();
|
|
260
|
+
const out: T[] = [];
|
|
261
|
+
for (const item of items) {
|
|
262
|
+
const itemKey = key(item);
|
|
263
|
+
if (seen.has(itemKey)) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
seen.add(itemKey);
|
|
267
|
+
out.push(item);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function mergeStaticAndAgentResults(args: {
|
|
273
|
+
static: AuditItemResult[];
|
|
274
|
+
agent: AuditItemResult[];
|
|
275
|
+
}): AuditItemResult[] {
|
|
276
|
+
const byKey = new Map<
|
|
277
|
+
string,
|
|
278
|
+
{ static?: AuditItemResult; agent?: AuditItemResult }
|
|
279
|
+
>();
|
|
280
|
+
|
|
281
|
+
for (const result of args.static) {
|
|
282
|
+
const key = keyForResult(result);
|
|
283
|
+
const previous = byKey.get(key) ?? {};
|
|
284
|
+
byKey.set(key, { ...previous, static: result });
|
|
285
|
+
}
|
|
286
|
+
for (const result of args.agent) {
|
|
287
|
+
const key = keyForResult(result);
|
|
288
|
+
const previous = byKey.get(key) ?? {};
|
|
289
|
+
byKey.set(key, { ...previous, agent: result });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const out: AuditItemResult[] = [];
|
|
293
|
+
for (const key of [...byKey.keys()].sort()) {
|
|
294
|
+
const entry = byKey.get(key);
|
|
295
|
+
if (!entry) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (entry.static && entry.agent) {
|
|
299
|
+
out.push({
|
|
300
|
+
...entry.agent,
|
|
301
|
+
passed: entry.static.passed && entry.agent.passed,
|
|
302
|
+
findings: [
|
|
303
|
+
...entry.agent.findings.map((finding) =>
|
|
304
|
+
prefixRuleId(finding, "agent")
|
|
305
|
+
),
|
|
306
|
+
...entry.static.findings.map((finding) =>
|
|
307
|
+
prefixRuleId(finding, "static")
|
|
308
|
+
),
|
|
309
|
+
],
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
out.push(entry.agent ?? entry.static!);
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function matchesItemSelector(
|
|
319
|
+
result: AuditItemResult,
|
|
320
|
+
selector: string
|
|
321
|
+
): boolean {
|
|
322
|
+
const normalized = selector.trim();
|
|
323
|
+
if (!normalized) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
const labels = [
|
|
327
|
+
result.item,
|
|
328
|
+
`${result.type}:${result.item}`,
|
|
329
|
+
result.type === "mcp" ? `mcp:${result.item}` : null,
|
|
330
|
+
basename(result.path),
|
|
331
|
+
].filter(Boolean) as string[];
|
|
332
|
+
return labels.some(
|
|
333
|
+
(label) => label.toLowerCase() === normalized.toLowerCase()
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function matchesPath(result: AuditItemResult, candidate: string): boolean {
|
|
338
|
+
const normalized = candidate.trim().toLowerCase();
|
|
339
|
+
if (!normalized) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
const path = result.path.toLowerCase();
|
|
343
|
+
return path === normalized || path.endsWith(`/${normalized}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function matchesSelection(args: {
|
|
347
|
+
result: AuditItemResult;
|
|
348
|
+
filters: AuditFixArgs;
|
|
349
|
+
}): boolean {
|
|
350
|
+
if (
|
|
351
|
+
args.filters.itemSelectors.length > 0 &&
|
|
352
|
+
!args.filters.itemSelectors.some((selector) =>
|
|
353
|
+
matchesItemSelector(args.result, selector)
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
args.filters.paths.length > 0 &&
|
|
361
|
+
!args.filters.paths.some((candidate) => matchesPath(args.result, candidate))
|
|
362
|
+
) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function loadLatestStaticReport(
|
|
370
|
+
homeDir: string
|
|
371
|
+
): Promise<StaticAuditReport | null> {
|
|
372
|
+
const path = join(facultStateDir(homeDir), "audit", "static-latest.json");
|
|
373
|
+
const file = Bun.file(path);
|
|
374
|
+
if (!(await file.exists())) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return (await file.json()) as StaticAuditReport;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function loadLatestAgentReport(
|
|
381
|
+
homeDir: string
|
|
382
|
+
): Promise<AgentAuditReport | null> {
|
|
383
|
+
const path = join(facultStateDir(homeDir), "audit", "agent-latest.json");
|
|
384
|
+
const file = Bun.file(path);
|
|
385
|
+
if (!(await file.exists())) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
return (await file.json()) as AgentAuditReport;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function inferSource(args: {
|
|
392
|
+
requested?: AuditFixSource;
|
|
393
|
+
staticReport: StaticAuditReport | null;
|
|
394
|
+
agentReport: AgentAuditReport | null;
|
|
395
|
+
}): AuditFixSource {
|
|
396
|
+
if (args.requested) {
|
|
397
|
+
return args.requested;
|
|
398
|
+
}
|
|
399
|
+
if (args.staticReport && args.agentReport) {
|
|
400
|
+
return "combined";
|
|
401
|
+
}
|
|
402
|
+
if (args.agentReport) {
|
|
403
|
+
return "agent";
|
|
404
|
+
}
|
|
405
|
+
return "static";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function rewriteStaticReportResults(
|
|
409
|
+
report: StaticAuditReport,
|
|
410
|
+
fixedSelections: FindingSelection[]
|
|
411
|
+
): StaticAuditReport {
|
|
412
|
+
return applyAuditSuppressionsToStaticReport(
|
|
413
|
+
{
|
|
414
|
+
...report,
|
|
415
|
+
results: removeFixedInlineSecretFindings({
|
|
416
|
+
results: report.results,
|
|
417
|
+
fixed: fixedSelections,
|
|
418
|
+
}),
|
|
419
|
+
},
|
|
420
|
+
[]
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function rewriteAgentReportResults(
|
|
425
|
+
report: AgentAuditReport,
|
|
426
|
+
fixedSelections: FindingSelection[]
|
|
427
|
+
): AgentAuditReport {
|
|
428
|
+
return applyAuditSuppressionsToAgentReport(
|
|
429
|
+
{
|
|
430
|
+
...report,
|
|
431
|
+
results: removeFixedInlineSecretFindings({
|
|
432
|
+
results: report.results,
|
|
433
|
+
fixed: fixedSelections,
|
|
434
|
+
}),
|
|
435
|
+
},
|
|
436
|
+
[]
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function rewriteLatestReports(args: {
|
|
441
|
+
homeDir: string;
|
|
442
|
+
staticReport: StaticAuditReport | null;
|
|
443
|
+
agentReport: AgentAuditReport | null;
|
|
444
|
+
}) {
|
|
445
|
+
const auditDir = join(facultStateDir(args.homeDir), "audit");
|
|
446
|
+
await mkdir(auditDir, { recursive: true });
|
|
447
|
+
if (args.staticReport) {
|
|
448
|
+
await Bun.write(
|
|
449
|
+
join(auditDir, "static-latest.json"),
|
|
450
|
+
`${JSON.stringify(args.staticReport, null, 2)}\n`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (args.agentReport) {
|
|
454
|
+
await Bun.write(
|
|
455
|
+
join(auditDir, "agent-latest.json"),
|
|
456
|
+
`${JSON.stringify(args.agentReport, null, 2)}\n`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function selectFixableFindings(args: {
|
|
462
|
+
results: AuditItemResult[];
|
|
463
|
+
filters: AuditFixArgs;
|
|
464
|
+
}): FindingSelection[] {
|
|
465
|
+
return uniqueByKey(
|
|
466
|
+
args.results.flatMap((result) =>
|
|
467
|
+
result.findings
|
|
468
|
+
.filter(
|
|
469
|
+
(finding) =>
|
|
470
|
+
normalizeRuleId(finding.ruleId) === INLINE_SECRET_RULE_ID &&
|
|
471
|
+
result.type === "mcp" &&
|
|
472
|
+
matchesSelection({ result, filters: args.filters })
|
|
473
|
+
)
|
|
474
|
+
.map((finding) => ({ result, finding }))
|
|
475
|
+
),
|
|
476
|
+
(selection) => findingKey(selection)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function fixInlineMcpSecrets(args: {
|
|
481
|
+
findings: FindingSelection[];
|
|
482
|
+
homeDir?: string;
|
|
483
|
+
rootDir?: string;
|
|
484
|
+
}): Promise<{
|
|
485
|
+
fixed: number;
|
|
486
|
+
fixedSelections: FindingSelection[];
|
|
487
|
+
localPath: string | null;
|
|
488
|
+
riskyManagedOutputs: { path: string; state: "tracked" | "untracked" }[];
|
|
489
|
+
skipped: { label: string; reason: string }[];
|
|
490
|
+
syncedTools: string[];
|
|
491
|
+
trackedPath: string | null;
|
|
492
|
+
}> {
|
|
493
|
+
const homeDir = args.homeDir ?? homedir();
|
|
494
|
+
const rootDir =
|
|
495
|
+
args.rootDir ?? facultContextRootDir({ home: homeDir, cwd: process.cwd() });
|
|
496
|
+
const selected = args.findings.filter(
|
|
497
|
+
({ result, finding }) =>
|
|
498
|
+
result.type === "mcp" &&
|
|
499
|
+
normalizeRuleId(finding.ruleId) === INLINE_SECRET_RULE_ID &&
|
|
500
|
+
typeof finding.location === "string"
|
|
501
|
+
);
|
|
502
|
+
if (selected.length === 0) {
|
|
503
|
+
return {
|
|
504
|
+
fixed: 0,
|
|
505
|
+
fixedSelections: [],
|
|
506
|
+
localPath: null,
|
|
507
|
+
riskyManagedOutputs: [],
|
|
508
|
+
skipped: [],
|
|
509
|
+
syncedTools: [],
|
|
510
|
+
trackedPath: null,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const managedState = await loadManagedState(homeDir, rootDir);
|
|
515
|
+
const managedToolsByPath = new Map<string, string>();
|
|
516
|
+
for (const [tool, entry] of Object.entries(managedState.tools)) {
|
|
517
|
+
if (entry.mcpConfig) {
|
|
518
|
+
managedToolsByPath.set(entry.mcpConfig, tool);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const canonical = await loadCanonicalMcpState(rootDir, {
|
|
523
|
+
includeLocal: true,
|
|
524
|
+
});
|
|
525
|
+
const trackedServers = cloneRecord(canonical.trackedServers);
|
|
526
|
+
const localServers = cloneRecord(canonical.localServers);
|
|
527
|
+
const touchedTools = new Set<string>();
|
|
528
|
+
const fixedSelections: FindingSelection[] = [];
|
|
529
|
+
const skipped: { label: string; reason: string }[] = [];
|
|
530
|
+
|
|
531
|
+
for (const selection of selected) {
|
|
532
|
+
const parsed = selection.finding.location
|
|
533
|
+
? parseInlineSecretLocation(selection.finding.location)
|
|
534
|
+
: null;
|
|
535
|
+
const label = `${selection.result.item}:${selection.finding.location ?? selection.result.path}`;
|
|
536
|
+
if (!parsed) {
|
|
537
|
+
skipped.push({ label, reason: "could-not-parse-location" });
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const trackedServer = isPlainObject(trackedServers[parsed.serverName])
|
|
542
|
+
? (trackedServers[parsed.serverName] as Record<string, unknown>)
|
|
543
|
+
: null;
|
|
544
|
+
const localServer = isPlainObject(localServers[parsed.serverName])
|
|
545
|
+
? (localServers[parsed.serverName] as Record<string, unknown>)
|
|
546
|
+
: null;
|
|
547
|
+
|
|
548
|
+
let secretValue =
|
|
549
|
+
readSecretFromServer(trackedServer, parsed.envKey) ??
|
|
550
|
+
readSecretFromServer(localServer, parsed.envKey);
|
|
551
|
+
|
|
552
|
+
if (!secretValue) {
|
|
553
|
+
const selectedPathRaw = await Bun.file(selection.result.path)
|
|
554
|
+
.text()
|
|
555
|
+
.catch(() => null);
|
|
556
|
+
if (selectedPathRaw) {
|
|
557
|
+
try {
|
|
558
|
+
const parsedConfig = parseJsonLenient(selectedPathRaw);
|
|
559
|
+
const servers = extractServersObject(parsedConfig);
|
|
560
|
+
const selectedServer = servers?.[parsed.serverName];
|
|
561
|
+
secretValue = isPlainObject(selectedServer)
|
|
562
|
+
? readSecretFromServer(selectedServer, parsed.envKey)
|
|
563
|
+
: null;
|
|
564
|
+
} catch {
|
|
565
|
+
secretValue = null;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (!secretValue) {
|
|
571
|
+
skipped.push({ label, reason: "no-inline-secret-value-found" });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!trackedServer) {
|
|
576
|
+
skipped.push({ label, reason: "server-not-found-in-canonical-store" });
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
scrubTrackedServerEnv(trackedServer, parsed.envKey);
|
|
581
|
+
setLocalServerEnv({
|
|
582
|
+
localServers,
|
|
583
|
+
serverName: parsed.serverName,
|
|
584
|
+
envKey: parsed.envKey,
|
|
585
|
+
secretValue,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const managedTool = managedToolsByPath.get(selection.result.path);
|
|
589
|
+
if (managedTool) {
|
|
590
|
+
touchedTools.add(managedTool);
|
|
591
|
+
}
|
|
592
|
+
fixedSelections.push(selection);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (fixedSelections.length === 0) {
|
|
596
|
+
return {
|
|
597
|
+
fixed: 0,
|
|
598
|
+
fixedSelections: [],
|
|
599
|
+
localPath: null,
|
|
600
|
+
riskyManagedOutputs: [],
|
|
601
|
+
skipped,
|
|
602
|
+
syncedTools: [],
|
|
603
|
+
trackedPath: null,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
await mkdir(dirname(canonical.trackedPath), { recursive: true });
|
|
608
|
+
await Bun.write(
|
|
609
|
+
canonical.trackedPath,
|
|
610
|
+
stringifyCanonicalMcpServers(trackedServers)
|
|
611
|
+
);
|
|
612
|
+
await Bun.write(
|
|
613
|
+
canonical.localPath,
|
|
614
|
+
stringifyCanonicalMcpServers(localServers)
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
if (Object.keys(managedState.tools).length > 0) {
|
|
618
|
+
await syncManagedTools({ homeDir, rootDir });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const riskyManagedOutputs = (
|
|
622
|
+
await Promise.all(
|
|
623
|
+
[...touchedTools]
|
|
624
|
+
.map((tool) => managedState.tools[tool]?.mcpConfig)
|
|
625
|
+
.filter((path): path is string => typeof path === "string")
|
|
626
|
+
.map(async (pathValue) => {
|
|
627
|
+
const exposure = await getGitPathExposure(pathValue);
|
|
628
|
+
if (
|
|
629
|
+
exposure.insideRepo &&
|
|
630
|
+
(exposure.state === "tracked" || exposure.state === "untracked")
|
|
631
|
+
) {
|
|
632
|
+
return {
|
|
633
|
+
path: pathValue,
|
|
634
|
+
state: exposure.state,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
})
|
|
639
|
+
)
|
|
640
|
+
).filter(Boolean) as { path: string; state: "tracked" | "untracked" }[];
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
fixed: uniqueByKey(fixedSelections, (selection) => findingKey(selection))
|
|
644
|
+
.length,
|
|
645
|
+
fixedSelections,
|
|
646
|
+
localPath: canonical.localPath,
|
|
647
|
+
riskyManagedOutputs,
|
|
648
|
+
skipped,
|
|
649
|
+
syncedTools: [...touchedTools].sort(),
|
|
650
|
+
trackedPath: canonical.trackedPath,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function removeFixedInlineSecretFindings(args: {
|
|
655
|
+
results: AuditItemResult[];
|
|
656
|
+
fixed: FindingSelection[];
|
|
657
|
+
}): AuditItemResult[] {
|
|
658
|
+
const fixedKeys = new Set(
|
|
659
|
+
args.fixed.map((selection) => findingKey(selection))
|
|
660
|
+
);
|
|
661
|
+
if (fixedKeys.size === 0) {
|
|
662
|
+
return args.results;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return args.results.map((result) => {
|
|
666
|
+
const findings = result.findings.filter((finding) => {
|
|
667
|
+
if (normalizeRuleId(finding.ruleId) !== INLINE_SECRET_RULE_ID) {
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
const parsed = finding.location
|
|
671
|
+
? parseInlineSecretLocation(finding.location)
|
|
672
|
+
: null;
|
|
673
|
+
if (!parsed) {
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
return !fixedKeys.has(
|
|
677
|
+
[
|
|
678
|
+
result.type,
|
|
679
|
+
result.item,
|
|
680
|
+
parsed.serverName,
|
|
681
|
+
parsed.envKey,
|
|
682
|
+
INLINE_SECRET_RULE_ID,
|
|
683
|
+
].join("\0")
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
const status = computeStoredAuditStatus(findings);
|
|
687
|
+
return {
|
|
688
|
+
...result,
|
|
689
|
+
findings,
|
|
690
|
+
passed: isStoredAuditStatusPassed(status),
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
export async function runAuditFix(args: {
|
|
696
|
+
argv: string[];
|
|
697
|
+
homeDir?: string;
|
|
698
|
+
cwd?: string;
|
|
699
|
+
}): Promise<{
|
|
700
|
+
fixed: number;
|
|
701
|
+
localPath: string | null;
|
|
702
|
+
matched: number;
|
|
703
|
+
riskyManagedOutputs: { path: string; state: "tracked" | "untracked" }[];
|
|
704
|
+
skipped: { label: string; reason: string }[];
|
|
705
|
+
source: AuditFixSource;
|
|
706
|
+
syncedTools: string[];
|
|
707
|
+
trackedPath: string | null;
|
|
708
|
+
}> {
|
|
709
|
+
const parsed = parseAuditFixArgs(args.argv);
|
|
710
|
+
const homeDir = args.homeDir ?? homedir();
|
|
711
|
+
const cwd = args.cwd ?? process.cwd();
|
|
712
|
+
const rootDir = facultContextRootDir({ home: homeDir, cwd });
|
|
713
|
+
|
|
714
|
+
const staticReport = await loadLatestStaticReport(homeDir);
|
|
715
|
+
const agentReport = await loadLatestAgentReport(homeDir);
|
|
716
|
+
if (!(staticReport || agentReport)) {
|
|
717
|
+
throw new Error(
|
|
718
|
+
"No latest audit reports found. Run `fclt audit` first, then fix the flagged secrets."
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const source = inferSource({
|
|
723
|
+
requested: parsed.source,
|
|
724
|
+
staticReport,
|
|
725
|
+
agentReport,
|
|
726
|
+
});
|
|
727
|
+
const reportResults =
|
|
728
|
+
source === "static"
|
|
729
|
+
? (staticReport?.results ?? [])
|
|
730
|
+
: source === "agent"
|
|
731
|
+
? (agentReport?.results ?? [])
|
|
732
|
+
: mergeStaticAndAgentResults({
|
|
733
|
+
static: staticReport?.results ?? [],
|
|
734
|
+
agent: agentReport?.results ?? [],
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const selections = selectFixableFindings({
|
|
738
|
+
results: reportResults,
|
|
739
|
+
filters: parsed,
|
|
740
|
+
});
|
|
741
|
+
if (selections.length === 0) {
|
|
742
|
+
throw new Error(
|
|
743
|
+
"No inline MCP secret findings matched the requested filters."
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (parsed.dryRun) {
|
|
748
|
+
return {
|
|
749
|
+
fixed: 0,
|
|
750
|
+
localPath: null,
|
|
751
|
+
matched: selections.length,
|
|
752
|
+
riskyManagedOutputs: [],
|
|
753
|
+
skipped: [],
|
|
754
|
+
source,
|
|
755
|
+
syncedTools: [],
|
|
756
|
+
trackedPath: null,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const fixed = await fixInlineMcpSecrets({
|
|
761
|
+
findings: selections,
|
|
762
|
+
homeDir,
|
|
763
|
+
rootDir,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const nextStaticReport =
|
|
767
|
+
staticReport && fixed.fixedSelections.length > 0
|
|
768
|
+
? rewriteStaticReportResults(staticReport, fixed.fixedSelections)
|
|
769
|
+
: staticReport;
|
|
770
|
+
const nextAgentReport =
|
|
771
|
+
agentReport && fixed.fixedSelections.length > 0
|
|
772
|
+
? rewriteAgentReportResults(agentReport, fixed.fixedSelections)
|
|
773
|
+
: agentReport;
|
|
774
|
+
|
|
775
|
+
await rewriteLatestReports({
|
|
776
|
+
homeDir,
|
|
777
|
+
staticReport: nextStaticReport,
|
|
778
|
+
agentReport: nextAgentReport,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
await updateIndexFromAuditReport({
|
|
782
|
+
homeDir,
|
|
783
|
+
timestamp: new Date().toISOString(),
|
|
784
|
+
results: uniqueByKey(
|
|
785
|
+
mergeStaticAndAgentResults({
|
|
786
|
+
static: nextStaticReport?.results ?? [],
|
|
787
|
+
agent: nextAgentReport?.results ?? [],
|
|
788
|
+
}),
|
|
789
|
+
keyForResult
|
|
790
|
+
),
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
fixed: fixed.fixed,
|
|
795
|
+
localPath: fixed.localPath,
|
|
796
|
+
matched: selections.length,
|
|
797
|
+
riskyManagedOutputs: fixed.riskyManagedOutputs,
|
|
798
|
+
skipped: fixed.skipped,
|
|
799
|
+
source,
|
|
800
|
+
syncedTools: fixed.syncedTools,
|
|
801
|
+
trackedPath: fixed.trackedPath,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function printHelp() {
|
|
806
|
+
console.log(`fclt audit fix — remediate fixable audit findings
|
|
807
|
+
|
|
808
|
+
Usage:
|
|
809
|
+
fclt audit fix <item>
|
|
810
|
+
fclt audit fix --item <item> [--path <path>] [--source <static|agent|combined>]
|
|
811
|
+
fclt audit fix --all [--source <static|agent|combined>] [--yes]
|
|
812
|
+
fclt audit fix --dry-run ...
|
|
813
|
+
|
|
814
|
+
Notes:
|
|
815
|
+
- Currently fixes inline MCP secrets by moving them into a local canonical overlay.
|
|
816
|
+
- Tracked canonical MCP config is scrubbed and managed tool MCP configs are re-synced.
|
|
817
|
+
- Managed tool copies continue to work, but the canonical secret now lives in *.local.json.
|
|
818
|
+
`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export async function auditFixCommand(
|
|
822
|
+
argv: string[],
|
|
823
|
+
opts?: { cwd?: string; homeDir?: string }
|
|
824
|
+
) {
|
|
825
|
+
if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
826
|
+
printHelp();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
try {
|
|
831
|
+
const result = await runAuditFix({
|
|
832
|
+
argv,
|
|
833
|
+
cwd: opts?.cwd,
|
|
834
|
+
homeDir: opts?.homeDir,
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
if (argv.includes("--json")) {
|
|
838
|
+
console.log(JSON.stringify(result, null, 2));
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (argv.includes("--dry-run")) {
|
|
843
|
+
console.log(
|
|
844
|
+
`Matched ${result.matched} inline MCP secret finding${result.matched === 1 ? "" : "s"} in the ${result.source} audit view.`
|
|
845
|
+
);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
console.log(
|
|
850
|
+
`Fixed ${result.fixed} inline MCP secret finding${result.fixed === 1 ? "" : "s"} in the ${result.source} audit view.`
|
|
851
|
+
);
|
|
852
|
+
if (result.trackedPath && result.localPath) {
|
|
853
|
+
console.log(`Tracked canonical MCP config: ${result.trackedPath}`);
|
|
854
|
+
console.log(`Local MCP overlay: ${result.localPath}`);
|
|
855
|
+
}
|
|
856
|
+
if (result.syncedTools.length > 0) {
|
|
857
|
+
console.log(`Re-synced managed tools: ${result.syncedTools.join(", ")}`);
|
|
858
|
+
}
|
|
859
|
+
if (result.riskyManagedOutputs.length > 0) {
|
|
860
|
+
for (const output of result.riskyManagedOutputs) {
|
|
861
|
+
console.warn(
|
|
862
|
+
`Warning: ${output.path} is ${output.state === "tracked" ? "git-tracked" : "repo-local and not gitignored"}.`
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (result.skipped.length > 0) {
|
|
867
|
+
console.log(
|
|
868
|
+
`Skipped ${result.skipped.length} finding${result.skipped.length === 1 ? "" : "s"} that could not be fixed automatically.`
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
} catch (error) {
|
|
872
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
873
|
+
process.exitCode = 1;
|
|
874
|
+
}
|
|
875
|
+
}
|