dsh-capdiff 0.0.0-stage → 0.1.0
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/DESIGN.md +201 -0
- package/LICENSE +202 -0
- package/README.md +209 -2
- package/package.json +45 -4
- package/src/cli.js +257 -0
- package/src/core.js +914 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-capdiff CLI. Argument parsing and IO only; all analysis lives in core.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile } from 'node:fs/promises'
|
|
7
|
+
import { relative, resolve } from 'node:path'
|
|
8
|
+
import { parseArgs } from 'node:util'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
TOOL_NAME,
|
|
12
|
+
TOOL_VERSION,
|
|
13
|
+
buildFindings,
|
|
14
|
+
diffCapabilities,
|
|
15
|
+
parseDeclared,
|
|
16
|
+
scanTarget,
|
|
17
|
+
toSarif,
|
|
18
|
+
} from './core.js'
|
|
19
|
+
|
|
20
|
+
const USAGE = `Usage: ${TOOL_NAME} <file-or-dir> [options]
|
|
21
|
+
|
|
22
|
+
Scan .js/.mjs/.cjs files for statically visible capabilities and compare them
|
|
23
|
+
with the dsh.capabilities entries in a plugin manifest.
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--manifest <path> Manifest JSON containing dsh.capabilities
|
|
27
|
+
--json Print a machine-readable JSON report
|
|
28
|
+
--sarif Print a SARIF 2.1.0 log
|
|
29
|
+
-h, --help Show this help
|
|
30
|
+
-V, --version Show version
|
|
31
|
+
|
|
32
|
+
Directories are scanned recursively; node_modules and .git are skipped.
|
|
33
|
+
This is a static heuristic: code is never executed, and a clean report is not
|
|
34
|
+
proof that a plugin is safe.
|
|
35
|
+
|
|
36
|
+
Exit codes: 0 = scan completed (even with findings), 1 = unused (no business-negative
|
|
37
|
+
result; findings do not fail the command), 2 = bad arguments, input, or IO.`
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string|null|undefined} file
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function displayPath(file) {
|
|
44
|
+
if (!file) return '(none)'
|
|
45
|
+
const rel = relative(process.cwd(), file)
|
|
46
|
+
if (!rel) return '.'
|
|
47
|
+
return rel.startsWith('..') ? file : rel
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string[]} values
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function formatList(values) {
|
|
55
|
+
return values.length ? values.join(', ') : '(none)'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {{
|
|
60
|
+
* target: string,
|
|
61
|
+
* manifest: string|null,
|
|
62
|
+
* filesScanned: number,
|
|
63
|
+
* declared: string[],
|
|
64
|
+
* actual: string[],
|
|
65
|
+
* diff: { matched: string[], undeclared: string[], unused: string[] },
|
|
66
|
+
* findings: Array<{ kind: string, capability: string, file?: string, line?: number, evidence?: string }>
|
|
67
|
+
* }} result
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function formatHuman(result) {
|
|
71
|
+
const lines = []
|
|
72
|
+
lines.push(`${TOOL_NAME} ${TOOL_VERSION} - static capability diff (heuristic)`)
|
|
73
|
+
lines.push(`Target: ${displayPath(result.target)}`)
|
|
74
|
+
lines.push(
|
|
75
|
+
`Manifest: ${result.manifest ? displayPath(result.manifest) : '(none; pass --manifest to compare)'}`,
|
|
76
|
+
)
|
|
77
|
+
lines.push(`Files: ${result.filesScanned} source file(s) scanned`)
|
|
78
|
+
lines.push(`Declared: ${formatList(result.declared)}`)
|
|
79
|
+
lines.push(`Detected: ${formatList(result.actual)}`)
|
|
80
|
+
lines.push('')
|
|
81
|
+
|
|
82
|
+
if (result.diff.undeclared.length) {
|
|
83
|
+
lines.push(`Undeclared (${result.diff.undeclared.length}) - used in code, absent from manifest:`)
|
|
84
|
+
for (const finding of result.findings) {
|
|
85
|
+
if (finding.kind !== 'undeclared') continue
|
|
86
|
+
const where = finding.file
|
|
87
|
+
? `${displayPath(finding.file)}${finding.line ? `:${finding.line}` : ''}`
|
|
88
|
+
: 'scanned code'
|
|
89
|
+
const trigger = finding.evidence ? `, ${finding.evidence}` : ''
|
|
90
|
+
lines.push(` - ${finding.capability} (${where}${trigger})`)
|
|
91
|
+
}
|
|
92
|
+
lines.push('')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (result.diff.unused.length) {
|
|
96
|
+
lines.push(
|
|
97
|
+
`Unused declarations (${result.diff.unused.length}) - declared, not statically visible:`,
|
|
98
|
+
)
|
|
99
|
+
for (const finding of result.findings) {
|
|
100
|
+
if (finding.kind !== 'unused') continue
|
|
101
|
+
lines.push(` - ${finding.capability}`)
|
|
102
|
+
}
|
|
103
|
+
lines.push('')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (result.diff.matched.length) {
|
|
107
|
+
lines.push(`Matched (${result.diff.matched.length}): ${formatList(result.diff.matched)}`)
|
|
108
|
+
lines.push('')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!result.diff.undeclared.length && !result.diff.unused.length) {
|
|
112
|
+
lines.push('No static capability differences found.')
|
|
113
|
+
lines.push('')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (result.filesScanned === 0) {
|
|
117
|
+
lines.push('Warning: no .js/.mjs/.cjs files were found.')
|
|
118
|
+
lines.push('')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
lines.push('Note: static heuristic only - "matched" and zero findings are not proof of safety.')
|
|
122
|
+
return lines.join('\n')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @returns {Promise<number>} process exit code
|
|
127
|
+
*/
|
|
128
|
+
async function main() {
|
|
129
|
+
let parsed
|
|
130
|
+
try {
|
|
131
|
+
parsed = parseArgs({
|
|
132
|
+
allowPositionals: true,
|
|
133
|
+
options: {
|
|
134
|
+
help: { type: 'boolean', short: 'h' },
|
|
135
|
+
version: { type: 'boolean', short: 'V' },
|
|
136
|
+
manifest: { type: 'string' },
|
|
137
|
+
json: { type: 'boolean' },
|
|
138
|
+
sarif: { type: 'boolean' },
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(`${TOOL_NAME}: ${/** @type {Error} */ (error).message}`)
|
|
143
|
+
console.error(`Try '${TOOL_NAME} --help'.`)
|
|
144
|
+
return 2
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { values, positionals } = parsed
|
|
148
|
+
|
|
149
|
+
if (values.help) {
|
|
150
|
+
console.log(USAGE)
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
if (values.version) {
|
|
154
|
+
console.log(`${TOOL_NAME} ${TOOL_VERSION}`)
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
if (positionals.length === 0) {
|
|
158
|
+
console.error(USAGE)
|
|
159
|
+
return 2
|
|
160
|
+
}
|
|
161
|
+
if (positionals.length > 1) {
|
|
162
|
+
console.error(`${TOOL_NAME}: expected exactly one <file-or-dir> argument.`)
|
|
163
|
+
console.error(`Try '${TOOL_NAME} --help'.`)
|
|
164
|
+
return 2
|
|
165
|
+
}
|
|
166
|
+
if (values.json && values.sarif) {
|
|
167
|
+
console.error(`${TOOL_NAME}: choose either --json or --sarif, not both.`)
|
|
168
|
+
return 2
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const targetArg = positionals[0]
|
|
172
|
+
/** @type {string|null} */
|
|
173
|
+
let manifestPath = null
|
|
174
|
+
/** @type {string[]} */
|
|
175
|
+
let declared = []
|
|
176
|
+
|
|
177
|
+
if (values.manifest !== undefined) {
|
|
178
|
+
if (!values.manifest) {
|
|
179
|
+
console.error(`${TOOL_NAME}: --manifest requires a non-empty path.`)
|
|
180
|
+
return 2
|
|
181
|
+
}
|
|
182
|
+
manifestPath = resolve(values.manifest)
|
|
183
|
+
let text
|
|
184
|
+
try {
|
|
185
|
+
text = await readFile(manifestPath, 'utf8')
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const detail =
|
|
188
|
+
/** @type {NodeJS.ErrnoException} */ (error).code ?? /** @type {Error} */ (error).message
|
|
189
|
+
console.error(`${TOOL_NAME}: cannot read manifest "${values.manifest}": ${detail}`)
|
|
190
|
+
return 2
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let manifest
|
|
194
|
+
try {
|
|
195
|
+
manifest = JSON.parse(text)
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.error(
|
|
198
|
+
`${TOOL_NAME}: manifest "${values.manifest}" is not valid JSON: ${/** @type {Error} */ (error).message}`,
|
|
199
|
+
)
|
|
200
|
+
return 2
|
|
201
|
+
}
|
|
202
|
+
declared = parseDeclared(manifest)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let report
|
|
206
|
+
try {
|
|
207
|
+
report = await scanTarget(targetArg)
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const err = /** @type {NodeJS.ErrnoException} */ (error)
|
|
210
|
+
const detail = err.code === 'ENOENT' ? 'path does not exist' : err.message
|
|
211
|
+
console.error(`${TOOL_NAME}: cannot scan "${targetArg}": ${detail}`)
|
|
212
|
+
return 2
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const diff = diffCapabilities(declared, report.capabilities)
|
|
216
|
+
const findings = buildFindings(diff, report.firstEvidence, {
|
|
217
|
+
manifestFile: manifestPath ?? undefined,
|
|
218
|
+
target: report.target,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
if (values.sarif) {
|
|
222
|
+
const sarif = toSarif(findings, { file: manifestPath ?? undefined })
|
|
223
|
+
console.log(JSON.stringify(sarif, null, 2))
|
|
224
|
+
return 0
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const result = {
|
|
228
|
+
command: 'scan',
|
|
229
|
+
ok: true,
|
|
230
|
+
tool: TOOL_NAME,
|
|
231
|
+
version: TOOL_VERSION,
|
|
232
|
+
target: report.target,
|
|
233
|
+
manifest: manifestPath,
|
|
234
|
+
filesScanned: report.files.length,
|
|
235
|
+
declared,
|
|
236
|
+
actual: report.capabilities,
|
|
237
|
+
diff,
|
|
238
|
+
findings,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (values.json) {
|
|
242
|
+
console.log(JSON.stringify(result, null, 2))
|
|
243
|
+
return 0
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(formatHuman(result))
|
|
247
|
+
return 0
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main()
|
|
251
|
+
.then((code) => {
|
|
252
|
+
process.exitCode = code
|
|
253
|
+
})
|
|
254
|
+
.catch((error) => {
|
|
255
|
+
console.error(`${TOOL_NAME}: unexpected failure: ${error?.stack ?? error}`)
|
|
256
|
+
process.exitCode = 2
|
|
257
|
+
})
|