@rigour-labs/core 5.2.9 → 5.3.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.
Files changed (38) hide show
  1. package/dist/context/cache-engine.d.ts +75 -0
  2. package/dist/context/cache-engine.js +155 -0
  3. package/dist/context/cache-engine.test.d.ts +1 -0
  4. package/dist/context/cache-engine.test.js +107 -0
  5. package/dist/context/context-session.d.ts +25 -0
  6. package/dist/context/context-session.js +78 -0
  7. package/dist/context/index-bridge.d.ts +24 -0
  8. package/dist/context/index-bridge.js +114 -0
  9. package/dist/hooks/dlp-confidence.d.ts +16 -0
  10. package/dist/hooks/dlp-confidence.js +302 -0
  11. package/dist/hooks/dlp-confidence.test.d.ts +1 -0
  12. package/dist/hooks/dlp-confidence.test.js +51 -0
  13. package/dist/hooks/dlp-feedback.d.ts +48 -0
  14. package/dist/hooks/dlp-feedback.js +174 -0
  15. package/dist/hooks/dlp-feedback.test.d.ts +1 -0
  16. package/dist/hooks/dlp-feedback.test.js +39 -0
  17. package/dist/hooks/index.d.ts +2 -0
  18. package/dist/hooks/index.js +1 -0
  19. package/dist/hooks/input-validator.d.ts +8 -0
  20. package/dist/hooks/input-validator.js +125 -82
  21. package/dist/hooks/input-validator.test.js +92 -34
  22. package/dist/index.d.ts +6 -1
  23. package/dist/index.js +7 -1
  24. package/dist/services/context-telemetry-service.d.ts +121 -0
  25. package/dist/services/context-telemetry-service.js +375 -0
  26. package/dist/services/context-telemetry-service.test.d.ts +1 -0
  27. package/dist/services/context-telemetry-service.test.js +130 -0
  28. package/dist/settings.d.ts +11 -0
  29. package/dist/settings.js +18 -0
  30. package/dist/storage/context-telemetry.d.ts +83 -0
  31. package/dist/storage/context-telemetry.js +313 -0
  32. package/dist/storage/context-telemetry.test.d.ts +1 -0
  33. package/dist/storage/context-telemetry.test.js +80 -0
  34. package/dist/storage/db.js +140 -2
  35. package/dist/storage/index.d.ts +2 -0
  36. package/dist/storage/index.js +1 -0
  37. package/package.json +82 -82
  38. package/LICENSE +0 -21
@@ -0,0 +1,302 @@
1
+ const UNCONDITIONAL_BLOCK_TYPES = new Set([
2
+ 'private_key',
3
+ 'private_key_full',
4
+ 'gcp_service_account',
5
+ 'bearer_token',
6
+ 'jwt_token',
7
+ ]);
8
+ const PROVIDER_BLOCK_TYPES = new Set([
9
+ 'aws_secret_key',
10
+ 'azure_key',
11
+ 'anthropic_key',
12
+ 'slack_token',
13
+ 'sendgrid_key',
14
+ ]);
15
+ const PROVIDER_TYPES = new Set([
16
+ 'aws_access_key',
17
+ 'openai_key',
18
+ 'github_token',
19
+ 'stripe_key',
20
+ 'twilio_key',
21
+ ]);
22
+ const GENERIC_TYPES = new Set([
23
+ 'password_assignment',
24
+ 'env_variable',
25
+ 'ci_secret',
26
+ 'base64_secret',
27
+ 'hex_secret',
28
+ 'high_entropy_secret',
29
+ 'custom_pattern',
30
+ ]);
31
+ export function classifyDLPDetection(detection, input) {
32
+ const context = getDetectionContext(detection, input);
33
+ const value = extractDetectionValue(detection);
34
+ const reasons = new Set();
35
+ if (UNCONDITIONAL_BLOCK_TYPES.has(detection.type)) {
36
+ reasons.add('sensitive_secret_type');
37
+ return verdict(98, 'block', reasons);
38
+ }
39
+ if (isCommentLine(context.line) && !UNCONDITIONAL_BLOCK_TYPES.has(detection.type)) {
40
+ reasons.add('comment_context');
41
+ return verdict(15, 'allow', reasons);
42
+ }
43
+ const safe = safeValueVerdict(detection, value, context, reasons);
44
+ if (safe)
45
+ return safe;
46
+ if (GENERIC_TYPES.has(detection.type)) {
47
+ return classifyGenericSecret(detection, value, context, reasons);
48
+ }
49
+ let confidence = baseConfidence(detection.type);
50
+ addPositiveSignals(detection, value, reasons);
51
+ addContextSignals(context, value, reasons);
52
+ confidence += confidenceAdjustment(reasons);
53
+ const contextual = contextualAllowVerdict(detection, value, context, confidence, reasons);
54
+ if (contextual)
55
+ return contextual;
56
+ if (PROVIDER_BLOCK_TYPES.has(detection.type)) {
57
+ return verdict(confidence, confidence >= 80 ? 'block' : 'warn', reasons);
58
+ }
59
+ if (PROVIDER_TYPES.has(detection.type)) {
60
+ if (isDocumentationContext(context.before, context.line) && !isHighEntropy(value)) {
61
+ reasons.add('docs_context');
62
+ return verdict(25, 'allow', reasons);
63
+ }
64
+ return verdict(confidence, confidence >= 80 ? 'block' : 'warn', reasons);
65
+ }
66
+ if (detection.type === 'database_url' || detection.type === 'credentials_in_url') {
67
+ if (isLocalDatabaseUrl(value) || isLocalDatabaseUrl(context.line)) {
68
+ reasons.add('localhost_only');
69
+ return verdict(22, 'allow', reasons);
70
+ }
71
+ return verdict(confidence, confidence >= 80 ? 'block' : 'warn', reasons);
72
+ }
73
+ return verdict(confidence, confidence >= 80 ? 'block' : 'warn', reasons);
74
+ }
75
+ function classifyGenericSecret(detection, value, context, reasons) {
76
+ if (detection.type === 'custom_pattern') {
77
+ reasons.add('secret_assignment');
78
+ return verdict(55, 'warn', reasons);
79
+ }
80
+ if (isSafePlaceholder(detection.type, value, context)) {
81
+ reasons.add('safe_example_value');
82
+ return verdict(18, 'allow', reasons);
83
+ }
84
+ if (isSafeContext(context, value)) {
85
+ reasons.add('safe_context');
86
+ return verdict(25, 'allow', reasons);
87
+ }
88
+ if (isHighEntropy(value) && value.length >= 24) {
89
+ reasons.add('high_entropy');
90
+ reasons.add('secret_assignment');
91
+ return verdict(72, 'warn', reasons);
92
+ }
93
+ reasons.add('secret_assignment');
94
+ return verdict(25, 'allow', reasons);
95
+ }
96
+ function safeValueVerdict(detection, value, context, reasons) {
97
+ if (isEnvReference(value) || isHashLine(context.line)) {
98
+ reasons.add('safe_reference_value');
99
+ return verdict(18, 'allow', reasons);
100
+ }
101
+ if (isSafePlaceholder(detection.type, value, context)) {
102
+ reasons.add('safe_example_value');
103
+ return verdict(18, 'allow', reasons);
104
+ }
105
+ if (isPublicOrDevCredential(detection.type, value, context)) {
106
+ reasons.add('public_or_test_key');
107
+ return verdict(18, 'allow', reasons);
108
+ }
109
+ if (isLivePublishableStripeKey(detection.type, value)) {
110
+ reasons.add('public_live_key');
111
+ return verdict(45, 'warn', reasons);
112
+ }
113
+ }
114
+ function contextualAllowVerdict(detection, value, context, confidence, reasons) {
115
+ if (isSafeContext(context, value) && !isHighEntropySecret(detection.type, value)) {
116
+ reasons.add('safe_context');
117
+ return verdict(Math.min(confidence, 30), 'allow', reasons);
118
+ }
119
+ return commandOrLocalVerdict(detection, value, context, reasons);
120
+ }
121
+ function commandOrLocalVerdict(detection, value, context, reasons) {
122
+ if (detection.type === 'ssh_credentials' && !hasSensitiveSshOptions(context.line)) {
123
+ reasons.add('ssh_alias_or_host');
124
+ return verdict(25, 'allow', reasons);
125
+ }
126
+ if (isLocalDatabaseUrl(value) || isLocalDatabaseUrl(context.line)) {
127
+ reasons.add('localhost_only');
128
+ return verdict(22, 'allow', reasons);
129
+ }
130
+ }
131
+ function baseConfidence(type) {
132
+ if (PROVIDER_BLOCK_TYPES.has(type))
133
+ return 92;
134
+ if (PROVIDER_TYPES.has(type))
135
+ return 78;
136
+ if (GENERIC_TYPES.has(type))
137
+ return 40;
138
+ if (type === 'database_url' || type === 'credentials_in_url')
139
+ return 82;
140
+ if (type === 'ssh_credentials')
141
+ return 35;
142
+ return 50;
143
+ }
144
+ function addPositiveSignals(detection, value, reasons) {
145
+ if (PROVIDER_TYPES.has(detection.type) || PROVIDER_BLOCK_TYPES.has(detection.type)) {
146
+ reasons.add('provider_pattern');
147
+ }
148
+ if (GENERIC_TYPES.has(detection.type)) {
149
+ reasons.add('secret_assignment');
150
+ }
151
+ if (isHighEntropy(value)) {
152
+ reasons.add('high_entropy');
153
+ }
154
+ if (value.length >= 32) {
155
+ reasons.add('long_token');
156
+ }
157
+ }
158
+ function addContextSignals(context, value, reasons) {
159
+ if (isDocumentationContext(context.before, context.line))
160
+ reasons.add('docs_context');
161
+ if (isCommentLine(context.line))
162
+ reasons.add('comment_context');
163
+ if (isBuildLogLine(context.line))
164
+ reasons.add('build_log_context');
165
+ if (isSafePlaceholderValue(value))
166
+ reasons.add('known_placeholder');
167
+ if (isEnvReference(value))
168
+ reasons.add('env_reference');
169
+ if (isHashLine(context.line))
170
+ reasons.add('checksum_or_hash');
171
+ }
172
+ function confidenceAdjustment(reasons) {
173
+ const weights = {
174
+ provider_pattern: 12,
175
+ secret_assignment: 6,
176
+ high_entropy: 16,
177
+ long_token: 6,
178
+ docs_context: -35,
179
+ comment_context: -40,
180
+ build_log_context: -30,
181
+ known_placeholder: -45,
182
+ env_reference: -45,
183
+ checksum_or_hash: -50,
184
+ };
185
+ return [...reasons].reduce((sum, reason) => sum + (weights[reason] ?? 0), 0);
186
+ }
187
+ function getDetectionContext(detection, input) {
188
+ const start = detection.position?.start ?? 0;
189
+ const end = detection.position?.end ?? detection.match.length;
190
+ const lineStart = input.lastIndexOf('\n', start) + 1;
191
+ const lineEnd = input.indexOf('\n', end);
192
+ const beforeStart = Math.max(0, start - 300);
193
+ return {
194
+ line: input.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim(),
195
+ before: input.slice(beforeStart, start),
196
+ };
197
+ }
198
+ function extractDetectionValue(detection) {
199
+ const assignment = detection.match.match(/[:=]\s*['"]?(.+?)['"]?\s*$/);
200
+ const value = assignment?.[1] ?? detection.match;
201
+ return value.replace(/^['"`]+|['"`;]+$/g, '');
202
+ }
203
+ function isPublicOrDevCredential(type, value, context) {
204
+ if (/^pk_test_/i.test(value))
205
+ return true;
206
+ if (!PROVIDER_TYPES.has(type))
207
+ return false;
208
+ return isSafeProviderExample(value, context);
209
+ }
210
+ function isSafeContext(context, value) {
211
+ return isDocumentationContext(context.before, context.line)
212
+ || isBuildLogLine(context.line)
213
+ || isSafePlaceholderValue(value)
214
+ || isEnvReference(value)
215
+ || isHashLine(context.line);
216
+ }
217
+ function isDocumentationContext(before, line) {
218
+ const text = before + '\n' + line;
219
+ return /(?:example|sample|placeholder|fake|dummy|test fixture|fixture|docs?|readme|tutorial|settings\.json|cursor\.apiKey|env var|process\.env)/i.test(text);
220
+ }
221
+ function isCommentLine(line) {
222
+ return /^\s*(?:\/\/|#|\*|<!--)/.test(line);
223
+ }
224
+ function isBuildLogLine(line) {
225
+ return /^npm\s+(?:notice|warn|WARN|ERR!)/i.test(line)
226
+ || /^#\d+\s+[\d.]+\s/.test(line)
227
+ || /\b(?:missing|undefined|not set|not found|required)\b/i.test(line);
228
+ }
229
+ function isHashLine(line) {
230
+ return /(?:digest|hash|checksum|sha256|sha1|md5)\s*[:=]/i.test(line);
231
+ }
232
+ function isSafePlaceholder(type, value, context) {
233
+ if (isSafePlaceholderValue(value))
234
+ return true;
235
+ if (isPlaceholderCredentialUrl(value) || isPlaceholderCredentialUrl(context.line))
236
+ return true;
237
+ if (GENERIC_TYPES.has(type) && isGenericPlaceholderValue(value))
238
+ return true;
239
+ return PROVIDER_TYPES.has(type) && isSafeProviderExample(value, context);
240
+ }
241
+ function isSafePlaceholderValue(value) {
242
+ return /^(?:example|placeholder|dummy|fake|sample|changeme|replace[_-]?me|password123|secret123|correct horse battery staple)$/i.test(value)
243
+ || /^(?:your[_-]?)?(?:api[_-]?)?key[_-]?here$/i.test(value)
244
+ || /^not-a-real/i.test(value)
245
+ || /^super-secret-client-id-string$/i.test(value)
246
+ || /^x{8,}$/i.test(value)
247
+ || /^test[_-]/i.test(value)
248
+ || /^dev[_-]/i.test(value)
249
+ || /^mock[_-]/i.test(value)
250
+ || /^stub[_-]/i.test(value)
251
+ || value === 'abcdefghijklmnopqrstuvwxyz'
252
+ || /EXAMPLE(?:KEY)?$/i.test(value);
253
+ }
254
+ function isGenericPlaceholderValue(value) {
255
+ return /(?:example|placeholder|dummy|fake|sample|your[_-]|replace[_-]?me|not-a-real)/i.test(value);
256
+ }
257
+ function isSafeProviderExample(value, context) {
258
+ return isDocumentationContext(context.before, context.line) && isSafePlaceholderValue(value);
259
+ }
260
+ function isPlaceholderCredentialUrl(value) {
261
+ const match = value.match(/^[a-z][a-z0-9+.-]*:\/\/[^:@/\s]+:([^@/\s]+)@/i);
262
+ return match !== null && isSafePlaceholderValue(decodeURIComponent(match[1]));
263
+ }
264
+ function isLivePublishableStripeKey(type, value) {
265
+ return type === 'stripe_key' && /^pk_live_/i.test(value);
266
+ }
267
+ function isEnvReference(value) {
268
+ return /^\$\{?[A-Z][A-Z0-9_]+\}?$/.test(value)
269
+ || /^process\.env\.[A-Z][A-Z0-9_]+$/.test(value);
270
+ }
271
+ function isHighEntropySecret(type, value) {
272
+ return type === 'high_entropy_secret' || (value.length >= 24 && isHighEntropy(value));
273
+ }
274
+ function isHighEntropy(value) {
275
+ if (value.length < 24)
276
+ return false;
277
+ const freq = {};
278
+ for (const char of value)
279
+ freq[char] = (freq[char] || 0) + 1;
280
+ return Object.values(freq).reduce((sum, count) => {
281
+ const probability = count / value.length;
282
+ return sum - probability * Math.log2(probability);
283
+ }, 0) > 4.5;
284
+ }
285
+ function hasSensitiveSshOptions(line) {
286
+ return /\b(?:-i|IdentityFile)\b/.test(line) || /\bpassword\b/i.test(line);
287
+ }
288
+ function isLocalDatabaseUrl(value) {
289
+ const hasDbScheme = /(?:^|=)(?:postgres(?:ql)?|mysql|mariadb|mssql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\//i.test(value);
290
+ if (!hasDbScheme)
291
+ return false;
292
+ if (/@(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$)/i.test(value))
293
+ return true;
294
+ return /:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$)/i.test(value);
295
+ }
296
+ function verdict(confidence, decision, reasons) {
297
+ return {
298
+ confidence: Math.max(0, Math.min(99, Math.round(confidence))),
299
+ decision,
300
+ reason_codes: [...reasons].sort(),
301
+ };
302
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createDLPAuditEntry, formatDLPAlert, scanInputForCredentials, } from './input-validator.js';
3
+ describe('DLP confidence engine', () => {
4
+ it('allows explicit example password assignments', () => {
5
+ const result = scanInputForCredentials('password = "examplepassword"');
6
+ expect(result.status).toBe('clean');
7
+ expect(result.allowed_detections?.[0].decision).toBe('allow');
8
+ });
9
+ it('warns on high-entropy values even with api_key label', () => {
10
+ const result = scanInputForCredentials('api_key: "abcdefZ9y8X7w6V5u4T3s2R1q0P9o8N7m6"');
11
+ expect(result.status).toBe('warning');
12
+ expect(result.detections[0].decision).toBe('warn');
13
+ });
14
+ it('allows localhost database URLs with placeholder credentials', () => {
15
+ const result = scanInputForCredentials('DATABASE_URL=postgresql://user:password@localhost:5432/db');
16
+ expect(result.status).toBe('clean');
17
+ });
18
+ it('warns for live Stripe publishable keys', () => {
19
+ const result = scanInputForCredentials('STRIPE_PUBLIC_KEY=pk_live_123456789012345678901234');
20
+ expect(result.status).toBe('warning');
21
+ expect(result.detections[0].decision).toBe('warn');
22
+ expect(result.detections[0].reason_codes).toContain('public_live_key');
23
+ });
24
+ it('allows provider keys in comments (reduced false positives)', () => {
25
+ const result = scanInputForCredentials('// AWS_ACCESS_KEY_ID=AKIAZ9Y8X7W6V5U4T3Q2');
26
+ expect(result.status).toBe('clean');
27
+ });
28
+ it('allows GitHub tokens in comments', () => {
29
+ const result = scanInputForCredentials('// ghp_Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6B5C4A3D2');
30
+ expect(result.status).toBe('clean');
31
+ });
32
+ it('blocks JWT tokens in comments', () => {
33
+ const jwt = '// token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJwcm9kLXVzZXIiLCJpc3MiOiJhdXRoLXNlcnZpY2UifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
34
+ const result = scanInputForCredentials(jwt);
35
+ expect(result.status).toBe('blocked');
36
+ expect(result.detections.some(d => d.type === 'jwt_token')).toBe(true);
37
+ });
38
+ it('shows allowed safe values in clean output', () => {
39
+ const result = scanInputForCredentials('api_key: "abcdefghijklmnopqrstuvwxyz"');
40
+ const alert = formatDLPAlert(result);
41
+ expect(alert).toContain('allowed 1 sample or safe credential-like value');
42
+ });
43
+ it('includes allowed detections without raw values in audit log', () => {
44
+ const result = scanInputForCredentials('api_key: "abcdefghijklmnopqrstuvwxyz"');
45
+ const entry = createDLPAuditEntry(result, { agent: 'claude' });
46
+ const allowed = entry.allowed_detections;
47
+ expect(allowed).toHaveLength(1);
48
+ expect(allowed[0].decision).toBe('allow');
49
+ expect(allowed[0].match).toBeUndefined();
50
+ });
51
+ });
@@ -0,0 +1,48 @@
1
+ export interface DLPFeedbackEntry {
2
+ fingerprint: string;
3
+ type: string;
4
+ shape: string;
5
+ hits: number;
6
+ lastSeenAt: string;
7
+ source: 'hook' | 'cli' | 'mcp';
8
+ }
9
+ export interface DLPFeedbackStore {
10
+ version: 1;
11
+ entries: DLPFeedbackEntry[];
12
+ }
13
+ export interface DLPBlockManifest {
14
+ timestamp: string;
15
+ detections: Array<{
16
+ type: string;
17
+ match: string;
18
+ fingerprint: string;
19
+ shape: string;
20
+ }>;
21
+ }
22
+ /** Normalize a matched value to a shape for learning (preserves structure, not secret content). */
23
+ export declare function shapeValue(value: string): string;
24
+ export declare function fingerprintDetection(type: string, match: string, line?: string): string;
25
+ /** Stable fingerprint for hook-learned false positives (type + value shape only). */
26
+ export declare function feedbackFingerprint(type: string, match: string): string;
27
+ export declare function lastBlockPath(cwd: string): string;
28
+ export declare function recordDLPFeedback(cwd: string, entry: {
29
+ type: string;
30
+ match: string;
31
+ line?: string;
32
+ source?: DLPFeedbackEntry['source'];
33
+ }): Promise<string>;
34
+ export declare function isLearnedAllow(cwd: string | undefined, type: string, match: string, _line?: string): Promise<boolean>;
35
+ export declare function isLearnedAllowSync(store: DLPFeedbackStore, type: string, match: string, _line?: string): boolean;
36
+ export declare function loadDLPFeedbackStore(cwd: string): Promise<DLPFeedbackStore>;
37
+ export declare function getLearnedAllowCount(cwd: string): Promise<number>;
38
+ /** Synchronous load for hot-path DLP scanning (<50ms budget). */
39
+ export declare function loadDLPFeedbackStoreSync(cwd: string): DLPFeedbackStore;
40
+ export declare function writeDLPBlockManifest(cwd: string, detections: Array<{
41
+ type: string;
42
+ match: string;
43
+ position?: {
44
+ start: number;
45
+ end: number;
46
+ };
47
+ }>, input: string): Promise<void>;
48
+ export declare function allowLastDLPBlock(cwd: string, source?: DLPFeedbackEntry['source']): Promise<number>;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * DLP feedback learning — reduces false positives over time from hook-driven user corrections.
3
+ * Fingerprints are stored per-project in .rigour/dlp-feedback.json.
4
+ */
5
+ import { createHash } from 'crypto';
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+ /** Normalize a matched value to a shape for learning (preserves structure, not secret content). */
9
+ export function shapeValue(value) {
10
+ return value
11
+ .replace(/[A-Za-z0-9]{8,}/g, '<TOKEN>')
12
+ .replace(/\d{4,}/g, '<NUM>')
13
+ .slice(0, 120);
14
+ }
15
+ export function fingerprintDetection(type, match, line) {
16
+ const value = extractValueShape(match);
17
+ const raw = line
18
+ ? `${type}|${value}|${shapeValue(line)}`
19
+ : `${type}|${value}`;
20
+ return createHash('sha256').update(raw).digest('hex').slice(0, 24);
21
+ }
22
+ /** Stable fingerprint for hook-learned false positives (type + value shape only). */
23
+ export function feedbackFingerprint(type, match) {
24
+ const value = extractValueShape(match);
25
+ return createHash('sha256').update(`${type}|${value}`).digest('hex').slice(0, 24);
26
+ }
27
+ function extractValueShape(match) {
28
+ const assignment = match.match(/[:=]\s*['"]?(.+?)['"]?\s*$/);
29
+ const value = assignment?.[1] ?? match;
30
+ return shapeValue(value.replace(/^['"`]+|['"`;]+$/g, ''));
31
+ }
32
+ function feedbackPath(cwd) {
33
+ return path.join(cwd, '.rigour', 'dlp-feedback.json');
34
+ }
35
+ export function lastBlockPath(cwd) {
36
+ return path.join(cwd, '.rigour', 'dlp-last-block.json');
37
+ }
38
+ async function loadStore(cwd) {
39
+ const filePath = feedbackPath(cwd);
40
+ if (!await fs.pathExists(filePath)) {
41
+ return { version: 1, entries: [] };
42
+ }
43
+ try {
44
+ const data = await fs.readJson(filePath);
45
+ if (data?.version === 1 && Array.isArray(data.entries))
46
+ return data;
47
+ }
48
+ catch {
49
+ // corrupt — reset
50
+ }
51
+ return { version: 1, entries: [] };
52
+ }
53
+ async function saveStore(cwd, store) {
54
+ await fs.ensureDir(path.dirname(feedbackPath(cwd)));
55
+ await fs.writeJson(feedbackPath(cwd), store, { spaces: 2 });
56
+ }
57
+ export async function recordDLPFeedback(cwd, entry) {
58
+ const fingerprint = feedbackFingerprint(entry.type, entry.match);
59
+ const shape = extractValueShape(entry.match);
60
+ const store = await loadStore(cwd);
61
+ const existing = store.entries.find(e => e.fingerprint === fingerprint);
62
+ const now = new Date().toISOString();
63
+ if (existing) {
64
+ existing.hits += 1;
65
+ existing.lastSeenAt = now;
66
+ }
67
+ else {
68
+ store.entries.push({
69
+ fingerprint,
70
+ type: entry.type,
71
+ shape,
72
+ hits: 1,
73
+ lastSeenAt: now,
74
+ source: entry.source ?? 'hook',
75
+ });
76
+ }
77
+ if (store.entries.length > 500) {
78
+ store.entries.sort((a, b) => b.hits - a.hits || b.lastSeenAt.localeCompare(a.lastSeenAt));
79
+ store.entries = store.entries.slice(0, 500);
80
+ }
81
+ await saveStore(cwd, store);
82
+ return fingerprint;
83
+ }
84
+ export async function isLearnedAllow(cwd, type, match, _line) {
85
+ if (!cwd)
86
+ return false;
87
+ const store = await loadStore(cwd);
88
+ return isLearnedAllowSync(store, type, match);
89
+ }
90
+ export function isLearnedAllowSync(store, type, match, _line) {
91
+ const fingerprint = feedbackFingerprint(type, match);
92
+ return store.entries.some(e => e.fingerprint === fingerprint);
93
+ }
94
+ export async function loadDLPFeedbackStore(cwd) {
95
+ return loadStore(cwd);
96
+ }
97
+ export async function getLearnedAllowCount(cwd) {
98
+ const store = await loadStore(cwd);
99
+ return store.entries.length;
100
+ }
101
+ /** Synchronous load for hot-path DLP scanning (<50ms budget). */
102
+ export function loadDLPFeedbackStoreSync(cwd) {
103
+ const filePath = feedbackPath(cwd);
104
+ try {
105
+ if (!fs.existsSync(filePath)) {
106
+ return { version: 1, entries: [] };
107
+ }
108
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
109
+ if (data?.version === 1 && Array.isArray(data.entries))
110
+ return data;
111
+ }
112
+ catch {
113
+ // corrupt
114
+ }
115
+ return { version: 1, entries: [] };
116
+ }
117
+ function getLineAt(input, start) {
118
+ const lineStart = input.lastIndexOf('\n', start) + 1;
119
+ const lineEnd = input.indexOf('\n', start);
120
+ return input.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
121
+ }
122
+ export async function writeDLPBlockManifest(cwd, detections, input) {
123
+ const seen = new Set();
124
+ const manifestDetections = [];
125
+ for (const d of detections) {
126
+ const fp = feedbackFingerprint(d.type, d.match);
127
+ if (seen.has(fp))
128
+ continue;
129
+ seen.add(fp);
130
+ manifestDetections.push({
131
+ type: d.type,
132
+ match: d.match.slice(0, 80),
133
+ fingerprint: fp,
134
+ shape: extractValueShape(d.match),
135
+ });
136
+ }
137
+ const manifest = {
138
+ timestamp: new Date().toISOString(),
139
+ detections: manifestDetections,
140
+ };
141
+ await fs.ensureDir(path.dirname(lastBlockPath(cwd)));
142
+ await fs.writeJson(lastBlockPath(cwd), manifest, { spaces: 2 });
143
+ }
144
+ export async function allowLastDLPBlock(cwd, source = 'hook') {
145
+ const manifestPath = lastBlockPath(cwd);
146
+ if (!await fs.pathExists(manifestPath))
147
+ return 0;
148
+ const manifest = await fs.readJson(manifestPath);
149
+ const store = await loadStore(cwd);
150
+ const now = new Date().toISOString();
151
+ let count = 0;
152
+ for (const d of manifest.detections ?? []) {
153
+ const existing = store.entries.find(e => e.fingerprint === d.fingerprint);
154
+ if (existing) {
155
+ existing.hits += 1;
156
+ existing.lastSeenAt = now;
157
+ }
158
+ else {
159
+ store.entries.push({
160
+ fingerprint: d.fingerprint,
161
+ type: d.type,
162
+ shape: d.shape,
163
+ hits: 1,
164
+ lastSeenAt: now,
165
+ source,
166
+ });
167
+ }
168
+ count++;
169
+ }
170
+ if (count > 0) {
171
+ await saveStore(cwd, store);
172
+ }
173
+ return count;
174
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import fs from 'fs-extra';
5
+ import { recordDLPFeedback, feedbackFingerprint, loadDLPFeedbackStoreSync, isLearnedAllowSync, writeDLPBlockManifest, allowLastDLPBlock, } from './dlp-feedback.js';
6
+ import { scanInputForCredentials } from './input-validator.js';
7
+ describe('DLP feedback learning', () => {
8
+ const testCwd = path.join(os.tmpdir(), `rigour-dlp-fb-${Date.now()}`);
9
+ beforeEach(async () => {
10
+ await fs.ensureDir(path.join(testCwd, '.rigour'));
11
+ });
12
+ afterEach(async () => {
13
+ await fs.remove(testCwd);
14
+ });
15
+ it('produces stable fingerprints for same shape', () => {
16
+ const a = feedbackFingerprint('password_assignment', 'api_key = "your-key-here"');
17
+ const b = feedbackFingerprint('password_assignment', 'api_key = "your-key-here"');
18
+ expect(a).toBe(b);
19
+ });
20
+ it('records and applies learned false positives', async () => {
21
+ const match = 'api_key = "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4"';
22
+ await recordDLPFeedback(testCwd, { type: 'password_assignment', match, source: 'hook' });
23
+ const store = loadDLPFeedbackStoreSync(testCwd);
24
+ expect(isLearnedAllowSync(store, 'password_assignment', match)).toBe(true);
25
+ const result = scanInputForCredentials(match, { cwd: testCwd });
26
+ expect(result.status).toBe('clean');
27
+ expect(result.allowed_detections?.[0].reason_codes).toContain('learned_false_positive');
28
+ });
29
+ it('allowLastDLPBlock learns from manifest', async () => {
30
+ const text = 'api_key = "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4"';
31
+ const blocked = scanInputForCredentials(text, { cwd: testCwd, block_on_detection: true });
32
+ expect(blocked.status).toBe('warning');
33
+ await writeDLPBlockManifest(testCwd, blocked.detections, text);
34
+ const learned = await allowLastDLPBlock(testCwd, 'hook');
35
+ expect(learned).toBeGreaterThan(0);
36
+ const retry = scanInputForCredentials(text, { cwd: testCwd });
37
+ expect(retry.status).toBe('clean');
38
+ });
39
+ });
@@ -8,5 +8,7 @@ export type { HookTool, HookConfig, HookCheckerResult } from './types.js';
8
8
  export { DEFAULT_HOOK_CONFIG, FAST_GATE_IDS } from './types.js';
9
9
  export { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry } from './input-validator.js';
10
10
  export type { CredentialDetection, InputValidationResult, InputValidationConfig } from './input-validator.js';
11
+ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearnedAllowCount, loadDLPFeedbackStore, loadDLPFeedbackStoreSync, fingerprintDetection, } from './dlp-feedback.js';
12
+ export type { DLPFeedbackEntry, DLPFeedbackStore, DLPBlockManifest } from './dlp-feedback.js';
11
13
  export { generateDLPHookFiles } from './dlp-templates.js';
12
14
  export type { GeneratedDLPHookFile } from './dlp-templates.js';
@@ -7,4 +7,5 @@ export { generateHookFiles } from './templates.js';
7
7
  export { DEFAULT_HOOK_CONFIG, FAST_GATE_IDS } from './types.js';
8
8
  // DLP (Data Loss Prevention) — v4.2.0
9
9
  export { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry } from './input-validator.js';
10
+ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearnedAllowCount, loadDLPFeedbackStore, loadDLPFeedbackStoreSync, fingerprintDetection, } from './dlp-feedback.js';
10
11
  export { generateDLPHookFiles } from './dlp-templates.js';
@@ -28,10 +28,14 @@ export interface CredentialDetection {
28
28
  start: number;
29
29
  end: number;
30
30
  };
31
+ confidence?: number;
32
+ decision?: 'block' | 'warn' | 'allow';
33
+ reason_codes?: string[];
31
34
  }
32
35
  export interface InputValidationResult {
33
36
  status: 'clean' | 'blocked' | 'warning';
34
37
  detections: CredentialDetection[];
38
+ allowed_detections?: CredentialDetection[];
35
39
  duration_ms: number;
36
40
  scanned_length: number;
37
41
  }
@@ -46,6 +50,10 @@ export interface InputValidationConfig {
46
50
  ignore_patterns?: string[];
47
51
  /** Log blocked inputs to audit trail */
48
52
  audit_log?: boolean;
53
+ /** Project root for learned false-positive feedback (.rigour/dlp-feedback.json) */
54
+ cwd?: string;
55
+ /** Apply learned hook feedback to reduce repeat false positives (default true when cwd set) */
56
+ use_learned_feedback?: boolean;
49
57
  }
50
58
  /**
51
59
  * Scan input text for credential patterns.