@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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 '
|
|
41
|
-
|
|
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
|
|
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.`;
|
package/src/report/index.js
CHANGED
|
@@ -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
|
|
707
|
-
// render
|
|
708
|
-
const findings = normalizeFindings(scan).map(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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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.
|