@vibecheckai/cli 3.7.0 → 3.8.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/README.md +135 -63
- package/bin/_deprecations.js +447 -19
- package/bin/_router.js +1 -1
- package/bin/registry.js +347 -280
- package/bin/runners/context/generators/cursor-enhanced.js +2439 -0
- package/bin/runners/lib/agent-firewall/enforcement/gateway.js +1059 -0
- package/bin/runners/lib/agent-firewall/enforcement/index.js +98 -0
- package/bin/runners/lib/agent-firewall/enforcement/mode.js +318 -0
- package/bin/runners/lib/agent-firewall/enforcement/orchestrator.js +484 -0
- package/bin/runners/lib/agent-firewall/enforcement/proof-artifact.js +418 -0
- package/bin/runners/lib/agent-firewall/enforcement/schemas/change-event.schema.json +173 -0
- package/bin/runners/lib/agent-firewall/enforcement/schemas/intent.schema.json +181 -0
- package/bin/runners/lib/agent-firewall/enforcement/schemas/verdict.schema.json +222 -0
- package/bin/runners/lib/agent-firewall/enforcement/verdict-v2.js +333 -0
- package/bin/runners/lib/agent-firewall/index.js +200 -0
- package/bin/runners/lib/agent-firewall/integration/index.js +20 -0
- package/bin/runners/lib/agent-firewall/integration/ship-gate.js +437 -0
- package/bin/runners/lib/agent-firewall/intent/alignment-engine.js +622 -0
- package/bin/runners/lib/agent-firewall/intent/auto-detect.js +426 -0
- package/bin/runners/lib/agent-firewall/intent/index.js +102 -0
- package/bin/runners/lib/agent-firewall/intent/schema.js +352 -0
- package/bin/runners/lib/agent-firewall/intent/store.js +283 -0
- package/bin/runners/lib/agent-firewall/interception/fs-interceptor.js +502 -0
- package/bin/runners/lib/agent-firewall/interception/index.js +23 -0
- package/bin/runners/lib/agent-firewall/session/collector.js +451 -0
- package/bin/runners/lib/agent-firewall/session/index.js +26 -0
- package/bin/runners/lib/artifact-envelope.js +540 -0
- package/bin/runners/lib/auth-shared.js +977 -0
- package/bin/runners/lib/checkpoint.js +941 -0
- package/bin/runners/lib/cleanup/engine.js +571 -0
- package/bin/runners/lib/cleanup/index.js +53 -0
- package/bin/runners/lib/cleanup/output.js +375 -0
- package/bin/runners/lib/cleanup/rules.js +1060 -0
- package/bin/runners/lib/doctor/diagnosis-receipt.js +454 -0
- package/bin/runners/lib/doctor/failure-signatures.js +526 -0
- package/bin/runners/lib/doctor/fix-script.js +336 -0
- package/bin/runners/lib/doctor/modules/build-tools.js +453 -0
- package/bin/runners/lib/doctor/modules/index.js +62 -3
- package/bin/runners/lib/doctor/modules/os-quirks.js +706 -0
- package/bin/runners/lib/doctor/modules/repo-integrity.js +485 -0
- package/bin/runners/lib/doctor/safe-repair.js +384 -0
- package/bin/runners/lib/engines/attack-detector.js +1192 -0
- package/bin/runners/lib/entitlements-v2.js +2 -2
- package/bin/runners/lib/missions/briefing.js +427 -0
- package/bin/runners/lib/missions/checkpoint.js +753 -0
- package/bin/runners/lib/missions/hardening.js +851 -0
- package/bin/runners/lib/missions/plan.js +421 -32
- package/bin/runners/lib/missions/safety-gates.js +645 -0
- package/bin/runners/lib/missions/schema.js +478 -0
- package/bin/runners/lib/packs/bundle.js +675 -0
- package/bin/runners/lib/packs/evidence-pack.js +671 -0
- package/bin/runners/lib/packs/pack-factory.js +837 -0
- package/bin/runners/lib/packs/permissions-pack.js +686 -0
- package/bin/runners/lib/packs/proof-graph-pack.js +779 -0
- package/bin/runners/lib/safelist/index.js +96 -0
- package/bin/runners/lib/safelist/integration.js +334 -0
- package/bin/runners/lib/safelist/matcher.js +696 -0
- package/bin/runners/lib/safelist/schema.js +948 -0
- package/bin/runners/lib/safelist/store.js +438 -0
- package/bin/runners/lib/schemas/ship-manifest.schema.json +251 -0
- package/bin/runners/lib/ship-gate.js +832 -0
- package/bin/runners/lib/ship-manifest.js +1153 -0
- package/bin/runners/lib/ship-output.js +1 -1
- package/bin/runners/lib/unified-cli-output.js +710 -383
- package/bin/runners/lib/upsell.js +3 -3
- package/bin/runners/lib/why-tree.js +650 -0
- package/bin/runners/runAllowlist.js +33 -4
- package/bin/runners/runApprove.js +240 -1122
- package/bin/runners/runAudit.js +692 -0
- package/bin/runners/runAuth.js +325 -29
- package/bin/runners/runCheckpoint.js +442 -494
- package/bin/runners/runCleanup.js +343 -0
- package/bin/runners/runDoctor.js +269 -19
- package/bin/runners/runFix.js +411 -32
- package/bin/runners/runForge.js +411 -0
- package/bin/runners/runIntent.js +906 -0
- package/bin/runners/runKickoff.js +878 -0
- package/bin/runners/runLaunch.js +2000 -0
- package/bin/runners/runLink.js +785 -0
- package/bin/runners/runMcp.js +1741 -837
- package/bin/runners/runPacks.js +2089 -0
- package/bin/runners/runPolish.js +41 -0
- package/bin/runners/runSafelist.js +1190 -0
- package/bin/runners/runScan.js +21 -9
- package/bin/runners/runShield.js +1282 -0
- package/bin/runners/runShip.js +395 -16
- package/bin/vibecheck.js +34 -6
- package/mcp-server/README.md +117 -158
- package/mcp-server/handlers/tool-handler.ts +3 -3
- package/mcp-server/index.js +16 -0
- package/mcp-server/intent-firewall-interceptor.js +529 -0
- package/mcp-server/manifest.json +473 -0
- package/mcp-server/package.json +1 -1
- package/mcp-server/registry/tool-registry.js +315 -523
- package/mcp-server/registry/tools.json +442 -428
- package/mcp-server/tier-auth.js +68 -11
- package/mcp-server/tools-v3.js +70 -16
- package/package.json +1 -1
- package/bin/runners/runProof.zip +0 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibecheck safelist - Matching Engine
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* Determines if findings should be suppressed based on safelist entries
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Multiple matching strategies (ID, pattern, rule, file)
|
|
9
|
+
* - Command-scoped suppressions
|
|
10
|
+
* - Branch-aware matching
|
|
11
|
+
* - Match tracking and statistics
|
|
12
|
+
* - Dry-run preview support
|
|
13
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
const path = require("path");
|
|
19
|
+
const { SAFELIST_COMMANDS, LIMITS } = require("./schema");
|
|
20
|
+
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
// REGEX CACHE - Avoid recompiling patterns
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
const regexCache = new Map();
|
|
26
|
+
const MAX_CACHE_SIZE = 100;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get or create cached regex
|
|
30
|
+
*/
|
|
31
|
+
function getCachedRegex(pattern, flags = "i") {
|
|
32
|
+
const key = `${pattern}:${flags}`;
|
|
33
|
+
|
|
34
|
+
if (regexCache.has(key)) {
|
|
35
|
+
return regexCache.get(key);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const regex = new RegExp(pattern, flags);
|
|
40
|
+
|
|
41
|
+
// Evict oldest if cache is full
|
|
42
|
+
if (regexCache.size >= MAX_CACHE_SIZE) {
|
|
43
|
+
const firstKey = regexCache.keys().next().value;
|
|
44
|
+
regexCache.delete(firstKey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
regexCache.set(key, regex);
|
|
48
|
+
return regex;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
55
|
+
// MATCHING
|
|
56
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a finding matches a safelist entry
|
|
60
|
+
* @param {Object} finding - Finding to check
|
|
61
|
+
* @param {Object} entry - Safelist entry
|
|
62
|
+
* @param {Object} context - Context (filePath, command, branch, etc.)
|
|
63
|
+
* @returns {{ matches: boolean, reason: string|null, matchType: string|null, confidence: number }}
|
|
64
|
+
*/
|
|
65
|
+
function matchEntry(finding, entry, context = {}) {
|
|
66
|
+
const { filePath, command, projectRoot, branch, dryRun } = context;
|
|
67
|
+
|
|
68
|
+
const noMatch = { matches: false, reason: null, matchType: null, confidence: 0 };
|
|
69
|
+
|
|
70
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
71
|
+
// EXPIRY CHECK
|
|
72
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
73
|
+
|
|
74
|
+
if (entry.lifecycle?.expiresAt) {
|
|
75
|
+
const expiresAt = new Date(entry.lifecycle.expiresAt).getTime();
|
|
76
|
+
if (expiresAt < Date.now()) {
|
|
77
|
+
return { ...noMatch, reason: "Entry expired" };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
82
|
+
// COMMAND SCOPE CHECK
|
|
83
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
84
|
+
|
|
85
|
+
if (entry.scope?.commands && entry.scope.commands.length > 0) {
|
|
86
|
+
if (command && !entry.scope.commands.includes(command)) {
|
|
87
|
+
return { ...noMatch, reason: "Command not in scope" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
|
+
// BRANCH SCOPE CHECK
|
|
93
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
+
|
|
95
|
+
if (entry.scope?.type === "branch" && entry.scope?.branches) {
|
|
96
|
+
if (branch && !entry.scope.branches.some(b => matchBranch(b, branch))) {
|
|
97
|
+
return { ...noMatch, reason: "Branch not in scope" };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
102
|
+
// MATCH BY ENTRY TYPE
|
|
103
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
104
|
+
|
|
105
|
+
let result;
|
|
106
|
+
switch (entry.type) {
|
|
107
|
+
case "finding":
|
|
108
|
+
result = matchFindingEntry(finding, entry);
|
|
109
|
+
break;
|
|
110
|
+
case "pattern":
|
|
111
|
+
result = matchPatternEntry(finding, entry, { filePath, projectRoot });
|
|
112
|
+
break;
|
|
113
|
+
case "rule":
|
|
114
|
+
result = matchRuleEntry(finding, entry);
|
|
115
|
+
break;
|
|
116
|
+
case "file":
|
|
117
|
+
result = matchFileEntry(finding, entry, { filePath, projectRoot });
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
return { ...noMatch, reason: "Unknown entry type" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Add match type and confidence
|
|
124
|
+
if (result.matches) {
|
|
125
|
+
result.matchType = entry.type;
|
|
126
|
+
result.confidence = calculateConfidence(finding, entry, result);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Calculate match confidence (0-100)
|
|
134
|
+
* Higher = more specific match, less likely to be wrong
|
|
135
|
+
*/
|
|
136
|
+
function calculateConfidence(finding, entry, matchResult) {
|
|
137
|
+
let confidence = 50; // Base
|
|
138
|
+
|
|
139
|
+
// Exact ID match = highest confidence
|
|
140
|
+
if (entry.type === "finding" && entry.target?.findingId === finding.id) {
|
|
141
|
+
confidence = 100;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Rule match = high confidence
|
|
145
|
+
if (entry.type === "rule") {
|
|
146
|
+
confidence = 90;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// File + line match = high confidence
|
|
150
|
+
if (entry.type === "file" && entry.target?.lines) {
|
|
151
|
+
confidence = 85;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Pattern match = variable confidence
|
|
155
|
+
if (entry.type === "pattern") {
|
|
156
|
+
const pattern = entry.target?.pattern || "";
|
|
157
|
+
|
|
158
|
+
// Longer patterns = more specific = higher confidence
|
|
159
|
+
if (pattern.length > 20) confidence += 15;
|
|
160
|
+
else if (pattern.length > 10) confidence += 5;
|
|
161
|
+
|
|
162
|
+
// File-scoped patterns = higher confidence
|
|
163
|
+
if (entry.target?.file) confidence += 10;
|
|
164
|
+
|
|
165
|
+
// Line-scoped patterns = highest confidence for patterns
|
|
166
|
+
if (entry.target?.lines) confidence += 15;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Math.min(100, Math.max(0, confidence));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Match branch name against pattern
|
|
174
|
+
*/
|
|
175
|
+
function matchBranch(pattern, branch) {
|
|
176
|
+
if (pattern === branch) return true;
|
|
177
|
+
if (pattern === "*") return true;
|
|
178
|
+
|
|
179
|
+
// Support glob patterns
|
|
180
|
+
if (pattern.includes("*")) {
|
|
181
|
+
const regex = pattern
|
|
182
|
+
.replace(/\*/g, ".*")
|
|
183
|
+
.replace(/\?/g, ".");
|
|
184
|
+
return new RegExp(`^${regex}$`).test(branch);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Match by specific finding ID
|
|
192
|
+
*/
|
|
193
|
+
function matchFindingEntry(finding, entry) {
|
|
194
|
+
if (!entry.target?.findingId) {
|
|
195
|
+
return { matches: false, reason: "No finding ID in entry" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (finding.id === entry.target.findingId) {
|
|
199
|
+
return { matches: true, reason: entry.justification?.reason };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { matches: false, reason: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Match by regex pattern
|
|
207
|
+
*/
|
|
208
|
+
function matchPatternEntry(finding, entry, context) {
|
|
209
|
+
if (!entry.target?.pattern) {
|
|
210
|
+
return { matches: false, reason: "No pattern in entry" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Use cached regex
|
|
214
|
+
const regex = getCachedRegex(entry.target.pattern, "i");
|
|
215
|
+
if (!regex) {
|
|
216
|
+
return { matches: false, reason: "Invalid regex pattern" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Build list of targets to match against (ordered by specificity)
|
|
220
|
+
const targets = [];
|
|
221
|
+
|
|
222
|
+
// Most specific - exact fields
|
|
223
|
+
if (finding.id) targets.push({ value: finding.id, field: "id" });
|
|
224
|
+
if (finding.ruleId) targets.push({ value: finding.ruleId, field: "ruleId" });
|
|
225
|
+
|
|
226
|
+
// Medium specific - content fields
|
|
227
|
+
if (finding.message) targets.push({ value: finding.message, field: "message" });
|
|
228
|
+
if (finding.title) targets.push({ value: finding.title, field: "title" });
|
|
229
|
+
if (finding.description) targets.push({ value: finding.description, field: "description" });
|
|
230
|
+
|
|
231
|
+
// Less specific - category/type
|
|
232
|
+
if (finding.category) targets.push({ value: finding.category, field: "category" });
|
|
233
|
+
if (finding.type) targets.push({ value: finding.type, field: "type" });
|
|
234
|
+
|
|
235
|
+
// Context - file path
|
|
236
|
+
if (finding.file) targets.push({ value: finding.file, field: "file" });
|
|
237
|
+
|
|
238
|
+
// Try to match
|
|
239
|
+
let matchedField = null;
|
|
240
|
+
const matched = targets.some(t => {
|
|
241
|
+
if (regex.test(t.value)) {
|
|
242
|
+
matchedField = t.field;
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (!matched) {
|
|
249
|
+
return { matches: false, reason: null };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Check file scope if specified
|
|
253
|
+
if (entry.target.file) {
|
|
254
|
+
const { filePath, projectRoot } = context;
|
|
255
|
+
const findingFile = finding.file || filePath;
|
|
256
|
+
|
|
257
|
+
if (!findingFile) {
|
|
258
|
+
return { matches: false, reason: "File scope but no file context" };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const normalizedEntry = normalizePath(entry.target.file, projectRoot);
|
|
262
|
+
const normalizedFile = normalizePath(findingFile, projectRoot);
|
|
263
|
+
|
|
264
|
+
// Support glob patterns
|
|
265
|
+
if (entry.target.file.includes("*")) {
|
|
266
|
+
if (!matchGlob(entry.target.file, normalizedFile)) {
|
|
267
|
+
return { matches: false, reason: "File does not match glob" };
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
// Check for exact match, suffix match, or contains
|
|
271
|
+
if (normalizedFile !== normalizedEntry &&
|
|
272
|
+
!normalizedFile.endsWith("/" + normalizedEntry) &&
|
|
273
|
+
!normalizedFile.includes(normalizedEntry)) {
|
|
274
|
+
return { matches: false, reason: "File does not match" };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check line range if specified
|
|
280
|
+
if (entry.target.lines) {
|
|
281
|
+
const findingLine = finding.line || finding.startLine;
|
|
282
|
+
if (findingLine) {
|
|
283
|
+
const [start, end] = entry.target.lines;
|
|
284
|
+
if (findingLine < start || findingLine > end) {
|
|
285
|
+
return { matches: false, reason: "Line not in range" };
|
|
286
|
+
}
|
|
287
|
+
} else if (entry.target.lines[0] !== 1 || entry.target.lines[1] !== Infinity) {
|
|
288
|
+
// If we have line constraints but no line info in finding, don't match
|
|
289
|
+
// unless the range is effectively "all lines"
|
|
290
|
+
return { matches: false, reason: "Line info required but not available" };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
matches: true,
|
|
296
|
+
reason: entry.justification?.reason,
|
|
297
|
+
matchedField,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Match by rule ID
|
|
303
|
+
*/
|
|
304
|
+
function matchRuleEntry(finding, entry) {
|
|
305
|
+
if (!entry.target?.ruleId) {
|
|
306
|
+
return { matches: false, reason: "No rule ID in entry" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const findingRuleId = finding.ruleId || finding.id?.split("_")[0] || finding.type;
|
|
310
|
+
|
|
311
|
+
if (findingRuleId === entry.target.ruleId) {
|
|
312
|
+
return { matches: true, reason: entry.justification?.reason };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { matches: false, reason: null };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Match by file path
|
|
320
|
+
*/
|
|
321
|
+
function matchFileEntry(finding, entry, context) {
|
|
322
|
+
if (!entry.target?.file) {
|
|
323
|
+
return { matches: false, reason: "No file in entry" };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const { filePath, projectRoot } = context;
|
|
327
|
+
const findingFile = finding.file || filePath;
|
|
328
|
+
|
|
329
|
+
if (!findingFile) {
|
|
330
|
+
return { matches: false, reason: "No file context" };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const normalizedEntry = normalizePath(entry.target.file, projectRoot);
|
|
334
|
+
const normalizedFile = normalizePath(findingFile, projectRoot);
|
|
335
|
+
|
|
336
|
+
// Support glob patterns
|
|
337
|
+
if (entry.target.file.includes("*")) {
|
|
338
|
+
if (!matchGlob(entry.target.file, normalizedFile)) {
|
|
339
|
+
return { matches: false, reason: "File does not match glob" };
|
|
340
|
+
}
|
|
341
|
+
} else if (!normalizedFile.endsWith(normalizedEntry) && !normalizedFile.includes(normalizedEntry)) {
|
|
342
|
+
return { matches: false, reason: "File does not match" };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Check line range if specified
|
|
346
|
+
if (entry.target.lines && finding.line) {
|
|
347
|
+
const [start, end] = entry.target.lines;
|
|
348
|
+
if (finding.line < start || finding.line > end) {
|
|
349
|
+
return { matches: false, reason: "Line not in range" };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { matches: true, reason: entry.justification?.reason };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
357
|
+
// BULK MATCHING
|
|
358
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Check if a finding is suppressed by any safelist entry
|
|
362
|
+
* @param {Object} finding - Finding to check
|
|
363
|
+
* @param {Object} safelist - Loaded safelist
|
|
364
|
+
* @param {Object} context - Context (filePath, command, etc.)
|
|
365
|
+
* @returns {{ suppressed: boolean, entry: Object|null, reason: string|null, matchType: string|null, matchedField: string|null, confidence: number }}
|
|
366
|
+
*/
|
|
367
|
+
function isSuppressed(finding, safelist, context = {}) {
|
|
368
|
+
const noSuppress = {
|
|
369
|
+
suppressed: false,
|
|
370
|
+
entry: null,
|
|
371
|
+
reason: null,
|
|
372
|
+
matchType: null,
|
|
373
|
+
matchedField: null,
|
|
374
|
+
confidence: 0,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (!safelist?.entries || safelist.entries.length === 0) {
|
|
378
|
+
return noSuppress;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Try each entry, track best match (highest confidence)
|
|
382
|
+
let bestMatch = null;
|
|
383
|
+
let bestConfidence = 0;
|
|
384
|
+
|
|
385
|
+
for (const entry of safelist.entries) {
|
|
386
|
+
const result = matchEntry(finding, entry, context);
|
|
387
|
+
|
|
388
|
+
if (result.matches) {
|
|
389
|
+
// For exact ID matches, return immediately (100% confidence)
|
|
390
|
+
if (result.confidence === 100) {
|
|
391
|
+
return {
|
|
392
|
+
suppressed: true,
|
|
393
|
+
entry,
|
|
394
|
+
reason: result.reason,
|
|
395
|
+
matchType: result.matchType,
|
|
396
|
+
matchedField: result.matchedField,
|
|
397
|
+
confidence: result.confidence,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Track best match
|
|
402
|
+
if (result.confidence > bestConfidence) {
|
|
403
|
+
bestMatch = { entry, result };
|
|
404
|
+
bestConfidence = result.confidence;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Return best match if found
|
|
410
|
+
if (bestMatch) {
|
|
411
|
+
return {
|
|
412
|
+
suppressed: true,
|
|
413
|
+
entry: bestMatch.entry,
|
|
414
|
+
reason: bestMatch.result.reason,
|
|
415
|
+
matchType: bestMatch.result.matchType,
|
|
416
|
+
matchedField: bestMatch.result.matchedField,
|
|
417
|
+
confidence: bestMatch.result.confidence,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return noSuppress;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Filter findings, separating active from suppressed
|
|
426
|
+
* @param {Array} findings - Findings to filter
|
|
427
|
+
* @param {Object} safelist - Loaded safelist
|
|
428
|
+
* @param {Object} context - Context
|
|
429
|
+
* @returns {{ active: Array, suppressed: Array, stats: Object }}
|
|
430
|
+
*/
|
|
431
|
+
function filterFindings(findings, safelist, context = {}) {
|
|
432
|
+
const active = [];
|
|
433
|
+
const suppressed = [];
|
|
434
|
+
const stats = {
|
|
435
|
+
total: findings.length,
|
|
436
|
+
active: 0,
|
|
437
|
+
suppressed: 0,
|
|
438
|
+
byEntry: {},
|
|
439
|
+
byCategory: {},
|
|
440
|
+
byMatchType: {},
|
|
441
|
+
byConfidence: { high: 0, medium: 0, low: 0 },
|
|
442
|
+
matchedEntries: new Set(),
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
for (const finding of findings) {
|
|
446
|
+
const result = isSuppressed(finding, safelist, {
|
|
447
|
+
...context,
|
|
448
|
+
filePath: finding.file,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (result.suppressed) {
|
|
452
|
+
suppressed.push({
|
|
453
|
+
...finding,
|
|
454
|
+
_suppression: {
|
|
455
|
+
entryId: result.entry?.id,
|
|
456
|
+
reason: result.reason,
|
|
457
|
+
category: result.entry?.justification?.category,
|
|
458
|
+
owner: result.entry?.owner?.name,
|
|
459
|
+
ownerTeam: result.entry?.owner?.team,
|
|
460
|
+
expiresAt: result.entry?.lifecycle?.expiresAt,
|
|
461
|
+
matchType: result.matchType,
|
|
462
|
+
matchedField: result.matchedField,
|
|
463
|
+
confidence: result.confidence,
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
stats.suppressed++;
|
|
468
|
+
|
|
469
|
+
// Track by entry
|
|
470
|
+
const entryId = result.entry?.id || "unknown";
|
|
471
|
+
stats.byEntry[entryId] = (stats.byEntry[entryId] || 0) + 1;
|
|
472
|
+
stats.matchedEntries.add(entryId);
|
|
473
|
+
|
|
474
|
+
// Track by category
|
|
475
|
+
const category = result.entry?.justification?.category || "unknown";
|
|
476
|
+
stats.byCategory[category] = (stats.byCategory[category] || 0) + 1;
|
|
477
|
+
|
|
478
|
+
// Track by match type
|
|
479
|
+
const matchType = result.matchType || "unknown";
|
|
480
|
+
stats.byMatchType[matchType] = (stats.byMatchType[matchType] || 0) + 1;
|
|
481
|
+
|
|
482
|
+
// Track by confidence
|
|
483
|
+
const confidence = result.confidence || 0;
|
|
484
|
+
if (confidence >= 80) stats.byConfidence.high++;
|
|
485
|
+
else if (confidence >= 50) stats.byConfidence.medium++;
|
|
486
|
+
else stats.byConfidence.low++;
|
|
487
|
+
} else {
|
|
488
|
+
active.push(finding);
|
|
489
|
+
stats.active++;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Convert Set to Array for serialization
|
|
494
|
+
stats.matchedEntries = Array.from(stats.matchedEntries);
|
|
495
|
+
|
|
496
|
+
return { active, suppressed, stats };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Preview what would be suppressed (dry-run mode)
|
|
501
|
+
* @param {Array} findings - Findings to check
|
|
502
|
+
* @param {Object} safelist - Loaded safelist
|
|
503
|
+
* @param {Object} context - Context
|
|
504
|
+
* @returns {{ wouldSuppress: Array, stats: Object }}
|
|
505
|
+
*/
|
|
506
|
+
function previewSuppression(findings, safelist, context = {}) {
|
|
507
|
+
const result = filterFindings(findings, safelist, { ...context, dryRun: true });
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
wouldSuppress: result.suppressed.map(f => ({
|
|
511
|
+
finding: {
|
|
512
|
+
id: f.id,
|
|
513
|
+
message: f.message,
|
|
514
|
+
file: f.file,
|
|
515
|
+
line: f.line,
|
|
516
|
+
category: f.category,
|
|
517
|
+
},
|
|
518
|
+
suppressedBy: {
|
|
519
|
+
entryId: f._suppression.entryId,
|
|
520
|
+
reason: f._suppression.reason,
|
|
521
|
+
category: f._suppression.category,
|
|
522
|
+
owner: f._suppression.owner,
|
|
523
|
+
matchType: f._suppression.matchType,
|
|
524
|
+
confidence: f._suppression.confidence,
|
|
525
|
+
},
|
|
526
|
+
})),
|
|
527
|
+
stats: result.stats,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Find which safelist entries are unused (match nothing)
|
|
533
|
+
* @param {Array} findings - All findings
|
|
534
|
+
* @param {Object} safelist - Loaded safelist
|
|
535
|
+
* @param {Object} context - Context
|
|
536
|
+
* @returns {Array} Entries that matched nothing
|
|
537
|
+
*/
|
|
538
|
+
function findUnusedEntries(findings, safelist, context = {}) {
|
|
539
|
+
const { stats } = filterFindings(findings, safelist, context);
|
|
540
|
+
const matchedIds = new Set(stats.matchedEntries);
|
|
541
|
+
|
|
542
|
+
return safelist.entries.filter(entry => !matchedIds.has(entry.id));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Update match counts for entries that matched
|
|
547
|
+
* @param {Object} safelist - Safelist with _source annotations
|
|
548
|
+
* @param {Array} matchedEntryIds - Entry IDs that matched
|
|
549
|
+
* @param {Object} store - Store module for saving
|
|
550
|
+
* @param {string} projectRoot - Project root
|
|
551
|
+
*/
|
|
552
|
+
function updateMatchCounts(safelist, matchedEntryIds, store, projectRoot) {
|
|
553
|
+
const uniqueIds = [...new Set(matchedEntryIds)];
|
|
554
|
+
const now = new Date().toISOString();
|
|
555
|
+
|
|
556
|
+
for (const entryId of uniqueIds) {
|
|
557
|
+
store.updateEntry(projectRoot, entryId, {
|
|
558
|
+
lifecycle: {
|
|
559
|
+
lastMatchedAt: now,
|
|
560
|
+
matchCount: (safelist.entries.find(e => e.id === entryId)?.lifecycle?.matchCount || 0) + 1,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
567
|
+
// UTILITIES
|
|
568
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Normalize file path for comparison
|
|
572
|
+
*/
|
|
573
|
+
function normalizePath(filePath, projectRoot) {
|
|
574
|
+
if (!filePath) return "";
|
|
575
|
+
|
|
576
|
+
let normalized = filePath.replace(/\\/g, "/");
|
|
577
|
+
|
|
578
|
+
// Remove project root prefix if present
|
|
579
|
+
if (projectRoot) {
|
|
580
|
+
const normalizedRoot = projectRoot.replace(/\\/g, "/");
|
|
581
|
+
if (normalized.startsWith(normalizedRoot)) {
|
|
582
|
+
normalized = normalized.substring(normalizedRoot.length);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Remove leading slash
|
|
587
|
+
if (normalized.startsWith("/")) {
|
|
588
|
+
normalized = normalized.substring(1);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return normalized;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Simple glob matching
|
|
596
|
+
*/
|
|
597
|
+
function matchGlob(pattern, filePath) {
|
|
598
|
+
const regexPattern = pattern
|
|
599
|
+
.replace(/\./g, "\\.")
|
|
600
|
+
.replace(/\*\*/g, "{{GLOBSTAR}}")
|
|
601
|
+
.replace(/\*/g, "[^/]*")
|
|
602
|
+
.replace(/\?/g, "[^/]")
|
|
603
|
+
.replace(/{{GLOBSTAR}}/g, ".*");
|
|
604
|
+
|
|
605
|
+
return new RegExp(`^${regexPattern}$`, "i").test(filePath) ||
|
|
606
|
+
new RegExp(`${regexPattern}$`, "i").test(filePath);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Get entries that are expiring soon
|
|
611
|
+
* @param {Object} safelist - Loaded safelist
|
|
612
|
+
* @param {number} days - Days threshold
|
|
613
|
+
* @returns {Array} Entries expiring within threshold
|
|
614
|
+
*/
|
|
615
|
+
function getExpiringSoon(safelist, days = 7) {
|
|
616
|
+
const threshold = Date.now() + (days * 24 * 60 * 60 * 1000);
|
|
617
|
+
|
|
618
|
+
return safelist.entries.filter(entry => {
|
|
619
|
+
if (!entry.lifecycle?.expiresAt) return false;
|
|
620
|
+
const expiresAt = new Date(entry.lifecycle.expiresAt).getTime();
|
|
621
|
+
return expiresAt > Date.now() && expiresAt <= threshold;
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Get entries due for review
|
|
627
|
+
* @param {Object} safelist - Loaded safelist
|
|
628
|
+
* @returns {Array} Entries due for review
|
|
629
|
+
*/
|
|
630
|
+
function getDueForReview(safelist) {
|
|
631
|
+
const now = Date.now();
|
|
632
|
+
|
|
633
|
+
return safelist.entries.filter(entry => {
|
|
634
|
+
if (!entry.lifecycle?.reviewAt) return false;
|
|
635
|
+
return new Date(entry.lifecycle.reviewAt).getTime() <= now;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Get expired entries
|
|
641
|
+
* @param {Object} safelist - Loaded safelist
|
|
642
|
+
* @returns {Array} Expired entries
|
|
643
|
+
*/
|
|
644
|
+
function getExpired(safelist) {
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
|
|
647
|
+
return safelist.entries.filter(entry => {
|
|
648
|
+
if (!entry.lifecycle?.expiresAt) return false;
|
|
649
|
+
return new Date(entry.lifecycle.expiresAt).getTime() < now;
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Get unused entries (never matched)
|
|
655
|
+
* @param {Object} safelist - Loaded safelist
|
|
656
|
+
* @param {number} days - Days since creation
|
|
657
|
+
* @returns {Array} Entries that have never matched
|
|
658
|
+
*/
|
|
659
|
+
function getUnused(safelist, days = 30) {
|
|
660
|
+
const threshold = Date.now() - (days * 24 * 60 * 60 * 1000);
|
|
661
|
+
|
|
662
|
+
return safelist.entries.filter(entry => {
|
|
663
|
+
if (entry.lifecycle?.matchCount > 0) return false;
|
|
664
|
+
const createdAt = new Date(entry.lifecycle?.createdAt).getTime();
|
|
665
|
+
return createdAt < threshold;
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
670
|
+
// EXPORTS
|
|
671
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
672
|
+
|
|
673
|
+
module.exports = {
|
|
674
|
+
// Core matching
|
|
675
|
+
matchEntry,
|
|
676
|
+
isSuppressed,
|
|
677
|
+
filterFindings,
|
|
678
|
+
|
|
679
|
+
// Preview & analysis
|
|
680
|
+
previewSuppression,
|
|
681
|
+
findUnusedEntries,
|
|
682
|
+
calculateConfidence,
|
|
683
|
+
|
|
684
|
+
// Lifecycle management
|
|
685
|
+
updateMatchCounts,
|
|
686
|
+
getExpiringSoon,
|
|
687
|
+
getDueForReview,
|
|
688
|
+
getExpired,
|
|
689
|
+
getUnused,
|
|
690
|
+
|
|
691
|
+
// Utilities
|
|
692
|
+
normalizePath,
|
|
693
|
+
matchGlob,
|
|
694
|
+
matchBranch,
|
|
695
|
+
getCachedRegex,
|
|
696
|
+
};
|