@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.
@@ -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
+ }