@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.
- package/dist/index.js +323 -67
- 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
|
|
10
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
12
|
-
import { dirname, join as
|
|
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(
|
|
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
|
|
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
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
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
|
|
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
|
|
7096
|
-
const key = `${
|
|
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,
|
|
7100
|
-
} else if (SEVERITY_ORDER[
|
|
7101
|
-
seen.set(key,
|
|
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
|
|
7588
|
-
if (!
|
|
7589
|
-
const t = translationMap.get(
|
|
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
|
-
...
|
|
7731
|
+
...finding2,
|
|
7593
7732
|
plainEnglish: t.plainEnglish,
|
|
7594
7733
|
fix: {
|
|
7595
|
-
...
|
|
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
|
|
7730
|
-
bySeverity[
|
|
7731
|
-
bySource[
|
|
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
|
|
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(
|
|
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();
|