agent-security-scanner-mcp 4.1.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.
- package/README.md +394 -1
- package/compliance/gdpr-technical-controls.json +112 -0
- package/compliance/soc2-technical-controls.json +148 -0
- package/index.js +148 -1
- package/openclaw.plugin.json +21 -1
- package/package.json +1 -1
- package/src/lib/compliance-controls.js +100 -21
- package/src/lib/compliance-evaluator.js +150 -9
- package/src/lib/compliance-evidence.js +321 -0
- package/src/lib/cyclonedx.js +113 -0
- package/src/lib/lockfile-parsers.js +671 -0
- package/src/lib/osv-client.js +254 -0
- package/src/lib/purl.js +90 -0
- package/src/lib/sbom-component.js +88 -0
- package/src/tools/compliance-controls.js +22 -12
- package/src/tools/evaluate-compliance.js +161 -0
- package/src/tools/sbom-diff.js +199 -0
- package/src/tools/sbom-generate.js +116 -0
- package/src/tools/sbom-hallucinations.js +117 -0
- package/src/tools/sbom-report.js +271 -0
- package/src/tools/sbom-vulnerabilities.js +121 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { discoverDependencies } from '../lib/lockfile-parsers.js';
|
|
5
|
+
import { serialize } from '../lib/cyclonedx.js';
|
|
6
|
+
import { ecosystemFromPurlType } from '../lib/purl.js';
|
|
7
|
+
|
|
8
|
+
export const sbomDiffSchema = {
|
|
9
|
+
directory_path: z.string().describe('Path to project root directory'),
|
|
10
|
+
baseline_path: z.string().optional().describe('Path to baseline SBOM file (default: .scanner/sbom-baseline.json)'),
|
|
11
|
+
save_baseline: z.boolean().optional().describe('Save current SBOM as the new baseline'),
|
|
12
|
+
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe('Response detail level (default: compact)'),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function sbomDiff({ directory_path, baseline_path, save_baseline, verbosity }) {
|
|
16
|
+
if (!existsSync(directory_path)) {
|
|
17
|
+
return error(`Directory not found: ${directory_path}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Generate current SBOM
|
|
21
|
+
const componentList = discoverDependencies(directory_path);
|
|
22
|
+
const currentBom = serialize(componentList);
|
|
23
|
+
|
|
24
|
+
// Resolve baseline path
|
|
25
|
+
const resolvedBaseline = baseline_path || join(directory_path, '.scanner', 'sbom-baseline.json');
|
|
26
|
+
|
|
27
|
+
// Check if baseline exists BEFORE potentially overwriting it
|
|
28
|
+
const baselineExists = existsSync(resolvedBaseline);
|
|
29
|
+
|
|
30
|
+
// If no baseline exists and save_baseline requested: save and return immediately
|
|
31
|
+
if (!baselineExists && save_baseline) {
|
|
32
|
+
atomicWrite(resolvedBaseline, JSON.stringify(currentBom, null, 2));
|
|
33
|
+
return {
|
|
34
|
+
content: [{
|
|
35
|
+
type: 'text',
|
|
36
|
+
text: JSON.stringify({
|
|
37
|
+
message: `Baseline saved to ${resolvedBaseline}. Run again to compare.`,
|
|
38
|
+
baseline_saved: resolvedBaseline,
|
|
39
|
+
current: {
|
|
40
|
+
total_components: componentList.metadata.total,
|
|
41
|
+
ecosystems: componentList.metadata.ecosystems,
|
|
42
|
+
},
|
|
43
|
+
}, null, 2),
|
|
44
|
+
}],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// If no baseline exists and save_baseline not requested: tell user
|
|
49
|
+
if (!baselineExists) {
|
|
50
|
+
return {
|
|
51
|
+
content: [{
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: JSON.stringify({
|
|
54
|
+
message: `No baseline found at ${resolvedBaseline}. Run with save_baseline=true to create one.`,
|
|
55
|
+
current: {
|
|
56
|
+
total_components: componentList.metadata.total,
|
|
57
|
+
ecosystems: componentList.metadata.ecosystems,
|
|
58
|
+
},
|
|
59
|
+
}, null, 2),
|
|
60
|
+
}],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let baselineBom;
|
|
65
|
+
try {
|
|
66
|
+
baselineBom = JSON.parse(readFileSync(resolvedBaseline, 'utf-8'));
|
|
67
|
+
} catch {
|
|
68
|
+
return error(`Failed to parse baseline: ${resolvedBaseline}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build component maps by name+ecosystem (PURL without version for matching)
|
|
72
|
+
const currentMap = buildComponentMap(currentBom.components || []);
|
|
73
|
+
const baselineMap = buildComponentMap(baselineBom.components || []);
|
|
74
|
+
|
|
75
|
+
const added = [];
|
|
76
|
+
const removed = [];
|
|
77
|
+
const versionChanged = [];
|
|
78
|
+
const unchanged = [];
|
|
79
|
+
|
|
80
|
+
// Find added and version-changed
|
|
81
|
+
for (const [key, comp] of currentMap) {
|
|
82
|
+
const baseComp = baselineMap.get(key);
|
|
83
|
+
if (!baseComp) {
|
|
84
|
+
added.push(comp);
|
|
85
|
+
} else if (baseComp.version !== comp.version) {
|
|
86
|
+
versionChanged.push({
|
|
87
|
+
name: comp.name,
|
|
88
|
+
ecosystem: extractEcosystem(comp),
|
|
89
|
+
old_version: baseComp.version,
|
|
90
|
+
new_version: comp.version,
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
unchanged.push(comp);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find removed
|
|
98
|
+
for (const [key, comp] of baselineMap) {
|
|
99
|
+
if (!currentMap.has(key)) {
|
|
100
|
+
removed.push(comp);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If save_baseline requested and baseline already existed: compare first, then overwrite
|
|
105
|
+
if (save_baseline) {
|
|
106
|
+
atomicWrite(resolvedBaseline, JSON.stringify(currentBom, null, 2));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const level = verbosity || 'compact';
|
|
110
|
+
let response;
|
|
111
|
+
switch (level) {
|
|
112
|
+
case 'minimal':
|
|
113
|
+
response = {
|
|
114
|
+
added: added.length,
|
|
115
|
+
removed: removed.length,
|
|
116
|
+
version_changed: versionChanged.length,
|
|
117
|
+
unchanged: unchanged.length,
|
|
118
|
+
...(save_baseline && { baseline_saved: resolvedBaseline }),
|
|
119
|
+
};
|
|
120
|
+
break;
|
|
121
|
+
case 'full':
|
|
122
|
+
response = {
|
|
123
|
+
added_packages: added.map(formatComponent),
|
|
124
|
+
removed_packages: removed.map(formatComponent),
|
|
125
|
+
version_changed: versionChanged,
|
|
126
|
+
unchanged_count: unchanged.length,
|
|
127
|
+
current_total: currentMap.size,
|
|
128
|
+
baseline_total: baselineMap.size,
|
|
129
|
+
baseline_timestamp: baselineBom.metadata?.timestamp || 'unknown',
|
|
130
|
+
...(save_baseline && { baseline_saved: resolvedBaseline }),
|
|
131
|
+
};
|
|
132
|
+
break;
|
|
133
|
+
case 'compact':
|
|
134
|
+
default:
|
|
135
|
+
response = {
|
|
136
|
+
summary: `+${added.length} added, -${removed.length} removed, ~${versionChanged.length} changed, ${unchanged.length} unchanged`,
|
|
137
|
+
added_packages: added.map(c => ({ name: c.name, version: c.version })),
|
|
138
|
+
removed_packages: removed.map(c => ({ name: c.name, version: c.version })),
|
|
139
|
+
version_changed: versionChanged,
|
|
140
|
+
...(save_baseline && { baseline_saved: resolvedBaseline }),
|
|
141
|
+
};
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function buildComponentMap(components) {
|
|
151
|
+
const map = new Map();
|
|
152
|
+
for (const comp of components) {
|
|
153
|
+
// Key by name + ecosystem (from purl type or properties)
|
|
154
|
+
const eco = extractEcosystem(comp);
|
|
155
|
+
const key = `${eco}:${comp.name}`;
|
|
156
|
+
map.set(key, comp);
|
|
157
|
+
}
|
|
158
|
+
return map;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function extractEcosystem(component) {
|
|
162
|
+
if (component.properties) {
|
|
163
|
+
const eco = component.properties.find(p => p.name === 'cdx:ecosystem');
|
|
164
|
+
if (eco) return eco.value;
|
|
165
|
+
}
|
|
166
|
+
if (component.purl) {
|
|
167
|
+
const match = component.purl.match(/^pkg:([^/]+)/);
|
|
168
|
+
if (match) return ecosystemFromPurlType(match[1]);
|
|
169
|
+
}
|
|
170
|
+
return 'unknown';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatComponent(comp) {
|
|
174
|
+
return {
|
|
175
|
+
name: comp.name,
|
|
176
|
+
version: comp.version,
|
|
177
|
+
purl: comp.purl || '',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Atomic write: temp file + rename (pattern from scan-skill.js:785)
|
|
182
|
+
function atomicWrite(filePath, data) {
|
|
183
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
184
|
+
if (dir && !existsSync(dir)) {
|
|
185
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
186
|
+
}
|
|
187
|
+
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
188
|
+
writeFileSync(tmpFile, data, { encoding: 'utf-8', mode: 0o600 });
|
|
189
|
+
try {
|
|
190
|
+
renameSync(tmpFile, filePath);
|
|
191
|
+
} catch (renameErr) {
|
|
192
|
+
try { unlinkSync(tmpFile); } catch { /* best effort cleanup */ }
|
|
193
|
+
throw renameErr;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function error(msg) {
|
|
198
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
199
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { discoverDependencies } from '../lib/lockfile-parsers.js';
|
|
5
|
+
import { serialize } from '../lib/cyclonedx.js';
|
|
6
|
+
|
|
7
|
+
// Read tool version from package.json
|
|
8
|
+
let toolVersion = '0.0.0';
|
|
9
|
+
try {
|
|
10
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'));
|
|
11
|
+
toolVersion = pkg.version || '0.0.0';
|
|
12
|
+
} catch { /* fallback */ }
|
|
13
|
+
|
|
14
|
+
export const sbomGenerateSchema = {
|
|
15
|
+
directory_path: z.string().describe('Path to project root directory'),
|
|
16
|
+
include_dev: z.boolean().optional().describe('Include dev dependencies (default: true)'),
|
|
17
|
+
output_path: z.string().optional().describe('Path to write SBOM file. Absent = no write, present = write to that path.'),
|
|
18
|
+
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe('Response detail level (default: compact)'),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function sbomGenerate({ directory_path, include_dev, output_path, verbosity }) {
|
|
22
|
+
if (!existsSync(directory_path)) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: 'text', text: JSON.stringify({ error: `Directory not found: ${directory_path}` }) }],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const includeDev = include_dev !== false; // default true
|
|
29
|
+
const level = verbosity || 'compact';
|
|
30
|
+
|
|
31
|
+
// Discover dependencies
|
|
32
|
+
const componentList = discoverDependencies(directory_path, { includeDev });
|
|
33
|
+
|
|
34
|
+
// Serialize to CycloneDX
|
|
35
|
+
const bom = serialize(componentList, [], { toolVersion });
|
|
36
|
+
|
|
37
|
+
// Optionally write to file
|
|
38
|
+
let savedPath = null;
|
|
39
|
+
if (output_path) {
|
|
40
|
+
const dir = join(directory_path, '.scanner');
|
|
41
|
+
const resolvedPath = output_path.includes('/') || output_path.includes('\\')
|
|
42
|
+
? output_path
|
|
43
|
+
: join(dir, output_path);
|
|
44
|
+
|
|
45
|
+
const parentDir = resolvedPath.substring(0, resolvedPath.lastIndexOf('/'));
|
|
46
|
+
if (parentDir && !existsSync(parentDir)) {
|
|
47
|
+
mkdirSync(parentDir, { recursive: true, mode: 0o700 });
|
|
48
|
+
}
|
|
49
|
+
writeFileSync(resolvedPath, JSON.stringify(bom, null, 2), { mode: 0o600 });
|
|
50
|
+
savedPath = resolvedPath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Format response based on verbosity
|
|
54
|
+
let response;
|
|
55
|
+
switch (level) {
|
|
56
|
+
case 'minimal':
|
|
57
|
+
response = formatMinimal(componentList, savedPath);
|
|
58
|
+
break;
|
|
59
|
+
case 'full':
|
|
60
|
+
response = formatFull(bom, componentList, savedPath);
|
|
61
|
+
break;
|
|
62
|
+
case 'compact':
|
|
63
|
+
default:
|
|
64
|
+
response = formatCompact(componentList, savedPath);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatMinimal(componentList, savedPath) {
|
|
74
|
+
const { metadata } = componentList;
|
|
75
|
+
return {
|
|
76
|
+
total_components: metadata.total,
|
|
77
|
+
direct: metadata.direct,
|
|
78
|
+
dev: metadata.dev,
|
|
79
|
+
ecosystems: metadata.ecosystems,
|
|
80
|
+
...(savedPath && { saved_to: savedPath }),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatCompact(componentList, savedPath) {
|
|
85
|
+
const { components, metadata } = componentList;
|
|
86
|
+
|
|
87
|
+
const byEcosystem = {};
|
|
88
|
+
for (const c of components) {
|
|
89
|
+
if (!byEcosystem[c.ecosystem]) byEcosystem[c.ecosystem] = [];
|
|
90
|
+
byEcosystem[c.ecosystem].push({ name: c.name, version: c.version, isDev: c.isDev });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
project: metadata.name,
|
|
95
|
+
version: metadata.version,
|
|
96
|
+
total_components: metadata.total,
|
|
97
|
+
direct: metadata.direct,
|
|
98
|
+
dev: metadata.dev,
|
|
99
|
+
ecosystems: metadata.ecosystems,
|
|
100
|
+
components: byEcosystem,
|
|
101
|
+
...(savedPath && { saved_to: savedPath }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatFull(bom, componentList, savedPath) {
|
|
106
|
+
return {
|
|
107
|
+
project: componentList.metadata.name,
|
|
108
|
+
version: componentList.metadata.version,
|
|
109
|
+
total_components: componentList.metadata.total,
|
|
110
|
+
direct: componentList.metadata.direct,
|
|
111
|
+
dev: componentList.metadata.dev,
|
|
112
|
+
ecosystems: componentList.metadata.ecosystems,
|
|
113
|
+
bom,
|
|
114
|
+
...(savedPath && { saved_to: savedPath }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { discoverDependencies } from '../lib/lockfile-parsers.js';
|
|
4
|
+
import { componentFromBomComponent } from '../lib/sbom-component.js';
|
|
5
|
+
import { isHallucinated } from './check-package.js';
|
|
6
|
+
|
|
7
|
+
// Ecosystems supported by the hallucination checker (bloom filters + text sets)
|
|
8
|
+
const SUPPORTED_ECOSYSTEMS = new Set(['npm', 'pypi', 'rubygems', 'dart', 'perl', 'raku', 'crates']);
|
|
9
|
+
|
|
10
|
+
export const sbomHallucinationsSchema = {
|
|
11
|
+
directory_path: z.string().optional().describe('Path to project root (generates fresh SBOM)'),
|
|
12
|
+
sbom_path: z.string().optional().describe('Path to existing SBOM file'),
|
|
13
|
+
verbosity: z.enum(['minimal', 'compact', 'full']).optional().describe('Response detail level (default: compact)'),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function sbomCheckHallucinations({ directory_path, sbom_path, verbosity }) {
|
|
17
|
+
// Enforce exactly one of directory_path or sbom_path
|
|
18
|
+
if (directory_path && sbom_path) {
|
|
19
|
+
return error('Provide either directory_path or sbom_path, not both.');
|
|
20
|
+
}
|
|
21
|
+
if (!directory_path && !sbom_path) {
|
|
22
|
+
return error('Provide either directory_path or sbom_path.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Load or generate components
|
|
26
|
+
let components;
|
|
27
|
+
if (sbom_path) {
|
|
28
|
+
if (!existsSync(sbom_path)) return error(`SBOM file not found: ${sbom_path}`);
|
|
29
|
+
let bom;
|
|
30
|
+
try {
|
|
31
|
+
bom = JSON.parse(readFileSync(sbom_path, 'utf-8'));
|
|
32
|
+
} catch {
|
|
33
|
+
return error(`Failed to parse SBOM: ${sbom_path}`);
|
|
34
|
+
}
|
|
35
|
+
components = (bom.components || []).map(componentFromBomComponent);
|
|
36
|
+
} else {
|
|
37
|
+
if (!existsSync(directory_path)) return error(`Directory not found: ${directory_path}`);
|
|
38
|
+
const componentList = discoverDependencies(directory_path);
|
|
39
|
+
components = componentList.components;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hallucinated = [];
|
|
43
|
+
const unsupported = [];
|
|
44
|
+
const legitimate = [];
|
|
45
|
+
|
|
46
|
+
for (const component of components) {
|
|
47
|
+
const eco = component.ecosystem;
|
|
48
|
+
|
|
49
|
+
// Check if ecosystem is supported
|
|
50
|
+
if (!SUPPORTED_ECOSYSTEMS.has(eco)) {
|
|
51
|
+
unsupported.push({
|
|
52
|
+
name: component.name,
|
|
53
|
+
version: component.version,
|
|
54
|
+
ecosystem: eco,
|
|
55
|
+
status: 'unsupported_ecosystem',
|
|
56
|
+
message: `Registry verification not available for ${eco} ecosystem.`,
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = isHallucinated(component.name, eco);
|
|
62
|
+
if (result && result.hallucinated) {
|
|
63
|
+
hallucinated.push({
|
|
64
|
+
name: component.name,
|
|
65
|
+
version: component.version,
|
|
66
|
+
ecosystem: eco,
|
|
67
|
+
confidence: result.confidence || 'medium',
|
|
68
|
+
similar_packages: result.similar_packages || [],
|
|
69
|
+
});
|
|
70
|
+
} else {
|
|
71
|
+
legitimate.push({ name: component.name, ecosystem: eco });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const level = verbosity || 'compact';
|
|
76
|
+
let response;
|
|
77
|
+
switch (level) {
|
|
78
|
+
case 'minimal':
|
|
79
|
+
response = {
|
|
80
|
+
total_checked: components.length,
|
|
81
|
+
legitimate_count: legitimate.length,
|
|
82
|
+
hallucinated_count: hallucinated.length,
|
|
83
|
+
unsupported_count: unsupported.length,
|
|
84
|
+
};
|
|
85
|
+
break;
|
|
86
|
+
case 'full':
|
|
87
|
+
response = {
|
|
88
|
+
total_checked: components.length,
|
|
89
|
+
legitimate_count: legitimate.length,
|
|
90
|
+
hallucinated_count: hallucinated.length,
|
|
91
|
+
unsupported_count: unsupported.length,
|
|
92
|
+
hallucinated_packages: hallucinated,
|
|
93
|
+
unsupported_packages: unsupported,
|
|
94
|
+
legitimate_packages: legitimate,
|
|
95
|
+
};
|
|
96
|
+
break;
|
|
97
|
+
case 'compact':
|
|
98
|
+
default:
|
|
99
|
+
response = {
|
|
100
|
+
total_checked: components.length,
|
|
101
|
+
legitimate_count: legitimate.length,
|
|
102
|
+
hallucinated_count: hallucinated.length,
|
|
103
|
+
unsupported_count: unsupported.length,
|
|
104
|
+
hallucinated_packages: hallucinated,
|
|
105
|
+
unsupported_packages: unsupported,
|
|
106
|
+
};
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function error(msg) {
|
|
116
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
117
|
+
}
|