toolmoat 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/clients/configs.d.ts +124 -0
- package/dist/clients/configs.js +403 -0
- package/dist/commands/approve.d.ts +15 -0
- package/dist/commands/approve.js +128 -0
- package/dist/commands/diff.d.ts +6 -0
- package/dist/commands/diff.js +114 -0
- package/dist/commands/pins.d.ts +18 -0
- package/dist/commands/pins.js +88 -0
- package/dist/commands/proxy.d.ts +24 -0
- package/dist/commands/proxy.js +144 -0
- package/dist/commands/rules.d.ts +15 -0
- package/dist/commands/rules.js +173 -0
- package/dist/commands/scan.d.ts +57 -0
- package/dist/commands/scan.js +323 -0
- package/dist/commands/unwrap.d.ts +5 -0
- package/dist/commands/unwrap.js +69 -0
- package/dist/commands/wrap.d.ts +131 -0
- package/dist/commands/wrap.js +518 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/patterns/indicators.d.ts +41 -0
- package/dist/patterns/indicators.js +85 -0
- package/dist/patterns/secrets.d.ts +20 -0
- package/dist/patterns/secrets.js +70 -0
- package/dist/proxy/audit.d.ts +53 -0
- package/dist/proxy/audit.js +79 -0
- package/dist/proxy/canonical.d.ts +13 -0
- package/dist/proxy/canonical.js +34 -0
- package/dist/proxy/diff.d.ts +22 -0
- package/dist/proxy/diff.js +124 -0
- package/dist/proxy/fingerprint.d.ts +34 -0
- package/dist/proxy/fingerprint.js +140 -0
- package/dist/proxy/firewall.d.ts +132 -0
- package/dist/proxy/firewall.js +665 -0
- package/dist/proxy/framing.d.ts +45 -0
- package/dist/proxy/framing.js +86 -0
- package/dist/proxy/lockfile.d.ts +58 -0
- package/dist/proxy/lockfile.js +258 -0
- package/dist/proxy/names.d.ts +27 -0
- package/dist/proxy/names.js +175 -0
- package/dist/proxy/policy.d.ts +72 -0
- package/dist/proxy/policy.js +86 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +237 -0
- package/dist/proxy/spawn.d.ts +51 -0
- package/dist/proxy/spawn.js +110 -0
- package/dist/report/index.d.ts +35 -0
- package/dist/report/index.js +25 -0
- package/dist/report/json.d.ts +22 -0
- package/dist/report/json.js +40 -0
- package/dist/report/markdown.d.ts +12 -0
- package/dist/report/markdown.js +87 -0
- package/dist/report/sarif.d.ts +89 -0
- package/dist/report/sarif.js +142 -0
- package/dist/report/text.d.ts +16 -0
- package/dist/report/text.js +206 -0
- package/dist/rules/code.d.ts +15 -0
- package/dist/rules/code.js +729 -0
- package/dist/rules/config.d.ts +3 -0
- package/dist/rules/config.js +2164 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +8 -0
- package/dist/rules/secrets.d.ts +2 -0
- package/dist/rules/secrets.js +108 -0
- package/dist/rules/text.d.ts +2 -0
- package/dist/rules/text.js +1698 -0
- package/dist/scan/discover.d.ts +37 -0
- package/dist/scan/discover.js +525 -0
- package/dist/scan/engine.d.ts +24 -0
- package/dist/scan/engine.js +111 -0
- package/dist/scan/ignore.d.ts +28 -0
- package/dist/scan/ignore.js +46 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +25 -0
- package/dist/util/colors.d.ts +12 -0
- package/dist/util/colors.js +24 -0
- package/dist/util/frontmatter.d.ts +20 -0
- package/dist/util/frontmatter.js +136 -0
- package/dist/util/glob.d.ts +7 -0
- package/dist/util/glob.js +44 -0
- package/dist/util/hash.d.ts +3 -0
- package/dist/util/hash.js +24 -0
- package/dist/util/jsonc.d.ts +14 -0
- package/dist/util/jsonc.js +113 -0
- package/dist/util/paths.d.ts +9 -0
- package/dist/util/paths.js +37 -0
- package/dist/util/text.d.ts +29 -0
- package/dist/util/text.js +96 -0
- package/dist/util/toml-lite.d.ts +40 -0
- package/dist/util/toml-lite.js +507 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +57 -4
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential and secret files whose contents are valuable to an attacker:
|
|
3
|
+
* SSH keys, cloud credentials, package registry tokens and so on.
|
|
4
|
+
*/
|
|
5
|
+
export const SENSITIVE_CRED_PATHS = [
|
|
6
|
+
{ id: 'ssh-private-key', name: 'SSH private key', regex: /\.ssh[/\\]id_[a-z0-9]+\b/i },
|
|
7
|
+
{ id: 'ssh-identity', name: 'SSH identity key', regex: /\.ssh[/\\][a-z0-9_.-]{0,40}(?:_rsa|_ed25519|_ecdsa|_dsa|\.pem)\b/i },
|
|
8
|
+
{ id: 'aws-credentials', name: 'AWS credentials file', regex: /\.aws[/\\]credentials\b/i },
|
|
9
|
+
{ id: 'gcloud-adc', name: 'gcloud application default credentials', regex: /application_default_credentials\.json/i },
|
|
10
|
+
{ id: 'gcloud-credentials-db', name: 'gcloud credentials database', regex: /gcloud[/\\][a-z0-9_.-]{0,30}credentials\.db/i },
|
|
11
|
+
{ id: 'azure-dir', name: 'Azure credential directory', regex: /\.azure[/\\]/i },
|
|
12
|
+
{ id: 'kube-config', name: 'Kubernetes config', regex: /\.kube[/\\]config\b/i },
|
|
13
|
+
{ id: 'docker-config', name: 'Docker config (registry auth)', regex: /\.docker[/\\]config\.json\b/i },
|
|
14
|
+
{ id: 'netrc', name: '.netrc credentials', regex: /\.netrc\b/i },
|
|
15
|
+
{ id: 'git-credentials', name: 'git stored credentials', regex: /\.git-credentials\b/i },
|
|
16
|
+
{ id: 'npmrc', name: 'npm credentials (.npmrc)', regex: /\.npmrc\b/i },
|
|
17
|
+
{ id: 'pypirc', name: 'PyPI credentials (.pypirc)', regex: /\.pypirc\b/i },
|
|
18
|
+
{ id: 'gnupg', name: 'GnuPG keyring directory', regex: /\.gnupg[/\\]/i },
|
|
19
|
+
{ id: 'etc-shadow', name: '/etc/shadow password hashes', regex: /\/etc\/shadow\b/ },
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* AI-agent and developer-tool config files that store API keys, OAuth tokens
|
|
23
|
+
* or that, once rewritten, make an injection persistent. The config scanner
|
|
24
|
+
* reuses this list for its own path matching.
|
|
25
|
+
*/
|
|
26
|
+
export const AGENT_CONFIG_PATHS = [
|
|
27
|
+
{ id: 'claude-json', name: '~/.claude.json', regex: /\.claude\.json\b/i },
|
|
28
|
+
{ id: 'claude-credentials', name: 'Claude credentials', regex: /\.claude[/\\]\.credentials\.json\b/i },
|
|
29
|
+
{ id: 'claude-settings', name: 'Claude settings', regex: /\.claude[/\\]settings(?:\.local)?\.json\b/i },
|
|
30
|
+
{ id: 'claude-desktop-config', name: 'Claude Desktop config', regex: /claude_desktop_config\.json\b/i },
|
|
31
|
+
{ id: 'codex-auth', name: 'Codex auth', regex: /\.codex[/\\]auth\.json\b/i },
|
|
32
|
+
{ id: 'codex-config', name: 'Codex config', regex: /\.codex[/\\]config\.toml\b/i },
|
|
33
|
+
{ id: 'gh-hosts', name: 'GitHub CLI hosts', regex: /gh[/\\]hosts\.yml\b/i },
|
|
34
|
+
{ id: 'openai-config', name: 'OpenAI config directory', regex: /\.config[/\\]openai\b/i },
|
|
35
|
+
{ id: 'cursor-mcp', name: 'Cursor MCP config', regex: /\.cursor[/\\]mcp\.json\b/i },
|
|
36
|
+
{ id: 'gemini-settings', name: 'Gemini settings', regex: /\.gemini[/\\]settings\.json\b/i },
|
|
37
|
+
{ id: 'mcp-json', name: '.mcp.json', regex: /(?:^|[/\\])\.mcp\.json\b/i },
|
|
38
|
+
{ id: 'claude-md', name: 'CLAUDE.md agent instructions', regex: /\bCLAUDE\.md\b/ },
|
|
39
|
+
{ id: 'agents-md', name: 'AGENTS.md agent instructions', regex: /\bAGENTS\.md\b/ },
|
|
40
|
+
{ id: 'claude-skills-dir', name: '~/.claude/skills', regex: /\.claude[/\\]skills\b/i },
|
|
41
|
+
{ id: 'claude-plugins-dir', name: '~/.claude/plugins', regex: /\.claude[/\\]plugins\b/i },
|
|
42
|
+
{ id: 'git-hooks-dir', name: '.git/hooks', regex: /\.git[/\\]hooks\b/i },
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* Hosts and URL shapes used to exfiltrate stolen data: webhook sinks, request
|
|
46
|
+
* bins, tunnels, out-of-band interaction services and anonymous file drops.
|
|
47
|
+
*/
|
|
48
|
+
export const EXFIL_HOSTS = [
|
|
49
|
+
{ id: 'discord-webhook', name: 'Discord webhook', regex: /discord(?:app)?\.com\/api\/webhooks\//i, severity: 'high' },
|
|
50
|
+
{ id: 'telegram-bot', name: 'Telegram bot API', regex: /api\.telegram\.org\/bot[^\s/"']{0,80}\/send(?:Message|Document|Photo)/i, severity: 'high' },
|
|
51
|
+
{ id: 'webhook-site', name: 'webhook.site sink', regex: /\bwebhook\.site\//i, severity: 'high' },
|
|
52
|
+
{ id: 'requestbin', name: 'RequestBin', regex: /\brequestbin\.[a-z]{2,10}\b/i, severity: 'high' },
|
|
53
|
+
{ id: 'pipedream', name: 'Pipedream endpoint', regex: /\b[a-z0-9-]{1,40}\.m\.pipedream\.net\b/i, severity: 'high' },
|
|
54
|
+
{ id: 'ngrok', name: 'ngrok tunnel', regex: /\b[a-z0-9-]{1,40}\.ngrok(?:-free)?\.(?:app|io)\b/i, severity: 'high' },
|
|
55
|
+
{ id: 'cloudflare-tunnel', name: 'Cloudflare quick tunnel', regex: /\b[a-z0-9-]{1,60}\.trycloudflare\.com\b/i, severity: 'high' },
|
|
56
|
+
{ id: 'interactsh', name: 'interact.sh OOB', regex: /\binteract\.sh\b/i, severity: 'high' },
|
|
57
|
+
{ id: 'oast', name: 'OAST OOB domain', regex: /\b[a-z0-9.-]{1,60}\.oast\.(?:pro|live|site|fun|me|online)\b/i, severity: 'high' },
|
|
58
|
+
{ id: 'burp-collaborator', name: 'Burp Collaborator', regex: /\b[a-z0-9.-]{0,60}burpcollaborator\.net\b/i, severity: 'high' },
|
|
59
|
+
{ id: 'pastebin-api', name: 'Pastebin API', regex: /pastebin\.com\/(?:api|raw)\//i, severity: 'high' },
|
|
60
|
+
{ id: 'transfer-sh', name: 'transfer.sh drop', regex: /(?:https?:\/\/)?transfer\.sh\/[A-Za-z0-9]/i, severity: 'high' },
|
|
61
|
+
{ id: 'file-io', name: 'file.io drop', regex: /(?:https?:\/\/)?file\.io\/[A-Za-z0-9]/i, severity: 'high' },
|
|
62
|
+
{ id: '0x0', name: '0x0.st drop', regex: /\b0x0\.st\b/i, severity: 'high' },
|
|
63
|
+
{ id: 'temp-sh', name: 'temp.sh drop', regex: /(?:https?:\/\/)?temp\.sh\/[A-Za-z0-9]/i, severity: 'high' },
|
|
64
|
+
{ id: 'termbin', name: 'termbin.com drop', regex: /\btermbin\.com\b/i, severity: 'high' },
|
|
65
|
+
{ id: 'gofile', name: 'gofile.io drop', regex: /\bgofile\.io\b/i, severity: 'high' },
|
|
66
|
+
{ id: 'bashupload', name: 'bashupload.com drop', regex: /\bbashupload\.com\b/i, severity: 'high' },
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Living-off-the-land binaries abused to download and run payloads on Windows.
|
|
70
|
+
* Exported as plain names for other modules that want to match them.
|
|
71
|
+
*/
|
|
72
|
+
export const LOLBINS = [
|
|
73
|
+
'certutil',
|
|
74
|
+
'bitsadmin',
|
|
75
|
+
'mshta',
|
|
76
|
+
'regsvr32',
|
|
77
|
+
'rundll32',
|
|
78
|
+
'wmic',
|
|
79
|
+
'installutil',
|
|
80
|
+
'msbuild',
|
|
81
|
+
'cscript',
|
|
82
|
+
'wscript',
|
|
83
|
+
'curl',
|
|
84
|
+
'certreq',
|
|
85
|
+
];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential patterns shared by the TM-SEC rules, report redaction and the
|
|
3
|
+
* proxy's egress guard. Every pattern must be linear-time: no nested
|
|
4
|
+
* quantifiers and bounded repetition only.
|
|
5
|
+
*/
|
|
6
|
+
export interface SecretPattern {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
regex: RegExp;
|
|
10
|
+
}
|
|
11
|
+
export declare const SECRET_PATTERNS: readonly SecretPattern[];
|
|
12
|
+
export interface SecretHit {
|
|
13
|
+
patternId: string;
|
|
14
|
+
name: string;
|
|
15
|
+
index: number;
|
|
16
|
+
length: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function findSecrets(text: string, limit?: number): SecretHit[];
|
|
19
|
+
/** Keeps the first 4 characters of each secret so a reader can recognise it, and masks the rest. */
|
|
20
|
+
export declare function redactSecrets(text: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const SECRET_PATTERNS = [
|
|
2
|
+
{ id: 'private-key', name: 'Private key block', regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/ },
|
|
3
|
+
{ id: 'aws-access-key', name: 'AWS access key id', regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
4
|
+
{ id: 'github-token', name: 'GitHub token', regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}\b/ },
|
|
5
|
+
{ id: 'github-fine-grained', name: 'GitHub fine-grained token', regex: /\bgithub_pat_[A-Za-z0-9_]{22,255}\b/ },
|
|
6
|
+
{ id: 'anthropic-key', name: 'Anthropic API key', regex: /\bsk-ant-(?:api|admin|oat)[0-9]{2}-[A-Za-z0-9_-]{40,255}/ },
|
|
7
|
+
{ id: 'openai-key', name: 'OpenAI API key', regex: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,255}T3BlbkFJ[A-Za-z0-9_-]{20,255}\b/ },
|
|
8
|
+
{ id: 'openai-project-key', name: 'OpenAI project key', regex: /\bsk-proj-[A-Za-z0-9_-]{40,255}\b/ },
|
|
9
|
+
{ id: 'google-api-key', name: 'Google API key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
10
|
+
{ id: 'slack-token', name: 'Slack token', regex: /\bxox[abposr]-[A-Za-z0-9-]{10,255}\b/ },
|
|
11
|
+
{ id: 'slack-webhook', name: 'Slack webhook URL', regex: /https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9_]{6,20}\/B[A-Za-z0-9_]{6,20}\/[A-Za-z0-9_]{20,40}/ },
|
|
12
|
+
{ id: 'stripe-live-key', name: 'Stripe live key', regex: /\b(?:sk|rk)_live_[A-Za-z0-9]{20,255}\b/ },
|
|
13
|
+
{ id: 'npm-token', name: 'npm access token', regex: /\bnpm_[A-Za-z0-9]{36}\b/ },
|
|
14
|
+
{ id: 'huggingface-token', name: 'Hugging Face token', regex: /\bhf_[A-Za-z0-9]{34,40}\b/ },
|
|
15
|
+
{ id: 'gitlab-token', name: 'GitLab token', regex: /\bglpat-[A-Za-z0-9_-]{20,64}\b/ },
|
|
16
|
+
{ id: 'telegram-bot-token', name: 'Telegram bot token', regex: /\b[0-9]{8,10}:AA[A-Za-z0-9_-]{33}\b/ },
|
|
17
|
+
{ id: 'google-oauth-secret', name: 'Google OAuth client secret', regex: /\bGOCSPX-[A-Za-z0-9_-]{20,40}\b/ },
|
|
18
|
+
{ id: 'groq-key', name: 'Groq API key', regex: /\bgsk_[A-Za-z0-9]{40,60}\b/ },
|
|
19
|
+
{ id: 'xai-key', name: 'xAI API key', regex: /\bxai-[A-Za-z0-9]{40,120}\b/ },
|
|
20
|
+
{ id: 'digitalocean-token', name: 'DigitalOcean token', regex: /\bdo[oprt]_v1_[a-f0-9]{64}\b/ },
|
|
21
|
+
{ id: 'supabase-token', name: 'Supabase access token', regex: /\bsbp_[a-f0-9]{40}\b/ },
|
|
22
|
+
{ id: 'sendgrid-key', name: 'SendGrid API key', regex: /\bSG\.[A-Za-z0-9_-]{16,32}\.[A-Za-z0-9_-]{32,64}\b/ },
|
|
23
|
+
{ id: 'mailgun-key', name: 'Mailgun API key', regex: /\bkey-[0-9a-f]{32}\b/ },
|
|
24
|
+
{ id: 'shopify-token', name: 'Shopify access token', regex: /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/ },
|
|
25
|
+
{ id: 'twilio-sid', name: 'Twilio account SID', regex: /\bAC[0-9a-fA-F]{32}\b/ },
|
|
26
|
+
{ id: 'twilio-api-key', name: 'Twilio API key SID', regex: /\bSK[0-9a-fA-F]{32}\b/ },
|
|
27
|
+
{ id: 'azure-storage-key', name: 'Azure storage account key', regex: /AccountKey=[A-Za-z0-9+/]{86}==/ },
|
|
28
|
+
{ id: 'discord-bot-token', name: 'Discord bot token', regex: /\b[MNO][A-Za-z0-9_-]{23,25}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,40}\b/ },
|
|
29
|
+
{ id: 'jwt', name: 'JSON Web Token', regex: /\beyJ[A-Za-z0-9_-]{10,2000}\.eyJ[A-Za-z0-9_-]{10,4000}\.[A-Za-z0-9_-]{10,2000}/ },
|
|
30
|
+
];
|
|
31
|
+
export function findSecrets(text, limit = 50) {
|
|
32
|
+
const hits = [];
|
|
33
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
34
|
+
const re = new RegExp(pattern.regex.source, pattern.regex.flags.includes('g') ? pattern.regex.flags : `${pattern.regex.flags}g`);
|
|
35
|
+
let match;
|
|
36
|
+
while (hits.length < limit && (match = re.exec(text)) !== null) {
|
|
37
|
+
hits.push({ patternId: pattern.id, name: pattern.name, index: match.index, length: match[0].length });
|
|
38
|
+
if (match[0].length === 0)
|
|
39
|
+
re.lastIndex++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
hits.sort((a, b) => a.index - b.index);
|
|
43
|
+
return dropOverlaps(hits);
|
|
44
|
+
}
|
|
45
|
+
function dropOverlaps(sorted) {
|
|
46
|
+
const out = [];
|
|
47
|
+
let end = -1;
|
|
48
|
+
for (const hit of sorted) {
|
|
49
|
+
if (hit.index >= end) {
|
|
50
|
+
out.push(hit);
|
|
51
|
+
end = hit.index + hit.length;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** Keeps the first 4 characters of each secret so a reader can recognise it, and masks the rest. */
|
|
57
|
+
export function redactSecrets(text) {
|
|
58
|
+
const hits = findSecrets(text, 1000);
|
|
59
|
+
if (hits.length === 0)
|
|
60
|
+
return text;
|
|
61
|
+
let out = '';
|
|
62
|
+
let cursor = 0;
|
|
63
|
+
for (const hit of hits) {
|
|
64
|
+
out += text.slice(cursor, hit.index);
|
|
65
|
+
const secret = text.slice(hit.index, hit.index + hit.length);
|
|
66
|
+
out += hit.patternId === 'private-key' ? secret : `${secret.slice(0, 4)}${'*'.repeat(8)}[redacted]`;
|
|
67
|
+
cursor = hit.index + hit.length;
|
|
68
|
+
}
|
|
69
|
+
return out + text.slice(cursor);
|
|
70
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FindingSummary } from './lockfile.js';
|
|
2
|
+
export type AuditEventName = 'start' | 'tools/list' | 'pinned' | 'blocked' | 'changed' | 'new' | 'flagged' | 'call' | 'secret' | 'injection' | 'server-request' | 'exit';
|
|
3
|
+
export interface SecretSummary {
|
|
4
|
+
patternId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
/** One JSONL line of the audit log. Argument values appear only with --log-args, and then redacted. */
|
|
8
|
+
export interface AuditEvent {
|
|
9
|
+
ts: string;
|
|
10
|
+
server: string;
|
|
11
|
+
event: AuditEventName;
|
|
12
|
+
tool?: string;
|
|
13
|
+
decision?: string;
|
|
14
|
+
findings?: Array<FindingSummary | SecretSummary>;
|
|
15
|
+
/** Size of the JSON-encoded tool arguments. */
|
|
16
|
+
argBytes?: number;
|
|
17
|
+
args?: unknown;
|
|
18
|
+
/** Method of a server-to-client request. */
|
|
19
|
+
method?: string;
|
|
20
|
+
/** Short human-readable context, such as a block reason or a tool count. */
|
|
21
|
+
detail?: string;
|
|
22
|
+
/** Exit code of the server process. */
|
|
23
|
+
code?: number;
|
|
24
|
+
}
|
|
25
|
+
export type AuditInput = Omit<AuditEvent, 'ts' | 'server'>;
|
|
26
|
+
/** Where the firewall reports events. Implementations must never throw. */
|
|
27
|
+
export interface AuditSink {
|
|
28
|
+
write(event: AuditInput): void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Append-only JSONL audit log written through a non-blocking stream. Any
|
|
32
|
+
* failure (unwritable directory, full disk) disables logging with one
|
|
33
|
+
* warning instead of disturbing the proxy.
|
|
34
|
+
*/
|
|
35
|
+
export declare class AuditLog implements AuditSink {
|
|
36
|
+
readonly file: string;
|
|
37
|
+
private readonly server;
|
|
38
|
+
private readonly warn;
|
|
39
|
+
private stream;
|
|
40
|
+
private failed;
|
|
41
|
+
constructor(file: string, server: string, warn?: (message: string) => void);
|
|
42
|
+
write(event: AuditInput): void;
|
|
43
|
+
/** Flushes and closes the stream. Resolves even when the log is broken or slow. */
|
|
44
|
+
close(timeoutMs?: number): Promise<void>;
|
|
45
|
+
private fail;
|
|
46
|
+
}
|
|
47
|
+
/** An audit sink that keeps events in memory, for tests and embedding. */
|
|
48
|
+
export declare class MemoryAudit implements AuditSink {
|
|
49
|
+
readonly events: AuditInput[];
|
|
50
|
+
write(event: AuditInput): void;
|
|
51
|
+
}
|
|
52
|
+
/** Arguments as they may appear in the log with --log-args: secrets redacted, size capped. */
|
|
53
|
+
export declare function loggableArgs(argsJson: string): unknown;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { redactSecrets } from '../patterns/secrets.js';
|
|
4
|
+
const MAX_LOGGED_ARGS = 16 * 1024;
|
|
5
|
+
/**
|
|
6
|
+
* Append-only JSONL audit log written through a non-blocking stream. Any
|
|
7
|
+
* failure (unwritable directory, full disk) disables logging with one
|
|
8
|
+
* warning instead of disturbing the proxy.
|
|
9
|
+
*/
|
|
10
|
+
export class AuditLog {
|
|
11
|
+
file;
|
|
12
|
+
server;
|
|
13
|
+
warn;
|
|
14
|
+
stream;
|
|
15
|
+
failed = false;
|
|
16
|
+
constructor(file, server, warn = () => { }) {
|
|
17
|
+
this.file = file;
|
|
18
|
+
this.server = server;
|
|
19
|
+
this.warn = warn;
|
|
20
|
+
try {
|
|
21
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
22
|
+
this.stream = fs.createWriteStream(file, { flags: 'a' });
|
|
23
|
+
this.stream.on('error', (error) => this.fail(error));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
this.fail(error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
write(event) {
|
|
30
|
+
if (this.failed || !this.stream)
|
|
31
|
+
return;
|
|
32
|
+
try {
|
|
33
|
+
this.stream.write(`${JSON.stringify({ ts: new Date().toISOString(), server: this.server, ...event })}\n`);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
this.fail(error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Flushes and closes the stream. Resolves even when the log is broken or slow. */
|
|
40
|
+
close(timeoutMs = 2000) {
|
|
41
|
+
const stream = this.stream;
|
|
42
|
+
this.stream = undefined;
|
|
43
|
+
if (!stream || this.failed)
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
47
|
+
timer.unref();
|
|
48
|
+
stream.end(() => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
fail(error) {
|
|
55
|
+
if (this.failed)
|
|
56
|
+
return;
|
|
57
|
+
this.failed = true;
|
|
58
|
+
this.warn(`audit log disabled, cannot write ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** An audit sink that keeps events in memory, for tests and embedding. */
|
|
62
|
+
export class MemoryAudit {
|
|
63
|
+
events = [];
|
|
64
|
+
write(event) {
|
|
65
|
+
this.events.push(event);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Arguments as they may appear in the log with --log-args: secrets redacted, size capped. */
|
|
69
|
+
export function loggableArgs(argsJson) {
|
|
70
|
+
const redacted = redactSecrets(argsJson);
|
|
71
|
+
if (redacted.length > MAX_LOGGED_ARGS)
|
|
72
|
+
return `${redacted.slice(0, MAX_LOGGED_ARGS)}…[truncated]`;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(redacted);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return redacted;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON for hashing and comparing tool definitions that come from an
|
|
3
|
+
* untrusted server. Same output as sorting keys and calling JSON.stringify,
|
|
4
|
+
* except that a key named "__proto__" is kept: assigning to it on a plain
|
|
5
|
+
* object sets the prototype instead of creating a property, so a schema
|
|
6
|
+
* carrying one would hash the same whatever it contained under that key, while
|
|
7
|
+
* the client still receives it.
|
|
8
|
+
*/
|
|
9
|
+
export declare function canonicalJson(value: unknown): string;
|
|
10
|
+
/** The value for an own key only, so names such as "constructor" or "toString" are never mistaken for entries. */
|
|
11
|
+
export declare function getOwn<T>(record: Readonly<Record<string, T>>, key: string): T | undefined;
|
|
12
|
+
/** Sets an own property even for the key "__proto__" (plain assignment would replace the prototype). */
|
|
13
|
+
export declare function setOwn<T>(record: Record<string, T>, key: string, value: T): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON for hashing and comparing tool definitions that come from an
|
|
3
|
+
* untrusted server. Same output as sorting keys and calling JSON.stringify,
|
|
4
|
+
* except that a key named "__proto__" is kept: assigning to it on a plain
|
|
5
|
+
* object sets the prototype instead of creating a property, so a schema
|
|
6
|
+
* carrying one would hash the same whatever it contained under that key, while
|
|
7
|
+
* the client still receives it.
|
|
8
|
+
*/
|
|
9
|
+
export function canonicalJson(value) {
|
|
10
|
+
return JSON.stringify(sortKeys(value));
|
|
11
|
+
}
|
|
12
|
+
function sortKeys(value) {
|
|
13
|
+
if (Array.isArray(value))
|
|
14
|
+
return value.map(sortKeys);
|
|
15
|
+
if (value !== null && typeof value === 'object') {
|
|
16
|
+
const source = value;
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const key of Object.keys(source).sort()) {
|
|
19
|
+
const child = source[key];
|
|
20
|
+
if (child !== undefined)
|
|
21
|
+
Object.defineProperty(out, key, { value: sortKeys(child), enumerable: true, writable: true, configurable: true });
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
/** The value for an own key only, so names such as "constructor" or "toString" are never mistaken for entries. */
|
|
28
|
+
export function getOwn(record, key) {
|
|
29
|
+
return Object.hasOwn(record, key) ? record[key] : undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Sets an own property even for the key "__proto__" (plain assignment would replace the prototype). */
|
|
32
|
+
export function setOwn(record, key, value) {
|
|
33
|
+
Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type DiffOp = {
|
|
2
|
+
type: 'same' | 'removed' | 'added';
|
|
3
|
+
line: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Line diff based on a longest common subsequence. Common prefix and suffix
|
|
7
|
+
* are stripped first, so typical edits (an appended block in a description)
|
|
8
|
+
* cost almost nothing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function diffLines(before: readonly string[], after: readonly string[]): DiffOp[];
|
|
11
|
+
export interface Hunk {
|
|
12
|
+
ops: DiffOp[];
|
|
13
|
+
}
|
|
14
|
+
/** Groups a diff into hunks of changes with `context` unchanged lines around each. */
|
|
15
|
+
export declare function hunks(ops: readonly DiffOp[], context?: number): Hunk[];
|
|
16
|
+
/**
|
|
17
|
+
* Pretty-prints a tool definition for review: canonical key order, two-space
|
|
18
|
+
* indent, and strings that contain newlines spread over several lines so a
|
|
19
|
+
* diff shows exactly which lines of a description were added. The output is
|
|
20
|
+
* for humans and is not meant to be parsed back as JSON.
|
|
21
|
+
*/
|
|
22
|
+
export declare function prettyDefinition(value: unknown): string[];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { canonicalJson } from './canonical.js';
|
|
2
|
+
/** Above this many LCS cells the middle of the diff is shown as a plain replace, keeping memory bounded. */
|
|
3
|
+
const MAX_LCS_CELLS = 4_000_000;
|
|
4
|
+
/**
|
|
5
|
+
* Line diff based on a longest common subsequence. Common prefix and suffix
|
|
6
|
+
* are stripped first, so typical edits (an appended block in a description)
|
|
7
|
+
* cost almost nothing.
|
|
8
|
+
*/
|
|
9
|
+
export function diffLines(before, after) {
|
|
10
|
+
let start = 0;
|
|
11
|
+
while (start < before.length && start < after.length && before[start] === after[start])
|
|
12
|
+
start++;
|
|
13
|
+
let endA = before.length;
|
|
14
|
+
let endB = after.length;
|
|
15
|
+
while (endA > start && endB > start && before[endA - 1] === after[endB - 1]) {
|
|
16
|
+
endA--;
|
|
17
|
+
endB--;
|
|
18
|
+
}
|
|
19
|
+
const ops = before.slice(0, start).map((line) => ({ type: 'same', line }));
|
|
20
|
+
ops.push(...diffMiddle(before.slice(start, endA), after.slice(start, endB)));
|
|
21
|
+
ops.push(...before.slice(endA).map((line) => ({ type: 'same', line })));
|
|
22
|
+
return ops;
|
|
23
|
+
}
|
|
24
|
+
function diffMiddle(a, b) {
|
|
25
|
+
const n = a.length;
|
|
26
|
+
const m = b.length;
|
|
27
|
+
if (n === 0 || m === 0 || (n + 1) * (m + 1) > MAX_LCS_CELLS) {
|
|
28
|
+
return [...a.map((line) => ({ type: 'removed', line })), ...b.map((line) => ({ type: 'added', line }))];
|
|
29
|
+
}
|
|
30
|
+
const width = m + 1;
|
|
31
|
+
const table = new Uint32Array((n + 1) * width);
|
|
32
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
33
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
34
|
+
table[i * width + j] = a[i] === b[j] ? table[(i + 1) * width + j + 1] + 1 : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const ops = [];
|
|
38
|
+
let i = 0;
|
|
39
|
+
let j = 0;
|
|
40
|
+
while (i < n && j < m) {
|
|
41
|
+
if (a[i] === b[j]) {
|
|
42
|
+
ops.push({ type: 'same', line: a[i] });
|
|
43
|
+
i++;
|
|
44
|
+
j++;
|
|
45
|
+
}
|
|
46
|
+
else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
|
47
|
+
ops.push({ type: 'removed', line: a[i++] });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
ops.push({ type: 'added', line: b[j++] });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
while (i < n)
|
|
54
|
+
ops.push({ type: 'removed', line: a[i++] });
|
|
55
|
+
while (j < m)
|
|
56
|
+
ops.push({ type: 'added', line: b[j++] });
|
|
57
|
+
return ops;
|
|
58
|
+
}
|
|
59
|
+
/** Groups a diff into hunks of changes with `context` unchanged lines around each. */
|
|
60
|
+
export function hunks(ops, context = 3) {
|
|
61
|
+
const changed = ops.map((op, index) => (op.type === 'same' ? -1 : index)).filter((index) => index !== -1);
|
|
62
|
+
const out = [];
|
|
63
|
+
let current;
|
|
64
|
+
for (const index of changed) {
|
|
65
|
+
const from = Math.max(0, index - context);
|
|
66
|
+
const to = Math.min(ops.length - 1, index + context);
|
|
67
|
+
if (current && from <= current.to + 1)
|
|
68
|
+
current.to = to;
|
|
69
|
+
else {
|
|
70
|
+
if (current)
|
|
71
|
+
out.push({ ops: ops.slice(current.from, current.to + 1) });
|
|
72
|
+
current = { from, to };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (current)
|
|
76
|
+
out.push({ ops: ops.slice(current.from, current.to + 1) });
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Pretty-prints a tool definition for review: canonical key order, two-space
|
|
81
|
+
* indent, and strings that contain newlines spread over several lines so a
|
|
82
|
+
* diff shows exactly which lines of a description were added. The output is
|
|
83
|
+
* for humans and is not meant to be parsed back as JSON.
|
|
84
|
+
*/
|
|
85
|
+
export function prettyDefinition(value) {
|
|
86
|
+
const normalized = value === undefined ? null : JSON.parse(canonicalJson(value));
|
|
87
|
+
const lines = [];
|
|
88
|
+
emit(normalized, '', '', '', lines);
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
function emit(value, indent, prefix, suffix, lines) {
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
if (value.length === 0) {
|
|
94
|
+
lines.push(`${indent}${prefix}[]${suffix}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
lines.push(`${indent}${prefix}[`);
|
|
98
|
+
value.forEach((item, index) => emit(item, `${indent} `, '', index < value.length - 1 ? ',' : '', lines));
|
|
99
|
+
lines.push(`${indent}]${suffix}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (value !== null && typeof value === 'object') {
|
|
103
|
+
const entries = Object.entries(value);
|
|
104
|
+
if (entries.length === 0) {
|
|
105
|
+
lines.push(`${indent}${prefix}{}${suffix}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
lines.push(`${indent}${prefix}{`);
|
|
109
|
+
entries.forEach(([key, child], index) => emit(child, `${indent} `, `${JSON.stringify(key)}: `, index < entries.length - 1 ? ',' : '', lines));
|
|
110
|
+
lines.push(`${indent}}${suffix}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (typeof value === 'string' && value.includes('\n')) {
|
|
114
|
+
const parts = value.split('\n');
|
|
115
|
+
parts.forEach((part, index) => {
|
|
116
|
+
const body = JSON.stringify(part).slice(1, -1);
|
|
117
|
+
const open = index === 0 ? `${indent}${prefix}"` : `${indent} `;
|
|
118
|
+
const close = index === parts.length - 1 ? `"${suffix}` : '\\n';
|
|
119
|
+
lines.push(`${open}${body}${close}`);
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
lines.push(`${indent}${prefix}${JSON.stringify(value)}${suffix}`);
|
|
124
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { EngineOptions } from '../scan/engine.js';
|
|
2
|
+
import type { Finding, Rule } from '../types.js';
|
|
3
|
+
/** The parts of an MCP tool that shape what the model sees and does. Only these are pinned. */
|
|
4
|
+
export declare const FINGERPRINT_FIELDS: readonly ['name', 'title', 'description', 'inputSchema', 'outputSchema', 'annotations'];
|
|
5
|
+
/** A tool definition reduced to the pinned fields, with keys in canonical order. */
|
|
6
|
+
export type ToolDefinition = Record<string, unknown>;
|
|
7
|
+
/** Picks the pinned fields of a raw tool object and normalizes key order so equal tools compare equal. */
|
|
8
|
+
export declare function toolDefinition(tool: Record<string, unknown>): ToolDefinition;
|
|
9
|
+
/** sha256 of the canonical JSON of the pinned fields: stable under key reordering, sensitive to any other change. */
|
|
10
|
+
export declare function fingerprint(definition: ToolDefinition): string;
|
|
11
|
+
export interface ModelText {
|
|
12
|
+
text: string;
|
|
13
|
+
/** True when the definition was too big to read to the end. */
|
|
14
|
+
truncated: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Collects every string of a tool definition that ends up in the model's
|
|
18
|
+
* context: name, title, description, annotation titles and, inside the input
|
|
19
|
+
* and output schemas, every string except structural keywords (type, format,
|
|
20
|
+
* pattern, $ref...): descriptions, titles, comments, vendor extensions,
|
|
21
|
+
* parameter names, defaults, enums and examples, at any depth. Each piece goes
|
|
22
|
+
* on its own paragraph.
|
|
23
|
+
*/
|
|
24
|
+
export declare function modelVisibleText(definition: ToolDefinition): string;
|
|
25
|
+
/** Like modelVisibleText, and says when the definition was too big to read completely. Walks iteratively, so depth is no limit. */
|
|
26
|
+
export declare function collectModelText(definition: ToolDefinition): ModelText;
|
|
27
|
+
/** Synthetic path under which a live tool is reported, e.g. mcp://github/create_issue. */
|
|
28
|
+
export declare function toolPath(server: string, tool: string): string;
|
|
29
|
+
/** Runs the scanner's tool-definition rules over a tool's model-visible text. */
|
|
30
|
+
export declare function scanTool(server: string, definition: ToolDefinition, rules: readonly Rule[], onRuleError?: EngineOptions['onRuleError']): Finding[];
|
|
31
|
+
/** Runs the same rules over text a tool returned, which can carry injected instructions too. */
|
|
32
|
+
export declare function scanToolOutput(server: string, tool: string, text: string, rules: readonly Rule[], onRuleError?: EngineOptions['onRuleError']): Finding[];
|
|
33
|
+
/** Scans the `instructions` of a server's initialize result, which many clients add to the model's context. */
|
|
34
|
+
export declare function scanServerInstructions(server: string, text: string, rules: readonly Rule[], onRuleError?: EngineOptions['onRuleError']): Finding[];
|