@clear-capabilities/agentic-security-scanner 0.147.0 → 0.148.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/bin/agentic-security.js +47 -15
  3. package/dist/1122.index.js +79 -2
  4. package/dist/3180.index.js +73 -1
  5. package/dist/5051.index.js +77 -6
  6. package/dist/agentic-security.mjs +2 -2
  7. package/dist/agentic-security.mjs.sha256 +1 -1
  8. package/dist/compliance-frameworks/nist-800-171-r3.json +1269 -0
  9. package/dist/frontend/index.html +21 -0
  10. package/dist/frontend/src/app.js +176 -0
  11. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  12. package/dist/frontend/src/components/filter-rail.js +119 -0
  13. package/dist/frontend/src/components/query-bar.js +126 -0
  14. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  15. package/dist/frontend/src/export-entry.js +36 -0
  16. package/dist/frontend/src/lib/api-client.js +92 -0
  17. package/dist/frontend/src/lib/contrast.js +34 -0
  18. package/dist/frontend/src/lib/dom.js +24 -0
  19. package/dist/frontend/src/lib/escape-html.js +16 -0
  20. package/dist/frontend/src/lib/flow-path.js +40 -0
  21. package/dist/frontend/src/lib/focus-controls.js +149 -0
  22. package/dist/frontend/src/lib/protection-visual.js +46 -0
  23. package/dist/frontend/src/lib/query-language.js +240 -0
  24. package/dist/frontend/src/lib/row-filters.js +43 -0
  25. package/dist/frontend/src/lib/state.js +84 -0
  26. package/dist/frontend/src/main.js +83 -0
  27. package/dist/frontend/src/shell.js +184 -0
  28. package/dist/frontend/src/views/architecture-view.js +798 -0
  29. package/dist/frontend/src/views/inventory-view.js +292 -0
  30. package/dist/frontend/src/views/privacy-view.js +172 -0
  31. package/dist/frontend/src/views/trace-view.js +206 -0
  32. package/dist/frontend/styles/architecture-view.css +93 -0
  33. package/dist/frontend/styles/filter-rail.css +34 -0
  34. package/dist/frontend/styles/inspector.css +69 -0
  35. package/dist/frontend/styles/inventory-view.css +74 -0
  36. package/dist/frontend/styles/privacy-view.css +86 -0
  37. package/dist/frontend/styles/query-bar.css +107 -0
  38. package/dist/frontend/styles/shell.css +155 -0
  39. package/dist/frontend/styles/tokens.css +128 -0
  40. package/dist/frontend/styles/trace-view.css +95 -0
  41. package/package.json +2 -2
  42. package/src/posture/auditor-walkthrough.js +89 -6
  43. package/src/posture/compliance-frameworks/nist-800-171-r3.json +1269 -0
  44. package/src/server/static-assets.js +11 -6
  45. package/src/shared/frontend-root.js +52 -0
@@ -0,0 +1,128 @@
1
+ /*
2
+ * Data Flow Explorer design tokens (PRD §8.1). Dark is the reference theme.
3
+ * Values are copied verbatim from the PRD — do not adjust without a
4
+ * deliberate, separately-reviewed change.
5
+ */
6
+
7
+ :root {
8
+ /* Surfaces */
9
+ --surface-canvas: #061625;
10
+ --surface-panel: #0B1E2F;
11
+ --surface-elevated: #102A40;
12
+ --border-default: #28445A;
13
+
14
+ /* Text */
15
+ --text-primary: #F3F7FA;
16
+ --text-secondary: #9FB3C5;
17
+
18
+ /* Selection / focus */
19
+ --accent-selection: #3DA9FF;
20
+
21
+ /* Protection verdict status */
22
+ --status-protected: #59D17D;
23
+ --status-unprotected: #FF625C;
24
+ --status-unknown: #F5B83D;
25
+
26
+ /* Text color for content painted directly on a status token background
27
+ (e.g. the coverage banner). NOT the same as --text-primary, which is
28
+ sized against --surface-canvas/--surface-panel, not against a status
29
+ color. Verified with lib/contrast.js's contrastRatio(): #061625 on
30
+ dark --status-unknown (#F5B83D) is 10.27:1. See the light-theme block
31
+ below for the light-theme value and its own verified ratio. */
32
+ --status-banner-text: #061625;
33
+
34
+ /* AI processing context */
35
+ --context-ai: #B47AFF;
36
+
37
+ /* Data class overlays */
38
+ --class-pii: #28D1C5;
39
+ --class-phi: #A97BFF;
40
+ --class-pci: #4CA7FF;
41
+
42
+ /* Spacing (8px grid, PRD §7.7) */
43
+ --space-1: 8px;
44
+ --space-2: 16px;
45
+ --space-3: 24px;
46
+ --space-4: 32px;
47
+
48
+ /* Radius / border (PRD §8.2) */
49
+ --radius-default: 8px;
50
+ --border-width: 1px;
51
+
52
+ /* Typography (PRD §8.2) — bundled system stack; never a remote/CDN font */
53
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
54
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
55
+ --font-size-view-title: 23px;
56
+ --font-size-panel-title: 15px;
57
+ --font-size-node-title: 14px;
58
+ --font-size-body: 13px;
59
+ --font-size-code: 12.5px;
60
+
61
+ /* Motion (PRD §8.2 — focus/topology change only, no ambient animation) */
62
+ --motion-duration: 200ms;
63
+ --motion-duration-reduced: 0ms;
64
+
65
+ /* Shell region sizing (PRD §7.7) */
66
+ --header-height: 56px;
67
+ --view-tabs-height: 44px;
68
+ --left-rail-width: 248px;
69
+ --left-rail-collapsed-width: 56px;
70
+ --right-inspector-width: 360px;
71
+ --context-rail-height: 72px;
72
+ }
73
+
74
+ /* Light theme — same semantic tokens, WCAG-AA-equivalent contrast (PRD §8.1/§23) */
75
+ :root[data-theme="light"] {
76
+ --surface-canvas: #FFFFFF;
77
+ --surface-panel: #F4F7FA;
78
+ --surface-elevated: #E7EEF3;
79
+ --border-default: #C7D3DC;
80
+
81
+ --text-primary: #0B1E2F;
82
+ --text-secondary: #3E5568;
83
+
84
+ --accent-selection: #1B6FC2;
85
+
86
+ /* status-protected and status-unknown darkened slightly from their
87
+ original values (#1E8A4C, #9A6B00) — Milestone 3, sub-project A11y,
88
+ found via test/tokens-contrast.test.js that both originals failed
89
+ WCAG AA (4.5:1) against --surface-panel (#F4F7FA): 4.07:1 and 4.36:1
90
+ respectively. Verified with lib/contrast.js's contrastRatio() against
91
+ BOTH backgrounds a status color plausibly renders on: --status-
92
+ protected (#1C8047) is 4.97:1 on --surface-canvas / 4.62:1 on
93
+ --surface-panel; --status-unknown (#956700) is 4.98:1 / 4.63:1. Both
94
+ now clear AA with margin (>=4.6) rather than sitting at the exact
95
+ 4.50 boundary, which is fragile against hex-rounding. --status-
96
+ unprotected was already compliant (5.26:1 / 4.89:1), unchanged. */
97
+ --status-protected: #1C8047;
98
+ --status-unprotected: #C7362D;
99
+ --status-unknown: #956700;
100
+
101
+ /* Verified with lib/contrast.js's contrastRatio(): #FFFFFF on light
102
+ --status-unknown (#956700) is 4.98:1 (>= 4.5:1 AA). The dark-block
103
+ value (#061625, --surface-canvas's own dark value) is 3.90:1 here --
104
+ below AA -- which is the bug this token pair fixes: shell.css must
105
+ never hardcode dark theme's value and expect it to also work in
106
+ light theme. */
107
+ --status-banner-text: #FFFFFF;
108
+
109
+ --context-ai: #6E38B8;
110
+
111
+ --class-pii: #0E7C73;
112
+ --class-phi: #6E44B8;
113
+ --class-pci: #1C63A8;
114
+ }
115
+
116
+ @media (prefers-reduced-motion: reduce) {
117
+ :root {
118
+ --motion-duration: var(--motion-duration-reduced);
119
+ }
120
+ }
121
+
122
+ body {
123
+ background: var(--surface-canvas);
124
+ color: var(--text-primary);
125
+ font-family: var(--font-family);
126
+ font-size: var(--font-size-body);
127
+ margin: 0;
128
+ }
@@ -0,0 +1,95 @@
1
+ .trace-view {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--space-2);
5
+ max-width: 640px;
6
+ }
7
+
8
+ .trace-step {
9
+ display: flex;
10
+ gap: var(--space-2);
11
+ padding: var(--space-2);
12
+ background: var(--surface-panel);
13
+ border: var(--border-width) solid var(--border-default);
14
+ border-radius: var(--radius-default);
15
+ }
16
+
17
+ .trace-step-number {
18
+ flex: 0 0 auto;
19
+ width: 24px;
20
+ height: 24px;
21
+ border-radius: 50%;
22
+ background: var(--surface-elevated);
23
+ color: var(--text-primary);
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ font-size: var(--font-size-code);
28
+ font-weight: 600;
29
+ }
30
+
31
+ .trace-step-body {
32
+ flex: 1 1 auto;
33
+ }
34
+
35
+ .trace-step-kind {
36
+ color: var(--text-secondary);
37
+ font-size: var(--font-size-code);
38
+ text-transform: uppercase;
39
+ }
40
+
41
+ .trace-step-mapping {
42
+ font-family: var(--font-mono);
43
+ font-size: var(--font-size-code);
44
+ color: var(--text-primary);
45
+ margin: 4px 0;
46
+ }
47
+
48
+ .trace-step-node {
49
+ color: var(--text-secondary);
50
+ font-size: var(--font-size-body);
51
+ }
52
+
53
+ .trace-step-boundary {
54
+ display: inline-block;
55
+ border: 1px solid var(--status-unprotected);
56
+ color: var(--status-unprotected);
57
+ border-radius: var(--radius-default);
58
+ padding: 1px 6px;
59
+ font-size: var(--font-size-code);
60
+ margin-top: 4px;
61
+ }
62
+
63
+ .trace-branch-group {
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: var(--space-2);
67
+ padding-left: var(--space-2);
68
+ border-left: 2px solid var(--accent-selection);
69
+ }
70
+
71
+ .trace-branch-label {
72
+ color: var(--text-secondary);
73
+ font-size: var(--font-size-code);
74
+ text-transform: uppercase;
75
+ }
76
+
77
+ .trace-alternates {
78
+ margin-top: var(--space-2);
79
+ }
80
+
81
+ .trace-alternate-item {
82
+ padding: var(--space-1) 0;
83
+ cursor: pointer;
84
+ color: var(--text-secondary);
85
+ }
86
+
87
+ .trace-alternate-item:hover {
88
+ color: var(--text-primary);
89
+ }
90
+
91
+ .trace-empty {
92
+ color: var(--text-secondary);
93
+ font-style: italic;
94
+ padding: var(--space-2);
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.147.0",
3
+ "version": "0.148.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -56,7 +56,7 @@
56
56
  "lodash-es": "^4.18.1"
57
57
  },
58
58
  "scripts": {
59
- "build": "ncc build bin/agentic-security.js -o dist --minify -e web-tree-sitter -e tree-sitter-wasms && rm -rf dist/compliance-frameworks && mkdir -p dist/compliance-frameworks && cp src/posture/compliance-frameworks/*.json dist/compliance-frameworks/ && mv dist/index.js dist/agentic-security.mjs && rm -f dist/package.json && chmod +x dist/agentic-security.mjs && node -e \"const fs=require('fs');const p='dist/agentic-security.mjs';const c=fs.readFileSync(p,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+c);\" && node -e \"const fs=require('fs');const c=require('crypto');const h=c.createHash('sha256').update(fs.readFileSync('dist/agentic-security.mjs')).digest('hex');fs.writeFileSync('dist/agentic-security.mjs.sha256',h+' agentic-security.mjs\\n');\"",
59
+ "build": "ncc build bin/agentic-security.js -o dist --minify -e web-tree-sitter -e tree-sitter-wasms && rm -rf dist/compliance-frameworks && mkdir -p dist/compliance-frameworks && cp src/posture/compliance-frameworks/*.json dist/compliance-frameworks/ && node scripts/copy-frontend.mjs && mv dist/index.js dist/agentic-security.mjs && rm -f dist/package.json && chmod +x dist/agentic-security.mjs && node -e \"const fs=require('fs');const p='dist/agentic-security.mjs';const c=fs.readFileSync(p,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(p,'#!/usr/bin/env node\\n'+c);\" && node -e \"const fs=require('fs');const c=require('crypto');const h=c.createHash('sha256').update(fs.readFileSync('dist/agentic-security.mjs')).digest('hex');fs.writeFileSync('dist/agentic-security.mjs.sha256',h+' agentic-security.mjs\\n');\"",
60
60
  "prepare": "node ../scripts/pre-push-gate.mjs --install-hook",
61
61
  "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs && node ../scripts/release-check.mjs",
62
62
  "test": "node ../scripts/run-unit-tests.mjs && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js && node --experimental-test-module-mocks --test test/fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection.test.js && node --experimental-test-module-mocks --test test/detector-fault-injection-k8s-admission.test.js && node --experimental-test-module-mocks --test test/lineage-fault-injection.test.js && npm run test:python",
@@ -10,6 +10,14 @@
10
10
  // nist-ai-600-1 NIST AI Risk Management Framework, GenAI profile
11
11
  // nist-privacy-1-1 NIST Privacy Framework 1.1 (see posture/privacy-framework.js
12
12
  // for the assessment + remediation layer over it)
13
+ // nist-800-171-r3 NIST SP 800-171 Rev. 3 — Protecting CUI in Nonfederal
14
+ // Systems. FULL 97-requirement catalogue; 43 of those are
15
+ // organisational/physical and carry no automated signal at
16
+ // all, deliberately carried so the negative space is visible.
17
+ // Control text is generated from the CSV export by
18
+ // scripts/nist-800-171/build-catalog.py; the codeTestable
19
+ // rating is OURS, not NIST's (that export rates nothing for
20
+ // testability) and lives in nist-800-171/code-testability.json.
13
21
  // owasp-asvs-5 OWASP Application Security Verification Standard 5.0
14
22
  // owasp-llm-top-10 OWASP Top 10 for LLM Applications 2025
15
23
  // eu-ai-act EU AI Act (Regulation 2024/1689)
@@ -345,6 +353,60 @@ export function evaluateFramework(scanRoot, fw, scan) {
345
353
  families.get(k).push(f);
346
354
  }
347
355
 
356
+ // VACUOUS SATISFACTION GUARD (shared-evaluator half).
357
+ //
358
+ // The `family:` branch below clears a mapping when no findings of that family
359
+ // are open — which is ALSO true of a scan that examined nothing at all. So
360
+ // pointing `--report <framework>` at an empty directory, a wrong path, or a
361
+ // scan that failed to read anything reported mapped controls as fully
362
+ // evidenced, on the strength of having looked at zero files.
363
+ //
364
+ // privacy-framework.js's `bucketOf` has carried this guard for its own module
365
+ // since it was found by that module's test, and its comment already named
366
+ // `evaluateFramework` as the place the hazard originates — but the guard was
367
+ // never applied HERE, so every framework reached through `--report` /
368
+ // `--walkthrough` / `--gap` / `--format oscal` still had it. Found while
369
+ // adding NIST SP 800-171 Rev. 3, whose 50 mapped controls made a
370
+ // zero-file scan report 32 controls `present`.
371
+ //
372
+ // Reads THREE count fields, not the two privacy-framework.js reads. Measured,
373
+ // not assumed: a real scan of a clean one-file project persists
374
+ // `scanned.files: 1` and `_scanMeta.filesScanned: 1` but NO top-level
375
+ // `filesScanned` — that key exists only on the in-memory object engine.js
376
+ // returns. privacy-framework.js reads `filesScanned ?? _scanMeta.filesScanned`
377
+ // and gets away with it because engine.js hands it the live in-memory object;
378
+ // this evaluator is reached from the CLI with the PERSISTED artifact, where
379
+ // that first key is absent. Mirroring privacy's exact field list here would
380
+ // therefore have read 0 for every real scan and degraded a genuinely clean
381
+ // project to "not assessed" — the opposite failure, and worse, because it
382
+ // punishes the operator who did everything right.
383
+ //
384
+ // ABSENT IS UNKNOWN, NOT ZERO. Degrading requires POSITIVE evidence that
385
+ // nothing was examined: at least one count field actually present AND every
386
+ // present one reading 0. A scan object carrying no count fields at all
387
+ // (synthetic fixtures, older artifacts, third-party callers) is treated as
388
+ // assessable and behaves exactly as before. Same precedent as relevance.js —
389
+ // never assert the negative without evidence, and `unknown` is not `false`.
390
+ // The findings/components/... fallbacks then rescue the remaining case: an
391
+ // artifact that records 0 files but plainly holds real results.
392
+ //
393
+ // An unassessable scan does not remove or fail a control: the mapping is
394
+ // disclosed as unverifiable, which caps the control at 'partial' via the same
395
+ // path a `rule:` mapping already uses. Recall-preserving, same precedent as
396
+ // falsification.js / proof-gate.js.
397
+ const _counts = [
398
+ scan && scan.filesScanned,
399
+ scan && scan._scanMeta && scan._scanMeta.filesScanned,
400
+ scan && scan.scanned && scan.scanned.files,
401
+ ].filter((v) => typeof v === 'number' && Number.isFinite(v));
402
+ const _examinedNothing = _counts.length > 0 && _counts.every((v) => v === 0);
403
+ const assessable = !_examinedNothing
404
+ || findings.length > 0
405
+ || components.length > 0
406
+ || secrets.length > 0
407
+ || logicVulns.length > 0
408
+ || supplyChain.length > 0;
409
+
348
410
  const results = [];
349
411
  for (const c of fw.controls || []) {
350
412
  const obs = [];
@@ -447,6 +509,11 @@ export function evaluateFramework(scanRoot, fw, scan) {
447
509
  allCleared = false;
448
510
  contributingFindings.push(...open);
449
511
  obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
512
+ } else if (!assessable) {
513
+ // Empty bucket, but the scan examined nothing — see the guard above.
514
+ // Not evidence, and deliberately not a failure either.
515
+ obs.push(`⚠ ${fam}: the scan examined no files, so an empty finding bucket is not evidence (not assessed).`);
516
+ hasUnverifiableMapping = true;
450
517
  } else {
451
518
  obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
452
519
  anyCleared = true;
@@ -465,7 +532,15 @@ export function evaluateFramework(scanRoot, fw, scan) {
465
532
  'aibom': 'aibom.json',
466
533
  'attack-taxonomy': 'last-scan.json',
467
534
  'why-fired': 'last-scan.json',
468
- 'scan-history': 'scan-history/',
535
+ // Two real spellings, both live in this codebase: security-trend.js and
536
+ // router.js read `scan-history.json` (a FILE), findings-memory.js uses
537
+ // `scan-history` (a DIRECTORY). Only the directory was listed here, so
538
+ // on a normal scan — which writes the .json — every control mapped to
539
+ // module:scan-history reported the artifact missing and could never
540
+ // clear. Five bundled frameworks were affected. An array means "any of
541
+ // these satisfies it", which is the honest reading: the control asks
542
+ // whether a scan history exists, not which shape it took.
543
+ 'scan-history': ['scan-history.json', 'scan-history/'],
469
544
  'integrity': 'last-scan.json.sig',
470
545
  'watch-mode': 'watch-status.json',
471
546
  'cve-alert-daemon': 'cve-alerts/',
@@ -480,17 +555,25 @@ export function evaluateFramework(scanRoot, fw, scan) {
480
555
  'security-fixer': '.../agents/security-fixer.md',
481
556
  'mcp-tools': '.../scanner/src/mcp/tools.js',
482
557
  };
558
+ // A table entry is either one path or an array of acceptable ones. An
559
+ // array means the artifact has more than one real spelling in this
560
+ // codebase and any of them evidences the control; the FIRST is the
561
+ // canonical name used in the observation text when none is found, so
562
+ // the message still names something a reader can go create.
483
563
  const target = ARTIFACT[mod];
564
+ const candidates = target == null ? [] : (Array.isArray(target) ? target : [target]);
484
565
  // A '.../' sentinel marks a source-relative artifact (project source,
485
566
  // e.g. a hook or agent file) — resolve it against the scan root itself.
486
567
  // Everything else is a runtime artifact under the STATE dir. Without
487
568
  // this, `statePath(scanRoot, '.../x')` never resolves and the
488
569
  // control falsely reads "not present" for every project.
489
- const resolved = !target ? null
490
- : target.startsWith('.../') ? path.join(scanRoot, target.slice(4))
491
- : statePath(scanRoot, target);
492
- const label = target ? target.replace(/^\.\.\.\//, '') : '(unmapped)';
493
- if (resolved && fs.existsSync(resolved)) {
570
+ const _resolve = (t) => t.startsWith('.../')
571
+ ? path.join(scanRoot, t.slice(4))
572
+ : statePath(scanRoot, t);
573
+ const found = candidates.find((t) => { try { return fs.existsSync(_resolve(t)); } catch { return false; } });
574
+ const resolved = found ? _resolve(found) : null;
575
+ const label = (found || candidates[0] || '(unmapped)').replace(/^\.\.\.\//, '');
576
+ if (resolved) {
494
577
  obs.push(`✓ ${mod}: ${label} present.`);
495
578
  anySignal = true;
496
579
  anyCleared = true;