devvami 1.4.0 → 1.4.1
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/oclif.manifest.json +187 -1
- package/package.json +1 -1
- package/src/commands/vuln/detail.js +65 -0
- package/src/commands/vuln/scan.js +155 -0
- package/src/commands/vuln/search.js +128 -0
- package/src/formatters/vuln.js +317 -0
- package/src/help.js +15 -0
- package/src/services/audit-detector.js +120 -0
- package/src/services/audit-runner.js +365 -0
- package/src/services/nvd.js +245 -0
- package/src/types.js +9 -5
- package/src/utils/errors.js +2 -0
- package/src/utils/tui/modal.js +224 -0
- package/src/utils/tui/navigable-table.js +496 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { NVD_ATTRIBUTION } from '../../services/nvd.js'
|
|
3
|
+
|
|
4
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
// ANSI escape sequences (re-declared locally — avoids cross-module coupling)
|
|
6
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const ANSI_CLEAR = '\x1b[2J'
|
|
9
|
+
const ANSI_HOME = '\x1b[H'
|
|
10
|
+
|
|
11
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// Internal helpers
|
|
13
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Clamp a value between min and max (inclusive).
|
|
17
|
+
* @param {number} val
|
|
18
|
+
* @param {number} min
|
|
19
|
+
* @param {number} max
|
|
20
|
+
* @returns {number}
|
|
21
|
+
*/
|
|
22
|
+
function clamp(val, min, max) {
|
|
23
|
+
return Math.min(Math.max(val, min), max)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Center a string within a fixed width, padding with spaces on both sides.
|
|
28
|
+
* @param {string} text - Plain text (no ANSI codes)
|
|
29
|
+
* @param {number} width
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function centerText(text, width) {
|
|
33
|
+
if (text.length >= width) return text.slice(0, width)
|
|
34
|
+
const totalPad = width - text.length
|
|
35
|
+
const left = Math.floor(totalPad / 2)
|
|
36
|
+
const right = totalPad - left
|
|
37
|
+
return ' '.repeat(left) + text + ' '.repeat(right)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
// T006: buildModalScreen
|
|
42
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the full modal overlay screen string from the current interactive state.
|
|
46
|
+
* Renders the modal content lines with a border, scroll indicators, and footer hints.
|
|
47
|
+
* Prepends ANSI clear + home to replace the previous frame.
|
|
48
|
+
* @param {import('./navigable-table.js').InteractiveTableState} state
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function buildModalScreen(state) {
|
|
52
|
+
const { modalContent, modalScrollOffset, termRows, termCols, firstRefUrl } = state
|
|
53
|
+
|
|
54
|
+
const lines = []
|
|
55
|
+
|
|
56
|
+
// Modal inner width: leave 4 chars for left/right borders + padding
|
|
57
|
+
const innerWidth = Math.max(20, termCols - 4)
|
|
58
|
+
|
|
59
|
+
// ── Title bar ──────────────────────────────────────────────────────────────
|
|
60
|
+
const titleText = 'CVE Detail'
|
|
61
|
+
lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
|
|
62
|
+
lines.push(chalk.bold.cyan('║ ') + chalk.bold(centerText(titleText, innerWidth)) + chalk.bold.cyan(' ║'))
|
|
63
|
+
lines.push(chalk.bold.cyan('╠' + '═'.repeat(innerWidth + 2) + '╣'))
|
|
64
|
+
|
|
65
|
+
const BORDER_LINES = 3 // title bar: top + title + divider
|
|
66
|
+
const FOOTER_LINES = 4 // empty + attribution + hints + bottom border
|
|
67
|
+
const contentViewport = Math.max(1, termRows - BORDER_LINES - FOOTER_LINES)
|
|
68
|
+
|
|
69
|
+
const content = modalContent ?? []
|
|
70
|
+
const maxOffset = Math.max(0, content.length - contentViewport)
|
|
71
|
+
const safeOffset = clamp(modalScrollOffset, 0, maxOffset)
|
|
72
|
+
|
|
73
|
+
// ── Content lines ──────────────────────────────────────────────────────────
|
|
74
|
+
const visibleLines = content.slice(safeOffset, safeOffset + contentViewport)
|
|
75
|
+
for (const line of visibleLines) {
|
|
76
|
+
// Truncate to inner width to avoid terminal wrapping
|
|
77
|
+
const truncated = line.length > innerWidth ? line.slice(0, innerWidth - 1) + '…' : line
|
|
78
|
+
lines.push(chalk.bold.cyan('║ ') + truncated.padEnd(innerWidth) + chalk.bold.cyan(' ║'))
|
|
79
|
+
}
|
|
80
|
+
// Pad to fill the content viewport
|
|
81
|
+
for (let i = visibleLines.length; i < contentViewport; i++) {
|
|
82
|
+
lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Footer ─────────────────────────────────────────────────────────────────
|
|
86
|
+
lines.push(chalk.bold.cyan('╠' + '═'.repeat(innerWidth + 2) + '╣'))
|
|
87
|
+
lines.push(chalk.bold.cyan('║ ') + chalk.dim(NVD_ATTRIBUTION).slice(0, innerWidth).padEnd(innerWidth) + chalk.bold.cyan(' ║'))
|
|
88
|
+
|
|
89
|
+
const scrollHint = content.length > contentViewport ? ' ↑↓/PgUp/PgDn scroll' : ''
|
|
90
|
+
const openHint = firstRefUrl ? ' o open ref' : ''
|
|
91
|
+
const hintLine = ` ↑↓ scroll Esc back to table q exit${openHint}${scrollHint}`
|
|
92
|
+
const truncHint = hintLine.length > innerWidth ? hintLine.slice(0, innerWidth - 1) + '…' : hintLine
|
|
93
|
+
lines.push(chalk.bold.cyan('║ ') + chalk.dim(truncHint).padEnd(innerWidth) + chalk.bold.cyan(' ║'))
|
|
94
|
+
lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
|
|
95
|
+
|
|
96
|
+
return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
// T007: buildLoadingScreen
|
|
101
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a "loading detail" screen to display while a CVE detail fetch is in flight.
|
|
105
|
+
* @param {string} cveId - The CVE identifier being fetched
|
|
106
|
+
* @param {number} termRows - Current terminal height
|
|
107
|
+
* @param {number} termCols - Current terminal width
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
export function buildLoadingScreen(cveId, termRows, termCols) {
|
|
111
|
+
const lines = []
|
|
112
|
+
const innerWidth = Math.max(20, termCols - 4)
|
|
113
|
+
const midRow = Math.floor(termRows / 2)
|
|
114
|
+
|
|
115
|
+
lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
|
|
116
|
+
|
|
117
|
+
for (let i = 1; i < midRow - 1; i++) {
|
|
118
|
+
lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const loadingText = `Loading ${cveId}…`
|
|
122
|
+
const centred = centerText(loadingText, innerWidth)
|
|
123
|
+
lines.push(chalk.bold.cyan('║ ') + chalk.yellow(centred) + chalk.bold.cyan(' ║'))
|
|
124
|
+
|
|
125
|
+
const remaining = termRows - lines.length - 1
|
|
126
|
+
for (let i = 0; i < remaining; i++) {
|
|
127
|
+
lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
|
|
131
|
+
|
|
132
|
+
return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
// T008: buildErrorScreen
|
|
137
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Build an error modal screen displayed when a CVE detail fetch fails.
|
|
141
|
+
* @param {string} cveId - The CVE identifier that failed to load
|
|
142
|
+
* @param {string} errorMessage - The error message to display
|
|
143
|
+
* @param {number} termRows - Current terminal height
|
|
144
|
+
* @param {number} termCols - Current terminal width
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function buildErrorScreen(cveId, errorMessage, termRows, termCols) {
|
|
148
|
+
const lines = []
|
|
149
|
+
const innerWidth = Math.max(20, termCols - 4)
|
|
150
|
+
const midRow = Math.floor(termRows / 2)
|
|
151
|
+
|
|
152
|
+
lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
|
|
153
|
+
|
|
154
|
+
for (let i = 1; i < midRow - 2; i++) {
|
|
155
|
+
lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const titleText = `Failed to load ${cveId}`
|
|
159
|
+
lines.push(chalk.bold.cyan('║ ') + chalk.red.bold(centerText(titleText, innerWidth)) + chalk.bold.cyan(' ║'))
|
|
160
|
+
lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
|
|
161
|
+
|
|
162
|
+
const truncErr =
|
|
163
|
+
errorMessage.length > innerWidth ? errorMessage.slice(0, innerWidth - 1) + '…' : errorMessage
|
|
164
|
+
lines.push(chalk.bold.cyan('║ ') + chalk.red(truncErr.padEnd(innerWidth)) + chalk.bold.cyan(' ║'))
|
|
165
|
+
|
|
166
|
+
const remaining = termRows - lines.length - 2
|
|
167
|
+
for (let i = 0; i < remaining; i++) {
|
|
168
|
+
lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const hintText = centerText('Press Esc to return to the table', innerWidth)
|
|
172
|
+
lines.push(chalk.bold.cyan('║ ') + chalk.dim(hintText) + chalk.bold.cyan(' ║'))
|
|
173
|
+
lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
|
|
174
|
+
|
|
175
|
+
return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
// T015: handleModalKeypress
|
|
180
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Pure state reducer for keypresses in the modal view.
|
|
184
|
+
* Returns a new state on scroll, a control object on exit/back/open-url, or unchanged state.
|
|
185
|
+
* @param {import('./navigable-table.js').InteractiveTableState} state
|
|
186
|
+
* @param {{ name: string, ctrl?: boolean }} key - readline keypress event
|
|
187
|
+
* @returns {
|
|
188
|
+
* import('./navigable-table.js').InteractiveTableState |
|
|
189
|
+
* { backToTable: true } |
|
|
190
|
+
* { exit: true } |
|
|
191
|
+
* { openUrl: string }
|
|
192
|
+
* }
|
|
193
|
+
*/
|
|
194
|
+
export function handleModalKeypress(state, key) {
|
|
195
|
+
const { modalContent, modalScrollOffset, termRows, firstRefUrl } = state
|
|
196
|
+
|
|
197
|
+
if (key.ctrl && key.name === 'c') return { exit: true }
|
|
198
|
+
if (key.name === 'q') return { exit: true }
|
|
199
|
+
|
|
200
|
+
if (key.name === 'escape') return { backToTable: true }
|
|
201
|
+
|
|
202
|
+
if (key.name === 'o' && firstRefUrl) return { openUrl: firstRefUrl }
|
|
203
|
+
|
|
204
|
+
const contentLen = modalContent ? modalContent.length : 0
|
|
205
|
+
const BORDER_LINES = 3
|
|
206
|
+
const FOOTER_LINES = 4
|
|
207
|
+
const contentViewport = Math.max(1, termRows - BORDER_LINES - FOOTER_LINES)
|
|
208
|
+
const maxOffset = Math.max(0, contentLen - contentViewport)
|
|
209
|
+
|
|
210
|
+
if (key.name === 'up') {
|
|
211
|
+
return { ...state, modalScrollOffset: clamp(modalScrollOffset - 1, 0, maxOffset) }
|
|
212
|
+
}
|
|
213
|
+
if (key.name === 'down') {
|
|
214
|
+
return { ...state, modalScrollOffset: clamp(modalScrollOffset + 1, 0, maxOffset) }
|
|
215
|
+
}
|
|
216
|
+
if (key.name === 'pageup') {
|
|
217
|
+
return { ...state, modalScrollOffset: clamp(modalScrollOffset - contentViewport, 0, maxOffset) }
|
|
218
|
+
}
|
|
219
|
+
if (key.name === 'pagedown') {
|
|
220
|
+
return { ...state, modalScrollOffset: clamp(modalScrollOffset + contentViewport, 0, maxOffset) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return state // unrecognized key — no state change
|
|
224
|
+
}
|