@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.
Files changed (2) hide show
  1. package/dist/index.js +170 -57
  2. 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
- return chunkResults.flat();
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.8",
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/shared": "0.1.0",
55
- "@shipsafe/scanner": "0.1.0"
54
+ "@shipsafe/scanner": "0.1.0",
55
+ "@shipsafe/shared": "0.1.0"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsup",