@ship-safe/cli 1.1.16 → 1.1.17

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 +323 -67
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,9 +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";
10
+ import { readFileSync as readFileSync7 } from "fs";
11
11
  import { fileURLToPath } from "url";
12
- import { dirname, join as join5 } from "path";
12
+ import { dirname, join as join6 } from "path";
13
13
 
14
14
  // src/commands/scan.ts
15
15
  import { resolve as resolve2, join as join4 } from "path";
@@ -4178,6 +4178,12 @@ var FIX_PROMPT_LIMITS = {
4178
4178
  var LIFETIME_TIERS = new Set(
4179
4179
  Object.values(TIERS).filter((t) => t.aiScansAreLifetime).map((t) => t.id)
4180
4180
  );
4181
+ function isPaidTier(tier) {
4182
+ if (!tier) return false;
4183
+ if (tier === "badge") return true;
4184
+ const def = TIERS[tier];
4185
+ return def ? def.priceCents > 0 : false;
4186
+ }
4181
4187
  var LANGUAGE_EXTENSIONS = {
4182
4188
  ".js": "javascript",
4183
4189
  ".jsx": "javascript",
@@ -5125,6 +5131,35 @@ var SECRET_RULES = [
5125
5131
  languages: ["*"],
5126
5132
  patterns: [{ regex: `(?:OVSX_PAT|OVSX_TOKEN|OPEN_VSX_TOKEN)\\s*[:=]\\s*["']?[A-Za-z0-9-]{30,}`, type: "match" }],
5127
5133
  fix: { description: "Rotate at https://open-vsx.org/user-settings/tokens. Re-publish the latest known-good version of your extension after confirming the source hasn't been tampered with.", suggestion: "process.env.OVSX_PAT" }
5134
+ },
5135
+ {
5136
+ id: "secrets/public-env-secret-exposed",
5137
+ title: "Secret Exposed to the Browser via a Public Variable",
5138
+ description: "A secret is tied to a public, client-bundled variable (VITE_, REACT_APP_, EXPO_PUBLIC_). These are inlined into the JavaScript shipped to every visitor, so anyone can read the key by viewing your site's source. (NEXT_PUBLIC_ is covered by a separate Next.js rule.)",
5139
+ severity: "critical",
5140
+ confidence: "high",
5141
+ cwe: "CWE-200",
5142
+ owasp: "A01:2021",
5143
+ languages: ["*"],
5144
+ patterns: [
5145
+ // A public-prefixed variable whose NAME is unambiguously a server secret.
5146
+ // Scoped to non-Next prefixes (framework/nextjs-public-env-secret owns
5147
+ // NEXT_PUBLIC_) and to names that value-based rules can't catch from a bare
5148
+ // `process.env.X` reference — so this is purely additive, no double-counting
5149
+ // with llm/api-key-client-env. API_KEY/TOKEN are deliberately excluded (those
5150
+ // overlap with value-based detection and legitimate publishable keys).
5151
+ {
5152
+ regex: "(?:VITE_|REACT_APP_|EXPO_PUBLIC_)[A-Z0-9_]*(?:SECRET|SERVICE_ROLE|PRIVATE|PASSWORD)(?!.*(?:anon|public))",
5153
+ type: "match"
5154
+ }
5155
+ ],
5156
+ excludePatterns: [
5157
+ { regex: "ANON_KEY|PUBLISHABLE|PUBLIC_KEY|_URL\\b|_ID\\b", type: "context_line" }
5158
+ ],
5159
+ fix: {
5160
+ description: "Never put a secret in a public variable. Use a server-only variable (drop the VITE_/EXPO_PUBLIC_ prefix) and read it from server code, or proxy the request through your backend so the key never reaches the browser.",
5161
+ suggestion: "// server only \u2014 no public prefix:\nconst key = process.env.STRIPE_SECRET_KEY;"
5162
+ }
5128
5163
  }
5129
5164
  ];
5130
5165
  var INJECTION_RULES = [
@@ -5203,6 +5238,56 @@ var INJECTION_RULES = [
5203
5238
  ],
5204
5239
  excludePatterns: [{ regex: "(?:__proto__|hasOwnProperty|prototype|constructor|sanitize|safeMerge|lodash\\.merge|deepmerge)", type: "context_line" }],
5205
5240
  fix: { description: "Validate that user-provided keys don't include __proto__, constructor, or prototype. Use Object.create(null) for key-value maps from user input.", suggestion: "if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('Invalid key')" }
5241
+ },
5242
+ {
5243
+ id: "injection/python-sql-formatting",
5244
+ title: "SQL Query Built with String Formatting (Python)",
5245
+ description: "A SQL query passed to .execute() is built with % formatting or + concatenation. If any part comes from user input, this is SQL injection. Pass values as parameters instead. (f-string queries are caught separately.)",
5246
+ severity: "critical",
5247
+ confidence: "high",
5248
+ cwe: "CWE-89",
5249
+ owasp: "A03:2021",
5250
+ languages: ["python"],
5251
+ patterns: [
5252
+ // f-strings are owned by framework/django-raw-sql; only the %/+ forms here,
5253
+ // to stay purely additive and avoid double-counting one query.
5254
+ { regex: `\\.execute(?:many)?\\s*\\(\\s*["'][^"']*["']\\s*%`, type: "match" },
5255
+ { regex: `\\.execute(?:many)?\\s*\\(\\s*["'][^"']*["']\\s*\\+`, type: "match" }
5256
+ ],
5257
+ fix: {
5258
+ description: "Use parameterized queries \u2014 pass values as the second argument to execute(), never format them into the SQL string.",
5259
+ suggestion: 'cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))'
5260
+ }
5261
+ },
5262
+ {
5263
+ id: "injection/python-unsafe-yaml-load",
5264
+ title: "Unsafe YAML Deserialization (Python)",
5265
+ description: "yaml.load() without SafeLoader can construct arbitrary Python objects, so untrusted YAML can execute code on your server. Use yaml.safe_load() instead.",
5266
+ severity: "critical",
5267
+ confidence: "high",
5268
+ cwe: "CWE-502",
5269
+ owasp: "A08:2021",
5270
+ languages: ["python"],
5271
+ patterns: [{ regex: "yaml\\.load\\s*\\((?![^)]{0,200}SafeLoader)", type: "match" }],
5272
+ fix: {
5273
+ description: "Use yaml.safe_load() for any input you don't fully control.",
5274
+ suggestion: "data = yaml.safe_load(user_input)"
5275
+ }
5276
+ },
5277
+ {
5278
+ id: "config/flask-debug-enabled",
5279
+ title: "Flask Debug Mode Enabled",
5280
+ description: "Flask runs with debug=True. The Werkzeug interactive debugger lets anyone who triggers an error run arbitrary code on your server. Never enable debug mode in production.",
5281
+ severity: "critical",
5282
+ confidence: "high",
5283
+ cwe: "CWE-489",
5284
+ owasp: "A05:2021",
5285
+ languages: ["python"],
5286
+ patterns: [{ regex: "\\.run\\s*\\([^)]*debug\\s*=\\s*True", type: "match" }],
5287
+ fix: {
5288
+ description: "Never run Flask with debug=True in production. Drive it from an environment variable that defaults to off.",
5289
+ suggestion: 'app.run(debug=os.environ.get("FLASK_DEBUG") == "1")'
5290
+ }
5206
5291
  }
5207
5292
  ];
5208
5293
  var XSS_RULES = [
@@ -5290,6 +5375,10 @@ var AUTH_RULES = [
5290
5375
  languages: ["javascript", "typescript"],
5291
5376
  patterns: [
5292
5377
  { regex: `(?:Access-Control-Allow-Origin|origin)\\s*[:=]\\s*["']\\*["']`, type: "match" },
5378
+ // Two-argument header form: res.header("Access-Control-Allow-Origin", "*")
5379
+ // / res.setHeader(...) — very common in AI-generated Express code, missed by
5380
+ // the colon/equals pattern above.
5381
+ { regex: `["']Access-Control-Allow-Origin["']\\s*,\\s*["']\\*["']`, type: "match" },
5293
5382
  { regex: "cors\\(\\s*\\)", type: "match" },
5294
5383
  { regex: `cors\\(\\s*\\{\\s*origin\\s*:\\s*(?:true|\\*|["']\\*["'])`, type: "match" }
5295
5384
  ],
@@ -7002,6 +7091,10 @@ function dedupeEntries(entries) {
7002
7091
  return true;
7003
7092
  });
7004
7093
  }
7094
+ var OSV_QUERYBATCH_URL = "https://api.osv.dev/v1/querybatch";
7095
+ var OSV_VULN_URL = "https://api.osv.dev/v1/vulns";
7096
+ var MAX_ENRICH = 200;
7097
+ var ENRICH_CONCURRENCY = 8;
7005
7098
  async function queryOsv(packages) {
7006
7099
  const queries = packages.map((p) => ({
7007
7100
  package: { name: p.name, ecosystem: p.ecosystem },
@@ -7009,7 +7102,7 @@ async function queryOsv(packages) {
7009
7102
  }));
7010
7103
  let response;
7011
7104
  try {
7012
- response = await fetch("https://api.osv.dev/v1/querybatch", {
7105
+ response = await fetch(OSV_QUERYBATCH_URL, {
7013
7106
  method: "POST",
7014
7107
  headers: { "Content-Type": "application/json" },
7015
7108
  body: JSON.stringify({ queries })
@@ -7025,38 +7118,84 @@ async function queryOsv(packages) {
7025
7118
  );
7026
7119
  }
7027
7120
  const data = await response.json();
7028
- const findings = [];
7121
+ const matches = [];
7122
+ const ids = /* @__PURE__ */ new Set();
7029
7123
  for (let i = 0; i < data.results.length; i++) {
7030
7124
  const result = data.results[i];
7031
7125
  const pkg = packages[i];
7032
7126
  if (!result?.vulns || result.vulns.length === 0 || !pkg) continue;
7033
7127
  for (const vuln of result.vulns) {
7034
- const cveAlias = vuln.aliases?.find((a) => a.startsWith("CVE-"));
7035
- const severity = mapOsvSeverity(vuln);
7036
- const fixedVersion = getFixedVersion(vuln, pkg.name);
7037
- const summaryText = vuln.summary ? vuln.summary.replace(/\n/g, " ").trim() : `Known vulnerability in ${pkg.name}`;
7038
- const humanTitle = summaryText.length > 120 ? summaryText.slice(0, 117) + "..." : summaryText;
7039
- findings.push({
7040
- id: `dep-${vuln.id}`,
7041
- ruleId: vuln.id,
7042
- title: humanTitle,
7043
- description: `${pkg.name}@${pkg.version} \u2014 ${vuln.id}${cveAlias ? ` (${cveAlias})` : ""}. ${summaryText}`,
7044
- plainEnglish: `Your dependency "${pkg.name}" version ${pkg.version} has a known vulnerability (${vuln.id}). ${summaryText}`,
7045
- severity,
7046
- confidence: "high",
7047
- source: "dependency",
7048
- file: pkg.lockfile,
7049
- line: 1,
7050
- snippet: `${pkg.name}@${pkg.version}`,
7051
- fix: {
7052
- description: fixedVersion ? `Upgrade ${pkg.name} to version ${fixedVersion} or later.` : `Check ${vuln.id} for remediation steps. Consider upgrading or replacing this dependency.`,
7053
- suggestion: fixedVersion ? `npm install ${pkg.name}@${fixedVersion}` : void 0
7054
- },
7055
- cwe: cveAlias
7056
- });
7128
+ if (!vuln.id) continue;
7129
+ matches.push({ pkg, id: vuln.id });
7130
+ ids.add(vuln.id);
7057
7131
  }
7058
7132
  }
7059
- return findings;
7133
+ if (matches.length === 0) return [];
7134
+ const details = await fetchVulnDetails([...ids].slice(0, MAX_ENRICH));
7135
+ return matches.map(
7136
+ ({ pkg, id }) => buildDependencyFinding(pkg, id, details.get(id))
7137
+ );
7138
+ }
7139
+ async function fetchVulnDetails(ids) {
7140
+ const out = /* @__PURE__ */ new Map();
7141
+ let cursor = 0;
7142
+ async function worker() {
7143
+ while (cursor < ids.length) {
7144
+ const id = ids[cursor++];
7145
+ if (!id) continue;
7146
+ try {
7147
+ const res = await fetch(`${OSV_VULN_URL}/${encodeURIComponent(id)}`);
7148
+ if (res.ok) out.set(id, await res.json());
7149
+ } catch {
7150
+ }
7151
+ }
7152
+ }
7153
+ const workers = Array.from(
7154
+ { length: Math.min(ENRICH_CONCURRENCY, ids.length) },
7155
+ () => worker()
7156
+ );
7157
+ await Promise.all(workers);
7158
+ return out;
7159
+ }
7160
+ function upgradeCommand(pkg, version) {
7161
+ switch (pkg.ecosystem) {
7162
+ case "npm":
7163
+ return `npm install ${pkg.name}@${version}`;
7164
+ case "PyPI":
7165
+ return `pip install --upgrade "${pkg.name}==${version}"`;
7166
+ case "Go":
7167
+ return `go get ${pkg.name}@v${version}`;
7168
+ case "RubyGems":
7169
+ return `bundle update ${pkg.name}`;
7170
+ default:
7171
+ return `Upgrade ${pkg.name} to ${version}`;
7172
+ }
7173
+ }
7174
+ function buildDependencyFinding(pkg, id, vuln) {
7175
+ const cveAlias = vuln?.aliases?.find((a) => a.startsWith("CVE-"));
7176
+ const severity = vuln ? mapOsvSeverity(vuln) : "medium";
7177
+ const fixedVersion = vuln ? getFixedVersion(vuln, pkg.name) : void 0;
7178
+ const ref = cveAlias ?? id;
7179
+ const summaryText = vuln?.summary ? vuln.summary.replace(/\n/g, " ").trim() : `Known vulnerability in ${pkg.name}`;
7180
+ const humanTitle = summaryText.length > 120 ? summaryText.slice(0, 117) + "..." : summaryText;
7181
+ return {
7182
+ id: `dep-${id}`,
7183
+ ruleId: id,
7184
+ title: humanTitle,
7185
+ description: `${pkg.name}@${pkg.version} \u2014 ${id}${cveAlias ? ` (${cveAlias})` : ""}. ${summaryText}`,
7186
+ plainEnglish: `Your dependency "${pkg.name}" version ${pkg.version} has a known vulnerability (${ref}). ${summaryText}`,
7187
+ severity,
7188
+ confidence: "high",
7189
+ source: "dependency",
7190
+ file: pkg.lockfile,
7191
+ line: 1,
7192
+ snippet: `${pkg.name}@${pkg.version}`,
7193
+ fix: {
7194
+ description: fixedVersion ? `Upgrade ${pkg.name} to version ${fixedVersion} or later.` : `Check ${ref} for remediation steps. Consider upgrading or replacing this dependency.`,
7195
+ suggestion: fixedVersion ? upgradeCommand(pkg, fixedVersion) : void 0
7196
+ },
7197
+ cwe: cveAlias
7198
+ };
7060
7199
  }
7061
7200
  function mapOsvSeverity(vuln) {
7062
7201
  if (vuln.severity) {
@@ -7092,13 +7231,13 @@ function getFixedVersion(vuln, packageName) {
7092
7231
  }
7093
7232
  function deduplicateFindings(findings) {
7094
7233
  const seen = /* @__PURE__ */ new Map();
7095
- for (const finding of findings) {
7096
- const key = `${finding.file}:${finding.line}:${finding.ruleId}`;
7234
+ for (const finding2 of findings) {
7235
+ const key = `${finding2.file}:${finding2.line}:${finding2.ruleId}`;
7097
7236
  const existing = seen.get(key);
7098
7237
  if (!existing) {
7099
- seen.set(key, finding);
7100
- } else if (SEVERITY_ORDER[finding.severity] < SEVERITY_ORDER[existing.severity]) {
7101
- seen.set(key, finding);
7238
+ seen.set(key, finding2);
7239
+ } else if (SEVERITY_ORDER[finding2.severity] < SEVERITY_ORDER[existing.severity]) {
7240
+ seen.set(key, finding2);
7102
7241
  }
7103
7242
  }
7104
7243
  return Array.from(seen.values()).sort(
@@ -7584,15 +7723,15 @@ async function translateFindings(findings, apiKey, platform) {
7584
7723
  for (const { start, translations } of batchResults) {
7585
7724
  const translationMap = new Map(translations.map((t) => [t.id, t]));
7586
7725
  for (let j = start; j < Math.min(start + batchSize, translated.length); j++) {
7587
- const finding = translated[j];
7588
- if (!finding) continue;
7589
- const t = translationMap.get(finding.id);
7726
+ const finding2 = translated[j];
7727
+ if (!finding2) continue;
7728
+ const t = translationMap.get(finding2.id);
7590
7729
  if (t) {
7591
7730
  translated[j] = {
7592
- ...finding,
7731
+ ...finding2,
7593
7732
  plainEnglish: t.plainEnglish,
7594
7733
  fix: {
7595
- ...finding.fix,
7734
+ ...finding2.fix,
7596
7735
  description: t.fixDescription
7597
7736
  }
7598
7737
  };
@@ -7655,45 +7794,21 @@ function detectPlatform(files) {
7655
7794
  if (lower.endsWith("package.json") && content.includes("lovable-tagger")) {
7656
7795
  signals.lovable.push("lovable-tagger in package.json");
7657
7796
  }
7658
- if (content.includes("lovable") && /\/\*.*lovable.*\*\//i.test(content)) {
7659
- signals.lovable.push("lovable comment in source");
7660
- }
7661
7797
  if (lower.includes(".lovable") || lower.includes("lovable.config")) {
7662
7798
  signals.lovable.push("lovable config file");
7663
7799
  }
7664
7800
  if (lower.includes(".bolt/") || lower.includes(".bolt\\")) {
7665
7801
  signals.bolt.push(".bolt/ directory detected");
7666
7802
  }
7667
- if (lower.endsWith("package.json") && (content.includes("@bolt") || content.includes("bolt-"))) {
7668
- signals.bolt.push("bolt dependency in package.json");
7669
- }
7670
- if (/\/[/*].*bolt\.new/i.test(content)) {
7671
- signals.bolt.push("bolt.new reference in source");
7672
- }
7673
7803
  if (lower.endsWith(".cursorrules") || lower.endsWith(".cursorignore")) {
7674
7804
  signals.cursor.push(".cursorrules file detected");
7675
7805
  }
7676
- if (/\/[/*].*cursor/i.test(content) && lower.endsWith(".ts")) {
7677
- signals.cursor.push("cursor reference in source");
7678
- }
7679
7806
  if (lower.includes(".v0/") || lower.includes(".v0\\")) {
7680
7807
  signals.v0.push(".v0/ directory detected");
7681
7808
  }
7682
- if (content.includes("v0.dev") || content.includes("@v0/")) {
7683
- signals.v0.push("v0.dev reference in source");
7684
- }
7685
- if (lower.endsWith("package.json") && content.includes('"v0"')) {
7686
- signals.v0.push("v0 dependency in package.json");
7687
- }
7688
7809
  if (lower.includes("base44.config") || lower.includes(".base44")) {
7689
7810
  signals.base44.push("base44 config file");
7690
7811
  }
7691
- if (/from\s+['"]@?base44\b/i.test(content) || /require\s*\(\s*['"]@?base44\b/i.test(content)) {
7692
- signals.base44.push("base44 SDK import in source");
7693
- }
7694
- if (/https?:\/\/[^"'\s]*base44\.app/i.test(content)) {
7695
- signals.base44.push("base44.app URL in source");
7696
- }
7697
7812
  }
7698
7813
  const scores = Object.entries(signals).filter(([key]) => key !== "manual").map(([platform, sigs]) => ({
7699
7814
  platform,
@@ -7726,9 +7841,9 @@ function computeSummary(findings, files) {
7726
7841
  llm: 0,
7727
7842
  dependency: 0
7728
7843
  };
7729
- for (const finding of findings) {
7730
- bySeverity[finding.severity]++;
7731
- bySource[finding.source]++;
7844
+ for (const finding2 of findings) {
7845
+ bySeverity[finding2.severity]++;
7846
+ bySource[finding2.source]++;
7732
7847
  }
7733
7848
  return {
7734
7849
  total: findings.length,
@@ -7838,6 +7953,95 @@ async function scan(config, onProgress) {
7838
7953
  ...warnings.length > 0 ? { warnings } : {}
7839
7954
  };
7840
7955
  }
7956
+ async function fetchStatus(fetchImpl, url, init, timeoutMs) {
7957
+ const controller = new AbortController();
7958
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
7959
+ try {
7960
+ const res = await fetchImpl(url, { ...init, signal: controller.signal });
7961
+ return res.status;
7962
+ } catch {
7963
+ return null;
7964
+ } finally {
7965
+ clearTimeout(timer);
7966
+ }
7967
+ }
7968
+ function classify(status) {
7969
+ if (status === null) return "unverifiable";
7970
+ if (status === 200) return "live";
7971
+ if (status === 401 || status === 403) return "revoked";
7972
+ return "unverifiable";
7973
+ }
7974
+ var PROVIDER_VALIDATORS = [
7975
+ {
7976
+ id: "openai",
7977
+ name: "OpenAI",
7978
+ extract: (t) => t.match(/sk-(?:proj-)?[A-Za-z0-9_-]{20,}/)?.[0] ?? null,
7979
+ check: async (value, f, ms) => classify(
7980
+ await fetchStatus(f, "https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${value}` } }, ms)
7981
+ )
7982
+ },
7983
+ {
7984
+ id: "stripe",
7985
+ name: "Stripe",
7986
+ extract: (t) => t.match(/(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/)?.[0] ?? null,
7987
+ check: async (value, f, ms) => classify(
7988
+ await fetchStatus(f, "https://api.stripe.com/v1/balance", { headers: { Authorization: `Bearer ${value}` } }, ms)
7989
+ )
7990
+ },
7991
+ {
7992
+ id: "github",
7993
+ name: "GitHub",
7994
+ extract: (t) => t.match(/gh[posru]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,}/)?.[0] ?? null,
7995
+ check: async (value, f, ms) => classify(
7996
+ await fetchStatus(
7997
+ f,
7998
+ "https://api.github.com/user",
7999
+ { headers: { Authorization: `Bearer ${value}`, "User-Agent": "ShipSafe-Validator" } },
8000
+ ms
8001
+ )
8002
+ )
8003
+ },
8004
+ {
8005
+ id: "sendgrid",
8006
+ name: "SendGrid",
8007
+ extract: (t) => t.match(/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/)?.[0] ?? null,
8008
+ check: async (value, f, ms) => classify(
8009
+ await fetchStatus(f, "https://api.sendgrid.com/v3/scopes", { headers: { Authorization: `Bearer ${value}` } }, ms)
8010
+ )
8011
+ },
8012
+ {
8013
+ id: "gitlab",
8014
+ name: "GitLab",
8015
+ extract: (t) => t.match(/glpat-[A-Za-z0-9_-]{20}/)?.[0] ?? null,
8016
+ check: async (value, f, ms) => classify(await fetchStatus(f, "https://gitlab.com/api/v4/user", { headers: { "PRIVATE-TOKEN": value } }, ms))
8017
+ },
8018
+ {
8019
+ id: "slack",
8020
+ name: "Slack",
8021
+ extract: (t) => t.match(/xox[baprs]-[A-Za-z0-9-]{10,}/)?.[0] ?? null,
8022
+ // auth.test is read-only; Slack returns {ok:true|false} regardless of HTTP 200.
8023
+ check: async (value, f, ms) => {
8024
+ const controller = new AbortController();
8025
+ const timer = setTimeout(() => controller.abort(), ms);
8026
+ try {
8027
+ const res = await f("https://slack.com/api/auth.test", {
8028
+ method: "POST",
8029
+ headers: { Authorization: `Bearer ${value}` },
8030
+ signal: controller.signal
8031
+ });
8032
+ const body = await res.json().catch(() => null);
8033
+ if (body?.ok === true) return "live";
8034
+ if (body?.ok === false) return "revoked";
8035
+ return "unverifiable";
8036
+ } catch {
8037
+ return "unverifiable";
8038
+ } finally {
8039
+ clearTimeout(timer);
8040
+ }
8041
+ }
8042
+ }
8043
+ ];
8044
+ var VALIDATOR_BY_ID = new Map(PROVIDER_VALIDATORS.map((v) => [v.id, v]));
7841
8045
 
7842
8046
  // src/lib/file-collector.ts
7843
8047
  import { readdirSync, readFileSync, statSync, existsSync } from "fs";
@@ -8600,7 +8804,7 @@ async function scanCommand(targetPath, options) {
8600
8804
  spinner.succeed(
8601
8805
  chalk5.green(`Scan complete \u2014 ${result.findings.length} findings in ${result.durationMs}ms`)
8602
8806
  );
8603
- if (tokenData?.tier === "growth" || tokenData?.tier === "badge" || tokenData?.tier === "shield") {
8807
+ if (tokenData && isPaidTier(tokenData.tier)) {
8604
8808
  const aiSpinner = ora2({
8605
8809
  text: chalk5.dim("Running AI-powered deep analysis... (this may take 1-3 minutes)"),
8606
8810
  color: "yellow",
@@ -8968,13 +9172,59 @@ function initCommand() {
8968
9172
  console.log(chalk6.green("Created .shipsafe.yml configuration file."));
8969
9173
  }
8970
9174
 
9175
+ // src/commands/false-positive.ts
9176
+ import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
9177
+ import { homedir as homedir4 } from "os";
9178
+ import { join as join5 } from "path";
9179
+ import chalk7 from "chalk";
9180
+ var TOKEN_FILE2 = join5(homedir4(), ".shipsafe", "token.json");
9181
+ async function falsePositiveCommand(ruleId, options) {
9182
+ if (!existsSync7(TOKEN_FILE2)) {
9183
+ console.error(chalk7.red("Not logged in. Run `shipsafe login` first."));
9184
+ process.exitCode = 1;
9185
+ return;
9186
+ }
9187
+ let token;
9188
+ try {
9189
+ token = JSON.parse(readFileSync6(TOKEN_FILE2, "utf-8")).token;
9190
+ if (!token) throw new Error("no token");
9191
+ } catch {
9192
+ console.error(chalk7.red("Couldn't read your login token. Run `shipsafe login` again."));
9193
+ process.exitCode = 1;
9194
+ return;
9195
+ }
9196
+ try {
9197
+ const res = await fetch(`${options.apiUrl}/api/cli/false-positive`, {
9198
+ method: "POST",
9199
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
9200
+ body: JSON.stringify({
9201
+ ruleId,
9202
+ filePath: options.file,
9203
+ lineNumber: options.line ? parseInt(options.line, 10) : void 0,
9204
+ reason: options.reason,
9205
+ surface: "cli"
9206
+ })
9207
+ });
9208
+ if (!res.ok) {
9209
+ const msg = await res.json().then((b) => b?.error).catch(() => null);
9210
+ console.error(chalk7.red(`Couldn't report: ${msg || `HTTP ${res.status}`}`));
9211
+ process.exitCode = 1;
9212
+ return;
9213
+ }
9214
+ console.log(chalk7.green(`\u2713 Reported ${ruleId} as a false positive. Thanks \u2014 this helps ShipSafe tune the rule.`));
9215
+ } catch (e) {
9216
+ console.error(chalk7.red(`Couldn't reach ShipSafe: ${e instanceof Error ? e.message : "network error"}`));
9217
+ process.exitCode = 1;
9218
+ }
9219
+ }
9220
+
8971
9221
  // src/index.ts
8972
9222
  function getVersion() {
8973
9223
  try {
8974
9224
  let dir = dirname(fileURLToPath(import.meta.url));
8975
9225
  for (let i = 0; i < 5; i++) {
8976
9226
  try {
8977
- const pkg = JSON.parse(readFileSync6(join5(dir, "package.json"), "utf-8"));
9227
+ const pkg = JSON.parse(readFileSync7(join6(dir, "package.json"), "utf-8"));
8978
9228
  if (pkg.version) return pkg.version;
8979
9229
  } catch {
8980
9230
  }
@@ -9024,4 +9274,10 @@ program.command("whoami").description("Show current login status and plan info")
9024
9274
  ).action(whoamiCommand);
9025
9275
  program.command("ignore").description("Suppress a rule in future scans").argument("<rule-id>", "Rule ID to ignore (e.g. secrets/generic-api-key)").option("-r, --reason <reason>", "Why this rule is being suppressed").action(ignoreCommand);
9026
9276
  program.command("unignore").description("Re-enable a previously suppressed rule").argument("<rule-id>", "Rule ID to unignore").action(unignoreCommand);
9277
+ program.command("false-positive").description("Report a finding as a false positive to ShipSafe (helps tune the rule)").argument("<rule-id>", "Rule ID to report (e.g. secrets/generic-api-key)").option("-f, --file <path>", "File the finding was reported on").option("-l, --line <number>", "Line number of the finding").option("-r, --reason <reason>", "Why it's a false positive").option(
9278
+ "--api-url <url>",
9279
+ "API URL for ShipSafe server",
9280
+ validateApiUrl,
9281
+ "https://ship-safe.co"
9282
+ ).action(falsePositiveCommand);
9027
9283
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ship-safe/cli",
3
- "version": "1.1.16",
3
+ "version": "1.1.17",
4
4
  "description": "Security scanner for AI-generated code — find vulnerabilities before you ship",
5
5
  "type": "module",
6
6
  "license": "MIT",