thumbgate 1.27.6 → 1.27.8
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/.claude/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +6 -2
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +49 -5
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +16 -1
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bin/cli.js +180 -2
- package/bin/postinstall.js +1 -1
- package/config/gate-templates.json +84 -0
- package/config/gates/claim-verification.json +6 -0
- package/config/gates/default.json +20 -0
- package/config/github-about.json +1 -1
- package/config/model-candidates.json +50 -0
- package/package.json +66 -25
- package/public/agent-manager.html +41 -1
- package/public/agents-cost-savings.html +1 -1
- package/public/ai-malpractice-prevention.html +2 -1
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +15 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/blog.html +1 -1
- package/public/brand/thumbgate-mark.svg +15 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/codex-enterprise.html +1 -1
- package/public/codex-plugin.html +1 -1
- package/public/compare.html +23 -3
- package/public/dashboard.html +312 -30
- package/public/federal.html +1 -1
- package/public/guide.html +5 -4
- package/public/index.html +167 -49
- package/public/js/buyer-intent.js +672 -0
- package/public/learn.html +74 -7
- package/public/lessons.html +2 -1
- package/public/numbers.html +3 -3
- package/public/pricing.html +63 -15
- package/public/pro.html +7 -7
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-memory-lifecycle.js +211 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/auto-promote-gates.js +75 -4
- package/scripts/build-metadata.js +24 -3
- package/scripts/cli-schema.js +22 -0
- package/scripts/dashboard-chat.js +2 -1
- package/scripts/dashboard.js +8 -0
- package/scripts/export-databricks-bundle.js +5 -1
- package/scripts/export-dpo-pairs.js +7 -2
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-loop.js +34 -0
- package/scripts/filesystem-search.js +35 -10
- package/scripts/gates-engine.js +198 -6
- package/scripts/gemini-embedding-policy.js +2 -1
- package/scripts/hook-stop-anti-claim.js +227 -0
- package/scripts/hook-thumbgate-cache-updater.js +18 -2
- package/scripts/lesson-inference.js +8 -3
- package/scripts/lesson-search.js +17 -1
- package/scripts/operational-integrity.js +39 -5
- package/scripts/plausible-domain-config.js +4 -2
- package/scripts/rate-limiter.js +12 -6
- package/scripts/secret-redaction.js +166 -0
- package/scripts/security-scanner.js +100 -0
- package/scripts/self-distill-agent.js +3 -1
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/seo-gsd.js +635 -0
- package/scripts/statusline-cache-path.js +17 -2
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/statusline-local-stats.js +9 -1
- package/scripts/statusline-meta.js +5 -2
- package/scripts/statusline.sh +13 -1
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/telemetry-analytics.js +9 -0
- package/scripts/thumbgate-search.js +85 -19
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/vector-store.js +44 -0
- package/scripts/workspace-evolver.js +62 -2
- package/src/api/server.js +715 -86
|
@@ -580,17 +580,51 @@ function resolveGitHubRepository(env = process.env) {
|
|
|
580
580
|
function findOpenPrForBranch({ branchName, runner = runGh, env = process.env } = {}) {
|
|
581
581
|
const normalizedBranch = String(branchName || '').trim();
|
|
582
582
|
if (!normalizedBranch) return null;
|
|
583
|
-
|
|
583
|
+
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
|
|
584
|
+
if (!token) {
|
|
584
585
|
return null;
|
|
585
586
|
}
|
|
586
|
-
const args = ['pr', 'list'];
|
|
587
587
|
const repository = resolveGitHubRepository(env);
|
|
588
|
-
if (repository)
|
|
589
|
-
|
|
588
|
+
if (!repository) return null;
|
|
589
|
+
|
|
590
|
+
// Try REST API first as it is much more robust and doesn't rely on gh CLI's GraphQL auth
|
|
591
|
+
try {
|
|
592
|
+
const owner = repository.split('/')[0];
|
|
593
|
+
const url = `https://api.github.com/repos/${repository}/pulls?head=${encodeURIComponent(owner + ':' + normalizedBranch)}&state=open`;
|
|
594
|
+
const curlConfig = `header = "Authorization: token ${token}"\nheader = "User-Agent: ThumbGate-CI"`;
|
|
595
|
+
const curlResult = spawnSync('curl', [
|
|
596
|
+
'-s',
|
|
597
|
+
'-K', '-',
|
|
598
|
+
url
|
|
599
|
+
], {
|
|
600
|
+
encoding: 'utf8',
|
|
601
|
+
input: curlConfig
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
if (curlResult && curlResult.status === 0) {
|
|
605
|
+
const prs = JSON.parse(curlResult.stdout || '[]');
|
|
606
|
+
if (Array.isArray(prs) && prs.length > 0) {
|
|
607
|
+
return {
|
|
608
|
+
number: prs[0].number,
|
|
609
|
+
state: prs[0].state,
|
|
610
|
+
isDraft: prs[0].draft || false,
|
|
611
|
+
url: prs[0].html_url
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} catch (err) {
|
|
616
|
+
console.warn(`[findOpenPrForBranch] REST fallback failed: ${err.message}`);
|
|
590
617
|
}
|
|
591
|
-
|
|
618
|
+
|
|
619
|
+
// Fall back to gh CLI
|
|
620
|
+
const args = ['pr', 'list', '--repo', repository, '--head', normalizedBranch, '--state', 'open', '--json', 'number,state,isDraft,url'];
|
|
592
621
|
const result = runner(args);
|
|
593
622
|
if (!result || result.status !== 0) {
|
|
623
|
+
if (result) {
|
|
624
|
+
console.warn(`[findOpenPrForBranch] gh command failed with status ${result.status}. Stderr: ${result.stderr || ''}`);
|
|
625
|
+
} else {
|
|
626
|
+
console.warn(`[findOpenPrForBranch] gh command failed to spawn.`);
|
|
627
|
+
}
|
|
594
628
|
return null;
|
|
595
629
|
}
|
|
596
630
|
try {
|
|
@@ -14,9 +14,11 @@ function normalizeDomain(value) {
|
|
|
14
14
|
const input = String(value || '').trim();
|
|
15
15
|
if (!input) return '';
|
|
16
16
|
try {
|
|
17
|
-
return new URL(input.includes('://') ? input : `https://${input}`).
|
|
17
|
+
return new URL(input.includes('://') ? input : `https://${input}`).hostname.toLowerCase();
|
|
18
18
|
} catch {
|
|
19
|
-
|
|
19
|
+
const withoutProtocol = input.replace(/^https?:\/\//i, '');
|
|
20
|
+
const hostnameAndPort = withoutProtocol.split('/')[0];
|
|
21
|
+
return hostnameAndPort.toLowerCase().split(':')[0];
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
|
package/scripts/rate-limiter.js
CHANGED
|
@@ -17,8 +17,8 @@ const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-li
|
|
|
17
17
|
// hit the wall within the first week, not the first quarter.
|
|
18
18
|
// ──────────────────────────────────────────────────────────
|
|
19
19
|
const FREE_TIER_LIMITS = {
|
|
20
|
-
capture_feedback: { daily:
|
|
21
|
-
prevention_rules: { daily: 2, lifetime:
|
|
20
|
+
capture_feedback: { daily: 2, lifetime: 10, label: 'feedback captures (2/day, 10 total on free)' },
|
|
21
|
+
prevention_rules: { daily: 2, lifetime: 3, label: 'prevention rules generated (2/day on free)' },
|
|
22
22
|
recall: { daily: 0, lifetime: 0, label: 'recall queries (Pro only)' },
|
|
23
23
|
search_lessons: { daily: 0, lifetime: 0, label: 'lesson searches (Pro only)' },
|
|
24
24
|
search_thumbgate: { daily: 0, lifetime: 0, label: 'ThumbGate searches (Pro only)' },
|
|
@@ -29,12 +29,12 @@ const FREE_TIER_LIMITS = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const FREE_TIER_MAX_GATES = 3; // 3 active prevention rules on free; Pro is unlimited
|
|
32
|
-
const FREE_TIER_DAILY_BLOCKS =
|
|
32
|
+
const FREE_TIER_DAILY_BLOCKS = 2; // 3 gate blocks/day on free; after limit, deny → warn + upgrade CTA
|
|
33
33
|
|
|
34
34
|
const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Enterprise: ${ENTERPRISE_PRICE_LABEL} after workflow qualification.`;
|
|
35
35
|
|
|
36
36
|
const PAYWALL_MESSAGES = {
|
|
37
|
-
capture_feedback: 'Free tier:
|
|
37
|
+
capture_feedback: 'Free tier: 2 captures/day (10 total). Your feedback is stored locally — upgrade to capture unlimited.',
|
|
38
38
|
prevention_rules: 'Free tier includes 3 active prevention rules and 2 rule generations/day. Upgrade to Pro for unlimited rules.',
|
|
39
39
|
recall: 'Recall is a Pro feature. Your past feedback is stored locally — upgrade to search and reuse it.',
|
|
40
40
|
search_lessons: 'Lesson search is a Pro feature. Upgrade to find patterns in your agent\'s mistakes.',
|
|
@@ -153,12 +153,18 @@ function checkLimit(action, authContext) {
|
|
|
153
153
|
const dailyCurrent = usage.counts[action] || 0;
|
|
154
154
|
const lifetimeCurrent = usage.lifetime[action] || 0;
|
|
155
155
|
|
|
156
|
+
const isCapture = action === 'capture_feedback';
|
|
157
|
+
const monthlyLink = isCapture
|
|
158
|
+
? 'https://buy.stripe.com/7sYfZhaiE1eSbO99uj3sI0d'
|
|
159
|
+
: PRO_MONTHLY_PAYMENT_LINK;
|
|
160
|
+
const upgradeMessage = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${monthlyLink}\n Enterprise: ${ENTERPRISE_PRICE_LABEL} after workflow qualification.`;
|
|
161
|
+
|
|
156
162
|
// Check lifetime limit first (the hard wall)
|
|
157
163
|
if (lifetimeLimit !== Infinity && lifetimeCurrent >= lifetimeLimit) {
|
|
158
164
|
const paywallMsg = PAYWALL_MESSAGES[action] || PAYWALL_MESSAGES.default;
|
|
159
165
|
return {
|
|
160
166
|
allowed: false,
|
|
161
|
-
message: `${paywallMsg}\n\n${
|
|
167
|
+
message: `${paywallMsg}\n\n${upgradeMessage}`,
|
|
162
168
|
used: lifetimeCurrent,
|
|
163
169
|
limit: lifetimeLimit,
|
|
164
170
|
limitType: 'lifetime',
|
|
@@ -170,7 +176,7 @@ function checkLimit(action, authContext) {
|
|
|
170
176
|
const paywallMsg = PAYWALL_MESSAGES[action] || PAYWALL_MESSAGES.default;
|
|
171
177
|
return {
|
|
172
178
|
allowed: false,
|
|
173
|
-
message: `Daily limit reached. ${paywallMsg}\n\n${
|
|
179
|
+
message: `Daily limit reached. ${paywallMsg}\n\n${upgradeMessage}`,
|
|
174
180
|
used: dailyCurrent,
|
|
175
181
|
limit: dailyLimit,
|
|
176
182
|
limitType: 'daily',
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* secret-redaction.js — the single, canonical secret-redaction helper.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS
|
|
7
|
+
* On 2026-06-10 a live Stripe `sk_live_` key was found in plaintext inside a captured
|
|
8
|
+
* `.thumbgate/conversation-window.jsonl`. ThumbGate must never become a secret-leak vector:
|
|
9
|
+
* secrets must be redacted (a) before any conversation/feedback/memory record lands on disk,
|
|
10
|
+
* and (b) before any training/analytics dataset is exported or shared.
|
|
11
|
+
*
|
|
12
|
+
* This module is the ONE place that owns redaction-at-rest. Capture writers and dataset
|
|
13
|
+
* exporters all call into it instead of each rolling their own. It is intentionally separate
|
|
14
|
+
* from `secret-scanner.js`: that module is the PreToolUse *scan/block* layer and deliberately
|
|
15
|
+
* tolerates `sk_test_`/`test_token` inside commands. For data persisted to disk we redact
|
|
16
|
+
* test-mode secrets too — a `sk_test_` key is still a credential we should not store.
|
|
17
|
+
*
|
|
18
|
+
* NOT REDACTED ON PURPOSE
|
|
19
|
+
* - Stripe publishable keys (`pk_live_`, `pk_test_`) are public by design. No pattern matches
|
|
20
|
+
* them, and the generic `key=value` pattern keys on `api_key|secret|token|...`, never on a
|
|
21
|
+
* bare `publishable_key`, so they are preserved verbatim.
|
|
22
|
+
*
|
|
23
|
+
* Properties:
|
|
24
|
+
* - Idempotent: re-redacting already-redacted text is a no-op (the `[REDACTED:id]` marker
|
|
25
|
+
* contains characters outside every value charset).
|
|
26
|
+
* - Non-mutating: `redactSecretsDeep` returns a redacted copy; inputs are untouched.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const REDACTED = (id) => `[REDACTED:${id}]`;
|
|
30
|
+
|
|
31
|
+
// Identifier names that denote a credential, used by the generic `key = value` pattern. Kept as
|
|
32
|
+
// two small matchers (instead of one mega-alternation) so the assignment regex stays simple.
|
|
33
|
+
const SECRET_KEY_NAME = /(?:api[_-]?key|secret|token|password|passwd|credential|private[_-]?key|access[_-]?token|client[_-]?secret)/i;
|
|
34
|
+
// Public identifiers whose values are not secrets (e.g. Stripe publishable `pk_*` keys). Excluded
|
|
35
|
+
// so `publishable_key = pk_live_…` is preserved verbatim.
|
|
36
|
+
const PUBLIC_KEY_NAME = /publishable|public/i;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ordered list of redaction patterns. Order matters: the most specific provider patterns run
|
|
40
|
+
* before the broad `key=value` fallback so a known secret keeps its precise label. Every regex
|
|
41
|
+
* is `g` (and case-insensitive where appropriate) so all occurrences are replaced.
|
|
42
|
+
*
|
|
43
|
+
* `replace` may be a string or a function `(match, ...groups) => string`.
|
|
44
|
+
*/
|
|
45
|
+
const SECRET_REDACTION_PATTERNS = [
|
|
46
|
+
// PEM / OpenSSH private key blocks — redact the whole block.
|
|
47
|
+
{
|
|
48
|
+
id: 'private_key_block',
|
|
49
|
+
label: 'Private key block',
|
|
50
|
+
regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g,
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Stripe secret-bearing keys. Publishable keys (pk_*) are intentionally absent.
|
|
54
|
+
{ id: 'stripe_live_secret', label: 'Stripe live secret key', regex: /\bsk_live_[A-Za-z0-9]{8,}/g },
|
|
55
|
+
{ id: 'stripe_test_secret', label: 'Stripe test secret key', regex: /\bsk_test_[A-Za-z0-9]{8,}/g },
|
|
56
|
+
{ id: 'stripe_restricted_live', label: 'Stripe restricted live key', regex: /\brk_live_[A-Za-z0-9]{8,}/g },
|
|
57
|
+
{ id: 'stripe_restricted_test', label: 'Stripe restricted test key', regex: /\brk_test_[A-Za-z0-9]{8,}/g },
|
|
58
|
+
{ id: 'stripe_webhook_secret', label: 'Stripe webhook signing secret', regex: /\bwhsec_[A-Za-z0-9]{8,}/g },
|
|
59
|
+
|
|
60
|
+
// Anthropic / OpenAI. Run before the legacy `sk-` pattern so the precise label wins.
|
|
61
|
+
{ id: 'anthropic_api_key', label: 'Anthropic API key', regex: /\bsk-ant-[A-Za-z0-9_-]{16,}/g },
|
|
62
|
+
{ id: 'openai_project_key', label: 'OpenAI project key', regex: /\bsk-proj-[A-Za-z0-9_-]{16,}/g },
|
|
63
|
+
{ id: 'openai_api_key', label: 'OpenAI API key', regex: /\bsk-[A-Za-z0-9]{20,}/g },
|
|
64
|
+
|
|
65
|
+
// GitHub tokens.
|
|
66
|
+
{ id: 'github_pat', label: 'GitHub personal access token', regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}/g },
|
|
67
|
+
{ id: 'github_fine_grained_pat', label: 'GitHub fine-grained token', regex: /\bgithub_pat_\w{20,}/g },
|
|
68
|
+
|
|
69
|
+
// Slack tokens.
|
|
70
|
+
{ id: 'slack_token', label: 'Slack token', regex: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
71
|
+
|
|
72
|
+
// Google API key.
|
|
73
|
+
{ id: 'google_api_key', label: 'Google API key', regex: /\bAIza[0-9A-Za-z_-]{35}/g },
|
|
74
|
+
|
|
75
|
+
// AWS access key id (long-lived AKIA + temporary ASIA).
|
|
76
|
+
{ id: 'aws_access_key', label: 'AWS access key id', regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
77
|
+
|
|
78
|
+
// JSON Web Tokens.
|
|
79
|
+
{
|
|
80
|
+
id: 'jwt',
|
|
81
|
+
label: 'JSON Web Token',
|
|
82
|
+
regex: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9._-]{8,}\.[A-Za-z0-9._-]{8,}/g,
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// `Authorization: Bearer <token>` style. Keep the scheme word, redact the credential.
|
|
86
|
+
{
|
|
87
|
+
id: 'bearer_token',
|
|
88
|
+
label: 'Bearer token',
|
|
89
|
+
regex: /\b(Bearer|Token)\s+[a-z0-9._~+/=-]{16,}/gi,
|
|
90
|
+
replace: (_m, scheme) => `${scheme} ${REDACTED('bearer_token')}`,
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// Generic `<name> = <value>` / `<name>: "<value>"` assignment with a 16+ char whitespace-free
|
|
94
|
+
// value. A simple matcher finds every assignment; the replace fn redacts only when the name is a
|
|
95
|
+
// credential (SECRET_KEY_NAME) and not a public identifier (PUBLIC_KEY_NAME), so `publishable_key`
|
|
96
|
+
// is preserved. Runs last so provider-specific labels are preferred.
|
|
97
|
+
{
|
|
98
|
+
id: 'secret_assignment',
|
|
99
|
+
label: 'Secret assignment',
|
|
100
|
+
regex: /\b([\w.-]{2,40})(\s*[:=]\s*["']?)([A-Za-z0-9._+=~/-]{16,})(["']?)/g,
|
|
101
|
+
replace: (match, key, pre, _value, post) =>
|
|
102
|
+
(SECRET_KEY_NAME.test(key) && !PUBLIC_KEY_NAME.test(key))
|
|
103
|
+
? `${key}${pre}${REDACTED('secret_assignment')}${post}`
|
|
104
|
+
: match,
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Redact secrets from a single string. Returns the input unchanged when it is not a non-empty
|
|
110
|
+
* string. Each pattern's lastIndex is reset before use so the module-level (stateful, `g`-flag)
|
|
111
|
+
* regexes are safe to reuse across calls.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} text
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function redactSecrets(text) {
|
|
117
|
+
if (typeof text !== 'string' || text.length === 0) return text;
|
|
118
|
+
let out = text;
|
|
119
|
+
for (const pattern of SECRET_REDACTION_PATTERNS) {
|
|
120
|
+
pattern.regex.lastIndex = 0;
|
|
121
|
+
const replacement = pattern.replace || REDACTED(pattern.id);
|
|
122
|
+
out = out.replace(pattern.regex, replacement);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns true if `text` contains at least one redactable secret.
|
|
129
|
+
* @param {string} text
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
function containsSecret(text) {
|
|
133
|
+
if (typeof text !== 'string' || text.length === 0) return false;
|
|
134
|
+
return SECRET_REDACTION_PATTERNS.some((pattern) => {
|
|
135
|
+
pattern.regex.lastIndex = 0;
|
|
136
|
+
return pattern.regex.test(text);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Recursively redact every string contained in `value`. Objects and arrays are deep-copied so
|
|
142
|
+
* the input is never mutated; non-string primitives pass through untouched. Object keys are left
|
|
143
|
+
* as-is (only values are redacted).
|
|
144
|
+
*
|
|
145
|
+
* @param {*} value
|
|
146
|
+
* @returns {*} redacted copy
|
|
147
|
+
*/
|
|
148
|
+
function redactSecretsDeep(value) {
|
|
149
|
+
if (typeof value === 'string') return redactSecrets(value);
|
|
150
|
+
if (Array.isArray(value)) return value.map((item) => redactSecretsDeep(item));
|
|
151
|
+
if (value && typeof value === 'object') {
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const [key, val] of Object.entries(value)) {
|
|
154
|
+
out[key] = redactSecretsDeep(val);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
SECRET_REDACTION_PATTERNS,
|
|
163
|
+
redactSecrets,
|
|
164
|
+
redactSecretsDeep,
|
|
165
|
+
containsSecret,
|
|
166
|
+
};
|
|
@@ -37,6 +37,7 @@ const VULN_PATTERNS = [
|
|
|
37
37
|
label: 'Command injection via unsanitized input',
|
|
38
38
|
regex: /\bexec(?:Sync)?\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+\s*(?:req\.|input|args|params|query|body|user))/g,
|
|
39
39
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
|
|
40
|
+
remediation: 'Avoid passing unsanitized input to child_process.exec/execSync. Use execFile or spawn with args passed as an array.',
|
|
40
41
|
},
|
|
41
42
|
{
|
|
42
43
|
id: 'shell-interpolation',
|
|
@@ -45,6 +46,7 @@ const VULN_PATTERNS = [
|
|
|
45
46
|
label: 'Shell command with string interpolation',
|
|
46
47
|
regex: /\bexec(?:Sync)?\s*\(\s*`[^`]*\$\{[^}]*(?:req\.|input|args|params|query|body|user|process\.env)/g,
|
|
47
48
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
|
|
49
|
+
remediation: 'Use child_process.execFile or child_process.spawn with an array of arguments to avoid shell command interpolation.',
|
|
48
50
|
},
|
|
49
51
|
{
|
|
50
52
|
id: 'sql-injection',
|
|
@@ -53,6 +55,7 @@ const VULN_PATTERNS = [
|
|
|
53
55
|
label: 'Potential SQL injection via string concatenation',
|
|
54
56
|
regex: /(?:query|execute|run|all|get)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+\s*(?:req\.|input|args|params|query|body|user))/g,
|
|
55
57
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs', '.py'],
|
|
58
|
+
remediation: 'Use parameterized queries or prepared statements instead of dynamic SQL string concatenation.',
|
|
56
59
|
},
|
|
57
60
|
{
|
|
58
61
|
id: 'eval-usage',
|
|
@@ -61,6 +64,7 @@ const VULN_PATTERNS = [
|
|
|
61
64
|
label: 'Dynamic code execution (eval/Function constructor)',
|
|
62
65
|
regex: /\b(?:eval|new\s+Function)\s*\([^)]*(?:req\.|input|args|params|query|body|user)/g,
|
|
63
66
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
|
|
67
|
+
remediation: 'Use JSON.parse() or a safe parser library instead of eval() or dynamic Function constructors.',
|
|
64
68
|
},
|
|
65
69
|
|
|
66
70
|
// XSS
|
|
@@ -71,6 +75,7 @@ const VULN_PATTERNS = [
|
|
|
71
75
|
label: 'Potential XSS via innerHTML assignment',
|
|
72
76
|
regex: /\.innerHTML\s*=\s*(?!['"]<(?:div|span|p|br|hr)\s*\/?>['"])/g,
|
|
73
77
|
fileTypes: ['.js', '.ts', '.jsx', '.tsx', '.mjs'],
|
|
78
|
+
remediation: 'Use element.textContent or element.innerText instead of innerHTML to prevent cross-site scripting (XSS).',
|
|
74
79
|
},
|
|
75
80
|
{
|
|
76
81
|
id: 'xss-dangerously-set',
|
|
@@ -79,6 +84,7 @@ const VULN_PATTERNS = [
|
|
|
79
84
|
label: 'React dangerouslySetInnerHTML with dynamic content',
|
|
80
85
|
regex: /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:\s*(?!['"])/g,
|
|
81
86
|
fileTypes: ['.jsx', '.tsx', '.js', '.ts'],
|
|
87
|
+
remediation: 'Ensure dynamic content passed to dangerouslySetInnerHTML is sanitized using DOMPurify or equivalent.',
|
|
82
88
|
},
|
|
83
89
|
|
|
84
90
|
// Path traversal
|
|
@@ -89,6 +95,7 @@ const VULN_PATTERNS = [
|
|
|
89
95
|
label: 'Path traversal via unsanitized user input',
|
|
90
96
|
regex: /path\.(?:join|resolve)\s*\([^)]*(?:req\.|input|args|params|query|body|user)/g,
|
|
91
97
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
|
|
98
|
+
remediation: 'Sanitize path input using path.basename() or validate the path against an explicit list of allowed directories.',
|
|
92
99
|
},
|
|
93
100
|
{
|
|
94
101
|
id: 'path-traversal-direct',
|
|
@@ -97,6 +104,7 @@ const VULN_PATTERNS = [
|
|
|
97
104
|
label: 'Direct file read with user-controlled path',
|
|
98
105
|
regex: /fs\.(?:readFile(?:Sync)?|createReadStream)\s*\(\s*(?:req\.|input|args|params|query|body|user)/g,
|
|
99
106
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
|
|
107
|
+
remediation: 'Validate input paths and restrict file system access to a sandboxed directory.',
|
|
100
108
|
},
|
|
101
109
|
|
|
102
110
|
// Prototype pollution
|
|
@@ -107,6 +115,7 @@ const VULN_PATTERNS = [
|
|
|
107
115
|
label: 'Potential prototype pollution via recursive merge',
|
|
108
116
|
regex: /(?:__proto__|constructor\s*\[\s*['"]prototype['"]\s*\]|Object\.assign\s*\(\s*\{\s*\})/g,
|
|
109
117
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
|
|
118
|
+
remediation: 'Avoid recursive object mergers without checking for __proto__ or constructor keys, or use Object.create(null).',
|
|
110
119
|
},
|
|
111
120
|
|
|
112
121
|
// Insecure crypto
|
|
@@ -117,6 +126,7 @@ const VULN_PATTERNS = [
|
|
|
117
126
|
label: 'Weak hash algorithm (MD5/SHA1) for security use',
|
|
118
127
|
regex: /createHash\s*\(\s*['"](?:md5|sha1)['"]\s*\)/gi,
|
|
119
128
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
|
|
129
|
+
remediation: 'Use SHA-256 or SHA-512 (e.g. crypto.createHash("sha256")) instead of MD5 or SHA-1 for security use cases.',
|
|
120
130
|
},
|
|
121
131
|
{
|
|
122
132
|
id: 'hardcoded-secret',
|
|
@@ -125,6 +135,7 @@ const VULN_PATTERNS = [
|
|
|
125
135
|
label: 'Hardcoded secret/password in source code',
|
|
126
136
|
regex: /(?:password|secret|apiKey|api_key|token)\s*[:=]\s*['"][A-Za-z0-9+/=_-]{12,}['"]/g,
|
|
127
137
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs', '.py', '.go', '.java'],
|
|
138
|
+
remediation: 'Move hardcoded secrets to environment variables or use a secure secrets manager.',
|
|
128
139
|
},
|
|
129
140
|
|
|
130
141
|
// SSRF
|
|
@@ -135,6 +146,7 @@ const VULN_PATTERNS = [
|
|
|
135
146
|
label: 'Potential SSRF via user-controlled URL',
|
|
136
147
|
regex: /(?:fetch|axios|got|request|https?\.(?:get|request))\s*\(\s*(?:`[^`]*\$\{|(?:req\.|input|args|params|query|body|user))/g,
|
|
137
148
|
fileTypes: ['.js', '.ts', '.mjs', '.cjs'],
|
|
149
|
+
remediation: 'Validate the destination host against a strict whitelist of allowed domains and block requests to internal IPs.',
|
|
138
150
|
},
|
|
139
151
|
|
|
140
152
|
// Insecure deserialization
|
|
@@ -145,6 +157,7 @@ const VULN_PATTERNS = [
|
|
|
145
157
|
label: 'Unsafe deserialization of untrusted data',
|
|
146
158
|
regex: /(?:unserialize|yaml\.load\s*\((?!.*Loader\s*=\s*yaml\.SafeLoader)|pickle\.loads?|Marshal\.load)/g,
|
|
147
159
|
fileTypes: ['.js', '.ts', '.py', '.rb'],
|
|
160
|
+
remediation: 'Use safe parsing libraries or options (such as yaml.safeLoad) instead of unsafe deserialization/eval-like loaders.',
|
|
148
161
|
},
|
|
149
162
|
{
|
|
150
163
|
id: 'badhost-url-confusion',
|
|
@@ -153,6 +166,7 @@ const VULN_PATTERNS = [
|
|
|
153
166
|
label: 'Potential BadHost-style host or URL confusion in AI service',
|
|
154
167
|
regex: /\b(?:request\.url(?:\.path)?|url_for\s*\([^)]*_external\s*=\s*True|headers\s*\[\s*['"](?:host|x-forwarded-host)['"]\s*\])/gi,
|
|
155
168
|
fileTypes: ['.py'],
|
|
169
|
+
remediation: 'Verify Host and X-Forwarded-Host headers against an approved whitelist before using them for routing or URL generation.',
|
|
156
170
|
},
|
|
157
171
|
];
|
|
158
172
|
|
|
@@ -166,6 +180,7 @@ const SUPPLY_CHAIN_PATTERNS = [
|
|
|
166
180
|
category: 'supply-chain',
|
|
167
181
|
severity: 'high',
|
|
168
182
|
label: 'Potentially typosquatted package name',
|
|
183
|
+
remediation: 'Verify spelling and authenticity of package name. If typosquatted, run: npm uninstall <package>.',
|
|
169
184
|
// Common typosquat indicators: single-char substitutions of popular packages
|
|
170
185
|
knownSafe: new Set([
|
|
171
186
|
'express', 'lodash', 'axios', 'react', 'vue', 'angular', 'moment',
|
|
@@ -179,6 +194,7 @@ const SUPPLY_CHAIN_PATTERNS = [
|
|
|
179
194
|
category: 'supply-chain',
|
|
180
195
|
severity: 'critical',
|
|
181
196
|
label: 'Suspicious install script in package.json',
|
|
197
|
+
remediation: 'Remove the pre/postinstall script, or run package installation with --ignore-scripts.',
|
|
182
198
|
regex: /["'](?:pre|post)?install["']\s*:\s*["'](?:.*(?:curl|wget|nc\s|bash\s|sh\s|eval|exec|child_process))/g,
|
|
183
199
|
},
|
|
184
200
|
{
|
|
@@ -186,10 +202,79 @@ const SUPPLY_CHAIN_PATTERNS = [
|
|
|
186
202
|
category: 'supply-chain',
|
|
187
203
|
severity: 'medium',
|
|
188
204
|
label: 'Wildcard or latest version in dependency',
|
|
205
|
+
remediation: 'Specify a concrete version constraint (e.g., "^1.0.0") instead of a wildcard or latest.',
|
|
189
206
|
regex: /["'](?:dependencies|devDependencies|peerDependencies)["'][\s\S]{0,500}?["'][^"']+["']\s*:\s*["'](?:\*|latest|>=)/g,
|
|
190
207
|
},
|
|
191
208
|
];
|
|
192
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Simple static analysis check (reachability/usage check) to see if a package is imported.
|
|
212
|
+
* @param {string} pkg - The package name to search for
|
|
213
|
+
* @param {string} rootDir - Root directory to walk
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
function isPackageImported(pkg, rootDir = process.cwd()) {
|
|
217
|
+
const IGNORED_DIRS = new Set([
|
|
218
|
+
'node_modules', '.git', 'dist', 'coverage', '.planning', '.artifacts', '.gemini', '.antigravitycli'
|
|
219
|
+
]);
|
|
220
|
+
const FILE_EXTS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.py']);
|
|
221
|
+
|
|
222
|
+
// Escape package name for regex
|
|
223
|
+
const escapedPkg = pkg.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
224
|
+
const importRegex = new RegExp(
|
|
225
|
+
`(?:require\\s*\\(\\s*['"]${escapedPkg}['"]\\s*\\)|from\\s*['"]${escapedPkg}['"]|import\\s*\\(\\s*['"]${escapedPkg}['"]\\s*\\)|import\\s+['"]${escapedPkg}['"])`,
|
|
226
|
+
'i'
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
let found = false;
|
|
230
|
+
let fileCount = 0;
|
|
231
|
+
const maxFiles = 200; // safety limit to keep it fast (<50ms)
|
|
232
|
+
|
|
233
|
+
function walk(dir) {
|
|
234
|
+
if (found || fileCount >= maxFiles) return;
|
|
235
|
+
let files;
|
|
236
|
+
try {
|
|
237
|
+
files = fs.readdirSync(dir);
|
|
238
|
+
} catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const file of files) {
|
|
243
|
+
if (found || fileCount >= maxFiles) return;
|
|
244
|
+
const fullPath = path.join(dir, file);
|
|
245
|
+
let stat;
|
|
246
|
+
try {
|
|
247
|
+
stat = fs.statSync(fullPath);
|
|
248
|
+
} catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (stat.isDirectory()) {
|
|
253
|
+
if (!IGNORED_DIRS.has(file)) {
|
|
254
|
+
walk(fullPath);
|
|
255
|
+
}
|
|
256
|
+
} else if (stat.isFile()) {
|
|
257
|
+
const ext = path.extname(file).toLowerCase();
|
|
258
|
+
if (FILE_EXTS.has(ext)) {
|
|
259
|
+
fileCount++;
|
|
260
|
+
try {
|
|
261
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
262
|
+
if (importRegex.test(content)) {
|
|
263
|
+
found = true;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// ignore read errors
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
walk(rootDir);
|
|
275
|
+
return found;
|
|
276
|
+
}
|
|
277
|
+
|
|
193
278
|
// ---------------------------------------------------------------------------
|
|
194
279
|
// Core scanning functions
|
|
195
280
|
// ---------------------------------------------------------------------------
|
|
@@ -227,6 +312,7 @@ function scanCode(content, filePath = '') {
|
|
|
227
312
|
line: lineNumber,
|
|
228
313
|
match: match[0].slice(0, 120),
|
|
229
314
|
path: filePath,
|
|
315
|
+
remediation: pattern.remediation,
|
|
230
316
|
});
|
|
231
317
|
// Only report first match per pattern per file to avoid noise
|
|
232
318
|
break;
|
|
@@ -287,6 +373,10 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
287
373
|
|
|
288
374
|
for (const [pkg, version] of Object.entries(newDeps)) {
|
|
289
375
|
if (!oldDeps[pkg]) {
|
|
376
|
+
// Run usage check (reachability)
|
|
377
|
+
const reachable = isPackageImported(pkg);
|
|
378
|
+
const reachability = reachable ? 'imported' : 'unimported';
|
|
379
|
+
|
|
290
380
|
// New dependency added — check for red flags
|
|
291
381
|
if (version === '*' || version === 'latest' || version.startsWith('>=')) {
|
|
292
382
|
findings.push({
|
|
@@ -295,6 +385,9 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
295
385
|
severity: 'medium',
|
|
296
386
|
label: `Wildcard version for new dependency: ${pkg}@${version}`,
|
|
297
387
|
path: 'package.json',
|
|
388
|
+
remediation: `Specify a concrete version constraint (e.g., "^${version === '*' || version === 'latest' ? '1.0.0' : version}") instead of a wildcard.`,
|
|
389
|
+
reachable,
|
|
390
|
+
reachability,
|
|
298
391
|
});
|
|
299
392
|
}
|
|
300
393
|
|
|
@@ -306,6 +399,9 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
306
399
|
severity: 'high',
|
|
307
400
|
label: `Suspiciously short package name: "${pkg}"`,
|
|
308
401
|
path: 'package.json',
|
|
402
|
+
remediation: 'Double-check spelling and verify this package is not a typosquatting attempt.',
|
|
403
|
+
reachable,
|
|
404
|
+
reachability,
|
|
309
405
|
});
|
|
310
406
|
}
|
|
311
407
|
|
|
@@ -318,6 +414,9 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
318
414
|
severity: slopsquatFinding.severity,
|
|
319
415
|
label: slopsquatFinding.label,
|
|
320
416
|
path: 'package.json',
|
|
417
|
+
remediation: `Verify spelling and authenticity of package "${pkg}". If typosquatted, run: npm uninstall ${pkg}.`,
|
|
418
|
+
reachable,
|
|
419
|
+
reachability,
|
|
321
420
|
});
|
|
322
421
|
}
|
|
323
422
|
}
|
|
@@ -335,6 +434,7 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
335
434
|
severity: 'critical',
|
|
336
435
|
label: `Suspicious install script: ${name} → ${cmd.slice(0, 80)}`,
|
|
337
436
|
path: 'package.json',
|
|
437
|
+
remediation: 'Remove the suspicious pre/postinstall script, or run package installation with --ignore-scripts.',
|
|
338
438
|
});
|
|
339
439
|
}
|
|
340
440
|
}
|
|
@@ -26,6 +26,7 @@ const { resolveFeedbackDir } = require('./feedback-paths');
|
|
|
26
26
|
const { createLesson, inferStructuredLesson } = require('./lesson-inference');
|
|
27
27
|
const { buildStableId } = require('./conversation-context');
|
|
28
28
|
const { ensureParentDir, readJsonl } = require('./fs-utils');
|
|
29
|
+
const { redactSecretsDeep } = require('./secret-redaction');
|
|
29
30
|
|
|
30
31
|
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir() || '';
|
|
31
32
|
const SELF_DISTILL_RUNS_PATH = path.join(HOME, '.thumbgate', 'self-distill-runs.jsonl');
|
|
@@ -383,7 +384,8 @@ async function generateLlmLessons(conversationWindow, model) {
|
|
|
383
384
|
|
|
384
385
|
function writeRunManifest(manifest) {
|
|
385
386
|
ensureParentDir(SELF_DISTILL_RUNS_PATH);
|
|
386
|
-
|
|
387
|
+
// Redact secrets — the manifest embeds lesson triggers/actions distilled from conversation text.
|
|
388
|
+
fs.appendFileSync(SELF_DISTILL_RUNS_PATH, JSON.stringify(redactSecretsDeep(manifest)) + '\n');
|
|
387
389
|
}
|
|
388
390
|
|
|
389
391
|
function readRunManifests() {
|