shakerscan-cli 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/README.md +40 -0
- package/dist/index.js +669 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Shaker CLI
|
|
2
|
+
|
|
3
|
+
First-party command-line interface for the Shaker Scan control plane.
|
|
4
|
+
|
|
5
|
+
Install from npm:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g shakerscan-cli
|
|
9
|
+
shaker --help
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or run it without installing:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx shakerscan-cli --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Current commands:
|
|
19
|
+
|
|
20
|
+
- `usage`
|
|
21
|
+
- `scan submit`
|
|
22
|
+
- `scan status`
|
|
23
|
+
- `scan wait`
|
|
24
|
+
- `findings`
|
|
25
|
+
- `verify`
|
|
26
|
+
- `policy eval`
|
|
27
|
+
- `evidence get`
|
|
28
|
+
- `approval-token issue`
|
|
29
|
+
- `approval-token verify`
|
|
30
|
+
- `remediation request`
|
|
31
|
+
- `remediation get`
|
|
32
|
+
- `gate`
|
|
33
|
+
|
|
34
|
+
Local usage from this repo:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd scanner-app
|
|
38
|
+
npm run build:cli
|
|
39
|
+
npm run cli -- gate --target https://example.com --scan-type preview --environment preview --policy-pack preview-fast --approval-token true
|
|
40
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
class CliError extends Error {
|
|
3
|
+
exitCode;
|
|
4
|
+
constructor(message, exitCode = 1) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'CliError';
|
|
7
|
+
this.exitCode = exitCode;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
class HttpError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
body;
|
|
13
|
+
constructor(status, body) {
|
|
14
|
+
super(`HTTP ${status}: ${body}`);
|
|
15
|
+
this.name = 'HttpError';
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.body = body;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
class ShakerClient {
|
|
21
|
+
baseUrl;
|
|
22
|
+
apiKey;
|
|
23
|
+
constructor(baseUrl, apiKey) {
|
|
24
|
+
this.baseUrl = baseUrl;
|
|
25
|
+
this.apiKey = apiKey;
|
|
26
|
+
}
|
|
27
|
+
get headers() {
|
|
28
|
+
return {
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
'X-API-Key': this.apiKey,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async request(path, init) {
|
|
34
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
35
|
+
...init,
|
|
36
|
+
headers: {
|
|
37
|
+
...this.headers,
|
|
38
|
+
...(init?.headers || {}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new HttpError(response.status, await response.text());
|
|
43
|
+
}
|
|
44
|
+
return (await response.json());
|
|
45
|
+
}
|
|
46
|
+
submitScan(target, input) {
|
|
47
|
+
return this.request('/api/v1/scan', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
target,
|
|
51
|
+
scan_type: input.scanType,
|
|
52
|
+
options: input.options || {},
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
getScan(scanId) {
|
|
57
|
+
return this.request(`/api/v1/scan?id=${encodeURIComponent(scanId)}`);
|
|
58
|
+
}
|
|
59
|
+
getFindings(scanId, severity, limit = 200, offset = 0) {
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
scan_id: scanId,
|
|
62
|
+
limit: String(limit),
|
|
63
|
+
offset: String(offset),
|
|
64
|
+
});
|
|
65
|
+
if (severity) {
|
|
66
|
+
params.set('severity', severity);
|
|
67
|
+
}
|
|
68
|
+
return this.request(`/api/v1/findings?${params.toString()}`);
|
|
69
|
+
}
|
|
70
|
+
verifyFinding(findingId) {
|
|
71
|
+
return this.request(`/api/v1/findings/${encodeURIComponent(findingId)}/verify`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
evaluatePolicy(scanId, environment, policyPack) {
|
|
76
|
+
return this.request('/api/v1/policy/evaluate', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
body: JSON.stringify({ scan_id: scanId, environment, policy_pack: policyPack }),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
getEvidence(evidenceId) {
|
|
82
|
+
return this.request(`/api/v1/evidence/${encodeURIComponent(evidenceId)}`);
|
|
83
|
+
}
|
|
84
|
+
issueApprovalToken(evidenceId, options) {
|
|
85
|
+
return this.request(`/api/v1/evidence/${encodeURIComponent(evidenceId)}/token`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
ttl_seconds: options.ttlSeconds,
|
|
89
|
+
audience: options.audience,
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
verifyApprovalToken(token, audience) {
|
|
94
|
+
return this.request('/api/v1/approval-tokens/verify', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
token,
|
|
98
|
+
audience,
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
requestRemediation(findingId, options) {
|
|
103
|
+
return this.request(`/api/v1/findings/${encodeURIComponent(findingId)}/remediate`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
policy_evaluation_id: options.policyEvaluationId,
|
|
107
|
+
notes: options.notes,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
getRemediation(remediationJobId) {
|
|
112
|
+
return this.request(`/api/v1/remediation/${encodeURIComponent(remediationJobId)}`);
|
|
113
|
+
}
|
|
114
|
+
getUsage() {
|
|
115
|
+
return this.request('/api/v1/usage');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function parseArgs(argv) {
|
|
119
|
+
const positionals = [];
|
|
120
|
+
const flags = new Map();
|
|
121
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
122
|
+
const token = argv[index];
|
|
123
|
+
if (!token.startsWith('--')) {
|
|
124
|
+
positionals.push(token);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const flag = token.slice(2);
|
|
128
|
+
const next = argv[index + 1];
|
|
129
|
+
if (!next || next.startsWith('--')) {
|
|
130
|
+
flags.set(flag, true);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
flags.set(flag, next);
|
|
134
|
+
index += 1;
|
|
135
|
+
}
|
|
136
|
+
return { positionals, flags };
|
|
137
|
+
}
|
|
138
|
+
function getFlag(args, name) {
|
|
139
|
+
return args.flags.get(name);
|
|
140
|
+
}
|
|
141
|
+
function getString(args, name, fallback) {
|
|
142
|
+
const value = getFlag(args, name);
|
|
143
|
+
if (typeof value === 'string')
|
|
144
|
+
return value;
|
|
145
|
+
if (value === true)
|
|
146
|
+
return 'true';
|
|
147
|
+
return fallback;
|
|
148
|
+
}
|
|
149
|
+
function getBoolean(args, name, fallback = false) {
|
|
150
|
+
const value = getFlag(args, name);
|
|
151
|
+
if (value === undefined)
|
|
152
|
+
return fallback;
|
|
153
|
+
if (typeof value === 'boolean')
|
|
154
|
+
return value;
|
|
155
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
156
|
+
}
|
|
157
|
+
function hasFlag(args, name) {
|
|
158
|
+
return args.flags.has(name);
|
|
159
|
+
}
|
|
160
|
+
function getOptionalBoolean(args, name) {
|
|
161
|
+
if (!hasFlag(args, name))
|
|
162
|
+
return undefined;
|
|
163
|
+
return getBoolean(args, name);
|
|
164
|
+
}
|
|
165
|
+
function getNumber(args, name, fallback) {
|
|
166
|
+
const value = getString(args, name);
|
|
167
|
+
if (value === undefined)
|
|
168
|
+
return fallback;
|
|
169
|
+
const parsed = Number(value);
|
|
170
|
+
if (Number.isNaN(parsed)) {
|
|
171
|
+
throw new CliError(`Invalid value for --${name}: ${value}`);
|
|
172
|
+
}
|
|
173
|
+
return parsed;
|
|
174
|
+
}
|
|
175
|
+
function requireString(args, name) {
|
|
176
|
+
const value = getString(args, name);
|
|
177
|
+
if (!value) {
|
|
178
|
+
throw new CliError(`Missing required flag --${name}`);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
function resolveClient(args) {
|
|
183
|
+
const apiKey = getString(args, 'api-key') || process.env.SHAKER_API_KEY || process.env.SCANNER_API_KEY;
|
|
184
|
+
if (!apiKey) {
|
|
185
|
+
throw new CliError('Set SHAKER_API_KEY or pass --api-key');
|
|
186
|
+
}
|
|
187
|
+
const baseUrl = getString(args, 'base-url') ||
|
|
188
|
+
process.env.SHAKER_BASE_URL ||
|
|
189
|
+
process.env.SCANNER_API_URL ||
|
|
190
|
+
'https://shakerscan.com';
|
|
191
|
+
return new ShakerClient(baseUrl.replace(/\/$/, ''), apiKey);
|
|
192
|
+
}
|
|
193
|
+
const VALID_SCAN_TYPES = ['sandbox', 'preview', 'standard', 'complete', 'full'];
|
|
194
|
+
function getScanTypeArg(args, fallback) {
|
|
195
|
+
const value = getString(args, 'scan-type');
|
|
196
|
+
if (value === undefined)
|
|
197
|
+
return fallback;
|
|
198
|
+
const normalized = value.trim().toLowerCase();
|
|
199
|
+
if (!VALID_SCAN_TYPES.includes(normalized)) {
|
|
200
|
+
throw new CliError(`Invalid --scan-type. Use one of: ${VALID_SCAN_TYPES.join(', ')}`);
|
|
201
|
+
}
|
|
202
|
+
return normalized;
|
|
203
|
+
}
|
|
204
|
+
function buildScanOptions(args) {
|
|
205
|
+
const options = {};
|
|
206
|
+
const quick = getOptionalBoolean(args, 'quick');
|
|
207
|
+
const isPublic = getOptionalBoolean(args, 'public');
|
|
208
|
+
const active = getOptionalBoolean(args, 'active');
|
|
209
|
+
if (quick !== undefined)
|
|
210
|
+
options.quick = quick;
|
|
211
|
+
if (isPublic !== undefined)
|
|
212
|
+
options.public = isPublic;
|
|
213
|
+
if (active !== undefined)
|
|
214
|
+
options.active = active;
|
|
215
|
+
return options;
|
|
216
|
+
}
|
|
217
|
+
function sleep(ms) {
|
|
218
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
219
|
+
}
|
|
220
|
+
function severityCounts(findings) {
|
|
221
|
+
const counts = {
|
|
222
|
+
critical: 0,
|
|
223
|
+
high: 0,
|
|
224
|
+
medium: 0,
|
|
225
|
+
low: 0,
|
|
226
|
+
info: 0,
|
|
227
|
+
};
|
|
228
|
+
for (const finding of findings) {
|
|
229
|
+
counts[finding.severity] += 1;
|
|
230
|
+
}
|
|
231
|
+
return counts;
|
|
232
|
+
}
|
|
233
|
+
function print(data) {
|
|
234
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
235
|
+
}
|
|
236
|
+
function shellQuote(value) {
|
|
237
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
238
|
+
}
|
|
239
|
+
function formatRemediationHandoff(remediation, format) {
|
|
240
|
+
const prDraft = remediation.artifact.pr_draft;
|
|
241
|
+
const patchSuggestion = remediation.artifact.patch_suggestion;
|
|
242
|
+
if (format === 'shell') {
|
|
243
|
+
return [
|
|
244
|
+
`git checkout -b ${shellQuote(prDraft.branch_name)}`,
|
|
245
|
+
'',
|
|
246
|
+
'# Apply the suggested changes below, then open a PR:',
|
|
247
|
+
...patchSuggestion.changes.map((entry) => `# - ${entry}`),
|
|
248
|
+
'',
|
|
249
|
+
`gh pr create --title ${shellQuote(prDraft.title)} --body ${shellQuote(prDraft.body_markdown)}`,
|
|
250
|
+
].join('\n');
|
|
251
|
+
}
|
|
252
|
+
return [
|
|
253
|
+
`# ${prDraft.title}`,
|
|
254
|
+
'',
|
|
255
|
+
`Branch: \`${prDraft.branch_name}\``,
|
|
256
|
+
remediation.artifact.target_url ? `Target: ${remediation.artifact.target_url}` : '',
|
|
257
|
+
'',
|
|
258
|
+
`## Summary`,
|
|
259
|
+
prDraft.summary,
|
|
260
|
+
'',
|
|
261
|
+
`## Patch suggestion`,
|
|
262
|
+
patchSuggestion.approach,
|
|
263
|
+
'',
|
|
264
|
+
`### Candidate locations`,
|
|
265
|
+
...patchSuggestion.candidate_locations.map((entry) => `- ${entry}`),
|
|
266
|
+
'',
|
|
267
|
+
`### Example changes`,
|
|
268
|
+
...patchSuggestion.changes.map((entry) => `- ${entry}`),
|
|
269
|
+
'',
|
|
270
|
+
prDraft.body_markdown,
|
|
271
|
+
]
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.join('\n');
|
|
274
|
+
}
|
|
275
|
+
function nextStep(decision) {
|
|
276
|
+
switch (decision) {
|
|
277
|
+
case 'allow':
|
|
278
|
+
return 'continue';
|
|
279
|
+
case 'block':
|
|
280
|
+
return 'fix_and_retry';
|
|
281
|
+
default:
|
|
282
|
+
return 'request_approval_or_fix';
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function fallbackDecision(findings) {
|
|
286
|
+
const counts = severityCounts(findings);
|
|
287
|
+
if (counts.critical > 0)
|
|
288
|
+
return 'block';
|
|
289
|
+
if (counts.high > 0)
|
|
290
|
+
return 'needs_approval';
|
|
291
|
+
return 'allow';
|
|
292
|
+
}
|
|
293
|
+
async function waitForCompletion(client, scanId, intervalSeconds, timeoutSeconds) {
|
|
294
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
295
|
+
while (Date.now() < deadline) {
|
|
296
|
+
const scan = await client.getScan(scanId);
|
|
297
|
+
if (scan.status === 'completed' || scan.status === 'failed') {
|
|
298
|
+
return scan;
|
|
299
|
+
}
|
|
300
|
+
await sleep(intervalSeconds * 1000);
|
|
301
|
+
}
|
|
302
|
+
throw new CliError(`Timed out waiting for scan ${scanId}`, 1);
|
|
303
|
+
}
|
|
304
|
+
async function handleUsage(args) {
|
|
305
|
+
const client = resolveClient(args);
|
|
306
|
+
print(await client.getUsage());
|
|
307
|
+
}
|
|
308
|
+
async function handleScan(args) {
|
|
309
|
+
const client = resolveClient(args);
|
|
310
|
+
const subcommand = args.positionals[1];
|
|
311
|
+
if (subcommand === 'submit') {
|
|
312
|
+
const target = requireString(args, 'target');
|
|
313
|
+
print(await client.submitScan(target, {
|
|
314
|
+
scanType: getScanTypeArg(args),
|
|
315
|
+
options: buildScanOptions(args),
|
|
316
|
+
}));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (subcommand === 'status') {
|
|
320
|
+
print(await client.getScan(requireString(args, 'scan-id')));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (subcommand === 'wait') {
|
|
324
|
+
const scanId = requireString(args, 'scan-id');
|
|
325
|
+
print(await waitForCompletion(client, scanId, getNumber(args, 'interval', 5), getNumber(args, 'timeout', 300)));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
throw new CliError('Usage: shaker scan <submit|status|wait> [flags]');
|
|
329
|
+
}
|
|
330
|
+
async function handleFindings(args) {
|
|
331
|
+
const client = resolveClient(args);
|
|
332
|
+
print(await client.getFindings(requireString(args, 'scan-id'), getString(args, 'severity'), getNumber(args, 'limit', 200), getNumber(args, 'offset', 0)));
|
|
333
|
+
}
|
|
334
|
+
async function handleVerify(args) {
|
|
335
|
+
const client = resolveClient(args);
|
|
336
|
+
print(await client.verifyFinding(requireString(args, 'finding-id')));
|
|
337
|
+
}
|
|
338
|
+
async function handlePolicy(args) {
|
|
339
|
+
const client = resolveClient(args);
|
|
340
|
+
const subcommand = args.positionals[1];
|
|
341
|
+
if (subcommand !== 'eval') {
|
|
342
|
+
throw new CliError('Usage: shaker policy eval --scan-id <id> [--environment preview] [--policy-pack default]');
|
|
343
|
+
}
|
|
344
|
+
print(await client.evaluatePolicy(requireString(args, 'scan-id'), getString(args, 'environment', 'preview') || 'preview', getString(args, 'policy-pack')));
|
|
345
|
+
}
|
|
346
|
+
async function handleEvidence(args) {
|
|
347
|
+
const client = resolveClient(args);
|
|
348
|
+
const subcommand = args.positionals[1];
|
|
349
|
+
if (subcommand !== 'get') {
|
|
350
|
+
throw new CliError('Usage: shaker evidence get --evidence-id <id>');
|
|
351
|
+
}
|
|
352
|
+
print(await client.getEvidence(requireString(args, 'evidence-id')));
|
|
353
|
+
}
|
|
354
|
+
async function handleApprovalToken(args) {
|
|
355
|
+
const client = resolveClient(args);
|
|
356
|
+
const subcommand = args.positionals[1];
|
|
357
|
+
if (subcommand === 'issue') {
|
|
358
|
+
print(await client.issueApprovalToken(requireString(args, 'evidence-id'), {
|
|
359
|
+
ttlSeconds: getNumber(args, 'ttl', 900),
|
|
360
|
+
audience: getString(args, 'audience'),
|
|
361
|
+
}));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (subcommand === 'verify') {
|
|
365
|
+
print(await client.verifyApprovalToken(requireString(args, 'token'), getString(args, 'audience')));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
throw new CliError('Usage: shaker approval-token <issue|verify> [--evidence-id <id>] [--token <token>]');
|
|
369
|
+
}
|
|
370
|
+
async function handleRemediation(args) {
|
|
371
|
+
const client = resolveClient(args);
|
|
372
|
+
const subcommand = args.positionals[1];
|
|
373
|
+
if (subcommand === 'request') {
|
|
374
|
+
print(await client.requestRemediation(requireString(args, 'finding-id'), {
|
|
375
|
+
policyEvaluationId: getString(args, 'policy-evaluation-id'),
|
|
376
|
+
notes: getString(args, 'notes'),
|
|
377
|
+
}));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (subcommand === 'get') {
|
|
381
|
+
print(await client.getRemediation(requireString(args, 'remediation-job-id')));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (subcommand === 'handoff') {
|
|
385
|
+
const remediation = await client.getRemediation(requireString(args, 'remediation-job-id'));
|
|
386
|
+
const format = getString(args, 'format', 'markdown');
|
|
387
|
+
if (format !== 'markdown' && format !== 'shell') {
|
|
388
|
+
throw new CliError('Invalid --format. Use markdown or shell.');
|
|
389
|
+
}
|
|
390
|
+
process.stdout.write(`${formatRemediationHandoff(remediation, format)}\n`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
throw new CliError('Usage: shaker remediation <request|get|handoff> [flags]');
|
|
394
|
+
}
|
|
395
|
+
async function handleGate(args) {
|
|
396
|
+
const client = resolveClient(args);
|
|
397
|
+
const target = getString(args, 'target');
|
|
398
|
+
const existingScanId = getString(args, 'scan-id');
|
|
399
|
+
if (!target && !existingScanId) {
|
|
400
|
+
throw new CliError('Pass --target or --scan-id to gate');
|
|
401
|
+
}
|
|
402
|
+
let scan;
|
|
403
|
+
if (existingScanId) {
|
|
404
|
+
scan = await waitForCompletion(client, existingScanId, getNumber(args, 'interval', 5), getNumber(args, 'timeout', 300));
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const submitted = await client.submitScan(target, {
|
|
408
|
+
scanType: getScanTypeArg(args, 'preview'),
|
|
409
|
+
options: buildScanOptions(args),
|
|
410
|
+
});
|
|
411
|
+
scan = await waitForCompletion(client, submitted.scan_id, getNumber(args, 'interval', 5), getNumber(args, 'timeout', 300));
|
|
412
|
+
}
|
|
413
|
+
if (scan.status !== 'completed') {
|
|
414
|
+
const result = {
|
|
415
|
+
status: 'error',
|
|
416
|
+
scan_id: scan.scan_id,
|
|
417
|
+
reason: `scan_${scan.status}`,
|
|
418
|
+
};
|
|
419
|
+
print(result);
|
|
420
|
+
process.exitCode = 1;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const findingsResponse = await client.getFindings(scan.scan_id, undefined, getNumber(args, 'limit', 200), 0);
|
|
424
|
+
const findings = findingsResponse.findings;
|
|
425
|
+
const counts = severityCounts(findings);
|
|
426
|
+
const verifySet = new Set((getString(args, 'verify-severities', 'critical,high') || 'critical,high')
|
|
427
|
+
.split(',')
|
|
428
|
+
.map((value) => value.trim())
|
|
429
|
+
.filter(Boolean));
|
|
430
|
+
const verificationCandidates = findings.filter((finding) => verifySet.has(finding.severity));
|
|
431
|
+
const verifications = [];
|
|
432
|
+
const verificationErrors = [];
|
|
433
|
+
for (const finding of verificationCandidates) {
|
|
434
|
+
try {
|
|
435
|
+
verifications.push(await client.verifyFinding(finding.id));
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
verificationErrors.push({
|
|
439
|
+
finding_id: finding.id,
|
|
440
|
+
error: error instanceof Error ? error.message : 'Unknown verification error',
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
let policy = null;
|
|
445
|
+
let evidence = null;
|
|
446
|
+
let approvalToken = null;
|
|
447
|
+
let decisionSource = 'policy_api';
|
|
448
|
+
let decision;
|
|
449
|
+
let rationale;
|
|
450
|
+
try {
|
|
451
|
+
policy = await client.evaluatePolicy(scan.scan_id, getString(args, 'environment', 'preview') || 'preview', getString(args, 'policy-pack'));
|
|
452
|
+
evidence = await client.getEvidence(policy.evidence_id);
|
|
453
|
+
decision = policy.decision;
|
|
454
|
+
rationale = policy.rationale;
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (!(error instanceof HttpError) || (error.status !== 403 && error.status !== 404)) {
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
decisionSource = 'fallback';
|
|
461
|
+
decision = fallbackDecision(findings);
|
|
462
|
+
rationale =
|
|
463
|
+
decision === 'block'
|
|
464
|
+
? 'Fallback gate blocked because at least one critical finding exists.'
|
|
465
|
+
: decision === 'needs_approval'
|
|
466
|
+
? 'Fallback gate requires approval because high findings exist.'
|
|
467
|
+
: 'Fallback gate allowed because no critical or high findings exist.';
|
|
468
|
+
}
|
|
469
|
+
const remediationLimit = getNumber(args, 'remediation-limit', 1);
|
|
470
|
+
const createRemediation = getBoolean(args, 'remediation', true);
|
|
471
|
+
const remediationJobs = [];
|
|
472
|
+
if (createRemediation && remediationLimit > 0 && decision !== 'allow') {
|
|
473
|
+
const candidates = findings
|
|
474
|
+
.filter((finding) => finding.severity === 'critical' || finding.severity === 'high')
|
|
475
|
+
.slice(0, remediationLimit);
|
|
476
|
+
for (const finding of candidates) {
|
|
477
|
+
try {
|
|
478
|
+
remediationJobs.push(await client.requestRemediation(finding.id, {
|
|
479
|
+
policyEvaluationId: policy?.evaluation_id,
|
|
480
|
+
notes: getString(args, 'remediation-notes', `CLI gate for ${scan.scan_id}`),
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
verificationErrors.push({
|
|
485
|
+
finding_id: finding.id,
|
|
486
|
+
error: error instanceof Error ? error.message : 'Unknown remediation error',
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (getBoolean(args, 'approval-token', false) &&
|
|
492
|
+
decision === 'allow' &&
|
|
493
|
+
policy?.evidence_id &&
|
|
494
|
+
evidence?.approval_token?.supported &&
|
|
495
|
+
evidence.approval_token.eligible) {
|
|
496
|
+
approvalToken = await client.issueApprovalToken(policy.evidence_id, {
|
|
497
|
+
ttlSeconds: getNumber(args, 'approval-token-ttl', 900),
|
|
498
|
+
audience: getString(args, 'approval-token-audience'),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const summary = {
|
|
502
|
+
command: 'gate',
|
|
503
|
+
decision,
|
|
504
|
+
decision_source: decisionSource,
|
|
505
|
+
next_step: nextStep(decision),
|
|
506
|
+
scan_id: scan.scan_id,
|
|
507
|
+
target: scan.target,
|
|
508
|
+
scan_status: scan.status,
|
|
509
|
+
environment: getString(args, 'environment', 'preview'),
|
|
510
|
+
policy_pack: getString(args, 'policy-pack', 'default'),
|
|
511
|
+
findings: {
|
|
512
|
+
total: findingsResponse.summary.total,
|
|
513
|
+
by_severity: counts,
|
|
514
|
+
},
|
|
515
|
+
verifications: {
|
|
516
|
+
attempted: verificationCandidates.length,
|
|
517
|
+
completed: verifications.length,
|
|
518
|
+
errors: verificationErrors,
|
|
519
|
+
artifacts: verifications.map((artifact) => ({
|
|
520
|
+
verification_id: artifact.verification_id,
|
|
521
|
+
finding_id: artifact.finding_id,
|
|
522
|
+
status: artifact.status,
|
|
523
|
+
confidence: artifact.confidence,
|
|
524
|
+
})),
|
|
525
|
+
},
|
|
526
|
+
policy: policy
|
|
527
|
+
? {
|
|
528
|
+
evaluation_id: policy.evaluation_id,
|
|
529
|
+
policy_pack: policy.policy_pack || null,
|
|
530
|
+
evidence_id: policy.evidence_id,
|
|
531
|
+
decision: policy.decision,
|
|
532
|
+
rationale: policy.rationale,
|
|
533
|
+
approval_token: policy.approval_token || null,
|
|
534
|
+
}
|
|
535
|
+
: {
|
|
536
|
+
evaluation_id: null,
|
|
537
|
+
evidence_id: null,
|
|
538
|
+
decision,
|
|
539
|
+
rationale,
|
|
540
|
+
},
|
|
541
|
+
evidence: evidence
|
|
542
|
+
? {
|
|
543
|
+
id: evidence.id,
|
|
544
|
+
kind: evidence.kind,
|
|
545
|
+
evidence_hash: evidence.evidence_hash,
|
|
546
|
+
approval_token: evidence.approval_token || null,
|
|
547
|
+
exception: evidence.exception || null,
|
|
548
|
+
}
|
|
549
|
+
: null,
|
|
550
|
+
approval_token: approvalToken
|
|
551
|
+
? {
|
|
552
|
+
token_type: approvalToken.token_type,
|
|
553
|
+
token: approvalToken.token,
|
|
554
|
+
issued_at: approvalToken.issued_at,
|
|
555
|
+
expires_at: approvalToken.expires_at,
|
|
556
|
+
audience: approvalToken.audience,
|
|
557
|
+
evidence_id: approvalToken.evidence_id,
|
|
558
|
+
}
|
|
559
|
+
: null,
|
|
560
|
+
remediation: remediationJobs.map((job) => ({
|
|
561
|
+
remediation_job_id: job.remediation_job_id,
|
|
562
|
+
finding_id: job.finding_id,
|
|
563
|
+
status: job.status,
|
|
564
|
+
artifact_hash: job.artifact_hash,
|
|
565
|
+
family: job.artifact.family,
|
|
566
|
+
patch_suggestion: {
|
|
567
|
+
approach: job.artifact.patch_suggestion.approach,
|
|
568
|
+
candidate_locations: job.artifact.patch_suggestion.candidate_locations,
|
|
569
|
+
},
|
|
570
|
+
pr_draft: {
|
|
571
|
+
title: job.artifact.pr_draft.title,
|
|
572
|
+
branch_name: job.artifact.pr_draft.branch_name,
|
|
573
|
+
summary: job.artifact.pr_draft.summary,
|
|
574
|
+
},
|
|
575
|
+
})),
|
|
576
|
+
};
|
|
577
|
+
print(summary);
|
|
578
|
+
process.exitCode = decision === 'allow' ? 0 : decision === 'block' ? 10 : 20;
|
|
579
|
+
}
|
|
580
|
+
function help() {
|
|
581
|
+
const output = [
|
|
582
|
+
'Shaker CLI',
|
|
583
|
+
'',
|
|
584
|
+
'Environment:',
|
|
585
|
+
' SHAKER_API_KEY or SCANNER_API_KEY',
|
|
586
|
+
' SHAKER_BASE_URL or SCANNER_API_URL (default https://shakerscan.com)',
|
|
587
|
+
'',
|
|
588
|
+
'Commands:',
|
|
589
|
+
' shaker usage',
|
|
590
|
+
' shaker scan submit --target <url> [--scan-type preview|sandbox|standard|complete|full]',
|
|
591
|
+
' shaker scan status --scan-id <id>',
|
|
592
|
+
' shaker scan wait --scan-id <id> [--interval 5] [--timeout 300]',
|
|
593
|
+
' shaker findings --scan-id <id> [--severity high] [--limit 200]',
|
|
594
|
+
' shaker verify --finding-id <id>',
|
|
595
|
+
' shaker policy eval --scan-id <id> [--environment preview] [--policy-pack default]',
|
|
596
|
+
' shaker evidence get --evidence-id <id>',
|
|
597
|
+
' shaker approval-token issue --evidence-id <id> [--ttl 900] [--audience github-actions]',
|
|
598
|
+
' shaker approval-token verify --token <token> [--audience github-actions]',
|
|
599
|
+
' shaker remediation request --finding-id <id> [--policy-evaluation-id <id>] [--notes text]',
|
|
600
|
+
' shaker remediation get --remediation-job-id <id>',
|
|
601
|
+
' shaker remediation handoff --remediation-job-id <id> [--format markdown|shell]',
|
|
602
|
+
' shaker gate --target <url> [--scan-type preview|sandbox|standard|complete|full] [--environment preview] [--policy-pack default] [--approval-token true]',
|
|
603
|
+
'',
|
|
604
|
+
'Gate exit codes:',
|
|
605
|
+
' 0 allow',
|
|
606
|
+
' 10 block',
|
|
607
|
+
' 20 needs_approval',
|
|
608
|
+
' 1 error',
|
|
609
|
+
];
|
|
610
|
+
process.stdout.write(`${output.join('\n')}\n`);
|
|
611
|
+
}
|
|
612
|
+
async function main() {
|
|
613
|
+
const args = parseArgs(process.argv.slice(2));
|
|
614
|
+
const command = args.positionals[0];
|
|
615
|
+
if (!command || command === 'help' || getBoolean(args, 'help')) {
|
|
616
|
+
help();
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (command === 'usage') {
|
|
620
|
+
await handleUsage(args);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (command === 'scan') {
|
|
624
|
+
await handleScan(args);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (command === 'findings') {
|
|
628
|
+
await handleFindings(args);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (command === 'verify') {
|
|
632
|
+
await handleVerify(args);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (command === 'policy') {
|
|
636
|
+
await handlePolicy(args);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (command === 'evidence') {
|
|
640
|
+
await handleEvidence(args);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (command === 'approval-token') {
|
|
644
|
+
await handleApprovalToken(args);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (command === 'remediation') {
|
|
648
|
+
await handleRemediation(args);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (command === 'gate') {
|
|
652
|
+
await handleGate(args);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
throw new CliError(`Unknown command: ${command}`);
|
|
656
|
+
}
|
|
657
|
+
main().catch((error) => {
|
|
658
|
+
if (error instanceof CliError) {
|
|
659
|
+
process.stderr.write(`${error.message}\n`);
|
|
660
|
+
process.exit(error.exitCode);
|
|
661
|
+
}
|
|
662
|
+
if (error instanceof HttpError) {
|
|
663
|
+
process.stderr.write(`${error.message}\n`);
|
|
664
|
+
process.exit(1);
|
|
665
|
+
}
|
|
666
|
+
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`);
|
|
667
|
+
process.exit(1);
|
|
668
|
+
});
|
|
669
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shakerscan-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "First-party CLI for the ShakerScan security control plane",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"shaker": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsc --watch",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"shakerscan",
|
|
18
|
+
"security",
|
|
19
|
+
"scanner",
|
|
20
|
+
"dast",
|
|
21
|
+
"cli",
|
|
22
|
+
"devsecops"
|
|
23
|
+
],
|
|
24
|
+
"author": "ShakerScan",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"homepage": "https://shakerscan.com",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/andriyze/scanner"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/andriyze/scanner/issues"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^20",
|
|
46
|
+
"typescript": "^5"
|
|
47
|
+
}
|
|
48
|
+
}
|