@clear-capabilities/agentic-security-scanner 0.147.5 → 0.148.1

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
@@ -11,6 +11,150 @@
11
11
 
12
12
 
13
13
 
14
+ ## 0.148.1 - Six real false-positive fixes from a customer bug report, two report-consistency fixes
15
+
16
+ A developer ran the scanner on two of their own applications and sent back a detailed,
17
+ reproducible bug report. All six false positives traced back to the same defect class:
18
+ a loosely-bounded regex matching an identifier or text substring without real syntax
19
+ awareness. None were caught by unit tests, because a fixture an author writes by hand is
20
+ shaped the way the author expects the code to look; these were found by a real user reading
21
+ the lines a real scan cited.
22
+
23
+ 1. SQL injection fired on "Selected"/"selected". Case-insensitive `SELECT` had no word
24
+ boundary, so it matched the first six letters of "Selected" (an exception message) and
25
+ "selected" (an ordinary list variable). The rule's own name claimed "assigned to variable"
26
+ but never checked for an assignment; both fixed (`scanner/src/sast/python-sinks.js`).
27
+
28
+ 2. A Python method literally named `fetch(self, ...)` was reported as a missing-timeout
29
+ HTTP-DoS finding, at its interface declaration, its implementation, and its call site, with
30
+ a hardcoded JavaScript remediation. The rule named JS-only APIs (fetch/axios/http.get) but
31
+ carried no `langScope`, so it matched the raw text "fetch(" in any language. Fixed with a
32
+ language scope and a declaration-vs-call exclusion (`scanner/src/engine.js`).
33
+
34
+ 3. `HUGGINGFACE_TOKEN = getpass.getpass("Enter your Hugging Face token: ")` was flagged
35
+ Critical, Hardcoded Secret. The captured-value character class matched newlines, so once
36
+ the word "token" inside the human-readable PROMPT happened to be followed by `: "`, the
37
+ "secret" spanned past that string's own closing quote into the next function definition.
38
+ Excluding newlines from the class confines a match to one physical line
39
+ (`scanner/src/engine.js`).
40
+
41
+ 4. `bundle.publisher_domain` and `bundle.published_at` were read as a message-queue producer
42
+ boundary. `(?:kafka|pubsub|sqs|sns)\.produce|\.publish|\.sendMessage` parses as three
43
+ TOP-LEVEL alternatives; only the first is scoped to a queue library, so `.publish` alone
44
+ matched the first 8 characters of "publisher"/"published" with no queue library and no
45
+ method call in sight. The identical defect existed one line above for `queue-consumer`
46
+ (`.subscribe`/`.receiveMessage`, e.g. an RxJS Observable) and is fixed the same way:
47
+ the method names moved inside the shared prefix group, and a trailing `(` is now required
48
+ (`scanner/src/posture/threat-model.js`).
49
+
50
+ 5. `self.session = requests.Session()` was classified as an authenticated user-session asset.
51
+ The pattern checked only the LHS variable name, never the RHS constructor. A negative
52
+ lookahead now excludes `requests.Session`/`aiohttp.ClientSession`/`httpx.Client`/
53
+ `httpx.AsyncClient` (`scanner/src/posture/threat-model.js`).
54
+
55
+ 6. `scanned.lines` was always 0 and a finding's `whyFired.scanner.rulesetVersion` was always
56
+ `null`, in both cases despite the top-level scan doing the right thing elsewhere. Two dead-
57
+ wiring bugs, not detector defects: `linesScanned` was read in `report/index.js` but never
58
+ assigned anywhere in the engine, and `annotateWhyFired(finalFindings, {})` was called with a
59
+ hardcoded empty context even though the engine already computes the real ruleset version two
60
+ other places in the same function (`scanner/src/engine.js`).
61
+
62
+ `scanner/src/posture/threat-model.js` had zero test coverage before this release
63
+ (`scanner/test/threat-model.test.js` is new). Each fix also carries a negative case proving the
64
+ real, intended finding is still reported — a precision fix that silences a genuine positive is
65
+ worse than the false positive it replaced.
66
+
67
+ Confirmed, by reading the code, NOT a bug: the duplicate `stableId` the report also flagged
68
+ (same id on the interface declaration and the implementation) is documented, intended
69
+ behavior — `stable-id.js` deliberately hashes on normalized code shape rather than file/line,
70
+ specifically so near-identical code keeps one id across refactors. Moot here regardless, since
71
+ fix 2 above means neither finding fires again.
72
+
73
+ Not fixed, flagged rather than guessed at: the report's "uncertainty inversion" observation,
74
+ where an unproven, low-confidence finding still produced a confident PoC, ATT&CK mapping and
75
+ dollar-impact narrative downstream. `mitigation-composite.js`'s `exposed-in-prod` verdict is
76
+ confirmed to be answering an orthogonal question by design (would a known production control
77
+ block this, defaulting to exposed absent one) and never reads `proof.verdict` or confidence at
78
+ all — so the actual gap, if real, is in a PoC or dollar-estimate consumer not gating on proof
79
+ state, which needs its own dedicated investigation rather than a fix rushed into this release.
80
+
81
+ ## 0.148.0 - NIST SP 800-171 Rev. 3 (CUI / CMMC basis) as the 10th bundled framework
82
+
83
+ Adds NIST SP 800-171 Rev. 3 to `/compliance --report <framework>` (aliases
84
+ `800-171` and `cui`), `--walkthrough`, `--gap` and `--format oscal`, plus a
85
+ standalone deep-attestation scanner at `scripts/nist-800-171/scan.py`.
86
+
87
+ **All 97 requirements are carried, including the 43 this engine cannot assess.**
88
+ Shipping only the code-observable subset would have been a smaller, better-looking
89
+ artifact and a dishonest one, omission reads as coverage. Whole families
90
+ (Awareness and Training, Personnel Security, Physical Protection) and most of
91
+ Incident Response, Maintenance and Media Protection report as requiring manual
92
+ evidence. Ratings: 16 `yes`, 38 `partial`, 43 `no`.
93
+
94
+ **The `code_testable` rating is ours, not NIST's.** Unlike the AI 600-1 workbook,
95
+ the 800-171 export rates no control for testability. That judgment lives in
96
+ `scripts/nist-800-171/code-testability.json` with a per-requirement rationale, and
97
+ is joined into the generated catalog by `build-catalog.py`. A control with no
98
+ rating is a hard build failure, never a default, defaulting would either invent
99
+ coverage or silently suppress a requirement. The generator is stdlib-only, so
100
+ unlike the openpyxl-based AI 600-1 gate its drift check can never be unrunnable
101
+ for a missing dependency.
102
+
103
+ **Fixes a real pre-existing bug in the shared evaluator.** `evaluateFramework`,
104
+ behind `--report`/`--walkthrough`/`--gap`/`--format oscal` for *all ten* frameworks
105
+ had no vacuous-satisfaction guard: a scan that read zero files produced empty
106
+ finding buckets, and an empty bucket rendered as `✓ no open findings`. Measured on
107
+ 800-171, **32 of 97 controls read `present` off a scan that examined nothing**. The
108
+ guard existed only inside `privacy-framework.js`, whose own comment already named
109
+ `evaluateFramework` as where the hazard originates. The fix reads three count
110
+ fields (a real scan persists `scanned.files` and `_scanMeta.filesScanned` but *not*
111
+ top-level `filesScanned`, which exists only on the in-memory object) and treats
112
+ **absent as unknown, not zero**, degrading requires positive evidence that nothing
113
+ was examined, so a genuinely clean project is unaffected. 2419/2419 posture tests
114
+ pass with no test file modified.
115
+
116
+ **One scanning engine, not two.** `scripts/nist-compliance/scan.py` now reads its
117
+ framework identity from the catalog it is given rather than from hardcoded strings,
118
+ and the 800-171 scanner is a thin wrapper supplying defaults. This keeps exactly one
119
+ copy of the ReDoS-hardened matcher, two copies would mean a fix to one silently
120
+ missing the other. The AI 600-1 scanner's md/csv/json output was pinned before and
121
+ after and is byte-identical.
122
+
123
+ **Fixes OSCAL documents that could not be attested.** Every compliance OSCAL export
124
+ read its timestamp from `scan._scanMeta.startedAt`, a key scans do not have, they
125
+ carry `startedAt` at the top level. So `_when()` fell through to `new Date()` at emit
126
+ time, which that function's own comment says must never happen, because "two emits of
127
+ one scan then differ and the artifact cannot be attested." Two emits of the same scan
128
+ genuinely differed. Fixed at all three call sites (both in `bin/agentic-security.js`,
129
+ one in `commands/compliance.md`); `last-modified` is now the scan's own clock, and with
130
+ `AGENTIC_SECURITY_DETERMINISTIC=1` the document is byte-identical across runs. This
131
+ affected every framework, not just the new one.
132
+
133
+ Also: the `nist-catalog-freshness` release gate now iterates every generated catalog
134
+ instead of naming one script path, `docs/compliance/nist-800-171-r3-coverage.md` is
135
+ pointer-based rather than restating counts that would drift, and that page carries a
136
+ recorded walkthrough (`docs/brand/nist-800-171-demo.tape`, regenerable with `vhs`)
137
+ showing assess → remediate → re-assess against `examples/demo-app`.
138
+
139
+ **Fixes `module:scan-history`, which could never resolve.** The evaluator's artifact table
140
+ listed only `scan-history/` (a directory) while an ordinary scan writes `scan-history.json`
141
+ (a file), both spellings are real in this codebase (`findings-memory.js` uses the
142
+ directory; `security-trend.js` and `router.js` use the file), but only one was listed. Every
143
+ control mapped to it reported the artifact missing even when the history existed, across
144
+ five bundled frameworks. Table entries may now be an array of acceptable paths, and any one
145
+ of them evidences the control.
146
+
147
+ Measured effect, because a change that moves compliance verdicts should be quantified rather
148
+ than asserted: **no control anywhere became `satisfied`**, `present` held at 43 across all
149
+ five frameworks. Eight controls moved `manual` → `partial` (2 eu-ai-act, 1 hipaa, 4
150
+ nist-800-171-r3, 1 nist-ai-600-1, 1 nist-privacy-1-1). Those are artifact-existence controls,
151
+ which the honesty model already caps at `partial`, so finding the artifact can only move them
152
+ out of "not assessed", never to satisfied. In OSCAL terms the reports get *stricter*, not
153
+ more flattering: for 800-171 against the demo app, findings went 42 → 46 with satisfied
154
+ unchanged at 28, i.e. four controls moved from unassessed into assessed-and-not-satisfied.
155
+
156
+ No CMMC assessment and no SPRS score is produced or implied.
157
+
14
158
  ## 0.147.5 — Fix: `bench:provenance:check`'s cold-memory sample size was unreliable on GitHub Actions
15
159
 
16
160
  `v0.147.4`'s tag was pushed but its hosted release-gate run failed before
@@ -2447,7 +2447,7 @@ async function cmdCompliance(args) {
2447
2447
  if (fmt === 'oscal') {
2448
2448
  const { toOSCALCompliance, complianceRowsFromEvaluation } = await import('../src/report/oscal.js');
2449
2449
  writeStdout(JSON.stringify(
2450
- toOSCALCompliance(fw, complianceRowsFromEvaluation(evaluation), { startedAt: scan._scanMeta?.startedAt }),
2450
+ toOSCALCompliance(fw, complianceRowsFromEvaluation(evaluation), { startedAt: scan.startedAt || scan._scanMeta?.startedAt }),
2451
2451
  null, 2) + '\n');
2452
2452
  return 0;
2453
2453
  }
@@ -2478,7 +2478,7 @@ async function cmdCompliance(args) {
2478
2478
  const { toOSCALCompliance, complianceRowsFromPrivacy } = await import('../src/report/oscal.js');
2479
2479
  const fwMeta = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID) || { id: PRIVACY_FRAMEWORK_ID, name: r.frameworkName };
2480
2480
  writeStdout(JSON.stringify(
2481
- toOSCALCompliance(fwMeta, complianceRowsFromPrivacy(r), { startedAt: scan._scanMeta?.startedAt }),
2481
+ toOSCALCompliance(fwMeta, complianceRowsFromPrivacy(r), { startedAt: scan.startedAt || scan._scanMeta?.startedAt }),
2482
2482
  null, 2) + '\n');
2483
2483
  return args.flags['fail-on'] === 'gap' && r.summary.gap > 0 ? 1 : 0;
2484
2484
  }
@@ -6302,7 +6302,27 @@ async function cmdDataflowWatch(args) {
6302
6302
  // change that makes `scan --watch` exit before it ever watches anything.
6303
6303
  }
6304
6304
 
6305
+ // A version below the declared `engines.node` floor is still let through by
6306
+ // npm (EBADENGINE is a warning, not an install failure), so on an old Node
6307
+ // this file used to either crash deep inside a dependency with a confusing
6308
+ // stack, or — on the specific versions handled by the guard below — do
6309
+ // nothing at all. Fail fast, once, with a message that names the actual
6310
+ // requirement instead.
6311
+ function checkNodeVersionOrExit() {
6312
+ const required = __require('../package.json').engines?.node;
6313
+ const requiredMajor = Number(/(\d+)/.exec(required || '')?.[1]);
6314
+ const actualMajor = Number(process.versions.node.split('.')[0]);
6315
+ if (Number.isFinite(requiredMajor) && actualMajor < requiredMajor) {
6316
+ console.error(
6317
+ `agentic-security: requires Node.js ${required} — you're running Node ${process.versions.node}.\n` +
6318
+ `Upgrade Node (e.g. 'nvm install ${requiredMajor}' or https://nodejs.org/) and try again.`
6319
+ );
6320
+ process.exit(1);
6321
+ }
6322
+ }
6323
+
6305
6324
  async function main() {
6325
+ checkNodeVersionOrExit();
6306
6326
  const args = parseArgs(process.argv.slice(2));
6307
6327
  const cmd = args._[0];
6308
6328
  try {
@@ -6663,18 +6683,30 @@ async function main() {
6663
6683
  // @clear-capabilities/agentic-security-scanner` install path), Node
6664
6684
  // resolves `import.meta.url` to the symlink's realpath while
6665
6685
  // `process.argv[1]` stays the symlink path as invoked, so the two never
6666
- // match, the guard is always false, and the CLI silently exits with no
6667
- // output. `import.meta.main` is resolved correctly through a symlink
6668
- // verified live through an actual symlink, not just read about see the
6669
- // Task 17 fix report. It was added in Node v24.2.0 (backported to
6670
- // v22.18.0) and is currently Stability 1.0 (early development) per Node's
6671
- // own docs NOT stable, and NOT available on v20.11 as an earlier
6672
- // version of this comment incorrectly claimed. Concretely: it is
6673
- // `undefined` on Node 24.0.0/24.1.x, which satisfy this repo's declared
6674
- // `engines.node: ">=24.0.0"` floor, so `import.meta.main` alone would
6675
- // reproduce this exact bug (main() silently never runs) on a plain
6676
- // non-symlinked invocation under those two point releases. The `??`
6677
- // fallback below covers that gap without bumping the engines floor.
6678
- if (import.meta.main ?? (import.meta.url === `file://${process.argv[1]}`)) {
6686
+ // match under a naive comparison. `import.meta.main` is resolved correctly
6687
+ // through a symlink verified live through an actual symlink, not just
6688
+ // read about see the Task 17 fix report. It was added in Node v24.2.0
6689
+ // (backported to v22.18.0) and is currently Stability 1.0 (early
6690
+ // development) per Node's own docs NOT stable, and NOT available on
6691
+ // v20.11 as an earlier version of this comment incorrectly claimed.
6692
+ // Concretely: it is `undefined` on Node 24.0.0/24.1.x, which satisfy this
6693
+ // repo's declared `engines.node: ">=24.0.0"` floor, so `import.meta.main`
6694
+ // alone would reproduce this exact bug (main() silently never runs) on
6695
+ // those two point releases when invoked through the symlinked bin (npx/
6696
+ // global install) the common case, not the exception. The fallback
6697
+ // below resolves `process.argv[1]`'s realpath before comparing, so it
6698
+ // gives the right answer through a symlink too — verified live against a
6699
+ // real symlink, same as the `import.meta.main` claim above — instead of
6700
+ // silently exiting with no output on every Node version where
6701
+ // `import.meta.main` is undefined (also: <22.18.0, and 23.x).
6702
+ function isDirectCliInvocation() {
6703
+ if (import.meta.main !== undefined) return import.meta.main;
6704
+ try {
6705
+ return import.meta.url === `file://${fs.realpathSync(process.argv[1])}`;
6706
+ } catch {
6707
+ return false;
6708
+ }
6709
+ }
6710
+ if (isDirectCliInvocation()) {
6679
6711
  main();
6680
6712
  }