@clear-capabilities/agentic-security-scanner 0.141.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 CHANGED
@@ -10,6 +10,236 @@
10
10
 
11
11
 
12
12
 
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
+
109
+ ## 0.142.0 — The last five PRD items, and three criteria that now fail on evidence
110
+
111
+ 0.141.0 built instruments for the surfaces that had none. This closes the
112
+ remainder of `docs/WORLD_CLASS_HARNESS_PRD.md`: four of the five items found
113
+ live engine bugs, and the fifth re-derived the headline the whole document rests
114
+ on. Three success criteria now **fail** — measured, rather than unknown.
115
+
116
+ ### A sanitizer could be undone and the flow still read as clean
117
+
118
+ `he.decode(escapeHtml(req.query.name))` reaching an HTML sink was reported
119
+ **sanitized**. The decode puts back exactly what the escape removed, so this was
120
+ a missed XSS presented as a clean flow.
121
+
122
+ Nothing modelled reversal: the catalog holds sanitizers, a decoder is the
123
+ opposite, so it was never even recorded on the path. Fixed across three layers —
124
+ the taint walk now collects un-sanitizer callees, the gate maps them to the
125
+ family they reverse (percent-decoding does **not** undo HTML escaping, so a flat
126
+ list would be wrong), and the finding-projection allowlist had to learn the new
127
+ field. That last one is why the fix looked inert through three rounds of
128
+ debugging.
129
+
130
+ Found by the mutation gate, which went from 12 to **34 cases** — the five
131
+ detector families 0.141.0 shipped had owed a metamorphic pair and an adversarial
132
+ near-miss and had none.
133
+
134
+ ### The CloudFormation ingress rule was keyed on YAML key order
135
+
136
+ `- CidrIp:` first and `- IpProtocol:` first are the same template — YAML mappings
137
+ are unordered — and only the second matched. The rule was keyed on the author's
138
+ formatting, which is syntax, not meaning. Also caught by the mutation gate, by
139
+ the metamorphic case that exists for exactly this.
140
+
141
+ ### Every reachability demotion we could adjudicate was wrong
142
+
143
+ Reachability was reported as a demotion *rate*, and that rate was **0 for every
144
+ entry by construction**: `bench/sca-replay` fetched lockfiles, so the analysis
145
+ had no source to walk. A number that is structurally zero looks like a
146
+ measurement and is not one.
147
+
148
+ With source fetched and scored against an import-level oracle: **3 adjudicable
149
+ demotions, 3 false-unreachable — 100% wrong, in the missed-exploit direction.**
150
+ `express`/`cookie`, `express`/`send`, `poetry`/`requests` — each demoted to
151
+ `info` and out of the report, each verified by hand as genuinely imported.
152
+
153
+ Three defects behind it:
154
+
155
+ - **Failure to prove reachability was reported as proof of unreachability.** A
156
+ site the analysis could not reason about became a positive claim. It is now
157
+ `unknown`, a state the code already used elsewhere.
158
+ - **A project with no routes was still asked "reachable from a route?"** For a
159
+ library that question has no answer — its callers are its users, not in the
160
+ tree.
161
+ - **`_enclosingFn` knew two of four declaration forms.** `res.cookie = function
162
+ (…)` — how most of the JS ecosystem defines a public method — was invisible, so
163
+ the scan attributed call sites to unrelated functions further up the file. It
164
+ now also tracks whether the enclosing function is **exported**, because a
165
+ public-API function with no in-tree caller is the normal case, not dead code.
166
+
167
+ **If you use SCA reachability to triage, this affected you**: findings were being
168
+ demoted out of your report on projects that genuinely import the vulnerable
169
+ package.
170
+
171
+ ### The VS Code extension had never been type-checked
172
+
173
+ `typescript` is now a devDependency and `npm run typecheck` runs in CI. It found
174
+ errors immediately: the tsconfig declared no `types` at all, so `process`,
175
+ `setTimeout` and `NodeJS.Timeout` were every one of them unresolved — `@types/node`
176
+ was installed and nothing consumed it — plus two implicit `any` parameters. The
177
+ build is esbuild, which strips types without checking them, so none of this had
178
+ ever surfaced.
179
+
180
+ Neovim and JetBrains are now smoke-tested in CI too. JetBrains is classified
181
+ **informational**: it downloads a full IntelliJ distribution, so red there is more
182
+ often the network than this code, and a habitually-red gate stops being read.
183
+
184
+ ## The population re-measured — and it is worse
185
+
186
+ 991 scored entries at engine 0.141.0 (the code that ships here), against 315 at
187
+ 0.138.0 before:
188
+
189
+ | | before | **now** |
190
+ |---|---:|---:|
191
+ | localized recall | 3.56% | **2.83%** |
192
+ | localized precision | 44.00% | **36.36%** |
193
+ | fix-discrimination | 81.8% | **71.43%** |
194
+ | held-out recall | — | **2.93%** |
195
+
196
+ **The headline fell because the question got harder.** The corpus tripled once
197
+ the advisory miner could page past the first hundred entries per ecosystem, and
198
+ what it pulled in is recent, TypeScript-heavy and dominated by authorization
199
+ classes. Held-out tracks development almost exactly, so nothing is fitted.
200
+
201
+ **Ruby 0% → 3.20%. Go 0% → 1.19%.** Both measured zeros are off zero, on
202
+ populations 8× and 1.2× their old size. **PHP is the new zero, at 0/73.**
203
+
204
+ Two of this release's own fixes are confirmed on real code rather than their own
205
+ fixtures: the Ruby `File.join` rule earns `lsegal/yard`, and `CONVENTION` earns
206
+ its first localized true positive ever on GitPython — the family recorded as
207
+ permanently silent until it turned out to be mislocalized by five lines.
208
+
209
+ ### Three success criteria now fail, on evidence
210
+
211
+ - **Fix-discrimination is 71.43%**, below its 80% floor. 8 of 28 findings still
212
+ fire on the code the fix produced — those detected an API, not a vulnerability.
213
+ - **Taint contributes 1 of 28** localized true positives. It was 1 of 12; the
214
+ count did not move while the population tripled. Deep mode also costs 5 extra
215
+ false positives and **loses** a Go finding that pattern-only makes.
216
+ - **Compliance still has no accuracy instrument** — the last feature measured by
217
+ nothing.
218
+
219
+ ### Measured and deliberately not fixed
220
+
221
+ - **The agent trust-boundary delta is 0 of 0 — undefined, not zero.** The
222
+ population now holds 28 entries of real MCP-server code; the engine produces no
223
+ localized true positives on any of them, so there is nothing for the boundary
224
+ modelling to have contributed to. It is not silent there — 21 findings per
225
+ entry, all on the advisory's own files, none of the labelled class. WRONG-CWE,
226
+ on the most differentiated surface in the product.
227
+ - **Reachability now demotes nothing** on the source-bearing corpus. The false
228
+ demotions are gone; whether the new caution is correct needs applications with
229
+ real routes, which those four entries are not.
230
+
231
+ ## Also
232
+
233
+ - A whole-population benchmark run **wedged** at 0.0% CPU after 4.5 hours, taking
234
+ every already-scored entry with it. `bench/independent/runner.mjs` now takes
235
+ `--offset`/`--limit` and `merge-chunks.mjs` reassembles the slices,
236
+ **recomputing** every aggregate from the per-entry rows and refusing to write
237
+ unless that reproduces each chunk's own numbers exactly. It caught two of its
238
+ own bugs that way.
239
+ - A guard-shaped method NAME on a declaration line (`def check_static_cache(`) no
240
+ longer counts as a containment guard, which had been silently dropping path
241
+ findings inside any method called `check*` / `validate*` / `ensure*`.
242
+
13
243
  ## 0.141.0 — Six new instruments, and the seven live bugs they found
14
244
 
15
245
  The previous release fixed five bugs found by measuring. This one builds the
@@ -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 === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
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 === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
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 !== 'json' && format !== 'sarif') {
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
- const wt = args.flags.walkthrough;
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
- console.log(renderWalkthrough(fw, evaluateFramework(scanRoot, fw, scan), {}));
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') {