@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.
Files changed (2) hide show
  1. package/dist/index.js +190 -59
  2. 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
- return chunkResults.flat();
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: "0.1.0",
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("0.1.0");
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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ship-safe/cli",
3
- "version": "1.1.8",
3
+ "version": "1.1.12",
4
4
  "description": "Security scanner for AI-generated code — find vulnerabilities before you ship",
5
5
  "type": "module",
6
6
  "license": "MIT",