codeant-cli 0.5.8 → 0.5.10

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.
@@ -0,0 +1,83 @@
1
+ // Result shaping for MCP clients, which cap how much tool output they accept.
2
+ // CLI commands keep returning the full payloads; only MCP responses are trimmed.
3
+
4
+ const DEFAULT_MAX_RESULT_CHARS = 80_000;
5
+
6
+ const REPO_FIELDS = ['full_name', 'name', 'private', 'visibility', 'default_branch', 'language', 'description', 'archived', 'pushed_at'];
7
+ const CLOUD_SCAN_DETAIL = ['per_service', 'service_rollup', 'compliance_rollup', 'regions'];
8
+
9
+ export function maxResultChars() {
10
+ const configured = Number(process.env.CODEANT_MCP_MAX_RESULT_CHARS);
11
+ return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_RESULT_CHARS;
12
+ }
13
+
14
+ function page(items, { limit, offset = 0 }) {
15
+ const slice = items.slice(offset, offset + limit);
16
+ const end = offset + slice.length;
17
+ return { items: slice, total: items.length, offset, limit, next_offset: end < items.length ? end : null };
18
+ }
19
+
20
+ function pick(record, fields) {
21
+ const picked = {};
22
+ for (const field of fields) if (field in record) picked[field] = record[field];
23
+ return Object.keys(picked).length ? picked : record;
24
+ }
25
+
26
+ function omit(record, fields) {
27
+ const kept = { ...record };
28
+ for (const field of fields) delete kept[field];
29
+ return kept;
30
+ }
31
+
32
+ export function shapeRepos(result, { search, limit = 100, offset = 0, full = false } = {}) {
33
+ let repos = result.repos || [];
34
+ if (search) {
35
+ const query = search.toLowerCase();
36
+ repos = repos.filter((repo) => String(repo.full_name || repo.name || '').toLowerCase().includes(query));
37
+ }
38
+ const { items, ...pagination } = page(repos, { limit, offset });
39
+ return { org: result.org, ...pagination, repos: full ? items : items.map((repo) => pick(repo, REPO_FIELDS)) };
40
+ }
41
+
42
+ export function shapePentestHistory(result, { limit = 25, offset = 0, full = false } = {}) {
43
+ const { history = [], ...rest } = result;
44
+ const { items, ...pagination } = page(history, { limit, offset });
45
+ return { ...rest, ...pagination, history: full ? items : items.map((entry) => omit(entry, ['credit_unlock'])) };
46
+ }
47
+
48
+ function shapeCloudScans(result, { limit, offset, full }) {
49
+ if (!Array.isArray(result?.scans)) return result;
50
+ const { items, ...pagination } = page(result.scans, { limit, offset });
51
+ return {
52
+ ...result,
53
+ ...pagination,
54
+ total: result.total ?? pagination.total,
55
+ scans: full ? items : items.map((scan) => omit(scan, CLOUD_SCAN_DETAIL)),
56
+ };
57
+ }
58
+
59
+ export function shapeCloudHistory(result, { limit = 10, offset = 0, full = false } = {}) {
60
+ if (!result.providers) return shapeCloudScans(result, { limit, offset, full });
61
+ const providers = Object.fromEntries(
62
+ Object.entries(result.providers).map(([provider, value]) => [provider, shapeCloudScans(value, { limit, offset, full })]),
63
+ );
64
+ return { ...result, providers };
65
+ }
66
+
67
+ // CSPM findings come back unpaged (thousands per scan); VM/container results are
68
+ // already paged by the API, so only the per-finding detail is trimmed for them.
69
+ export function shapeCloudFindings(result, { kind = 'cspm', limit = 25, offset = 0, all = false, full = false } = {}) {
70
+ const { findings = [], ...rest } = result;
71
+ const trim = (items) => (full ? items : items.map((finding) => omit(finding, ['compliance'])));
72
+ if (kind !== 'cspm') return { ...rest, findings: trim(findings) };
73
+ const { items, ...pagination } = page(findings, { limit: all ? findings.length : limit, offset });
74
+ return { ...rest, ...pagination, total: rest.total ?? pagination.total, findings: trim(items) };
75
+ }
76
+
77
+ export function tooLarge(length, hint) {
78
+ const max = maxResultChars();
79
+ return {
80
+ error: `Result too large: ${length} characters (about ${Math.round(length / 4)} tokens), over the ${max}-character limit for one tool result.`,
81
+ hint: `${hint || 'Narrow the request with limit/offset, filters, or fields where the tool supports them.'} Set CODEANT_MCP_MAX_RESULT_CHARS to raise the limit.`,
82
+ };
83
+ }
@@ -6,8 +6,23 @@ export const ADVANCED_RESULT_TYPES = {
6
6
  SECRETS: 'secrets',
7
7
  IAC: 'iac',
8
8
  DEAD_CODE: 'dead_code',
9
+ DUPLICATE_CODE: 'duplicate_code',
9
10
  };
10
11
 
12
+ const BACKEND_MARKERS = ['is_dismissed', 'reason_for_dismiss', 'comment_for_dismiss', 'is_false_positive'];
13
+
14
+ /**
15
+ * Dismissed / false-positive markers the backend stamps on each finding. Kept
16
+ * when a finding is rebuilt so callers can hide or show those findings.
17
+ */
18
+ function backendMarkers(item) {
19
+ const markers = {};
20
+ for (const key of BACKEND_MARKERS) {
21
+ if (item?.[key] !== undefined) markers[key] = item[key];
22
+ }
23
+ return markers;
24
+ }
25
+
11
26
  const EXTRA_DEAD_CODE_MESSAGES = {
12
27
  S1481: 'Unused local variable',
13
28
  S1854: 'Unused assignment',
@@ -74,12 +89,16 @@ function flattenDeadCode(deadCodeData) {
74
89
  name,
75
90
  confidence: confMatch ? confMatch[1] : '90',
76
91
  check_name: `Unused ${issueType}: ${name}`,
92
+ ...backendMarkers(issue),
77
93
  });
78
94
  });
79
95
  });
80
96
 
81
- // JS dead code — unused files
97
+ // JS dead code — unused files (plain path strings; the backend lists dismissed ones separately)
82
98
  const jsDeadCode = deadCodeData.js_dead_code || {};
99
+ const dismissedFiles = Array.isArray(jsDeadCode.dismissed_unused_files)
100
+ ? new Set(jsDeadCode.dismissed_unused_files)
101
+ : null;
83
102
  (jsDeadCode.unused_files || []).forEach((filePath) => {
84
103
  if (!filePath) return;
85
104
  flat.push({
@@ -90,6 +109,7 @@ function flattenDeadCode(deadCodeData) {
90
109
  severity: 'warning',
91
110
  type: 'unused_file',
92
111
  check_name: 'Unused file',
112
+ ...(dismissedFiles ? { is_dismissed: dismissedFiles.has(filePath) } : {}),
93
113
  });
94
114
  });
95
115
 
@@ -110,6 +130,7 @@ function flattenDeadCode(deadCodeData) {
110
130
  type: 'unused_export',
111
131
  name: exp.name,
112
132
  check_name: `Unused export: ${exp.name || 'unknown'}`,
133
+ ...backendMarkers(exp),
113
134
  });
114
135
  });
115
136
  });
@@ -124,7 +145,8 @@ function flattenDeadCode(deadCodeData) {
124
145
  const messageId = issue['message-id'] || '';
125
146
  const msgKey = messageId.includes(':') ? messageId.split(':')[1] : messageId;
126
147
  flat.push({
127
- file_path: extractRelativeFilePath(filePath),
148
+ // Keys end with the per-file result name (".../app.py/dead_code.json").
149
+ file_path: extractRelativeFilePath(filePath).replace(/\/[^/]+\.json$/, ''),
128
150
  line_number: issue.line_number || 0,
129
151
  issue_text: issue.issue_text || EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
130
152
  message: issue.issue_text || EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
@@ -133,6 +155,7 @@ function flattenDeadCode(deadCodeData) {
133
155
  rule_id: msgKey,
134
156
  confidence: issue.confidence || '90',
135
157
  check_name: EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
158
+ ...backendMarkers(issue),
136
159
  });
137
160
  });
138
161
  });
@@ -141,6 +164,49 @@ function flattenDeadCode(deadCodeData) {
141
164
  return flat;
142
165
  }
143
166
 
167
+ /** "[12-40]" → [12, 40]; anything unparsable → []. */
168
+ function parseWindow(window) {
169
+ const numbers = String(window ?? '').match(/\d+/g) || [];
170
+ return numbers.slice(0, 2).map(Number);
171
+ }
172
+
173
+ /**
174
+ * Flatten duplicate-code clone groups (each a list of { fileName, window }
175
+ * members) into one finding per member, linked by their group index.
176
+ */
177
+ function flattenDuplicateCode(groups) {
178
+ if (!Array.isArray(groups)) return [];
179
+ const flat = [];
180
+ groups.forEach((group, groupIndex) => {
181
+ const members = Array.isArray(group) ? group.filter((m) => m && typeof m === 'object') : [];
182
+ const locations = members.map((m) => {
183
+ const [start, end] = parseWindow(m.window);
184
+ return { file_path: extractRelativeFilePath(cleanLeadingSlash(m.fileName)), start, end };
185
+ });
186
+ members.forEach((member, i) => {
187
+ const { file_path, start, end } = locations[i];
188
+ const others = locations
189
+ .filter((_, j) => j !== i)
190
+ .map((o) => (o.start ? `${o.file_path}:${o.start}${o.end ? `-${o.end}` : ''}` : o.file_path));
191
+ const message = `Duplicated block (${members.length} locations)`;
192
+ flat.push({
193
+ file_path,
194
+ line_number: start || 1,
195
+ file_line_range: end ? [start, end] : [start || 1],
196
+ check_id: 'duplicate-code',
197
+ check_name: message,
198
+ issue_text: message,
199
+ message,
200
+ severity: 'low',
201
+ duplicate_group: member.originalIndex ?? groupIndex,
202
+ duplicate_locations: others,
203
+ ...backendMarkers(member),
204
+ });
205
+ });
206
+ });
207
+ return flat;
208
+ }
209
+
144
210
  /**
145
211
  * Normalize a single advanced issue to a consistent shape.
146
212
  */
@@ -163,7 +229,8 @@ function normalizeAdvancedIssue(item, resultType) {
163
229
  item.vulnerability_id || item.cve_id || item.advisory_id ||
164
230
  (item.package_name ? `Vulnerability in ${item.package_name}` : null) ||
165
231
  item.title || item.description || 'Package vulnerability detected';
166
- normalized.severity = item.severity || 'medium';
232
+ // SCA findings carry severity in ratings (where Change Severity overrides land).
233
+ normalized.severity = item.severity || item.ratings?.[0]?.severity || 'medium';
167
234
  break;
168
235
  case ADVANCED_RESULT_TYPES.SBOM:
169
236
  normalized.check_name = item.package_name
@@ -187,6 +254,10 @@ function normalizeAdvancedIssue(item, resultType) {
187
254
  item.check_name || item.name || item.function_name || item.symbol_name || item.description || 'Unused code detected';
188
255
  normalized.severity = item.severity || 'low';
189
256
  break;
257
+ case ADVANCED_RESULT_TYPES.DUPLICATE_CODE:
258
+ normalized.check_name = item.check_name || 'Duplicated block';
259
+ normalized.severity = item.severity || 'low';
260
+ break;
190
261
  default:
191
262
  normalized.check_name = item.description || item.message || item.name || 'Issue detected';
192
263
  }
@@ -195,7 +266,7 @@ function normalizeAdvancedIssue(item, resultType) {
195
266
  }
196
267
 
197
268
  /**
198
- * Fetch advanced scan results (SCA, SBOM, secrets, IaC, dead code).
269
+ * Fetch advanced scan results (SCA, SBOM, secrets, IaC, dead code, duplicate code).
199
270
  *
200
271
  * @param {string} repo - "org/repo-name"
201
272
  * @param {string} commitId - 40-char commit SHA
@@ -237,6 +308,8 @@ export async function fetchAdvancedScanResults(repo, commitId, resultType, opts
237
308
  resultsData = response.secrets;
238
309
  } else if (resultType === ADVANCED_RESULT_TYPES.DEAD_CODE) {
239
310
  resultsData = flattenDeadCode(response.dead_code);
311
+ } else if (resultType === ADVANCED_RESULT_TYPES.DUPLICATE_CODE) {
312
+ resultsData = flattenDuplicateCode(response.results);
240
313
  } else if (resultType === ADVANCED_RESULT_TYPES.SCA) {
241
314
  const scaResults = response.results;
242
315
  if (scaResults && typeof scaResults === 'object' && !Array.isArray(scaResults) && scaResults.all_vulnerabilities !== undefined) {
@@ -268,6 +341,7 @@ export async function fetchAdvancedScanResults(repo, commitId, resultType, opts
268
341
  guideline: check.guideline || '',
269
342
  code_block: check.code_block || [],
270
343
  resource: check.resource || '',
344
+ ...backendMarkers(check),
271
345
  });
272
346
  });
273
347
  });
@@ -328,3 +402,6 @@ export const fetchIacResults = (repo, commitId, opts) =>
328
402
 
329
403
  export const fetchDeadCodeResults = (repo, commitId, opts) =>
330
404
  fetchAdvancedScanResults(repo, commitId, ADVANCED_RESULT_TYPES.DEAD_CODE, opts);
405
+
406
+ export const fetchDuplicateCodeResults = (repo, commitId, opts) =>
407
+ fetchAdvancedScanResults(repo, commitId, ADVANCED_RESULT_TYPES.DUPLICATE_CODE, opts);
@@ -29,7 +29,21 @@ export async function fetchDismissedAlerts(repo, analysisType = 'security') {
29
29
  const dismissedAlerts = [];
30
30
 
31
31
  for (const [issueKey, dismissInfo] of Object.entries(dismissData)) {
32
- if (!issueKey.includes('||::||')) continue;
32
+ if (!issueKey.includes('||::||')) {
33
+ // SCA ("pkg@version[::manifest::cve]") and duplicate-code (group index)
34
+ // keys have no file/line parts; keep them so the count is complete.
35
+ dismissedAlerts.push({
36
+ file_path: '',
37
+ context_code_block: '',
38
+ test_id: '',
39
+ line_number: 0,
40
+ type: '',
41
+ issue_key: issueKey,
42
+ reason_for_dismiss: dismissInfo?.reason_for_dismiss || '',
43
+ comment_for_dismiss: dismissInfo?.comment_for_dismiss || '',
44
+ });
45
+ continue;
46
+ }
33
47
 
34
48
  const parts = issueKey.split('||::||');
35
49
  let file_path = parts[0] || '';
@@ -0,0 +1,36 @@
1
+ import { fetchApi } from '../utils/fetchApi.js';
2
+
3
+ /**
4
+ * Fetch the per-finding user overrides the web app saves for a repository
5
+ * (Mark/Unmark false positive, secrets confidence, Change Severity).
6
+ *
7
+ * Override keys and patches by analysis type:
8
+ * security — "id::<tracking id>|rule::<test_id>||::||<result key>||::||<line>" → { false_positive, severity }
9
+ * secrets — "<path>||::||<hashed_secret or line>||::||<type>" → { confidence_score }
10
+ * iac — "<file_path>||::||<check_id>||::||<start>-<end>" → { false_positive }
11
+ * sca — "<package>@<version>" → { severity }
12
+ *
13
+ * @param {string} repo - "org/repo-name"
14
+ * @param {string} analysisType - security | secrets | iac | sca
15
+ * @returns {Promise<{ success: boolean, overrides?: Object, error?: string }>}
16
+ */
17
+ export async function fetchIssueOverrides(repo, analysisType) {
18
+ try {
19
+ const response = await fetchApi('/extension/scans2/change-issue-details/get', 'POST', {
20
+ repo,
21
+ analysis_type: analysisType,
22
+ });
23
+
24
+ if (!response) {
25
+ return { success: false, error: 'Failed to connect to CodeAnt server' };
26
+ }
27
+
28
+ if (response.status === 'error') {
29
+ return { success: false, error: response.message || 'Failed to fetch issue overrides' };
30
+ }
31
+
32
+ return { success: true, overrides: response.data || {} };
33
+ } catch (error) {
34
+ return { success: false, error: error.message || 'Failed to fetch issue overrides' };
35
+ }
36
+ }
@@ -70,7 +70,6 @@ export async function fetchScanResults(repo, commitId, resultType, opts = {}) {
70
70
  filter_dismissed: filterDismissed,
71
71
  include_false_positives: includeFalsePositives,
72
72
  });
73
- console.error("dropship: ", JSON.stringify(response, null, 2));
74
73
 
75
74
  if (!response) {
76
75
  return { success: false, error: 'Failed to connect to CodeAnt server' };
@@ -7,7 +7,7 @@ export function getConnection() {
7
7
  if (!token) throw new Error('Azure DevOps token not found. Set AZURE_DEVOPS_TOKEN env var or run `codeant set-token azure <token>`.');
8
8
 
9
9
  const orgUrl = process.env.AZURE_DEVOPS_ORG_URL || getConfigValue('azureDevOpsOrgUrl');
10
- if (!orgUrl) throw new Error('Azure DevOps org URL not found. Set AZURE_DEVOPS_ORG_URL env var or run `codeant set-azure-org <url>`.');
10
+ if (!orgUrl) throw new Error('Azure DevOps org URL not found. Set the AZURE_DEVOPS_ORG_URL env var (for example https://dev.azure.com/<org>) or add azureDevOpsOrgUrl to ~/.codeant/config.json.');
11
11
 
12
12
  const authHandler = azdev.getPersonalAccessTokenHandler(token);
13
13
  return new azdev.WebApi(orgUrl, authHandler);
@@ -0,0 +1,18 @@
1
+ import { InvalidArgumentError } from 'commander';
2
+
3
+ /**
4
+ * Commander parser for an integer option within [min, max]. Commander passes the
5
+ * option's default as the parser's second argument, so a bare `parseInt` would
6
+ * use it as the radix (`--limit 30` with default 20 → 60).
7
+ */
8
+ export function intOption({ min = 0, max = Infinity } = {}) {
9
+ return (value) => {
10
+ const n = Number(value);
11
+ if (!Number.isInteger(n) || n < min || n > max) {
12
+ throw new InvalidArgumentError(
13
+ max === Infinity ? `Must be an integer of at least ${min}.` : `Must be an integer from ${min} to ${max}.`
14
+ );
15
+ }
16
+ return n;
17
+ };
18
+ }
@@ -4,6 +4,7 @@ import { getBaseUrl, getDashboardUrl } from './baseUrl.js';
4
4
 
5
5
  const DEFAULT_POLL_INTERVAL = 10_000;
6
6
  const DEFAULT_TIMEOUT = 10 * 60 * 1000;
7
+ const BROWSER_SPAWN_TIMEOUT = 2_000;
7
8
 
8
9
  export function isAlreadyLoggedIn() {
9
10
  return !!getConfigValue('apiKeyV2');
@@ -16,16 +17,24 @@ export async function startLoginFlow() {
16
17
  const loginUrl = `${dashboardUrl}?ideLoginToken=${token}`;
17
18
  const pollUrl = `${baseUrl}/extension/login/status?apiKey=${token}`;
18
19
 
19
- let browserOpened = false;
20
+ const browserOpened = await openBrowser(loginUrl);
21
+ return { token, loginUrl, pollUrl, browserOpened };
22
+ }
23
+
24
+ // `open` returns the launcher process without an 'error' listener, so a missing
25
+ // launcher (SSH, containers) would otherwise crash the MCP server.
26
+ async function openBrowser(url) {
20
27
  try {
21
28
  const { default: open } = await import('open');
22
- await open(loginUrl);
23
- browserOpened = true;
29
+ const child = await open(url);
30
+ return await new Promise((resolve) => {
31
+ child.once('error', () => resolve(false));
32
+ child.once('spawn', () => resolve(true));
33
+ setTimeout(() => resolve(true), BROWSER_SPAWN_TIMEOUT).unref?.();
34
+ });
24
35
  } catch {
25
- // Caller can fall back to printing loginUrl.
36
+ return false;
26
37
  }
27
-
28
- return { token, loginUrl, pollUrl, browserOpened };
29
38
  }
30
39
 
31
40
  export async function awaitLoginCompletion({
@@ -47,29 +56,26 @@ export async function awaitLoginCompletion({
47
56
  try {
48
57
  const response = await fetch(pollUrl);
49
58
  const data = await response.json();
50
- if (data.status === 'yes') {
59
+ // Do not save the token if the login was aborted while this poll was in flight.
60
+ if (data.status === 'yes' && !signal?.aborted) {
51
61
  setConfigValue('apiKeyV2', token);
52
- process.env.CODEANT_API_TOKEN = token;
53
62
  return { ok: true, token };
54
63
  }
55
64
  } catch {
56
65
  // Network blip — keep polling.
57
66
  }
58
67
 
68
+ if (signal?.aborted) throw new Error('Login aborted');
69
+
59
70
  const remaining = deadline - Date.now();
60
71
  if (remaining <= 0) break;
61
- await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remaining)));
72
+ await new Promise((resolve) => {
73
+ const onAbort = () => { clearTimeout(timer); resolve(); };
74
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, Math.min(pollIntervalMs, remaining));
75
+ signal?.addEventListener('abort', onAbort, { once: true });
76
+ });
62
77
  }
63
78
 
79
+ if (signal?.aborted) throw new Error('Login aborted');
64
80
  throw new Error('Login timed out. Please try again.');
65
81
  }
66
-
67
- export async function runLoginFlow({
68
- pollIntervalMs = DEFAULT_POLL_INTERVAL,
69
- timeoutMs = DEFAULT_TIMEOUT,
70
- signal,
71
- } = {}) {
72
- const { token, loginUrl, pollUrl, browserOpened } = await startLoginFlow();
73
- const result = await awaitLoginCompletion({ token, pollUrl, pollIntervalMs, timeoutMs, signal });
74
- return { ...result, loginUrl, browserOpened };
75
- }