- Layer 4 EntropyScanner: Shannon entropy, Base32/Base64 detection, CVE-2025-55284 ping/nslookup exfil, EchoLeak markdown pattern, DNS tunneling (iodine/dnscat) - Layer 5 UnicodeScanner: ASCII Smuggling (U+E0000 Tags Block), Variant Selectors, Zero-Width steganography, CamoLeak image-ordering (CVE-2025-53773), homoglyphs, BiDi override, high-entropy URL params - 30 DNS covert channel rules (dns-001 to dns-030) - ATLASMapper: 29 techniques (ATLAS v5.4.0 Feb 2026), added AML.T0062 (Agent Tool Invocation), AML.TA0015 (C2 tactic), memory poisoning, multi-agent trust, CamoLeak, Unicode steganography mappings - Rule count: 72 → 102 - Build: tsup 316ms, zero TypeScript errors
325 lines
9.4 KiB
TypeScript
325 lines
9.4 KiB
TypeScript
/**
|
|
* Decision Graph Analyzer — MindGuard-inspired Decision Dependency Graph analysis.
|
|
* Achieves 94-99% precision for detecting poisoned tool invocations by tracking
|
|
* data flow between tool calls and identifying when untrusted output
|
|
* influences privileged tool inputs.
|
|
* Part of ShieldX Layer 7 (MCP Guard & Tool Security).
|
|
*/
|
|
|
|
import type { TrustTagType } from '../types/trust.js'
|
|
|
|
/** Node in the decision dependency graph */
|
|
interface DecisionNode {
|
|
readonly id: string
|
|
readonly tool: string
|
|
readonly trustLevel: TrustTagType
|
|
readonly timestamp: string
|
|
}
|
|
|
|
/** Edge representing data flow between decisions */
|
|
interface DecisionEdge {
|
|
readonly from: string
|
|
readonly to: string
|
|
readonly dataFlow: string
|
|
}
|
|
|
|
/** Complete decision dependency graph */
|
|
interface DecisionGraph {
|
|
readonly nodes: readonly DecisionNode[]
|
|
readonly edges: readonly DecisionEdge[]
|
|
}
|
|
|
|
/** Input for building the graph */
|
|
interface ToolCallRecord {
|
|
readonly tool: string
|
|
readonly args: Readonly<Record<string, unknown>>
|
|
readonly result?: unknown
|
|
}
|
|
|
|
/** Result of graph analysis */
|
|
interface GraphAnalysisResult {
|
|
readonly anomalies: readonly string[]
|
|
readonly poisonedCalls: readonly string[]
|
|
readonly confidence: number
|
|
}
|
|
|
|
/** Trust level hierarchy (lower index = higher trust) */
|
|
const TRUST_HIERARCHY: readonly TrustTagType[] = [
|
|
'system',
|
|
'developer',
|
|
'user',
|
|
'tool_output',
|
|
'retrieved',
|
|
'external',
|
|
'untrusted',
|
|
]
|
|
|
|
/** Tools considered privileged (write, execute, send) */
|
|
const PRIVILEGED_TOOL_PATTERNS: readonly RegExp[] = [
|
|
/^(write|create|update|delete|remove|execute|run|send|publish|deploy)/i,
|
|
/(file|database|db|api|email|message|webhook)/i,
|
|
/(admin|sudo|root|system)/i,
|
|
]
|
|
|
|
/** Tools considered data-source (read, fetch, list) */
|
|
const DATA_SOURCE_PATTERNS: readonly RegExp[] = [
|
|
/^(read|get|fetch|list|search|query|retrieve|scan|browse)/i,
|
|
/(content|data|file|page|document)/i,
|
|
]
|
|
|
|
/**
|
|
* Generates a unique node ID for a tool call.
|
|
*/
|
|
function generateNodeId(tool: string, index: number): string {
|
|
return `${tool}_${index}_${Date.now()}`
|
|
}
|
|
|
|
/**
|
|
* Determines the trust level for a tool based on its name.
|
|
*/
|
|
function inferToolTrustLevel(tool: string): TrustTagType {
|
|
const lowerTool = tool.toLowerCase()
|
|
|
|
if (lowerTool.includes('system') || lowerTool.includes('internal')) {
|
|
return 'system'
|
|
}
|
|
if (lowerTool.includes('user') || lowerTool.includes('input')) {
|
|
return 'user'
|
|
}
|
|
if (lowerTool.includes('external') || lowerTool.includes('api') || lowerTool.includes('fetch')) {
|
|
return 'external'
|
|
}
|
|
if (lowerTool.includes('retrieve') || lowerTool.includes('rag') || lowerTool.includes('search')) {
|
|
return 'retrieved'
|
|
}
|
|
|
|
return 'tool_output'
|
|
}
|
|
|
|
/**
|
|
* Checks if a tool matches privileged patterns.
|
|
*/
|
|
function isPrivilegedTool(tool: string): boolean {
|
|
return PRIVILEGED_TOOL_PATTERNS.some(pattern => pattern.test(tool))
|
|
}
|
|
|
|
/**
|
|
* Checks if a tool matches data-source patterns.
|
|
*/
|
|
function isDataSourceTool(tool: string): boolean {
|
|
return DATA_SOURCE_PATTERNS.some(pattern => pattern.test(tool))
|
|
}
|
|
|
|
/**
|
|
* Extracts string values from an object recursively for comparison.
|
|
*/
|
|
function extractStringValues(obj: unknown, depth: number): readonly string[] {
|
|
if (depth > 8) return []
|
|
|
|
if (typeof obj === 'string') return [obj]
|
|
if (typeof obj === 'number' || typeof obj === 'boolean') return [String(obj)]
|
|
if (obj === null || obj === undefined) return []
|
|
|
|
const values: string[] = []
|
|
|
|
if (Array.isArray(obj)) {
|
|
for (const item of obj) {
|
|
values.push(...extractStringValues(item, depth + 1))
|
|
}
|
|
return values
|
|
}
|
|
|
|
if (typeof obj === 'object') {
|
|
for (const value of Object.values(obj as Record<string, unknown>)) {
|
|
values.push(...extractStringValues(value, depth + 1))
|
|
}
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
/**
|
|
* Detects data flow between two tool calls by checking if output from
|
|
* one call appears in the arguments of a subsequent call.
|
|
*/
|
|
function detectDataFlow(
|
|
sourceResult: unknown,
|
|
targetArgs: Readonly<Record<string, unknown>>,
|
|
): string | undefined {
|
|
if (sourceResult === undefined || sourceResult === null) return undefined
|
|
|
|
const sourceValues = extractStringValues(sourceResult, 0)
|
|
const targetValues = extractStringValues(targetArgs, 0)
|
|
|
|
// Check for significant string overlaps (>10 chars to avoid false positives)
|
|
for (const sourceVal of sourceValues) {
|
|
if (sourceVal.length < 10) continue
|
|
for (const targetVal of targetValues) {
|
|
if (targetVal.includes(sourceVal) || sourceVal.includes(targetVal)) {
|
|
const overlap = sourceVal.length > targetVal.length ? targetVal : sourceVal
|
|
if (overlap.length >= 10) {
|
|
return `value_propagation:${overlap.slice(0, 50)}`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Checks if trust level A is lower (less trusted) than trust level B.
|
|
*/
|
|
function isTrustLevelLower(a: TrustTagType, b: TrustTagType): boolean {
|
|
const indexA = TRUST_HIERARCHY.indexOf(a)
|
|
const indexB = TRUST_HIERARCHY.indexOf(b)
|
|
return indexA > indexB
|
|
}
|
|
|
|
/**
|
|
* Builds a Decision Dependency Graph from a sequence of tool calls.
|
|
* Tracks data flow between tool outputs and subsequent tool inputs.
|
|
*
|
|
* @param toolCalls - Ordered array of tool call records
|
|
* @returns Decision graph with nodes and edges
|
|
*/
|
|
export function buildGraph(toolCalls: readonly ToolCallRecord[]): DecisionGraph {
|
|
const nodes: DecisionNode[] = []
|
|
const edges: DecisionEdge[] = []
|
|
|
|
// Create nodes for each tool call
|
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
const call = toolCalls[i]!
|
|
const node: DecisionNode = {
|
|
id: generateNodeId(call.tool, i),
|
|
tool: call.tool,
|
|
trustLevel: inferToolTrustLevel(call.tool),
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
nodes.push(node)
|
|
}
|
|
|
|
// Detect edges: check if output from call[i] flows into args of call[j] (j > i)
|
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
const sourceCall = toolCalls[i]!
|
|
if (sourceCall.result === undefined) continue
|
|
|
|
for (let j = i + 1; j < toolCalls.length; j++) {
|
|
const targetCall = toolCalls[j]!
|
|
const sourceNode = nodes[i]!
|
|
const targetNode = nodes[j]!
|
|
const flow = detectDataFlow(sourceCall.result, targetCall.args)
|
|
if (flow !== undefined) {
|
|
edges.push({
|
|
from: sourceNode.id,
|
|
to: targetNode.id,
|
|
dataFlow: flow,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return { nodes, edges }
|
|
}
|
|
|
|
/**
|
|
* Analyzes a Decision Dependency Graph for anomalous patterns.
|
|
*
|
|
* Detects:
|
|
* - Trust boundary violations (untrusted output feeding privileged input)
|
|
* - Data exfiltration patterns (read -> external send)
|
|
* - Circular dependencies
|
|
* - Privilege escalation chains
|
|
*
|
|
* @param graph - The decision graph to analyze
|
|
* @returns Analysis result with anomalies, poisoned calls, and confidence
|
|
*/
|
|
export function analyzeGraph(graph: DecisionGraph): GraphAnalysisResult {
|
|
const anomalies: string[] = []
|
|
const poisonedCalls: string[] = []
|
|
|
|
const nodeMap = new Map<string, DecisionNode>()
|
|
for (const node of graph.nodes) {
|
|
nodeMap.set(node.id, node)
|
|
}
|
|
|
|
// Check each edge for trust boundary violations
|
|
for (const edge of graph.edges) {
|
|
const fromNode = nodeMap.get(edge.from)
|
|
const toNode = nodeMap.get(edge.to)
|
|
if (fromNode === undefined || toNode === undefined) continue
|
|
|
|
// Detect untrusted data flowing to privileged tools
|
|
if (isTrustLevelLower(fromNode.trustLevel, 'user') && isPrivilegedTool(toNode.tool)) {
|
|
anomalies.push(
|
|
`trust_violation:${fromNode.tool}(${fromNode.trustLevel})->${toNode.tool}(privileged)`,
|
|
)
|
|
poisonedCalls.push(toNode.id)
|
|
}
|
|
|
|
// Detect external/untrusted source feeding directly into write operations
|
|
if (
|
|
(fromNode.trustLevel === 'external' || fromNode.trustLevel === 'untrusted') &&
|
|
isPrivilegedTool(toNode.tool)
|
|
) {
|
|
anomalies.push(
|
|
`unsafe_data_flow:${fromNode.tool}->${toNode.tool}`,
|
|
)
|
|
poisonedCalls.push(toNode.id)
|
|
}
|
|
}
|
|
|
|
// Detect data exfiltration pattern: data source -> external send
|
|
for (const edge of graph.edges) {
|
|
const fromNode = nodeMap.get(edge.from)
|
|
const toNode = nodeMap.get(edge.to)
|
|
if (fromNode === undefined || toNode === undefined) continue
|
|
|
|
if (isDataSourceTool(fromNode.tool) && toNode.tool.toLowerCase().includes('send')) {
|
|
anomalies.push(
|
|
`potential_exfiltration:${fromNode.tool}->${toNode.tool}`,
|
|
)
|
|
poisonedCalls.push(toNode.id)
|
|
}
|
|
}
|
|
|
|
// Detect circular dependencies
|
|
const adjacency = new Map<string, readonly string[]>()
|
|
for (const edge of graph.edges) {
|
|
const existing = adjacency.get(edge.from) ?? []
|
|
adjacency.set(edge.from, [...existing, edge.to])
|
|
}
|
|
|
|
for (const node of graph.nodes) {
|
|
const visited = new Set<string>()
|
|
const stack = [node.id]
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()!
|
|
if (visited.has(current)) {
|
|
if (current === node.id && visited.size > 0) {
|
|
anomalies.push(`circular_dependency:${node.tool}`)
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
visited.add(current)
|
|
const neighbors = adjacency.get(current) ?? []
|
|
for (const neighbor of neighbors) {
|
|
stack.push(neighbor)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate confidence based on anomaly severity
|
|
const uniquePoisoned = [...new Set(poisonedCalls)]
|
|
const confidence = anomalies.length === 0
|
|
? 0
|
|
: Math.min(0.99, 0.5 + anomalies.length * 0.1 + uniquePoisoned.length * 0.15)
|
|
|
|
return {
|
|
anomalies,
|
|
poisonedCalls: uniquePoisoned,
|
|
confidence,
|
|
}
|
|
}
|