@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
@@ -16,6 +16,8 @@
16
16
  * - Azure / GCP / Stripe / Twilio keys
17
17
  *
18
18
  */
19
+ import { classifyDLPDetection } from './dlp-confidence.js';
20
+ import { isLearnedAllowSync, loadDLPFeedbackStoreSync } from './dlp-feedback.js';
19
21
  const CREDENTIAL_PATTERNS = [
20
22
  // ── Cloud Provider Keys ───────────────────────────────────
21
23
  {
@@ -53,7 +55,7 @@ const CREDENTIAL_PATTERNS = [
53
55
  // ── API Keys (Provider-Specific Prefixes) ─────────────────
54
56
  {
55
57
  type: 'openai_key',
56
- regex: /\b(sk-(?:proj-)?[A-Za-z0-9]{20,})\b/g,
58
+ regex: /\b(sk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{24,})\b/g,
57
59
  severity: 'critical',
58
60
  description: 'OpenAI API key detected',
59
61
  recommendation: 'Use process.env.OPENAI_API_KEY instead',
@@ -85,7 +87,7 @@ const CREDENTIAL_PATTERNS = [
85
87
  },
86
88
  {
87
89
  type: 'twilio_key',
88
- regex: /\b(SK[a-f0-9]{32})\b/g,
90
+ regex: /(?:TWILIO[_-]?(?:API[_-]?)?KEY|twilio[_-]?api[_-]?key)\s*[:=]\s*['"]?(SK[a-f0-9]{32})['"]?/gi,
89
91
  severity: 'high',
90
92
  description: 'Twilio API key detected',
91
93
  recommendation: 'Use process.env.TWILIO_API_KEY instead',
@@ -119,7 +121,7 @@ const CREDENTIAL_PATTERNS = [
119
121
  // ── Database Connection Strings ───────────────────────────
120
122
  {
121
123
  type: 'database_url',
122
- regex: /\b((?:postgres(?:ql)?|mysql|mariadb|mssql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^\s'"`,;}{)]+)/gi,
124
+ regex: /\b((?:postgres(?:ql)?|mysql|mariadb|mssql|mongodb(?:\+srv)?|redis|rediss|amqp|amqps):\/\/[^/\s@]+:[^@/\s]+@[^\s'"`,;}{)]+)/gi,
123
125
  severity: 'critical',
124
126
  description: 'Database connection string with credentials detected',
125
127
  recommendation: 'Use process.env.DATABASE_URL instead',
@@ -243,80 +245,31 @@ function shannonEntropy(str) {
243
245
  return sum - p * Math.log2(p);
244
246
  }, 0);
245
247
  }
246
- /**
247
- * Smart context filter — decides if a detection is a real credential or a false positive.
248
- *
249
- * The challenge: build logs can contain BOTH false positives (variable names mentioned
250
- * in npm warnings) AND real leaks (a password accidentally echoed in CI output).
251
- *
252
- * Strategy:
253
- * 1. If the match has an actual SECRET VALUE (high entropy, long random string) → KEEP IT
254
- * 2. If the match is just a variable NAME reference with no real value → SKIP IT
255
- * 3. If the match is in a documentation/comment context → SKIP IT
256
- */
257
- function isLikelyFalsePositive(detection, input) {
258
- const { match, type, position } = detection;
259
- // Provider-specific prefixed keys are ALWAYS real (AKIA*, sk-*, ghp_*, etc.)
260
- const prefixedTypes = [
261
- 'aws_access_key', 'openai_key', 'anthropic_key', 'github_token',
262
- 'stripe_key', 'slack_token', 'sendgrid_key', 'private_key',
263
- 'private_key_full', 'jwt_token', 'bearer_token', 'database_url',
264
- 'credentials_in_url', 'gcp_service_account',
265
- ];
266
- if (prefixedTypes.includes(type))
248
+ /** Credential-less local DB URLs (no user:pass@) are safe for dev discussion. */
249
+ function isCredentiallessLocalUrl(url) {
250
+ if (/:[^/@\s]+@[^/\s]+/i.test(url))
267
251
  return false;
268
- // Get the line containing the match
269
- const lineStart = input.lastIndexOf('\n', (position?.start ?? 0)) + 1;
270
- const lineEnd = input.indexOf('\n', (position?.end ?? match.length));
271
- const line = input.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
272
- // npm notice/warn lines mentioning variable names (not values)
273
- if (/^npm\s+(?:notice|warn|WARN|ERR!)/i.test(line))
274
- return true;
275
- // Docker build step output referencing env var names
276
- if (/^#\d+\s+[\d.]+\s/.test(line)) {
277
- // But if there's an actual assignment with a value, keep it
278
- if (/[:=]\s*['"]?[A-Za-z0-9+/=_-]{20,}/.test(match))
279
- return false;
252
+ return /:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$)/i.test(url);
253
+ }
254
+ function isPlaceholderEnvValue(value) {
255
+ return /^(?:your[_-]?)?(?:api[_-]?)?key[_-]?here$/i.test(value)
256
+ || /^your[_-]/i.test(value)
257
+ || /^replace[_-]?me$/i.test(value)
258
+ || /^changeme$/i.test(value)
259
+ || /^not-a-real/i.test(value)
260
+ || /^super-secret-client-id-string$/i.test(value)
261
+ || /^x{8,}$/i.test(value);
262
+ }
263
+ function looksLikeLowEntropyOpenAiKey(match) {
264
+ const suffix = match.replace(/^sk-(?:proj-)?/i, '');
265
+ if (suffix.length < 24)
280
266
  return true;
281
- }
282
- // "digest:", "hash:", "checksum:" followed by hex — not credentials
283
- if (/(?:digest|hash|checksum|sha256|sha1|md5)\s*[:=]/i.test(line))
267
+ if (/^(.)\1{5,}/.test(suffix))
284
268
  return true;
285
- // Error messages referencing env var names without values
286
- if (/(?:missing|undefined|not set|not found|required)\s+.*(?:NPM_TOKEN|DOCKER_PASSWORD|PYPI_TOKEN)/i.test(line))
269
+ if (shannonEntropy(suffix) < 3.8)
287
270
  return true;
288
- // For generic patterns (password_assignment, env_variable, ci_secret, base64/hex_secret):
289
- // Check if the captured VALUE has enough entropy to be a real secret
290
- const genericTypes = ['password_assignment', 'env_variable', 'ci_secret', 'base64_secret', 'hex_secret', 'high_entropy_secret'];
291
- if (genericTypes.includes(type)) {
292
- // Extract the value portion (after = or : )
293
- const valueMatch = match.match(/[:=]\s*['"]?(.+?)['"]?\s*$/);
294
- if (valueMatch) {
295
- const value = valueMatch[1];
296
- const entropy = shannonEntropyForFilter(value);
297
- // Low entropy + short = likely a placeholder, example, or variable name
298
- if (entropy < 3.0 && value.length < 16)
299
- return true;
300
- // Pure numeric strings in log context (build numbers, timestamps)
301
- if (/^\d+$/.test(value))
302
- return true;
303
- }
304
- }
305
271
  return false;
306
272
  }
307
- /** Shannon entropy helper for the context filter */
308
- function shannonEntropyForFilter(str) {
309
- if (str.length === 0)
310
- return 0;
311
- const freq = {};
312
- for (const c of str)
313
- freq[c] = (freq[c] || 0) + 1;
314
- const len = str.length;
315
- return Object.values(freq).reduce((sum, f) => {
316
- const p = f / len;
317
- return sum - p * Math.log2(p);
318
- }, 0);
319
- }
320
273
  // ── Core Scanner ──────────────────────────────────────────────────
321
274
  /**
322
275
  * Redact a matched credential for safe display.
@@ -360,6 +313,18 @@ export function scanInputForCredentials(input, config = {}) {
360
313
  if (capturedValue.length < minSecretLength && pattern.type !== 'private_key') {
361
314
  continue;
362
315
  }
316
+ if (pattern.type === 'database_url' && isCredentiallessLocalUrl(fullMatch)) {
317
+ continue;
318
+ }
319
+ if (pattern.type === 'openai_key' && looksLikeLowEntropyOpenAiKey(fullMatch)) {
320
+ continue;
321
+ }
322
+ if (pattern.type === 'env_variable') {
323
+ const envVal = fullMatch.split('=').slice(1).join('=').replace(/^['"]|['"]$/g, '').trim();
324
+ if (!envVal || isPlaceholderEnvValue(envVal)) {
325
+ continue;
326
+ }
327
+ }
363
328
  // Check ignore patterns
364
329
  const isIgnored = ignoreRegexes.some(ignore => {
365
330
  ignore.lastIndex = 0;
@@ -418,7 +383,7 @@ export function scanInputForCredentials(input, config = {}) {
418
383
  while ((entropyMatch = entropyRegex.exec(input)) !== null) {
419
384
  const value = entropyMatch[1];
420
385
  const entropy = shannonEntropy(value);
421
- if (entropy > 4.5 && value.length >= 24) {
386
+ if (entropy > 4.5 && value.length >= 24 && !isPlaceholderEnvValue(value)) {
422
387
  // Only add if not already caught by a more specific pattern
423
388
  const alreadyCaught = detections.some(d => d.position && entropyMatch.index >= d.position.start && entropyMatch.index < d.position.end);
424
389
  if (!alreadyCaught) {
@@ -435,22 +400,66 @@ export function scanInputForCredentials(input, config = {}) {
435
400
  }
436
401
  }
437
402
  }
438
- // ── Context filter: remove false positives from log/doc/CI output ──
439
- const filtered = detections.filter(d => !isLikelyFalsePositive(d, input));
403
+ // ── Confidence engine: score candidates and suppress allowed false positives ──
404
+ const useLearned = config.use_learned_feedback !== false && !!config.cwd;
405
+ const feedbackStore = useLearned && config.cwd
406
+ ? loadDLPFeedbackStoreSync(config.cwd)
407
+ : null;
408
+ const classified = detections.map(d => {
409
+ if (feedbackStore) {
410
+ const start = d.position?.start ?? 0;
411
+ const lineStart = input.lastIndexOf('\n', start) + 1;
412
+ const lineEnd = input.indexOf('\n', start);
413
+ const line = input.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
414
+ if (isLearnedAllowSync(feedbackStore, d.type, d.match, line)) {
415
+ return {
416
+ ...d,
417
+ confidence: 12,
418
+ decision: 'allow',
419
+ reason_codes: ['learned_false_positive'],
420
+ };
421
+ }
422
+ }
423
+ return {
424
+ ...d,
425
+ ...classifyDLPDetection(d, input),
426
+ };
427
+ });
428
+ // Line-level learned expansion: one false-positive mark allows sibling patterns on same line
429
+ const learnedStarts = new Set(classified
430
+ .filter(d => d.decision === 'allow' && d.reason_codes?.includes('learned_false_positive'))
431
+ .map(d => d.position?.start ?? -1));
432
+ const expanded = classified.map(d => {
433
+ const start = d.position?.start ?? -1;
434
+ if (start >= 0 && learnedStarts.has(start) && d.decision !== 'allow') {
435
+ return {
436
+ ...d,
437
+ confidence: 12,
438
+ decision: 'allow',
439
+ reason_codes: ['learned_false_positive'],
440
+ };
441
+ }
442
+ return d;
443
+ });
444
+ const actionable = expanded.filter(d => d.decision !== 'allow');
445
+ const allowed = expanded.filter(d => d.decision === 'allow');
440
446
  // Deduplicate overlapping detections (keep highest severity)
441
- const deduped = deduplicateDetections(filtered);
447
+ const deduped = deduplicateDetections(actionable);
448
+ const allowedDeduped = deduplicateDetections(allowed);
442
449
  // Sort by severity (critical first)
443
450
  const severityOrder = { critical: 0, high: 1, medium: 2 };
444
451
  deduped.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
445
452
  const blockOnDetection = config.block_on_detection ?? true;
453
+ const hasBlock = deduped.some(d => d.decision === 'block');
446
454
  const status = deduped.length === 0
447
455
  ? 'clean'
448
- : blockOnDetection
456
+ : blockOnDetection && hasBlock
449
457
  ? 'blocked'
450
458
  : 'warning';
451
459
  return {
452
460
  status,
453
461
  detections: deduped,
462
+ ...(allowedDeduped.length > 0 ? { allowed_detections: allowedDeduped } : {}),
454
463
  duration_ms: Date.now() - start,
455
464
  scanned_length: input.length,
456
465
  };
@@ -467,7 +476,6 @@ function deduplicateDetections(detections) {
467
476
  return posA - posB;
468
477
  });
469
478
  const result = [];
470
- const severityOrder = { critical: 0, high: 1, medium: 2 };
471
479
  for (const detection of sorted) {
472
480
  const overlapping = result.find(existing => {
473
481
  if (!existing.position || !detection.position)
@@ -477,7 +485,7 @@ function deduplicateDetections(detections) {
477
485
  });
478
486
  if (overlapping) {
479
487
  // Keep the higher severity detection
480
- if (severityOrder[detection.severity] < severityOrder[overlapping.severity]) {
488
+ if (shouldReplaceDetection(detection, overlapping)) {
481
489
  const idx = result.indexOf(overlapping);
482
490
  result[idx] = detection;
483
491
  }
@@ -488,25 +496,45 @@ function deduplicateDetections(detections) {
488
496
  }
489
497
  return result;
490
498
  }
499
+ function shouldReplaceDetection(candidate, existing) {
500
+ const severityOrder = { critical: 0, high: 1, medium: 2 };
501
+ const decisionOrder = { block: 0, warn: 1, allow: 2 };
502
+ const severityDelta = severityOrder[candidate.severity] - severityOrder[existing.severity];
503
+ if (severityDelta !== 0)
504
+ return severityDelta < 0;
505
+ const candidateDecision = decisionOrder[candidate.decision ?? 'warn'];
506
+ const existingDecision = decisionOrder[existing.decision ?? 'warn'];
507
+ if (candidateDecision !== existingDecision)
508
+ return candidateDecision < existingDecision;
509
+ return (candidate.confidence ?? 0) > (existing.confidence ?? 0);
510
+ }
491
511
  /**
492
512
  * Format a blocked input result for display in IDE/terminal.
493
513
  * Used by hooks and CLI for human-readable output.
494
514
  */
495
515
  export function formatDLPAlert(result) {
496
516
  if (result.status === 'clean') {
517
+ const allowedCount = result.allowed_detections?.length ?? 0;
518
+ if (allowedCount > 0) {
519
+ return `✓ Input clean — allowed ${allowedCount} sample or safe credential-like value${allowedCount === 1 ? '' : 's'}.`;
520
+ }
497
521
  return '✓ Input clean — no credentials detected.';
498
522
  }
499
523
  const icon = result.status === 'blocked' ? '🛑' : '⚠️';
500
524
  const header = result.status === 'blocked'
501
- ? `${icon} BLOCKED — ${result.detections.length} credential(s) detected in agent input`
502
- : `${icon} WARNING — ${result.detections.length} credential(s) detected in agent input`;
525
+ ? `${icon} BLOCKED — likely live secret (${result.detections.length} finding${result.detections.length === 1 ? '' : 's'})`
526
+ : `${icon} WARNING — token-like value needs review (${result.detections.length} finding${result.detections.length === 1 ? '' : 's'})`;
503
527
  const details = result.detections.map(d => {
504
- const complianceStr = d.compliance.length > 0
528
+ const showCompliance = result.status === 'blocked' && d.compliance.length > 0;
529
+ const confidence = d.confidence !== undefined ? ` | Confidence: ${d.confidence}%` : '';
530
+ const reasons = d.reason_codes?.length ? `\n Why: ${formatReasonCodes(d.reason_codes)}` : '';
531
+ const complianceStr = showCompliance
505
532
  ? `\n Compliance: ${d.compliance.join(', ')}`
506
533
  : '';
507
534
  return [
508
- ` [${d.severity.toUpperCase()}] ${d.description}`,
535
+ ` [${d.severity.toUpperCase()}] ${d.description}${confidence}`,
509
536
  ` Detected: ${d.redacted}`,
537
+ reasons,
510
538
  ` → ${d.recommendation}`,
511
539
  complianceStr,
512
540
  ].filter(Boolean).join('\n');
@@ -530,7 +558,22 @@ export function createDLPAuditEntry(result, metadata) {
530
558
  severity: d.severity,
531
559
  redacted: d.redacted,
532
560
  description: d.description,
561
+ confidence: d.confidence,
562
+ decision: d.decision,
563
+ reason_codes: d.reason_codes,
533
564
  compliance: d.compliance,
534
565
  })),
566
+ allowed_detections: (result.allowed_detections ?? []).map(d => ({
567
+ type: d.type,
568
+ severity: d.severity,
569
+ redacted: d.redacted,
570
+ description: d.description,
571
+ confidence: d.confidence,
572
+ decision: d.decision,
573
+ reason_codes: d.reason_codes,
574
+ })),
535
575
  };
536
576
  }
577
+ function formatReasonCodes(reasonCodes) {
578
+ return reasonCodes.map(reason => reason.replace(/_/g, ' ')).join(', ');
579
+ }
@@ -7,14 +7,16 @@ import { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry, } from '.
7
7
  // ── Cloud Provider Keys ──────────────────────────────────────────
8
8
  describe('scanInputForCredentials — AWS', () => {
9
9
  it('detects AWS Access Key IDs', () => {
10
- const result = scanInputForCredentials('Here is my key: AKIAIOSFODNN7EXAMPLE');
10
+ const result = scanInputForCredentials('Here is my key: AKIAZ9Y8X7W6V5U4T3Q2');
11
11
  expect(result.status).toBe('blocked');
12
12
  expect(result.detections).toHaveLength(1);
13
13
  expect(result.detections[0].type).toBe('aws_access_key');
14
14
  expect(result.detections[0].severity).toBe('critical');
15
+ expect(result.detections[0].decision).toBe('block');
16
+ expect(result.detections[0].confidence).toBeGreaterThanOrEqual(80);
15
17
  });
16
18
  it('detects AWS Secret Key assignments', () => {
17
- const result = scanInputForCredentials('aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"');
19
+ const result = scanInputForCredentials('aws_secret_access_key = "Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6B5C4A3D2E1F0"');
18
20
  expect(result.status).toBe('blocked');
19
21
  expect(result.detections.some(d => d.type === 'aws_secret_key')).toBe(true);
20
22
  });
@@ -38,27 +40,27 @@ describe('scanInputForCredentials — Azure', () => {
38
40
  // ── API Keys (Provider-Specific) ─────────────────────────────────
39
41
  describe('scanInputForCredentials — API keys', () => {
40
42
  it('detects OpenAI key', () => {
41
- const result = scanInputForCredentials('sk-proj-abc1234567890ABCDEFGH');
43
+ const result = scanInputForCredentials('sk-proj-Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6');
42
44
  expect(result.status).toBe('blocked');
43
45
  expect(result.detections[0].type).toBe('openai_key');
44
46
  });
45
47
  it('detects Anthropic key', () => {
46
- const result = scanInputForCredentials('sk-ant-api03-abcdefghijklmnop123456');
48
+ const result = scanInputForCredentials('sk-ant-api03-Z9Y8X7W6V5U4T3S2R1Q0');
47
49
  expect(result.status).toBe('blocked');
48
50
  expect(result.detections[0].type).toBe('anthropic_key');
49
51
  });
50
52
  it('detects GitHub PAT', () => {
51
- const result = scanInputForCredentials('ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh12');
53
+ const result = scanInputForCredentials('ghp_Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6B5C4A3D2');
52
54
  expect(result.status).toBe('blocked');
53
55
  expect(result.detections[0].type).toBe('github_token');
54
56
  });
55
57
  it('detects Stripe live key', () => {
56
- const result = scanInputForCredentials('sk_live_51HxAbCdEfGhIjKlMnOpQrStU');
58
+ const result = scanInputForCredentials('sk_live_Z9Y8X7W6V5U4T3S2R1Q0P9O8');
57
59
  expect(result.status).toBe('blocked');
58
60
  expect(result.detections[0].type).toBe('stripe_key');
59
61
  });
60
62
  it('detects SendGrid key', () => {
61
- const result = scanInputForCredentials('SG.abcdefghijklmnopqrstuv.1234567890abcdefghijklmnopqrstuvwxyz1234567');
63
+ const result = scanInputForCredentials('SG.Z9Y8X7W6V5U4T3S2R1Q0P9.Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6B5C4A3D2E1F0GH7');
62
64
  expect(result.status).toBe('blocked');
63
65
  expect(result.detections[0].type).toBe('sendgrid_key');
64
66
  });
@@ -88,7 +90,7 @@ describe('scanInputForCredentials — Database URLs', () => {
88
90
  expect(dbDetection).toBeDefined();
89
91
  });
90
92
  it('detects MongoDB connection string', () => {
91
- const result = scanInputForCredentials('mongodb+srv://admin:s3cret@cluster0.abc123.mongodb.net/production');
93
+ const result = scanInputForCredentials('mongodb+srv://admin:s3cret@cluster0.prod.mongodb.net/production');
92
94
  expect(result.status).toBe('blocked');
93
95
  });
94
96
  it('detects Redis connection string', () => {
@@ -107,24 +109,30 @@ describe('scanInputForCredentials — Tokens', () => {
107
109
  });
108
110
  // ── Generic Patterns ─────────────────────────────────────────────
109
111
  describe('scanInputForCredentials — Generic patterns', () => {
110
- it('detects password assignment', () => {
111
- const result = scanInputForCredentials("password = 'SuperSecret123!'");
112
- expect(result.status).toBe('blocked');
113
- expect(result.detections[0].type).toBe('password_assignment');
112
+ it('allows ambiguous short password assignment without high entropy', () => {
113
+ const result = scanInputForCredentials("password = 'WinterAccess987!'");
114
+ expect(result.status).toBe('clean');
114
115
  });
115
- it('detects api_key assignment', () => {
116
+ it('allows placeholder api_key assignment', () => {
116
117
  const result = scanInputForCredentials('api_key: "abcdefghijklmnopqrstuvwxyz"');
117
- expect(result.status).toBe('blocked');
118
+ expect(result.status).toBe('clean');
119
+ expect(result.detections).toHaveLength(0);
120
+ expect(result.allowed_detections?.[0].reason_codes).toContain('safe_example_value');
118
121
  });
119
- it('detects .env format', () => {
120
- const result = scanInputForCredentials('DATABASE_PASSWORD=myS3cr3tP@ssw0rd!');
121
- expect(result.status).toBe('blocked');
122
+ it('warns on high-entropy api_key assignment', () => {
123
+ const result = scanInputForCredentials('api_key: "Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4"');
124
+ expect(result.status).toBe('warning');
125
+ expect(result.detections[0].decision).toBe('warn');
126
+ expect(result.detections[0].reason_codes).toContain('high_entropy');
127
+ });
128
+ it('warns on high-entropy .env format values', () => {
129
+ const result = scanInputForCredentials('DATABASE_PASSWORD=Z9y8X7w6V5u4T3s2R1q0P9o8N7m6B5c4Xy');
130
+ expect(result.status).toBe('warning');
122
131
  expect(result.detections.some(d => d.type === 'env_variable')).toBe(true);
123
132
  });
124
- it('detects URL with embedded credentials', () => {
133
+ it('allows placeholder URL with embedded credentials', () => {
125
134
  const result = scanInputForCredentials('http://admin:password123@internal-api.company.com/v1');
126
- expect(result.status).toBe('blocked');
127
- expect(result.detections.some(d => d.type === 'credentials_in_url')).toBe(true);
135
+ expect(result.status).toBe('clean');
128
136
  });
129
137
  });
130
138
  // ── Clean Input ──────────────────────────────────────────────────
@@ -142,6 +150,27 @@ describe('scanInputForCredentials — Clean input', () => {
142
150
  const result = scanInputForCredentials('password = "xxx"');
143
151
  expect(result.status).toBe('clean'); // too short
144
152
  });
153
+ it('returns clean for SSH host aliases without sensitive options', () => {
154
+ const result = scanInputForCredentials('ssh deploy@staging');
155
+ expect(result.status).toBe('clean');
156
+ });
157
+ it('returns clean for public Stripe keys', () => {
158
+ const result = scanInputForCredentials('STRIPE_PUBLIC_KEY=pk_test_123456789012345678901234');
159
+ expect(result.status).toBe('clean');
160
+ expect(result.allowed_detections?.[0].reason_codes).toContain('public_or_test_key');
161
+ });
162
+ it('returns clean for fake provider samples in docs', () => {
163
+ const result = scanInputForCredentials('// example: AKIAIOSFODNN7EXAMPLE');
164
+ expect(result.status).toBe('clean');
165
+ });
166
+ it('returns clean for checksum lines', () => {
167
+ const result = scanInputForCredentials('sha256:abcdefabcdefabcdefabcdefabcdefabcdef');
168
+ expect(result.status).toBe('clean');
169
+ });
170
+ it('returns clean for env variable references', () => {
171
+ const result = scanInputForCredentials('NPM_TOKEN=${NPM_TOKEN}');
172
+ expect(result.status).toBe('clean');
173
+ });
145
174
  });
146
175
  // ── Config Options ───────────────────────────────────────────────
147
176
  describe('scanInputForCredentials — Config', () => {
@@ -150,7 +179,7 @@ describe('scanInputForCredentials — Config', () => {
150
179
  expect(result.status).toBe('clean');
151
180
  });
152
181
  it('returns warning instead of blocked when block_on_detection is false', () => {
153
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE', { block_on_detection: false });
182
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2', { block_on_detection: false });
154
183
  expect(result.status).toBe('warning');
155
184
  expect(result.detections).toHaveLength(1);
156
185
  });
@@ -163,7 +192,7 @@ describe('scanInputForCredentials — Config', () => {
163
192
  const result = scanInputForCredentials('internal-token-XYZ123456', {
164
193
  custom_patterns: ['internal-token-[A-Z0-9]+'],
165
194
  });
166
- expect(result.status).toBe('blocked');
195
+ expect(result.status).toBe('warning');
167
196
  expect(result.detections[0].type).toBe('custom_pattern');
168
197
  });
169
198
  it('respects ignore patterns', () => {
@@ -190,27 +219,26 @@ describe('scanInputForCredentials — Performance', () => {
190
219
  describe('scanInputForCredentials — Deduplication', () => {
191
220
  it('deduplicates overlapping detections and keeps higher severity', () => {
192
221
  // Input that might trigger both generic password_assignment and a more specific pattern
193
- const input = 'api_key = "sk-proj-abc1234567890ABCDEFGH"';
222
+ const input = 'api_key = "sk-proj-Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6"';
194
223
  const result = scanInputForCredentials(input);
195
224
  // Should not have duplicate detections for the same match region
196
225
  const positions = result.detections.map(d => d.position?.start);
197
226
  const uniquePositions = new Set(positions);
198
- // The exact count depends on pattern overlaps, but there should be some deduplication
199
- expect(result.detections.length).toBeLessThanOrEqual(positions.length);
227
+ expect(uniquePositions.size).toBe(positions.length);
200
228
  });
201
229
  });
202
230
  // ── Redaction ────────────────────────────────────────────────────
203
231
  describe('scanInputForCredentials — Redaction', () => {
204
232
  it('redacts matched credentials', () => {
205
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
233
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
206
234
  expect(result.detections[0].redacted).toContain('****');
207
- expect(result.detections[0].redacted).not.toBe('AKIAIOSFODNN7EXAMPLE');
235
+ expect(result.detections[0].redacted).not.toBe('AKIAZ9Y8X7W6V5U4T3Q2');
208
236
  });
209
237
  });
210
238
  // ── Compliance ───────────────────────────────────────────────────
211
239
  describe('scanInputForCredentials — Compliance', () => {
212
240
  it('includes compliance tags for AWS keys', () => {
213
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
241
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
214
242
  expect(result.detections[0].compliance).toContain('SOC2-CC6.1');
215
243
  expect(result.detections[0].compliance).toContain('HIPAA-164.312');
216
244
  expect(result.detections[0].compliance).toContain('PCI-DSS-3.4');
@@ -224,28 +252,30 @@ describe('formatDLPAlert', () => {
224
252
  expect(alert).toContain('clean');
225
253
  });
226
254
  it('shows BLOCKED header when credentials found', () => {
227
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
255
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
228
256
  const alert = formatDLPAlert(result);
229
257
  expect(alert).toContain('BLOCKED');
230
- expect(alert).toContain('credential');
258
+ expect(alert).toContain('likely live secret');
231
259
  });
232
260
  it('shows WARNING header when block_on_detection is false', () => {
233
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE', { block_on_detection: false });
261
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2', { block_on_detection: false });
234
262
  const alert = formatDLPAlert(result);
235
263
  expect(alert).toContain('WARNING');
236
264
  });
237
265
  it('includes severity, redacted value, and recommendation', () => {
238
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
266
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
239
267
  const alert = formatDLPAlert(result);
240
268
  expect(alert).toContain('CRITICAL');
241
269
  expect(alert).toContain('****');
270
+ expect(alert).toContain('Confidence');
271
+ expect(alert).toContain('Why');
242
272
  expect(alert).toContain('process.env');
243
273
  });
244
274
  });
245
275
  // ── createDLPAuditEntry ──────────────────────────────────────────
246
276
  describe('createDLPAuditEntry', () => {
247
277
  it('creates structured audit entry', () => {
248
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
278
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
249
279
  const entry = createDLPAuditEntry(result, { agent: 'claude', userId: 'test-user' });
250
280
  expect(entry.type).toBe('dlp_event');
251
281
  expect(entry.agent).toBe('claude');
@@ -261,11 +291,39 @@ describe('createDLPAuditEntry', () => {
261
291
  expect(entry.timestamp).toBe(ts);
262
292
  });
263
293
  it('redacts credentials in audit log (no raw match)', () => {
264
- const result = scanInputForCredentials('AKIAIOSFODNN7EXAMPLE');
294
+ const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
265
295
  const entry = createDLPAuditEntry(result, { agent: 'claude' });
266
296
  const detections = entry.detections;
267
297
  // Audit entry should have redacted field but NOT the raw match
268
298
  expect(detections[0].redacted).toBeDefined();
269
299
  expect(detections[0].match).toBeUndefined();
300
+ expect(detections[0].confidence).toBeDefined();
301
+ expect(detections[0].decision).toBe('block');
302
+ });
303
+ });
304
+ describe('false positive regression', () => {
305
+ it('allows engineering prompts about API key storage', () => {
306
+ const result = scanInputForCredentials('Should Cursor Admin API keys be stored in ~/.rigour/settings.json under cursor.apiKey?');
307
+ expect(result.status).toBe('clean');
308
+ });
309
+ it('allows credential-less localhost database URLs', () => {
310
+ expect(scanInputForCredentials('redis://localhost:6379').status).toBe('clean');
311
+ expect(scanInputForCredentials('mongodb://localhost:27017/test').status).toBe('clean');
312
+ });
313
+ it('allows placeholder env assignments', () => {
314
+ expect(scanInputForCredentials('API_KEY=your-key-here').status).toBe('clean');
315
+ expect(scanInputForCredentials('api_key = "replace-me"').status).toBe('clean');
316
+ });
317
+ it('allows bare SK-like token without Twilio context', () => {
318
+ expect(scanInputForCredentials('SK-test-fixture-not-a-real-twilio-key').status).toBe('clean');
319
+ });
320
+ it('allows sk-proj examples in comments', () => {
321
+ expect(scanInputForCredentials('// sk-proj-abcdefghijklmnopqrstuvwxyz').status).toBe('clean');
322
+ });
323
+ it('still blocks live production database URLs', () => {
324
+ expect(scanInputForCredentials('postgresql://user:password@prod-server:5432/mydb').status).toBe('blocked');
325
+ });
326
+ it('still blocks live AWS keys in prose', () => {
327
+ expect(scanInputForCredentials('Here is my key: AKIAZ9Y8X7W6V5U4T3Q2').status).toBe('blocked');
270
328
  });
271
329
  });
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export * from './utils/logger.js';
12
12
  export { FileScanner } from './utils/scanner.js';
13
13
  export * from './services/score-history.js';
14
14
  export * from './hooks/index.js';
15
- export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey } from './settings.js';
15
+ export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey, getCursorApiKey, updateCursorApiKey } from './settings.js';
16
16
  export type { RigourSettings, ResolvedDeepOptions, CLIDeepOptions } from './settings.js';
17
17
  export { DeepAnalysisGate } from './gates/deep-analysis.js';
18
18
  export { createProvider } from './inference/index.js';
@@ -32,3 +32,8 @@ export { IncrementalCache } from './services/incremental-cache.js';
32
32
  export type { IncrementalResult } from './services/incremental-cache.js';
33
33
  export { renderScoreGauge, renderSeveritySection, renderGateGrid, renderBrainStatus, renderFullReport, renderMcpHeadline, renderFixAttribution } from './services/terminal-renderer.js';
34
34
  export type { RenderOptions, GateResult } from './services/terminal-renderer.js';
35
+ export * from './context/cache-engine.js';
36
+ export * from './context/context-session.js';
37
+ export * from './context/index-bridge.js';
38
+ export * from './services/context-telemetry-service.js';
39
+ export { recordContextEvent, recordModelUsage, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics, } from './storage/index.js';
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { FileScanner } from './utils/scanner.js';
13
13
  export * from './services/score-history.js';
14
14
  export * from './hooks/index.js';
15
15
  // Settings Module (Global user config at ~/.rigour/settings.json)
16
- export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey } from './settings.js';
16
+ export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getProviderKey, getAgentConfig, getCliPreferences, updateProviderKey, removeProviderKey, getCursorApiKey, updateCursorApiKey } from './settings.js';
17
17
  // Deep Analysis Pipeline (v4.0+)
18
18
  export { DeepAnalysisGate } from './gates/deep-analysis.js';
19
19
  export { createProvider } from './inference/index.js';
@@ -32,6 +32,12 @@ export { getProvenanceTrends, getQualityTrend } from './services/adaptive-thresh
32
32
  export { IncrementalCache } from './services/incremental-cache.js';
33
33
  // Terminal Renderer (rich CLI/MCP output)
34
34
  export { renderScoreGauge, renderSeveritySection, renderGateGrid, renderBrainStatus, renderFullReport, renderMcpHeadline, renderFixAttribution } from './services/terminal-renderer.js';
35
+ // Context Telemetry & 4-Layer Caching Engine
36
+ export * from './context/cache-engine.js';
37
+ export * from './context/context-session.js';
38
+ export * from './context/index-bridge.js';
39
+ export * from './services/context-telemetry-service.js';
40
+ export { recordContextEvent, recordModelUsage, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics, } from './storage/index.js';
35
41
  // Pattern Index is intentionally NOT exported here to prevent
36
42
  // native dependency issues (sharp/transformers) from leaking into
37
43
  // non-AI parts of the system.