@clear-capabilities/agentic-security-scanner 0.139.0 → 0.140.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
@@ -9,6 +9,119 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.140.0 — Five shipped bugs, found by measuring instead of reading
13
+
14
+ Every fix here is a defect that was live in 0.139.1. None came from the feature
15
+ backlog; all five came from measuring the engine against real code and taking
16
+ failing signals seriously instead of explaining them away.
17
+
18
+ ### `scan --format sarif` produced INVALID SARIF in CI
19
+
20
+ The CLI dispatches every command as `process.exit(await cmdX(args))`, and
21
+ `process.exit()` does not flush an asynchronous stdout. stdout is asynchronous
22
+ exactly when it is a pipe — every `> results.sarif`, `| jq`, and CI capture — so
23
+ output was discarded at the 64 KiB pipe boundary, mid-token, with a normal exit
24
+ status. On one directory that was 65,536 bytes emitted of 390,177: roughly 83%
25
+ of the document silently dropped.
26
+
27
+ **If you upload SARIF to code scanning, this affected you on any project large
28
+ enough to matter.** A TTY and a file both flush synchronously, which is why it
29
+ looked fine by hand and broke in automation.
30
+
31
+ ### LLM01 — Prompt Injection — could never fail
32
+
33
+ Detectors emit the finding family as `<family>-<rule-slug>`
34
+ (`prompt-injection-http-user-input-in-llm-`), while the compliance evaluator
35
+ resolved `family:prompt-injection` as an exact key. It matched nothing, so the
36
+ control reported as evidenced no matter what the scan found. The first control
37
+ of the OWASP LLM Top 10 was structurally incapable of failing, along with ASVS
38
+ V5.1 and NIST AI 600-1 MG-3.2-005.
39
+
40
+ Two further compliance defects in the same matching code: an empty family
41
+ bucket rendered as `✓ no open critical/high findings`, so two controls (ASVS
42
+ V7.1, NIST Privacy CT.DP-P1) read `present` on every scan of every project; and
43
+ a first attempt at fixing that wrongly declared four live families
44
+ unevidenceable, degrading 15 working controls. Both directions are now gated —
45
+ a control with no possible evidence cannot read `present`, and a family with a
46
+ producer cannot be declared a gap.
47
+
48
+ ### `--deterministic` did not produce deterministic output
49
+
50
+ Four of ten emitted formats differed run to run: CycloneDX/SPDX document ids and
51
+ a CycloneDX bom-ref fallback from `crypto.randomUUID()`, a PoC marker from
52
+ `Math.random()`, and per-file wall-clock timings (which also determined the
53
+ sort ORDER, so blanking the values alone would not have been enough).
54
+
55
+ **An attestation over an SBOM was therefore unverifiable** — the point of
56
+ signing an artifact is that someone can regenerate and compare it.
57
+
58
+ ### 62% of concurrency findings on Go were false positives
59
+
60
+ The lock guard matched a bare receiver (`defer mu.Unlock()`) but not a qualified
61
+ one (`defer s.mu.Unlock()`), which is how a mutex held as a struct field is
62
+ always written. The acquire pattern always matched the qualified form, so the
63
+ two halves of the rule had disagreed since it was written — and the most
64
+ idiomatic CORRECT code was the most likely to be reported. Measured: 170 of 273
65
+ findings on a Go sample were false positives. Those findings also carried no
66
+ CWE, so they were invisible to every CWE-keyed report.
67
+
68
+ ### New gates
69
+
70
+ - `test/stdout-flush.test.js` — spawns the real CLI through a real pipe
71
+ - `test/format-determinism.test.js` — every emitted format, byte-compared
72
+ - `test/compliance-mapping-liveness.test.js` — both vacuous-pass directions
73
+ - `test/concurrency-cwe.test.js` — per-lock guard discrimination
74
+ - `bench/family-producers/OBSERVED.json` — 213 families observed across 331
75
+ real scan roots, recorded explicitly as a LOWER BOUND
76
+
77
+ ### Scope
78
+
79
+ The world-class-harness PRD is **partially delivered**. F10.5 (determinism as a
80
+ published property) is complete. F10.2 is half done — the enforcement half
81
+ landed; the measurement half needs detectors to declare their families, because
82
+ this release proved no textual search can enumerate them. Roughly 35 PRD items
83
+ remain, several blocked on design decisions rather than implementation.
84
+
85
+ ## 0.139.1 — The same scanner, published with provenance
86
+
87
+ **The shipped artifact is functionally identical to 0.139.0.** Only two commits
88
+ separate them, and neither is in the published package: a test-harness timeout
89
+ (`test/` does not ship) and a dependency hold (`.dependency-holds.json` does not
90
+ ship). Nothing about how the scanner behaves has changed.
91
+
92
+ This release exists for one reason: **0.139.0 was published without a provenance
93
+ attestation.** It went out from a maintainer's laptop because the CI token
94
+ lacked write access on the scoped package, so every tag-triggered publish failed
95
+ at `PUT` with `E404` — after having already signed provenance to the Sigstore
96
+ transparency log. The token has been replaced and verified (a re-run now returns
97
+ `E403 "cannot publish over the previously published versions"`, which is the
98
+ registry authenticating the write and rejecting only the duplicate version), so
99
+ this is the first release published by CI with `--provenance`.
100
+
101
+ For a security scanner that argues its case on *provable, measurable,
102
+ reproducible*, "you can cryptographically verify this artifact was built from
103
+ this commit in this repository" is a product property rather than a formality.
104
+ Provenance is signed at publish time and cannot be retrofitted, which is why it
105
+ takes a version number rather than an amendment to the last one.
106
+
107
+ Fixes carried along, neither user-visible:
108
+
109
+ - **`test/audit-cli.test.js` budgeted 4 s for a CLI that takes ~2.7 s idle** — a
110
+ 1.5× margin in a suite that runs files concurrently. It failed as `status:
111
+ null` on a *different* test each run, was unreproducible in isolation, and
112
+ blocked a release push on a commit whose entire diff was 8 lines of JSON.
113
+ Now 30 s (~11× idle), still bounded so a genuinely hung CLI fails rather than
114
+ stalls.
115
+ - **`@types/vscode` is held rather than upgraded.** It declares the VS Code API
116
+ surface the extension compiles against and should track `engines.vscode` — the
117
+ *oldest* supported host — not the newest published types. Chasing latest lets
118
+ the extension compile against APIs absent from hosts it claims to support, and
119
+ that failure lands as a runtime `TypeError` in a user's editor. The hold
120
+ records a pre-existing mismatch (`engines ^1.95.0` vs types `^1.125.0`) for a
121
+ deliberate decision at review time, since narrowing the supported host range
122
+ is a product call, not a dependency bump.
123
+
124
+
12
125
  ## 0.139.0 — Two detectors that were dead, a confinement rule that was documented but unenforced, and a new Go rule
13
126
 
14
127
  Every defect in this release is the same shape: **a control that exists, is
@@ -9,6 +9,35 @@ const __require = createRequire(import.meta.url);
9
9
  const PKG_VERSION = __require('../package.json').version;
10
10
  import { signLastScan as _signLastScan, verifyLastScan as _verifyLastScanShared } from '../src/posture/integrity.js';
11
11
  import { runScan } from '../src/runScan.js';
12
+
13
+ // Every command is dispatched as `process.exit(await cmdX(args))`, and
14
+ // process.exit() does NOT flush an asynchronous stdout. stdout is asynchronous
15
+ // whenever it is a PIPE — which is every `> file`, `| jq`, and CI capture — so
16
+ // anything still buffered when the process exits is discarded at the pipe
17
+ // boundary: 64 KiB on macOS and Linux.
18
+ //
19
+ // That silently truncated `scan --format sarif` mid-token for any project
20
+ // large enough to matter, i.e. the primary CI integration path, while still
21
+ // exiting with a normal status. The consumer sees a JSON parse error with no
22
+ // connection to its cause, or ingests a partial finding set.
23
+ //
24
+ // fs.writeSync(1, …) hands the bytes to the OS before returning, so a later
25
+ // exit cannot lose them. A non-blocking pipe can still short-write or raise
26
+ // EAGAIN, hence the loop — a partial write that is not retried is the same
27
+ // truncation bug wearing a different hat.
28
+ function writeStdout(s) {
29
+ const buf = Buffer.from(String(s), 'utf8');
30
+ let off = 0;
31
+ while (off < buf.length) {
32
+ try {
33
+ off += fs.writeSync(1, buf, off, buf.length - off);
34
+ } catch (e) {
35
+ if (e.code === 'EAGAIN') continue; // pipe full; the reader will drain it
36
+ if (e.code === 'EPIPE') return; // reader closed (`| head`) — not our error
37
+ throw e;
38
+ }
39
+ }
40
+ }
12
41
  import { toJSON, toMarkdown, toSARIF, toSTIX, toCSV, toJUnit, toCLI, toCLIByProfile, toShipVerdict, toProTable, toHTML, toSummary, toVex, exitCodeFor, normalizeFindings } from '../src/report/index.js';
13
42
  import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
14
43
  import { toPBOM } from '../src/sast/pipeline.js';
@@ -679,7 +708,7 @@ async function cmdScan(args) {
679
708
  }
680
709
 
681
710
  if (output) await fsp.writeFile(output, body);
682
- else process.stdout.write(body + '\n');
711
+ else writeStdout(body + '\n');
683
712
 
684
713
  // Persist last scan for /security-fix and /security-report
685
714
  const { isSafeStateDir: _isSafeStateDir, stateWritesEnabled: _writesOnScan } = await import('../src/posture/state-dir.js');
@@ -1251,7 +1280,7 @@ async function cmdHarness(args) {
1251
1280
  body += `\n\nHarnesses discovered: ${present.join(', ')}${includeHome ? ' (project + ~/)' : ' (project only)'}\n`;
1252
1281
  }
1253
1282
  if (args.flags.output) await fsp.writeFile(args.flags.output, body);
1254
- else process.stdout.write(body + '\n');
1283
+ else writeStdout(body + '\n');
1255
1284
  return exitCodeFor(scan);
1256
1285
  }
1257
1286
 
@@ -1746,7 +1775,7 @@ async function cmdCompliance(args) {
1746
1775
 
1747
1776
  if (args.flags.list) {
1748
1777
  const fws = listFrameworks(scanRoot);
1749
- if (fmt === 'json') { console.log(JSON.stringify(fws, null, 2)); return 0; }
1778
+ if (fmt === 'json') { writeStdout(JSON.stringify(fws, null, 2) + '\n'); return 0; }
1750
1779
  for (const f of fws) console.log(` ${f.id.padEnd(20)} ${f.name} [${f.source}]`);
1751
1780
  return 0;
1752
1781
  }
@@ -1780,7 +1809,7 @@ async function cmdCompliance(args) {
1780
1809
 
1781
1810
  const gapsOnly = !!args.flags.gap;
1782
1811
  if (fmt === 'json') {
1783
- console.log(JSON.stringify(gapsOnly ? { ...r, controls: r.controls.filter(c => c.bucket === 'gap') } : r, null, 2));
1812
+ writeStdout(JSON.stringify(gapsOnly ? { ...r, controls: r.controls.filter(c => c.bucket === 'gap') } : r, null, 2) + '\n');
1784
1813
  } else if (fmt === 'md') {
1785
1814
  console.log(fs.readFileSync(statePath(scanRoot, 'privacy-framework.md'), 'utf8'));
1786
1815
  } else {
@@ -2018,7 +2047,7 @@ async function cmdFix(args) {
2018
2047
 
2019
2048
  // Default mode: print the canonical template (back-compat — security-fixer subagent applies it).
2020
2049
  if (!isPreview && !isApply) {
2021
- console.log(JSON.stringify(f, null, 2));
2050
+ writeStdout(JSON.stringify(f, null, 2) + '\n');
2022
2051
  if (f.fix?.code) { console.log('\n--- suggested patch ---\n'); console.log(f.fix.code); }
2023
2052
  console.log('\nUse --preview to see a diff, or --apply to apply directly.');
2024
2053
  return 0;
@@ -2247,7 +2276,7 @@ async function main() {
2247
2276
  const { analyzeTranscript, formatCacheReport } = await import('../src/posture/cache-economics.js');
2248
2277
  const projectDir = path.resolve(args.flags.root || process.env.CLAUDE_PROJECT_DIR || process.cwd());
2249
2278
  const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
2250
- if (args.flags.json) console.log(JSON.stringify(result, null, 2));
2279
+ if (args.flags.json) writeStdout(JSON.stringify(result, null, 2) + '\n');
2251
2280
  else console.log(formatCacheReport(result));
2252
2281
  process.exit(0);
2253
2282
  }
@@ -2291,7 +2320,7 @@ async function main() {
2291
2320
  });
2292
2321
  if (args.flags.json) {
2293
2322
  // Stringify Set/etc. safely.
2294
- console.log(JSON.stringify(r, null, 2));
2323
+ writeStdout(JSON.stringify(r, null, 2) + '\n');
2295
2324
  } else if (!r.ok) {
2296
2325
  console.error(`cve-watch: ${r.reason || 'failed'}`);
2297
2326
  }
@@ -2307,7 +2336,7 @@ async function main() {
2307
2336
  const headRef = args.flags.head || args.flags.h || 'HEAD';
2308
2337
  if (!baseRef) { console.error('pr-delta: --base <ref> is required'); process.exit(2); }
2309
2338
  const delta = await computePrDelta(path.resolve(root), { baseRef, headRef });
2310
- if (args.flags.json) console.log(JSON.stringify(delta, null, 2));
2339
+ if (args.flags.json) writeStdout(JSON.stringify(delta, null, 2) + '\n');
2311
2340
  else console.log(renderPrDeltaText(delta));
2312
2341
  // Exit non-zero if any critical/high introduced (useful as CI gate).
2313
2342
  const i = delta.summary?.introduced || {};
@@ -2359,7 +2388,7 @@ async function main() {
2359
2388
  const repo = args.flags.repo;
2360
2389
  if (!repo) { console.error('leaderboard-row: --repo <owner/name> is required'); process.exit(2); }
2361
2390
  const row = leaderboardRowFor({ scanRoot: path.resolve(root), repo });
2362
- console.log(JSON.stringify(row, null, 2));
2391
+ writeStdout(JSON.stringify(row, null, 2) + '\n');
2363
2392
  process.exit(0);
2364
2393
  }
2365
2394
  case 'history': {
@@ -2372,7 +2401,7 @@ async function main() {
2372
2401
  since: args.flags.since || '6.months',
2373
2402
  interval: args.flags.interval || '1.month',
2374
2403
  });
2375
- if (args.flags.json) console.log(JSON.stringify(r, null, 2));
2404
+ if (args.flags.json) writeStdout(JSON.stringify(r, null, 2) + '\n');
2376
2405
  else if (r.error) console.error(`history: ${r.error}`);
2377
2406
  else {
2378
2407
  console.log(`Scanned ${r.refs.length} refs.`);
@@ -2408,7 +2437,7 @@ async function main() {
2408
2437
  ? (Array.isArray(args.flags.remove) ? args.flags.remove : [args.flags.remove])
2409
2438
  : [];
2410
2439
  const r = await runWhatIf(path.resolve(root), { overlays, remove });
2411
- if (args.flags.json) console.log(JSON.stringify(r, null, 2));
2440
+ if (args.flags.json) writeStdout(JSON.stringify(r, null, 2) + '\n');
2412
2441
  else {
2413
2442
  console.log(`baseline: ${r.baselineFindings} findings`);
2414
2443
  console.log(`what-if: ${r.whatIfFindings} findings (delta ${r.delta >= 0 ? '+' : ''}${r.delta})`);