codeant-cli 0.3.4 → 0.3.6

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.
@@ -24,7 +24,7 @@ function getClient() {
24
24
 
25
25
  /** Use the API key directly as distinct ID */
26
26
  function getDistinctId() {
27
- return process.env.CODEANT_API_TOKEN || getConfigValue('apiKey') || 'anonymous';
27
+ return process.env.CODEANT_API_TOKEN || getConfigValue('apiKeyV2') || 'anonymous';
28
28
  }
29
29
 
30
30
  /**
@@ -12,7 +12,7 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => {
12
12
  };
13
13
 
14
14
  // Add auth token from config or env
15
- const token = process.env.CODEANT_API_TOKEN || getConfigValue('apiKey');
15
+ const token = process.env.CODEANT_API_TOKEN || getConfigValue('apiKeyV2');
16
16
  if (token) {
17
17
  options.headers['Authorization'] = `Bearer ${token}`;
18
18
  }
@@ -0,0 +1,210 @@
1
+ import { parse } from 'smol-toml';
2
+ import { readFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import path from 'path';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const RULES_PATH = path.join(__dirname, '..', 'rules', 'secrets.toml');
8
+
9
+ let _cachedConfig = null;
10
+
11
+ /** Convert a Go-style regex string to a JS RegExp, returning null on failure. */
12
+ function toRegExp(pattern, extraFlags = '') {
13
+ const hasInlineI = pattern.includes('(?i)');
14
+ const cleaned = pattern.replace(/\(\?i\)/g, '');
15
+ const flags = extraFlags + (hasInlineI ? 'i' : '');
16
+ try {
17
+ return new RegExp(cleaned, flags);
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function loadConfig() {
24
+ if (_cachedConfig) return _cachedConfig;
25
+ const raw = readFileSync(RULES_PATH, 'utf-8');
26
+ const config = parse(raw);
27
+
28
+ const globalAllowlist = {
29
+ paths: (config.allowlist?.paths || []).map(p => toRegExp(p)).filter(Boolean),
30
+ regexes: (config.allowlist?.regexes || []).map(r => toRegExp(r)).filter(Boolean),
31
+ stopwords: config.allowlist?.stopwords || [],
32
+ };
33
+
34
+ const rules = (config.rules || [])
35
+ .filter(r => r.regex && !r.skipReport)
36
+ .map(r => {
37
+ const allowlists = (r.allowlists || []).map(a => ({
38
+ regexes: (a.regexes || []).map(re => toRegExp(re)).filter(Boolean),
39
+ paths: (a.paths || []).map(p => toRegExp(p)).filter(Boolean),
40
+ stopwords: a.stopwords || [],
41
+ regexTarget: a.regexTarget || 'secret',
42
+ condition: a.condition || 'OR',
43
+ }));
44
+
45
+ return {
46
+ id: r.id,
47
+ description: r.description || '',
48
+ regexRaw: r.regex,
49
+ entropy: r.entropy || 0,
50
+ keywords: r.keywords || [],
51
+ secretGroup: r.secretGroup || 0,
52
+ allowlists,
53
+ };
54
+ });
55
+
56
+ // Build keyword -> rules index for fast pre-filtering
57
+ const keywordIndex = new Map();
58
+ const noKeywordRules = [];
59
+ for (const rule of rules) {
60
+ if (rule.keywords.length === 0) {
61
+ noKeywordRules.push(rule);
62
+ } else {
63
+ for (const kw of rule.keywords) {
64
+ const lower = kw.toLowerCase();
65
+ if (!keywordIndex.has(lower)) keywordIndex.set(lower, []);
66
+ keywordIndex.get(lower).push(rule);
67
+ }
68
+ }
69
+ }
70
+
71
+ _cachedConfig = { rules, globalAllowlist, keywordIndex, noKeywordRules };
72
+ return _cachedConfig;
73
+ }
74
+
75
+ function shannonEntropy(str) {
76
+ if (!str) return 0;
77
+ const freq = {};
78
+ for (const ch of str) freq[ch] = (freq[ch] || 0) + 1;
79
+ const len = str.length;
80
+ let entropy = 0;
81
+ for (const count of Object.values(freq)) {
82
+ const p = count / len;
83
+ entropy -= p * Math.log2(p);
84
+ }
85
+ return entropy;
86
+ }
87
+
88
+ function isAllowlistedValue(secret, globalAllowlist) {
89
+ for (const re of globalAllowlist.regexes) {
90
+ if (re.test(secret)) return true;
91
+ }
92
+ for (const sw of globalAllowlist.stopwords) {
93
+ if (secret.includes(sw)) return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function isAllowlistedPath(filePath, globalAllowlist) {
99
+ for (const re of globalAllowlist.paths) {
100
+ if (re.test(filePath)) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ function checkRuleAllowlists(secret, matchStr, filePath, allowlists) {
106
+ for (const al of allowlists) {
107
+ const target = al.regexTarget === 'match' ? matchStr : secret;
108
+ const pathAllowed = al.paths.length > 0 && al.paths.some(p => p.test(filePath));
109
+ const regexAllowed = al.regexes.length > 0 && al.regexes.some(r => r.test(target));
110
+ const stopwordAllowed = al.stopwords.length > 0 && al.stopwords.some(sw => target.includes(sw));
111
+
112
+ if (al.condition === 'AND') {
113
+ const checks = [];
114
+ if (al.paths.length > 0) checks.push(pathAllowed);
115
+ if (al.regexes.length > 0) checks.push(regexAllowed);
116
+ if (al.stopwords.length > 0) checks.push(stopwordAllowed);
117
+ if (checks.length > 0 && checks.every(Boolean)) return true;
118
+ } else {
119
+ if (pathAllowed || regexAllowed || stopwordAllowed) return true;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+
125
+ function getLineNumber(content, index) {
126
+ let line = 1;
127
+ for (let i = 0; i < index && i < content.length; i++) {
128
+ if (content[i] === '\n') line++;
129
+ }
130
+ return line;
131
+ }
132
+
133
+ function maskSecret(secret) {
134
+ if (secret.length <= 8) return '*'.repeat(secret.length);
135
+ return secret.slice(0, 4) + '*'.repeat(Math.min(secret.length - 8, 20)) + secret.slice(-4);
136
+ }
137
+
138
+ /**
139
+ * Scan a single file's full content for secrets.
140
+ * Scans the entire file regardless of which lines changed (same as GitHub push protection).
141
+ */
142
+ export function scanFile(filePath, content) {
143
+ const config = loadConfig();
144
+ const { globalAllowlist, keywordIndex, noKeywordRules } = config;
145
+
146
+ if (isAllowlistedPath(filePath, globalAllowlist)) return [];
147
+
148
+ const contentLower = content.toLowerCase();
149
+ const findings = [];
150
+ const seen = new Set();
151
+
152
+ // Collect candidate rules via keyword pre-filter
153
+ const candidateRules = new Set(noKeywordRules);
154
+ for (const [keyword, rules] of keywordIndex) {
155
+ if (contentLower.includes(keyword)) {
156
+ for (const r of rules) candidateRules.add(r);
157
+ }
158
+ }
159
+
160
+ for (const rule of candidateRules) {
161
+ const regex = toRegExp(rule.regexRaw, 'g');
162
+ if (!regex) continue;
163
+
164
+ let match;
165
+ while ((match = regex.exec(content)) !== null) {
166
+ const fullMatch = match[0];
167
+ const secret = match[rule.secretGroup] || fullMatch;
168
+ const lineNum = getLineNumber(content, match.index);
169
+
170
+ // Dedup by rule+line
171
+ const key = `${rule.id}:${lineNum}`;
172
+ if (seen.has(key)) continue;
173
+
174
+ // Entropy check
175
+ if (rule.entropy > 0 && shannonEntropy(secret) < rule.entropy) continue;
176
+
177
+ // Global allowlist checks
178
+ if (isAllowlistedValue(secret, globalAllowlist)) continue;
179
+
180
+ // Per-rule allowlist checks
181
+ if (checkRuleAllowlists(secret, fullMatch, filePath, rule.allowlists)) continue;
182
+
183
+ seen.add(key);
184
+ findings.push({
185
+ type: rule.id,
186
+ description: rule.description,
187
+ line_number: lineNum,
188
+ confidence_score: rule.entropy > 0 ? 'HIGH' : 'MEDIUM',
189
+ secret_snippet: maskSecret(secret),
190
+ });
191
+ }
192
+ }
193
+
194
+ return findings;
195
+ }
196
+
197
+ /**
198
+ * Scan multiple files for secrets.
199
+ * Scans the full content of each file (not just diffs).
200
+ * @param {Array} files - Array of {file_path, code}
201
+ * @returns {Array} Array of {file_path, secrets: [...]}
202
+ */
203
+ export function detectSecrets(files) {
204
+ const results = [];
205
+ for (const file of files) {
206
+ const secrets = scanFile(file.file_path, file.code);
207
+ results.push({ file_path: file.file_path, secrets });
208
+ }
209
+ return results;
210
+ }