@clear-capabilities/agentic-security-scanner 0.142.0 → 0.143.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/CHANGELOG.md +95 -0
- package/bin/agentic-security.js +53 -7
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/report/index.js +32 -16
- package/src/report/oscal.js +630 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// OSCAL export — NIST's Open Security Controls Assessment Language.
|
|
2
|
+
// Model reference: https://pages.nist.gov/OSCAL-Reference/models/
|
|
3
|
+
//
|
|
4
|
+
// ── WHICH MODEL, AND WHY ─────────────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// OSCAL has seven models. Exactly one of them describes "something examined a
|
|
7
|
+
// system and reports what it found": `assessment-results`. A catalog and a
|
|
8
|
+
// profile describe control DEFINITIONS; an SSP describes a system DESIGN; a
|
|
9
|
+
// POA&M records PLANNED remediation with owners and due dates. A scanner is not
|
|
10
|
+
// entitled to author any of those — it has no owners, no dates, and no
|
|
11
|
+
// authority over a control catalog. So both exporters here emit
|
|
12
|
+
// `assessment-results`, and the only difference between them is what the
|
|
13
|
+
// single `result` says it REVIEWED.
|
|
14
|
+
//
|
|
15
|
+
// ── THE HONESTY PROBLEM OSCAL FORCES ─────────────────────────────────────────
|
|
16
|
+
//
|
|
17
|
+
// An OSCAL `finding` is a statement ABOUT A CONTROL. It requires a `target`
|
|
18
|
+
// carrying a `target-id` and a `status.state` that is binary: satisfied or
|
|
19
|
+
// not-satisfied. There is no "unknown", no "not applicable", and no "we did not
|
|
20
|
+
// look". That constraint is the useful part of the format, and it decides the
|
|
21
|
+
// shape of everything below:
|
|
22
|
+
//
|
|
23
|
+
// 1. A RAW SCAN EMITS NO `findings`. A SQL-injection hit is not an opinion
|
|
24
|
+
// about any control, because no catalog is in scope. It becomes an
|
|
25
|
+
// `observation` (what the tool saw) and a `risk` (what it would mean).
|
|
26
|
+
// Inventing control targets for CWEs would be publishing a mapping nobody
|
|
27
|
+
// wrote and no assessor agreed to.
|
|
28
|
+
//
|
|
29
|
+
// 2. A COMPLIANCE EVALUATION EMITS FINDINGS ONLY FOR CONTROLS THE ENGINE
|
|
30
|
+
// ACTUALLY DECIDED. Both upstream assessors distinguish "decided" from
|
|
31
|
+
// "not assessed" — `auditor-walkthrough.js` returns present / partial /
|
|
32
|
+
// manual, and `privacy-framework.js` returns satisfied / gap / engine-gap /
|
|
33
|
+
// manual. Only the decided ones become findings. A `manual` or
|
|
34
|
+
// `engine-gap` control becomes an observation with method EXAMINE and an
|
|
35
|
+
// explicit remark that a human must assess it. Calling it satisfied would
|
|
36
|
+
// be a false compliance claim; calling it not-satisfied would be a false
|
|
37
|
+
// failure. OSCAL has no third state, so it is not a finding at all — and
|
|
38
|
+
// the raw upstream status rides along as a prop so the distinction that
|
|
39
|
+
// OSCAL cannot express is still in the document.
|
|
40
|
+
//
|
|
41
|
+
// ── DETERMINISM ──────────────────────────────────────────────────────────────
|
|
42
|
+
//
|
|
43
|
+
// Every uuid is minted through `_uuid`, which mirrors posture/sbom.js: a
|
|
44
|
+
// content-derived, v4-shaped digest under `--deterministic`, a real random uuid
|
|
45
|
+
// otherwise. Cross-references (finding → related-observations → observation) are
|
|
46
|
+
// computed once and reused, so they hold in both modes. `format-determinism`
|
|
47
|
+
// covers this format; a `crypto.randomUUID()` added anywhere below without the
|
|
48
|
+
// deterministic branch fails that gate.
|
|
49
|
+
|
|
50
|
+
import crypto from 'node:crypto';
|
|
51
|
+
import { isDeterministic, SCANNER_VERSION } from '../posture/deterministic.js';
|
|
52
|
+
import { normalizeFindings, TOOL_CAVEATS } from './index.js';
|
|
53
|
+
|
|
54
|
+
// The OSCAL release these documents declare conformance to. Bump deliberately:
|
|
55
|
+
// `oscal-version` is a claim a validator checks the rest of the document
|
|
56
|
+
// against, not a decoration.
|
|
57
|
+
const OSCAL_VERSION = '1.1.2';
|
|
58
|
+
|
|
59
|
+
// Every extension this file adds lives under one namespace, so a consumer can
|
|
60
|
+
// drop everything it does not understand with a single filter instead of
|
|
61
|
+
// guessing which bare prop names are ours.
|
|
62
|
+
export const OSCAL_NS = 'https://github.com/Clear-Capabilities/agentic-security/ns/oscal';
|
|
63
|
+
|
|
64
|
+
const TOOL_URI = 'https://github.com/Clear-Capabilities/agentic-security';
|
|
65
|
+
|
|
66
|
+
// ── primitives ───────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function _stableUuidFrom(seed) {
|
|
69
|
+
const h = crypto.createHash('sha256').update(String(seed)).digest('hex');
|
|
70
|
+
// Shaped as a v4 uuid — version and variant nibbles set — because OSCAL's
|
|
71
|
+
// `uuid` datatype is validated, and a bare 32-char digest is rejected.
|
|
72
|
+
return [
|
|
73
|
+
h.slice(0, 8),
|
|
74
|
+
h.slice(8, 12),
|
|
75
|
+
`4${h.slice(13, 16)}`,
|
|
76
|
+
`${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}`,
|
|
77
|
+
h.slice(20, 32),
|
|
78
|
+
].join('-');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _uuid(seed) {
|
|
82
|
+
return isDeterministic() ? _stableUuidFrom(seed) : crypto.randomUUID();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* OSCAL's `token` datatype is an NCName: it must start with a letter or `_` and
|
|
87
|
+
* may then contain only letters, digits, `.`, `-` and `_`.
|
|
88
|
+
*
|
|
89
|
+
* This matters more than it looks. Real control identifiers routinely are not
|
|
90
|
+
* tokens — the CCPA catalog shipped with this engine uses ids like `§1798.100`,
|
|
91
|
+
* and GDPR uses `Art. 32(1)(a)`. Emitting those raw as a `control-id` or a
|
|
92
|
+
* `target-id` produces a document that fails validation at the first control,
|
|
93
|
+
* which is the failure mode of every "we support OSCAL" claim that was never
|
|
94
|
+
* run through a validator. The original id is never lost: it is carried beside
|
|
95
|
+
* the token as a `source-control-id` prop, and as the human-readable title.
|
|
96
|
+
*/
|
|
97
|
+
export function oscalToken(s) {
|
|
98
|
+
const raw = String(s == null ? '' : s);
|
|
99
|
+
const cleaned = raw.replace(/[^\p{L}\p{N}._-]/gu, '-');
|
|
100
|
+
return /^[\p{L}_]/u.test(cleaned) ? cleaned : `_${cleaned}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _when(meta) {
|
|
104
|
+
// `--deterministic` pins meta.startedAt to the epoch; ordinary runs carry the
|
|
105
|
+
// real scan start. Either way this is the scan's clock, never a fresh read of
|
|
106
|
+
// the wall clock at emit time, which would differ between two emits of one
|
|
107
|
+
// scan and make the artifact unattestable.
|
|
108
|
+
return (meta && meta.startedAt) || new Date().toISOString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function _prop(name, value) {
|
|
112
|
+
return { ns: OSCAL_NS, name, value: String(value) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _metadata(title, meta) {
|
|
116
|
+
return {
|
|
117
|
+
title,
|
|
118
|
+
'last-modified': _when(meta),
|
|
119
|
+
// The DOCUMENT version. Pinned to the engine version so re-emitting the
|
|
120
|
+
// same scan with the same engine yields the same document — a wall-clock or
|
|
121
|
+
// counter-based version would break determinism for no benefit.
|
|
122
|
+
version: SCANNER_VERSION,
|
|
123
|
+
'oscal-version': OSCAL_VERSION,
|
|
124
|
+
...(meta && meta.scanId ? { props: [_prop('scan-id', meta.scanId)] } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The tool, as an OSCAL assessment asset. This is the structural equivalent of
|
|
130
|
+
* SARIF's `tool.driver`: it gives every observation an `origin` to point at, so
|
|
131
|
+
* a reader can tell a machine observation from a human one without reading
|
|
132
|
+
* prose.
|
|
133
|
+
*/
|
|
134
|
+
function _toolComponent() {
|
|
135
|
+
return {
|
|
136
|
+
uuid: _uuid('agentic-security:assessment-asset:scanner'),
|
|
137
|
+
type: 'software',
|
|
138
|
+
title: 'agentic-security',
|
|
139
|
+
description:
|
|
140
|
+
'Static analysis, supply-chain, secrets and LLM-security scanner. Produced every observation in this document by automated examination of source code; no human assessor reviewed these results.',
|
|
141
|
+
props: [_prop('version', SCANNER_VERSION), _prop('information-uri', TOOL_URI)],
|
|
142
|
+
status: { state: 'operational' },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _toolOrigin(toolUuid) {
|
|
147
|
+
return [{ actors: [{ type: 'tool', 'actor-uuid': toolUuid }] }];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `assessment-assets` hangs off the RESULT's local-definitions, not the
|
|
152
|
+
* document's — `assessment-results/local-definitions` carries
|
|
153
|
+
* objectives-and-methods and activities, and nothing else. It was written at
|
|
154
|
+
* document level first; getting it wrong costs nothing at emit time and
|
|
155
|
+
* everything at validation time, which is the whole reason a format claim has
|
|
156
|
+
* to be checked rather than asserted.
|
|
157
|
+
*
|
|
158
|
+
* `assessment-platforms` is required inside it and must be non-empty. The
|
|
159
|
+
* platform is what RAN the assessment; the component is the software it ran.
|
|
160
|
+
* Here they are the same program described two ways, and `uses-components`
|
|
161
|
+
* links them so a reader is not left to infer it.
|
|
162
|
+
*/
|
|
163
|
+
function _assessmentAssets(tool) {
|
|
164
|
+
return {
|
|
165
|
+
components: [tool],
|
|
166
|
+
'assessment-platforms': [{
|
|
167
|
+
uuid: _uuid('agentic-security:assessment-platform'),
|
|
168
|
+
title: 'agentic-security command-line scanner',
|
|
169
|
+
'uses-components': [{ 'component-uuid': tool.uuid }],
|
|
170
|
+
}],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The caveats, as OSCAL back-matter. SARIF carries these as run notifications;
|
|
176
|
+
* OSCAL has no notification concept, and `back-matter.resources` is the model's
|
|
177
|
+
* general-purpose "documents this assessment depends on" slot. Referenced from
|
|
178
|
+
* the result's `links` so they are reachable from the result rather than
|
|
179
|
+
* stranded at the bottom of the file.
|
|
180
|
+
*/
|
|
181
|
+
function _caveatResources() {
|
|
182
|
+
return TOOL_CAVEATS.map(c => ({
|
|
183
|
+
uuid: _uuid(`caveat:${c.id}`),
|
|
184
|
+
title: c.shortDescription,
|
|
185
|
+
description: c.fullDescription,
|
|
186
|
+
props: [_prop('caveat-id', c.id)],
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── scan → assessment-results ────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
|
|
193
|
+
|
|
194
|
+
function _findingProps(f) {
|
|
195
|
+
const props = [];
|
|
196
|
+
if (f.severity) props.push(_prop('severity', f.severity));
|
|
197
|
+
if (f.cwe) props.push(_prop('cwe', f.cwe));
|
|
198
|
+
if (f.family) props.push(_prop('family', f.family));
|
|
199
|
+
if (f.stride) props.push(_prop('stride', f.stride));
|
|
200
|
+
if (f.file) props.push(_prop('file', String(f.file).replace(/\\/g, '/')));
|
|
201
|
+
if (Number.isInteger(f.line)) props.push(_prop('line', f.line));
|
|
202
|
+
if (f.stableId) props.push(_prop('stable-id', f.stableId));
|
|
203
|
+
// Tier labels, not the raw scores. The numbers are ordinal (see TOOL_CAVEATS)
|
|
204
|
+
// and a compliance reader is precisely the reader most likely to treat a
|
|
205
|
+
// decimal in a NIST-shaped document as a probability.
|
|
206
|
+
if (f.confidenceTier) props.push(_prop('confidence-tier', f.confidenceTier));
|
|
207
|
+
if (f.exploitabilityTier) props.push(_prop('exploitability-tier', f.exploitabilityTier));
|
|
208
|
+
if (f.evidence && f.evidence.proofTier) props.push(_prop('proof-tier', f.evidence.proofTier));
|
|
209
|
+
if (f.unreachable) props.push(_prop('reachability', 'demoted-unreachable'));
|
|
210
|
+
if (f.validator_verdict) props.push(_prop('validator-verdict', f.validator_verdict));
|
|
211
|
+
return props;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function _describe(f) {
|
|
215
|
+
return f.description || (f.fix && f.fix.description) || f.vuln || 'Security finding';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A scan, as an OSCAL assessment-results document.
|
|
220
|
+
*
|
|
221
|
+
* Emits observations (what was seen) and risks (what it would mean), and NO
|
|
222
|
+
* findings — see the header. `reviewed-controls` is present because the model
|
|
223
|
+
* requires it, and says plainly that no catalog was in scope rather than
|
|
224
|
+
* claiming `include-all`, which would assert this scan reviewed every control
|
|
225
|
+
* of an unnamed catalog.
|
|
226
|
+
*/
|
|
227
|
+
export function toOSCAL(scan, meta = {}) {
|
|
228
|
+
const findings = normalizeFindings(scan);
|
|
229
|
+
const when = _when(meta);
|
|
230
|
+
const tool = _toolComponent();
|
|
231
|
+
const caveats = _caveatResources();
|
|
232
|
+
|
|
233
|
+
const observations = [];
|
|
234
|
+
const risks = [];
|
|
235
|
+
|
|
236
|
+
findings.forEach((f, i) => {
|
|
237
|
+
const key = f.stableId || f.id || `${f.file}:${f.line}:${f.vuln}:${i}`;
|
|
238
|
+
const obsUuid = _uuid(`observation:${key}`);
|
|
239
|
+
const file = f.file ? String(f.file).replace(/\\/g, '/') : null;
|
|
240
|
+
const where = file ? `${file}:${Number.isInteger(f.line) ? f.line : '?'}` : 'location not recorded';
|
|
241
|
+
|
|
242
|
+
observations.push({
|
|
243
|
+
uuid: obsUuid,
|
|
244
|
+
title: f.vuln || 'Security finding',
|
|
245
|
+
description: `${_describe(f)} (observed at ${where})`,
|
|
246
|
+
// TEST, not EXAMINE: this is automated analysis of the artifact, which is
|
|
247
|
+
// what OSCAL's TEST method means. EXAMINE is reserved below for the
|
|
248
|
+
// controls a human still has to look at.
|
|
249
|
+
methods: ['TEST'],
|
|
250
|
+
types: ['finding'],
|
|
251
|
+
origins: _toolOrigin(tool.uuid),
|
|
252
|
+
...(file
|
|
253
|
+
? {
|
|
254
|
+
'relevant-evidence': [{
|
|
255
|
+
href: encodeURI(file),
|
|
256
|
+
description: `Source location reported by the scanner: ${where}.`,
|
|
257
|
+
}],
|
|
258
|
+
}
|
|
259
|
+
: {}),
|
|
260
|
+
collected: when,
|
|
261
|
+
props: _findingProps(f),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const remediation = typeof f.remediation === 'string' ? f.remediation.trim() : '';
|
|
265
|
+
risks.push({
|
|
266
|
+
uuid: _uuid(`risk:${key}`),
|
|
267
|
+
title: f.vuln || 'Security finding',
|
|
268
|
+
description: _describe(f),
|
|
269
|
+
// `statement` is the impact statement. It deliberately does not assert
|
|
270
|
+
// exploitability: the engine reports the presence of a weakness pattern,
|
|
271
|
+
// and only an execution-proven finding (proof-tier prop) says more.
|
|
272
|
+
statement:
|
|
273
|
+
`A ${f.cwe || 'weakness'} pattern was detected at ${where}. `
|
|
274
|
+
+ 'This document records the presence of the pattern and its severity ranking. '
|
|
275
|
+
+ 'It does not assert that the weakness is reachable or exploitable in deployment '
|
|
276
|
+
+ 'unless the observation carries a proof-tier property stating otherwise.',
|
|
277
|
+
props: _findingProps(f),
|
|
278
|
+
// Every scanner finding is by definition unaddressed at emit time — the
|
|
279
|
+
// scan just found it. A closed risk would have to come from remediation
|
|
280
|
+
// state this document does not have.
|
|
281
|
+
status: 'open',
|
|
282
|
+
'related-observations': [{ 'observation-uuid': obsUuid }],
|
|
283
|
+
...(remediation
|
|
284
|
+
? {
|
|
285
|
+
remediations: [{
|
|
286
|
+
uuid: _uuid(`response:${key}`),
|
|
287
|
+
// `recommendation`, not `planned`: nobody has committed to this.
|
|
288
|
+
lifecycle: 'recommendation',
|
|
289
|
+
title: 'Recommended remediation',
|
|
290
|
+
description: remediation,
|
|
291
|
+
}],
|
|
292
|
+
}
|
|
293
|
+
: {}),
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const bySeverity = {};
|
|
298
|
+
for (const f of findings) bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
|
299
|
+
|
|
300
|
+
const resultUuid = _uuid(`result:scan:${meta.scanId || ''}:${findings.length}`);
|
|
301
|
+
return {
|
|
302
|
+
uuid: _uuid(`assessment-results:scan:${meta.scanId || ''}:${findings.length}`),
|
|
303
|
+
metadata: _metadata('Automated security assessment results', meta),
|
|
304
|
+
// `import-ap` is REQUIRED by the model and there is no separate OSCAL
|
|
305
|
+
// assessment plan — this scan was not run against one. A same-document
|
|
306
|
+
// fragment reference is a valid uri-reference and says exactly that, rather
|
|
307
|
+
// than pointing at a plan that does not exist.
|
|
308
|
+
'import-ap': {
|
|
309
|
+
href: `#${resultUuid}`,
|
|
310
|
+
remarks:
|
|
311
|
+
'No OSCAL assessment plan governs this run. The scan was executed directly by the tool named in '
|
|
312
|
+
+ 'local-definitions.assessment-assets; this reference is a self-reference recorded because the '
|
|
313
|
+
+ 'OSCAL assessment-results model requires import-ap.',
|
|
314
|
+
},
|
|
315
|
+
results: [{
|
|
316
|
+
uuid: resultUuid,
|
|
317
|
+
'local-definitions': { 'assessment-assets': _assessmentAssets(tool) },
|
|
318
|
+
title: 'Automated source-code security scan',
|
|
319
|
+
description:
|
|
320
|
+
`Automated scan of ${scan && scan.filesScanned ? scan.filesScanned : 0} file(s) producing `
|
|
321
|
+
+ `${findings.length} observation(s). No control catalog was assessed — see reviewed-controls.`,
|
|
322
|
+
start: when,
|
|
323
|
+
props: [
|
|
324
|
+
_prop('files-scanned', (scan && scan.filesScanned) || 0),
|
|
325
|
+
_prop('observation-count', findings.length),
|
|
326
|
+
...SEVERITY_ORDER.filter(s => bySeverity[s]).map(s => _prop(`count-${s}`, bySeverity[s])),
|
|
327
|
+
],
|
|
328
|
+
links: caveats.map(c => ({ href: `#${c.uuid}`, rel: 'reference' })),
|
|
329
|
+
'reviewed-controls': {
|
|
330
|
+
description:
|
|
331
|
+
'No control catalog was in scope. This result reports weaknesses found in source code, not the '
|
|
332
|
+
+ 'satisfaction of controls; consequently it contains observations and risks but no findings, '
|
|
333
|
+
+ 'because an OSCAL finding is a statement about a control. For a control-level document, run '
|
|
334
|
+
+ 'the compliance exporter against a named framework.',
|
|
335
|
+
'control-selections': [{
|
|
336
|
+
description: 'No controls selected: a source-code scan does not review a control catalog.',
|
|
337
|
+
}],
|
|
338
|
+
},
|
|
339
|
+
observations,
|
|
340
|
+
risks,
|
|
341
|
+
remarks: TOOL_CAVEATS.map(c => `${c.id}: ${c.shortDescription}`).join(' | '),
|
|
342
|
+
}],
|
|
343
|
+
'back-matter': { resources: caveats },
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── compliance → assessment-results ──────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The normalized row every compliance adapter produces. One per control.
|
|
351
|
+
*
|
|
352
|
+
* `decision` is the only field OSCAL cares about and the only one with a
|
|
353
|
+
* closed set:
|
|
354
|
+
* 'satisfied' → a finding, target status satisfied
|
|
355
|
+
* 'not-satisfied'→ a finding, target status not-satisfied, plus an open risk
|
|
356
|
+
* 'unassessed' → NO finding. An observation with method EXAMINE, and the
|
|
357
|
+
* upstream status carried in `statusLabel` so the reason the
|
|
358
|
+
* engine could not decide survives into the document.
|
|
359
|
+
*/
|
|
360
|
+
function _decisionOf(row) {
|
|
361
|
+
return row.decision === 'satisfied' || row.decision === 'not-satisfied' ? row.decision : 'unassessed';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* How each upstream status maps onto OSCAL's binary finding state. Written out
|
|
366
|
+
* exhaustively and on purpose.
|
|
367
|
+
*
|
|
368
|
+
* The first version of this adapter mapped `present` to satisfied, `partial` to
|
|
369
|
+
* not-satisfied and EVERYTHING ELSE to unassessed. `evaluateFramework` also
|
|
370
|
+
* returns `absent` — signals exist and not one of them cleared, the strongest
|
|
371
|
+
* failure the evaluator can express — and the catch-all quietly relabelled it
|
|
372
|
+
* "requires human judgement", deleting real control failures from the document
|
|
373
|
+
* and attaching a remark that was simply false. It was caught by running the
|
|
374
|
+
* exporter against a bundled framework and reading the output, not by review.
|
|
375
|
+
*
|
|
376
|
+
* So: no catch-all. An unrecognised status is still reported (dropping a
|
|
377
|
+
* control would be worse) but it is reported AS unrecognised, with its raw
|
|
378
|
+
* value, so the next status added upstream shows up as a visible gap here
|
|
379
|
+
* instead of a silent misclassification.
|
|
380
|
+
*/
|
|
381
|
+
const EVALUATION_DECISION = Object.freeze({
|
|
382
|
+
present: 'satisfied',
|
|
383
|
+
partial: 'not-satisfied',
|
|
384
|
+
absent: 'not-satisfied',
|
|
385
|
+
manual: 'unassessed',
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Adapter for `auditor-walkthrough.js#evaluateFramework`, whose rows are
|
|
390
|
+
* `{ control, status, observations[] }`.
|
|
391
|
+
*/
|
|
392
|
+
export function complianceRowsFromEvaluation(evaluation) {
|
|
393
|
+
return (evaluation || []).map(r => {
|
|
394
|
+
const status = r.status || 'manual';
|
|
395
|
+
return {
|
|
396
|
+
id: (r.control && r.control.id) || 'unknown',
|
|
397
|
+
title: (r.control && r.control.summary) || '',
|
|
398
|
+
decision: EVALUATION_DECISION[status] || 'unassessed',
|
|
399
|
+
statusLabel: status,
|
|
400
|
+
known: Object.prototype.hasOwnProperty.call(EVALUATION_DECISION, status),
|
|
401
|
+
observations: (r.observations || []).map(o => (typeof o === 'string' ? o : (o && (o.text || o.summary)) || JSON.stringify(o))),
|
|
402
|
+
props: [
|
|
403
|
+
...(r.control && r.control.codeTestable ? [_prop('code-testable', r.control.codeTestable)] : []),
|
|
404
|
+
...(r.evidence && r.evidence.tier ? [_prop('evidence-tier', r.evidence.tier)] : []),
|
|
405
|
+
...(r.partiallyEvidenced ? [_prop('partially-evidenced', 'true')] : []),
|
|
406
|
+
],
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Adapter for `privacy-framework.js#assessPrivacyFramework`, whose controls are
|
|
413
|
+
* bucketed satisfied | gap | engine-gap | manual.
|
|
414
|
+
*
|
|
415
|
+
* `engine-gap` is the interesting one: NIST rates the control code-testable and
|
|
416
|
+
* this engine has no check for it. That is NOT a control failure and it is NOT
|
|
417
|
+
* a human-judgement control — it is a hole in the tool. OSCAL cannot express
|
|
418
|
+
* that, so it becomes an unassessed observation whose `statusLabel` prop says
|
|
419
|
+
* `engine-gap`, and the remark names the tool as the reason. Folding it into
|
|
420
|
+
* not-satisfied would blame the system for the scanner's coverage.
|
|
421
|
+
*/
|
|
422
|
+
const PRIVACY_DECISION = Object.freeze({
|
|
423
|
+
satisfied: 'satisfied',
|
|
424
|
+
gap: 'not-satisfied',
|
|
425
|
+
manual: 'unassessed',
|
|
426
|
+
'engine-gap': 'unassessed',
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
export function complianceRowsFromPrivacy(assessment) {
|
|
430
|
+
const remediationFor = id =>
|
|
431
|
+
((assessment.findings || []).find(f => f.id === `privacy-framework:${id}`) || {}).remediation || '';
|
|
432
|
+
return (assessment.controls || []).map(c => ({
|
|
433
|
+
id: c.id,
|
|
434
|
+
title: c.summary || '',
|
|
435
|
+
decision: PRIVACY_DECISION[c.bucket] || 'unassessed',
|
|
436
|
+
statusLabel: c.bucket,
|
|
437
|
+
known: Object.prototype.hasOwnProperty.call(PRIVACY_DECISION, c.bucket),
|
|
438
|
+
observations: [],
|
|
439
|
+
remediation: remediationFor(c.id),
|
|
440
|
+
props: [],
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const UNASSESSED_REMARK = {
|
|
445
|
+
unrecognised:
|
|
446
|
+
'Not assessed: the evaluator reported a status this exporter does not recognise, so no satisfied/'
|
|
447
|
+
+ 'not-satisfied claim is made. The raw status is on the assessment-status property. This is a defect '
|
|
448
|
+
+ 'in the exporter, not a statement about the control.',
|
|
449
|
+
manual:
|
|
450
|
+
'Not assessed by automated means: this control requires human judgement and has no code-testable mapping. '
|
|
451
|
+
+ 'Absence of a finding is not evidence of compliance.',
|
|
452
|
+
'engine-gap':
|
|
453
|
+
'Not assessed by this tool: the control is rated code-testable by its publisher, but this engine implements '
|
|
454
|
+
+ 'no check for it. This is a coverage gap in the scanner, NOT a defect in the assessed system.',
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* A control assessment, as an OSCAL assessment-results document.
|
|
459
|
+
*
|
|
460
|
+
* @param framework `{ id, name, publisher, url, license, controls? }` — the
|
|
461
|
+
* catalog metadata as loaded from the framework JSON.
|
|
462
|
+
* @param rows normalized control rows from one of the adapters above.
|
|
463
|
+
*/
|
|
464
|
+
export function toOSCALCompliance(framework, rows, meta = {}) {
|
|
465
|
+
const when = _when(meta);
|
|
466
|
+
const tool = _toolComponent();
|
|
467
|
+
const caveats = _caveatResources();
|
|
468
|
+
const fw = framework || {};
|
|
469
|
+
const list = rows || [];
|
|
470
|
+
|
|
471
|
+
const frameworkResource = {
|
|
472
|
+
uuid: _uuid(`framework:${fw.id || fw.name || 'unknown'}`),
|
|
473
|
+
title: fw.name || String(fw.id || 'Control framework'),
|
|
474
|
+
description:
|
|
475
|
+
`Control set assessed by this document.${fw.publisher ? ` Published by ${fw.publisher}.` : ''}`
|
|
476
|
+
+ `${fw.license ? ` License: ${fw.license}.` : ''}`,
|
|
477
|
+
props: [
|
|
478
|
+
...(fw.id ? [_prop('framework-id', fw.id)] : []),
|
|
479
|
+
...(fw.controlsDigest ? [_prop('controls-digest', fw.controlsDigest)] : []),
|
|
480
|
+
],
|
|
481
|
+
...(fw.url ? { rlinks: [{ href: fw.url }] } : {}),
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const observations = [];
|
|
485
|
+
const findings = [];
|
|
486
|
+
const risks = [];
|
|
487
|
+
|
|
488
|
+
list.forEach((row, i) => {
|
|
489
|
+
const decision = _decisionOf(row);
|
|
490
|
+
const token = oscalToken(row.id);
|
|
491
|
+
const obsUuid = _uuid(`compliance-observation:${fw.id}:${row.id}:${i}`);
|
|
492
|
+
const detail = (row.observations || []).filter(Boolean).join(' ');
|
|
493
|
+
|
|
494
|
+
observations.push({
|
|
495
|
+
uuid: obsUuid,
|
|
496
|
+
title: `${row.id} — ${row.title}`.trim(),
|
|
497
|
+
description: detail
|
|
498
|
+
|| (decision === 'unassessed'
|
|
499
|
+
? `Control ${row.id} was not assessed by automated means.`
|
|
500
|
+
: `Control ${row.id} was assessed from scanner evidence.`),
|
|
501
|
+
// EXAMINE for what a human still owns, TEST for what the engine decided.
|
|
502
|
+
// This is the field an auditor filters on to build their own worklist.
|
|
503
|
+
methods: decision === 'unassessed' ? ['EXAMINE'] : ['TEST'],
|
|
504
|
+
types: ['control-objective'],
|
|
505
|
+
origins: _toolOrigin(tool.uuid),
|
|
506
|
+
collected: when,
|
|
507
|
+
props: [
|
|
508
|
+
_prop('source-control-id', row.id),
|
|
509
|
+
_prop('assessment-status', row.statusLabel || decision),
|
|
510
|
+
...(row.props || []),
|
|
511
|
+
],
|
|
512
|
+
...(decision === 'unassessed'
|
|
513
|
+
? { remarks: row.known === false
|
|
514
|
+
? UNASSESSED_REMARK.unrecognised
|
|
515
|
+
: (UNASSESSED_REMARK[row.statusLabel] || UNASSESSED_REMARK.manual) }
|
|
516
|
+
: {}),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (decision === 'unassessed') return;
|
|
520
|
+
|
|
521
|
+
const findingUuid = _uuid(`compliance-finding:${fw.id}:${row.id}:${i}`);
|
|
522
|
+
const riskUuid = decision === 'not-satisfied' ? _uuid(`compliance-risk:${fw.id}:${row.id}:${i}`) : null;
|
|
523
|
+
|
|
524
|
+
findings.push({
|
|
525
|
+
uuid: findingUuid,
|
|
526
|
+
title: `${row.id} — ${row.title}`.trim(),
|
|
527
|
+
description: detail || `Automated assessment of control ${row.id}.`,
|
|
528
|
+
props: [_prop('source-control-id', row.id), _prop('assessment-status', row.statusLabel || decision)],
|
|
529
|
+
origins: _toolOrigin(tool.uuid),
|
|
530
|
+
target: {
|
|
531
|
+
// `objective-id`, not `statement-id`: these frameworks are control
|
|
532
|
+
// objectives, not statements of an implemented SSP component, and this
|
|
533
|
+
// document does not reference an SSP.
|
|
534
|
+
type: 'objective-id',
|
|
535
|
+
'target-id': token,
|
|
536
|
+
title: row.title || row.id,
|
|
537
|
+
status: {
|
|
538
|
+
state: decision,
|
|
539
|
+
reason: decision === 'satisfied' ? 'pass' : 'fail',
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
'related-observations': [{ 'observation-uuid': obsUuid }],
|
|
543
|
+
...(riskUuid ? { 'related-risks': [{ 'risk-uuid': riskUuid }] } : {}),
|
|
544
|
+
remarks:
|
|
545
|
+
decision === 'satisfied'
|
|
546
|
+
? 'Satisfied means the automated signals mapped to this control cleared. It is scanner evidence '
|
|
547
|
+
+ 'toward the control, not an attestation that the control is implemented and operating.'
|
|
548
|
+
: 'Not satisfied: at least one automated signal mapped to this control did not clear.',
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
if (riskUuid) {
|
|
552
|
+
const remediation = typeof row.remediation === 'string' ? row.remediation.trim() : '';
|
|
553
|
+
risks.push({
|
|
554
|
+
uuid: riskUuid,
|
|
555
|
+
title: `Control not satisfied: ${row.id}`,
|
|
556
|
+
description: row.title || `Control ${row.id}`,
|
|
557
|
+
statement: detail
|
|
558
|
+
|| `Automated signals mapped to control ${row.id} did not clear at assessment time.`,
|
|
559
|
+
props: [_prop('source-control-id', row.id)],
|
|
560
|
+
status: 'open',
|
|
561
|
+
'related-observations': [{ 'observation-uuid': obsUuid }],
|
|
562
|
+
...(remediation
|
|
563
|
+
? {
|
|
564
|
+
remediations: [{
|
|
565
|
+
uuid: _uuid(`compliance-response:${fw.id}:${row.id}:${i}`),
|
|
566
|
+
lifecycle: 'recommendation',
|
|
567
|
+
title: 'Recommended remediation',
|
|
568
|
+
description: remediation,
|
|
569
|
+
}],
|
|
570
|
+
}
|
|
571
|
+
: {}),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const counts = { satisfied: 0, 'not-satisfied': 0, unassessed: 0 };
|
|
577
|
+
for (const row of list) counts[_decisionOf(row)]++;
|
|
578
|
+
|
|
579
|
+
const resultUuid = _uuid(`compliance-result:${fw.id}:${list.length}`);
|
|
580
|
+
return {
|
|
581
|
+
uuid: _uuid(`compliance-assessment-results:${fw.id}:${list.length}`),
|
|
582
|
+
metadata: _metadata(`${fw.name || fw.id || 'Control framework'} — automated control assessment`, meta),
|
|
583
|
+
'import-ap': {
|
|
584
|
+
href: `#${resultUuid}`,
|
|
585
|
+
remarks:
|
|
586
|
+
'No OSCAL assessment plan governs this assessment. Controls were evaluated directly from scanner '
|
|
587
|
+
+ 'evidence by the tool named in local-definitions.assessment-assets; this reference is a '
|
|
588
|
+
+ 'self-reference recorded because the OSCAL assessment-results model requires import-ap.',
|
|
589
|
+
},
|
|
590
|
+
results: [{
|
|
591
|
+
uuid: resultUuid,
|
|
592
|
+
'local-definitions': { 'assessment-assets': _assessmentAssets(tool) },
|
|
593
|
+
title: `${fw.name || fw.id || 'Control framework'} assessment`,
|
|
594
|
+
description:
|
|
595
|
+
`${list.length} control(s) reviewed: ${counts.satisfied} satisfied, ${counts['not-satisfied']} not `
|
|
596
|
+
+ `satisfied, ${counts.unassessed} NOT ASSESSED. Unassessed controls carry no finding — an OSCAL `
|
|
597
|
+
+ 'finding requires a binary satisfied/not-satisfied state, and asserting either for a control '
|
|
598
|
+
+ 'nobody checked would be false. They appear as observations with method EXAMINE.',
|
|
599
|
+
start: when,
|
|
600
|
+
props: [
|
|
601
|
+
_prop('controls-reviewed', list.length),
|
|
602
|
+
_prop('count-satisfied', counts.satisfied),
|
|
603
|
+
_prop('count-not-satisfied', counts['not-satisfied']),
|
|
604
|
+
_prop('count-unassessed', counts.unassessed),
|
|
605
|
+
...(fw.id ? [_prop('framework-id', fw.id)] : []),
|
|
606
|
+
],
|
|
607
|
+
links: [
|
|
608
|
+
{ href: `#${frameworkResource.uuid}`, rel: 'source' },
|
|
609
|
+
...caveats.map(c => ({ href: `#${c.uuid}`, rel: 'reference' })),
|
|
610
|
+
],
|
|
611
|
+
'reviewed-controls': {
|
|
612
|
+
description: `${fw.name || fw.id || 'Control framework'}${fw.publisher ? ` (${fw.publisher})` : ''}.`
|
|
613
|
+
+ ' Control identifiers are OSCAL tokens derived from the publisher\'s identifiers; the original'
|
|
614
|
+
+ ' identifier is carried on each observation and finding as a source-control-id property.',
|
|
615
|
+
'control-selections': [{
|
|
616
|
+
description: 'Every control in the framework as loaded by the engine.',
|
|
617
|
+
'include-controls': list.map(r => ({ 'control-id': oscalToken(r.id) })),
|
|
618
|
+
}],
|
|
619
|
+
},
|
|
620
|
+
observations,
|
|
621
|
+
...(findings.length ? { findings } : {}),
|
|
622
|
+
...(risks.length ? { risks } : {}),
|
|
623
|
+
remarks:
|
|
624
|
+
'This document organizes automated scanner evidence against a control set. It is not an attestation '
|
|
625
|
+
+ 'of compliance and no licensed assessor reviewed it. '
|
|
626
|
+
+ TOOL_CAVEATS.map(c => `${c.id}: ${c.shortDescription}`).join(' | '),
|
|
627
|
+
}],
|
|
628
|
+
'back-matter': { resources: [frameworkResource, ...caveats] },
|
|
629
|
+
};
|
|
630
|
+
}
|