aictem 0.1.0__py3-none-any.whl

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.
aictem/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """aictem: discover repository, dependency, container, cloud and AI assets."""
2
+
3
+ __version__ = "0.1.0"
aictem/__main__.py ADDED
@@ -0,0 +1,106 @@
1
+ """aictem command line.
2
+
3
+ aictem discover [PATH] inventory a repository: packages, containers, cloud, CI and AI assets, with findings
4
+ aictem discover . --format json -o r.json
5
+ aictem discover . --upload send the inventory to the CTEM platform (CTEM_URL, CTEM_CI_TOKEN)
6
+ aictem discover . --vulns also look up known vulnerabilities in pinned packages (OSV)
7
+ aictem discover . --fail-on high exit 1 if a finding at or above the severity is present
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from aictem import __version__, output, scan
18
+ from aictem.model import SEVERITY_RANK
19
+
20
+ EXIT_FINDINGS, EXIT_UPLOAD_GATE, EXIT_ERROR = 1, 3, 2
21
+
22
+
23
+ def _parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(prog="aictem", description="Discover repository and AI assets and send them to the CTEM platform.")
25
+ parser.add_argument("--version", action="version", version=f"aictem {__version__}")
26
+ sub = parser.add_subparsers(dest="command", required=True)
27
+ d = sub.add_parser("discover", help="inventory a directory tree", description="Inventory a repository: dependencies, container images, cloud "
28
+ "resources, CI pipelines, secrets, and AI assets (providers, frameworks, agents, RAG, MCP servers, models).")
29
+ d.add_argument("path", nargs="?", default=".", help="directory to scan (default: current directory)")
30
+ d.add_argument("-f", "--format", choices=("table", "json", "sarif"), default="table", help="output format (json is the upload format)")
31
+ d.add_argument("-o", "--output", help="write the report to a file instead of standard output")
32
+ d.add_argument("--approved-providers", help="comma-separated AI provider keys; others are reported as shadow AI (for example openai,anthropic)")
33
+ d.add_argument("--no-secrets", action="store_true", help="skip the committed-credential scan")
34
+ d.add_argument("--vulns", action="store_true", help="look up known vulnerabilities in pinned packages at osv.dev (sends names and versions)")
35
+ d.add_argument("--exclude", action="append", default=[], metavar="GLOB", help="skip paths matching a glob (repeatable; also read from .aictemignore)")
36
+ d.add_argument("--max-files", type=int, default=50_000, help="stop after this many files (default 50000)")
37
+ d.add_argument("--fail-on", choices=("none", "low", "medium", "high", "critical"), default="none",
38
+ help="exit with status 1 when a finding at or above this severity is present")
39
+ d.add_argument("--all", action="store_true", help="table format: list every asset and finding")
40
+ up = d.add_argument_group("upload to the CTEM platform")
41
+ up.add_argument("--upload", action="store_true", help="send the report to the platform's CI ingestion endpoint")
42
+ up.add_argument("--url", default=os.environ.get("CTEM_URL"), help="platform address (default: $CTEM_URL)")
43
+ up.add_argument("--token-env", default="CTEM_CI_TOKEN", help="environment variable holding the pipeline token (default: CTEM_CI_TOKEN)")
44
+ up.add_argument("--gate", choices=("none", "low", "medium", "high", "critical"), default="none",
45
+ help="server-side gate: fail (exit 3) when open findings at or above this severity remain after the upload")
46
+ return parser
47
+
48
+
49
+ def main(argv: list[str] | None = None) -> int:
50
+ args = _parser().parse_args(argv)
51
+ approved = {p.strip().lower() for p in (args.approved_providers or "").split(",") if p.strip()} or None
52
+ report = scan.run(Path(args.path), approved_providers=approved, secrets_enabled=not args.no_secrets, max_files=args.max_files,
53
+ excludes=args.exclude)
54
+ if args.vulns:
55
+ from aictem import osv
56
+
57
+ try:
58
+ added = osv.enrich(report)
59
+ print(f"aictem: {added} known vulnerabilities in {report['summary']['vulnerabilities_checked']} pinned packages", file=sys.stderr)
60
+ except OSError as exc:
61
+ print(f"aictem: vulnerability lookup failed: {exc}", file=sys.stderr)
62
+
63
+ if args.format == "table" and not args.output:
64
+ output.table(report, show_all=args.all)
65
+ else:
66
+ rendered = output.to_sarif(report) if args.format == "sarif" else output.to_json(report) if args.format == "json" else None
67
+ if rendered is None: # table to a file
68
+ with open(args.output, "w", encoding="utf-8") as fh:
69
+ output.table(report, fh, show_all=True)
70
+ elif args.output:
71
+ Path(args.output).write_text(rendered, encoding="utf-8")
72
+ else:
73
+ sys.stdout.write(rendered)
74
+ if args.output:
75
+ s = report["summary"]
76
+ print(f"aictem: wrote {args.output}: {len(report['assets'])} assets ({s['ai_assets']} AI), {len(report['findings'])} findings", file=sys.stderr)
77
+
78
+ status = 0
79
+ if args.upload:
80
+ from aictem import upload
81
+
82
+ token = os.environ.get(args.token_env)
83
+ if not args.url or not token:
84
+ print(f"aictem: --upload needs --url (or CTEM_URL) and a pipeline token in ${args.token_env}", file=sys.stderr)
85
+ return EXIT_ERROR
86
+ code, body = upload.send(report, args.url, token, fail_on=args.gate)
87
+ if code >= 400 and code != 412:
88
+ print(f"aictem: upload failed (HTTP {code}): {(body.get('error') or {}).get('message') or body}", file=sys.stderr)
89
+ return EXIT_ERROR
90
+ gate = body.get("gate") or {}
91
+ print(f"aictem: uploaded ({body.get('status')}): {body.get('findings_new', 0)} new, {body.get('findings_seen', 0)} seen findings"
92
+ + (f"; gate {'passed' if gate.get('passed') else 'FAILED'} at {gate.get('threshold')}" if gate and gate.get("threshold") != "none" else ""),
93
+ file=sys.stderr)
94
+ if code == 412:
95
+ status = EXIT_UPLOAD_GATE
96
+
97
+ if args.fail_on != "none":
98
+ worst = output.worst(report)
99
+ if worst and SEVERITY_RANK[worst] >= SEVERITY_RANK[args.fail_on]:
100
+ print(f"aictem: findings at or above {args.fail_on} present (worst: {worst})", file=sys.stderr)
101
+ status = status or EXIT_FINDINGS
102
+ return status
103
+
104
+
105
+ if __name__ == "__main__":
106
+ sys.exit(main())
aictem/catalog.py ADDED
@@ -0,0 +1,313 @@
1
+ """What the detectors look for. Keys match the CTEM platform's AI signatures so uploads merge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Tech:
11
+ key: str
12
+ name: str
13
+ category: str # provider | framework | vector_db | serving | mcp | registry
14
+ pypi: tuple[str, ...] = ()
15
+ npm: tuple[str, ...] = ()
16
+ imports: tuple[str, ...] = () # Python module roots and JS/TS package specifiers
17
+ hosts: tuple[str, ...] = () # substrings of API hostnames or ports
18
+ env: tuple[str, ...] = ()
19
+ go: tuple[str, ...] = () # Go module paths
20
+ images: tuple[str, ...] = () # container image name prefixes
21
+ agentic: bool = False
22
+ rag: bool = False
23
+
24
+
25
+ T = Tech
26
+ TECHS: tuple[Tech, ...] = (
27
+ # Hosted model providers
28
+ T("openai", "OpenAI", "provider", ("openai",), ("openai",), ("openai",), ("api.openai.com",), ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
29
+ ("github.com/openai/openai-go", "github.com/sashabaranov/go-openai")),
30
+ T("anthropic", "Anthropic", "provider", ("anthropic",), ("@anthropic-ai/sdk",), ("anthropic", "@anthropic-ai/sdk"), ("api.anthropic.com",),
31
+ ("ANTHROPIC_API_KEY",), ("github.com/anthropics/anthropic-sdk-go",)),
32
+ T("google_gemini", "Google Gemini", "provider", ("google-generativeai", "google-genai"), ("@google/generative-ai", "@google/genai"),
33
+ ("google.generativeai", "google.genai", "@google/generative-ai", "@google/genai"), ("generativelanguage.googleapis.com",),
34
+ ("GEMINI_API_KEY", "GOOGLE_GENAI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"), ("google.golang.org/genai", "github.com/google/generative-ai-go")),
35
+ T("vertex_ai", "Google Vertex AI", "provider", ("google-cloud-aiplatform", "vertexai"), ("@google-cloud/vertexai",),
36
+ ("vertexai", "google.cloud.aiplatform", "@google-cloud/vertexai"), ("aiplatform.googleapis.com",)),
37
+ T("aws_bedrock", "AWS Bedrock", "provider", ("langchain-aws",), ("@aws-sdk/client-bedrock-runtime", "@aws-sdk/client-bedrock-agent-runtime"),
38
+ ("@aws-sdk/client-bedrock-runtime", "@aws-sdk/client-bedrock-agent-runtime"), ("bedrock-runtime.", "bedrock-agent-runtime."),
39
+ ("AWS_BEDROCK_MODEL_ID", "BEDROCK_MODEL_ID")),
40
+ T("azure_openai", "Azure OpenAI / AI Foundry", "provider", ("azure-ai-inference", "azure-ai-projects", "azure-ai-agents"),
41
+ ("@azure/openai", "@azure-rest/ai-inference"), ("azure.ai.inference", "azure.ai.projects", "azure.ai.agents", "@azure/openai"),
42
+ (".openai.azure.com", ".services.ai.azure.com", ".inference.ai.azure.com"), ("AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT")),
43
+ T("cohere", "Cohere", "provider", ("cohere",), ("cohere-ai",), ("cohere", "cohere-ai"), ("api.cohere.com", "api.cohere.ai"), ("COHERE_API_KEY", "CO_API_KEY")),
44
+ T("mistral", "Mistral AI", "provider", ("mistralai",), ("@mistralai/mistralai",), ("mistralai", "@mistralai/mistralai"), ("api.mistral.ai",), ("MISTRAL_API_KEY",)),
45
+ T("groq", "Groq", "provider", ("groq",), ("groq-sdk",), ("groq", "groq-sdk"), ("api.groq.com",), ("GROQ_API_KEY",)),
46
+ T("together", "Together AI", "provider", ("together",), ("together-ai",), ("together", "together-ai"), ("api.together.xyz", "api.together.ai"), ("TOGETHER_API_KEY",)),
47
+ T("fireworks", "Fireworks AI", "provider", ("fireworks-ai",), ("@fireworks-ai/sdk",), ("fireworks",), ("api.fireworks.ai",), ("FIREWORKS_API_KEY",)),
48
+ T("deepseek", "DeepSeek", "provider", (), (), (), ("api.deepseek.com",), ("DEEPSEEK_API_KEY",)),
49
+ T("xai", "xAI", "provider", ("xai-sdk",), ("@ai-sdk/xai",), ("xai_sdk",), ("api.x.ai",), ("XAI_API_KEY",)),
50
+ T("openrouter", "OpenRouter", "provider", (), ("@openrouter/ai-sdk-provider",), (), ("openrouter.ai",), ("OPENROUTER_API_KEY",)),
51
+ T("perplexity", "Perplexity", "provider", (), (), (), ("api.perplexity.ai",), ("PERPLEXITY_API_KEY", "PPLX_API_KEY")),
52
+ T("replicate", "Replicate", "provider", ("replicate",), ("replicate",), ("replicate",), ("api.replicate.com",), ("REPLICATE_API_TOKEN",)),
53
+ T("voyage", "Voyage AI", "provider", ("voyageai",), ("voyageai",), ("voyageai",), ("api.voyageai.com",), ("VOYAGE_API_KEY",)),
54
+ T("nvidia_nim", "NVIDIA NIM", "serving", (), (), (), ("integrate.api.nvidia.com", "ai.api.nvidia.com"), ("NVIDIA_API_KEY", "NGC_API_KEY"),
55
+ images=("nvcr.io/nim/",)),
56
+ # Self-hosted serving
57
+ T("ollama", "Ollama", "serving", ("ollama", "langchain-ollama"), ("ollama", "ollama-ai-provider"), ("ollama",), (":11434",), ("OLLAMA_HOST",),
58
+ ("github.com/ollama/ollama",), ("ollama/ollama",)),
59
+ T("vllm", "vLLM", "serving", ("vllm",), (), ("vllm",), (), (), images=("vllm/vllm-openai",)),
60
+ T("tgi", "Hugging Face Text Generation Inference", "serving", ("text-generation",), (), ("text_generation",), (), (),
61
+ images=("ghcr.io/huggingface/text-generation-inference",)),
62
+ T("llama_cpp", "llama.cpp", "serving", ("llama-cpp-python",), ("node-llama-cpp",), ("llama_cpp", "node-llama-cpp"), (), (),
63
+ images=("ghcr.io/ggerganov/llama.cpp", "ghcr.io/ggml-org/llama.cpp")),
64
+ T("localai", "LocalAI", "serving", (), (), (), (), (), images=("localai/localai", "quay.io/go-skynet/local-ai")),
65
+ T("open_webui", "Open WebUI", "serving", ("open-webui",), (), (), (), (), images=("ghcr.io/open-webui/open-webui",)),
66
+ # Gateways and frameworks
67
+ T("litellm", "LiteLLM gateway", "framework", ("litellm",), (), ("litellm",), (), ("LITELLM_MASTER_KEY",), images=("ghcr.io/berriai/litellm",)),
68
+ T("langchain", "LangChain", "framework",
69
+ ("langchain", "langchain-core", "langchain-community", "langgraph", "langchain-openai", "langchain-anthropic", "langchain-google-genai"),
70
+ ("langchain", "@langchain/core", "@langchain/langgraph", "@langchain/openai", "@langchain/anthropic", "@langchain/community"),
71
+ ("langchain", "langchain_core", "langchain_community", "langgraph", "langchain_openai", "langchain_anthropic",
72
+ "@langchain/core", "@langchain/langgraph", "@langchain/openai", "@langchain/anthropic", "@langchain/community"),
73
+ (), ("LANGCHAIN_API_KEY", "LANGSMITH_API_KEY"), ("github.com/tmc/langchaingo",), agentic=True),
74
+ T("claude_agent_sdk", "Claude Agent SDK", "framework", ("claude-agent-sdk", "claude-code-sdk"), ("@anthropic-ai/claude-agent-sdk", "@anthropic-ai/claude-code"),
75
+ ("claude_agent_sdk", "claude_code_sdk", "@anthropic-ai/claude-agent-sdk", "@anthropic-ai/claude-code"), agentic=True),
76
+ T("llamaindex", "LlamaIndex", "framework", ("llama-index", "llama-index-core"), ("llamaindex",), ("llama_index", "llamaindex"), (),
77
+ ("LLAMA_CLOUD_API_KEY",), rag=True),
78
+ T("haystack", "Haystack", "framework", ("haystack-ai", "farm-haystack"), (), ("haystack",), (), (), rag=True),
79
+ T("semantic_kernel", "Semantic Kernel", "framework", ("semantic-kernel",), ("semantic-kernel",), ("semantic_kernel",), agentic=True),
80
+ T("autogen", "AutoGen", "framework", ("pyautogen", "autogen", "autogen-agentchat", "autogen-core", "ag2"), (),
81
+ ("autogen", "autogen_agentchat", "autogen_core"), agentic=True),
82
+ T("deepagents", "LangChain Deep Agents", "framework", ("deepagents", "managed-deepagents"), ("deepagents",),
83
+ ("deepagents", "managed_deepagents"), agentic=True),
84
+ T("crewai", "CrewAI", "framework", ("crewai", "crewai-tools"), (), ("crewai", "crewai_tools"), agentic=True),
85
+ T("openai_agents", "OpenAI Agents SDK", "framework", ("openai-agents",), ("@openai/agents",), ("@openai/agents",), agentic=True),
86
+ T("pydantic_ai", "Pydantic AI", "framework", ("pydantic-ai", "pydantic-ai-slim"), (), ("pydantic_ai",), agentic=True),
87
+ T("smolagents", "smolagents", "framework", ("smolagents",), (), ("smolagents",), agentic=True),
88
+ T("google_adk", "Google Agent Development Kit", "framework", ("google-adk",), ("@google/adk",), ("google.adk", "@google/adk"), agentic=True),
89
+ T("strands", "Strands Agents", "framework", ("strands-agents",), (), ("strands",), agentic=True),
90
+ T("dspy", "DSPy", "framework", ("dspy", "dspy-ai"), (), ("dspy",)),
91
+ T("vercel_ai", "Vercel AI SDK", "framework", (), ("ai", "@ai-sdk/openai", "@ai-sdk/anthropic", "@ai-sdk/google"),
92
+ ("@ai-sdk/openai", "@ai-sdk/anthropic", "@ai-sdk/google", "@ai-sdk/react"), agentic=True),
93
+ T("mastra", "Mastra", "framework", (), ("@mastra/core",), ("@mastra/core",), agentic=True),
94
+ T("mcp", "Model Context Protocol SDK", "mcp", ("mcp", "fastmcp", "langchain-mcp-adapters"), ("@modelcontextprotocol/sdk", "@langchain/mcp-adapters"),
95
+ ("mcp", "fastmcp", "@modelcontextprotocol/sdk", "langchain_mcp_adapters", "@langchain/mcp-adapters"),
96
+ go=("github.com/mark3labs/mcp-go", "github.com/modelcontextprotocol/go-sdk")),
97
+ # Vector stores
98
+ T("pinecone", "Pinecone", "vector_db", ("pinecone", "pinecone-client"), ("@pinecone-database/pinecone",), ("pinecone", "@pinecone-database/pinecone"),
99
+ (".pinecone.io",), ("PINECONE_API_KEY",)),
100
+ T("chroma", "Chroma", "vector_db", ("chromadb", "langchain-chroma"), ("chromadb",), ("chromadb",), (":8000/api/v1",), ("CHROMA_HOST",),
101
+ images=("chromadb/chroma",)),
102
+ T("qdrant", "Qdrant", "vector_db", ("qdrant-client", "langchain-qdrant"), ("@qdrant/js-client-rest",), ("qdrant_client", "@qdrant/js-client-rest"),
103
+ (":6333", ".qdrant.io"), ("QDRANT_API_KEY", "QDRANT_URL"), images=("qdrant/qdrant",)),
104
+ T("weaviate", "Weaviate", "vector_db", ("weaviate-client",), ("weaviate-client", "weaviate-ts-client"), ("weaviate",),
105
+ (".weaviate.network", ".weaviate.cloud"), ("WEAVIATE_API_KEY", "WEAVIATE_URL"), images=("semitechnologies/weaviate", "cr.weaviate.io/")),
106
+ T("milvus", "Milvus", "vector_db", ("pymilvus",), ("@zilliz/milvus2-sdk-node",), ("pymilvus",), (":19530",), ("MILVUS_URI",), images=("milvusdb/milvus",)),
107
+ T("pgvector", "pgvector", "vector_db", ("pgvector",), ("pgvector",), ("pgvector",), images=("pgvector/pgvector",)),
108
+ T("faiss", "FAISS", "vector_db", ("faiss-cpu", "faiss-gpu"), (), ("faiss",)),
109
+ T("lancedb", "LanceDB", "vector_db", ("lancedb",), ("@lancedb/lancedb",), ("lancedb", "@lancedb/lancedb")),
110
+ # Model hubs and registries
111
+ T("huggingface", "Hugging Face", "registry", ("huggingface-hub", "transformers", "diffusers", "sentence-transformers"),
112
+ ("@huggingface/inference", "@huggingface/hub", "@huggingface/transformers"),
113
+ ("huggingface_hub", "transformers", "diffusers", "sentence_transformers", "@huggingface/inference", "@huggingface/transformers"),
114
+ ("huggingface.co", "api-inference.huggingface.co"), ("HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "HUGGING_FACE_HUB_TOKEN")),
115
+ T("mlflow", "MLflow", "registry", ("mlflow",), (), ("mlflow",), (), ("MLFLOW_TRACKING_URI",), images=("ghcr.io/mlflow/mlflow",)),
116
+ )
117
+ BY_KEY = {t.key: t for t in TECHS}
118
+ # Framework integration packages that also mean the named provider is used (langchain-anthropic -> Anthropic).
119
+ INTEGRATIONS = {
120
+ "claude-agent-sdk": "anthropic", "claude_agent_sdk": "anthropic", "@anthropic-ai/claude-agent-sdk": "anthropic",
121
+ "langchain-openai": "openai", "langchain-anthropic": "anthropic", "langchain-google-genai": "google_gemini",
122
+ "langchain-google-vertexai": "vertex_ai", "langchain-aws": "aws_bedrock", "langchain-mistralai": "mistral", "langchain-groq": "groq",
123
+ "langchain-cohere": "cohere", "langchain-ollama": "ollama", "langchain-together": "together", "langchain-fireworks": "fireworks",
124
+ "langchain-xai": "xai", "langchain-deepseek": "deepseek", "langchain-pinecone": "pinecone", "langchain-chroma": "chroma",
125
+ "langchain-qdrant": "qdrant", "langchain-weaviate": "weaviate", "langchain-milvus": "milvus", "langchain-postgres": "pgvector",
126
+ "llama-index-llms-openai": "openai", "llama-index-llms-anthropic": "anthropic", "llama-index-llms-ollama": "ollama",
127
+ "llama-index-embeddings-openai": "openai", "llama-index-vector-stores-chroma": "chroma", "llama-index-vector-stores-qdrant": "qdrant",
128
+ "llama-index-vector-stores-pinecone": "pinecone", "llama-index-vector-stores-postgres": "pgvector",
129
+ "@langchain/openai": "openai", "@langchain/anthropic": "anthropic", "@langchain/google-genai": "google_gemini", "@langchain/ollama": "ollama",
130
+ "@ai-sdk/openai": "openai", "@ai-sdk/anthropic": "anthropic", "@ai-sdk/google": "google_gemini", "@ai-sdk/mistral": "mistral",
131
+ "@ai-sdk/amazon-bedrock": "aws_bedrock", "@ai-sdk/azure": "azure_openai", "@ai-sdk/groq": "groq",
132
+ "langchain_openai": "openai", "langchain_anthropic": "anthropic", "langchain_google_genai": "google_gemini", "langchain_aws": "aws_bedrock",
133
+ "langchain_mistralai": "mistral", "langchain_groq": "groq", "langchain_ollama": "ollama", "langchain_chroma": "chroma",
134
+ "langchain_qdrant": "qdrant", "langchain_pinecone": "pinecone",
135
+ }
136
+ # "provider:model" identifiers used by init_chat_model and similar helpers.
137
+ MODEL_PREFIXES = {"openai": "openai", "anthropic": "anthropic", "google_genai": "google_gemini", "google_vertexai": "vertex_ai",
138
+ "google-vertex": "vertex_ai", "bedrock": "aws_bedrock", "bedrock_converse": "aws_bedrock", "mistralai": "mistral",
139
+ "groq": "groq", "ollama": "ollama", "azure_openai": "azure_openai", "cohere": "cohere", "fireworks": "fireworks",
140
+ "together": "together", "xai": "xai", "deepseek": "deepseek", "huggingface": "huggingface"}
141
+ PREFIXED_MODEL = re.compile(r"""["'](openai|anthropic|google_genai|google_vertexai|google-vertex|bedrock_converse|bedrock|mistralai|groq|ollama|"""
142
+ r"""azure_openai|cohere|fireworks|together|xai|deepseek|huggingface):(?=[\w.\-:]*[A-Za-z])([A-Za-z0-9][\w.\-:]{1,80})["']""") # not host:port
143
+ PYPI_INDEX = {p.lower(): t for t in TECHS for p in t.pypi}
144
+ NPM_INDEX = {p.lower(): t for t in TECHS for p in t.npm}
145
+ GO_INDEX = {p: t for t in TECHS for p in t.go}
146
+ IMPORT_INDEX = {i: t for t in TECHS for i in t.imports}
147
+ ENV_INDEX = {e: t for t in TECHS for e in t.env}
148
+ IMAGE_INDEX = {i: t for t in TECHS for i in t.images}
149
+ ASSET_TYPE = {"provider": "ai_provider", "serving": "ai_provider", "framework": "ai_framework", "mcp": "ai_framework",
150
+ "vector_db": "vector_database", "registry": "model_registry"}
151
+
152
+ # --- Secrets ---------------------------------------------------------------------------------------
153
+ # (rule, provider key or "", label, severity, pattern). Group 1 is a non-secret prefix kept in evidence.
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class SecretPattern:
158
+ rule: str
159
+ provider: str
160
+ label: str
161
+ severity: str
162
+ pattern: re.Pattern = field(compare=False)
163
+
164
+
165
+ S = SecretPattern
166
+ SECRET_PATTERNS: tuple[SecretPattern, ...] = (
167
+ S("anthropic_api_key", "anthropic", "Anthropic API key", "critical", re.compile(r"\b(sk-ant-(?:api|admin)\d{2}-)[A-Za-z0-9_\-]{60,}")),
168
+ S("openai_project_key", "openai", "OpenAI API key", "critical", re.compile(r"\b(sk-(?:proj|svcacct|admin)-)[A-Za-z0-9_\-]{40,}")),
169
+ S("openai_api_key", "openai", "OpenAI API key (legacy format)", "critical", re.compile(r"\b(sk-)[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}")),
170
+ S("huggingface_token", "huggingface", "Hugging Face token", "critical", re.compile(r"\b(hf_)[A-Za-z0-9]{34,}")),
171
+ # Generic Google key (Maps, Firebase web config, Gemini, ...): not evidence of an AI provider, and often public by design.
172
+ S("google_api_key", "", "Google API key", "medium", re.compile(r"\b(AIza)[0-9A-Za-z_\-]{35}\b")),
173
+ S("nvidia_api_key", "nvidia_nim", "NVIDIA API key", "critical", re.compile(r"\b(nvapi-)[A-Za-z0-9_\-]{50,}")),
174
+ S("pinecone_api_key", "pinecone", "Pinecone API key", "critical", re.compile(r"\b(pcsk_)[A-Za-z0-9_]{40,}")),
175
+ S("replicate_token", "replicate", "Replicate API token", "critical", re.compile(r"\b(r8_)[A-Za-z0-9]{37}\b")),
176
+ S("groq_api_key", "groq", "Groq API key", "critical", re.compile(r"\b(gsk_)[A-Za-z0-9]{48,}")),
177
+ S("xai_api_key", "xai", "xAI API key", "critical", re.compile(r"\b(xai-)[A-Za-z0-9]{70,}")),
178
+ S("aws_access_key", "", "AWS access key ID", "high", re.compile(r"\b((?:AKIA|ASIA))[0-9A-Z]{16}\b")),
179
+ S("github_token", "", "GitHub token", "critical", re.compile(r"\b(gh[pousr]_)[A-Za-z0-9]{36,}\b|\b(github_pat_)[A-Za-z0-9_]{60,}")),
180
+ S("gitlab_token", "", "GitLab token", "critical", re.compile(r"\b(glpat-)[A-Za-z0-9_\-]{20,}")),
181
+ S("slack_token", "", "Slack token", "high", re.compile(r"\b(xox[baprs]-)[A-Za-z0-9\-]{10,}")),
182
+ S("stripe_live_key", "", "Stripe live secret key", "critical", re.compile(r"\b((?:sk|rk)_live_)[A-Za-z0-9]{20,}")),
183
+ S("npm_token", "", "npm access token", "high", re.compile(r"\b(npm_)[A-Za-z0-9]{36}\b")),
184
+ S("sendgrid_key", "", "SendGrid API key", "high", re.compile(r"\b(SG\.)[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}\b")),
185
+ S("private_key", "", "Private key", "critical", re.compile(r"(-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----)")),
186
+ )
187
+ # Values that are clearly placeholders, not credentials.
188
+ PLACEHOLDER = re.compile(r"(x{6,}|0{8,}|your[_-]?(api[_-]?)?key|example|placeholder|changeme|dummy|<[^>]+>|\.\.\.)", re.IGNORECASE)
189
+
190
+ # --- Model artifacts --------------------------------------------------------------------------------
191
+ MODEL_EXTENSIONS = {
192
+ ".pkl": "pickle", ".pickle": "pickle", ".joblib": "pickle", ".pt": "pytorch", ".pth": "pytorch", ".ckpt": "pytorch",
193
+ ".bin": "binary", ".h5": "hdf5", ".keras": "keras", ".onnx": "onnx", ".safetensors": "safetensors", ".gguf": "gguf",
194
+ ".tflite": "tflite", ".mlmodel": "coreml",
195
+ }
196
+ MODEL_FILE_NAMES = {"saved_model.pb": "tensorflow"} # other .pb files are usually protobuf descriptors, not models
197
+ CODE_EXECUTION_FORMATS = {"pickle", "pytorch"}
198
+ MIN_MODEL_BYTES = 32 # smaller files with these extensions are not models
199
+
200
+ # --- MCP ---------------------------------------------------------------------------------------------
201
+ MCP_CONFIG_NAMES = {".mcp.json", "mcp.json", "claude_desktop_config.json", "mcp_config.json", "mcp-config.json", "mcp_settings.json",
202
+ "cline_mcp_settings.json"}
203
+ MCP_CONFIG_PARENTS = {".cursor", ".vscode", ".claude", ".windsurf", ".gemini", ".roo", ".kiro", ".amazonq", ".codex", ".continue"}
204
+
205
+ # --- Agent tools --------------------------------------------------------------------------------------
206
+ CAPABILITY_MARKERS: dict[str, re.Pattern] = {
207
+ "shell": re.compile(r"\b(shell|bash|exec(ute)?[_ ]?(command|cmd)|run[_ ]?(command|cmd|script)|terminal|subprocess)\b", re.IGNORECASE),
208
+ "code_execution": re.compile(r"\b(eval|run[_ ]?(python|code|js)|code[_ ]?interpreter|execute[_ ]?code|python[_ ]?repl)\b", re.IGNORECASE),
209
+ "filesystem_write": re.compile(r"\b(write|delete|remove|move|rename|create)[_ ]?(file|dir|directory|folder)|edit[_ ]?file\b", re.IGNORECASE),
210
+ "database_write": re.compile(r"\b(execute[_ ]?sql|run[_ ]?query|insert[_ ]?(row|record)|update[_ ]?(row|record|table)|drop[_ ]?table)\b", re.IGNORECASE),
211
+ "email_send": re.compile(r"\bsend[_ ]?(e?mail|message|sms|slack)\b", re.IGNORECASE),
212
+ "payment": re.compile(r"\b(payments?|refunds?|refund[_ ]?\w+|pay[_ ]?(invoice|bill|out|vendor)|transfer[_ ]?funds|charge[_ ]?(card|customer)|"
213
+ r"purchase|issue[_ ]?invoice|process[_ ]?payment)\b", re.IGNORECASE),
214
+ "deploy": re.compile(r"\b(deploy|release|kubectl|terraform[_ ]?apply|merge[_ ]?(pull|pr))\b", re.IGNORECASE),
215
+ "iam_admin": re.compile(r"\b(create|delete|grant|revoke|attach)[_ ]?(user|role|policy|permission|access[_ ]?key)\b", re.IGNORECASE),
216
+ "delete": re.compile(r"\b(delete|destroy|purge|wipe)[_ ]?\w*", re.IGNORECASE),
217
+ "external_http": re.compile(r"\b(fetch[_ ]?(url|page|web)|http[_ ]?request|curl|browse|web[_ ]?(search|fetch)|download)\b", re.IGNORECASE),
218
+ }
219
+ HIGH_IMPACT = {"shell", "code_execution", "filesystem_write", "database_write", "email_send", "payment", "deploy", "iam_admin", "delete",
220
+ "external_http"}
221
+
222
+ POISONING_INDICATORS: tuple[tuple[str, str, re.Pattern], ...] = (
223
+ ("instruction_override", "Text instructs the model to ignore or override other instructions",
224
+ re.compile(r"(ignore|disregard|forget|override)\s+(all\s+|any\s+)?(previous|prior|above|other|earlier|system)\s+(instructions?|rules?|prompts?)", re.IGNORECASE)),
225
+ ("concealment", "Text instructs the model to hide its behaviour from the user",
226
+ re.compile(r"(do\s+not|don'?t|never)\s+(tell|inform|mention|reveal|show|notify)\s+(this\s+)?(to\s+)?(the\s+)?(user|human)", re.IGNORECASE)),
227
+ ("imperative_block", "Description contains a directive block addressed to the model",
228
+ re.compile(r"<\s*(important|system|instructions?|secret|admin)\s*>|\[\s*(system|important)\s*\]", re.IGNORECASE)),
229
+ ("sensitive_file_access", "Description directs access to credential or key material",
230
+ re.compile(r"(~|\$HOME|/home/[^/\s]+|/root)/\.(ssh|aws|gnupg|kube)|id_rsa|/etc/(passwd|shadow)|credentials\.json", re.IGNORECASE)),
231
+ ("exfiltration", "Description directs data to an external destination or hidden parameter",
232
+ re.compile(r"(send|post|upload|forward|exfiltrate)\s+[^.]{0,60}to\s+https?://", re.IGNORECASE)),
233
+ )
234
+
235
+ # --- Infrastructure as code -------------------------------------------------------------------------
236
+ # resource type prefix -> (asset type, AI technology key or None)
237
+ # resource type -> (asset type, AI technology key or None). Types match exactly; an entry ending in "_" (Terraform) or "::"
238
+ # (CloudFormation) is a family prefix, used for AI services whose every resource is worth listing.
239
+ TERRAFORM_TYPES: tuple[tuple[str, str, str | None], ...] = (
240
+ ("aws_sagemaker_endpoint", "llm_endpoint", None), ("aws_sagemaker_model", "ai_model", None),
241
+ ("aws_bedrockagent_agent", "ai_agent", "aws_bedrock"), ("aws_bedrockagent_knowledge_base", "rag_application", "aws_bedrock"),
242
+ ("aws_bedrock_", "cloud_resource", "aws_bedrock"), ("aws_bedrockagent_", "cloud_resource", "aws_bedrock"),
243
+ ("google_vertex_ai_endpoint", "llm_endpoint", "vertex_ai"), ("google_vertex_ai_index", "vector_database", "vertex_ai"),
244
+ ("google_vertex_ai_", "cloud_resource", "vertex_ai"),
245
+ ("azurerm_cognitive_deployment", "llm_endpoint", "azure_openai"), ("azurerm_cognitive_account", "ai_provider", "azure_openai"),
246
+ ("azurerm_machine_learning_workspace", "model_registry", None), ("azurerm_search_service", "vector_database", None),
247
+ ("aws_s3_bucket", "storage", None), ("google_storage_bucket", "storage", None), ("azurerm_storage_account", "storage", None),
248
+ ("aws_db_instance", "database", None), ("aws_rds_cluster", "database", None), ("aws_dynamodb_table", "database", None),
249
+ ("google_sql_database_instance", "database", None), ("azurerm_postgresql_server", "database", None),
250
+ ("azurerm_postgresql_flexible_server", "database", None), ("azurerm_mssql_server", "database", None), ("azurerm_mssql_database", "database", None),
251
+ ("azurerm_cosmosdb_account", "database", None), ("aws_opensearch_domain", "database", None), ("aws_elasticache_cluster", "database", None),
252
+ ("aws_elasticache_replication_group", "database", None), ("aws_redshift_cluster", "database", None),
253
+ ("aws_lb", "load_balancer", None), ("aws_alb", "load_balancer", None), ("aws_elb", "load_balancer", None),
254
+ ("google_compute_forwarding_rule", "load_balancer", None), ("azurerm_lb", "load_balancer", None), ("azurerm_application_gateway", "load_balancer", None),
255
+ ("aws_iam_role", "identity", None), ("aws_iam_user", "identity", None), ("google_service_account", "identity", None),
256
+ ("azurerm_user_assigned_identity", "identity", None),
257
+ ("aws_instance", "host", None), ("google_compute_instance", "host", None), ("azurerm_linux_virtual_machine", "host", None),
258
+ ("azurerm_windows_virtual_machine", "host", None),
259
+ ("aws_eks_cluster", "cloud_resource", None), ("google_container_cluster", "cloud_resource", None), ("azurerm_kubernetes_cluster", "cloud_resource", None),
260
+ ("aws_lambda_function", "cloud_resource", None), ("google_cloudfunctions_function", "cloud_resource", None),
261
+ ("google_cloudfunctions2_function", "cloud_resource", None), ("google_cloud_run_service", "cloud_resource", None),
262
+ ("google_cloud_run_v2_service", "cloud_resource", None), ("aws_ecs_service", "cloud_resource", None),
263
+ ("aws_api_gateway_rest_api", "api", None), ("aws_apigatewayv2_api", "api", None),
264
+ )
265
+ CFN_TYPES: tuple[tuple[str, str, str | None], ...] = (
266
+ ("AWS::SageMaker::Endpoint", "llm_endpoint", None), ("AWS::SageMaker::Model", "ai_model", None),
267
+ ("AWS::Bedrock::Agent", "ai_agent", "aws_bedrock"), ("AWS::Bedrock::KnowledgeBase", "rag_application", "aws_bedrock"),
268
+ ("AWS::Bedrock::", "cloud_resource", "aws_bedrock"), ("AWS::S3::Bucket", "storage", None), ("AWS::RDS::DBInstance", "database", None),
269
+ ("AWS::RDS::DBCluster", "database", None), ("AWS::DynamoDB::Table", "database", None),
270
+ ("AWS::ElasticLoadBalancingV2::LoadBalancer", "load_balancer", None), ("AWS::IAM::Role", "identity", None), ("AWS::IAM::User", "identity", None),
271
+ ("AWS::EC2::Instance", "host", None), ("AWS::EKS::Cluster", "cloud_resource", None), ("AWS::Lambda::Function", "cloud_resource", None),
272
+ ("AWS::Serverless::Function", "cloud_resource", None), ("AWS::ECS::Service", "cloud_resource", None), ("AWS::ApiGateway::RestApi", "api", None),
273
+ ("AWS::ApiGatewayV2::Api", "api", None), ("AWS::OpenSearchService::Domain", "database", None),
274
+ )
275
+
276
+
277
+ def resource_type(kind: str, table) -> tuple[str, str | None] | None:
278
+ """(asset type, technology) for an IaC resource type: exact entries first, then family prefixes."""
279
+ for name, asset_type, tech in table:
280
+ if kind == name:
281
+ return asset_type, tech
282
+ for name, asset_type, tech in table:
283
+ if name.endswith(("_", "::")) and kind.startswith(name):
284
+ return asset_type, tech
285
+ return None
286
+
287
+
288
+ # Hosts whose URLs in code are documentation or package indexes, not services the code calls.
289
+ NON_SERVICE_DOMAINS = (
290
+ "github.com", "githubusercontent.com", "gitlab.com", "stackoverflow.com", "wikipedia.org", "example.com", "example.org", "example.net",
291
+ "schema.org", "w3.org", "json-schema.org", "npmjs.com", "npmjs.org", "pypi.org", "pythonhosted.org", "mozilla.org", "python.org",
292
+ "shields.io", "creativecommons.org", "opensource.org", "unpkg.com", "jsdelivr.net", "twitter.com", "x.com", "youtube.com", "medium.com",
293
+ "spdx.org", "readthedocs.io", "readthedocs.org", "apache.org", "gnu.org", "microsoft.com", "google.com", "apple.com",
294
+ )
295
+ NON_SERVICE_LABELS = ("docs", "doc", "schemas", "fonts", "img", "images", "cdn", "static", "assets", "www")
296
+
297
+
298
+ def is_reference_host(host: str) -> bool:
299
+ """True for documentation, package-index and asset hosts: URLs to them are references, not services the code calls."""
300
+ host = host.lower().split(":")[0]
301
+ if any(host == d or host.endswith("." + d) for d in NON_SERVICE_DOMAINS):
302
+ return True
303
+ return host.split(".")[0] in NON_SERVICE_LABELS
304
+
305
+
306
+ # URL path or port markers of a self-hosted inference API.
307
+ INFERENCE_MARKERS = re.compile(r"(:11434\b|/v1/chat/completions|/v1/completions|/v1/embeddings|/api/generate|/api/chat\b|/generate_stream|/v1/models)")
308
+ MODEL_NAME = re.compile(
309
+ r"""(?:model(?:_name|_id|Name|Id)?\s*[=:]\s*|"model"\s*:\s*)["']((?:gpt-|o[134]-|o[134]\b|claude-|gemini-|mistral-|mixtral|llama|codellama|"""
310
+ r"""qwen|deepseek|phi-|command-r|grok-|text-embedding-|embed-|anthropic\.|amazon\.|meta\.)[\w.:\-/]*)["']""", re.IGNORECASE)
311
+ HF_MODEL_REF = re.compile(
312
+ r"""(?:from_pretrained|hf_hub_download|snapshot_download|pipeline|SentenceTransformer|AutoModel\w*\.from_pretrained)\s*\(\s*"""
313
+ r"""(?:[^)]*?(?:repo_id|model)\s*=\s*)?["']([A-Za-z0-9][\w.\-]*/[\w.\-]+)["']""")
@@ -0,0 +1,50 @@
1
+ """Detectors read files and record assets, relationships, findings and AI usage signals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from aictem.catalog import BY_KEY, Tech
9
+ from aictem.model import Asset, Inventory, Location
10
+
11
+
12
+ @dataclass
13
+ class Signal:
14
+ """One observed use of an AI technology."""
15
+
16
+ tech: Tech
17
+ method: str # dependency | import | config | endpoint | secret | container | iac
18
+ location: Location
19
+ package: str | None = None
20
+ version: str | None = None
21
+
22
+
23
+ @dataclass
24
+ class Context:
25
+ root: Path
26
+ inventory: Inventory
27
+ repo: Asset
28
+ repo_info: dict
29
+ tracked: set[str] | None = None # git-tracked paths, when git is available
30
+ signals: list[Signal] = field(default_factory=list)
31
+ models_referenced: dict[str, set[str]] = field(default_factory=dict) # tech key -> model names
32
+ agent_files: dict[str, dict] = field(default_factory=dict) # rel path -> {frameworks, tools, flags, name, identifiers}
33
+ tool_files: dict[str, list[dict]] = field(default_factory=dict) # tools defined outside any agent definition
34
+ mcp_tool_files: dict[str, list[dict]] = field(default_factory=dict) # tool modules of MCP servers
35
+ rag_markers: dict[str, list[str]] = field(default_factory=dict) # rel path -> markers
36
+ endpoints: dict[str, Location] = field(default_factory=dict) # origin -> first location (self-hosted inference)
37
+ ci_systems: set[str] = field(default_factory=set)
38
+ languages: dict[str, int] = field(default_factory=dict)
39
+
40
+ def signal(self, key: str, method: str, file: str, line: int | None = None, package: str | None = None, version: str | None = None) -> None:
41
+ tech = BY_KEY.get(key)
42
+ if tech is not None:
43
+ self.signals.append(Signal(tech, method, Location(file, line, method), package, version))
44
+
45
+ def is_tracked(self, rel: str) -> bool | None:
46
+ return None if self.tracked is None else rel in self.tracked
47
+
48
+
49
+ def line_of(text: str, index: int) -> int:
50
+ return text.count("\n", 0, index) + 1