ship-safe 4.0.0 → 4.2.0

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.
@@ -1,207 +1,225 @@
1
- /**
2
- * Enhanced Scoring Engine
3
- * ========================
4
- *
5
- * Risk-based scoring with 8 categories, EPSS integration,
6
- * KEV flagging, and historical trend tracking.
7
- *
8
- * Score = 100 - sum(category deductions)
9
- * Each category has a weight and max deduction cap.
10
- */
11
-
12
- import fs from 'fs';
13
- import path from 'path';
14
-
15
- // =============================================================================
16
- // SCORING CONFIGURATION
17
- // =============================================================================
18
-
19
- const CATEGORIES = {
20
- secrets: { weight: 15, label: 'Secrets', deductions: { critical: 25, high: 15, medium: 5 } },
21
- injection: { weight: 15, label: 'Code Vulnerabilities', deductions: { critical: 20, high: 10, medium: 3 } },
22
- deps: { weight: 15, label: 'Dependencies', deductions: { critical: 20, high: 10, medium: 5, moderate: 5 } },
23
- auth: { weight: 15, label: 'Auth & Access Control', deductions: { critical: 20, high: 10, medium: 3 } },
24
- config: { weight: 10, label: 'Configuration', deductions: { critical: 15, high: 8, medium: 3 } },
25
- 'supply-chain':{ weight: 10, label: 'Supply Chain', deductions: { critical: 15, high: 8, medium: 3 } },
26
- api: { weight: 10, label: 'API Security', deductions: { critical: 15, high: 8, medium: 3 } },
27
- llm: { weight: 10, label: 'AI/LLM Security', deductions: { critical: 15, high: 8, medium: 3 } },
28
- };
29
-
30
- // Fallback categories for findings that don't match a known category
31
- const FALLBACK_CATEGORY_MAP = {
32
- 'secret': 'secrets',
33
- 'vulnerability': 'injection',
34
- 'ssrf': 'injection',
35
- 'history': 'secrets',
36
- 'cicd': 'config',
37
- 'mobile': 'injection',
38
- 'recon': null, // skip recon findings
39
- };
40
-
41
- const GRADES = [
42
- { min: 90, letter: 'A', label: 'Ship it!', color: 'green' },
43
- { min: 75, letter: 'B', label: 'Minor issues to review', color: 'cyan' },
44
- { min: 60, letter: 'C', label: 'Fix before shipping', color: 'yellow' },
45
- { min: 40, letter: 'D', label: 'Significant security risks', color: 'red' },
46
- { min: 0, letter: 'F', label: 'Not safe to ship', color: 'red' },
47
- ];
48
-
49
- // =============================================================================
50
- // SCORING ENGINE
51
- // =============================================================================
52
-
53
- export class ScoringEngine {
54
- /**
55
- * Compute the security score from agent findings + dependency vulnerabilities.
56
- *
57
- * @param {object[]} findings — Array of finding objects from agents
58
- * @param {object[]} depVulns — Array of dependency CVE objects
59
- * @returns {object} — { score, grade, categories, breakdown }
60
- */
61
- compute(findings = [], depVulns = []) {
62
- const categoryResults = {};
63
-
64
- // Initialize all categories
65
- for (const [key, config] of Object.entries(CATEGORIES)) {
66
- categoryResults[key] = {
67
- label: config.label,
68
- weight: config.weight,
69
- counts: { critical: 0, high: 0, medium: 0, low: 0 },
70
- deduction: 0,
71
- maxDeduction: config.weight, // Cap at category weight
72
- findings: [],
73
- };
74
- }
75
-
76
- // ── Classify findings into categories ─────────────────────────────────────
77
- for (const finding of findings) {
78
- const cat = this.resolveCategory(finding.category);
79
- if (!cat || !categoryResults[cat]) continue;
80
-
81
- const sev = finding.severity || 'medium';
82
- categoryResults[cat].counts[sev] = (categoryResults[cat].counts[sev] || 0) + 1;
83
- categoryResults[cat].findings.push(finding);
84
- }
85
-
86
- // ── Add dependency vulnerabilities ────────────────────────────────────────
87
- for (const vuln of depVulns) {
88
- const sev = vuln.severity || 'medium';
89
- categoryResults.deps.counts[sev] = (categoryResults.deps.counts[sev] || 0) + 1;
90
- }
91
-
92
- // ── Compute deductions per category ───────────────────────────────────────
93
- for (const [key, config] of Object.entries(CATEGORIES)) {
94
- const result = categoryResults[key];
95
- let deduction = 0;
96
-
97
- for (const [sev, pts] of Object.entries(config.deductions)) {
98
- deduction += (result.counts[sev] || 0) * pts;
99
- }
100
-
101
- result.deduction = Math.min(deduction, result.maxDeduction);
102
- }
103
-
104
- // ── Compute total score ───────────────────────────────────────────────────
105
- const totalDeduction = Object.values(categoryResults).reduce(
106
- (sum, r) => sum + r.deduction, 0
107
- );
108
- const score = Math.max(0, 100 - totalDeduction);
109
- const grade = GRADES.find(g => score >= g.min);
110
-
111
- return {
112
- score,
113
- grade,
114
- categories: categoryResults,
115
- totalFindings: findings.length,
116
- totalDepVulns: depVulns.length,
117
- };
118
- }
119
-
120
- /**
121
- * Map a finding category to a scoring category.
122
- */
123
- resolveCategory(findingCategory) {
124
- if (CATEGORIES[findingCategory]) return findingCategory;
125
- if (FALLBACK_CATEGORY_MAP[findingCategory] !== undefined) {
126
- return FALLBACK_CATEGORY_MAP[findingCategory];
127
- }
128
- return 'injection'; // default fallback
129
- }
130
-
131
- /**
132
- * Save score to history file for trend tracking.
133
- */
134
- saveToHistory(rootPath, scoreResult) {
135
- const historyDir = path.join(rootPath, '.ship-safe');
136
- const historyFile = path.join(historyDir, 'history.json');
137
-
138
- try {
139
- if (!fs.existsSync(historyDir)) {
140
- fs.mkdirSync(historyDir, { recursive: true });
141
- }
142
-
143
- let history = [];
144
- if (fs.existsSync(historyFile)) {
145
- try {
146
- history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
147
- } catch { history = []; }
148
- }
149
-
150
- history.push({
151
- timestamp: new Date().toISOString(),
152
- score: scoreResult.score,
153
- grade: scoreResult.grade.letter,
154
- totalFindings: scoreResult.totalFindings,
155
- totalDepVulns: scoreResult.totalDepVulns,
156
- categoryScores: Object.fromEntries(
157
- Object.entries(scoreResult.categories).map(([k, v]) => [k, {
158
- deduction: v.deduction,
159
- counts: v.counts,
160
- }])
161
- ),
162
- });
163
-
164
- // Keep last 100 entries
165
- if (history.length > 100) history = history.slice(-100);
166
-
167
- fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));
168
- } catch {
169
- // Don't fail if history save fails
170
- }
171
- }
172
-
173
- /**
174
- * Load score history for trend display.
175
- */
176
- loadHistory(rootPath) {
177
- const historyFile = path.join(rootPath, '.ship-safe', 'history.json');
178
- try {
179
- if (fs.existsSync(historyFile)) {
180
- return JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
181
- }
182
- } catch { /* ignore */ }
183
- return [];
184
- }
185
-
186
- /**
187
- * Get trend summary comparing current to last scan.
188
- */
189
- getTrend(rootPath, currentScore) {
190
- const history = this.loadHistory(rootPath);
191
- if (history.length < 2) return null;
192
-
193
- const previous = history[history.length - 2];
194
- const diff = currentScore - previous.score;
195
-
196
- return {
197
- previousScore: previous.score,
198
- currentScore,
199
- diff,
200
- direction: diff > 0 ? 'improved' : diff < 0 ? 'regressed' : 'unchanged',
201
- previousDate: previous.timestamp,
202
- };
203
- }
204
- }
205
-
206
- export { GRADES, CATEGORIES };
207
- export default ScoringEngine;
1
+ /**
2
+ * Enhanced Scoring Engine
3
+ * ========================
4
+ *
5
+ * Risk-based scoring with 8 categories, EPSS integration,
6
+ * KEV flagging, and historical trend tracking.
7
+ *
8
+ * Score = 100 - sum(category deductions)
9
+ * Each category has a weight and max deduction cap.
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+
15
+ // =============================================================================
16
+ // SCORING CONFIGURATION
17
+ // =============================================================================
18
+
19
+ const CATEGORIES = {
20
+ secrets: { weight: 15, label: 'Secrets', deductions: { critical: 25, high: 15, medium: 5 } },
21
+ injection: { weight: 15, label: 'Code Vulnerabilities', deductions: { critical: 20, high: 10, medium: 3 } },
22
+ deps: { weight: 15, label: 'Dependencies', deductions: { critical: 20, high: 10, medium: 5, moderate: 5 } },
23
+ auth: { weight: 15, label: 'Auth & Access Control', deductions: { critical: 20, high: 10, medium: 3 } },
24
+ config: { weight: 10, label: 'Configuration', deductions: { critical: 15, high: 8, medium: 3 } },
25
+ 'supply-chain':{ weight: 10, label: 'Supply Chain', deductions: { critical: 15, high: 8, medium: 3 } },
26
+ api: { weight: 10, label: 'API Security', deductions: { critical: 15, high: 8, medium: 3 } },
27
+ llm: { weight: 10, label: 'AI/LLM Security', deductions: { critical: 15, high: 8, medium: 3 } },
28
+ };
29
+
30
+ // Fallback categories for findings that don't match a known category
31
+ const FALLBACK_CATEGORY_MAP = {
32
+ 'secret': 'secrets',
33
+ 'vulnerability': 'injection',
34
+ 'ssrf': 'injection',
35
+ 'history': 'secrets',
36
+ 'cicd': 'config',
37
+ 'mobile': 'injection',
38
+ 'recon': null, // skip recon findings
39
+ };
40
+
41
+ const GRADES = [
42
+ { min: 90, letter: 'A', label: 'Ship it!', color: 'green' },
43
+ { min: 75, letter: 'B', label: 'Minor issues to review', color: 'cyan' },
44
+ { min: 60, letter: 'C', label: 'Fix before shipping', color: 'yellow' },
45
+ { min: 40, letter: 'D', label: 'Significant security risks', color: 'red' },
46
+ { min: 0, letter: 'F', label: 'Not safe to ship', color: 'red' },
47
+ ];
48
+
49
+ // =============================================================================
50
+ // SCORING ENGINE
51
+ // =============================================================================
52
+
53
+ export class ScoringEngine {
54
+ /**
55
+ * Compute the security score from agent findings + dependency vulnerabilities.
56
+ *
57
+ * @param {object[]} findings — Array of finding objects from agents
58
+ * @param {object[]} depVulns — Array of dependency CVE objects
59
+ * @returns {object} — { score, grade, categories, breakdown }
60
+ */
61
+ compute(findings = [], depVulns = []) {
62
+ const categoryResults = {};
63
+
64
+ // Initialize all categories
65
+ for (const [key, config] of Object.entries(CATEGORIES)) {
66
+ categoryResults[key] = {
67
+ label: config.label,
68
+ weight: config.weight,
69
+ counts: { critical: 0, high: 0, medium: 0, low: 0 },
70
+ deduction: 0,
71
+ maxDeduction: config.weight, // Cap at category weight
72
+ findings: [],
73
+ };
74
+ }
75
+
76
+ // ── Classify findings into categories ─────────────────────────────────────
77
+ for (const finding of findings) {
78
+ const cat = this.resolveCategory(finding.category);
79
+ if (!cat || !categoryResults[cat]) continue;
80
+
81
+ const sev = finding.severity || 'medium';
82
+ categoryResults[cat].counts[sev] = (categoryResults[cat].counts[sev] || 0) + 1;
83
+ categoryResults[cat].findings.push(finding);
84
+ }
85
+
86
+ // ── Add dependency vulnerabilities ────────────────────────────────────────
87
+ for (const vuln of depVulns) {
88
+ const sev = vuln.severity || 'medium';
89
+ categoryResults.deps.counts[sev] = (categoryResults.deps.counts[sev] || 0) + 1;
90
+ }
91
+
92
+ // ── Compute deductions per category (confidence-weighted) ─────────────────
93
+ const CONFIDENCE_MULTIPLIER = { high: 1.0, medium: 0.6, low: 0.3 };
94
+
95
+ for (const [key, config] of Object.entries(CATEGORIES)) {
96
+ const result = categoryResults[key];
97
+ let deduction = 0;
98
+
99
+ // Count-based deductions for deps (no per-finding confidence)
100
+ for (const [sev, pts] of Object.entries(config.deductions)) {
101
+ if (key === 'deps') {
102
+ deduction += (result.counts[sev] || 0) * pts;
103
+ }
104
+ }
105
+
106
+ // Per-finding confidence-weighted deductions for agent findings
107
+ if (key !== 'deps') {
108
+ for (const finding of result.findings) {
109
+ const sev = finding.severity || 'medium';
110
+ const pts = config.deductions[sev] || 0;
111
+ const confidence = finding.confidence || 'high';
112
+ const multiplier = CONFIDENCE_MULTIPLIER[confidence] || 1.0;
113
+ deduction += pts * multiplier;
114
+ }
115
+ }
116
+
117
+ result.deduction = Math.min(deduction, result.maxDeduction);
118
+ }
119
+
120
+ // ── Compute total score ───────────────────────────────────────────────────
121
+ const totalDeduction = Object.values(categoryResults).reduce(
122
+ (sum, r) => sum + r.deduction, 0
123
+ );
124
+ const score = Math.max(0, 100 - totalDeduction);
125
+ const grade = GRADES.find(g => score >= g.min);
126
+
127
+ return {
128
+ score,
129
+ grade,
130
+ categories: categoryResults,
131
+ totalFindings: findings.length,
132
+ totalDepVulns: depVulns.length,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Map a finding category to a scoring category.
138
+ */
139
+ resolveCategory(findingCategory) {
140
+ if (CATEGORIES[findingCategory]) return findingCategory;
141
+ if (FALLBACK_CATEGORY_MAP[findingCategory] !== undefined) {
142
+ return FALLBACK_CATEGORY_MAP[findingCategory];
143
+ }
144
+ return 'injection'; // default fallback
145
+ }
146
+
147
+ /**
148
+ * Save score to history file for trend tracking.
149
+ */
150
+ saveToHistory(rootPath, scoreResult, suppressions = null) {
151
+ const historyDir = path.join(rootPath, '.ship-safe');
152
+ const historyFile = path.join(historyDir, 'history.json');
153
+
154
+ try {
155
+ if (!fs.existsSync(historyDir)) {
156
+ fs.mkdirSync(historyDir, { recursive: true });
157
+ }
158
+
159
+ let history = [];
160
+ if (fs.existsSync(historyFile)) {
161
+ try {
162
+ history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
163
+ } catch { history = []; }
164
+ }
165
+
166
+ const entry = {
167
+ timestamp: new Date().toISOString(),
168
+ score: scoreResult.score,
169
+ grade: scoreResult.grade.letter,
170
+ totalFindings: scoreResult.totalFindings,
171
+ totalDepVulns: scoreResult.totalDepVulns,
172
+ categoryScores: Object.fromEntries(
173
+ Object.entries(scoreResult.categories).map(([k, v]) => [k, {
174
+ deduction: v.deduction,
175
+ counts: v.counts,
176
+ }])
177
+ ),
178
+ };
179
+ if (suppressions) entry.suppressions = suppressions;
180
+ history.push(entry);
181
+
182
+ // Keep last 100 entries
183
+ if (history.length > 100) history = history.slice(-100);
184
+
185
+ fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));
186
+ } catch {
187
+ // Don't fail if history save fails
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Load score history for trend display.
193
+ */
194
+ loadHistory(rootPath) {
195
+ const historyFile = path.join(rootPath, '.ship-safe', 'history.json');
196
+ try {
197
+ if (fs.existsSync(historyFile)) {
198
+ return JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
199
+ }
200
+ } catch { /* ignore */ }
201
+ return [];
202
+ }
203
+
204
+ /**
205
+ * Get trend summary comparing current to last scan.
206
+ */
207
+ getTrend(rootPath, currentScore) {
208
+ const history = this.loadHistory(rootPath);
209
+ if (history.length < 2) return null;
210
+
211
+ const previous = history[history.length - 2];
212
+ const diff = currentScore - previous.score;
213
+
214
+ return {
215
+ previousScore: previous.score,
216
+ currentScore,
217
+ diff,
218
+ direction: diff > 0 ? 'improved' : diff < 0 ? 'regressed' : 'unchanged',
219
+ previousDate: previous.timestamp,
220
+ };
221
+ }
222
+ }
223
+
224
+ export { GRADES, CATEGORIES };
225
+ export default ScoringEngine;
@@ -34,6 +34,7 @@ import { scoreCommand } from '../commands/score.js';
34
34
  import { redTeamCommand } from '../commands/red-team.js';
35
35
  import { watchCommand } from '../commands/watch.js';
36
36
  import { auditCommand } from '../commands/audit.js';
37
+ import { doctorCommand } from '../commands/doctor.js';
37
38
  import { PolicyEngine } from '../agents/policy-engine.js';
38
39
  import { SBOMGenerator } from '../agents/sbom-generator.js';
39
40
 
@@ -82,6 +83,7 @@ program
82
83
  .option('--json', 'Output results as JSON (useful for CI)')
83
84
  .option('--sarif', 'Output results in SARIF format (for GitHub Code Scanning)')
84
85
  .option('--include-tests', 'Also scan test files (excluded by default to reduce false positives)')
86
+ .option('--no-cache', 'Force full rescan (ignore cached results)')
85
87
  .action(scanCommand);
86
88
 
87
89
  // -----------------------------------------------------------------------------
@@ -187,9 +189,14 @@ program
187
189
  .description('Full security audit: secrets + 12 agents + deps + score + remediation plan')
188
190
  .option('--json', 'Output results as JSON')
189
191
  .option('--sarif', 'Output results in SARIF format')
192
+ .option('--csv', 'Output results as CSV')
193
+ .option('--md', 'Output results as Markdown')
190
194
  .option('--html [file]', 'HTML report path (default: ship-safe-report.html)')
195
+ .option('--compare', 'Show detailed comparison with last scan')
196
+ .option('--timeout <ms>', 'Per-agent timeout in milliseconds (default: 30000)', parseInt)
191
197
  .option('--no-deps', 'Skip dependency audit')
192
198
  .option('--no-ai', 'Skip AI classification')
199
+ .option('--no-cache', 'Force full rescan (ignore cached results)')
193
200
  .option('-v, --verbose', 'Verbose output')
194
201
  .action(auditCommand);
195
202
 
@@ -248,6 +255,14 @@ program
248
255
  }
249
256
  });
250
257
 
258
+ // -----------------------------------------------------------------------------
259
+ // DOCTOR COMMAND
260
+ // -----------------------------------------------------------------------------
261
+ program
262
+ .command('doctor')
263
+ .description('Diagnose environment: check Node.js, git, API keys, cache, and dependencies')
264
+ .action(doctorCommand);
265
+
251
266
  // -----------------------------------------------------------------------------
252
267
  // PARSE AND RUN
253
268
  // -----------------------------------------------------------------------------
@@ -262,6 +277,7 @@ if (process.argv.length === 2) {
262
277
  console.log(chalk.white(' npx ship-safe watch . ') + chalk.gray('# Continuous monitoring mode'));
263
278
  console.log(chalk.white(' npx ship-safe sbom . ') + chalk.gray('# Generate CycloneDX SBOM'));
264
279
  console.log(chalk.white(' npx ship-safe policy init ') + chalk.gray('# Create security policy template'));
280
+ console.log(chalk.white(' npx ship-safe doctor ') + chalk.gray('# Check environment and configuration'));
265
281
  console.log();
266
282
  console.log(chalk.gray(' Core commands:'));
267
283
  console.log(chalk.white(' npx ship-safe agent . ') + chalk.gray('# AI audit: scan + classify + auto-fix'));