@ship-safe/cli 1.1.8 → 1.1.12
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 +190 -59
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,6 +7,9 @@ var __export = (target, all) => {
|
|
|
7
7
|
|
|
8
8
|
// src/index.ts
|
|
9
9
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
10
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { dirname, join as join5 } from "path";
|
|
10
13
|
|
|
11
14
|
// src/commands/scan.ts
|
|
12
15
|
import { resolve as resolve2, join as join4 } from "path";
|
|
@@ -14,9 +17,65 @@ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync
|
|
|
14
17
|
import { homedir as homedir3 } from "os";
|
|
15
18
|
import chalk5 from "chalk";
|
|
16
19
|
import ora2 from "ora";
|
|
17
|
-
import { select } from "@inquirer/prompts";
|
|
20
|
+
import { select, confirm } from "@inquirer/prompts";
|
|
18
21
|
import { parse as parseYaml } from "yaml";
|
|
19
22
|
|
|
23
|
+
// ../../packages/scanner/dist/chunk-C2F2TUFE.js
|
|
24
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
25
|
+
var clients = /* @__PURE__ */ new Map();
|
|
26
|
+
function getClient(apiKey) {
|
|
27
|
+
let client = clients.get(apiKey);
|
|
28
|
+
if (!client) {
|
|
29
|
+
client = new Anthropic({ apiKey, maxRetries: 2 });
|
|
30
|
+
clients.set(apiKey, client);
|
|
31
|
+
}
|
|
32
|
+
return client;
|
|
33
|
+
}
|
|
34
|
+
async function callClaude(apiKey, system, prompt, options) {
|
|
35
|
+
const anthropic = getClient(apiKey);
|
|
36
|
+
const maxTokens = options?.maxTokens ?? 4096;
|
|
37
|
+
const response = await anthropic.messages.create(
|
|
38
|
+
{
|
|
39
|
+
model: options?.model ?? "claude-haiku-4-5-20251001",
|
|
40
|
+
max_tokens: maxTokens,
|
|
41
|
+
temperature: 0,
|
|
42
|
+
// Zero temperature for fully deterministic JSON output
|
|
43
|
+
// Use content block format with cache_control for prompt caching.
|
|
44
|
+
// The system prompt is identical across chunks within a scan —
|
|
45
|
+
// caching it saves ~90% on input tokens for subsequent chunk calls.
|
|
46
|
+
system: [
|
|
47
|
+
{
|
|
48
|
+
type: "text",
|
|
49
|
+
text: system,
|
|
50
|
+
cache_control: { type: "ephemeral" }
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
messages: [{ role: "user", content: prompt }]
|
|
54
|
+
},
|
|
55
|
+
{ timeout: 6e4 }
|
|
56
|
+
);
|
|
57
|
+
const truncated = response.stop_reason === "max_tokens";
|
|
58
|
+
if (response.usage) {
|
|
59
|
+
console.log(
|
|
60
|
+
`[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}` : "")
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (truncated) {
|
|
64
|
+
console.warn(
|
|
65
|
+
`[LLM] Response truncated (hit ${maxTokens} max_tokens). Some findings may be lost.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const textBlock = response.content.find((b) => b.type === "text");
|
|
69
|
+
return {
|
|
70
|
+
text: textBlock?.text ?? "",
|
|
71
|
+
truncated,
|
|
72
|
+
usage: response.usage ? {
|
|
73
|
+
inputTokens: response.usage.input_tokens,
|
|
74
|
+
outputTokens: response.usage.output_tokens
|
|
75
|
+
} : void 0
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
20
79
|
// ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js
|
|
21
80
|
var external_exports = {};
|
|
22
81
|
__export(external_exports, {
|
|
@@ -4184,7 +4243,6 @@ var llmResponseSchema = external_exports.object({
|
|
|
4184
4243
|
// ../../packages/scanner/dist/index.js
|
|
4185
4244
|
import path from "path";
|
|
4186
4245
|
import { createHash } from "crypto";
|
|
4187
|
-
import Anthropic from "@anthropic-ai/sdk";
|
|
4188
4246
|
function detectLanguage(filePath) {
|
|
4189
4247
|
const ext = path.extname(filePath).toLowerCase();
|
|
4190
4248
|
return LANGUAGE_EXTENSIONS[ext] ?? "unknown";
|
|
@@ -6201,59 +6259,6 @@ function deduplicateFindings(findings) {
|
|
|
6201
6259
|
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
|
|
6202
6260
|
);
|
|
6203
6261
|
}
|
|
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
6262
|
var MAX_LINES_PER_CHUNK = 3e3;
|
|
6258
6263
|
var LOGIC_PATTERNS = [
|
|
6259
6264
|
// Route handlers that handle user input (security-sensitive)
|
|
@@ -6397,6 +6402,22 @@ Example of a medium-severity finding:
|
|
|
6397
6402
|
}
|
|
6398
6403
|
]
|
|
6399
6404
|
|
|
6405
|
+
Example of a clean code response (no vulnerabilities found):
|
|
6406
|
+
|
|
6407
|
+
Code analyzed:
|
|
6408
|
+
\`\`\`
|
|
6409
|
+
import { hash } from "bcrypt";
|
|
6410
|
+
import { db } from "./db";
|
|
6411
|
+
|
|
6412
|
+
export async function createUser(email: string, password: string) {
|
|
6413
|
+
const hashed = await hash(password, 12);
|
|
6414
|
+
return db.insert("users", { email, password: hashed });
|
|
6415
|
+
}
|
|
6416
|
+
\`\`\`
|
|
6417
|
+
|
|
6418
|
+
Response:
|
|
6419
|
+
[]
|
|
6420
|
+
|
|
6400
6421
|
If the code has no security issues, respond with: []
|
|
6401
6422
|
|
|
6402
6423
|
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 +6554,15 @@ async function scanWithLLM(files, apiKey, platform) {
|
|
|
6533
6554
|
});
|
|
6534
6555
|
const CONCURRENCY = 5;
|
|
6535
6556
|
const chunkResults = [];
|
|
6557
|
+
const CIRCUIT_BREAKER_THRESHOLD = 3;
|
|
6558
|
+
let consecutiveFailures = 0;
|
|
6559
|
+
let circuitBroken = false;
|
|
6536
6560
|
for (let i = 0; i < sortedChunks.length; i += CONCURRENCY) {
|
|
6561
|
+
if (circuitBroken) break;
|
|
6537
6562
|
const batch = sortedChunks.slice(i, i + CONCURRENCY);
|
|
6538
6563
|
const batchResults = await Promise.all(
|
|
6539
6564
|
batch.map(async (chunk) => {
|
|
6565
|
+
if (circuitBroken) return [];
|
|
6540
6566
|
const prompt = buildAnalysisPrompt(chunk.files, platform);
|
|
6541
6567
|
const systemPrompt = getSystemPrompt(platform);
|
|
6542
6568
|
const chunkFindings = [];
|
|
@@ -6567,6 +6593,7 @@ async function scanWithLLM(files, apiKey, platform) {
|
|
|
6567
6593
|
);
|
|
6568
6594
|
llmFindings = parseResponse(retry.text);
|
|
6569
6595
|
}
|
|
6596
|
+
consecutiveFailures = 0;
|
|
6570
6597
|
for (const lf of llmFindings) {
|
|
6571
6598
|
let matchedPath = lf.file;
|
|
6572
6599
|
let content = fileMap.get(lf.file);
|
|
@@ -6616,16 +6643,42 @@ async function scanWithLLM(files, apiKey, platform) {
|
|
|
6616
6643
|
});
|
|
6617
6644
|
}
|
|
6618
6645
|
} catch (error) {
|
|
6646
|
+
consecutiveFailures++;
|
|
6619
6647
|
console.error(
|
|
6620
|
-
`LLM scan failed for chunk: ${error instanceof Error ? error.message : "unknown"}`
|
|
6648
|
+
`LLM scan failed for chunk (${consecutiveFailures} consecutive): ${error instanceof Error ? error.message : "unknown"}`
|
|
6621
6649
|
);
|
|
6650
|
+
if (consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
6651
|
+
circuitBroken = true;
|
|
6652
|
+
console.warn(
|
|
6653
|
+
`[llm] Circuit breaker tripped: ${consecutiveFailures} consecutive failures. Skipping remaining chunks.`
|
|
6654
|
+
);
|
|
6655
|
+
}
|
|
6622
6656
|
}
|
|
6623
6657
|
return chunkFindings;
|
|
6624
6658
|
})
|
|
6625
6659
|
);
|
|
6626
6660
|
chunkResults.push(...batchResults);
|
|
6627
6661
|
}
|
|
6628
|
-
|
|
6662
|
+
const findings = chunkResults.flat();
|
|
6663
|
+
if (circuitBroken) {
|
|
6664
|
+
findings.push({
|
|
6665
|
+
id: generateFindingId("_circuit_breaker", 0, "llm-partial-results"),
|
|
6666
|
+
ruleId: "llm-circuit-breaker",
|
|
6667
|
+
title: "AI scan returned partial results",
|
|
6668
|
+
description: "The AI scanner encountered multiple consecutive failures and stopped early. Some files were not analyzed. Please retry the scan or check back later.",
|
|
6669
|
+
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.",
|
|
6670
|
+
severity: "low",
|
|
6671
|
+
confidence: "high",
|
|
6672
|
+
source: "llm",
|
|
6673
|
+
file: "",
|
|
6674
|
+
line: 0,
|
|
6675
|
+
snippet: "",
|
|
6676
|
+
fix: {
|
|
6677
|
+
description: "Re-run the scan. If the issue persists, the AI service may be temporarily degraded."
|
|
6678
|
+
}
|
|
6679
|
+
});
|
|
6680
|
+
}
|
|
6681
|
+
return findings;
|
|
6629
6682
|
}
|
|
6630
6683
|
function parseTranslations(text) {
|
|
6631
6684
|
const start = text.lastIndexOf("[");
|
|
@@ -6932,6 +6985,7 @@ async function scan(config, onProgress) {
|
|
|
6932
6985
|
summary,
|
|
6933
6986
|
report,
|
|
6934
6987
|
durationMs,
|
|
6988
|
+
platform,
|
|
6935
6989
|
...warnings.length > 0 ? { warnings } : {}
|
|
6936
6990
|
};
|
|
6937
6991
|
}
|
|
@@ -7226,7 +7280,7 @@ function formatSarifOutput(result) {
|
|
|
7226
7280
|
tool: {
|
|
7227
7281
|
driver: {
|
|
7228
7282
|
name: "ShipSafe",
|
|
7229
|
-
version: "
|
|
7283
|
+
version: "1.1.11",
|
|
7230
7284
|
rules: result.findings.map((f) => ({
|
|
7231
7285
|
id: f.ruleId,
|
|
7232
7286
|
shortDescription: { text: f.title },
|
|
@@ -7878,6 +7932,68 @@ async function scanCommand(targetPath, options) {
|
|
|
7878
7932
|
)
|
|
7879
7933
|
);
|
|
7880
7934
|
}
|
|
7935
|
+
if (!options.ci && token && result.findings.length > 0 && options.output === "table") {
|
|
7936
|
+
try {
|
|
7937
|
+
const wantsFixPrompt = await confirm({
|
|
7938
|
+
message: chalk5.bold("Generate an AI fix prompt you can paste into Cursor/Lovable/Bolt?"),
|
|
7939
|
+
default: true
|
|
7940
|
+
});
|
|
7941
|
+
if (wantsFixPrompt) {
|
|
7942
|
+
const fixSpinner = ora2({
|
|
7943
|
+
text: chalk5.dim("Generating fix prompt..."),
|
|
7944
|
+
color: "magenta",
|
|
7945
|
+
spinner: "dots12"
|
|
7946
|
+
}).start();
|
|
7947
|
+
try {
|
|
7948
|
+
const fixRes = await fetch(`${options.apiUrl}/api/cli/fix-prompt`, {
|
|
7949
|
+
method: "POST",
|
|
7950
|
+
headers: {
|
|
7951
|
+
"Content-Type": "application/json",
|
|
7952
|
+
Authorization: `Bearer ${token.token}`
|
|
7953
|
+
},
|
|
7954
|
+
body: JSON.stringify({
|
|
7955
|
+
findings: result.findings.map((f) => ({
|
|
7956
|
+
title: f.title,
|
|
7957
|
+
severity: f.severity,
|
|
7958
|
+
filePath: f.file,
|
|
7959
|
+
lineNumber: f.line,
|
|
7960
|
+
fixDescription: f.fix.description,
|
|
7961
|
+
fixSuggestion: f.fix.suggestion,
|
|
7962
|
+
cwe: f.cwe
|
|
7963
|
+
})),
|
|
7964
|
+
platform: result.platform || "manual"
|
|
7965
|
+
})
|
|
7966
|
+
});
|
|
7967
|
+
if (fixRes.ok) {
|
|
7968
|
+
const fixData = await fixRes.json();
|
|
7969
|
+
fixSpinner.succeed(chalk5.green("Fix prompt generated!"));
|
|
7970
|
+
console.log("");
|
|
7971
|
+
console.log(chalk5.bold.magenta("\u2501".repeat(60)));
|
|
7972
|
+
console.log(chalk5.bold.magenta(" AI FIX PROMPT"));
|
|
7973
|
+
console.log(chalk5.dim(` Tailored for ${fixData.platform === "manual" ? "your AI assistant" : fixData.platform}`));
|
|
7974
|
+
console.log(chalk5.bold.magenta("\u2501".repeat(60)));
|
|
7975
|
+
console.log("");
|
|
7976
|
+
console.log(fixData.prompt);
|
|
7977
|
+
console.log("");
|
|
7978
|
+
console.log(chalk5.bold.magenta("\u2501".repeat(60)));
|
|
7979
|
+
console.log(chalk5.dim(" Copy the above and paste it into your AI coding tool."));
|
|
7980
|
+
console.log(chalk5.bold.magenta("\u2501".repeat(60)));
|
|
7981
|
+
console.log("");
|
|
7982
|
+
} else if (fixRes.status === 429) {
|
|
7983
|
+
const errData = await fixRes.json();
|
|
7984
|
+
fixSpinner.warn(chalk5.yellow(errData.error || "Fix prompt quota exhausted."));
|
|
7985
|
+
} else if (fixRes.status === 401) {
|
|
7986
|
+
fixSpinner.warn("Session expired. Run `shipsafe login` to re-authenticate.");
|
|
7987
|
+
} else {
|
|
7988
|
+
fixSpinner.fail("Could not generate fix prompt.");
|
|
7989
|
+
}
|
|
7990
|
+
} catch {
|
|
7991
|
+
fixSpinner.fail("Could not reach ShipSafe servers.");
|
|
7992
|
+
}
|
|
7993
|
+
}
|
|
7994
|
+
} catch {
|
|
7995
|
+
}
|
|
7996
|
+
}
|
|
7881
7997
|
if (!options.ci && token) {
|
|
7882
7998
|
try {
|
|
7883
7999
|
const feedbackFile = join4(homedir3(), ".shipsafe", "feedback.json");
|
|
@@ -7963,6 +8079,21 @@ function initCommand() {
|
|
|
7963
8079
|
}
|
|
7964
8080
|
|
|
7965
8081
|
// src/index.ts
|
|
8082
|
+
function getVersion() {
|
|
8083
|
+
try {
|
|
8084
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
8085
|
+
for (let i = 0; i < 5; i++) {
|
|
8086
|
+
try {
|
|
8087
|
+
const pkg = JSON.parse(readFileSync6(join5(dir, "package.json"), "utf-8"));
|
|
8088
|
+
if (pkg.name === "@ship-safe/cli") return pkg.version;
|
|
8089
|
+
} catch {
|
|
8090
|
+
}
|
|
8091
|
+
dir = dirname(dir);
|
|
8092
|
+
}
|
|
8093
|
+
} catch {
|
|
8094
|
+
}
|
|
8095
|
+
return "1.1.11";
|
|
8096
|
+
}
|
|
7966
8097
|
function validateApiUrl(value) {
|
|
7967
8098
|
let parsed;
|
|
7968
8099
|
try {
|
|
@@ -7976,7 +8107,7 @@ function validateApiUrl(value) {
|
|
|
7976
8107
|
return value;
|
|
7977
8108
|
}
|
|
7978
8109
|
var program = new Command();
|
|
7979
|
-
program.name("shipsafe").description("Security scanner for AI-generated code").version(
|
|
8110
|
+
program.name("shipsafe").description("Security scanner for AI-generated code").version(getVersion());
|
|
7980
8111
|
program.command("scan").description("Scan a directory or file for security vulnerabilities").argument("[path]", "Path to scan", ".").addOption(
|
|
7981
8112
|
new Option("-o, --output <format>", "Output format: table, json, sarif").choices(["table", "json", "sarif"]).default("table")
|
|
7982
8113
|
).addOption(
|