@clear-capabilities/agentic-security-scanner 0.140.0 → 0.141.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.
- package/CHANGELOG.md +148 -0
- package/dist/113.index.js +79 -3
- package/dist/178.index.js +1 -1
- package/dist/238.index.js +77 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +12 -0
- package/dist/526.index.js +79 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +34 -7
- package/dist/compliance-frameworks/eu-ai-act.json +65 -14
- package/dist/compliance-frameworks/gdpr.json +56 -12
- package/dist/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/dist/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/dist/compliance-frameworks/nist-csf-2.json +78 -16
- package/dist/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/dist/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/package.json +16 -5
- package/src/dataflow/catalog.js +61 -0
- package/src/engine.js +262 -22
- package/src/mcp/tools.js +12 -0
- package/src/posture/accuracy-scorecard.js +57 -0
- package/src/posture/aibom.js +110 -1
- package/src/posture/auditor-walkthrough.js +56 -17
- package/src/posture/compliance-frameworks/ccpa.json +34 -7
- package/src/posture/compliance-frameworks/eu-ai-act.json +65 -14
- package/src/posture/compliance-frameworks/gdpr.json +56 -12
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +68 -15
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +57 -12
- package/src/posture/compliance-frameworks/nist-csf-2.json +78 -16
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +3 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +91 -20
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +89 -20
- package/src/posture/concurrency-checker.js +3 -3
- package/src/posture/coverage-strength.js +182 -0
- package/src/posture/epss.js +17 -1
- package/src/posture/family-registry.js +103 -0
- package/src/posture/family-resolve.js +47 -0
- package/src/posture/fix-coverage.js +113 -0
- package/src/posture/fix-metrics.js +76 -0
- package/src/posture/mcp-rug-pull.js +144 -0
- package/src/posture/poc-inprocess.js +217 -1
- package/src/posture/proof-coverage.js +162 -0
- package/src/posture/reachability-filter.js +44 -0
- package/src/posture/sbom.js +12 -3
- package/src/runScan.js +56 -5
- package/src/sast/CLAUDE.md +2 -2
- package/src/sast/claude-md-prompt-injection.js +47 -3
- package/src/sast/cloud-iam.js +23 -0
- package/src/sast/convention-deviation.js +66 -3
- package/src/sast/crypto-protocol.js +23 -0
- package/src/sast/dapp-frontend.js +20 -0
- package/src/sast/iac-cloud-templates.js +337 -0
- package/src/sast/k8s-admission.js +27 -0
- package/src/sast/ml-supply-chain.js +22 -0
- package/src/sast/ruby.js +132 -0
- package/src/sast/web3-advanced.js +26 -0
- package/src/sca/CLAUDE.md +21 -4
- package/src/sca/container.js +18 -1
- package/src/sca/dep-confusion.js +69 -3
package/src/dataflow/catalog.js
CHANGED
|
@@ -163,6 +163,41 @@ export const CATALOG = [
|
|
|
163
163
|
// `dict.get(...)`/`config.get(...)` elsewhere in the file does not also
|
|
164
164
|
// fire.
|
|
165
165
|
{ kind: 'source', id: 'py-flask-args-get', language: 'py', framework: 'flask', match: { type: 'call', callee: 'get', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values/headers/cookies/json/data.get() (Flask/Django)', provenance: 'url-param' },
|
|
166
|
+
// PRD F2.2 — the MULTI-VALUE accessors, which were missing entirely.
|
|
167
|
+
//
|
|
168
|
+
// `getlist` is the standard Flask/Werkzeug and Django QueryDict API for a
|
|
169
|
+
// repeated query parameter (`?host=a&host=b`), and `getall` is its
|
|
170
|
+
// multidict equivalent. Only `get` was modelled, so every repeated-parameter
|
|
171
|
+
// flow was invisible to the taint engine.
|
|
172
|
+
//
|
|
173
|
+
// This is worth recording because the PRD attributed the miss to Python
|
|
174
|
+
// COMPREHENSIONS (`[x for x in request.args.getlist(...)]`) and proposed
|
|
175
|
+
// modelling them. Comprehensions already flow — verified with the same shape
|
|
176
|
+
// over `request.args.get()`, which the engine tracks end to end. The example
|
|
177
|
+
// failed on its SOURCE, not on its loop, and modelling comprehensions would
|
|
178
|
+
// have changed nothing while looking like a fix.
|
|
179
|
+
{ kind: 'source', id: 'py-flask-args-getlist', language: 'py', framework: 'flask', match: { type: 'call', callee: 'getlist', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values.getlist() (Flask/Django)', provenance: 'url-param' },
|
|
180
|
+
{ kind: 'source', id: 'py-flask-args-getall', language: 'py', framework: 'flask', match: { type: 'call', callee: 'getall', receiver: '^(?:args|form|values|headers|cookies|json|data|GET|POST|FILES|META)$', receiverBase: '^(?:request|req)$' }, label: 'request.args/form/values.getall() (multidict)', provenance: 'url-param' },
|
|
181
|
+
|
|
182
|
+
// PRD F2.3 — NETWORK-RESPONSE sources.
|
|
183
|
+
//
|
|
184
|
+
// Only two existed, both C++ (`recv`, `recvfrom`), so a response body from an
|
|
185
|
+
// external service was trusted input everywhere else. It is not: the upstream
|
|
186
|
+
// may be compromised, attacker-influenced (the far end of an SSRF), or simply
|
|
187
|
+
// a third party whose output this code renders, executes or shells out with.
|
|
188
|
+
// This is the same trust boundary as an HTTP request arriving — the direction
|
|
189
|
+
// is reversed, not the trust.
|
|
190
|
+
//
|
|
191
|
+
// Kept under its OWN provenance ('network') rather than folded into
|
|
192
|
+
// http-body, so a report can say where the value came from and a team that
|
|
193
|
+
// genuinely trusts its own internal API can filter on it. Collapsing them
|
|
194
|
+
// would remove exactly the fact needed to triage these.
|
|
195
|
+
{ kind: 'source', id: 'js-fetch-json', language: 'js', framework: 'fetch', match: { type: 'call', callee: 'json', receiverBase: '^(?:res|resp|response|r)$' }, label: 'HTTP response .json() [fetch]', provenance: 'network' },
|
|
196
|
+
{ kind: 'source', id: 'js-fetch-text', language: 'js', framework: 'fetch', match: { type: 'call', callee: 'text', receiverBase: '^(?:res|resp|response|r)$' }, label: 'HTTP response .text() [fetch]', provenance: 'network' },
|
|
197
|
+
{ kind: 'source', id: 'js-axios-data', language: 'js', framework: 'axios', match: { type: 'member', object: 'response', prop: 'data' }, label: 'axios response.data', provenance: 'network' },
|
|
198
|
+
{ kind: 'source', id: 'py-requests-text', language: 'py', framework: 'requests', match: { type: 'member', object: 'resp', prop: 'text' }, label: 'requests response .text', provenance: 'network' },
|
|
199
|
+
{ kind: 'source', id: 'py-requests-json', language: 'py', framework: 'requests', match: { type: 'call', callee: 'json', receiverBase: '^(?:resp|response|r)$' }, label: 'requests response .json()', provenance: 'network' },
|
|
200
|
+
{ kind: 'source', id: 'py-urlopen-read', language: 'py', framework: 'urllib', match: { type: 'call', callee: 'read', receiverBase: '^(?:resp|response|r|f)$' }, label: 'urlopen read() [urllib]', provenance: 'network' },
|
|
166
201
|
{ kind: 'source', id: 'py-fastapi-request-query',language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Query' }, label: 'fastapi.Query()' },
|
|
167
202
|
{ kind: 'source', id: 'py-fastapi-request-body', language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Body' }, label: 'fastapi.Body()' },
|
|
168
203
|
{ kind: 'source', id: 'py-fastapi-form', language: 'py', framework: 'fastapi', match: { type: 'call', callee: 'Form' }, label: 'fastapi.Form()' },
|
|
@@ -228,6 +263,32 @@ export const CATALOG = [
|
|
|
228
263
|
{ kind: 'source', id: 'py-mcp-tool', language: 'py', framework: 'mcp', match: { type: 'annotation', name: 'mcp.tool' }, label: '@mcp.tool() parameter', provenance: 'agent-tool' },
|
|
229
264
|
{ kind: 'source', id: 'py-mcp-server-tool', language: 'py', framework: 'mcp', match: { type: 'annotation', name: 'server.tool' }, label: '@server.tool() parameter', provenance: 'agent-tool' },
|
|
230
265
|
|
|
266
|
+
// PRD F5.3 — the SAME trust boundary in JavaScript/TypeScript.
|
|
267
|
+
//
|
|
268
|
+
// The agent-tool boundary was modelled for Python only, while the
|
|
269
|
+
// TypeScript SDK (@modelcontextprotocol/sdk) is the dominant implementation.
|
|
270
|
+
// A tool argument is attacker-influenced in exactly the way an HTTP body is:
|
|
271
|
+
// whatever the model was persuaded to pass, by a web page it read, a file it
|
|
272
|
+
// opened, or another tool's output. Treating it as trusted because "the model
|
|
273
|
+
// sent it" is the confused-deputy assumption this whole feature exists to
|
|
274
|
+
// reject.
|
|
275
|
+
//
|
|
276
|
+
// `request.params.arguments` is the CallToolRequest shape every SDK server
|
|
277
|
+
// handler receives; `extra.arguments` covers the newer callback signature.
|
|
278
|
+
{ kind: 'source', id: 'js-mcp-call-args', language: 'js', framework: 'mcp', match: { type: 'member', object: 'params', prop: 'arguments' }, label: 'MCP tool call arguments', provenance: 'agent-tool' },
|
|
279
|
+
{ kind: 'source', id: 'js-mcp-request-params', language: 'js', framework: 'mcp', match: { type: 'member', object: 'request', prop: 'params' }, label: 'MCP request.params', provenance: 'agent-tool' },
|
|
280
|
+
{ kind: 'source', id: 'js-mcp-extra-args', language: 'js', framework: 'mcp', match: { type: 'member', object: 'extra', prop: 'arguments' }, label: 'MCP tool callback arguments', provenance: 'agent-tool' },
|
|
281
|
+
|
|
282
|
+
// TOOL OUTPUT is the other half of F5.3's shape (tool output -> model context
|
|
283
|
+
// -> tool invocation). Content returned by ANOTHER tool or an MCP resource is
|
|
284
|
+
// not the agent's own reasoning — it is third-party text that reached the
|
|
285
|
+
// context window. A server that reads a resource and passes it onward is the
|
|
286
|
+
// indirect-injection path, and it was invisible while only tool INPUT was a
|
|
287
|
+
// source.
|
|
288
|
+
{ kind: 'source', id: 'js-mcp-tool-result', language: 'js', framework: 'mcp', match: { type: 'member', object: 'result', prop: 'content' }, label: 'MCP tool result content', provenance: 'agent-tool' },
|
|
289
|
+
{ kind: 'source', id: 'js-mcp-resource-contents', language: 'js', framework: 'mcp', match: { type: 'member', object: 'resource', prop: 'contents' }, label: 'MCP resource contents', provenance: 'agent-tool' },
|
|
290
|
+
{ kind: 'source', id: 'py-mcp-tool-result', language: 'py', framework: 'mcp', match: { type: 'member', object: 'result', prop: 'content' }, label: 'MCP tool result content', provenance: 'agent-tool' },
|
|
291
|
+
|
|
231
292
|
// ─── SOURCES (Go) ─────────────────────────────────────────────────────────
|
|
232
293
|
{ kind: 'source', id: 'go-r-form', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'Form' }, label: 'r.Form' },
|
|
233
294
|
{ kind: 'source', id: 'go-r-postform', language: 'go', framework: 'net/http', match: { type: 'member', object: 'r', prop: 'PostForm' }, label: 'r.PostForm' },
|
package/src/engine.js
CHANGED
|
@@ -15,6 +15,7 @@ import { scanLlmCost } from './sast/llm-cost-advisor.js';
|
|
|
15
15
|
import { scanBusinessLogic } from './sast/logic.js';
|
|
16
16
|
import { scanPipeline } from './sast/pipeline.js';
|
|
17
17
|
import { scanMCP } from './sast/mcp-audit.js';
|
|
18
|
+
import { detectRugPull as _detectRugPull, saveBaseline as _saveMcpBaseline, fingerprintConfig as _fingerprintMcp } from './posture/mcp-rug-pull.js';
|
|
18
19
|
import { scanClaudeSettings } from './sast/claude-settings.js';
|
|
19
20
|
import { scanClaudeMdPromptInjection } from './sast/claude-md-prompt-injection.js';
|
|
20
21
|
import { scanClaudeHookInjection } from './sast/claude-hook-injection.js';
|
|
@@ -36,8 +37,10 @@ import { scanMobileManifest } from './sast/mobile-manifest.js';
|
|
|
36
37
|
import { scanQuarkusHardening } from './sast/quarkus-hardening.js';
|
|
37
38
|
import { scanFastapiHardening } from './sast/fastapi-hardening.js';
|
|
38
39
|
import { isDeterministic } from './posture/deterministic.js';
|
|
40
|
+
import { proofCoverage } from './posture/proof-coverage.js';
|
|
39
41
|
import { scanAuthZ } from './sast/authz.js';
|
|
40
42
|
import { scanApiBrokenAuthz } from './sast/api-authz.js';
|
|
43
|
+
import { scanCloudTemplates, isCloudFormationTemplate } from './sast/iac-cloud-templates.js';
|
|
41
44
|
import { scanTerraform } from './sast/iac-terraform.js';
|
|
42
45
|
import { scanCrossService } from './sast/cross-service.js';
|
|
43
46
|
import { scanRbacConsistency } from './sast/rbac-consistency.js';
|
|
@@ -125,7 +128,7 @@ import { scanMutationXSS } from './sast/mutation-xss.js';
|
|
|
125
128
|
import { scanDeserializationGadgets, _detectGadgets } from './sast/deserialization-gadgets.js';
|
|
126
129
|
// Phase 2 — Kotlin / Ruby / PHP coverage.
|
|
127
130
|
import { scanKotlin } from './sast/kotlin.js';
|
|
128
|
-
import { scanRuby } from './sast/ruby.js';
|
|
131
|
+
import { scanRuby, scanRubyPathJoin } from './sast/ruby.js';
|
|
129
132
|
import { scanPhp } from './sast/php.js';
|
|
130
133
|
import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
|
|
131
134
|
// Phase 1 — precision-engineering posture modules.
|
|
@@ -665,6 +668,7 @@ function _isIaCFile(p){
|
|
|
665
668
|
if (IAC_FILENAMES.has(base)) return true;
|
|
666
669
|
if (/\.dockerfile$/i.test(base)) return true;
|
|
667
670
|
if (/\.tf$|\.tfvars$/i.test(base)) return true;
|
|
671
|
+
if (/\.bicep$/i.test(base)) return true;
|
|
668
672
|
// K8s YAML heuristic: under k8s/ — the CONTENT half of this rule lives in
|
|
669
673
|
// `isKubernetesManifest` below, because a path predicate cannot see content.
|
|
670
674
|
if (/(?:^|\/)k8s(?:\/|$)/.test(p) && /\.ya?ml$/i.test(base)) return true;
|
|
@@ -710,6 +714,27 @@ export function isKubernetesManifest(relPath, content) {
|
|
|
710
714
|
return _K8S_API_VERSION_RE.test(head) && _K8S_KIND_RE.test(head);
|
|
711
715
|
}
|
|
712
716
|
function getExt(n){const p=n.split(".");return p.length>1?p.pop().toLowerCase():"";}
|
|
717
|
+
|
|
718
|
+
// Agent instruction files (CLAUDE.md, AGENTS.md, .cursorrules, …) are admitted
|
|
719
|
+
// by CONTENT-INDEPENDENT NAME, the same way isKubernetesManifest admits a
|
|
720
|
+
// manifest a path predicate cannot recognise.
|
|
721
|
+
//
|
|
722
|
+
// They fail shouldScan() because they are markdown, so `fileContents` never held
|
|
723
|
+
// one, so scanClaudeMdPromptInjection / scanClaudeHookInjection / scanMCP at the
|
|
724
|
+
// per-file dispatch could never run on them. The detectors were CORRECT and
|
|
725
|
+
// fully tested when called directly — they were simply never called. That is the
|
|
726
|
+
// same dark-detector shape as k8s-admission and install-script, and it is worse
|
|
727
|
+
// here: for an agentic security tool a poisoned instruction file loaded into
|
|
728
|
+
// every session is a flagship threat, and a normal scan could not see it.
|
|
729
|
+
//
|
|
730
|
+
// Matched on the naming convention only. A repository's ordinary docs stay out
|
|
731
|
+
// of scope, so this admits a handful of files rather than every .md in the tree.
|
|
732
|
+
const _INSTRUCTION_FILE_ADMIT_RE = /(?:^|[\\/])(?:CLAUDE|AGENTS|GEMINI|CURSOR|CODEX|KIRO|QWEN|TRAE|OPENCODE|SYSTEM_PROMPT)\.(?:md|markdown|txt|prompt|system\.md)$|(?:^|[\\/])\.(?:cursorrules|windsurfrules|aiderrules)$|(?:^|[\\/])\.(?:claude|cursor|codex|gemini)[\\/].*\.(?:md|json|ya?ml)$/i;
|
|
733
|
+
|
|
734
|
+
export function isInstructionFile(relPath) {
|
|
735
|
+
return typeof relPath === 'string' && _INSTRUCTION_FILE_ADMIT_RE.test(relPath);
|
|
736
|
+
}
|
|
737
|
+
|
|
713
738
|
function shouldScan(p){if(/\.(test|spec|mock)\./i.test(p))return false;if(/_test\.go$/i.test(p))return false;if(/_spec\.rb$/i.test(p))return false;if(/Test\.(?:java|cs|kt|scala)$/i.test(p))return false;if(/\.min\.[mc]?js$/i.test(p))return false;for(const x of p.split("/"))if(IGNORE_DIRS.has(x))return false;
|
|
714
739
|
// Mobile + framework manifest files needed by the v4 detectors.
|
|
715
740
|
const base=p.split('/').pop();
|
|
@@ -1402,6 +1427,10 @@ function _sinkLineIdentifiers(ctx) {
|
|
|
1402
1427
|
// (excluding the sink line itself, which trivially contains its own
|
|
1403
1428
|
// argument). Tries every match, not just the first, since a window can
|
|
1404
1429
|
// contain several guard-shaped lines and only one need actually correlate.
|
|
1430
|
+
// A function/method declaration line, in every language this engine reads.
|
|
1431
|
+
// Matching a guard-shaped NAME here means a method is being defined, not called.
|
|
1432
|
+
const _DECL_LINE_RE = /^\s*(?:@\w+\s*)?(?:(?:public|private|protected|internal|static|final|abstract|override|async|export|default|func|fun|fn|def|sub|function)\s+)+[\w.<>\[\]]+\s*\(|^\s*def\s+\w|^\s*(?:async\s+)?function\s+\w|^\s*func\s+(?:\([^)]*\)\s*)?\w+\s*\(|^\s*(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/;
|
|
1433
|
+
|
|
1405
1434
|
function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
|
|
1406
1435
|
const w = _guardWindow(ctx);
|
|
1407
1436
|
const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
|
|
@@ -1412,6 +1441,19 @@ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
|
|
|
1412
1441
|
let m;
|
|
1413
1442
|
while ((m = re.exec(w))) {
|
|
1414
1443
|
const guardLineIdx = w.slice(0, m.index).split('\n').length - 1; // 0-based within window
|
|
1444
|
+
// A DECLARATION is not a guard. The shape alternative deliberately matches a
|
|
1445
|
+
// project-local validator by name (`[Cc]heck\w+\(`, `[Ee]nsure\w+\(`, …), and
|
|
1446
|
+
// that also matches the line that DEFINES such a method — so
|
|
1447
|
+
// `def check_static_cache(request)` was read as containment being applied,
|
|
1448
|
+
// and every path-traversal finding inside a method whose own name begins
|
|
1449
|
+
// "check"/"validate"/"ensure"/"verify"/"assert"/"require" was dropped.
|
|
1450
|
+
//
|
|
1451
|
+
// Found by test/detector-liveness.test.js on the ruby-path-join fixture: the
|
|
1452
|
+
// rule fired in isolation and produced nothing through a scan — the same
|
|
1453
|
+
// signature as rate-limit.js and sibling-guard-omission. Narrow by design:
|
|
1454
|
+
// it skips declaration LINES, and does not touch the window heuristic that
|
|
1455
|
+
// every other CWE-22 and CWE-918 emitter depends on.
|
|
1456
|
+
if (_DECL_LINE_RE.test(wLines[guardLineIdx] || '')) continue;
|
|
1415
1457
|
const lo = Math.max(0, guardLineIdx - span);
|
|
1416
1458
|
const hi = Math.min(wLines.length, guardLineIdx + span + 1);
|
|
1417
1459
|
const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
|
|
@@ -1850,9 +1892,27 @@ const STRUCTURAL_VULN_PATTERNS=[
|
|
|
1850
1892
|
type:"File Serve",vuln:"Path Traversal (sendFile with User Input)",severity:"high",cwe:"CWE-22",stride:"Information Disclosure",
|
|
1851
1893
|
fix:"Allowlist file paths; never pass raw user input to sendFile"},
|
|
1852
1894
|
// ── Command Injection ──────────────────────────────────────────────────────
|
|
1853
|
-
|
|
1895
|
+
// SHELL-INVOKING forms only. `exec`/`execSync` hand the whole string to a
|
|
1896
|
+
// shell, so user input anywhere in the command is injection.
|
|
1897
|
+
//
|
|
1898
|
+
// `execFile`/`spawn` are DELIBERATELY NOT HERE. They take an argv array and
|
|
1899
|
+
// do not spawn a shell, so a tainted ARGUMENT cannot inject a command — they
|
|
1900
|
+
// are the canonical FIX for this very finding. The previous rule matched them
|
|
1901
|
+
// and rated it critical, while its own remediation text read "Use execFile
|
|
1902
|
+
// with argument array": following the advice could not clear the finding.
|
|
1903
|
+
// Flagging the fix as the bug is how a team learns to ignore a scanner.
|
|
1904
|
+
{regex:/(?:exec|execSync)\s*\([^;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^;)]{0,200}\)/g,
|
|
1854
1905
|
type:"OS Command",vuln:"Command Injection (User-Controlled Input)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
|
|
1855
1906
|
fix:"Use execFile with argument array; never interpolate user input into shell commands"},
|
|
1907
|
+
// The two ways an argv-form call IS still injectable:
|
|
1908
|
+
// 1. the tainted value is the COMMAND (first argument), not an argument to it
|
|
1909
|
+
// 2. `shell: true` is passed, which re-introduces the shell the argv form avoids
|
|
1910
|
+
{regex:/(?:execFile|spawn)(?:Sync)?\s*\(\s*[^,;)]*(?:req\.|\.body\.|\.query\.|\.params\.)[^,;)]{0,120}[,)]/g,
|
|
1911
|
+
type:"OS Command",vuln:"Command Injection (User-Controlled Command Name)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
|
|
1912
|
+
fix:"The COMMAND itself is user-controlled — an argv array does not help. Resolve the binary from a fixed allowlist."},
|
|
1913
|
+
{regex:/(?:execFile|spawn)(?:Sync)?\s*\([^;]{0,300}shell\s*:\s*true[^;]{0,200}(?:req\.|\.body\.|\.query\.|\.params\.)|(?:execFile|spawn)(?:Sync)?\s*\([^;]{0,300}(?:req\.|\.body\.|\.query\.|\.params\.)[^;]{0,200}shell\s*:\s*true/g,
|
|
1914
|
+
type:"OS Command",vuln:"Command Injection (argv form with shell:true)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
|
|
1915
|
+
fix:"`shell: true` re-introduces the shell that the argv array exists to avoid. Drop the option, or escape the input."},
|
|
1856
1916
|
{regex:/(?:vm\.runInContext|vm\.runInNewContext|new\s+vm\.Script)\s*\(/g,
|
|
1857
1917
|
type:"VM Sandbox",vuln:"VM Sandbox Execution (RCE Risk)",severity:"critical",cwe:"CWE-94",stride:"Elevation of Privilege",
|
|
1858
1918
|
fix:"Never execute user-supplied code in vm.runInContext; use a strict AST sandbox"},
|
|
@@ -6851,6 +6911,19 @@ const CIPHER_TRANSIT_PATTERNS=[
|
|
|
6851
6911
|
function classifyCipherStrength(cipher){const c=cipher.toUpperCase();if(/\bRC4\b|\bRC2\b|\bARCFOUR\b|SSLV2|SSLV3|\bNULL\b|\bEXPORT\b|\bANULL\b|\bENULL\b|\bECB\b|\bMD5\b|\bSHA1\b(?![\d_])/.test(c))return"weak";if(/\bDES\b/.test(c)&&!/3DES|EDE|TRIPLE/.test(c))return"weak";if(/3DES|TRIPLE.?DES|DES.EDE|TLS.?1.?1|TLSV1\.1/.test(c))return"weak";if(/BCRYPT|ARGON2|SCRYPT|PBKDF2|FERNET|CHACHA20|CHACHAPOLY|PASSWORD_BCRYPT|PASSWORD_ARGON/.test(c))return"strong";if(/\bAES\b|SHA256|SHA384|SHA512|SHA3|BLAKE2|HMACSHA256|HMACSHA512|\bGCM\b|\bCCM\b|ECDHE|DHE|TLS.?1.?[23]|TLSV1\.[23]|HTTPS.SERVER|TLS.SERVER|TLS.CERTIF|HS256|RS256|ES256/.test(c))return"strong";return"unknown";}
|
|
6852
6912
|
function scanCiphers(fp,raw){const cleaned=stripNoise(raw,fp);const lines=raw.split("\n");const atRest=[],inTransit=[];for(const pat of CIPHER_REST_PATTERNS){const re=new RegExp(pat.regex.source,pat.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const cipher=pat.getLabel(m);atRest.push({cipher,strength:classifyCipherStrength(cipher),ctx:pat.ctx,file:fp,line,snippet:(lines[line-1]||"").trim()});}}for(const pat of CIPHER_TRANSIT_PATTERNS){const re=new RegExp(pat.regex.source,pat.regex.flags);let m;while((m=re.exec(cleaned))){const line=lineAt(cleaned,m.index);const cipher=pat.getLabel(m);inTransit.push({cipher,strength:classifyCipherStrength(cipher),ctx:pat.ctx,file:fp,line,snippet:(lines[line-1]||"").trim()});}}const uniq=(a)=>a.filter((v,i,arr)=>arr.findIndex(x=>x.cipher===v.cipher&&x.file===v.file&&x.line===v.line)===i);return{atRest:uniq(atRest),inTransit:uniq(inTransit)};}
|
|
6853
6913
|
|
|
6914
|
+
// True for the JWT specimen published in the standard's own documentation.
|
|
6915
|
+
// Decodes the payload rather than matching the encoded string, so a token that
|
|
6916
|
+
// merely shares a prefix is not suppressed.
|
|
6917
|
+
function _isSpecimenJwt(token){
|
|
6918
|
+
try{
|
|
6919
|
+
const parts=String(token).split('.');
|
|
6920
|
+
if(parts.length!==3)return false;
|
|
6921
|
+
const payload=Buffer.from(parts[1].replace(/-/g,'+').replace(/_/g,'/'),'base64').toString('utf8');
|
|
6922
|
+
const d=JSON.parse(payload);
|
|
6923
|
+
return d&&d.sub==='1234567890'&&typeof d.name==='string'&&d.name==='John Doe';
|
|
6924
|
+
}catch(_){return false;}
|
|
6925
|
+
}
|
|
6926
|
+
|
|
6854
6927
|
function scanCredentials(fp,raw){
|
|
6855
6928
|
if(!CRED_PREFILTER.test(raw))return[];
|
|
6856
6929
|
const lines=raw.split("\n");const results=[];const seen=new Set();
|
|
@@ -6860,11 +6933,23 @@ function scanCredentials(fp,raw){
|
|
|
6860
6933
|
while((m=re.exec(raw))!=null){
|
|
6861
6934
|
const val=m[0];
|
|
6862
6935
|
if(/placeholder|example|xxx+|your_|changeme|<[A-Z_]+>|MY_|INSERT_|REPLACE_|TODO|test_key|fake_|sample_|dummy_/i.test(val))continue;
|
|
6936
|
+
// The published specimen token, which appears verbatim in essentially
|
|
6937
|
+
// every piece of JWT documentation and in most auth tutorials. Its
|
|
6938
|
+
// payload decodes to {"sub":"1234567890","name":"John Doe",…} — a
|
|
6939
|
+
// documented example value in exactly the sense AKIAIOSFODNN7EXAMPLE is,
|
|
6940
|
+
// and suppressed for the same reason and just as narrowly: the check is
|
|
6941
|
+
// on the DECODED payload, so a real token that merely resembles it is
|
|
6942
|
+
// unaffected. Found by bench/secrets-precision as the single false
|
|
6943
|
+
// positive in its negative set.
|
|
6944
|
+
if(pat.n==="Exposed JWT Token"&&_isSpecimenJwt(val))continue;
|
|
6863
6945
|
const line=raw.substring(0,m.index).split("\n").length;
|
|
6864
6946
|
const snippet=lines[line-1]?.trim()||"";
|
|
6865
6947
|
// Per-pattern line-context gate: if ctx is set, the matched line must satisfy it
|
|
6866
6948
|
if(pat.ctx&&!pat.ctx.test(snippet))continue;
|
|
6867
|
-
|
|
6949
|
+
// The same placeholder-credential guard now covers every URI-with-inline-
|
|
6950
|
+
// credentials pattern, not just the one it was written for. A connection
|
|
6951
|
+
// string pointing at localhost with `user:pass` is a README, not a leak.
|
|
6952
|
+
if((pat.n==="Password in URL"||pat.urlCreds)&&/localhost|127\.0\.|0\.0\.0\.0|example\.com|test\.com|::1|user:pass|admin:admin|foo:bar|user:password|username:password|admin:password|root:password|test:test|john:doe|demo:demo|myuser:mypass|guest:guest/i.test(val))continue;
|
|
6868
6953
|
const key=`${fp}:${line}:${pat.n}`;
|
|
6869
6954
|
if(seen.has(key))continue;seen.add(key);
|
|
6870
6955
|
const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
|
|
@@ -6873,10 +6958,18 @@ function scanCredentials(fp,raw){
|
|
|
6873
6958
|
// unredacted-snippet leak as scanEntropySecrets — `snippet` carried
|
|
6874
6959
|
// the raw source line (full credential value) straight through to
|
|
6875
6960
|
// every report format. Redact the exact matched value here too.
|
|
6876
|
-
results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
|
|
6961
|
+
results.push({vuln:pat.n,severity,cwe:"CWE-798",stride:"Information Disclosure",file:fp,line,_urlCreds:!!pat.urlCreds,snippet:snippet.split(val).join(masked),masked,fix:"Remove the hardcoded credential. Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Rotate the exposed credential immediately, treat it as compromised.",code:`// Remove hardcoded value:\n// const secret = "${masked}";\n\n// Use environment variable instead:\nconst secret = process.env.${pat.n.toUpperCase().replace(/[^A-Z0-9]/g,"_")};`});
|
|
6877
6962
|
}
|
|
6878
6963
|
}
|
|
6879
|
-
|
|
6964
|
+
// One secret, one finding. A `postgres://user:pass@host/db` matches both the
|
|
6965
|
+
// specific PostgreSQL pattern and the generic "Password in URL" one, and
|
|
6966
|
+
// reporting the same credential on the same line twice is noise that makes a
|
|
6967
|
+
// secrets report look padded. The specific name wins: it tells the reader
|
|
6968
|
+
// which system to go and rotate.
|
|
6969
|
+
const specificUrlLines=new Set(results.filter(r=>r._urlCreds).map(r=>`${r.file}:${r.line}`));
|
|
6970
|
+
const deduped=results.filter(r=>!(r.vuln==="Password in URL"&&specificUrlLines.has(`${r.file}:${r.line}`)));
|
|
6971
|
+
for(const r of deduped)delete r._urlCreds;
|
|
6972
|
+
return deduped;
|
|
6880
6973
|
}
|
|
6881
6974
|
|
|
6882
6975
|
/* ── OSV-backed SCA Engine ───────────────────────────────────────────────── */
|
|
@@ -6977,16 +7070,60 @@ async function _enrichWithEPSS(supplyChainResults){
|
|
|
6977
7070
|
const _KEV_FEED_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
6978
7071
|
const _KEV_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6979
7072
|
|
|
7073
|
+
// PRD F3.4 — a KEV catalog has no meaning without its age.
|
|
7074
|
+
//
|
|
7075
|
+
// The refresh TTL above only decides when to TRY the network. Every failure
|
|
7076
|
+
// path below falls back to `cached?.byCve` with NO age bound, so an offline
|
|
7077
|
+
// machine, a blocked egress rule or a CISA outage silently serves a catalog of
|
|
7078
|
+
// any age. A six-month-old catalog does not fail loudly — it quietly omits
|
|
7079
|
+
// every vulnerability added since, which UNDERSTATES risk. That is the worst
|
|
7080
|
+
// direction for this particular signal: KEV membership is used to escalate.
|
|
7081
|
+
//
|
|
7082
|
+
// The catalog is still used when stale (dropping it would understate risk even
|
|
7083
|
+
// harder), but its age is recorded and surfaced on the scan so a report can
|
|
7084
|
+
// state it, and `staleness` is a first-class value rather than an inference.
|
|
7085
|
+
const _KEV_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7086
|
+
|
|
7087
|
+
// Populated by _loadKEVCatalog and read when the scan result is assembled.
|
|
7088
|
+
let _kevCatalogMeta = { source: 'not-loaded', fetchedAt: null, ageDays: null, stale: null, entries: 0 };
|
|
7089
|
+
export function kevCatalogMeta() { return { ..._kevCatalogMeta }; }
|
|
7090
|
+
|
|
7091
|
+
function _setKevMeta(source, ts, entries) {
|
|
7092
|
+
const ageMs = ts ? Date.now() - ts : null;
|
|
7093
|
+
_kevCatalogMeta = {
|
|
7094
|
+
source,
|
|
7095
|
+
fetchedAt: ts ? new Date(ts).toISOString() : null,
|
|
7096
|
+
ageDays: ageMs == null ? null : Math.floor(ageMs / 86400000),
|
|
7097
|
+
stale: ageMs == null ? null : ageMs > _KEV_STALE_AFTER_MS,
|
|
7098
|
+
entries: entries || 0,
|
|
7099
|
+
meaning: 'KEV membership escalates severity. A stale catalog omits recently-added CVEs, so it understates risk rather than overstating it.',
|
|
7100
|
+
};
|
|
7101
|
+
}
|
|
7102
|
+
|
|
6980
7103
|
async function _loadKEVCatalog(){
|
|
6981
|
-
if (process.env.AGENTIC_SECURITY_OFFLINE === '1')
|
|
7104
|
+
if (process.env.AGENTIC_SECURITY_OFFLINE === '1') {
|
|
7105
|
+
_setKevMeta('offline-skipped', null, 0);
|
|
7106
|
+
return null;
|
|
7107
|
+
}
|
|
6982
7108
|
// Cached blob: { ts, byCve: { 'CVE-XXXX-YYYY': { dateAdded, ransomwareCampaign, vendor, product, vuln, action } } }
|
|
6983
7109
|
const cached = _osvCacheGet('kev:catalog');
|
|
6984
|
-
if (cached && cached.ts && (Date.now() - cached.ts < _KEV_TTL_MS))
|
|
7110
|
+
if (cached && cached.ts && (Date.now() - cached.ts < _KEV_TTL_MS)) {
|
|
7111
|
+
_setKevMeta('cache-fresh', cached.ts, Object.keys(cached.byCve || {}).length);
|
|
7112
|
+
return cached.byCve || null;
|
|
7113
|
+
}
|
|
7114
|
+
const fallback = () => {
|
|
7115
|
+
if (cached && cached.byCve) {
|
|
7116
|
+
_setKevMeta('cache-stale', cached.ts || null, Object.keys(cached.byCve).length);
|
|
7117
|
+
return cached.byCve;
|
|
7118
|
+
}
|
|
7119
|
+
_setKevMeta('unavailable', null, 0);
|
|
7120
|
+
return null;
|
|
7121
|
+
};
|
|
6985
7122
|
try {
|
|
6986
7123
|
const res = await fetch(_KEV_FEED_URL, {
|
|
6987
7124
|
headers: { 'User-Agent': 'agentic-security/0.1' },
|
|
6988
7125
|
});
|
|
6989
|
-
if (!res.ok) return
|
|
7126
|
+
if (!res.ok) return fallback();
|
|
6990
7127
|
const j = await res.json();
|
|
6991
7128
|
const byCve = {};
|
|
6992
7129
|
for (const v of (j.vulnerabilities || [])) {
|
|
@@ -7001,9 +7138,11 @@ async function _loadKEVCatalog(){
|
|
|
7001
7138
|
dueDate: v.dueDate || null,
|
|
7002
7139
|
};
|
|
7003
7140
|
}
|
|
7004
|
-
|
|
7141
|
+
const ts = Date.now();
|
|
7142
|
+
_osvCacheSet('kev:catalog', { ts, byCve });
|
|
7143
|
+
_setKevMeta('network', ts, Object.keys(byCve).length);
|
|
7005
7144
|
return byCve;
|
|
7006
|
-
} catch { return
|
|
7145
|
+
} catch { return fallback(); }
|
|
7007
7146
|
}
|
|
7008
7147
|
|
|
7009
7148
|
async function _enrichWithKEV(supplyChainResults){
|
|
@@ -7155,7 +7294,14 @@ function _parseGoMod(text,filePath){
|
|
|
7155
7294
|
if(t===')'){inReq=false;continue;}
|
|
7156
7295
|
let m=inReq?t.match(/^([^\s]+)\s+v([^\s/]+)/):t.match(/^require\s+([^\s]+)\s+v([^\s/]+)/);
|
|
7157
7296
|
if(m){
|
|
7158
|
-
|
|
7297
|
+
// Keep the version VERBATIM. This used to do `.replace(/-.*$/,'')`,
|
|
7298
|
+
// which turns every Go pseudo-version — v0.0.0-20210903162142-ad29c8ab022f
|
|
7299
|
+
// — into a bare `0.0.0`. That is not a shorter version, it is a different
|
|
7300
|
+
// and nonexistent one, and it made every pseudo-versioned module in a tree
|
|
7301
|
+
// collapse onto the same key. Normalisation for an advisory query belongs
|
|
7302
|
+
// at the query, where _osvQueryVersion does it; a component's recorded
|
|
7303
|
+
// version is also what lands in the SBOM, where truncating it is worse.
|
|
7304
|
+
const name=m[1];const ver=m[2];
|
|
7159
7305
|
const isIndirect=t.includes('// indirect');
|
|
7160
7306
|
out.push({name,version:ver,group:name.split('/').slice(0,2).join('/'),
|
|
7161
7307
|
scope:isIndirect?'optional':'required',purl:_makePurl('golang',name,ver,''),
|
|
@@ -7550,8 +7696,10 @@ function _parseGoSum(text, filePath){
|
|
|
7550
7696
|
const m = t.match(/^(\S+)\s+v([^\s]+)\s+h1:/);
|
|
7551
7697
|
if (!m) continue;
|
|
7552
7698
|
const name = m[1];
|
|
7553
|
-
//
|
|
7554
|
-
|
|
7699
|
+
// Verbatim, minus the leading `v`. The suffixes this used to strip
|
|
7700
|
+
// (`+incompatible`, the pseudo-version timestamp+sha) are part of the
|
|
7701
|
+
// module version the advisory database matches on — see _parseGoMod.
|
|
7702
|
+
const ver = m[2].replace(/^v/, '');
|
|
7555
7703
|
const dedupKey = `${name}@${ver}`;
|
|
7556
7704
|
if (seen.has(dedupKey)) continue;
|
|
7557
7705
|
seen.add(dedupKey);
|
|
@@ -7646,11 +7794,20 @@ function parseManifests(allFileContents){
|
|
|
7646
7794
|
// R10: Gradle resolved transitive graph — `gradle dependencies > gradle-dependencies.txt`.
|
|
7647
7795
|
'gradle-dependencies.txt':_parseGradleDependencies,
|
|
7648
7796
|
};
|
|
7797
|
+
// Requirements files are named a dozen ways and the basename table can only
|
|
7798
|
+
// hold one of them. `requirements/dev.txt`, `requirements-dev.txt` and
|
|
7799
|
+
// `requirements/base.txt` are all ordinary; matched by SHAPE so a new variant
|
|
7800
|
+
// does not need a new table entry. Kept narrow on purpose — an arbitrary
|
|
7801
|
+
// `.txt` reaching this parser would invent dependencies out of prose.
|
|
7802
|
+
const _REQ_FILE=/^requirements(?:[._-][\w.-]+)?\.txt$/i;
|
|
7803
|
+
const _REQ_DIR=/(?:^|\/)requirements\/[\w.-]+\.txt$/i;
|
|
7804
|
+
const _pick=(fp,base)=>PARSERS[base]||((_REQ_FILE.test(base)||_REQ_DIR.test(fp))?_parseRequirementsTxt:null);
|
|
7649
7805
|
const out=[],seen=new Set();
|
|
7650
7806
|
for(const[fp,content]of Object.entries(allFileContents)){
|
|
7651
7807
|
const base=fp.split('/').pop();
|
|
7652
|
-
|
|
7653
|
-
|
|
7808
|
+
const parser=_pick(fp.split('\\').join('/'),base);
|
|
7809
|
+
if(!parser)continue;
|
|
7810
|
+
for(const comp of parser(content,fp)){
|
|
7654
7811
|
const key=`${comp.ecosystem}:${comp.name}:${comp.version}`;
|
|
7655
7812
|
if(!seen.has(key)){seen.add(key);out.push(comp);}
|
|
7656
7813
|
}
|
|
@@ -7728,6 +7885,34 @@ function computeAttackPathComponents(findings,components,byFile){
|
|
|
7728
7885
|
return{flagged,pathsByKey};
|
|
7729
7886
|
}
|
|
7730
7887
|
|
|
7888
|
+
// The version string an advisory database can actually match on.
|
|
7889
|
+
//
|
|
7890
|
+
// This used to be `version.match(/(\d+\.\d+(?:\.\d+)*)/)`, which takes the
|
|
7891
|
+
// first dotted-number run and throws the rest away. For most ecosystems that is
|
|
7892
|
+
// harmless; for Go it is destructive. A Go pseudo-version is
|
|
7893
|
+
//
|
|
7894
|
+
// v0.0.0-20210903162142-ad29c8ab022f
|
|
7895
|
+
//
|
|
7896
|
+
// and the leading `0.0.0` is a placeholder, not a version — every pseudo-version
|
|
7897
|
+
// in the tree collapsed to the same meaningless `0.0.0`, so the query asked
|
|
7898
|
+
// about a release that does not exist and the real one was never checked.
|
|
7899
|
+
// bench/sca-replay attributed nearly every remaining Go miss to exactly this.
|
|
7900
|
+
// `+incompatible` builds lost their suffix the same way.
|
|
7901
|
+
//
|
|
7902
|
+
// A WILDCARD is refused outright rather than truncated. `2.0.*` is a range; it
|
|
7903
|
+
// has no single version to be affected, and reporting "phpseclib 2.0.* is
|
|
7904
|
+
// vulnerable" names something that was never installed.
|
|
7905
|
+
function _osvQueryVersion(raw){
|
|
7906
|
+
const s=String(raw||'').trim();
|
|
7907
|
+
if(!s)return null;
|
|
7908
|
+
if(/[*x]/i.test(s.replace(/^[\^~>=<\s]+/,'').replace(/[-+][\w.-]+$/,'')))return null;
|
|
7909
|
+
// Strip only a leading range operator or `v`; keep the whole version after it.
|
|
7910
|
+
const m=s.match(/^[\^~>=<\s]*v?(\d[\w.+-]*)$/);
|
|
7911
|
+
if(m)return m[1];
|
|
7912
|
+
const fallback=s.match(/(\d+\.\d+(?:\.\d+)*)/);
|
|
7913
|
+
return fallback?fallback[1]:null;
|
|
7914
|
+
}
|
|
7915
|
+
|
|
7731
7916
|
async function queryOSV(components,allFileContents){
|
|
7732
7917
|
const OSV_ECO={npm:'npm',pypi:'PyPI',packagist:'Packagist',rubygems:'RubyGems',golang:'Go',cargo:'crates.io',maven:'Maven',pub:'Pub'};
|
|
7733
7918
|
const results=[];
|
|
@@ -7742,7 +7927,7 @@ async function queryOSV(components,allFileContents){
|
|
|
7742
7927
|
const queries=[],uncached=[],vulnAffects={};
|
|
7743
7928
|
for(const comp of queryable){
|
|
7744
7929
|
const eco=OSV_ECO[comp.ecosystem];
|
|
7745
|
-
const cleanVer=(comp.version
|
|
7930
|
+
const cleanVer=_osvQueryVersion(comp.version);
|
|
7746
7931
|
if(!cleanVer)continue;
|
|
7747
7932
|
const ck=`comp:${eco}:${comp.name}:${cleanVer}`;
|
|
7748
7933
|
const cached=_osvCacheGet(ck);
|
|
@@ -8039,7 +8224,7 @@ function _deterministicFileTimings(timings) {
|
|
|
8039
8224
|
|
|
8040
8225
|
const _fileTimings = [];
|
|
8041
8226
|
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
|
|
8042
|
-
const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f])) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
|
|
8227
|
+
const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];
|
|
8043
8228
|
// ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
|
|
8044
8229
|
// runScan({resume:true})). Default OFF, so existing behaviour is untouched.
|
|
8045
8230
|
// Only this loop is checkpointed; every cross-file pass below re-runs, so
|
|
@@ -8107,7 +8292,7 @@ function _deterministicFileTimings(timings) {
|
|
|
8107
8292
|
let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
|
|
8108
8293
|
if(_ckptDone.has(p)&&_ckptReplay(p))continue;
|
|
8109
8294
|
const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
|
|
8110
|
-
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}const cc=_blankCached(c,_commentLangFor(p));fc[p]=c;aR.push(...scanRoutes(p,cc));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,cc));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,cc));aF.push(...scanExtraStructural(p,cc));aF.push(...scanAliasedSinks(p,cc));aF.push(...scanJavaSAST(p,cc));aF.push(...scanJavaBenchExtras(p,cc));aLogic.push(...scanMiddlewareOrdering(p,cc));aLogic.push(...scanReDoS(p,cc));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,cc));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,cc);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,cc));aF.push(...scanIaC(p,cc));aF.push(...scanTerraform(p,cc));
|
|
8295
|
+
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}const cc=_blankCached(c,_commentLangFor(p));fc[p]=c;aR.push(...scanRoutes(p,cc));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,cc));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,cc));aF.push(...scanExtraStructural(p,cc));aF.push(...scanAliasedSinks(p,cc));aF.push(...scanJavaSAST(p,cc));aF.push(...scanJavaBenchExtras(p,cc));aLogic.push(...scanMiddlewareOrdering(p,cc));aLogic.push(...scanReDoS(p,cc));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,cc));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,cc);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,cc));aF.push(...scanIaC(p,cc));aF.push(...scanTerraform(p,cc));aF.push(...scanCloudTemplates(p,c));
|
|
8111
8296
|
aF.push(...scanLLM(p,c));
|
|
8112
8297
|
aF.push(...scanLLMOwasp(p,c));
|
|
8113
8298
|
aF.push(...scanLlmCost(p,c));
|
|
@@ -8116,6 +8301,31 @@ function _deterministicFileTimings(timings) {
|
|
|
8116
8301
|
aF.push(...scanContainer(p,cc));
|
|
8117
8302
|
aF.push(...scanInstallScripts(p,cc));
|
|
8118
8303
|
aF.push(...scanMCP(p,c));
|
|
8304
|
+
// PRD F5.2 — rug-pull: a tool whose definition changed AFTER approval.
|
|
8305
|
+
// Every scanMCP rule judges the CURRENT content, so a description that is
|
|
8306
|
+
// innocuous today and hostile tomorrow passes both scans. This compares
|
|
8307
|
+
// against a recorded baseline, which is the only way to see a change.
|
|
8308
|
+
// Wired here rather than left as a tested module: a detector with no call
|
|
8309
|
+
// site is a dark detector, which is the exact class this session keeps
|
|
8310
|
+
// finding.
|
|
8311
|
+
if (/(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i.test(p)) {
|
|
8312
|
+
try {
|
|
8313
|
+
const _cfg = JSON.parse(c);
|
|
8314
|
+
const _rp = _detectRugPull(scanRoot, _cfg, { file: p });
|
|
8315
|
+
aF.push(..._rp.findings);
|
|
8316
|
+
// Record on first sight so the NEXT scan has something to compare
|
|
8317
|
+
// against; refresh after reporting so a reviewed change is not
|
|
8318
|
+
// re-reported forever.
|
|
8319
|
+
_saveMcpBaseline(scanRoot, _fingerprintMcp(_cfg));
|
|
8320
|
+
} catch (e) {
|
|
8321
|
+
// Only a malformed config is tolerated here — scanMCP already reports
|
|
8322
|
+
// what it can from one. Anything else is a programmer error and must
|
|
8323
|
+
// not be swallowed: a bare `catch {}` around this block hid a
|
|
8324
|
+
// ReferenceError (`root` vs `scanRoot`) that silently disabled the
|
|
8325
|
+
// whole detector while every unit test still passed.
|
|
8326
|
+
if (!(e instanceof SyntaxError)) throw e;
|
|
8327
|
+
}
|
|
8328
|
+
}
|
|
8119
8329
|
aF.push(...scanClaudeSettings(p,c));
|
|
8120
8330
|
aF.push(...scanClaudeMdPromptInjection(p,c));
|
|
8121
8331
|
aF.push(...scanClaudeHookInjection(p,c));
|
|
@@ -8200,7 +8410,7 @@ function _deterministicFileTimings(timings) {
|
|
|
8200
8410
|
aF.push(...scanSSRFCloudMetadata(p,cc));
|
|
8201
8411
|
aF.push(...scanMutationXSS(p,cc));
|
|
8202
8412
|
aF.push(...scanKotlin(p,cc));
|
|
8203
|
-
aF.push(...scanRuby(p,cc));
|
|
8413
|
+
aF.push(...scanRuby(p,cc));aF.push(...scanRubyPathJoin(p,cc));
|
|
8204
8414
|
aF.push(...scanPhp(p,cc));
|
|
8205
8415
|
// Integration block: scaffolded SAST scanners. Gated by env var.
|
|
8206
8416
|
if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
|
|
@@ -9557,7 +9767,13 @@ function _deterministicFileTimings(timings) {
|
|
|
9557
9767
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
9558
9768
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
9559
9769
|
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
9560
|
-
|
|
9770
|
+
// PRD F7.2: publish what CANNOT be proven alongside what can. A proof RATE
|
|
9771
|
+
// computed over the provable subset makes a narrow subset look like strength;
|
|
9772
|
+
// the three-bucket split (provable / declined-on-purpose / not-yet-classified)
|
|
9773
|
+
// is the honest shape. Measured on the CVE corpus: 19% / 13% / 68%.
|
|
9774
|
+
let _proofCoverage = null;
|
|
9775
|
+
try { _proofCoverage = proofCoverage([...finalFindings, ...aLogic]); } catch { _proofCoverage = null; }
|
|
9776
|
+
return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
|
|
9561
9777
|
|
|
9562
9778
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
9563
9779
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
|
@@ -9767,6 +9983,30 @@ const CREDENTIAL_PATTERNS=[
|
|
|
9767
9983
|
// Database / Infrastructure
|
|
9768
9984
|
// ctx gate: JDBC URLs in docs/test configs without credentials are not findings; require @ or password= evidence
|
|
9769
9985
|
{n:"Database Connection String",r:"jdbc:[a-z:]+://[A-Za-z0-9\\.\\-_:;=/@?,&]+",s:"h",ctx:/@|password=|passwd=|pwd=/i},
|
|
9986
|
+
// PRD F4.1. bench/secrets-precision measured format coverage at 83% and every
|
|
9987
|
+
// one of these five was a genuine absence, not a tuning problem. Four are
|
|
9988
|
+
// among the most common real leaks there are — a database URI with the
|
|
9989
|
+
// password inline is what a connection string looks like when someone pastes
|
|
9990
|
+
// one into a config file.
|
|
9991
|
+
//
|
|
9992
|
+
// `jdbc:` was the ONLY database URI shape covered. `postgres://` and
|
|
9993
|
+
// `mongodb+srv://` are far more common in the ecosystems this tool is aimed
|
|
9994
|
+
// at, and the generic "Password in URL" pattern could not reach them: it is
|
|
9995
|
+
// gated behind CRED_PREFILTER, which had no token for either scheme.
|
|
9996
|
+
{n:"PostgreSQL Connection URI",r:"postgres(?:ql)?://[^\\s:@/]{1,64}:[^\\s:@/]{6,64}@[^\\s/\"']{1,128}",s:"h",urlCreds:true},
|
|
9997
|
+
{n:"MongoDB Connection URI",r:"mongodb(?:\\+srv)?://[^\\s:@/]{1,64}:[^\\s:@/]{6,64}@[^\\s/\"']{1,128}",s:"h",urlCreds:true},
|
|
9998
|
+
{n:"Azure Storage Account Key",r:"AccountKey=[A-Za-z0-9+/]{86}==",s:"c"},
|
|
9999
|
+
{n:"GitLab Personal Access Token",r:"glpat-[0-9A-Za-z_-]{20}",s:"c"},
|
|
10000
|
+
{n:"DigitalOcean Personal Access Token",r:"dop_v1_[a-f0-9]{64}",s:"c"},
|
|
10001
|
+
{n:"Supabase Service Key",r:"sbp_[a-f0-9]{40}",s:"c"},
|
|
10002
|
+
{n:"HubSpot Private App Token",r:"pat-(?:na|eu)[0-9]-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",s:"c"},
|
|
10003
|
+
// NOT added, deliberately: Datadog, Vercel and Algolia keys are a bare run of
|
|
10004
|
+
// hex or alphanumerics with no prefix. bench/secrets-precision reports them as
|
|
10005
|
+
// misses and they should stay reported. A pattern for "32 hex characters"
|
|
10006
|
+
// would fire on every content digest, Cargo checksum, test vector and build
|
|
10007
|
+
// hash in the negative set — trading five detections for thousands of false
|
|
10008
|
+
// positives, in the feature most prone to alert fatigue. Closing this needs
|
|
10009
|
+
// variable-name context, not another regex.
|
|
9770
10010
|
// Downgraded to medium; scanner also skips localhost/example hosts (see scanCredentials)
|
|
9771
10011
|
{n:"Password in URL",r:"[a-zA-Z]{3,10}://[^/\\s:@]{3,20}:[^/\\s:@]{3,20}@.{1,100}[\"'\\s]",s:"m"},
|
|
9772
10012
|
{n:"WordPress Secret Key",r:"define(.{0,20})?(DB_PASSWORD|AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|AUTH_SALT|NONCE_KEY).{0,20}['\"].{10,120}['\"]",s:"h"},
|
|
@@ -9778,7 +10018,7 @@ const CREDENTIAL_PATTERNS=[
|
|
|
9778
10018
|
// ctx gate: only report when the line contains a storage/assignment keyword, filters standalone examples in comments
|
|
9779
10019
|
{n:"Exposed JWT Token",r:"eyJ[a-zA-Z0-9]{10,}\\.eyJ[a-zA-Z0-9]{10,}\\.[a-zA-Z0-9_\\-]{10,}",s:"m",ctx:/token|jwt|auth|bearer|secret|key|credential|sign|=|:/i},
|
|
9780
10020
|
];
|
|
9781
|
-
const CRED_PREFILTER=/AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|da2-[a-z0-9]{10}|amzn\.mws|AIza|ya29\.[0-9A-Za-z]{15}|googleusercontent|[Hh][Ee][Rr][Oo][Kk][Uu]|dt0[A-Za-z][0-9]{2}\.|ghp_|gho_|ghu_|ghs_|ghr_|sk_live_|sk_test_|rk_live_|access_token\$production|sq0atp|sq0csp|xox[baprs]-[0-9a-zA-Z]{8}|hooks\.slack\.com|discord(?:app)?\.com\/api\/webhooks|outlook\.office\.com\/webhook|[0-9]{8,10}:AA[0-9A-Za-z]|AAAA[a-zA-Z0-9_-]{7}:|twilio|SG\.[a-zA-Z0-9_-]{15}|mailchimp|key-[0-9a-zA-Z]{20}|shpat_|shpss_|shpca_|shppa_|-----BEGIN .*(PRIVATE|PGP)|NRAA-|NRII-|NRIQ-|NRRA-|EAACEdEose0cBA|pypi-AgEIcH|hooks\.zapier\.com|jdbc:|cloudinary:\/\/|R_[0-9a-f]{20}|eyJ[a-zA-Z0-9]{10,}\.eyJ
|
|
10021
|
+
const CRED_PREFILTER=/AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA|da2-[a-z0-9]{10}|amzn\.mws|AIza|ya29\.[0-9A-Za-z]{15}|googleusercontent|[Hh][Ee][Rr][Oo][Kk][Uu]|dt0[A-Za-z][0-9]{2}\.|ghp_|gho_|ghu_|ghs_|ghr_|sk_live_|sk_test_|rk_live_|access_token\$production|sq0atp|sq0csp|xox[baprs]-[0-9a-zA-Z]{8}|hooks\.slack\.com|discord(?:app)?\.com\/api\/webhooks|outlook\.office\.com\/webhook|[0-9]{8,10}:AA[0-9A-Za-z]|AAAA[a-zA-Z0-9_-]{7}:|twilio|SG\.[a-zA-Z0-9_-]{15}|mailchimp|key-[0-9a-zA-Z]{20}|shpat_|shpss_|shpca_|shppa_|-----BEGIN .*(PRIVATE|PGP)|NRAA-|NRII-|NRIQ-|NRRA-|EAACEdEose0cBA|pypi-AgEIcH|hooks\.zapier\.com|jdbc:|cloudinary:\/\/|R_[0-9a-f]{20}|eyJ[a-zA-Z0-9]{10,}\.eyJ|postgres(?:ql)?:\/\/|mongodb(?:\+srv)?:\/\/|AccountKey=|glpat-|dop_v1_|sbp_[a-f0-9]{10}|pat-(?:na|eu)[0-9]-|[a-z][a-z0-9+.-]{2,15}:\/\/[^\s:@\/]{3,64}:[^\s:@\/]{3,64}@/i;
|
|
9782
10022
|
const SECRET_IMPACT_MAP={
|
|
9783
10023
|
"AWS Access Key ID":"Provides programmatic access to AWS resources. With the paired secret key, an attacker can enumerate S3 buckets, exfiltrate databases, spin up EC2 instances for cryptomining, or pivot to any service the role permits. If the key belongs to an admin role, this is full cloud account takeover.",
|
|
9784
10024
|
"AWS AppSync GraphQL Key":"Allows unauthenticated queries and mutations against your AppSync GraphQL API. Attackers can read application data, trigger mutations to corrupt records, or enumerate the schema to map further attack surface.",
|
|
@@ -10000,7 +10240,7 @@ export {
|
|
|
10000
10240
|
classifyOrphans, classifyField, classifyEndpoint, shouldScan,
|
|
10001
10241
|
_isFalsePositiveCredential, _detectSafeSinkShape,
|
|
10002
10242
|
_loadCustomRules, _isCustomSuppressed, _isPathIgnored,
|
|
10003
|
-
scanIaC, IAC_PATTERNS, _isIaCFile,
|
|
10243
|
+
scanIaC, IAC_PATTERNS, _isIaCFile, isCloudFormationTemplate,
|
|
10004
10244
|
payloadsForFinding, buildProofObligation,
|
|
10005
10245
|
DATA_CLASSES, SOURCE_PATTERNS, SINK_PATTERNS, SANITIZER_PATTERNS,
|
|
10006
10246
|
ROUTE_PATTERNS, AUTH_PATTERNS, IGNORE_DIRS, CODE_EXTS,
|
package/src/mcp/tools.js
CHANGED
|
@@ -330,6 +330,18 @@ function _maybeOffload(sessionRoot, toolName, items) {
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
// ─── scan_diff ───────────────────────────────────────────────────────────────
|
|
333
|
+
// Test seam for the write boundary (PRD F6.4).
|
|
334
|
+
//
|
|
335
|
+
// `_confine` and `isReservedWrite` ARE the confinement contract in
|
|
336
|
+
// agents/_CONFINEMENT.md. A boundary is only worth what its refusals are worth,
|
|
337
|
+
// and refusals cannot be adversarially tested through the public tools without
|
|
338
|
+
// also exercising a real scan, a real patch and a real filesystem write — so
|
|
339
|
+
// the check would be measuring four things and attributing failure to one.
|
|
340
|
+
//
|
|
341
|
+
// Exported under the `_internals` convention this codebase already uses
|
|
342
|
+
// (see posture/poc-inprocess.js). Not part of the MCP tool surface.
|
|
343
|
+
export const _internals = { _confine, isReservedWrite: _isReservedWritePath };
|
|
344
|
+
|
|
333
345
|
export const scan_diff = {
|
|
334
346
|
name: 'scan_diff',
|
|
335
347
|
description: 'Scan a list of files for security findings. Use BEFORE writing a Write/Edit to disk so the agent can self-correct. Returns findings with severity, file:line, title, remediation. Snippets are redacted of obvious secret patterns. Paths confined to the session root; symlinks are refused.',
|