@rigour-labs/core 5.2.9 → 5.3.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/dist/context/cache-engine.d.ts +75 -0
- package/dist/context/cache-engine.js +155 -0
- package/dist/context/cache-engine.test.d.ts +1 -0
- package/dist/context/cache-engine.test.js +107 -0
- package/dist/context/context-session.d.ts +25 -0
- package/dist/context/context-session.js +78 -0
- package/dist/context/index-bridge.d.ts +24 -0
- package/dist/context/index-bridge.js +114 -0
- package/dist/hooks/dlp-confidence.d.ts +16 -0
- package/dist/hooks/dlp-confidence.js +302 -0
- package/dist/hooks/dlp-confidence.test.d.ts +1 -0
- package/dist/hooks/dlp-confidence.test.js +51 -0
- package/dist/hooks/dlp-feedback.d.ts +48 -0
- package/dist/hooks/dlp-feedback.js +174 -0
- package/dist/hooks/dlp-feedback.test.d.ts +1 -0
- package/dist/hooks/dlp-feedback.test.js +39 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/input-validator.d.ts +8 -0
- package/dist/hooks/input-validator.js +125 -82
- package/dist/hooks/input-validator.test.js +92 -34
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -1
- package/dist/services/context-telemetry-service.d.ts +121 -0
- package/dist/services/context-telemetry-service.js +375 -0
- package/dist/services/context-telemetry-service.test.d.ts +1 -0
- package/dist/services/context-telemetry-service.test.js +130 -0
- package/dist/settings.d.ts +11 -0
- package/dist/settings.js +18 -0
- package/dist/storage/context-telemetry.d.ts +83 -0
- package/dist/storage/context-telemetry.js +313 -0
- package/dist/storage/context-telemetry.test.d.ts +1 -0
- package/dist/storage/context-telemetry.test.js +80 -0
- package/dist/storage/db.js +140 -2
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/index.js +1 -0
- package/package.json +82 -82
- 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-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
439
|
-
const
|
|
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(
|
|
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 (
|
|
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}
|
|
502
|
-
: `${icon} WARNING — ${result.detections.length}
|
|
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
|
|
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:
|
|
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 = "
|
|
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-
|
|
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-
|
|
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('
|
|
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('
|
|
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.
|
|
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.
|
|
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('
|
|
111
|
-
const result = scanInputForCredentials("password = '
|
|
112
|
-
expect(result.status).toBe('
|
|
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('
|
|
116
|
+
it('allows placeholder api_key assignment', () => {
|
|
116
117
|
const result = scanInputForCredentials('api_key: "abcdefghijklmnopqrstuvwxyz"');
|
|
117
|
-
expect(result.status).toBe('
|
|
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('
|
|
120
|
-
const result = scanInputForCredentials('
|
|
121
|
-
expect(result.status).toBe('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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-
|
|
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
|
-
|
|
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('
|
|
233
|
+
const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
|
|
206
234
|
expect(result.detections[0].redacted).toContain('****');
|
|
207
|
-
expect(result.detections[0].redacted).not.toBe('
|
|
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('
|
|
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('
|
|
255
|
+
const result = scanInputForCredentials('AKIAZ9Y8X7W6V5U4T3Q2');
|
|
228
256
|
const alert = formatDLPAlert(result);
|
|
229
257
|
expect(alert).toContain('BLOCKED');
|
|
230
|
-
expect(alert).toContain('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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.
|