@clear-capabilities/agentic-security-scanner 0.139.1 → 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 +221 -0
- package/bin/agentic-security.js +40 -11
- 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 +281 -23
- 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 +137 -21
- 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 +42 -5
- 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/integrity.js +59 -8
- package/src/posture/mcp-rug-pull.js +144 -0
- package/src/posture/poc-generator.js +17 -1
- 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 +50 -7
- 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';
|
|
@@ -35,8 +36,11 @@ import { scanLlmTradingAgent } from './sast/llm-trading-agent.js';
|
|
|
35
36
|
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';
|
|
39
|
+
import { isDeterministic } from './posture/deterministic.js';
|
|
40
|
+
import { proofCoverage } from './posture/proof-coverage.js';
|
|
38
41
|
import { scanAuthZ } from './sast/authz.js';
|
|
39
42
|
import { scanApiBrokenAuthz } from './sast/api-authz.js';
|
|
43
|
+
import { scanCloudTemplates, isCloudFormationTemplate } from './sast/iac-cloud-templates.js';
|
|
40
44
|
import { scanTerraform } from './sast/iac-terraform.js';
|
|
41
45
|
import { scanCrossService } from './sast/cross-service.js';
|
|
42
46
|
import { scanRbacConsistency } from './sast/rbac-consistency.js';
|
|
@@ -124,7 +128,7 @@ import { scanMutationXSS } from './sast/mutation-xss.js';
|
|
|
124
128
|
import { scanDeserializationGadgets, _detectGadgets } from './sast/deserialization-gadgets.js';
|
|
125
129
|
// Phase 2 — Kotlin / Ruby / PHP coverage.
|
|
126
130
|
import { scanKotlin } from './sast/kotlin.js';
|
|
127
|
-
import { scanRuby } from './sast/ruby.js';
|
|
131
|
+
import { scanRuby, scanRubyPathJoin } from './sast/ruby.js';
|
|
128
132
|
import { scanPhp } from './sast/php.js';
|
|
129
133
|
import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secret-entropy.js';
|
|
130
134
|
// Phase 1 — precision-engineering posture modules.
|
|
@@ -664,6 +668,7 @@ function _isIaCFile(p){
|
|
|
664
668
|
if (IAC_FILENAMES.has(base)) return true;
|
|
665
669
|
if (/\.dockerfile$/i.test(base)) return true;
|
|
666
670
|
if (/\.tf$|\.tfvars$/i.test(base)) return true;
|
|
671
|
+
if (/\.bicep$/i.test(base)) return true;
|
|
667
672
|
// K8s YAML heuristic: under k8s/ — the CONTENT half of this rule lives in
|
|
668
673
|
// `isKubernetesManifest` below, because a path predicate cannot see content.
|
|
669
674
|
if (/(?:^|\/)k8s(?:\/|$)/.test(p) && /\.ya?ml$/i.test(base)) return true;
|
|
@@ -709,6 +714,27 @@ export function isKubernetesManifest(relPath, content) {
|
|
|
709
714
|
return _K8S_API_VERSION_RE.test(head) && _K8S_KIND_RE.test(head);
|
|
710
715
|
}
|
|
711
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
|
+
|
|
712
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;
|
|
713
739
|
// Mobile + framework manifest files needed by the v4 detectors.
|
|
714
740
|
const base=p.split('/').pop();
|
|
@@ -1401,6 +1427,10 @@ function _sinkLineIdentifiers(ctx) {
|
|
|
1401
1427
|
// (excluding the sink line itself, which trivially contains its own
|
|
1402
1428
|
// argument). Tries every match, not just the first, since a window can
|
|
1403
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
|
+
|
|
1404
1434
|
function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
|
|
1405
1435
|
const w = _guardWindow(ctx);
|
|
1406
1436
|
const re = new RegExp(guardRe.source, guardRe.flags.includes('g') ? guardRe.flags : guardRe.flags + 'g');
|
|
@@ -1411,6 +1441,19 @@ function _guardMatchNearSinkIdentifier(ctx, guardRe, span = 2) {
|
|
|
1411
1441
|
let m;
|
|
1412
1442
|
while ((m = re.exec(w))) {
|
|
1413
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;
|
|
1414
1457
|
const lo = Math.max(0, guardLineIdx - span);
|
|
1415
1458
|
const hi = Math.min(wLines.length, guardLineIdx + span + 1);
|
|
1416
1459
|
const local = wLines.slice(lo, hi).filter((l) => l !== sinkLineText).join('\n');
|
|
@@ -1849,9 +1892,27 @@ const STRUCTURAL_VULN_PATTERNS=[
|
|
|
1849
1892
|
type:"File Serve",vuln:"Path Traversal (sendFile with User Input)",severity:"high",cwe:"CWE-22",stride:"Information Disclosure",
|
|
1850
1893
|
fix:"Allowlist file paths; never pass raw user input to sendFile"},
|
|
1851
1894
|
// ── Command Injection ──────────────────────────────────────────────────────
|
|
1852
|
-
|
|
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,
|
|
1853
1905
|
type:"OS Command",vuln:"Command Injection (User-Controlled Input)",severity:"critical",cwe:"CWE-78",stride:"Elevation of Privilege",
|
|
1854
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."},
|
|
1855
1916
|
{regex:/(?:vm\.runInContext|vm\.runInNewContext|new\s+vm\.Script)\s*\(/g,
|
|
1856
1917
|
type:"VM Sandbox",vuln:"VM Sandbox Execution (RCE Risk)",severity:"critical",cwe:"CWE-94",stride:"Elevation of Privilege",
|
|
1857
1918
|
fix:"Never execute user-supplied code in vm.runInContext; use a strict AST sandbox"},
|
|
@@ -6850,6 +6911,19 @@ const CIPHER_TRANSIT_PATTERNS=[
|
|
|
6850
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";}
|
|
6851
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)};}
|
|
6852
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
|
+
|
|
6853
6927
|
function scanCredentials(fp,raw){
|
|
6854
6928
|
if(!CRED_PREFILTER.test(raw))return[];
|
|
6855
6929
|
const lines=raw.split("\n");const results=[];const seen=new Set();
|
|
@@ -6859,11 +6933,23 @@ function scanCredentials(fp,raw){
|
|
|
6859
6933
|
while((m=re.exec(raw))!=null){
|
|
6860
6934
|
const val=m[0];
|
|
6861
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;
|
|
6862
6945
|
const line=raw.substring(0,m.index).split("\n").length;
|
|
6863
6946
|
const snippet=lines[line-1]?.trim()||"";
|
|
6864
6947
|
// Per-pattern line-context gate: if ctx is set, the matched line must satisfy it
|
|
6865
6948
|
if(pat.ctx&&!pat.ctx.test(snippet))continue;
|
|
6866
|
-
|
|
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;
|
|
6867
6953
|
const key=`${fp}:${line}:${pat.n}`;
|
|
6868
6954
|
if(seen.has(key))continue;seen.add(key);
|
|
6869
6955
|
const severity=pat.s==="c"?"critical":pat.s==="h"?"high":"medium";
|
|
@@ -6872,10 +6958,18 @@ function scanCredentials(fp,raw){
|
|
|
6872
6958
|
// unredacted-snippet leak as scanEntropySecrets — `snippet` carried
|
|
6873
6959
|
// the raw source line (full credential value) straight through to
|
|
6874
6960
|
// every report format. Redact the exact matched value here too.
|
|
6875
|
-
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,"_")};`});
|
|
6876
6962
|
}
|
|
6877
6963
|
}
|
|
6878
|
-
|
|
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;
|
|
6879
6973
|
}
|
|
6880
6974
|
|
|
6881
6975
|
/* ── OSV-backed SCA Engine ───────────────────────────────────────────────── */
|
|
@@ -6976,16 +7070,60 @@ async function _enrichWithEPSS(supplyChainResults){
|
|
|
6976
7070
|
const _KEV_FEED_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
6977
7071
|
const _KEV_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6978
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
|
+
|
|
6979
7103
|
async function _loadKEVCatalog(){
|
|
6980
|
-
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
|
+
}
|
|
6981
7108
|
// Cached blob: { ts, byCve: { 'CVE-XXXX-YYYY': { dateAdded, ransomwareCampaign, vendor, product, vuln, action } } }
|
|
6982
7109
|
const cached = _osvCacheGet('kev:catalog');
|
|
6983
|
-
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
|
+
};
|
|
6984
7122
|
try {
|
|
6985
7123
|
const res = await fetch(_KEV_FEED_URL, {
|
|
6986
7124
|
headers: { 'User-Agent': 'agentic-security/0.1' },
|
|
6987
7125
|
});
|
|
6988
|
-
if (!res.ok) return
|
|
7126
|
+
if (!res.ok) return fallback();
|
|
6989
7127
|
const j = await res.json();
|
|
6990
7128
|
const byCve = {};
|
|
6991
7129
|
for (const v of (j.vulnerabilities || [])) {
|
|
@@ -7000,9 +7138,11 @@ async function _loadKEVCatalog(){
|
|
|
7000
7138
|
dueDate: v.dueDate || null,
|
|
7001
7139
|
};
|
|
7002
7140
|
}
|
|
7003
|
-
|
|
7141
|
+
const ts = Date.now();
|
|
7142
|
+
_osvCacheSet('kev:catalog', { ts, byCve });
|
|
7143
|
+
_setKevMeta('network', ts, Object.keys(byCve).length);
|
|
7004
7144
|
return byCve;
|
|
7005
|
-
} catch { return
|
|
7145
|
+
} catch { return fallback(); }
|
|
7006
7146
|
}
|
|
7007
7147
|
|
|
7008
7148
|
async function _enrichWithKEV(supplyChainResults){
|
|
@@ -7154,7 +7294,14 @@ function _parseGoMod(text,filePath){
|
|
|
7154
7294
|
if(t===')'){inReq=false;continue;}
|
|
7155
7295
|
let m=inReq?t.match(/^([^\s]+)\s+v([^\s/]+)/):t.match(/^require\s+([^\s]+)\s+v([^\s/]+)/);
|
|
7156
7296
|
if(m){
|
|
7157
|
-
|
|
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];
|
|
7158
7305
|
const isIndirect=t.includes('// indirect');
|
|
7159
7306
|
out.push({name,version:ver,group:name.split('/').slice(0,2).join('/'),
|
|
7160
7307
|
scope:isIndirect?'optional':'required',purl:_makePurl('golang',name,ver,''),
|
|
@@ -7549,8 +7696,10 @@ function _parseGoSum(text, filePath){
|
|
|
7549
7696
|
const m = t.match(/^(\S+)\s+v([^\s]+)\s+h1:/);
|
|
7550
7697
|
if (!m) continue;
|
|
7551
7698
|
const name = m[1];
|
|
7552
|
-
//
|
|
7553
|
-
|
|
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/, '');
|
|
7554
7703
|
const dedupKey = `${name}@${ver}`;
|
|
7555
7704
|
if (seen.has(dedupKey)) continue;
|
|
7556
7705
|
seen.add(dedupKey);
|
|
@@ -7645,11 +7794,20 @@ function parseManifests(allFileContents){
|
|
|
7645
7794
|
// R10: Gradle resolved transitive graph — `gradle dependencies > gradle-dependencies.txt`.
|
|
7646
7795
|
'gradle-dependencies.txt':_parseGradleDependencies,
|
|
7647
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);
|
|
7648
7805
|
const out=[],seen=new Set();
|
|
7649
7806
|
for(const[fp,content]of Object.entries(allFileContents)){
|
|
7650
7807
|
const base=fp.split('/').pop();
|
|
7651
|
-
|
|
7652
|
-
|
|
7808
|
+
const parser=_pick(fp.split('\\').join('/'),base);
|
|
7809
|
+
if(!parser)continue;
|
|
7810
|
+
for(const comp of parser(content,fp)){
|
|
7653
7811
|
const key=`${comp.ecosystem}:${comp.name}:${comp.version}`;
|
|
7654
7812
|
if(!seen.has(key)){seen.add(key);out.push(comp);}
|
|
7655
7813
|
}
|
|
@@ -7727,6 +7885,34 @@ function computeAttackPathComponents(findings,components,byFile){
|
|
|
7727
7885
|
return{flagged,pathsByKey};
|
|
7728
7886
|
}
|
|
7729
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
|
+
|
|
7730
7916
|
async function queryOSV(components,allFileContents){
|
|
7731
7917
|
const OSV_ECO={npm:'npm',pypi:'PyPI',packagist:'Packagist',rubygems:'RubyGems',golang:'Go',cargo:'crates.io',maven:'Maven',pub:'Pub'};
|
|
7732
7918
|
const results=[];
|
|
@@ -7741,7 +7927,7 @@ async function queryOSV(components,allFileContents){
|
|
|
7741
7927
|
const queries=[],uncached=[],vulnAffects={};
|
|
7742
7928
|
for(const comp of queryable){
|
|
7743
7929
|
const eco=OSV_ECO[comp.ecosystem];
|
|
7744
|
-
const cleanVer=(comp.version
|
|
7930
|
+
const cleanVer=_osvQueryVersion(comp.version);
|
|
7745
7931
|
if(!cleanVer)continue;
|
|
7746
7932
|
const ck=`comp:${eco}:${comp.name}:${cleanVer}`;
|
|
7747
7933
|
const cached=_osvCacheGet(ck);
|
|
@@ -8019,9 +8205,26 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8019
8205
|
try { _GLOBAL_JAVA_TAINTED_METHODS = _buildGlobalJavaTaintedMethodIndex(fileContents); }
|
|
8020
8206
|
catch { _GLOBAL_JAVA_TAINTED_METHODS = new Set(); }
|
|
8021
8207
|
const _perFileTimeoutMs = parseInt(process.env.AGENTIC_SECURITY_PER_FILE_TIMEOUT_MS || '10000', 10);
|
|
8208
|
+
// Per-file wall-clock timings are the slowest-first performance view, and they
|
|
8209
|
+
// leaked straight into --deterministic output: the `ms` values differ run to
|
|
8210
|
+
// run, and because the list is SORTED BY those values the ORDER differs too.
|
|
8211
|
+
// Zeroing the numbers alone would not have been enough — the sort key would
|
|
8212
|
+
// have become constant and the resulting order arbitrary. Under deterministic
|
|
8213
|
+
// mode the timings are therefore reported as 0 and ordered by filename, so the
|
|
8214
|
+
// field keeps its shape (consumers still see the same 20 entries) without
|
|
8215
|
+
// carrying anything a clock decided.
|
|
8216
|
+
function _deterministicFileTimings(timings) {
|
|
8217
|
+
if (!isDeterministic()) return timings.sort((a, b) => b.ms - a.ms).slice(0, 20);
|
|
8218
|
+
return timings
|
|
8219
|
+
.slice()
|
|
8220
|
+
.sort((a, b) => String(a.file).localeCompare(String(b.file)))
|
|
8221
|
+
.slice(0, 20)
|
|
8222
|
+
.map(t => ({ ...t, ms: 0 }));
|
|
8223
|
+
}
|
|
8224
|
+
|
|
8022
8225
|
const _fileTimings = [];
|
|
8023
8226
|
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
|
|
8024
|
-
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=[];
|
|
8025
8228
|
// ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
|
|
8026
8229
|
// runScan({resume:true})). Default OFF, so existing behaviour is untouched.
|
|
8027
8230
|
// Only this loop is checkpointed; every cross-file pass below re-runs, so
|
|
@@ -8089,7 +8292,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8089
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"});
|
|
8090
8293
|
if(_ckptDone.has(p)&&_ckptReplay(p))continue;
|
|
8091
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};
|
|
8092
|
-
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));
|
|
8093
8296
|
aF.push(...scanLLM(p,c));
|
|
8094
8297
|
aF.push(...scanLLMOwasp(p,c));
|
|
8095
8298
|
aF.push(...scanLlmCost(p,c));
|
|
@@ -8098,6 +8301,31 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8098
8301
|
aF.push(...scanContainer(p,cc));
|
|
8099
8302
|
aF.push(...scanInstallScripts(p,cc));
|
|
8100
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
|
+
}
|
|
8101
8329
|
aF.push(...scanClaudeSettings(p,c));
|
|
8102
8330
|
aF.push(...scanClaudeMdPromptInjection(p,c));
|
|
8103
8331
|
aF.push(...scanClaudeHookInjection(p,c));
|
|
@@ -8182,7 +8410,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8182
8410
|
aF.push(...scanSSRFCloudMetadata(p,cc));
|
|
8183
8411
|
aF.push(...scanMutationXSS(p,cc));
|
|
8184
8412
|
aF.push(...scanKotlin(p,cc));
|
|
8185
|
-
aF.push(...scanRuby(p,cc));
|
|
8413
|
+
aF.push(...scanRuby(p,cc));aF.push(...scanRubyPathJoin(p,cc));
|
|
8186
8414
|
aF.push(...scanPhp(p,cc));
|
|
8187
8415
|
// Integration block: scaffolded SAST scanners. Gated by env var.
|
|
8188
8416
|
if (process.env.AGENTIC_SECURITY_NO_INTEGRATION !== '1') {
|
|
@@ -9516,7 +9744,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
9516
9744
|
// seen when only N-of-those-candidates were actually analyzed.
|
|
9517
9745
|
// checkpoint.total intentionally keeps files.length — that field means the
|
|
9518
9746
|
// full candidate set for resume bookkeeping, a different, correct meaning.
|
|
9519
|
-
const _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_fileTimings
|
|
9747
|
+
const _scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length}};
|
|
9520
9748
|
// R8: the scan completed, so the checkpoint has been fully consumed — remove
|
|
9521
9749
|
// it. Anything that threw before this point leaves it in place to resume from.
|
|
9522
9750
|
try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
|
|
@@ -9539,7 +9767,13 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
9539
9767
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
9540
9768
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
9541
9769
|
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
9542
|
-
|
|
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};}
|
|
9543
9777
|
|
|
9544
9778
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
9545
9779
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
|
@@ -9749,6 +9983,30 @@ const CREDENTIAL_PATTERNS=[
|
|
|
9749
9983
|
// Database / Infrastructure
|
|
9750
9984
|
// ctx gate: JDBC URLs in docs/test configs without credentials are not findings; require @ or password= evidence
|
|
9751
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.
|
|
9752
10010
|
// Downgraded to medium; scanner also skips localhost/example hosts (see scanCredentials)
|
|
9753
10011
|
{n:"Password in URL",r:"[a-zA-Z]{3,10}://[^/\\s:@]{3,20}:[^/\\s:@]{3,20}@.{1,100}[\"'\\s]",s:"m"},
|
|
9754
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"},
|
|
@@ -9760,7 +10018,7 @@ const CREDENTIAL_PATTERNS=[
|
|
|
9760
10018
|
// ctx gate: only report when the line contains a storage/assignment keyword, filters standalone examples in comments
|
|
9761
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},
|
|
9762
10020
|
];
|
|
9763
|
-
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;
|
|
9764
10022
|
const SECRET_IMPACT_MAP={
|
|
9765
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.",
|
|
9766
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.",
|
|
@@ -9982,7 +10240,7 @@ export {
|
|
|
9982
10240
|
classifyOrphans, classifyField, classifyEndpoint, shouldScan,
|
|
9983
10241
|
_isFalsePositiveCredential, _detectSafeSinkShape,
|
|
9984
10242
|
_loadCustomRules, _isCustomSuppressed, _isPathIgnored,
|
|
9985
|
-
scanIaC, IAC_PATTERNS, _isIaCFile,
|
|
10243
|
+
scanIaC, IAC_PATTERNS, _isIaCFile, isCloudFormationTemplate,
|
|
9986
10244
|
payloadsForFinding, buildProofObligation,
|
|
9987
10245
|
DATA_CLASSES, SOURCE_PATTERNS, SINK_PATTERNS, SANITIZER_PATTERNS,
|
|
9988
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.',
|