@ship-safe/cli 1.1.8 → 1.1.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.
- package/dist/index.js +170 -57
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -14,9 +14,65 @@ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync
|
|
|
14
14
|
import { homedir as homedir3 } from "os";
|
|
15
15
|
import chalk5 from "chalk";
|
|
16
16
|
import ora2 from "ora";
|
|
17
|
-
import { select } from "@inquirer/prompts";
|
|
17
|
+
import { select, confirm } from "@inquirer/prompts";
|
|
18
18
|
import { parse as parseYaml } from "yaml";
|
|
19
19
|
|
|
20
|
+
// ../../packages/scanner/dist/chunk-C2F2TUFE.js
|
|
21
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
22
|
+
var clients = /* @__PURE__ */ new Map();
|
|
23
|
+
function getClient(apiKey) {
|
|
24
|
+
let client = clients.get(apiKey);
|
|
25
|
+
if (!client) {
|
|
26
|
+
client = new Anthropic({ apiKey, maxRetries: 2 });
|
|
27
|
+
clients.set(apiKey, client);
|
|
28
|
+
}
|
|
29
|
+
return client;
|
|
30
|
+
}
|
|
31
|
+
async function callClaude(apiKey, system, prompt, options) {
|
|
32
|
+
const anthropic = getClient(apiKey);
|
|
33
|
+
const maxTokens = options?.maxTokens ?? 4096;
|
|
34
|
+
const response = await anthropic.messages.create(
|
|
35
|
+
{
|
|
36
|
+
model: options?.model ?? "claude-haiku-4-5-20251001",
|
|
37
|
+
max_tokens: maxTokens,
|
|
38
|
+
temperature: 0,
|
|
39
|
+
// Zero temperature for fully deterministic JSON output
|
|
40
|
+
// Use content block format with cache_control for prompt caching.
|
|
41
|
+
// The system prompt is identical across chunks within a scan —
|
|
42
|
+
// caching it saves ~90% on input tokens for subsequent chunk calls.
|
|
43
|
+
system: [
|
|
44
|
+
{
|
|
45
|
+
type: "text",
|
|
46
|
+
text: system,
|
|
47
|
+
cache_control: { type: "ephemeral" }
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
messages: [{ role: "user", content: prompt }]
|
|
51
|
+
},
|
|
52
|
+
{ timeout: 6e4 }
|
|
53
|
+
);
|
|
54
|
+
const truncated = response.stop_reason === "max_tokens";
|
|
55
|
+
if (response.usage) {
|
|
56
|
+
console.log(
|
|
57
|
+
`[LLM] model=${options?.model ?? "haiku"} input=${response.usage.input_tokens} output=${response.usage.output_tokens}` + (response.usage.cache_read_input_tokens ? ` cached=${response.usage.cache_read_input_tokens}` : "")
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (truncated) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`[LLM] Response truncated (hit ${maxTokens} max_tokens). Some findings may be lost.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const textBlock = response.content.find((b) => b.type === "text");
|
|
66
|
+
return {
|
|
67
|
+
text: textBlock?.text ?? "",
|
|
68
|
+
truncated,
|
|
69
|
+
usage: response.usage ? {
|
|
70
|
+
inputTokens: response.usage.input_tokens,
|
|
71
|
+
outputTokens: response.usage.output_tokens
|
|
72
|
+
} : void 0
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
20
76
|
// ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
|
|
21
77
|
var external_exports = {};
|
|
22
78
|
__export(external_exports, {
|
|
@@ -4184,7 +4240,6 @@ var llmResponseSchema = external_exports.object({
|
|
|
4184
4240
|
// ../../packages/scanner/dist/index.js
|
|
4185
4241
|
import path from "path";
|
|
4186
4242
|
import { createHash } from "crypto";
|
|
4187
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
4188
4243
|
function detectLanguage(filePath) {
|
|
4189
4244
|
const ext = path.extname(filePath).toLowerCase();
|
|
4190
4245
|
return LANGUAGE_EXTENSIONS[ext] ?? "unknown";
|
|
@@ -6201,59 +6256,6 @@ function deduplicateFindings(findings) {
|
|
|
6201
6256
|
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
|
|
6202
6257
|
);
|
|
6203
6258
|
}
|
|
6204
|
-
var clients = /* @__PURE__ */ new Map();
|
|
6205
|
-
function getClient(apiKey) {
|
|
6206
|
-
let client = clients.get(apiKey);
|
|
6207
|
-
if (!client) {
|
|
6208
|
-
client = new Anthropic({ apiKey, maxRetries: 2 });
|
|
6209
|
-
clients.set(apiKey, client);
|
|
6210
|
-
}
|
|
6211
|
-
return client;
|
|
6212
|
-
}
|
|
6213
|
-
async function callClaude(apiKey, system, prompt, options) {
|
|
6214
|
-
const anthropic = getClient(apiKey);
|
|
6215
|
-
const maxTokens = options?.maxTokens ?? 4096;
|
|
6216
|
-
const response = await anthropic.messages.create(
|
|
6217
|
-
{
|
|
6218
|
-
model: options?.model ?? "claude-haiku-4-5-20251001",
|
|
6219
|
-
max_tokens: maxTokens,
|
|
6220
|
-
temperature: 0,
|
|
6221
|
-
// Zero temperature for fully deterministic JSON output
|
|
6222
|
-
// Use content block format with cache_control for prompt caching.
|
|
6223
|
-
// The system prompt is identical across chunks within a scan —
|
|
6224
|
-
// caching it saves ~90% on input tokens for subsequent chunk calls.
|
|
6225
|
-
system: [
|
|
6226
|
-
{
|
|
6227
|
-
type: "text",
|
|
6228
|
-
text: system,
|
|
6229
|
-
cache_control: { type: "ephemeral" }
|
|
6230
|
-
}
|
|
6231
|
-
],
|
|
6232
|
-
messages: [{ role: "user", content: prompt }]
|
|
6233
|
-
},
|
|
6234
|
-
{ timeout: 6e4 }
|
|
6235
|
-
);
|
|
6236
|
-
const truncated = response.stop_reason === "max_tokens";
|
|
6237
|
-
if (response.usage) {
|
|
6238
|
-
console.log(
|
|
6239
|
-
`[LLM] model=${options?.model ?? "haiku"} input=${response.usage.input_tokens} output=${response.usage.output_tokens}` + (response.usage.cache_read_input_tokens ? ` cached=${response.usage.cache_read_input_tokens}` : "")
|
|
6240
|
-
);
|
|
6241
|
-
}
|
|
6242
|
-
if (truncated) {
|
|
6243
|
-
console.warn(
|
|
6244
|
-
`[LLM] Response truncated (hit ${maxTokens} max_tokens). Some findings may be lost.`
|
|
6245
|
-
);
|
|
6246
|
-
}
|
|
6247
|
-
const textBlock = response.content.find((b) => b.type === "text");
|
|
6248
|
-
return {
|
|
6249
|
-
text: textBlock?.text ?? "",
|
|
6250
|
-
truncated,
|
|
6251
|
-
usage: response.usage ? {
|
|
6252
|
-
inputTokens: response.usage.input_tokens,
|
|
6253
|
-
outputTokens: response.usage.output_tokens
|
|
6254
|
-
} : void 0
|
|
6255
|
-
};
|
|
6256
|
-
}
|
|
6257
6259
|
var MAX_LINES_PER_CHUNK = 3e3;
|
|
6258
6260
|
var LOGIC_PATTERNS = [
|
|
6259
6261
|
// Route handlers that handle user input (security-sensitive)
|
|
@@ -6397,6 +6399,22 @@ Example of a medium-severity finding:
|
|
|
6397
6399
|
}
|
|
6398
6400
|
]
|
|
6399
6401
|
|
|
6402
|
+
Example of a clean code response (no vulnerabilities found):
|
|
6403
|
+
|
|
6404
|
+
Code analyzed:
|
|
6405
|
+
\`\`\`
|
|
6406
|
+
import { hash } from "bcrypt";
|
|
6407
|
+
import { db } from "./db";
|
|
6408
|
+
|
|
6409
|
+
export async function createUser(email: string, password: string) {
|
|
6410
|
+
const hashed = await hash(password, 12);
|
|
6411
|
+
return db.insert("users", { email, password: hashed });
|
|
6412
|
+
}
|
|
6413
|
+
\`\`\`
|
|
6414
|
+
|
|
6415
|
+
Response:
|
|
6416
|
+
[]
|
|
6417
|
+
|
|
6400
6418
|
If the code has no security issues, respond with: []
|
|
6401
6419
|
|
|
6402
6420
|
You will receive source code wrapped in <user_code_to_analyze> tags. Analyze it for security vulnerabilities. Focus on context-dependent issues that need human-level understanding.
|
|
@@ -6533,10 +6551,15 @@ async function scanWithLLM(files, apiKey, platform) {
|
|
|
6533
6551
|
});
|
|
6534
6552
|
const CONCURRENCY = 5;
|
|
6535
6553
|
const chunkResults = [];
|
|
6554
|
+
const CIRCUIT_BREAKER_THRESHOLD = 3;
|
|
6555
|
+
let consecutiveFailures = 0;
|
|
6556
|
+
let circuitBroken = false;
|
|
6536
6557
|
for (let i = 0; i < sortedChunks.length; i += CONCURRENCY) {
|
|
6558
|
+
if (circuitBroken) break;
|
|
6537
6559
|
const batch = sortedChunks.slice(i, i + CONCURRENCY);
|
|
6538
6560
|
const batchResults = await Promise.all(
|
|
6539
6561
|
batch.map(async (chunk) => {
|
|
6562
|
+
if (circuitBroken) return [];
|
|
6540
6563
|
const prompt = buildAnalysisPrompt(chunk.files, platform);
|
|
6541
6564
|
const systemPrompt = getSystemPrompt(platform);
|
|
6542
6565
|
const chunkFindings = [];
|
|
@@ -6567,6 +6590,7 @@ async function scanWithLLM(files, apiKey, platform) {
|
|
|
6567
6590
|
);
|
|
6568
6591
|
llmFindings = parseResponse(retry.text);
|
|
6569
6592
|
}
|
|
6593
|
+
consecutiveFailures = 0;
|
|
6570
6594
|
for (const lf of llmFindings) {
|
|
6571
6595
|
let matchedPath = lf.file;
|
|
6572
6596
|
let content = fileMap.get(lf.file);
|
|
@@ -6616,16 +6640,42 @@ async function scanWithLLM(files, apiKey, platform) {
|
|
|
6616
6640
|
});
|
|
6617
6641
|
}
|
|
6618
6642
|
} catch (error) {
|
|
6643
|
+
consecutiveFailures++;
|
|
6619
6644
|
console.error(
|
|
6620
|
-
`LLM scan failed for chunk: ${error instanceof Error ? error.message : "unknown"}`
|
|
6645
|
+
`LLM scan failed for chunk (${consecutiveFailures} consecutive): ${error instanceof Error ? error.message : "unknown"}`
|
|
6621
6646
|
);
|
|
6647
|
+
if (consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
6648
|
+
circuitBroken = true;
|
|
6649
|
+
console.warn(
|
|
6650
|
+
`[llm] Circuit breaker tripped: ${consecutiveFailures} consecutive failures. Skipping remaining chunks.`
|
|
6651
|
+
);
|
|
6652
|
+
}
|
|
6622
6653
|
}
|
|
6623
6654
|
return chunkFindings;
|
|
6624
6655
|
})
|
|
6625
6656
|
);
|
|
6626
6657
|
chunkResults.push(...batchResults);
|
|
6627
6658
|
}
|
|
6628
|
-
|
|
6659
|
+
const findings = chunkResults.flat();
|
|
6660
|
+
if (circuitBroken) {
|
|
6661
|
+
findings.push({
|
|
6662
|
+
id: generateFindingId("_circuit_breaker", 0, "llm-partial-results"),
|
|
6663
|
+
ruleId: "llm-circuit-breaker",
|
|
6664
|
+
title: "AI scan returned partial results",
|
|
6665
|
+
description: "The AI scanner encountered multiple consecutive failures and stopped early. Some files were not analyzed. Please retry the scan or check back later.",
|
|
6666
|
+
plainEnglish: "The AI analysis couldn't finish checking all your files due to repeated errors. You may be missing some findings \u2014 try scanning again.",
|
|
6667
|
+
severity: "low",
|
|
6668
|
+
confidence: "high",
|
|
6669
|
+
source: "llm",
|
|
6670
|
+
file: "",
|
|
6671
|
+
line: 0,
|
|
6672
|
+
snippet: "",
|
|
6673
|
+
fix: {
|
|
6674
|
+
description: "Re-run the scan. If the issue persists, the AI service may be temporarily degraded."
|
|
6675
|
+
}
|
|
6676
|
+
});
|
|
6677
|
+
}
|
|
6678
|
+
return findings;
|
|
6629
6679
|
}
|
|
6630
6680
|
function parseTranslations(text) {
|
|
6631
6681
|
const start = text.lastIndexOf("[");
|
|
@@ -6932,6 +6982,7 @@ async function scan(config, onProgress) {
|
|
|
6932
6982
|
summary,
|
|
6933
6983
|
report,
|
|
6934
6984
|
durationMs,
|
|
6985
|
+
platform,
|
|
6935
6986
|
...warnings.length > 0 ? { warnings } : {}
|
|
6936
6987
|
};
|
|
6937
6988
|
}
|
|
@@ -7878,6 +7929,68 @@ async function scanCommand(targetPath, options) {
|
|
|
7878
7929
|
)
|
|
7879
7930
|
);
|
|
7880
7931
|
}
|
|
7932
|
+
if (!options.ci && token && result.findings.length > 0 && options.output === "table") {
|
|
7933
|
+
try {
|
|
7934
|
+
const wantsFixPrompt = await confirm({
|
|
7935
|
+
message: chalk5.bold("Generate an AI fix prompt you can paste into Cursor/Lovable/Bolt?"),
|
|
7936
|
+
default: true
|
|
7937
|
+
});
|
|
7938
|
+
if (wantsFixPrompt) {
|
|
7939
|
+
const fixSpinner = ora2({
|
|
7940
|
+
text: chalk5.dim("Generating fix prompt..."),
|
|
7941
|
+
color: "magenta",
|
|
7942
|
+
spinner: "dots12"
|
|
7943
|
+
}).start();
|
|
7944
|
+
try {
|
|
7945
|
+
const fixRes = await fetch(`${options.apiUrl}/api/cli/fix-prompt`, {
|
|
7946
|
+
method: "POST",
|
|
7947
|
+
headers: {
|
|
7948
|
+
"Content-Type": "application/json",
|
|
7949
|
+
Authorization: `Bearer ${token.token}`
|
|
7950
|
+
},
|
|
7951
|
+
body: JSON.stringify({
|
|
7952
|
+
findings: result.findings.map((f) => ({
|
|
7953
|
+
title: f.title,
|
|
7954
|
+
severity: f.severity,
|
|
7955
|
+
filePath: f.file,
|
|
7956
|
+
lineNumber: f.line,
|
|
7957
|
+
fixDescription: f.fix.description,
|
|
7958
|
+
fixSuggestion: f.fix.suggestion,
|
|
7959
|
+
cwe: f.cwe
|
|
7960
|
+
})),
|
|
7961
|
+
platform: result.platform || "manual"
|
|
7962
|
+
})
|
|
7963
|
+
});
|
|
7964
|
+
if (fixRes.ok) {
|
|
7965
|
+
const fixData = await fixRes.json();
|
|
7966
|
+
fixSpinner.succeed(chalk5.green("Fix prompt generated!"));
|
|
7967
|
+
console.log("");
|
|
7968
|
+
console.log(chalk5.bold.magenta("\u2501".repeat(60)));
|
|
7969
|
+
console.log(chalk5.bold.magenta(" AI FIX PROMPT"));
|
|
7970
|
+
console.log(chalk5.dim(` Tailored for ${fixData.platform === "manual" ? "your AI assistant" : fixData.platform}`));
|
|
7971
|
+
console.log(chalk5.bold.magenta("\u2501".repeat(60)));
|
|
7972
|
+
console.log("");
|
|
7973
|
+
console.log(fixData.prompt);
|
|
7974
|
+
console.log("");
|
|
7975
|
+
console.log(chalk5.bold.magenta("\u2501".repeat(60)));
|
|
7976
|
+
console.log(chalk5.dim(" Copy the above and paste it into your AI coding tool."));
|
|
7977
|
+
console.log(chalk5.bold.magenta("\u2501".repeat(60)));
|
|
7978
|
+
console.log("");
|
|
7979
|
+
} else if (fixRes.status === 429) {
|
|
7980
|
+
const errData = await fixRes.json();
|
|
7981
|
+
fixSpinner.warn(chalk5.yellow(errData.error || "Fix prompt quota exhausted."));
|
|
7982
|
+
} else if (fixRes.status === 401) {
|
|
7983
|
+
fixSpinner.warn("Session expired. Run `shipsafe login` to re-authenticate.");
|
|
7984
|
+
} else {
|
|
7985
|
+
fixSpinner.fail("Could not generate fix prompt.");
|
|
7986
|
+
}
|
|
7987
|
+
} catch {
|
|
7988
|
+
fixSpinner.fail("Could not reach ShipSafe servers.");
|
|
7989
|
+
}
|
|
7990
|
+
}
|
|
7991
|
+
} catch {
|
|
7992
|
+
}
|
|
7993
|
+
}
|
|
7881
7994
|
if (!options.ci && token) {
|
|
7882
7995
|
try {
|
|
7883
7996
|
const feedbackFile = join4(homedir3(), ".shipsafe", "feedback.json");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ship-safe/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.10",
|
|
4
4
|
"description": "Security scanner for AI-generated code — find vulnerabilities before you ship",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"@types/node": "^22",
|
|
52
52
|
"tsup": "^8",
|
|
53
53
|
"typescript": "^5.7",
|
|
54
|
-
"@shipsafe/
|
|
55
|
-
"@shipsafe/
|
|
54
|
+
"@shipsafe/scanner": "0.1.0",
|
|
55
|
+
"@shipsafe/shared": "0.1.0"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"build": "tsup",
|