@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
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,101 @@
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
## 0.143.0 — OSCAL output, and the finding an OSCAL document must refuse to make
|
|
15
|
+
|
|
16
|
+
`--format oscal` was documented in `commands/compliance.md` long before anything
|
|
17
|
+
implemented it. In 0.139.0 that was corrected to an explicit refusal rather than
|
|
18
|
+
left aspirational. This release makes it real, in both places the request asked
|
|
19
|
+
for: any scan, and any framework assessment.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
agentic-security scan . --format oscal
|
|
23
|
+
agentic-security compliance --report <framework> --format oscal
|
|
24
|
+
agentic-security compliance --format oscal # NIST Privacy Framework 1.1
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Both emit NIST [OSCAL](https://pages.nist.gov/OSCAL-Reference/models/) 1.1.2
|
|
28
|
+
`assessment-results`. `--report <fw>` is also now a real CLI synonym for
|
|
29
|
+
`--walkthrough <fw>`; the slash command had always spelled it that way, and it
|
|
30
|
+
previously reached the frameworks only through an inlined script in `commands/`.
|
|
31
|
+
|
|
32
|
+
**The interesting part is what these documents refuse to say.** An OSCAL
|
|
33
|
+
`finding` is a statement about a control, and its `status.state` is binary:
|
|
34
|
+
satisfied or not-satisfied. There is no "unknown" and no "we did not look". So:
|
|
35
|
+
|
|
36
|
+
- **A raw scan emits no findings at all.** Observations (what the scanner saw)
|
|
37
|
+
and risks (what it would mean), and a `reviewed-controls` block that says
|
|
38
|
+
plainly that no catalog was in scope. A SQL-injection hit is not an opinion
|
|
39
|
+
about a control; emitting one would publish a CWE→control mapping nobody
|
|
40
|
+
wrote. `include-all` is likewise absent — it would assert this scan reviewed
|
|
41
|
+
every control of an unnamed catalog.
|
|
42
|
+
- **A control the engine could not decide carries no finding.** `manual`
|
|
43
|
+
controls, and on the privacy path `engine-gap` controls — where NIST rates the
|
|
44
|
+
control code-testable and *this scanner has no check for it* — become
|
|
45
|
+
observations with method `EXAMINE`. Calling them satisfied would be a false
|
|
46
|
+
compliance claim; calling them not-satisfied would blame the assessed system
|
|
47
|
+
for a hole in the tool.
|
|
48
|
+
|
|
49
|
+
The distinction OSCAL cannot express rides along as an `assessment-status`
|
|
50
|
+
property, so nothing is lost by the conversion. Full mapping table in
|
|
51
|
+
`docs/OSCAL.md`.
|
|
52
|
+
|
|
53
|
+
**A bug this found in itself.** The first adapter mapped `present` to satisfied,
|
|
54
|
+
`partial` to not-satisfied, and everything else to unassessed. `evaluateFramework`
|
|
55
|
+
also returns `absent` — signals exist and not one cleared, the strongest failure
|
|
56
|
+
it can express — and the catch-all silently relabelled it "requires human
|
|
57
|
+
judgement", deleting real control failures from the document and attaching a
|
|
58
|
+
remark that was false. It was caught by running the exporter against a bundled
|
|
59
|
+
framework and reading the output. There is now no catch-all: the mapping is
|
|
60
|
+
exhaustive, and an unrecognised upstream status is reported *as* unrecognised,
|
|
61
|
+
naming itself as an exporter defect rather than making a claim about the control.
|
|
62
|
+
|
|
63
|
+
**Two more defects the checking found, both invisible at emit time.** OSCAL's
|
|
64
|
+
`assessment-assets` — the block that identifies what performed the assessment —
|
|
65
|
+
is scoped to the *result*, not the document (`assessment-results/local-definitions`
|
|
66
|
+
carries objectives-and-methods and activities, and nothing else), and it requires
|
|
67
|
+
at least one `assessment-platforms` entry. The first draft had it at document
|
|
68
|
+
level with no platform. Both produce a document that emits cleanly and fails
|
|
69
|
+
validation, which is precisely the failure mode of a format claim nobody ran
|
|
70
|
+
through a validator.
|
|
71
|
+
|
|
72
|
+
**Control identifiers are rewritten, and the originals kept.** OSCAL's `token`
|
|
73
|
+
datatype is an NCName. The CCPA catalog bundled with this engine uses ids like
|
|
74
|
+
`§1798.100`, which is not one — emitting it raw produces a document a validator
|
|
75
|
+
rejects at the first control, the usual failure mode of an OSCAL export that was
|
|
76
|
+
never run through one. Ids are sanitised to legal tokens and the publisher's
|
|
77
|
+
original is carried on every observation and finding as `source-control-id`.
|
|
78
|
+
|
|
79
|
+
**Checked, and the checks were checked.** `test/oscal-conformance.test.js` (15
|
|
80
|
+
tests) validates required fields, the constrained datatypes (`uuid`, `token`,
|
|
81
|
+
`dateTime-with-timezone`), the closed value sets, and referential integrity —
|
|
82
|
+
every `*-uuid` and `#fragment` must resolve inside the document. It also pins the
|
|
83
|
+
doctrine above, which is not a schema property and would otherwise be one
|
|
84
|
+
refactor from reversing. Three deliberate regressions (neutering the token
|
|
85
|
+
sanitiser, the deterministic uuid shaping, and the no-findings rule) were each
|
|
86
|
+
confirmed to fail the suite before the source was restored. Scope is stated in
|
|
87
|
+
the file: structural validation, not full JSON-Schema validation against NIST's
|
|
88
|
+
published schema — fetching it at test time breaks the no-network rule and
|
|
89
|
+
vendoring it adds a file that rots silently. Same call, same reasoning, as
|
|
90
|
+
`test/sbom-conformance.test.js`.
|
|
91
|
+
|
|
92
|
+
`oscal` joins the `format-determinism` gate, so two emits of one scan are
|
|
93
|
+
byte-identical and an attestation over the document still verifies. That gate
|
|
94
|
+
also exposed a real gap: every conformance test ran the `crypto.randomUUID()`
|
|
95
|
+
branch, leaving the `--deterministic` branch — the one an attestation is
|
|
96
|
+
actually taken over — untested. A digest slice is not a legal uuid; roughly 15
|
|
97
|
+
in 16 fail on the variant nibble alone. It is now covered explicitly.
|
|
98
|
+
|
|
99
|
+
**Also:** the two load-bearing caveats (ordinal scores are not probabilities;
|
|
100
|
+
benchmark-tuned F1 does not generalize) now come from one exported constant
|
|
101
|
+
rather than being spelled out inside `toSARIF`. SARIF carries them as run
|
|
102
|
+
notifications, OSCAL as back-matter resources, from the same source — two copies
|
|
103
|
+
of a caveat is one copy that goes stale, and the stale one is always the one
|
|
104
|
+
somebody reads. The three inline "is this a machine format" lists in the CLI
|
|
105
|
+
became one set, for the same reason: each new format previously had to be added
|
|
106
|
+
to all three or it got human chatter interleaved into its output.
|
|
107
|
+
|
|
108
|
+
|
|
14
109
|
## 0.142.0 — The last five PRD items, and three criteria that now fail on evidence
|
|
15
110
|
|
|
16
111
|
0.141.0 built instruments for the surfaces that had none. This closes the
|
package/bin/agentic-security.js
CHANGED
|
@@ -39,6 +39,17 @@ function writeStdout(s) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
import { toJSON, toMarkdown, toSARIF, toSTIX, toCSV, toJUnit, toCLI, toCLIByProfile, toShipVerdict, toProTable, toHTML, toSummary, toVex, exitCodeFor, normalizeFindings } from '../src/report/index.js';
|
|
42
|
+
import { toOSCAL } from '../src/report/oscal.js';
|
|
43
|
+
|
|
44
|
+
// Formats whose output is a machine artifact, not something a human reads in a
|
|
45
|
+
// terminal. Three separate copies of this list used to be spelled out inline —
|
|
46
|
+
// the MTTR line, the fix-duration line and the streak line — and each new
|
|
47
|
+
// format had to be added to all three or it silently got human chatter
|
|
48
|
+
// interleaved into its stdout/stderr. One list, one place to update.
|
|
49
|
+
const MACHINE_FORMATS = new Set([
|
|
50
|
+
'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom',
|
|
51
|
+
]);
|
|
52
|
+
function isMachineFormat(fmt) { return MACHINE_FORMATS.has(String(fmt)); }
|
|
42
53
|
import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
|
|
43
54
|
import { toPBOM } from '../src/sast/pipeline.js';
|
|
44
55
|
import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
|
|
@@ -96,8 +107,13 @@ Commands:
|
|
|
96
107
|
compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
|
|
97
108
|
--list show bundled + BYO frameworks
|
|
98
109
|
--walkthrough <id> auditor narrative for any framework
|
|
110
|
+
--report <id> synonym for --walkthrough
|
|
99
111
|
--gap only the failing controls
|
|
100
|
-
--format cli|json|md (default cli)
|
|
112
|
+
--format cli|json|md|oscal (default cli)
|
|
113
|
+
--format oscal NIST OSCAL assessment-results.
|
|
114
|
+
Controls the engine could not
|
|
115
|
+
decide carry NO finding — see
|
|
116
|
+
docs/OSCAL.md.
|
|
101
117
|
--fail-on gap exit 1 when a control is failing
|
|
102
118
|
Reads .agentic-security/last-scan.json — run a scan first.
|
|
103
119
|
version Print version
|
|
@@ -113,7 +129,7 @@ Commands:
|
|
|
113
129
|
Options:
|
|
114
130
|
--profile vibecoder|pro Override profile for this run
|
|
115
131
|
--only sast|sca|secrets Limit scan to one pillar
|
|
116
|
-
--format <fmt> cli | json | md | sarif | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
|
|
132
|
+
--format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
|
|
117
133
|
--pack <name> Focus on a curated rule pack (repeatable): owasp-top-10 | cwe-top-25 | llm-security | supply-chain
|
|
118
134
|
--baseline <ref> Diff against a git ref; only findings new vs. that ref count (ci subcommand)
|
|
119
135
|
--fail-on critical|high|medium|low|none ci-mode exit policy (default: critical)
|
|
@@ -678,6 +694,10 @@ async function cmdScan(args) {
|
|
|
678
694
|
if (format === 'json') body = JSON.stringify(toJSON(scan, meta, { includeSuppressed }), null, 2);
|
|
679
695
|
else if (format === 'md' || format === 'markdown') body = toMarkdown(scan, meta);
|
|
680
696
|
else if (format === 'sarif') body = JSON.stringify(toSARIF(scan, meta), null, 2);
|
|
697
|
+
// OSCAL assessment-results. Observations and risks only, never findings — an
|
|
698
|
+
// OSCAL finding is a statement about a control, and a source scan reviews no
|
|
699
|
+
// control catalog. See src/report/oscal.js.
|
|
700
|
+
else if (format === 'oscal') body = JSON.stringify(toOSCAL(scan, meta), null, 2);
|
|
681
701
|
else if (format === 'stix') body = JSON.stringify(toSTIX(scan, meta), null, 2);
|
|
682
702
|
else if (format === 'junit') body = toJUnit(scan, meta);
|
|
683
703
|
else if (format === 'csv') body = toCSV(scan);
|
|
@@ -759,7 +779,7 @@ async function cmdScan(args) {
|
|
|
759
779
|
const removed = prevAll.filter(f => !currentFps.has(fingerprintFinding(f)));
|
|
760
780
|
persistedScan.mttr = computeMTTR(removed);
|
|
761
781
|
// Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
|
|
762
|
-
const isJson = format
|
|
782
|
+
const isJson = isMachineFormat(format);
|
|
763
783
|
if (!isJson) {
|
|
764
784
|
const sla = renderSlaSummary(persistedScan.findings || []);
|
|
765
785
|
if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
|
|
@@ -789,7 +809,7 @@ async function cmdScan(args) {
|
|
|
789
809
|
const fixMetrics = fixDurationReport(path.resolve(target));
|
|
790
810
|
if (fixMetrics.attempts > 0) {
|
|
791
811
|
persistedScan.fixMetrics = fixMetrics;
|
|
792
|
-
const isJsonFmt = format
|
|
812
|
+
const isJsonFmt = isMachineFormat(format);
|
|
793
813
|
const line = renderFixDurationSummary(fixMetrics);
|
|
794
814
|
if (!isJsonFmt && line) process.stderr.write(`🔧 agentic-security: ${line}\n`);
|
|
795
815
|
}
|
|
@@ -832,7 +852,7 @@ async function cmdScan(args) {
|
|
|
832
852
|
try {
|
|
833
853
|
const streak = persistedScan !== null ? recordScan(stateDirPath, persistedScan) : null;
|
|
834
854
|
// Print celebration / streak line to stderr so it doesn't pollute --format json
|
|
835
|
-
if (streak && process.stderr.isTTY && format
|
|
855
|
+
if (streak && process.stderr.isTTY && !isMachineFormat(format)) {
|
|
836
856
|
const delta = formatGradeDelta(streak);
|
|
837
857
|
const line = formatStreakLine(streak);
|
|
838
858
|
if (delta) process.stderr.write('\n' + delta + '\n');
|
|
@@ -1791,14 +1811,27 @@ async function cmdCompliance(args) {
|
|
|
1791
1811
|
// The engine records this two ways depending on the emit path; take either.
|
|
1792
1812
|
scan.filesScanned = scan._scanMeta?.filesScanned ?? scan.scanned?.files ?? 0;
|
|
1793
1813
|
|
|
1794
|
-
|
|
1814
|
+
// `--report <fw>` is accepted as a synonym for `--walkthrough <fw>`: the
|
|
1815
|
+
// slash-command surface has always spelled it that way, and it previously
|
|
1816
|
+
// reached the frameworks only through an inlined node call in commands/
|
|
1817
|
+
// rather than through this CLI. One code path, two spellings.
|
|
1818
|
+
const wt = args.flags.walkthrough || args.flags.report;
|
|
1795
1819
|
if (wt && wt !== true) {
|
|
1796
1820
|
const fw = loadFramework(scanRoot, String(wt));
|
|
1797
1821
|
if (!fw) {
|
|
1798
1822
|
console.error(`Unknown framework "${wt}". Try --list.`);
|
|
1799
1823
|
return 2;
|
|
1800
1824
|
}
|
|
1801
|
-
|
|
1825
|
+
const evaluation = evaluateFramework(scanRoot, fw, scan);
|
|
1826
|
+
if (fmt === 'oscal') {
|
|
1827
|
+
const { toOSCALCompliance, complianceRowsFromEvaluation } = await import('../src/report/oscal.js');
|
|
1828
|
+
writeStdout(JSON.stringify(
|
|
1829
|
+
toOSCALCompliance(fw, complianceRowsFromEvaluation(evaluation), { startedAt: scan._scanMeta?.startedAt }),
|
|
1830
|
+
null, 2) + '\n');
|
|
1831
|
+
return 0;
|
|
1832
|
+
}
|
|
1833
|
+
if (fmt === 'json') { writeStdout(JSON.stringify(evaluation, null, 2) + '\n'); return 0; }
|
|
1834
|
+
console.log(renderWalkthrough(fw, evaluation, {}));
|
|
1802
1835
|
return 0;
|
|
1803
1836
|
}
|
|
1804
1837
|
|
|
@@ -1808,6 +1841,19 @@ async function cmdCompliance(args) {
|
|
|
1808
1841
|
if (!r) { console.error(`Framework ${PRIVACY_FRAMEWORK_ID} could not be loaded.`); return 2; }
|
|
1809
1842
|
|
|
1810
1843
|
const gapsOnly = !!args.flags.gap;
|
|
1844
|
+
if (fmt === 'oscal') {
|
|
1845
|
+
// The privacy assessment's own bucket model, not evaluateFramework's — see
|
|
1846
|
+
// complianceRowsFromPrivacy for why `engine-gap` must not become a control
|
|
1847
|
+
// failure. `--gap` is deliberately NOT applied here: an OSCAL document that
|
|
1848
|
+
// silently omitted the satisfied controls would understate what was
|
|
1849
|
+
// reviewed, and reviewed-controls would then disagree with the findings.
|
|
1850
|
+
const { toOSCALCompliance, complianceRowsFromPrivacy } = await import('../src/report/oscal.js');
|
|
1851
|
+
const fwMeta = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID) || { id: PRIVACY_FRAMEWORK_ID, name: r.frameworkName };
|
|
1852
|
+
writeStdout(JSON.stringify(
|
|
1853
|
+
toOSCALCompliance(fwMeta, complianceRowsFromPrivacy(r), { startedAt: scan._scanMeta?.startedAt }),
|
|
1854
|
+
null, 2) + '\n');
|
|
1855
|
+
return args.flags['fail-on'] === 'gap' && r.summary.gap > 0 ? 1 : 0;
|
|
1856
|
+
}
|
|
1811
1857
|
if (fmt === 'json') {
|
|
1812
1858
|
writeStdout(JSON.stringify(gapsOnly ? { ...r, controls: r.controls.filter(c => c.bucket === 'gap') } : r, null, 2) + '\n');
|
|
1813
1859
|
} else if (fmt === 'md') {
|