agent-security-scanner-mcp 4.1.1 → 4.3.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 +198 -0
- package/compliance/gdpr-technical-controls.json +112 -0
- package/compliance/soc2-technical-controls.json +148 -0
- package/index.js +9 -1
- package/package.json +1 -1
- package/src/config.js +3 -2
- 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/tools/compliance-controls.js +22 -12
- package/src/tools/evaluate-compliance.js +161 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// src/lib/compliance-controls.js —
|
|
1
|
+
// src/lib/compliance-controls.js — Multi-framework controls registry loader + schema validator.
|
|
2
2
|
|
|
3
|
-
import { readFileSync } from 'fs';
|
|
3
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
4
4
|
import { join, dirname } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
6
|
|
|
@@ -11,14 +11,15 @@ try {
|
|
|
11
11
|
__dirname = process.cwd();
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const KNOWN_DOMAINS = new Set(['security', 'safety']);
|
|
15
14
|
const KNOWN_TOOLS = new Set([
|
|
16
15
|
'scan_security', 'scan_agent_prompt', 'scan_project', 'scan_skill',
|
|
17
16
|
'scan_mcp_server', 'scan_agent_action', 'scan_git_diff',
|
|
17
|
+
'sbom_generate', 'sbom_scan_vulnerabilities', 'sbom_check_hallucinations', 'sbom_diff',
|
|
18
18
|
]);
|
|
19
19
|
const OWASP_TAG_RE = /^LLM\d{2}$/;
|
|
20
|
+
const VALID_CHECK_OPS = new Set(['exists', 'eq', 'lte', 'gte']);
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
const _cache = new Map();
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Validate the controls registry schema. Returns array of error strings (empty = valid).
|
|
@@ -36,6 +37,13 @@ export function validateRegistry(data) {
|
|
|
36
37
|
return errors;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// Validate registry-level domains array
|
|
41
|
+
if (!Array.isArray(data.domains) || data.domains.length === 0) {
|
|
42
|
+
errors.push('Registry must have a non-empty "domains" array');
|
|
43
|
+
return errors;
|
|
44
|
+
}
|
|
45
|
+
const registryDomains = new Set(data.domains);
|
|
46
|
+
|
|
39
47
|
const ids = new Set();
|
|
40
48
|
for (const ctrl of data.controls) {
|
|
41
49
|
// Required fields
|
|
@@ -50,9 +58,9 @@ export function validateRegistry(data) {
|
|
|
50
58
|
}
|
|
51
59
|
ids.add(ctrl.id);
|
|
52
60
|
|
|
53
|
-
// Domain validation
|
|
54
|
-
if (ctrl.domain && !
|
|
55
|
-
errors.push(`Control ${ctrl.id}: unknown domain "${ctrl.domain}"`);
|
|
61
|
+
// Domain validation — check against this registry's own domains
|
|
62
|
+
if (ctrl.domain && !registryDomains.has(ctrl.domain)) {
|
|
63
|
+
errors.push(`Control ${ctrl.id}: unknown domain "${ctrl.domain}" (registry allows: ${data.domains.join(', ')})`);
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
// Scanner tools validation
|
|
@@ -64,7 +72,7 @@ export function validateRegistry(data) {
|
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
// OWASP tags validation
|
|
75
|
+
// OWASP tags validation (optional — only validated when present)
|
|
68
76
|
if (Array.isArray(ctrl.owasp_llm)) {
|
|
69
77
|
for (const tag of ctrl.owasp_llm) {
|
|
70
78
|
if (!OWASP_TAG_RE.test(tag)) {
|
|
@@ -73,6 +81,19 @@ export function validateRegistry(data) {
|
|
|
73
81
|
}
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
// References validation (optional — array of strings)
|
|
85
|
+
if (ctrl.references !== undefined) {
|
|
86
|
+
if (!Array.isArray(ctrl.references)) {
|
|
87
|
+
errors.push(`Control ${ctrl.id}: references must be an array`);
|
|
88
|
+
} else {
|
|
89
|
+
for (const ref of ctrl.references) {
|
|
90
|
+
if (typeof ref !== 'string') {
|
|
91
|
+
errors.push(`Control ${ctrl.id}: each reference must be a string`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
// Evaluation field types
|
|
77
98
|
if (ctrl.evaluation) {
|
|
78
99
|
const ev = ctrl.evaluation;
|
|
@@ -102,6 +123,39 @@ export function validateRegistry(data) {
|
|
|
102
123
|
if (ev.min_grade !== undefined && typeof ev.min_grade !== 'string') {
|
|
103
124
|
errors.push(`Control ${ctrl.id}: evaluation.min_grade must be a string`);
|
|
104
125
|
}
|
|
126
|
+
|
|
127
|
+
// evidence_checks validation (generic path-based checks for SOC2/GDPR controls)
|
|
128
|
+
if (ev.evidence_checks !== undefined) {
|
|
129
|
+
if (!Array.isArray(ev.evidence_checks)) {
|
|
130
|
+
errors.push(`Control ${ctrl.id}: evaluation.evidence_checks must be an array`);
|
|
131
|
+
} else {
|
|
132
|
+
for (let i = 0; i < ev.evidence_checks.length; i++) {
|
|
133
|
+
const check = ev.evidence_checks[i];
|
|
134
|
+
const prefix = `Control ${ctrl.id}: evidence_checks[${i}]`;
|
|
135
|
+
if (!check.path || typeof check.path !== 'string') {
|
|
136
|
+
errors.push(`${prefix}: must have a string "path"`);
|
|
137
|
+
}
|
|
138
|
+
if (!check.operator || !VALID_CHECK_OPS.has(check.operator)) {
|
|
139
|
+
errors.push(`${prefix}: operator must be one of: ${[...VALID_CHECK_OPS].join(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
if (check.operator !== 'exists' && check.value === undefined) {
|
|
142
|
+
errors.push(`${prefix}: non-exists operators require a "value"`);
|
|
143
|
+
}
|
|
144
|
+
if (!check.on_fail || !['fail', 'partial', 'not_evaluated'].includes(check.on_fail)) {
|
|
145
|
+
errors.push(`${prefix}: on_fail must be "fail", "partial", or "not_evaluated"`);
|
|
146
|
+
}
|
|
147
|
+
if (check.not_evaluated_reason !== undefined && typeof check.not_evaluated_reason !== 'string') {
|
|
148
|
+
errors.push(`${prefix}: not_evaluated_reason must be a string`);
|
|
149
|
+
}
|
|
150
|
+
if (check.reason !== undefined && typeof check.reason !== 'string') {
|
|
151
|
+
errors.push(`${prefix}: reason must be a string`);
|
|
152
|
+
}
|
|
153
|
+
if (check.default !== undefined && typeof check.default !== 'number' && typeof check.default !== 'boolean') {
|
|
154
|
+
errors.push(`${prefix}: default must be a number or boolean`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
105
159
|
}
|
|
106
160
|
}
|
|
107
161
|
|
|
@@ -109,34 +163,42 @@ export function validateRegistry(data) {
|
|
|
109
163
|
}
|
|
110
164
|
|
|
111
165
|
/**
|
|
112
|
-
* Load
|
|
166
|
+
* Load a controls registry by framework name. Validates on first load per framework.
|
|
167
|
+
* @param {string} [framework='aiuc-1'] - Framework identifier (maps to compliance/<framework>-controls.json)
|
|
113
168
|
* @returns {object} The full registry object
|
|
114
169
|
*/
|
|
115
|
-
export function loadControls() {
|
|
116
|
-
if (_cache) return _cache;
|
|
117
|
-
|
|
118
|
-
const controlsPath = join(__dirname, '..', '..', 'compliance',
|
|
119
|
-
|
|
170
|
+
export function loadControls(framework = 'aiuc-1') {
|
|
171
|
+
if (_cache.has(framework)) return _cache.get(framework);
|
|
172
|
+
|
|
173
|
+
const controlsPath = join(__dirname, '..', '..', 'compliance', `${framework}-controls.json`);
|
|
174
|
+
let raw;
|
|
175
|
+
try {
|
|
176
|
+
raw = readFileSync(controlsPath, 'utf-8');
|
|
177
|
+
} catch (err) {
|
|
178
|
+
throw new Error(`Cannot load controls registry for framework "${framework}": ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
const data = JSON.parse(raw);
|
|
120
181
|
|
|
121
182
|
const errors = validateRegistry(data);
|
|
122
183
|
if (errors.length > 0) {
|
|
123
|
-
throw new Error(
|
|
184
|
+
throw new Error(`${framework} controls registry validation failed:\n${errors.join('\n')}`);
|
|
124
185
|
}
|
|
125
186
|
|
|
126
|
-
_cache
|
|
187
|
+
_cache.set(framework, data);
|
|
127
188
|
return data;
|
|
128
189
|
}
|
|
129
190
|
|
|
130
191
|
/**
|
|
131
|
-
* Filter controls by domain, control IDs, or OWASP tags.
|
|
192
|
+
* Filter controls by framework, domain, control IDs, or OWASP tags.
|
|
132
193
|
* @param {object} [filters]
|
|
133
|
-
* @param {string} [filters.
|
|
194
|
+
* @param {string} [filters.framework] - Framework to load (default: 'aiuc-1')
|
|
195
|
+
* @param {string} [filters.domain] - Domain name or 'all'
|
|
134
196
|
* @param {string[]} [filters.controlIds] - Specific control IDs
|
|
135
197
|
* @param {string[]} [filters.owaspFilter] - OWASP LLM tags to match
|
|
136
198
|
* @returns {object[]} Filtered controls
|
|
137
199
|
*/
|
|
138
|
-
export function filterControls({ domain, controlIds, owaspFilter } = {}) {
|
|
139
|
-
const registry = loadControls();
|
|
200
|
+
export function filterControls({ framework = 'aiuc-1', domain, controlIds, owaspFilter } = {}) {
|
|
201
|
+
const registry = loadControls(framework);
|
|
140
202
|
let controls = registry.controls;
|
|
141
203
|
|
|
142
204
|
if (domain && domain !== 'all') {
|
|
@@ -158,7 +220,24 @@ export function filterControls({ domain, controlIds, owaspFilter } = {}) {
|
|
|
158
220
|
return controls;
|
|
159
221
|
}
|
|
160
222
|
|
|
223
|
+
/**
|
|
224
|
+
* List available framework names by scanning the compliance directory.
|
|
225
|
+
* @returns {string[]} Available framework identifiers
|
|
226
|
+
*/
|
|
227
|
+
export function listFrameworks() {
|
|
228
|
+
const dir = join(__dirname, '..', '..', 'compliance');
|
|
229
|
+
try {
|
|
230
|
+
return readdirSync(dir)
|
|
231
|
+
.filter(f => f.endsWith('-controls.json'))
|
|
232
|
+
.map(f => f.replace('-controls.json', ''));
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export { KNOWN_TOOLS };
|
|
239
|
+
|
|
161
240
|
// Reset cache (for testing)
|
|
162
241
|
export function _resetCache() {
|
|
163
|
-
_cache
|
|
242
|
+
_cache.clear();
|
|
164
243
|
}
|
|
@@ -4,6 +4,131 @@ import { scoreBatch } from './aivss.js';
|
|
|
4
4
|
|
|
5
5
|
const GRADE_ORDER = { A: 4, B: 3, C: 2, D: 1, F: 0 };
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a dot-path like "supply_chain.vulnerabilities.by_severity.critical" against an object.
|
|
9
|
+
* Returns undefined for any missing intermediate key (never throws).
|
|
10
|
+
*/
|
|
11
|
+
export function resolvePath(obj, path) {
|
|
12
|
+
if (obj == null || typeof path !== 'string') return undefined;
|
|
13
|
+
const segments = path.split('.');
|
|
14
|
+
let current = obj;
|
|
15
|
+
for (const seg of segments) {
|
|
16
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
17
|
+
current = current[seg];
|
|
18
|
+
}
|
|
19
|
+
return current;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run a single evidence_check against the evidence bundle.
|
|
24
|
+
* Returns { passed: boolean, status: string|null, reason: string } where:
|
|
25
|
+
* - passed=true means check succeeded (no status change needed)
|
|
26
|
+
* - passed=false means on_fail status should be applied, with reason
|
|
27
|
+
*/
|
|
28
|
+
function runEvidenceCheck(check, evidenceBundle) {
|
|
29
|
+
const value = resolvePath(evidenceBundle, check.path);
|
|
30
|
+
|
|
31
|
+
// Missing evidence handling — three tiers:
|
|
32
|
+
//
|
|
33
|
+
// 1. Any node along the path is explicitly `null` → source failure → not_evaluated.
|
|
34
|
+
// The evidence collector sets sections to null when a data source fails (e.g., OSV down).
|
|
35
|
+
//
|
|
36
|
+
// 2. A top-level section key is missing entirely (e.g., bundle has no "supply_chain") →
|
|
37
|
+
// evidence was never collected → not_evaluated. Defaults must not mask this.
|
|
38
|
+
//
|
|
39
|
+
// 3. A deeper leaf is missing but its parent exists (e.g., scan.by_category_severity.crypto
|
|
40
|
+
// is absent because no crypto findings) → safe to apply check.default.
|
|
41
|
+
//
|
|
42
|
+
if (value === undefined || value === null) {
|
|
43
|
+
const segments = check.path.split('.');
|
|
44
|
+
|
|
45
|
+
// Walk the path to find where it breaks
|
|
46
|
+
let current = evidenceBundle;
|
|
47
|
+
let depth = 0;
|
|
48
|
+
for (depth = 0; depth < segments.length; depth++) {
|
|
49
|
+
if (current === null) {
|
|
50
|
+
// Explicit null — source failure
|
|
51
|
+
const failedAt = segments.slice(0, depth).join('.') || segments[0];
|
|
52
|
+
return {
|
|
53
|
+
passed: false,
|
|
54
|
+
status: 'not_evaluated',
|
|
55
|
+
reason: `Evidence source unavailable: ${failedAt} is null (full path: ${check.path})`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (current === undefined || typeof current !== 'object') break;
|
|
59
|
+
const next = current[segments[depth]];
|
|
60
|
+
if (next === null) {
|
|
61
|
+
// Explicit null at this level
|
|
62
|
+
const failedAt = segments.slice(0, depth + 1).join('.');
|
|
63
|
+
return {
|
|
64
|
+
passed: false,
|
|
65
|
+
status: 'not_evaluated',
|
|
66
|
+
reason: `Evidence source unavailable: ${failedAt} is null (full path: ${check.path})`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (next === undefined) break; // missing key — check depth below
|
|
70
|
+
current = next;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// If the break happened at depth 0 or 1, the top-level section is missing.
|
|
74
|
+
// This means evidence was never collected — not_evaluated, no defaults.
|
|
75
|
+
if (depth <= 1) {
|
|
76
|
+
return {
|
|
77
|
+
passed: false,
|
|
78
|
+
status: 'not_evaluated',
|
|
79
|
+
reason: `Evidence source unavailable: ${segments[0]} is missing (full path: ${check.path})`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Break happened deeper — parent section exists, leaf key absent.
|
|
84
|
+
// Safe to apply default (e.g., scan ran but no "crypto" category).
|
|
85
|
+
if (check.default !== undefined) {
|
|
86
|
+
return evaluateOp(check.operator, check.default, check.value, check);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
passed: false,
|
|
91
|
+
status: 'not_evaluated',
|
|
92
|
+
reason: `Missing evidence at path: ${check.path}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return evaluateOp(check.operator, value, check.value, check);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function evaluateOp(operator, actual, expected, check) {
|
|
100
|
+
let passed;
|
|
101
|
+
switch (operator) {
|
|
102
|
+
case 'exists':
|
|
103
|
+
passed = actual !== undefined && actual !== null;
|
|
104
|
+
break;
|
|
105
|
+
case 'eq':
|
|
106
|
+
passed = actual === expected;
|
|
107
|
+
break;
|
|
108
|
+
case 'lte':
|
|
109
|
+
passed = typeof actual === 'number' && actual <= expected;
|
|
110
|
+
break;
|
|
111
|
+
case 'gte':
|
|
112
|
+
passed = typeof actual === 'number' && actual >= expected;
|
|
113
|
+
break;
|
|
114
|
+
default:
|
|
115
|
+
return { passed: false, status: 'not_evaluated', reason: `Unknown operator: ${operator}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (passed) {
|
|
119
|
+
return { passed: true, status: null, reason: '' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const status = check.on_fail || 'fail';
|
|
123
|
+
// Use distinct reason for not_evaluated vs failure to avoid confusing audit consumers.
|
|
124
|
+
// "reason" is the failure message; "not_evaluated_reason" explains why evidence was insufficient.
|
|
125
|
+
const reason = status === 'not_evaluated'
|
|
126
|
+
? (check.not_evaluated_reason || `Evidence insufficient: ${check.path} ${operator} ${expected} (actual: ${actual})`)
|
|
127
|
+
: (check.reason || `Check failed: ${check.path} ${operator} ${expected} (actual: ${actual})`);
|
|
128
|
+
|
|
129
|
+
return { passed: false, status, reason };
|
|
130
|
+
}
|
|
131
|
+
|
|
7
132
|
/**
|
|
8
133
|
* Check if actual grade is worse than threshold.
|
|
9
134
|
* Missing/null grade → treated as F (worst case).
|
|
@@ -18,14 +143,11 @@ function gradeIsWorse(actual, threshold) {
|
|
|
18
143
|
* Evaluate a single control against evidence.
|
|
19
144
|
*
|
|
20
145
|
* @param {object} control - A control from the registry
|
|
21
|
-
* @param {object} evidence
|
|
22
|
-
* @param {object
|
|
23
|
-
* @param {object[]} evidence.findings - Normalized findings from all available tools
|
|
24
|
-
* @param {object} evidence.grades - Map of tool/scope → grade (e.g. { project: 'B' })
|
|
25
|
-
* @param {string[]} evidence.toolsRun - Array of tool names whose output is available
|
|
146
|
+
* @param {object} evidence - Legacy evidence shape (aivssPosture, findings, grades, toolsRun)
|
|
147
|
+
* @param {object} [evidenceBundle] - Full evidence bundle for evidence_checks evaluation
|
|
26
148
|
* @returns {{ control_id: string, status: string, reasons: string[] }}
|
|
27
149
|
*/
|
|
28
|
-
export function evaluateControl(control, evidence) {
|
|
150
|
+
export function evaluateControl(control, evidence, evidenceBundle) {
|
|
29
151
|
if (!control || !control.id || !control.evaluation) {
|
|
30
152
|
return {
|
|
31
153
|
control_id: control?.id || 'unknown',
|
|
@@ -123,6 +245,24 @@ export function evaluateControl(control, evidence) {
|
|
|
123
245
|
}
|
|
124
246
|
}
|
|
125
247
|
|
|
248
|
+
// 7. Run evidence_checks (generic path-based checks, used by SOC2/GDPR controls)
|
|
249
|
+
if (Array.isArray(ev.evidence_checks) && evidenceBundle) {
|
|
250
|
+
for (const check of ev.evidence_checks) {
|
|
251
|
+
const result = runEvidenceCheck(check, evidenceBundle);
|
|
252
|
+
if (!result.passed) {
|
|
253
|
+
if (result.status === 'not_evaluated') {
|
|
254
|
+
// If we haven't already failed/passed via legacy checks, mark not_evaluated
|
|
255
|
+
if (status === 'pass') status = 'not_evaluated';
|
|
256
|
+
} else if (result.status === 'fail') {
|
|
257
|
+
status = 'fail';
|
|
258
|
+
} else if (result.status === 'partial' && status !== 'fail') {
|
|
259
|
+
status = 'partial';
|
|
260
|
+
}
|
|
261
|
+
if (result.reason) reasons.push(result.reason);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
126
266
|
return { control_id: control.id, status, reasons };
|
|
127
267
|
}
|
|
128
268
|
|
|
@@ -130,11 +270,12 @@ export function evaluateControl(control, evidence) {
|
|
|
130
270
|
* Evaluate all controls against evidence.
|
|
131
271
|
*
|
|
132
272
|
* @param {object[]} controls - Array of controls from registry
|
|
133
|
-
* @param {object} evidence -
|
|
273
|
+
* @param {object} evidence - Legacy evidence shape
|
|
274
|
+
* @param {object} [evidenceBundle] - Full evidence bundle for evidence_checks
|
|
134
275
|
* @returns {{ controls_evaluated: number, pass: number, partial: number, fail: number, not_evaluated: number, results: object[] }}
|
|
135
276
|
*/
|
|
136
|
-
export function evaluateAll(controls, evidence) {
|
|
137
|
-
const results = controls.map(c => evaluateControl(c, evidence));
|
|
277
|
+
export function evaluateAll(controls, evidence, evidenceBundle) {
|
|
278
|
+
const results = controls.map(c => evaluateControl(c, evidence, evidenceBundle));
|
|
138
279
|
|
|
139
280
|
const summary = { pass: 0, partial: 0, fail: 0, not_evaluated: 0 };
|
|
140
281
|
for (const r of results) {
|