@clear-capabilities/agentic-security-scanner 0.123.0 → 0.124.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.
@@ -1 +1 @@
1
- c8cf8da77f8cf7ba57141e8280ea3642843125b66c871e2051e5a99c1894cbbd agentic-security.mjs
1
+ a89f9898e9c186c99eef3e08ab7a5bf886af031eb068aff011ff617d3a12a08e agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.123.0",
3
+ "version": "0.124.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, SARIF ingest, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -29,6 +29,54 @@ function riskNote(f) {
29
29
  return null;
30
30
  }
31
31
 
32
+ function _firstSentences(text, n) {
33
+ if (!text) return '';
34
+ const t = String(text).replace(/\s+/g, ' ').trim();
35
+ return t.split(/(?<=[.!?])\s+/).slice(0, n).join(' ');
36
+ }
37
+
38
+ // "How it fires" summary from the whyFired provenance already on the finding:
39
+ // the source→sink flow (or the sink line), plus rejected sanitizers / observed
40
+ // guards / reachability demotion. Returns null when no provenance exists.
41
+ function _flowSummary(f) {
42
+ const w = f.whyFired;
43
+ if (!w || !w.evidence) return null;
44
+ const ev = w.evidence;
45
+ const steps = Array.isArray(ev.pathSteps) ? ev.pathSteps.filter(s => s && s.label) : [];
46
+ let flow = null;
47
+ if (steps.length) flow = steps.map(s => s.label).join(' → ');
48
+ else if (ev.sourceSnippet && ev.sinkSnippet && ev.sourceSnippet !== ev.sinkSnippet) flow = `${ev.sourceSnippet.trim()} → ${ev.sinkSnippet.trim()}`;
49
+ else if (ev.sinkSnippet) flow = String(ev.sinkSnippet).trim();
50
+ const tags = [];
51
+ if (Array.isArray(ev.sanitizers) && ev.sanitizers.length) tags.push(`${ev.sanitizers.length} sanitizer(s) rejected`);
52
+ if (Array.isArray(ev.guards) && ev.guards.length) tags.push(`${ev.guards.length} guard(s) seen`);
53
+ if (w.considered && w.considered.reachabilityFilter === 'demoted') tags.push('reachability-demoted');
54
+ return { detector: w.detector || null, parser: w.parser || null, flow, tags };
55
+ }
56
+
57
+ // The inline "explain" depth — assembled entirely from fields already on the
58
+ // finding (narration + whyFired + fix), so the default finding view carries the
59
+ // same depth as `/triage --explain` without a second command. verbose=false
60
+ // trims the narration to 2 sentences and omits the evidence tags + fix code.
61
+ function explainParts(f, { verbose = false } = {}) {
62
+ const why = f.narration
63
+ ? (verbose ? String(f.narration).replace(/\s+/g, ' ').trim() : _firstSentences(f.narration, 2))
64
+ : '';
65
+ const fs = _flowSummary(f);
66
+ let how = '';
67
+ if (fs && (fs.flow || fs.detector)) {
68
+ const head = fs.detector ? fs.detector + (fs.parser ? ` (${fs.parser})` : '') : '';
69
+ how = [head, fs.flow].filter(Boolean).join(' · ');
70
+ if (verbose && fs.tags.length) how += ` · ${fs.tags.join(' · ')}`;
71
+ }
72
+ let fix = '';
73
+ if (f.fix && typeof f.fix === 'object' && typeof f.fix.description === 'string') fix = f.fix.description;
74
+ else if (typeof f.fix === 'string') fix = f.fix;
75
+ else if (typeof f.remediation === 'string') fix = f.remediation;
76
+ const fixCode = verbose && f.fix && typeof f.fix === 'object' && typeof f.fix.code === 'string' ? f.fix.code : '';
77
+ return { why, how, fix: fix.replace(/\s+/g, ' ').trim(), fixCode };
78
+ }
79
+
32
80
  function fingerprint(f){
33
81
  const s = `${f.file}:${f.line||f.source?.line||0}:${f.vuln||f.type||''}`;
34
82
  return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
@@ -703,9 +751,12 @@ export function toVex(scan, meta = {}) {
703
751
  }
704
752
 
705
753
  export function toHTML(scan, meta = {}) {
706
- // Attach the "likely lower risk" note (computed server-side) so the browser
707
- // render can badge over-stated high/critical findings.
708
- const findings = normalizeFindings(scan).map(f => ({ ...f, _riskNote: riskNote(f) }));
754
+ // Attach the "likely lower risk" note + inline explain depth (computed
755
+ // server-side) so the browser render shows them without a second command.
756
+ const findings = normalizeFindings(scan).map(f => {
757
+ const ex = explainParts(f, { verbose: true });
758
+ return { ...f, _riskNote: riskNote(f), _explainWhy: ex.why, _explainHow: ex.how };
759
+ });
709
760
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
710
761
  for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
711
762
  const stride = {};
@@ -773,6 +824,9 @@ export function toHTML(scan, meta = {}) {
773
824
  .f-body{display:none;margin-top:12px;padding-top:12px;border-top:1px solid #1e293b;font-size:12px}
774
825
  .f.expanded .f-body{display:block}
775
826
  .f-body pre{background:#020617;padding:10px;border-radius:4px;overflow-x:auto;font-size:11px;line-height:1.5}
827
+ .f-why{margin-top:8px;color:#cbd5e1}
828
+ .f-how{margin-top:6px;color:#94a3b8;font-size:13px}
829
+ .f-how code{font-family:ui-monospace,monospace;color:#e2e8f4}
776
830
  .f-fix{background:#0d1f3d;border-left:3px solid #38bdf8;padding:8px 12px;margin-top:8px;border-radius:0 4px 4px 0}
777
831
  .hidden{display:none!important}
778
832
  </style></head>
@@ -824,6 +878,8 @@ function makeCard(f) {
824
878
  (f._riskNote ? '<span class="f-note" title="The reachability / confidence pipeline marked this down from its rule-default severity">↓ ' + esc(f._riskNote) + '</span>' : '') +
825
879
  '</div>' +
826
880
  '<div class="f-body">' +
881
+ (f._explainWhy ? '<div class="f-why"><b>Why it matters:</b> ' + esc(f._explainWhy) + '</div>' : '') +
882
+ (f._explainHow ? '<div class="f-how"><b>How it fires:</b> <code>' + esc(f._explainHow) + '</code></div>' : '') +
827
883
  (f.snippet ? '<pre>' + esc(f.snippet) + '</pre>' : '') +
828
884
  (f.masked ? '<pre style="color:#f97316">' + esc(f.masked) + ' (masked)</pre>' : '') +
829
885
  (f.fix && f.fix.description ? '<div class="f-fix"><b>Fix:</b> ' + esc(f.fix.description) + (f.fix.code ? '<pre>' + esc(f.fix.code) + '</pre>' : '') + '</div>' : '') +
@@ -926,10 +982,12 @@ export function toCLI(scan, { verbose=false, color=true }={}){
926
982
  const rn = riskNote(f);
927
983
  if (rn) lines.push(` ${c('↓ ' + rn, '\x1b[2;33m')}`);
928
984
  if (f.masked) lines.push(` ${c('value:', DIM)} ${f.masked}`);
929
- if (verbose && f.fix?.description) {
930
- lines.push(` ${c('fix:', DIM)} ${f.fix.description}`);
931
- if (f.fix.code) for (const ln of f.fix.code.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
932
- }
985
+ // Inline explain depth — why it matters / how it fires / the fix (#explain).
986
+ const ex = explainParts(f, { verbose });
987
+ if (ex.why) lines.push(` ${c('why:', DIM)} ${ex.why}`);
988
+ if (ex.how) lines.push(` ${c('how:', DIM)} ${ex.how}`);
989
+ if (ex.fix) lines.push(` ${c('fix:', DIM)} ${ex.fix}`);
990
+ if (ex.fixCode) for (const ln of ex.fixCode.split('\n').slice(0, 6)) lines.push(` ${c(ln, DIM)}`);
933
991
  }
934
992
  lines.push('');
935
993
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
@@ -1155,6 +1213,9 @@ export function toProTable(scan, options = {}) {
1155
1213
  else lines.push(`${sev} ${where} ${cwe} ${cvss} ${owasp} ${vuln} ${conf}`);
1156
1214
  const rn = riskNote(f);
1157
1215
  if (rn) lines.push(c(' ↓ ' + rn, '\x1b[2;33m'));
1216
+ // One compact "why it matters" line keeps the table scannable but not bare.
1217
+ const why1 = f.narration ? _firstSentences(f.narration, 1) : '';
1218
+ if (why1) lines.push(c(' ↳ ' + why1, DIM));
1158
1219
  }
1159
1220
 
1160
1221
  // Footer counts.