@shomra/agent 0.2.9 → 0.2.10
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/ai-usage.mjs +155 -0
- package/code-sast.mjs +38 -0
- package/discovery.mjs +123 -2
- package/package.json +2 -1
package/ai-usage.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-USAGE inventory extractor (CLI port of the backend's checks/ai-usage.ts,
|
|
3
|
+
* kept in sync so both surfaces detect the same thing). Finds where a repo USES
|
|
4
|
+
* an LLM/AI provider in its own source — SDK imports + provider-specific call
|
|
5
|
+
* sites — so `shomra` can inventory "this code talks to OpenAI / a local model"
|
|
6
|
+
* as plain shadow-AI usage, independent of whether that usage is vulnerable.
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free, line-oriented, low-false-positive: a provider is only claimed
|
|
9
|
+
* when a line either IMPORTS its SDK module or matches a provider-specific CALL
|
|
10
|
+
* signature — never from a bare mention of the word "openai" in prose.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Human label per category — shared by the report + backend. */
|
|
14
|
+
export const AI_USAGE_CATEGORY_LABEL = {
|
|
15
|
+
'llm-api': 'hosted LLM API',
|
|
16
|
+
'llm-framework': 'LLM framework',
|
|
17
|
+
'local-runtime': 'local model runtime',
|
|
18
|
+
'inference-gateway': 'inference gateway',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The provider catalog — every entry is an LLM/AI *usage* surface.
|
|
22
|
+
const PROVIDERS = [
|
|
23
|
+
{ id: 'openai', label: 'OpenAI', category: 'llm-api', npm: ['openai'], npmPrefix: ['@azure/openai'], py: ['openai'],
|
|
24
|
+
call: [/\b(?:Async)?(?:Azure)?OpenAI\s*\(/, /\bchat\.completions\.create\s*\(/, /\bresponses\.create\s*\(/, /\bembeddings\.create\s*\(/, /\bChatCompletion\.create\s*\(/, /\bopenai\.(?:ChatCompletion|Completion|Embedding)\b/] },
|
|
25
|
+
{ id: 'anthropic', label: 'Anthropic', category: 'llm-api', npm: ['@anthropic-ai/sdk', '@anthropic-ai/bedrock-sdk', '@anthropic-ai/vertex-sdk'], npmPrefix: ['@anthropic-ai/'], py: ['anthropic'],
|
|
26
|
+
call: [/\b(?:Async)?Anthropic(?:Bedrock|Vertex)?\s*\(/, /\bmessages\.create\s*\(/, /\bmessages\.stream\s*\(/] },
|
|
27
|
+
{ id: 'google-gemini', label: 'Google Gemini', category: 'llm-api', npm: ['@google/generative-ai', '@google/genai'], py: ['google.generativeai', 'google.genai'], pyRoot: ['google.generativeai', 'google.genai'],
|
|
28
|
+
call: [/\bGenerativeModel\s*\(/, /\bgenerate_content\s*\(/, /\bgetGenerativeModel\s*\(/] },
|
|
29
|
+
{ id: 'google-vertex', label: 'Google Vertex AI', category: 'llm-api', npm: ['@google-cloud/vertexai', '@google-cloud/aiplatform'], py: ['vertexai', 'google.cloud.aiplatform'], pyRoot: ['vertexai', 'google.cloud.aiplatform'],
|
|
30
|
+
call: [/\bTextGenerationModel\b/, /\bGenerativeModel\.from_pretrained\s*\(/] },
|
|
31
|
+
{ id: 'aws-bedrock', label: 'AWS Bedrock', category: 'llm-api', npm: ['@aws-sdk/client-bedrock-runtime', '@aws-sdk/client-bedrock'],
|
|
32
|
+
call: [/\bbedrock[-_]?runtime\b/i, /\bBedrockRuntime(?:Client)?\b/, /\binvoke_model(?:_with_response_stream)?\s*\(/, /\bclient\s*\(\s*['"]bedrock/i] },
|
|
33
|
+
{ id: 'cohere', label: 'Cohere', category: 'llm-api', npm: ['cohere-ai'], py: ['cohere'], call: [/\bcohere\.Client\w*\s*\(/, /\bClientV2\s*\(/] },
|
|
34
|
+
{ id: 'mistral', label: 'Mistral', category: 'llm-api', npm: ['@mistralai/mistralai'], py: ['mistralai'], call: [/\bMistral(?:Client|AsyncClient|)\s*\(/, /\bchat\.complete\s*\(/] },
|
|
35
|
+
{ id: 'groq', label: 'Groq', category: 'llm-api', npm: ['groq-sdk'], py: ['groq'], call: [/\bGroq\s*\(/] },
|
|
36
|
+
{ id: 'together', label: 'Together AI', category: 'llm-api', npm: ['together-ai'], py: ['together'], call: [/\bTogether\s*\(/] },
|
|
37
|
+
{ id: 'replicate', label: 'Replicate', category: 'llm-api', npm: ['replicate'], py: ['replicate'], call: [/\breplicate\.run\s*\(/, /\bReplicate\s*\(/] },
|
|
38
|
+
{ id: 'huggingface-inference', label: 'Hugging Face Inference', category: 'llm-api', npm: ['@huggingface/inference'], py: ['huggingface_hub'], call: [/\bInferenceClient\s*\(/] },
|
|
39
|
+
|
|
40
|
+
{ id: 'litellm', label: 'LiteLLM', category: 'inference-gateway', npm: ['litellm'], py: ['litellm'], call: [/\blitellm\.(?:a?completion|a?embedding)\s*\(/] },
|
|
41
|
+
{ id: 'openrouter', label: 'OpenRouter', category: 'inference-gateway', npm: ['openrouter'], call: [/\bopenrouter\.ai\b/i] },
|
|
42
|
+
{ id: 'vercel-ai', label: 'Vercel AI SDK', category: 'inference-gateway', npm: ['ai'], npmPrefix: ['@ai-sdk/'], call: [/\b(?:generateText|streamText|generateObject|streamObject)\s*\(/] },
|
|
43
|
+
|
|
44
|
+
{ id: 'langchain', label: 'LangChain', category: 'llm-framework', npmPrefix: ['@langchain/'], npm: ['langchain'], pyRoot: ['langchain'], call: [/\bChat(?:OpenAI|Anthropic|Google\w*|Bedrock|Vertex\w*|Cohere|Mistral\w*)\s*\(/, /\bLLMChain\s*\(/, /\bChatPromptTemplate\b/] },
|
|
45
|
+
{ id: 'langgraph', label: 'LangGraph', category: 'llm-framework', npm: ['@langchain/langgraph'], pyRoot: ['langgraph'], call: [/\bStateGraph\s*\(/] },
|
|
46
|
+
{ id: 'llama-index', label: 'LlamaIndex', category: 'llm-framework', npmPrefix: ['@llamaindex/'], npm: ['llamaindex'], pyRoot: ['llama_index'], call: [/\bVectorStoreIndex\b/, /\bServiceContext\b/] },
|
|
47
|
+
{ id: 'crewai', label: 'CrewAI', category: 'llm-framework', py: ['crewai'], pyRoot: ['crewai'], call: [/\bCrew\s*\(/, /\bAgent\s*\(\s*role\s*=/] },
|
|
48
|
+
{ id: 'autogen', label: 'AutoGen', category: 'llm-framework', py: ['autogen', 'pyautogen'], pyRoot: ['autogen', 'autogen_agentchat'], call: [/\bAssistantAgent\s*\(/, /\bConversableAgent\s*\(/] },
|
|
49
|
+
{ id: 'semantic-kernel', label: 'Semantic Kernel', category: 'llm-framework', py: ['semantic_kernel'], pyRoot: ['semantic_kernel'], npm: ['@microsoft/semantic-kernel'], call: [/\bKernel\.builder\b/] },
|
|
50
|
+
{ id: 'haystack', label: 'Haystack', category: 'llm-framework', py: ['haystack'], pyRoot: ['haystack'], call: [/\bPipeline\s*\(\s*\)/] },
|
|
51
|
+
{ id: 'dspy', label: 'DSPy', category: 'llm-framework', py: ['dspy'], pyRoot: ['dspy'], call: [/\bdspy\.(?:Predict|ChainOfThought|Signature)\b/] },
|
|
52
|
+
{ id: 'guidance', label: 'Guidance', category: 'llm-framework', py: ['guidance'], call: [/\bguidance\.\w+/] },
|
|
53
|
+
{ id: 'instructor', label: 'Instructor', category: 'llm-framework', py: ['instructor'], call: [/\binstructor\.(?:from_openai|patch|from_anthropic)\s*\(/] },
|
|
54
|
+
{ id: 'pydantic-ai', label: 'PydanticAI', category: 'llm-framework', py: ['pydantic_ai'], pyRoot: ['pydantic_ai'], call: [/\bAgent\s*\(\s*['"]/] },
|
|
55
|
+
|
|
56
|
+
{ id: 'ollama', label: 'Ollama', category: 'local-runtime', npm: ['ollama'], py: ['ollama'], call: [/\bollama\.(?:chat|generate|embeddings)\s*\(/] },
|
|
57
|
+
{ id: 'transformers', label: 'Transformers', category: 'local-runtime', py: ['transformers'], pyRoot: ['transformers'], call: [/\bpipeline\s*\(/, /\bAutoModel\w*\.from_pretrained\s*\(/] },
|
|
58
|
+
{ id: 'sentence-transformers', label: 'Sentence-Transformers', category: 'local-runtime', py: ['sentence_transformers'], call: [/\bSentenceTransformer\s*\(/] },
|
|
59
|
+
{ id: 'vllm', label: 'vLLM', category: 'local-runtime', py: ['vllm'], call: [/\bLLM\s*\(\s*model\s*=/, /\bSamplingParams\s*\(/] },
|
|
60
|
+
{ id: 'llama-cpp', label: 'llama.cpp', category: 'local-runtime', npm: ['node-llama-cpp'], py: ['llama_cpp'], call: [/\bLlama\s*\(\s*model_path\s*=/] },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const MODEL_ON_LINE = /\bmodel(?:_?id|_?name)?\s*[=:]\s*['"]([A-Za-z0-9][\w.:\/-]{1,80})['"]/;
|
|
64
|
+
const MAX_CODE_LEN = 240;
|
|
65
|
+
const clipLine = (s) => (s.length > MAX_CODE_LEN ? s.slice(0, MAX_CODE_LEN) + '…' : s);
|
|
66
|
+
const SCAN_EXT = /\.(py|ipynb|[mc]?[jt]sx?|java|kt|go|rb|php|cs|rs|scala)$/i;
|
|
67
|
+
export function isAiUsageScannable(file) {
|
|
68
|
+
return SCAN_EXT.test(String(file || ''));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function importSpecifiers(raw) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const m of raw.matchAll(/(?:\bfrom\s+|\brequire\s*\(\s*|\bimport\s*\(\s*|\bimport\s+)['"]([^'"]+)['"]/g)) out.push(m[1]);
|
|
74
|
+
let pm = raw.match(/^\s*from\s+([A-Za-z_][\w.]*)\s+import\b/);
|
|
75
|
+
if (pm) out.push(pm[1]);
|
|
76
|
+
pm = raw.match(/^\s*import\s+([A-Za-z_][\w.]*(?:\s*,\s*[A-Za-z_][\w.]*)*)/);
|
|
77
|
+
if (pm) for (const mod of pm[1].split(',')) out.push(mod.trim());
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function importMatches(spec, p) {
|
|
82
|
+
if (!spec) return false;
|
|
83
|
+
const s = spec.trim();
|
|
84
|
+
if (p.npm?.includes(s)) return true;
|
|
85
|
+
if (p.npmPrefix?.some((pre) => s === pre.replace(/\/$/, '') || s.startsWith(pre))) return true;
|
|
86
|
+
if (p.py?.includes(s)) return true;
|
|
87
|
+
if (p.pyRoot?.some((root) => s === root || s.startsWith(root + '.') || s.startsWith(root + '_'))) return true;
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Extract AI-usage sightings from one source file's text. */
|
|
92
|
+
export function scanAiUsage(text, file = '') {
|
|
93
|
+
if (!text || !isAiUsageScannable(file)) return [];
|
|
94
|
+
const out = [];
|
|
95
|
+
const lines = text.split(/\r?\n/);
|
|
96
|
+
for (let i = 0; i < lines.length; i++) {
|
|
97
|
+
const raw = lines[i];
|
|
98
|
+
const trimmed = raw.trim();
|
|
99
|
+
if (!trimmed) continue;
|
|
100
|
+
const ln = i + 1;
|
|
101
|
+
const isComment = trimmed.startsWith('#') || trimmed.startsWith('//') || trimmed.startsWith('*');
|
|
102
|
+
const specs = isComment ? [] : importSpecifiers(raw);
|
|
103
|
+
const modelOnLine = (raw.match(MODEL_ON_LINE) || [])[1];
|
|
104
|
+
const seenOnLine = new Set();
|
|
105
|
+
for (const p of PROVIDERS) {
|
|
106
|
+
const importedSpec = specs.find((s) => importMatches(s, p));
|
|
107
|
+
if (importedSpec) {
|
|
108
|
+
const key = `${p.id}:import`;
|
|
109
|
+
if (!seenOnLine.has(key)) {
|
|
110
|
+
seenOnLine.add(key);
|
|
111
|
+
out.push({ provider: p.id, label: p.label, category: p.category, kind: 'import', via: importedSpec, code: clipLine(trimmed), line: ln, file });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!isComment && p.call) {
|
|
115
|
+
const hit = p.call.find((re) => re.test(raw));
|
|
116
|
+
if (hit) {
|
|
117
|
+
const key = `${p.id}:call`;
|
|
118
|
+
if (!seenOnLine.has(key)) {
|
|
119
|
+
seenOnLine.add(key);
|
|
120
|
+
out.push({ provider: p.id, label: p.label, category: p.category, kind: 'call', via: (raw.match(hit) || [])[0] || p.label, code: clipLine(trimmed), ...(modelOnLine ? { model: modelOnLine } : {}), line: ln, file });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Cap on individual sites carried per provider row. */
|
|
130
|
+
const MAX_SITES_PER_PROVIDER = 60;
|
|
131
|
+
|
|
132
|
+
/** One inventory row per provider, aggregated across all files. */
|
|
133
|
+
export function rollupAiUsage(usages) {
|
|
134
|
+
const byProvider = new Map();
|
|
135
|
+
for (const u of usages) {
|
|
136
|
+
let row = byProvider.get(u.provider);
|
|
137
|
+
if (!row) {
|
|
138
|
+
row = { provider: u.provider, label: u.label, category: u.category, files: [], firstSite: null, sites: [], models: [], sightings: 0, hasCallSite: false };
|
|
139
|
+
byProvider.set(u.provider, row);
|
|
140
|
+
}
|
|
141
|
+
row.sightings++;
|
|
142
|
+
if (!row.files.includes(u.file)) row.files.push(u.file);
|
|
143
|
+
if (u.model && !row.models.includes(u.model)) row.models.push(u.model);
|
|
144
|
+
if (u.kind === 'call') row.hasCallSite = true;
|
|
145
|
+
if (row.sites.length < MAX_SITES_PER_PROVIDER) {
|
|
146
|
+
row.sites.push({ file: u.file, line: u.line, kind: u.kind, via: u.via, ...(u.code ? { code: u.code } : {}), ...(u.model ? { model: u.model } : {}) });
|
|
147
|
+
}
|
|
148
|
+
const better = u.kind === 'call' && (!row.firstSite || row.firstSite.kind !== 'call');
|
|
149
|
+
if (!row.firstSite || better) row.firstSite = { file: u.file, line: u.line, via: u.via, kind: u.kind };
|
|
150
|
+
}
|
|
151
|
+
for (const row of byProvider.values()) {
|
|
152
|
+
row.sites.sort((a, b) => a.file.localeCompare(b.file) || (b.kind === 'call' ? 1 : 0) - (a.kind === 'call' ? 1 : 0) || a.line - b.line);
|
|
153
|
+
}
|
|
154
|
+
return [...byProvider.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
155
|
+
}
|
package/code-sast.mjs
CHANGED
|
@@ -245,6 +245,25 @@ const PY_RULES = [
|
|
|
245
245
|
remediation: 'Never set allow_dangerous_deserialization=True on an index you did not build. Rebuild the vector store from source documents in your own environment, or use a non-pickle store format.',
|
|
246
246
|
cwe: 'CWE-502',
|
|
247
247
|
},
|
|
248
|
+
{
|
|
249
|
+
id: 'python.mcp_client',
|
|
250
|
+
title: 'MCP client integration (untrusted tool-output ingress)',
|
|
251
|
+
// LOW capability twin of js.mcp_client: importing the MCP SDK client surface
|
|
252
|
+
// says this file acts as an MCP host that feeds server tool output to a model.
|
|
253
|
+
severity: 'LOW',
|
|
254
|
+
category: 'agentic',
|
|
255
|
+
confidence: 0.55,
|
|
256
|
+
// Precise: `mcp.client.*` and `from mcp.client…import` are unambiguous;
|
|
257
|
+
// `from mcp import` only counts WITH ClientSession (excludes server-authoring
|
|
258
|
+
// imports); bare ClientSession( is NOT matched (aiohttp.ClientSession FP).
|
|
259
|
+
re: /\bfrom\s+mcp\.client[\w.]*\s+import\b|\bimport\s+mcp\.client\b|\bmcp\.client\.\w+|\bfrom\s+mcp\s+import\b[^\n]*\bClientSession\b/,
|
|
260
|
+
codeOnly: true,
|
|
261
|
+
sink: (m) => m[0].trim(),
|
|
262
|
+
source: 'MCP server tool output',
|
|
263
|
+
message: 'Acts as an MCP client/host: opens a ClientSession to MCP servers and passes their tool descriptions and results back to a model. Every server it reaches is an untrusted-input ingress — a poisoned tool description or result can hijack the agent (prompt injection / tool poisoning).',
|
|
264
|
+
remediation: 'Pin exactly which MCP servers this client may connect to and run each through governance before trusting it. Treat all server output as untrusted data, never instructions, and screen it (runtime firewall) before it reaches the model.',
|
|
265
|
+
cwe: 'CWE-829',
|
|
266
|
+
},
|
|
248
267
|
{
|
|
249
268
|
id: 'python.reduce_payload',
|
|
250
269
|
title: 'Custom __reduce__ (pickle RCE gadget)',
|
|
@@ -367,6 +386,25 @@ const JS_RULES = [
|
|
|
367
386
|
remediation: 'Avoid shelling out. If unavoidable, use execFile with a fixed binary and an argument array (never a shell string), and validate every argument.',
|
|
368
387
|
cwe: 'CWE-78',
|
|
369
388
|
},
|
|
389
|
+
{
|
|
390
|
+
id: 'js.mcp_client',
|
|
391
|
+
title: 'MCP client integration (untrusted tool-output ingress)',
|
|
392
|
+
// LOW capability signal: the /client SDK entrypoint or a *ClientTransport says
|
|
393
|
+
// this file acts as an MCP host — it connects to servers and feeds their tool
|
|
394
|
+
// output to a model. That is the toxic-flow ingress; on its own it is a lead.
|
|
395
|
+
severity: 'LOW',
|
|
396
|
+
category: 'agentic',
|
|
397
|
+
confidence: 0.55,
|
|
398
|
+
// /client subpath + transport class names are unambiguous. `new Client(` is NOT
|
|
399
|
+
// matched — too many libs export a generic Client. Not codeOnly: the /client
|
|
400
|
+
// import path lives inside a require()/import string.
|
|
401
|
+
re: /@modelcontextprotocol\/sdk\/client|\b(StdioClientTransport|SSEClientTransport|StreamableHTTPClientTransport|WebSocketClientTransport)\b/,
|
|
402
|
+
sink: (m) => m[0].trim(),
|
|
403
|
+
source: 'MCP server tool output',
|
|
404
|
+
message: 'Acts as an MCP client/host: connects to MCP servers and passes their tool descriptions and results back to a model. Every server it reaches is an untrusted-input ingress — a poisoned tool description or result can hijack the agent (prompt injection / tool poisoning).',
|
|
405
|
+
remediation: 'Pin exactly which MCP servers this client may connect to and run each through governance before trusting it. Treat all server output as untrusted data, never instructions, and screen it (runtime firewall) before it reaches the model.',
|
|
406
|
+
cwe: 'CWE-829',
|
|
407
|
+
},
|
|
370
408
|
{
|
|
371
409
|
id: 'js.decode_and_run',
|
|
372
410
|
title: 'Encoded payload decode-and-run',
|
package/discovery.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import fs from 'node:fs';
|
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { scanAiUsage, rollupAiUsage, isAiUsageScannable, AI_USAGE_CATEGORY_LABEL } from './ai-usage.mjs';
|
|
23
24
|
|
|
24
25
|
const HOME = os.homedir();
|
|
25
26
|
const APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
|
|
@@ -142,13 +143,17 @@ const isVectorIndex = (base) =>
|
|
|
142
143
|
VECTOR_INDEX_BASENAMES.has(base.toLowerCase()) ||
|
|
143
144
|
VECTOR_INDEX_EXTS.has((base.slice(base.lastIndexOf('.') + 1) || '').toLowerCase());
|
|
144
145
|
|
|
146
|
+
/** Cap on SOURCE files collected for AI-usage-in-code scanning (bounded so a big
|
|
147
|
+
* monorepo can't turn the sweep into a full read of every .py/.ts on disk). */
|
|
148
|
+
const MAX_SOURCE_FILES = 600;
|
|
149
|
+
|
|
145
150
|
/**
|
|
146
151
|
* One bounded breadth-first walk per root that collects every file of interest.
|
|
147
|
-
* Returns { mcp:[], rules:[], manifests:[], env:[], vector:[] }
|
|
152
|
+
* Returns { mcp:[], rules:[], manifests:[], env:[], vector:[], source:[] } lists.
|
|
148
153
|
* Depth- and count-limited so it never turns into a full-disk crawl.
|
|
149
154
|
*/
|
|
150
155
|
function walkWorkspace(roots) {
|
|
151
|
-
const found = { mcp: [], rules: [], manifests: [], env: [], vector: [] };
|
|
156
|
+
const found = { mcp: [], rules: [], manifests: [], env: [], vector: [], source: [] };
|
|
152
157
|
const seenDir = new Set();
|
|
153
158
|
let budget = 40_000; // total directories visited across all roots
|
|
154
159
|
const maxDepth = 6;
|
|
@@ -163,6 +168,9 @@ function walkWorkspace(roots) {
|
|
|
163
168
|
} else if (MANIFEST_NAMES.has(base)) found.manifests.push({ file: full, parentBase });
|
|
164
169
|
else if (isEnvFile(base)) found.env.push({ file: full, parentBase });
|
|
165
170
|
else if (isVectorIndex(base)) found.vector.push({ file: full, parentBase });
|
|
171
|
+
// Application source — collected (capped) so discoverAiUsageInCode can find
|
|
172
|
+
// the LLM/AI providers this code actually calls, not just what a manifest declares.
|
|
173
|
+
else if (found.source.length < MAX_SOURCE_FILES && isAiUsageScannable(base)) found.source.push({ file: full, parentBase });
|
|
166
174
|
};
|
|
167
175
|
|
|
168
176
|
for (const root of roots) {
|
|
@@ -427,6 +435,117 @@ export function discoverAiDependencies(roots = [process.cwd()], files = null) {
|
|
|
427
435
|
return assets;
|
|
428
436
|
}
|
|
429
437
|
|
|
438
|
+
// ── AI usage in code (SDK imports + provider call sites) ─────────
|
|
439
|
+
// The dependency scan above sees which AI SDKs a manifest DECLARES; this sees
|
|
440
|
+
// which LLM/AI providers the code actually CALLS (an `openai` import + a
|
|
441
|
+
// `chat.completions.create`, a LangChain chain, `ollama.chat`) — the shadow-AI
|
|
442
|
+
// usage a manifest can miss (a transitive dep, a vendored client) and can't
|
|
443
|
+
// localize (which file, which model). Surfaced regardless of whether it is
|
|
444
|
+
// vulnerable. One AI_TOOL asset per provider, category 'code-usage'.
|
|
445
|
+
export function discoverAiUsageInCode(roots = [process.cwd()], files = null) {
|
|
446
|
+
const walk = files || walkWorkspace(roots);
|
|
447
|
+
const usages = [];
|
|
448
|
+
for (const { file } of walk.source || []) {
|
|
449
|
+
if (!isAiUsageScannable(path.basename(file))) continue;
|
|
450
|
+
const text = readText(file, 300_000);
|
|
451
|
+
if (text == null) continue;
|
|
452
|
+
for (const u of scanAiUsage(text, file)) usages.push(u);
|
|
453
|
+
}
|
|
454
|
+
const assets = [];
|
|
455
|
+
for (const row of rollupAiUsage(usages)) {
|
|
456
|
+
const site = row.firstSite;
|
|
457
|
+
assets.push({
|
|
458
|
+
type: 'AI_TOOL',
|
|
459
|
+
name: `${row.label} (in code)`,
|
|
460
|
+
identifier: `ai-usage:${row.provider}`,
|
|
461
|
+
vendor: 'ai-sdk',
|
|
462
|
+
metadata: {
|
|
463
|
+
category: 'code-usage',
|
|
464
|
+
provider: row.provider,
|
|
465
|
+
aiCategory: row.category,
|
|
466
|
+
aiCategoryLabel: AI_USAGE_CATEGORY_LABEL[row.category],
|
|
467
|
+
fileCount: row.files.length,
|
|
468
|
+
files: row.files.slice(0, 10),
|
|
469
|
+
models: row.models.slice(0, 10),
|
|
470
|
+
callSites: row.sightings,
|
|
471
|
+
hasCallSite: row.hasCallSite,
|
|
472
|
+
firstSite: site ? { file: site.file, line: site.line } : null,
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return assets;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── MCP client / host SDK usage in code ──────────────────────────
|
|
480
|
+
// A repo that depends on the MCP SDK acts as an MCP HOST: it connects out to MCP
|
|
481
|
+
// servers and feeds their (untrusted) tool output back into a model — the
|
|
482
|
+
// toxic-flow / lethal-trifecta ingress. This is distinct from the MCP SERVERS a
|
|
483
|
+
// machine is CONFIGURED to launch (discoverMcpServers, keyed as servers) and from
|
|
484
|
+
// generic AI SDKs (discoverAiDependencies): here the code itself is the client.
|
|
485
|
+
// Manifest-level detection can't prove which SDK surface is used, so the on-disk
|
|
486
|
+
// SAST rules (js.mcp_client / python.mcp_client) confirm the client role from
|
|
487
|
+
// actual imports; this lens surfaces the dependency so shadow MCP hosts are seen.
|
|
488
|
+
const NPM_MCP_CLIENT = new Set(['mcp-use', 'mcp-client']);
|
|
489
|
+
const NPM_MCP_CLIENT_PREFIX = ['@modelcontextprotocol/', '@mastra/mcp', '@langchain/mcp'];
|
|
490
|
+
const PY_MCP_CLIENT = ['mcp', 'fastmcp', 'mcp-use', 'mcpadapt', 'langchain-mcp-adapters'];
|
|
491
|
+
|
|
492
|
+
function npmMcpClientDeps(pkg) {
|
|
493
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}), ...(pkg.optionalDependencies || {}) };
|
|
494
|
+
const hits = [];
|
|
495
|
+
for (const name of Object.keys(deps)) {
|
|
496
|
+
if (NPM_MCP_CLIENT.has(name) || NPM_MCP_CLIENT_PREFIX.some((p) => name.startsWith(p))) hits.push(name);
|
|
497
|
+
}
|
|
498
|
+
return hits;
|
|
499
|
+
}
|
|
500
|
+
function pyMcpClientDeps(text) {
|
|
501
|
+
const hits = [];
|
|
502
|
+
for (const pkg of PY_MCP_CLIENT) {
|
|
503
|
+
const re = new RegExp(`(^|[^a-z0-9_.-])${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_.-]|$)`, 'im');
|
|
504
|
+
if (re.test(text)) hits.push(pkg);
|
|
505
|
+
}
|
|
506
|
+
return hits;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function discoverMcpClients(roots = [process.cwd()], files = null) {
|
|
510
|
+
const walk = files || walkWorkspace(roots);
|
|
511
|
+
const byPkg = new Map(); // `${eco}:${pkg}` -> { pkg, eco, manifests:Set }
|
|
512
|
+
const add = (eco, pkg, manifest) => {
|
|
513
|
+
const key = `${eco}:${pkg}`;
|
|
514
|
+
if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set() });
|
|
515
|
+
byPkg.get(key).manifests.add(manifest);
|
|
516
|
+
};
|
|
517
|
+
for (const { file } of walk.manifests) {
|
|
518
|
+
const base = path.basename(file);
|
|
519
|
+
if (base === 'package.json') {
|
|
520
|
+
const json = readJson(file);
|
|
521
|
+
if (!json) continue;
|
|
522
|
+
for (const pkg of npmMcpClientDeps(json)) add('npm', pkg, file);
|
|
523
|
+
} else {
|
|
524
|
+
const text = readText(file, 100_000);
|
|
525
|
+
if (text == null) continue;
|
|
526
|
+
for (const pkg of pyMcpClientDeps(text)) add('pip', pkg, file);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const assets = [];
|
|
530
|
+
for (const { pkg, eco, manifests } of byPkg.values()) {
|
|
531
|
+
const list = [...manifests];
|
|
532
|
+
assets.push({
|
|
533
|
+
type: 'AI_TOOL',
|
|
534
|
+
name: `${pkg} (${eco})`,
|
|
535
|
+
identifier: `mcp-client:${eco}:${pkg}`,
|
|
536
|
+
vendor: 'mcp-client',
|
|
537
|
+
metadata: {
|
|
538
|
+
category: 'mcp-client',
|
|
539
|
+
ecosystem: eco,
|
|
540
|
+
package: pkg,
|
|
541
|
+
usedInProjects: list.length,
|
|
542
|
+
manifests: list.slice(0, 10),
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
return assets;
|
|
547
|
+
}
|
|
548
|
+
|
|
430
549
|
// ── API keys sitting in .env files ───────────────────────────────
|
|
431
550
|
|
|
432
551
|
const KEY_NAME_VENDOR = {
|
|
@@ -792,6 +911,8 @@ export function discoverAll(roots = [process.cwd()], opts = {}) {
|
|
|
792
911
|
...discoverMcpServers(scanRoots, files),
|
|
793
912
|
...discoverRulesFiles(scanRoots, files),
|
|
794
913
|
...discoverAiDependencies(scanRoots, files),
|
|
914
|
+
...discoverAiUsageInCode(scanRoots, files),
|
|
915
|
+
...discoverMcpClients(scanRoots, files),
|
|
795
916
|
...discoverVectorStores(scanRoots, files),
|
|
796
917
|
...discoverDotenvKeys(scanRoots, files),
|
|
797
918
|
...discoverAiTools(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Shomra — a local-first security scanner and runtime firewall for AI agents, MCP servers, prompts, and models. Gates AI artifacts in your editor and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"guard-signals.mjs",
|
|
19
19
|
"code-sast.mjs",
|
|
20
20
|
"model-refs.mjs",
|
|
21
|
+
"ai-usage.mjs",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE",
|
|
23
24
|
"NOTICE"
|