@clear-capabilities/agentic-security-scanner 0.123.0 → 0.124.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.
@@ -1 +1 @@
1
- c8cf8da77f8cf7ba57141e8280ea3642843125b66c871e2051e5a99c1894cbbd agentic-security.mjs
1
+ c162f0538727ff2cce518e33cd577b95521e9a1926abe63d7ed89afc3d21c21c 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.1",
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",
@@ -15,9 +15,9 @@
15
15
 
16
16
  const TEMPLATES = {
17
17
  'sql-injection': (f) =>
18
- `An unauthenticated attacker sends a crafted request to ${_routeOf(f)} containing UNION-style SQL syntax in the ${f.source?.variable || 'tainted'} field. The server's database driver executes the injected query verbatim, returning rows from any table the connection has read access to. Typical impact: full table dump of users (emails, password hashes), bypass of authentication via boolean-blind exfiltration. If the DB role has write privileges, the attacker can also INSERT/UPDATE arbitrary rows. Recovery cost: incident response, customer notification, password reset, regulatory reporting if PII leaked.`,
18
+ `An unauthenticated attacker sends a crafted request containing UNION-style SQL syntax in the ${f.source?.variable || 'tainted'} field. The server's database driver executes the injected query verbatim, returning rows from any table the connection has read access to. Typical impact: full table dump of users (emails, password hashes), bypass of authentication via boolean-blind exfiltration. If the DB role has write privileges, the attacker can also INSERT/UPDATE arbitrary rows. Recovery cost: incident response, customer notification, password reset, regulatory reporting if PII leaked.`,
19
19
  'command-injection': (f) =>
20
- `The handler at ${f.file}:${f.line} passes user-controlled input to a shell-spawning function. An attacker can append shell metacharacters (";", "$(...)", backticks) to execute arbitrary commands as the application's UID. Typical impact: read of /etc/passwd, /proc/self/environ (env vars including secrets), outbound connections to attacker-controlled hosts (data exfil). On unprivileged containers the blast radius is limited to that container; on privileged or root-owned processes, the attacker can pivot to the host.`,
20
+ `The handler passes user-controlled input to a shell-spawning function. An attacker can append shell metacharacters (";", "$(...)", backticks) to execute arbitrary commands as the application's UID. Typical impact: read of /etc/passwd, /proc/self/environ (env vars including secrets), outbound connections to attacker-controlled hosts (data exfil). On unprivileged containers the blast radius is limited to that container; on privileged or root-owned processes, the attacker can pivot to the host.`,
21
21
  'xss': (f) =>
22
22
  `An attacker injects HTML/JS markup into user-controllable input. The server reflects (or stores) it without encoding, so when a victim browser renders the page, the attacker's script executes in the victim's session origin. Typical impact: session cookie theft, CSRF-bypass on internal endpoints, account takeover via API calls executed under the victim's auth. Cost: incident response, customer notification, potential data egress depending on what the victim's session can access.`,
23
23
  'ssrf': (f) =>
@@ -27,18 +27,23 @@ const TEMPLATES = {
27
27
  'code-injection': (f) =>
28
28
  `User input is fed into a code-evaluation function (eval, new Function, exec). An attacker supplies arbitrary code that executes in the application's runtime context, with full access to the application's data, env, and outbound network. Typical impact: equivalent to remote code execution; same recovery cost as command-injection.`,
29
29
  'csrf': (f) =>
30
- `The state-changing endpoint at ${f.file}:${f.line} doesn't validate that the request originated from your own application. An attacker hosts a page that issues a same-shape request from a logged-in victim's browser. Typical impact: state changes performed under the victim's identity — password change, money movement, role escalation. Cost: depends on what state can change; for billing endpoints, this is fraud-level.`,
30
+ `The state-changing endpoint doesn't validate that the request originated from your own application. An attacker hosts a page that issues a same-shape request from a logged-in victim's browser. Typical impact: state changes performed under the victim's identity — password change, money movement, role escalation. Cost: depends on what state can change; for billing endpoints, this is fraud-level.`,
31
31
  'open-redirect': (f) =>
32
32
  `The endpoint redirects to a URL the attacker controls. Used as part of phishing chains: victim clicks a legitimate-looking link to your domain, gets redirected to attacker.example, enters credentials thinking they're still on your site. Typical impact: phishing-amplified credential theft; reputational damage if your domain ends up on a phish-tracking list.`,
33
33
  'insecure-deserialization': (f) =>
34
34
  `The handler deserializes attacker-controlled bytes via pickle/yaml-load/Marshal. The deserialization callback invokes arbitrary code from class constructors / __reduce__ / __wakeup__. Typical impact: equivalent to remote code execution. Cost: full incident response, including investigating whether the attacker established persistence.`,
35
35
  'xxe': (f) =>
36
- `The XML parser at ${f.file}:${f.line} resolves external entities. An attacker submits XML referencing file:///etc/passwd or http://internal/. Typical impact: file disclosure, SSRF, blind out-of-band exfiltration of secrets. Cost: similar to SSRF + path-traversal combined.`,
36
+ `The XML parser resolves external entities. An attacker submits XML referencing file:///etc/passwd or http://internal/. Typical impact: file disclosure, SSRF, blind out-of-band exfiltration of secrets. Cost: similar to SSRF + path-traversal combined.`,
37
37
  };
38
38
 
39
39
  function _routeOf(f) {
40
- if (!f) return '<endpoint>';
41
- return `${f.file || '?'}:${f.line || '?'}`;
40
+ if (!f) return 'this endpoint';
41
+ // Narration may run before the line is finalised; fall back to the file alone
42
+ // (or a generic phrase) rather than emitting "file:undefined" / "file:?".
43
+ const line = Number(f.line) || Number(f.source?.line) || 0;
44
+ if (f.file && line) return `${f.file}:${line}`;
45
+ if (f.file) return f.file;
46
+ return 'this endpoint';
42
47
  }
43
48
 
44
49
  function _templateFor(f) {
@@ -65,7 +70,7 @@ async function _renderLlm(f) {
65
70
  Vuln: ${f.vuln}
66
71
  CWE: ${f.cwe}
67
72
  Severity: ${f.severity}
68
- Location: ${f.file}:${f.line}
73
+ Location: ${_routeOf(f)}
69
74
  Snippet: ${(f.snippet || '').slice(0, 200)}
70
75
 
71
76
  Write ONE paragraph (5-7 sentences) covering: (1) how an attacker reaches this code, (2) what they get if exploited, (3) typical recovery cost. Plain English, no marketing language, no emoji.`;
@@ -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.