ship-safe 3.2.0 → 4.1.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.
- package/README.md +209 -437
- package/cli/agents/api-fuzzer.js +224 -0
- package/cli/agents/auth-bypass-agent.js +326 -0
- package/cli/agents/base-agent.js +253 -0
- package/cli/agents/cicd-scanner.js +200 -0
- package/cli/agents/config-auditor.js +413 -0
- package/cli/agents/git-history-scanner.js +167 -0
- package/cli/agents/html-reporter.js +363 -0
- package/cli/agents/index.js +56 -0
- package/cli/agents/injection-tester.js +401 -0
- package/cli/agents/llm-redteam.js +251 -0
- package/cli/agents/mobile-scanner.js +225 -0
- package/cli/agents/orchestrator.js +157 -0
- package/cli/agents/policy-engine.js +149 -0
- package/cli/agents/recon-agent.js +196 -0
- package/cli/agents/sbom-generator.js +176 -0
- package/cli/agents/scoring-engine.js +207 -0
- package/cli/agents/ssrf-prober.js +130 -0
- package/cli/agents/supply-chain-agent.js +274 -0
- package/cli/bin/ship-safe.js +85 -3
- package/cli/commands/audit.js +620 -0
- package/cli/commands/red-team.js +315 -0
- package/cli/commands/scan.js +79 -8
- package/cli/commands/watch.js +160 -0
- package/cli/index.js +39 -1
- package/cli/providers/llm-provider.js +288 -0
- package/cli/utils/cache-manager.js +258 -0
- package/cli/utils/patterns.js +95 -0
- package/package.json +18 -14
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReconAgent — Attack Surface Discovery
|
|
3
|
+
* =======================================
|
|
4
|
+
*
|
|
5
|
+
* Maps the full attack surface before other agents run.
|
|
6
|
+
* Detects frameworks, API routes, auth patterns, databases,
|
|
7
|
+
* cloud providers, and frontend exposure.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { BaseAgent } from './base-agent.js';
|
|
13
|
+
|
|
14
|
+
export class ReconAgent extends BaseAgent {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('ReconAgent', 'Attack surface discovery and mapping', 'recon');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async analyze(context) {
|
|
20
|
+
const { rootPath } = context;
|
|
21
|
+
const files = await this.discoverFiles(rootPath);
|
|
22
|
+
|
|
23
|
+
const recon = {
|
|
24
|
+
frameworks: [],
|
|
25
|
+
languages: new Set(),
|
|
26
|
+
apiRoutes: [],
|
|
27
|
+
authPatterns: [],
|
|
28
|
+
databases: [],
|
|
29
|
+
cloudProviders: [],
|
|
30
|
+
frontendExposure: [],
|
|
31
|
+
packageManagers: [],
|
|
32
|
+
cicd: [],
|
|
33
|
+
hasDockerfile: false,
|
|
34
|
+
hasTerraform: false,
|
|
35
|
+
hasKubernetes: false,
|
|
36
|
+
envFiles: [],
|
|
37
|
+
configFiles: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── Detect by config files ────────────────────────────────────────────────
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
const basename = path.basename(file);
|
|
43
|
+
const relPath = path.relative(rootPath, file).replace(/\\/g, '/');
|
|
44
|
+
const ext = path.extname(file).toLowerCase();
|
|
45
|
+
|
|
46
|
+
// Languages
|
|
47
|
+
if (['.js', '.jsx', '.mjs', '.cjs'].includes(ext)) recon.languages.add('javascript');
|
|
48
|
+
if (['.ts', '.tsx'].includes(ext)) recon.languages.add('typescript');
|
|
49
|
+
if (ext === '.py') recon.languages.add('python');
|
|
50
|
+
if (ext === '.rb') recon.languages.add('ruby');
|
|
51
|
+
if (ext === '.go') recon.languages.add('go');
|
|
52
|
+
if (ext === '.java') recon.languages.add('java');
|
|
53
|
+
if (ext === '.rs') recon.languages.add('rust');
|
|
54
|
+
if (ext === '.php') recon.languages.add('php');
|
|
55
|
+
|
|
56
|
+
// Frameworks
|
|
57
|
+
if (basename === 'next.config.js' || basename === 'next.config.mjs' || basename === 'next.config.ts') {
|
|
58
|
+
recon.frameworks.push('nextjs');
|
|
59
|
+
}
|
|
60
|
+
if (basename === 'nuxt.config.ts' || basename === 'nuxt.config.js') recon.frameworks.push('nuxtjs');
|
|
61
|
+
if (basename === 'svelte.config.js') recon.frameworks.push('sveltekit');
|
|
62
|
+
if (basename === 'remix.config.js') recon.frameworks.push('remix');
|
|
63
|
+
if (basename === 'astro.config.mjs' || basename === 'astro.config.ts') recon.frameworks.push('astro');
|
|
64
|
+
if (basename === 'angular.json') recon.frameworks.push('angular');
|
|
65
|
+
if (basename === 'vite.config.ts' || basename === 'vite.config.js') recon.frameworks.push('vite');
|
|
66
|
+
if (basename === 'manage.py') recon.frameworks.push('django');
|
|
67
|
+
if (basename === 'Gemfile' && this.readFile(file)?.includes('rails')) recon.frameworks.push('rails');
|
|
68
|
+
if (basename === 'pubspec.yaml') recon.frameworks.push('flutter');
|
|
69
|
+
|
|
70
|
+
// API Routes
|
|
71
|
+
if (relPath.match(/app\/api\/.*\.(js|ts)$/) || relPath.match(/pages\/api\/.*\.(js|ts)$/)) {
|
|
72
|
+
recon.apiRoutes.push(relPath);
|
|
73
|
+
}
|
|
74
|
+
if (relPath.match(/routes?\.(js|ts)$/) || relPath.match(/router\.(js|ts)$/)) {
|
|
75
|
+
recon.apiRoutes.push(relPath);
|
|
76
|
+
}
|
|
77
|
+
if (relPath.match(/urls\.py$/)) recon.apiRoutes.push(relPath);
|
|
78
|
+
if (relPath.match(/controllers?\/.*\.(js|ts|rb)$/)) recon.apiRoutes.push(relPath);
|
|
79
|
+
|
|
80
|
+
// Auth
|
|
81
|
+
if (basename === 'auth.ts' || basename === 'auth.js' || relPath.includes('auth/')) {
|
|
82
|
+
recon.authPatterns.push(relPath);
|
|
83
|
+
}
|
|
84
|
+
if (basename === 'middleware.ts' || basename === 'middleware.js') {
|
|
85
|
+
recon.authPatterns.push(relPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Databases
|
|
89
|
+
if (basename === 'schema.prisma') recon.databases.push('prisma');
|
|
90
|
+
if (basename === 'drizzle.config.ts' || basename === 'drizzle.config.js') recon.databases.push('drizzle');
|
|
91
|
+
if (relPath.includes('models/') && ['.py', '.rb'].includes(ext)) recon.databases.push('orm');
|
|
92
|
+
|
|
93
|
+
// Cloud
|
|
94
|
+
if (basename === 'vercel.json') recon.cloudProviders.push('vercel');
|
|
95
|
+
if (basename === 'netlify.toml') recon.cloudProviders.push('netlify');
|
|
96
|
+
if (basename === 'fly.toml') recon.cloudProviders.push('fly');
|
|
97
|
+
if (basename === 'app.yaml' || basename === 'app.yml') recon.cloudProviders.push('gcp');
|
|
98
|
+
if (basename === 'serverless.yml' || basename === 'serverless.yaml') recon.cloudProviders.push('aws-serverless');
|
|
99
|
+
if (basename === 'render.yaml') recon.cloudProviders.push('render');
|
|
100
|
+
if (basename === 'railway.json') recon.cloudProviders.push('railway');
|
|
101
|
+
|
|
102
|
+
// IaC
|
|
103
|
+
if (ext === '.tf') recon.hasTerraform = true;
|
|
104
|
+
if (basename === 'Dockerfile' || basename.startsWith('Dockerfile.')) recon.hasDockerfile = true;
|
|
105
|
+
if (basename.match(/\.ya?ml$/) && relPath.match(/(k8s|kubernetes|deploy|helm)/i)) {
|
|
106
|
+
recon.hasKubernetes = true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// CI/CD
|
|
110
|
+
if (relPath.startsWith('.github/workflows/')) recon.cicd.push({ platform: 'github-actions', file: relPath });
|
|
111
|
+
if (basename === '.gitlab-ci.yml') recon.cicd.push({ platform: 'gitlab', file: relPath });
|
|
112
|
+
if (basename === 'Jenkinsfile') recon.cicd.push({ platform: 'jenkins', file: relPath });
|
|
113
|
+
if (basename === '.circleci/config.yml' || relPath.startsWith('.circleci/')) recon.cicd.push({ platform: 'circleci', file: relPath });
|
|
114
|
+
if (basename === 'bitbucket-pipelines.yml') recon.cicd.push({ platform: 'bitbucket', file: relPath });
|
|
115
|
+
if (basename === 'azure-pipelines.yml') recon.cicd.push({ platform: 'azure', file: relPath });
|
|
116
|
+
|
|
117
|
+
// Package managers
|
|
118
|
+
if (basename === 'package.json') recon.packageManagers.push('npm');
|
|
119
|
+
if (basename === 'Pipfile' || basename === 'requirements.txt' || basename === 'pyproject.toml') recon.packageManagers.push('pip');
|
|
120
|
+
if (basename === 'Gemfile') recon.packageManagers.push('bundler');
|
|
121
|
+
if (basename === 'go.mod') recon.packageManagers.push('go');
|
|
122
|
+
if (basename === 'Cargo.toml') recon.packageManagers.push('cargo');
|
|
123
|
+
if (basename === 'composer.json') recon.packageManagers.push('composer');
|
|
124
|
+
|
|
125
|
+
// Env files
|
|
126
|
+
if (basename.startsWith('.env')) recon.envFiles.push(relPath);
|
|
127
|
+
|
|
128
|
+
// Config files
|
|
129
|
+
if (['vercel.json', 'netlify.toml', 'next.config.js', 'next.config.mjs',
|
|
130
|
+
'next.config.ts', 'docker-compose.yml', 'docker-compose.yaml',
|
|
131
|
+
'nginx.conf', 'Caddyfile', 'firebase.json', 'supabase/config.toml'
|
|
132
|
+
].includes(basename)) {
|
|
133
|
+
recon.configFiles.push(relPath);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Detect frontend exposure from package.json ────────────────────────────
|
|
138
|
+
const pkgPath = path.join(rootPath, 'package.json');
|
|
139
|
+
if (fs.existsSync(pkgPath)) {
|
|
140
|
+
try {
|
|
141
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
142
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
143
|
+
|
|
144
|
+
if (allDeps['express']) recon.frameworks.push('express');
|
|
145
|
+
if (allDeps['fastify']) recon.frameworks.push('fastify');
|
|
146
|
+
if (allDeps['hono']) recon.frameworks.push('hono');
|
|
147
|
+
if (allDeps['@hono/node-server']) recon.frameworks.push('hono');
|
|
148
|
+
if (allDeps['koa']) recon.frameworks.push('koa');
|
|
149
|
+
if (allDeps['flask'] || allDeps['Flask']) recon.frameworks.push('flask');
|
|
150
|
+
if (allDeps['fastapi'] || allDeps['FastAPI']) recon.frameworks.push('fastapi');
|
|
151
|
+
|
|
152
|
+
// Auth libraries
|
|
153
|
+
if (allDeps['next-auth'] || allDeps['@auth/core']) recon.authPatterns.push('next-auth');
|
|
154
|
+
if (allDeps['@clerk/nextjs'] || allDeps['@clerk/clerk-react']) recon.authPatterns.push('clerk');
|
|
155
|
+
if (allDeps['@supabase/supabase-js']) { recon.authPatterns.push('supabase-auth'); recon.databases.push('supabase'); }
|
|
156
|
+
if (allDeps['firebase']) { recon.authPatterns.push('firebase-auth'); recon.databases.push('firebase'); }
|
|
157
|
+
if (allDeps['jsonwebtoken'] || allDeps['jose']) recon.authPatterns.push('jwt');
|
|
158
|
+
if (allDeps['passport']) recon.authPatterns.push('passport');
|
|
159
|
+
|
|
160
|
+
// Databases
|
|
161
|
+
if (allDeps['@prisma/client'] || allDeps['prisma']) recon.databases.push('prisma');
|
|
162
|
+
if (allDeps['drizzle-orm']) recon.databases.push('drizzle');
|
|
163
|
+
if (allDeps['sequelize']) recon.databases.push('sequelize');
|
|
164
|
+
if (allDeps['typeorm']) recon.databases.push('typeorm');
|
|
165
|
+
if (allDeps['mongoose'] || allDeps['mongodb']) recon.databases.push('mongodb');
|
|
166
|
+
if (allDeps['pg'] || allDeps['postgres']) recon.databases.push('postgres');
|
|
167
|
+
if (allDeps['mysql2'] || allDeps['mysql']) recon.databases.push('mysql');
|
|
168
|
+
if (allDeps['@upstash/redis']) recon.databases.push('upstash-redis');
|
|
169
|
+
|
|
170
|
+
// AI/LLM
|
|
171
|
+
if (allDeps['openai'] || allDeps['@anthropic-ai/sdk'] || allDeps['ai']) {
|
|
172
|
+
recon.frameworks.push('ai-app');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Mobile
|
|
176
|
+
if (allDeps['react-native'] || allDeps['expo']) recon.frameworks.push('react-native');
|
|
177
|
+
|
|
178
|
+
} catch { /* skip parse errors */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Deduplicate arrays
|
|
182
|
+
recon.frameworks = [...new Set(recon.frameworks)];
|
|
183
|
+
recon.languages = [...recon.languages];
|
|
184
|
+
recon.authPatterns = [...new Set(recon.authPatterns)];
|
|
185
|
+
recon.databases = [...new Set(recon.databases)];
|
|
186
|
+
recon.cloudProviders = [...new Set(recon.cloudProviders)];
|
|
187
|
+
recon.packageManagers = [...new Set(recon.packageManagers)];
|
|
188
|
+
|
|
189
|
+
// Store on context for other agents
|
|
190
|
+
if (context) context.recon = recon;
|
|
191
|
+
|
|
192
|
+
return recon;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default ReconAgent;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SBOM Generator
|
|
3
|
+
* ===============
|
|
4
|
+
*
|
|
5
|
+
* Generates Software Bill of Materials in CycloneDX JSON format.
|
|
6
|
+
* Parses package.json, requirements.txt, Gemfile, go.mod, etc.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
export class SBOMGenerator {
|
|
13
|
+
/**
|
|
14
|
+
* Generate a CycloneDX 1.5 SBOM from the project.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} rootPath — Project root directory
|
|
17
|
+
* @returns {object} — CycloneDX JSON object
|
|
18
|
+
*/
|
|
19
|
+
generate(rootPath) {
|
|
20
|
+
const components = [];
|
|
21
|
+
|
|
22
|
+
// ── npm/yarn/pnpm ─────────────────────────────────────────────────────────
|
|
23
|
+
const pkgPath = path.join(rootPath, 'package.json');
|
|
24
|
+
if (fs.existsSync(pkgPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
27
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
28
|
+
|
|
29
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
30
|
+
components.push({
|
|
31
|
+
type: 'library',
|
|
32
|
+
name,
|
|
33
|
+
version: version.replace(/^[\^~>=<]/, ''),
|
|
34
|
+
purl: `pkg:npm/${name.replace('/', '%2F')}@${version.replace(/^[\^~>=<]/, '')}`,
|
|
35
|
+
scope: pkg.dependencies?.[name] ? 'required' : 'optional',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} catch { /* skip */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Python requirements.txt ───────────────────────────────────────────────
|
|
42
|
+
const reqPath = path.join(rootPath, 'requirements.txt');
|
|
43
|
+
if (fs.existsSync(reqPath)) {
|
|
44
|
+
try {
|
|
45
|
+
const lines = fs.readFileSync(reqPath, 'utf-8').split('\n');
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
49
|
+
const match = trimmed.match(/^([a-zA-Z0-9_-]+)(?:==|>=|~=)?(.+)?$/);
|
|
50
|
+
if (match) {
|
|
51
|
+
components.push({
|
|
52
|
+
type: 'library',
|
|
53
|
+
name: match[1],
|
|
54
|
+
version: match[2] || 'unspecified',
|
|
55
|
+
purl: `pkg:pypi/${match[1]}@${match[2] || 'latest'}`,
|
|
56
|
+
scope: 'required',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* skip */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Go modules ────────────────────────────────────────────────────────────
|
|
64
|
+
const goModPath = path.join(rootPath, 'go.mod');
|
|
65
|
+
if (fs.existsSync(goModPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const content = fs.readFileSync(goModPath, 'utf-8');
|
|
68
|
+
const requireBlock = content.match(/require\s*\(([\s\S]*?)\)/);
|
|
69
|
+
if (requireBlock) {
|
|
70
|
+
const lines = requireBlock[1].split('\n');
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const match = line.trim().match(/^(\S+)\s+v?(\S+)/);
|
|
73
|
+
if (match) {
|
|
74
|
+
components.push({
|
|
75
|
+
type: 'library',
|
|
76
|
+
name: match[1],
|
|
77
|
+
version: match[2],
|
|
78
|
+
purl: `pkg:golang/${match[1]}@${match[2]}`,
|
|
79
|
+
scope: 'required',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch { /* skip */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Rust Cargo.toml ───────────────────────────────────────────────────────
|
|
88
|
+
const cargoPath = path.join(rootPath, 'Cargo.toml');
|
|
89
|
+
if (fs.existsSync(cargoPath)) {
|
|
90
|
+
try {
|
|
91
|
+
const content = fs.readFileSync(cargoPath, 'utf-8');
|
|
92
|
+
const depsSection = content.match(/\[dependencies\]([\s\S]*?)(?:\[|$)/);
|
|
93
|
+
if (depsSection) {
|
|
94
|
+
const lines = depsSection[1].split('\n');
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const match = line.trim().match(/^([a-zA-Z0-9_-]+)\s*=\s*"([^"]+)"/);
|
|
97
|
+
if (match) {
|
|
98
|
+
components.push({
|
|
99
|
+
type: 'library',
|
|
100
|
+
name: match[1],
|
|
101
|
+
version: match[2],
|
|
102
|
+
purl: `pkg:cargo/${match[1]}@${match[2]}`,
|
|
103
|
+
scope: 'required',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch { /* skip */ }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Build CycloneDX BOM ───────────────────────────────────────────────────
|
|
112
|
+
const bom = {
|
|
113
|
+
bomFormat: 'CycloneDX',
|
|
114
|
+
specVersion: '1.5',
|
|
115
|
+
serialNumber: `urn:uuid:${this.uuid()}`,
|
|
116
|
+
version: 1,
|
|
117
|
+
metadata: {
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
tools: [{
|
|
120
|
+
vendor: 'ship-safe',
|
|
121
|
+
name: 'ship-safe',
|
|
122
|
+
version: '4.0.0',
|
|
123
|
+
}],
|
|
124
|
+
component: this.getProjectMetadata(rootPath),
|
|
125
|
+
},
|
|
126
|
+
components: components.map((c, i) => ({
|
|
127
|
+
'bom-ref': `component-${i}`,
|
|
128
|
+
type: c.type,
|
|
129
|
+
name: c.name,
|
|
130
|
+
version: c.version,
|
|
131
|
+
purl: c.purl,
|
|
132
|
+
scope: c.scope,
|
|
133
|
+
})),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return bom;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate SBOM and write to file.
|
|
141
|
+
*/
|
|
142
|
+
generateToFile(rootPath, outputPath, format = 'cyclonedx') {
|
|
143
|
+
const bom = this.generate(rootPath);
|
|
144
|
+
const output = JSON.stringify(bom, null, 2);
|
|
145
|
+
fs.writeFileSync(outputPath, output);
|
|
146
|
+
return outputPath;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getProjectMetadata(rootPath) {
|
|
150
|
+
const pkgPath = path.join(rootPath, 'package.json');
|
|
151
|
+
try {
|
|
152
|
+
if (fs.existsSync(pkgPath)) {
|
|
153
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
154
|
+
return {
|
|
155
|
+
type: 'application',
|
|
156
|
+
name: pkg.name || path.basename(rootPath),
|
|
157
|
+
version: pkg.version || '0.0.0',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
} catch { /* skip */ }
|
|
161
|
+
return {
|
|
162
|
+
type: 'application',
|
|
163
|
+
name: path.basename(rootPath),
|
|
164
|
+
version: '0.0.0',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
uuid() {
|
|
169
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
170
|
+
const r = Math.random() * 16 | 0;
|
|
171
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default SBOMGenerator;
|
|
@@ -0,0 +1,207 @@
|
|
|
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;
|