agent-security-scanner-mcp 4.1.1 → 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,6 +1,6 @@
1
- // src/lib/compliance-controls.js — AIUC-1 controls registry loader + schema validator.
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
- let _cache = null;
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 && !KNOWN_DOMAINS.has(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 the AIUC-1 controls registry. Validates on first 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', 'aiuc-1-controls.json');
119
- const data = JSON.parse(readFileSync(controlsPath, 'utf-8'));
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(`AIUC-1 controls registry validation failed:\n${errors.join('\n')}`);
184
+ throw new Error(`${framework} controls registry validation failed:\n${errors.join('\n')}`);
124
185
  }
125
186
 
126
- _cache = data;
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.domain] - 'security', 'safety', or 'all'
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 = null;
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|null} evidence.aivssPosture - Posture from scoreBatch, or null
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 - Same shape as evaluateControl
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) {