codeant-cli 0.4.6 → 0.4.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/package.json +8 -2
- package/src/commands/scans/dismissed.js +26 -0
- package/src/commands/scans/formatters/csv.js +24 -0
- package/src/commands/scans/formatters/index.js +12 -0
- package/src/commands/scans/formatters/json.js +8 -0
- package/src/commands/scans/formatters/md.js +63 -0
- package/src/commands/scans/formatters/sarif.js +105 -0
- package/src/commands/scans/formatters/table.js +83 -0
- package/src/commands/scans/get.js +64 -0
- package/src/commands/scans/history.js +43 -0
- package/src/commands/scans/index.js +146 -0
- package/src/commands/scans/lib/categories.js +51 -0
- package/src/commands/scans/lib/dismissMatch.js +32 -0
- package/src/commands/scans/lib/emit.js +25 -0
- package/src/commands/scans/lib/filters.js +72 -0
- package/src/commands/scans/lib/log.js +25 -0
- package/src/commands/scans/lib/normalize.js +155 -0
- package/src/commands/scans/lib/paginate.js +22 -0
- package/src/commands/scans/lib/resolveScan.js +50 -0
- package/src/commands/scans/lib/sort.js +19 -0
- package/src/commands/scans/orgs.js +15 -0
- package/src/commands/scans/repos.js +41 -0
- package/src/commands/scans/results.js +156 -0
- package/src/commands/scans/start-scan.js +90 -0
- package/src/components/ScanCenter.js +337 -0
- package/src/components/SelectList.js +80 -0
- package/src/index.js +12 -0
- package/src/scanCenter/handleSelectConnection.js +14 -0
- package/src/scanCenter/handleSelectRepo.js +19 -0
- package/src/scanCenter/handleSelectResultType.js +34 -0
- package/src/scanCenter/handleSelectScan.js +4 -0
- package/src/scanCenter/validateConnectionOnMount.js +15 -0
- package/src/scans/connectionHandler.js +52 -0
- package/src/scans/fetchAdvancedScanResults.js +325 -0
- package/src/scans/fetchDismissedAlerts.js +57 -0
- package/src/scans/fetchScanResults.js +115 -0
- package/src/scans/getScanHistory.js +38 -0
- package/src/scans/listRepos.js +38 -0
- package/src/scans/startScan.js +22 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determine if a NormalizedFinding matches any entry in dismissedAlerts.
|
|
3
|
+
*
|
|
4
|
+
* Dismiss key format: "file_path||::||context_code_block_or_line||::||test_id_or_type"
|
|
5
|
+
*
|
|
6
|
+
* @param {object} finding - NormalizedFinding
|
|
7
|
+
* @param {Array} dismissedAlerts - from fetchDismissedAlerts()
|
|
8
|
+
* @returns {object|null} matching dismiss entry, or null
|
|
9
|
+
*/
|
|
10
|
+
export function findDismissMatch(finding, dismissedAlerts) {
|
|
11
|
+
if (!dismissedAlerts || dismissedAlerts.length === 0) return null;
|
|
12
|
+
|
|
13
|
+
for (const d of dismissedAlerts) {
|
|
14
|
+
// File path must match (tail-match to handle prefix stripping differences)
|
|
15
|
+
const fp = finding.file_path ?? '';
|
|
16
|
+
const dfp = d.file_path ?? '';
|
|
17
|
+
if (dfp && fp && !fp.endsWith(dfp) && !dfp.endsWith(fp) && fp !== dfp) continue;
|
|
18
|
+
|
|
19
|
+
// test_id / type must match check_id when present
|
|
20
|
+
if (d.test_id && finding.check_id && d.test_id !== finding.check_id) continue;
|
|
21
|
+
|
|
22
|
+
// Line number match when available (secrets format uses line_number as part1)
|
|
23
|
+
if (d.line_number != null && finding.line_number != null && Number(d.line_number) !== Number(finding.line_number)) continue;
|
|
24
|
+
|
|
25
|
+
return d;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isDismissed(finding, dismissedAlerts) {
|
|
31
|
+
return findDismissMatch(finding, dismissedAlerts) !== null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Emit rendered content.
|
|
6
|
+
* - If outputPath is set: write bytes to file, print JSON envelope to stdout.
|
|
7
|
+
* - Otherwise: write to stdout.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} content - rendered string
|
|
10
|
+
* @param {string|null} outputPath
|
|
11
|
+
* @param {number} findingsCount - for the file envelope
|
|
12
|
+
*/
|
|
13
|
+
export function emit(content, outputPath, findingsCount = 0) {
|
|
14
|
+
if (outputPath) {
|
|
15
|
+
const resolved = path.resolve(outputPath);
|
|
16
|
+
fs.writeFileSync(resolved, content, 'utf8');
|
|
17
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
18
|
+
process.stdout.write(
|
|
19
|
+
JSON.stringify({ output: resolved, bytes, findings: findingsCount }, null, 2) + '\n'
|
|
20
|
+
);
|
|
21
|
+
} else {
|
|
22
|
+
process.stdout.write(content);
|
|
23
|
+
if (!content.endsWith('\n')) process.stdout.write('\n');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { minimatch } from 'minimatch';
|
|
2
|
+
import { isDismissed, findDismissMatch } from './dismissMatch.js';
|
|
3
|
+
import { normalizeSeverity } from './normalize.js';
|
|
4
|
+
|
|
5
|
+
const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0 };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Apply all filters to findings in-place (returns new array).
|
|
9
|
+
*
|
|
10
|
+
* @param {Array} findings - NormalizedFinding[]
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {string[]|null} opts.severity - allowed severity levels (e.g. ['critical','high'])
|
|
13
|
+
* @param {string|null} opts.pathGlob - minimatch glob for file_path
|
|
14
|
+
* @param {string|null} opts.checkRegex - regex applied to check_id + check_name
|
|
15
|
+
* @param {Array} opts.dismissedAlerts - from fetchDismissedAlerts()
|
|
16
|
+
* @param {boolean} opts.includeDismissed
|
|
17
|
+
* @returns {Array} filtered NormalizedFinding[] (dismissed field annotated)
|
|
18
|
+
*/
|
|
19
|
+
export function applyFilters(findings, {
|
|
20
|
+
severity = null,
|
|
21
|
+
pathGlob = null,
|
|
22
|
+
checkRegex = null,
|
|
23
|
+
dismissedAlerts = [],
|
|
24
|
+
includeDismissed = false,
|
|
25
|
+
} = {}) {
|
|
26
|
+
// Pre-compile regex — strip Python/PCRE inline flag (?i) and fold into JS flag
|
|
27
|
+
let checkRe = null;
|
|
28
|
+
if (checkRegex) {
|
|
29
|
+
try {
|
|
30
|
+
let pattern = checkRegex;
|
|
31
|
+
if (pattern.startsWith('(?i)')) pattern = pattern.slice(4);
|
|
32
|
+
checkRe = new RegExp(pattern, 'i');
|
|
33
|
+
} catch {
|
|
34
|
+
const err = new Error(`Invalid --check regex: ${checkRegex}`);
|
|
35
|
+
err.exitCode = 1;
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Severity set
|
|
41
|
+
const sevSet = severity && severity.length > 0
|
|
42
|
+
? new Set(severity.map((s) => normalizeSeverity(s)))
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
const result = [];
|
|
46
|
+
for (const f of findings) {
|
|
47
|
+
// Annotate dismiss status
|
|
48
|
+
const match = findDismissMatch(f, dismissedAlerts);
|
|
49
|
+
f.dismissed = match !== null;
|
|
50
|
+
if (match) {
|
|
51
|
+
f.dismiss_info = {
|
|
52
|
+
reason: match.reason_for_dismiss || null,
|
|
53
|
+
comment: match.comment_for_dismiss || null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Filter dismissed
|
|
58
|
+
if (f.dismissed && !includeDismissed) continue;
|
|
59
|
+
|
|
60
|
+
// Filter by severity
|
|
61
|
+
if (sevSet && !sevSet.has(f.severity)) continue;
|
|
62
|
+
|
|
63
|
+
// Filter by path glob
|
|
64
|
+
if (pathGlob && !minimatch(f.file_path, pathGlob, { matchBase: true })) continue;
|
|
65
|
+
|
|
66
|
+
// Filter by check regex
|
|
67
|
+
if (checkRe && !checkRe.test(f.check_id) && !checkRe.test(f.check_name)) continue;
|
|
68
|
+
|
|
69
|
+
result.push(f);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// stderr progress logger — respects --quiet and --no-color
|
|
2
|
+
|
|
3
|
+
let quietMode = false;
|
|
4
|
+
let noColorMode = false;
|
|
5
|
+
|
|
6
|
+
export function setQuiet(q) { quietMode = !!q; }
|
|
7
|
+
export function setNoColor(nc) { noColorMode = !!nc; }
|
|
8
|
+
|
|
9
|
+
function isTTY() { return process.stderr.isTTY === true; }
|
|
10
|
+
|
|
11
|
+
function dim(text) {
|
|
12
|
+
if (noColorMode || !isTTY()) return text;
|
|
13
|
+
return `\x1b[2m${text}\x1b[0m`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Write a progress line to stderr (suppressed by --quiet). */
|
|
17
|
+
export function progress(msg) {
|
|
18
|
+
if (quietMode) return;
|
|
19
|
+
process.stderr.write(dim(`[progress] ${msg}`) + '\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Write a JSON error object to stderr (never suppressed). */
|
|
23
|
+
export function logError(obj) {
|
|
24
|
+
process.stderr.write(JSON.stringify(obj) + '\n');
|
|
25
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const pkg = require('../../../../package.json');
|
|
5
|
+
|
|
6
|
+
const SEV_MAP = {
|
|
7
|
+
critical: 'critical', blocker: 'critical', p0: 'critical',
|
|
8
|
+
high: 'high', error: 'high', major: 'high',
|
|
9
|
+
medium: 'medium', warning: 'medium', moderate: 'medium', p2: 'medium',
|
|
10
|
+
low: 'low', note: 'low', minor: 'low', p3: 'low',
|
|
11
|
+
info: 'info', informational: 'info', information: 'info', p4: 'info',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function normalizeSeverity(raw) {
|
|
15
|
+
if (!raw) return 'unknown';
|
|
16
|
+
return SEV_MAP[String(raw).toLowerCase()] ?? 'unknown';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Stable djb2-variant hash → 4-char hex suffix for finding IDs. */
|
|
20
|
+
function shortHash(str) {
|
|
21
|
+
let h = 5381;
|
|
22
|
+
for (let i = 0; i < str.length; i++) {
|
|
23
|
+
h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0;
|
|
24
|
+
}
|
|
25
|
+
return h.toString(16).slice(0, 4);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a stable finding ID: category:file_path:line:check_id:hash
|
|
30
|
+
*/
|
|
31
|
+
function buildId(category, filePath, lineNumber, checkId, message) {
|
|
32
|
+
const hash = shortHash(`${filePath}:${lineNumber}:${checkId}:${message}`);
|
|
33
|
+
return `${category}:${filePath}:${lineNumber}:${checkId || 'nocheck'}:${hash}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a raw issue (from any fetcher) into a NormalizedFinding.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} issue - raw issue from fetcher
|
|
40
|
+
* @param {string} category - category key (e.g. 'sast')
|
|
41
|
+
* @returns {object} NormalizedFinding
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeIssue(issue, category) {
|
|
44
|
+
if (!issue) return null;
|
|
45
|
+
|
|
46
|
+
const filePath = issue.file_path || 'unknown';
|
|
47
|
+
const lineNumber = issue.line_number || issue.start_line || issue.line || 1;
|
|
48
|
+
const lineRange = issue.file_line_range || issue.line_range || [lineNumber];
|
|
49
|
+
const checkId = issue.check_id || issue.test_id || issue.rule_id || issue.vulnerability_id || issue.cve_id || '';
|
|
50
|
+
const checkName = issue.check_name || issue.issue_text || issue.message || issue.description || issue.name || '';
|
|
51
|
+
const message = issue.message || issue.issue_text || issue.description || checkName || '';
|
|
52
|
+
const severity = normalizeSeverity(issue.severity);
|
|
53
|
+
|
|
54
|
+
// Package info for sca/sbom
|
|
55
|
+
let packageInfo = null;
|
|
56
|
+
if (category === 'sca' || category === 'sbom') {
|
|
57
|
+
const name = issue.package_name || issue.name || null;
|
|
58
|
+
if (name) {
|
|
59
|
+
packageInfo = {
|
|
60
|
+
name,
|
|
61
|
+
version: issue.version || issue.package_version || null,
|
|
62
|
+
ecosystem: issue.ecosystem || issue.package_manager || null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// CWE / CVE
|
|
68
|
+
const cwe = issue.cwe || issue.cwe_id || null;
|
|
69
|
+
const cve = issue.cve || issue.cve_id || issue.vulnerability_id || null;
|
|
70
|
+
|
|
71
|
+
// Category-specific metadata
|
|
72
|
+
const metadata = {};
|
|
73
|
+
if (category === 'iac') {
|
|
74
|
+
if (issue.guideline) metadata.guideline = issue.guideline;
|
|
75
|
+
if (issue.resource) metadata.resource = issue.resource;
|
|
76
|
+
} else if (category === 'sast' || category === 'anti_patterns') {
|
|
77
|
+
if (issue.issue_confidence) metadata.confidence = issue.issue_confidence;
|
|
78
|
+
if (issue.issue_type || issue.test_type) metadata.issue_type = issue.issue_type || issue.test_type;
|
|
79
|
+
} else if (category === 'secrets') {
|
|
80
|
+
if (issue.type || issue.secret_type) metadata.secret_type = issue.type || issue.secret_type;
|
|
81
|
+
if (issue.confidence_score) metadata.confidence_score = issue.confidence_score;
|
|
82
|
+
} else if (category === 'dead_code') {
|
|
83
|
+
if (issue.type) metadata.type = issue.type;
|
|
84
|
+
if (issue.confidence) metadata.confidence = issue.confidence;
|
|
85
|
+
} else if (category === 'complex_functions') {
|
|
86
|
+
if (issue.complexity !== undefined) metadata.complexity = issue.complexity;
|
|
87
|
+
} else if (category === 'sca') {
|
|
88
|
+
if (issue.cvss_score !== undefined) metadata.cvss_score = issue.cvss_score;
|
|
89
|
+
if (issue.fix_version) metadata.fix_version = issue.fix_version;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const id = buildId(category, filePath, lineNumber, checkId, message);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
category,
|
|
97
|
+
severity,
|
|
98
|
+
file_path: filePath,
|
|
99
|
+
line_number: lineNumber,
|
|
100
|
+
line_range: Array.isArray(lineRange) ? lineRange : [lineNumber],
|
|
101
|
+
check_id: checkId,
|
|
102
|
+
check_name: checkName,
|
|
103
|
+
message,
|
|
104
|
+
rule_id: checkId,
|
|
105
|
+
cwe: cwe ? String(cwe) : null,
|
|
106
|
+
cve: cve ? String(cve) : null,
|
|
107
|
+
package: packageInfo,
|
|
108
|
+
metadata,
|
|
109
|
+
dismissed: false,
|
|
110
|
+
dismiss_info: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Build the outer envelope object. */
|
|
115
|
+
export function buildEnvelope({
|
|
116
|
+
repo,
|
|
117
|
+
scan,
|
|
118
|
+
categories,
|
|
119
|
+
findings,
|
|
120
|
+
pagination,
|
|
121
|
+
filters,
|
|
122
|
+
errors,
|
|
123
|
+
}) {
|
|
124
|
+
const total = pagination.total;
|
|
125
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 };
|
|
126
|
+
const byCategory = {};
|
|
127
|
+
|
|
128
|
+
for (const f of findings) {
|
|
129
|
+
bySeverity[f.severity] = (bySeverity[f.severity] ?? 0) + 1;
|
|
130
|
+
byCategory[f.category] = (byCategory[f.category] ?? 0) + 1;
|
|
131
|
+
}
|
|
132
|
+
// summary uses all findings (pre-pagination)
|
|
133
|
+
// recalculate from pre-pagination total
|
|
134
|
+
const allSeverity = { ...bySeverity };
|
|
135
|
+
const allCategory = { ...byCategory };
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
schema_version: '1.0',
|
|
139
|
+
tool: 'codeant-cli',
|
|
140
|
+
tool_version: pkg.version,
|
|
141
|
+
generated_at: new Date().toISOString(),
|
|
142
|
+
repo,
|
|
143
|
+
scan,
|
|
144
|
+
categories,
|
|
145
|
+
summary: {
|
|
146
|
+
total,
|
|
147
|
+
by_severity: allSeverity,
|
|
148
|
+
by_category: allCategory,
|
|
149
|
+
},
|
|
150
|
+
pagination,
|
|
151
|
+
filters,
|
|
152
|
+
errors,
|
|
153
|
+
findings,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice findings and return pagination metadata.
|
|
3
|
+
* @param {Array} findings - full (sorted, filtered) findings array
|
|
4
|
+
* @param {{ limit?: number, offset?: number }} opts
|
|
5
|
+
* @returns {{ items: Array, pagination: object }}
|
|
6
|
+
*/
|
|
7
|
+
export function paginate(findings, { limit = 100, offset = 0 } = {}) {
|
|
8
|
+
const total = findings.length;
|
|
9
|
+
const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0;
|
|
10
|
+
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : 100;
|
|
11
|
+
const items = findings.slice(safeOffset, safeOffset + safeLimit);
|
|
12
|
+
return {
|
|
13
|
+
items,
|
|
14
|
+
pagination: {
|
|
15
|
+
limit: safeLimit,
|
|
16
|
+
offset: safeOffset,
|
|
17
|
+
returned: items.length,
|
|
18
|
+
total,
|
|
19
|
+
has_more: safeOffset + items.length < total,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getScanHistory } from '../../../scans/getScanHistory.js';
|
|
2
|
+
|
|
3
|
+
function withExitCode(code, message) {
|
|
4
|
+
const err = new Error(message);
|
|
5
|
+
err.exitCode = code;
|
|
6
|
+
return err;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the scan to use.
|
|
11
|
+
* Precedence: --scan (explicit SHA) > --branch (latest on branch) > global latest.
|
|
12
|
+
*
|
|
13
|
+
* @param {{ repo: string, scan?: string, branch?: string }} opts
|
|
14
|
+
* @returns {Promise<{ commit_id, branch, timestamp, status, resolved_by }>}
|
|
15
|
+
*/
|
|
16
|
+
export async function resolveScan({ repo, scan, branch }) {
|
|
17
|
+
if (scan) {
|
|
18
|
+
const { success, scanHistory, error } = await getScanHistory(repo);
|
|
19
|
+
const history = success ? (scanHistory ?? []) : [];
|
|
20
|
+
const hit = history.find((s) => s.latest_commit_sha === scan);
|
|
21
|
+
return {
|
|
22
|
+
commit_id: scan,
|
|
23
|
+
branch: hit?.branch ?? branch ?? null,
|
|
24
|
+
timestamp: hit?.timestamp ?? null,
|
|
25
|
+
status: hit?.status ?? 'done',
|
|
26
|
+
resolved_by: 'explicit',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { success, scanHistory, error } = await getScanHistory(repo);
|
|
31
|
+
if (!success) throw withExitCode(1, `scan history: ${error}`);
|
|
32
|
+
|
|
33
|
+
const sorted = [...(scanHistory ?? [])]
|
|
34
|
+
.filter((s) => s.latest_commit_sha && s.timestamp)
|
|
35
|
+
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
36
|
+
const filtered = branch ? sorted.filter((s) => s.branch === branch) : sorted;
|
|
37
|
+
const pick = filtered[0];
|
|
38
|
+
|
|
39
|
+
if (!pick) {
|
|
40
|
+
throw withExitCode(2, branch ? `no scans for branch "${branch}"` : 'no scans found');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
commit_id: pick.latest_commit_sha,
|
|
45
|
+
branch: pick.branch ?? branch ?? null,
|
|
46
|
+
timestamp: pick.timestamp,
|
|
47
|
+
status: pick.status ?? 'done',
|
|
48
|
+
resolved_by: branch ? 'branch' : 'latest',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0 };
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic sort: severity desc, file_path asc, line_number asc, check_id asc, id asc.
|
|
5
|
+
* Same input always produces byte-identical output.
|
|
6
|
+
*/
|
|
7
|
+
export function deterministicSort(findings) {
|
|
8
|
+
return [...findings].sort((a, b) => {
|
|
9
|
+
const sd = (SEV_RANK[b.severity] ?? 0) - (SEV_RANK[a.severity] ?? 0);
|
|
10
|
+
if (sd !== 0) return sd;
|
|
11
|
+
const fp = (a.file_path ?? '').localeCompare(b.file_path ?? '', 'en', { sensitivity: 'base' });
|
|
12
|
+
if (fp !== 0) return fp;
|
|
13
|
+
const ln = (a.line_number ?? 0) - (b.line_number ?? 0);
|
|
14
|
+
if (ln !== 0) return ln;
|
|
15
|
+
const ci = (a.check_id ?? '').localeCompare(b.check_id ?? '', 'en');
|
|
16
|
+
if (ci !== 0) return ci;
|
|
17
|
+
return (a.id ?? '').localeCompare(b.id ?? '', 'en');
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { validateConnection } from '../../scans/connectionHandler.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* codeant scans orgs
|
|
5
|
+
* List authenticated organizations.
|
|
6
|
+
*/
|
|
7
|
+
export async function runOrgs() {
|
|
8
|
+
const result = await validateConnection();
|
|
9
|
+
if (!result.success) {
|
|
10
|
+
const err = new Error(result.error || 'Failed to validate connection');
|
|
11
|
+
err.exitCode = result.error?.toLowerCase().includes('network') ? 4 : 1;
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
return { connections: result.connections, email: result.email };
|
|
15
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { validateConnection } from '../../scans/connectionHandler.js';
|
|
2
|
+
import { listRepos as listReposApi } from '../../scans/listRepos.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* codeant scans repos [--org <org>]
|
|
6
|
+
* List repositories. Auto-picks org when only one is authenticated.
|
|
7
|
+
*/
|
|
8
|
+
export async function runRepos({ org } = {}) {
|
|
9
|
+
let orgName = org;
|
|
10
|
+
|
|
11
|
+
if (!orgName) {
|
|
12
|
+
const conn = await validateConnection();
|
|
13
|
+
if (!conn.success) {
|
|
14
|
+
const err = new Error(conn.error || 'Failed to validate connection');
|
|
15
|
+
err.exitCode = 1;
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
if (conn.connections.length === 0) {
|
|
19
|
+
const err = new Error('No authenticated organizations found');
|
|
20
|
+
err.exitCode = 1;
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
if (conn.connections.length > 1) {
|
|
24
|
+
const err = new Error(
|
|
25
|
+
`Multiple orgs found. Specify one with --org. Available: ${conn.connections.map((c) => c.organizationName).join(', ')}`
|
|
26
|
+
);
|
|
27
|
+
err.exitCode = 1;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
orgName = conn.connections[0].organizationName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = await listReposApi(orgName);
|
|
34
|
+
if (!result.success) {
|
|
35
|
+
const err = new Error(result.error || 'Failed to list repositories');
|
|
36
|
+
err.exitCode = 1;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { org: orgName, repos: result.repos };
|
|
41
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { resolveScan } from './lib/resolveScan.js';
|
|
2
|
+
import { parseTypes } from './lib/categories.js';
|
|
3
|
+
import { normalizeIssue, buildEnvelope } from './lib/normalize.js';
|
|
4
|
+
import { applyFilters } from './lib/filters.js';
|
|
5
|
+
import { deterministicSort } from './lib/sort.js';
|
|
6
|
+
import { paginate } from './lib/paginate.js';
|
|
7
|
+
import { emit } from './lib/emit.js';
|
|
8
|
+
import { progress, logError } from './lib/log.js';
|
|
9
|
+
import { FORMATTERS } from './formatters/index.js';
|
|
10
|
+
import { fetchDismissedAlerts } from '../../scans/fetchDismissedAlerts.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* codeant scans results — full orchestration.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
*/
|
|
17
|
+
export async function runResults(opts = {}) {
|
|
18
|
+
const {
|
|
19
|
+
repo,
|
|
20
|
+
scan,
|
|
21
|
+
branch,
|
|
22
|
+
types,
|
|
23
|
+
severity,
|
|
24
|
+
path: pathGlob,
|
|
25
|
+
check: checkRegex,
|
|
26
|
+
includeDismissed = false,
|
|
27
|
+
format = 'json',
|
|
28
|
+
output: outputPath = null,
|
|
29
|
+
fields = null,
|
|
30
|
+
limit = 100,
|
|
31
|
+
offset = 0,
|
|
32
|
+
failFast = false,
|
|
33
|
+
} = opts;
|
|
34
|
+
|
|
35
|
+
if (!repo) {
|
|
36
|
+
const err = new Error('--repo is required');
|
|
37
|
+
err.exitCode = 1;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const formatter = FORMATTERS[format];
|
|
42
|
+
if (!formatter) {
|
|
43
|
+
const err = new Error(`Unknown --format "${format}". Valid: ${Object.keys(FORMATTERS).join(', ')}`);
|
|
44
|
+
err.exitCode = 1;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 1. Resolve scan
|
|
49
|
+
progress(`resolving scan for ${repo}…`);
|
|
50
|
+
const scanMeta = await resolveScan({ repo, scan, branch });
|
|
51
|
+
progress(`using commit ${scanMeta.commit_id} (${scanMeta.resolved_by})`);
|
|
52
|
+
|
|
53
|
+
// 2. Parse types
|
|
54
|
+
const categories = parseTypes(types);
|
|
55
|
+
|
|
56
|
+
// 3. Fetch in parallel + dismissed alerts
|
|
57
|
+
progress(`fetching ${categories.map((c) => c.key).join(', ')}…`);
|
|
58
|
+
const [settled, dismissedResult] = await Promise.all([
|
|
59
|
+
Promise.allSettled(categories.map((c) => c.fetcher(repo, scanMeta.commit_id))),
|
|
60
|
+
includeDismissed
|
|
61
|
+
? Promise.resolve({ success: true, dismissedAlerts: [] })
|
|
62
|
+
: fetchDismissedAlerts(repo, 'security'),
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const dismissedAlerts = dismissedResult.success ? (dismissedResult.dismissedAlerts ?? []) : [];
|
|
66
|
+
|
|
67
|
+
// 4. Collect findings + errors
|
|
68
|
+
const allFindings = [];
|
|
69
|
+
const errors = [];
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < settled.length; i++) {
|
|
72
|
+
const cat = categories[i];
|
|
73
|
+
const s = settled[i];
|
|
74
|
+
|
|
75
|
+
if (s.status === 'rejected' || !s.value?.success) {
|
|
76
|
+
const msg = s.status === 'rejected' ? s.reason?.message : s.value?.error;
|
|
77
|
+
errors.push({ category: cat.key, error: msg || 'unknown error' });
|
|
78
|
+
logError({ category: cat.key, error: msg || 'unknown error' });
|
|
79
|
+
if (failFast) {
|
|
80
|
+
const err = new Error(`Category "${cat.key}" failed: ${msg}`);
|
|
81
|
+
err.exitCode = 3;
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
progress(`normalizing ${cat.key} (${s.value.issues?.length ?? 0} issues)…`);
|
|
88
|
+
for (const issue of s.value.issues ?? []) {
|
|
89
|
+
const f = normalizeIssue(issue, cat.key);
|
|
90
|
+
if (f) allFindings.push(f);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 5. Filter
|
|
95
|
+
const severityList = severity
|
|
96
|
+
? severity.split(',').map((s) => s.trim()).filter(Boolean)
|
|
97
|
+
: null;
|
|
98
|
+
|
|
99
|
+
const filtered = applyFilters(allFindings, {
|
|
100
|
+
severity: severityList,
|
|
101
|
+
pathGlob: pathGlob || null,
|
|
102
|
+
checkRegex: checkRegex || null,
|
|
103
|
+
dismissedAlerts,
|
|
104
|
+
includeDismissed,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// 6. Sort
|
|
108
|
+
const sorted = deterministicSort(filtered);
|
|
109
|
+
|
|
110
|
+
// 7. Paginate
|
|
111
|
+
const { items: pageItems, pagination } = paginate(sorted, { limit, offset });
|
|
112
|
+
|
|
113
|
+
// 8. Build envelope (summary uses pre-pagination totals)
|
|
114
|
+
const filtersObj = {
|
|
115
|
+
severity: severityList,
|
|
116
|
+
path: pathGlob || null,
|
|
117
|
+
check: checkRegex || null,
|
|
118
|
+
include_dismissed: includeDismissed,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Rebuild summary from all filtered (pre-page) findings
|
|
122
|
+
const summaryBySev = { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 };
|
|
123
|
+
const summaryByCat = {};
|
|
124
|
+
for (const f of sorted) {
|
|
125
|
+
summaryBySev[f.severity] = (summaryBySev[f.severity] ?? 0) + 1;
|
|
126
|
+
summaryByCat[f.category] = (summaryByCat[f.category] ?? 0) + 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const envelope = buildEnvelope({
|
|
130
|
+
repo,
|
|
131
|
+
scan: scanMeta,
|
|
132
|
+
categories: categories.map((c) => c.key),
|
|
133
|
+
findings: pageItems,
|
|
134
|
+
pagination,
|
|
135
|
+
filters: filtersObj,
|
|
136
|
+
errors,
|
|
137
|
+
});
|
|
138
|
+
// Override summary with pre-pagination counts
|
|
139
|
+
envelope.summary = { total: sorted.length, by_severity: summaryBySev, by_category: summaryByCat };
|
|
140
|
+
|
|
141
|
+
// 9. Project fields
|
|
142
|
+
if (fields) {
|
|
143
|
+
const fieldList = fields.split(',').map((f) => f.trim()).filter(Boolean);
|
|
144
|
+
envelope.findings = envelope.findings.map((f) => {
|
|
145
|
+
const projected = {};
|
|
146
|
+
for (const key of fieldList) {
|
|
147
|
+
if (key in f) projected[key] = f[key];
|
|
148
|
+
}
|
|
149
|
+
return projected;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 10. Render + emit
|
|
154
|
+
const rendered = formatter.render(envelope);
|
|
155
|
+
emit(rendered, outputPath, envelope.findings.length);
|
|
156
|
+
}
|