@aimarket/warden 0.9.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -1
- package/README-es.md +36 -11
- package/README-fr.md +36 -11
- package/README-ru.md +38 -11
- package/README-zh.md +36 -11
- package/README.md +38 -11
- package/dist/classifier.d.ts +39 -10
- package/dist/classifier.d.ts.map +1 -1
- package/dist/classifier.js +241 -45
- package/dist/classifier.js.map +1 -1
- package/dist/encoded-text.d.ts +7 -0
- package/dist/encoded-text.d.ts.map +1 -0
- package/dist/encoded-text.js +63 -0
- package/dist/encoded-text.js.map +1 -0
- package/dist/fold.d.ts +1 -2
- package/dist/fold.d.ts.map +1 -1
- package/dist/fold.js +3 -2
- package/dist/fold.js.map +1 -1
- package/dist/histor.d.ts +39 -4
- package/dist/histor.d.ts.map +1 -1
- package/dist/histor.js +11 -7
- package/dist/histor.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -4
- package/dist/index.js.map +1 -1
- package/dist/mcp-tools.d.ts.map +1 -1
- package/dist/mcp-tools.js +29 -4
- package/dist/mcp-tools.js.map +1 -1
- package/dist/pin-store.d.ts.map +1 -1
- package/dist/pin-store.js +1 -0
- package/dist/pin-store.js.map +1 -1
- package/dist/pinning.d.ts +1 -1
- package/dist/pinning.d.ts.map +1 -1
- package/dist/pinning.js +12 -4
- package/dist/pinning.js.map +1 -1
- package/dist/result-screen.d.ts +19 -0
- package/dist/result-screen.d.ts.map +1 -0
- package/dist/result-screen.js +86 -0
- package/dist/result-screen.js.map +1 -0
- package/dist/scan-lock.d.ts.map +1 -1
- package/dist/scan-lock.js +2 -1
- package/dist/scan-lock.js.map +1 -1
- package/dist/scan-report.d.ts.map +1 -1
- package/dist/scan-report.js +15 -0
- package/dist/scan-report.js.map +1 -1
- package/dist/scan.d.ts +40 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +245 -25
- package/dist/scan.js.map +1 -1
- package/dist/static-scan.d.ts +4 -3
- package/dist/static-scan.d.ts.map +1 -1
- package/dist/static-scan.js +40 -9
- package/dist/static-scan.js.map +1 -1
- package/dist/toxic-flow.d.ts +33 -0
- package/dist/toxic-flow.d.ts.map +1 -0
- package/dist/toxic-flow.js +117 -0
- package/dist/toxic-flow.js.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/wrap-cli.d.ts +3 -0
- package/dist/wrap-cli.d.ts.map +1 -1
- package/dist/wrap-cli.js +17 -6
- package/dist/wrap-cli.js.map +1 -1
- package/dist/wrap-state.js +2 -2
- package/dist/wrap-state.js.map +1 -1
- package/dist/wrap.d.ts.map +1 -1
- package/dist/wrap.js +20 -2
- package/dist/wrap.js.map +1 -1
- package/docs/scan.es.md +29 -14
- package/docs/scan.fr.md +29 -14
- package/docs/scan.md +29 -14
- package/docs/scan.ru.md +29 -14
- package/docs/scan.zh.md +29 -14
- package/docs/scanner-comparison.es.md +3 -12
- package/docs/scanner-comparison.fr.md +3 -13
- package/docs/scanner-comparison.md +3 -12
- package/docs/scanner-comparison.ru.md +3 -12
- package/docs/scanner-comparison.zh.md +3 -7
- package/docs/security-hardening-language-boundary.md +306 -0
- package/package.json +6 -3
- package/scripts/check-quality-gate.mjs +64 -0
- package/scripts/quality-public-key.pem +3 -0
package/dist/scan.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ClientKind, type ConfiguredServer, type DiscoveryEnv } from './scan-config.js';
|
|
2
2
|
import { type FetchedServer } from './mcp-client.js';
|
|
3
|
+
import { type HistorCheck } from './histor.js';
|
|
4
|
+
import { type ToxicFlow } from './toxic-flow.js';
|
|
3
5
|
import { type ClassifierOptions } from './classifier.js';
|
|
4
6
|
import { type ToolChange } from './scan-lock.js';
|
|
5
7
|
import type { Severity, ToolDef, WardenFinding, WardenPolicy, RulesetRef } from './types.js';
|
|
@@ -34,6 +36,12 @@ export interface ScanOptions {
|
|
|
34
36
|
}
|
|
35
37
|
export type ServerStatus = 'scanned' | 'launch-only' | 'blocked-at-launch' | 'error' | 'skipped';
|
|
36
38
|
export interface HistorSummary {
|
|
39
|
+
/** What was asked about: an endpoint, or a stdio server's `npm:`/`pypi:` package. */
|
|
40
|
+
package?: string;
|
|
41
|
+
/** The package version HISTOR last observed in its sandbox. */
|
|
42
|
+
packageVersion?: string;
|
|
43
|
+
/** HISTOR's id for the server: what its feed takes in `watch=`. */
|
|
44
|
+
targetId?: string;
|
|
37
45
|
match?: string;
|
|
38
46
|
note?: string;
|
|
39
47
|
page?: string;
|
|
@@ -73,6 +81,8 @@ export interface ScanServerResult {
|
|
|
73
81
|
model: string;
|
|
74
82
|
flagged: number;
|
|
75
83
|
error?: string;
|
|
84
|
+
incompleteTools?: string[];
|
|
85
|
+
retried?: number;
|
|
76
86
|
};
|
|
77
87
|
lock?: {
|
|
78
88
|
state: 'match' | 'drift' | 'missing' | 'updated' | 'unchanged' | 'refused';
|
|
@@ -101,6 +111,13 @@ export interface ScanReport {
|
|
|
101
111
|
stale: string[];
|
|
102
112
|
written: boolean;
|
|
103
113
|
};
|
|
114
|
+
/**
|
|
115
|
+
* Per client: private data, outside text and an outbound channel among the servers it starts
|
|
116
|
+
* (toxic-flow.ts). Advisory: a flow never blocks and never changes the exit code.
|
|
117
|
+
*/
|
|
118
|
+
flows: ToxicFlow[];
|
|
119
|
+
/** With --histor: a feed of changes to the servers HISTOR knows among these, for any feed reader. */
|
|
120
|
+
historWatch?: string;
|
|
104
121
|
summary: {
|
|
105
122
|
servers: number;
|
|
106
123
|
scanned: number;
|
|
@@ -126,6 +143,29 @@ export declare function historEndpoint(raw: string): {
|
|
|
126
143
|
endpoint?: string;
|
|
127
144
|
reason?: string;
|
|
128
145
|
};
|
|
146
|
+
/**
|
|
147
|
+
* The npm or PyPI package a stdio launch runs, as HISTOR names it (`npm:<name>`,
|
|
148
|
+
* `pypi:<normalised name>`), or why it is not sent. Only a package from the public
|
|
149
|
+
* registry is named: a local path, a git or tarball URL, or a private registry
|
|
150
|
+
* (`--registry`, `--index-url`) stays on this machine. The version is not sent —
|
|
151
|
+
* the digest already tells HISTOR which of the versions it observed you run.
|
|
152
|
+
*/
|
|
153
|
+
export declare function historPackage(command: string, args: readonly string[]): {
|
|
154
|
+
package?: string;
|
|
155
|
+
reason?: string;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* What HISTOR knows about a package beyond its tools: a name made to be taken for a popular one,
|
|
159
|
+
* and the marks of a stolen publishing token on the version it observed. All advisory.
|
|
160
|
+
*/
|
|
161
|
+
export declare function packageFindings(answer: HistorCheck): WardenFinding[];
|
|
162
|
+
/**
|
|
163
|
+
* What the package did in HISTOR's sandbox (gVisor's own trace, decoy credentials, canary tool
|
|
164
|
+
* calls). Reading a decoy or writing where it would persist is the loud part; contacting its own
|
|
165
|
+
* API during a call is what API servers do and is not a finding, nor is loading .env from the
|
|
166
|
+
* working directory, which dotenv does for half the ecosystem.
|
|
167
|
+
*/
|
|
168
|
+
export declare function behaviourFindings(behaviour: Record<string, unknown> | undefined, version: string): WardenFinding[];
|
|
129
169
|
export interface ScanDeps {
|
|
130
170
|
fetch?: (server: ConfiguredServer, opts: ScanOptions) => Promise<FetchedServer>;
|
|
131
171
|
historFetch?: typeof fetch;
|
package/dist/scan.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"AAOA,OAAO,EAAiC,KAAK,UAAU,EAAE,KAAK,gBAAgB,EAAE,KAAK,YAAY,EAAqB,MAAM,kBAAkB,CAAC;AAC/I,OAAO,EAA+B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../src/scan.ts"],"names":[],"mappings":"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"}
|
package/dist/scan.js
CHANGED
|
@@ -9,7 +9,8 @@ import { discoverServers, CLIENT_KINDS } from './scan-config.js';
|
|
|
9
9
|
import { fetchServer, McpClientError } from './mcp-client.js';
|
|
10
10
|
import { mtlToolSetDigest, MtlError } from './mtl.js';
|
|
11
11
|
import { historCheck, DEFAULT_HISTOR_URL } from './histor.js';
|
|
12
|
-
import {
|
|
12
|
+
import { boundCapabilities, toxicFlows } from './toxic-flow.js';
|
|
13
|
+
import { inspectTools, ClassifierError } from './classifier.js';
|
|
13
14
|
import { LockStore, readLock, writeLock, lockEntryFor, diffTools, LOCK_VERSION } from './scan-lock.js';
|
|
14
15
|
export const SEVERITIES = ['info', 'low', 'medium', 'high', 'critical'];
|
|
15
16
|
// ── argument parsing ─────────────────────────────────────────────────────────
|
|
@@ -35,18 +36,22 @@ How to connect
|
|
|
35
36
|
Verdicts
|
|
36
37
|
--fail-on SEVERITY block at this severity: info|low|medium|high|critical (default high)
|
|
37
38
|
--policy FILE strict JSON policy, as for wrap
|
|
39
|
+
--require-approval require an explicit operator pin/lock covering every field and identity
|
|
38
40
|
--lock FILE compare with reviewed definitions; a server missing from the lock is blocked
|
|
39
41
|
--update-lock write what the servers advertise now into --lock (refuses blocked servers)
|
|
40
42
|
--state-dir DIR where wrap keeps pins (read only here)
|
|
41
43
|
--feed URL --feed-key HEX signed threat feed, as for wrap
|
|
42
|
-
--histor ask the HISTOR log whether
|
|
44
|
+
--histor ask the HISTOR log whether your servers serve you what they serve everyone
|
|
45
|
+
(remote: endpoint + digest; npm/PyPI stdio: package name + digest)
|
|
43
46
|
(sends the endpoint without query or credentials, and a digest; never tool text)
|
|
44
47
|
--histor-url URL default ${DEFAULT_HISTOR_URL}
|
|
45
48
|
--classifier-url URL --classifier-model NAME
|
|
46
49
|
also ask a model you choose (OpenAI-compatible: Ollama, vLLM, a hosted API)
|
|
47
50
|
whether each tool's text directs the model. Sends tool definitions to that URL.
|
|
48
51
|
Key, if any, from WARDEN_CLASSIFIER_API_KEY. Advisory unless --classifier-blocks.
|
|
49
|
-
--classifier-blocks
|
|
52
|
+
--classifier-blocks enforce model verdicts; incomplete/error/uncertain inspection also refuses admission
|
|
53
|
+
--classifier-reasoning-effort none|low|medium|high
|
|
54
|
+
optional provider-supported budget; omitted uses the provider default
|
|
50
55
|
|
|
51
56
|
Output
|
|
52
57
|
--json JSON report on stdout instead of a table
|
|
@@ -62,7 +67,9 @@ export async function parseScanArgs(argv, env = process.env) {
|
|
|
62
67
|
format: 'table', failOnError: false, color: !!process.stdout.isTTY && !env.NO_COLOR,
|
|
63
68
|
};
|
|
64
69
|
let failOn, policyFile;
|
|
70
|
+
let requireApproval = false;
|
|
65
71
|
let classifierUrl, classifierModel, classifierBlocks = false;
|
|
72
|
+
let classifierReasoning;
|
|
66
73
|
const clients = [];
|
|
67
74
|
const value = (flag) => {
|
|
68
75
|
const v = argv.shift();
|
|
@@ -148,6 +155,12 @@ export async function parseScanArgs(argv, env = process.env) {
|
|
|
148
155
|
case '--classifier-model':
|
|
149
156
|
classifierModel = value(arg);
|
|
150
157
|
break;
|
|
158
|
+
case '--classifier-reasoning-effort':
|
|
159
|
+
classifierReasoning = value(arg);
|
|
160
|
+
break;
|
|
161
|
+
case '--require-approval':
|
|
162
|
+
requireApproval = true;
|
|
163
|
+
break;
|
|
151
164
|
case '--classifier-blocks':
|
|
152
165
|
classifierBlocks = true;
|
|
153
166
|
break;
|
|
@@ -188,23 +201,28 @@ export async function parseScanArgs(argv, env = process.env) {
|
|
|
188
201
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
189
202
|
throw new Error('policy must be an object');
|
|
190
203
|
for (const key of Object.keys(raw))
|
|
191
|
-
if (!['blockAtSeverity', 'pinToolDefs', 'allowUnknownServers', 'sensitiveToolPatterns'].includes(key))
|
|
204
|
+
if (!['blockAtSeverity', 'pinToolDefs', 'allowUnknownServers', 'sensitiveToolPatterns', 'requireApproval', 'capabilityBindings'].includes(key))
|
|
192
205
|
throw new Error(`Unknown policy field ${key}`);
|
|
193
206
|
opts.policy = asPolicy(raw);
|
|
194
207
|
}
|
|
208
|
+
if (requireApproval)
|
|
209
|
+
opts.policy.requireApproval = true;
|
|
195
210
|
if (failOn)
|
|
196
211
|
opts.policy = { ...opts.policy, blockAtSeverity: failOn };
|
|
197
212
|
if (!!classifierUrl !== !!classifierModel)
|
|
198
213
|
throw new Error('--classifier-url and --classifier-model go together');
|
|
199
214
|
if (classifierBlocks && !classifierUrl)
|
|
200
215
|
throw new Error('--classifier-blocks needs --classifier-url and --classifier-model');
|
|
216
|
+
if (classifierReasoning && (!classifierUrl || !['none', 'low', 'medium', 'high'].includes(classifierReasoning)))
|
|
217
|
+
throw new Error('--classifier-reasoning-effort needs a classifier and must be none, low, medium or high');
|
|
201
218
|
if (classifierUrl && classifierModel) {
|
|
202
219
|
const cu = new URL(classifierUrl);
|
|
203
220
|
if (cu.protocol !== 'https:' && cu.protocol !== 'http:')
|
|
204
221
|
throw new Error('--classifier-url must be http(s)');
|
|
205
222
|
if (cu.username || cu.password)
|
|
206
223
|
throw new Error('--classifier-url must not carry credentials; use WARDEN_CLASSIFIER_API_KEY');
|
|
207
|
-
opts.classifier = { url: classifierUrl, model: classifierModel, apiKey: env.WARDEN_CLASSIFIER_API_KEY || undefined, blocks: classifierBlocks
|
|
224
|
+
opts.classifier = { url: classifierUrl, model: classifierModel, apiKey: env.WARDEN_CLASSIFIER_API_KEY || undefined, blocks: classifierBlocks,
|
|
225
|
+
...(classifierReasoning ? { reasoningEffort: classifierReasoning } : {}) };
|
|
208
226
|
}
|
|
209
227
|
const u = new URL(opts.historUrl);
|
|
210
228
|
if (u.protocol !== 'https:' && u.protocol !== 'http:')
|
|
@@ -286,6 +304,149 @@ export function historEndpoint(raw) {
|
|
|
286
304
|
return { reason: 'the path looks like it carries a credential' };
|
|
287
305
|
return { endpoint: `${u.protocol}//${u.host}${u.pathname}` };
|
|
288
306
|
}
|
|
307
|
+
const NPM_PACKAGE = /^(@[a-z0-9][a-z0-9._~-]{0,100}\/)?[a-z0-9][a-z0-9._~-]{0,100}$/;
|
|
308
|
+
const PYPI_PACKAGE = /^[A-Za-z0-9]([A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/;
|
|
309
|
+
/** Launcher flags that take a value, so the value is not mistaken for the package. */
|
|
310
|
+
const VALUE_FLAGS = new Set(['--python', '-p', '--with', '-w', '--with-requirements', '--cache', '--node-options', '--call', '-c', '--spec', '--pip-args']);
|
|
311
|
+
/**
|
|
312
|
+
* The npm or PyPI package a stdio launch runs, as HISTOR names it (`npm:<name>`,
|
|
313
|
+
* `pypi:<normalised name>`), or why it is not sent. Only a package from the public
|
|
314
|
+
* registry is named: a local path, a git or tarball URL, or a private registry
|
|
315
|
+
* (`--registry`, `--index-url`) stays on this machine. The version is not sent —
|
|
316
|
+
* the digest already tells HISTOR which of the versions it observed you run.
|
|
317
|
+
*/
|
|
318
|
+
export function historPackage(command, args) {
|
|
319
|
+
const base = (command.split(/[\\/]/).pop() ?? '').toLowerCase().replace(/\.(cmd|exe|ps1)$/, '');
|
|
320
|
+
let rest = [...args];
|
|
321
|
+
let kind;
|
|
322
|
+
if (base === 'npx' || base === 'bunx')
|
|
323
|
+
kind = 'npm';
|
|
324
|
+
else if ((base === 'npm' && rest[0] === 'exec') || ((base === 'pnpm' || base === 'yarn') && rest[0] === 'dlx') || (base === 'bun' && rest[0] === 'x')) {
|
|
325
|
+
kind = 'npm';
|
|
326
|
+
rest = rest.slice(1);
|
|
327
|
+
}
|
|
328
|
+
else if (base === 'uvx')
|
|
329
|
+
kind = 'pypi';
|
|
330
|
+
else if (base === 'uv' && rest[0] === 'tool' && rest[1] === 'run') {
|
|
331
|
+
kind = 'pypi';
|
|
332
|
+
rest = rest.slice(2);
|
|
333
|
+
}
|
|
334
|
+
else if (base === 'pipx' && rest[0] === 'run') {
|
|
335
|
+
kind = 'pypi';
|
|
336
|
+
rest = rest.slice(1);
|
|
337
|
+
}
|
|
338
|
+
else
|
|
339
|
+
return { reason: 'not started from a package manager (npx, uvx …)' };
|
|
340
|
+
let spec;
|
|
341
|
+
for (let i = 0; i < rest.length; i++) {
|
|
342
|
+
const a = rest[i];
|
|
343
|
+
if (/^--(registry|index-url|index|extra-index-url|find-links)(=|$)|^-i$|^-f$/.test(a))
|
|
344
|
+
return { reason: 'a private registry' };
|
|
345
|
+
if (a === '--') {
|
|
346
|
+
spec = rest[i + 1];
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
if (a === '--package' || a === '--from' || (kind === 'npm' && a === '-p')) {
|
|
350
|
+
spec = rest[i + 1];
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
if (a.startsWith('--package=') || a.startsWith('--from=')) {
|
|
354
|
+
spec = a.slice(a.indexOf('=') + 1);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
if (a.startsWith('-')) {
|
|
358
|
+
if (VALUE_FLAGS.has(a))
|
|
359
|
+
i++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
spec = a;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (!spec)
|
|
366
|
+
return { reason: 'no package in the launch line' };
|
|
367
|
+
if (spec.includes('://') || /^(file:|git[+:]|github:|link:|workspace:|\.|\/|~|[A-Za-z]:\\)/.test(spec))
|
|
368
|
+
return { reason: 'not a registry package' };
|
|
369
|
+
if (kind === 'npm') {
|
|
370
|
+
const at = spec.indexOf('@', 1);
|
|
371
|
+
const name = at > 0 ? spec.slice(0, at) : spec;
|
|
372
|
+
return NPM_PACKAGE.test(name) ? { package: `npm:${name}` } : { reason: 'not a registry package' };
|
|
373
|
+
}
|
|
374
|
+
const name = spec.split(/[[=<>!~@; ]/)[0];
|
|
375
|
+
return PYPI_PACKAGE.test(name) ? { package: `pypi:${name.toLowerCase().replace(/[-_.]+/g, '-')}` } : { reason: 'not a registry package' };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* What HISTOR knows about a package beyond its tools: a name made to be taken for a popular one,
|
|
379
|
+
* and the marks of a stolen publishing token on the version it observed. All advisory.
|
|
380
|
+
*/
|
|
381
|
+
export function packageFindings(answer) {
|
|
382
|
+
const out = [];
|
|
383
|
+
const look = answer.packageLookalike;
|
|
384
|
+
if (look && typeof look.of === 'string') {
|
|
385
|
+
out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_LOOKALIKE', advisory: true,
|
|
386
|
+
message: `This package is named like ${displaySafe(look.of, 120)} (${Number(look.weekly ?? 0).toLocaleString('en')} downloads a week; this one: ${Number(look.ownWeekly ?? 0).toLocaleString('en')}) — ${displaySafe(String(look.how ?? 'a similar name'), 80)}. Make sure it is the one you meant to install.` });
|
|
387
|
+
}
|
|
388
|
+
const sig = answer.target?.packageSignals;
|
|
389
|
+
const flags = Array.isArray(sig?.flags) ? sig.flags.filter((f) => typeof f === 'string') : [];
|
|
390
|
+
const v = displaySafe(String(sig?.version ?? '?'), 40), pv = displaySafe(String(sig?.previousVersion ?? '?'), 40);
|
|
391
|
+
if (flags.includes('provenance-lost'))
|
|
392
|
+
out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_PROVENANCE_LOST', advisory: true,
|
|
393
|
+
message: `Version ${v} has no attested build, though ${pv} had one: it was not published by the project's CI. A stolen publishing token looks like this.` });
|
|
394
|
+
if (flags.includes('install-scripts-added'))
|
|
395
|
+
out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_INSTALL_SCRIPTS', advisory: true,
|
|
396
|
+
message: `Version ${v} runs install scripts (${(sig?.installScripts ?? []).map(s => displaySafe(String(s), 20)).join(', ')}) that ${pv} did not: they run on install, before any check.` });
|
|
397
|
+
if (flags.includes('publisher-changed'))
|
|
398
|
+
out.push({ gate: 'histor', severity: 'low', code: 'HISTOR_PACKAGE_PUBLISHER_CHANGED', advisory: true,
|
|
399
|
+
message: `Version ${v} was published by ${displaySafe(String(sig?.publisher ?? '?'), 60)}, ${pv} by ${displaySafe(String(sig?.previousPublisher ?? '?'), 60)}.` });
|
|
400
|
+
if (flags.includes('new-dependencies'))
|
|
401
|
+
out.push({ gate: 'histor', severity: 'low', code: 'HISTOR_PACKAGE_NEW_DEPENDENCIES', advisory: true,
|
|
402
|
+
message: `Version ${v} added dependencies ${pv} did not have: ${(sig?.newDependencies ?? []).slice(0, 8).map(d => displaySafe(String(d), 60)).join(', ')}.` });
|
|
403
|
+
out.push(...behaviourFindings(sig?.behaviour, v));
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
const list = (xs, n = 6, len = 80) => (Array.isArray(xs) ? xs : []).filter((x) => typeof x === 'string').slice(0, n).map(x => displaySafe(x, len)).join(', ');
|
|
407
|
+
const arr = (xs) => (Array.isArray(xs) ? xs.filter((x) => typeof x === 'string') : []);
|
|
408
|
+
/**
|
|
409
|
+
* What the package did in HISTOR's sandbox (gVisor's own trace, decoy credentials, canary tool
|
|
410
|
+
* calls). Reading a decoy or writing where it would persist is the loud part; contacting its own
|
|
411
|
+
* API during a call is what API servers do and is not a finding, nor is loading .env from the
|
|
412
|
+
* working directory, which dotenv does for half the ecosystem.
|
|
413
|
+
*/
|
|
414
|
+
export function behaviourFindings(behaviour, version) {
|
|
415
|
+
if (!behaviour || typeof behaviour !== 'object')
|
|
416
|
+
return [];
|
|
417
|
+
const out = [];
|
|
418
|
+
const phase = (k) => (behaviour[k] && typeof behaviour[k] === 'object' ? behaviour[k] : {});
|
|
419
|
+
const names = { installScripts: 'its install scripts', startup: 'at startup', calls: 'when its tools were called' };
|
|
420
|
+
for (const key of ['installScripts', 'startup', 'calls']) {
|
|
421
|
+
const b = phase(key);
|
|
422
|
+
const secrets = arr(b.decoys).filter(d => !d.startsWith('.env'));
|
|
423
|
+
if (secrets.length)
|
|
424
|
+
out.push({ gate: 'histor', severity: 'high', code: 'HISTOR_PACKAGE_READS_SECRETS', advisory: true,
|
|
425
|
+
message: `In HISTOR's sandbox, version ${version} opened decoy credentials ${names[key]}: ${list(secrets)}.` });
|
|
426
|
+
if (arr(b.writes).length)
|
|
427
|
+
out.push({ gate: 'histor', severity: 'high', code: 'HISTOR_PACKAGE_PERSISTENCE', advisory: true,
|
|
428
|
+
message: `In HISTOR's sandbox, version ${version} wrote ${names[key]} where it would outlive the session: ${list(b.writes)}.` });
|
|
429
|
+
}
|
|
430
|
+
const start = phase('startup');
|
|
431
|
+
if (arr(start.network).length || arr(start.lookups).length)
|
|
432
|
+
out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_STARTUP_NETWORK', advisory: true,
|
|
433
|
+
message: `In HISTOR's sandbox, version ${version} reached out at startup, before any tool was called: ${list(arr(start.network).length ? start.network : start.lookups)}.` });
|
|
434
|
+
const spawned = [...arr(start.exec), ...arr(phase('calls').exec)];
|
|
435
|
+
if (spawned.length)
|
|
436
|
+
out.push({ gate: 'histor', severity: 'low', code: 'HISTOR_PACKAGE_STARTS_PROGRAMS', advisory: true,
|
|
437
|
+
message: `In HISTOR's sandbox, version ${version} started programs: ${list(spawned, 6, 100)}.` });
|
|
438
|
+
const scripts = phase('installScripts');
|
|
439
|
+
if (arr(scripts.network).length || arr(scripts.lookups).length || arr(scripts.exec).length)
|
|
440
|
+
out.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_PACKAGE_INSTALL_BEHAVIOUR', advisory: true,
|
|
441
|
+
message: `Installing version ${version} runs scripts (${list(scripts.packages, 4, 60)}) that ${arr(scripts.network).length || arr(scripts.lookups).length ? `reach ${list(arr(scripts.network).length ? scripts.network : scripts.lookups)}` : `start ${list(scripts.exec, 4, 100)}`} — before any check can run.` });
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
function historWatchUrl(opts, servers) {
|
|
445
|
+
if (!opts.histor)
|
|
446
|
+
return {};
|
|
447
|
+
const ids = [...new Set(servers.map(s => s.histor?.targetId).filter((x) => !!x))].sort().slice(0, 100);
|
|
448
|
+
return ids.length ? { historWatch: `${opts.historUrl.replace(/\/+$/, '')}/feed.xml?watch=${ids.join(',')}` } : {};
|
|
449
|
+
}
|
|
289
450
|
// ── the scan ─────────────────────────────────────────────────────────────────
|
|
290
451
|
async function pool(items, n, fn) {
|
|
291
452
|
const out = new Array(items.length);
|
|
@@ -329,6 +490,7 @@ export async function runScan(opts, deps = {}) {
|
|
|
329
490
|
const lockStore = opts.lock ? new LockStore(lockFile?.servers ?? {}) : undefined;
|
|
330
491
|
const wrapPins = READ_ONLY(new FilePinStore(join(opts.stateDir, 'pins')));
|
|
331
492
|
const now = (deps.now ?? (() => new Date()))().toISOString();
|
|
493
|
+
const semanticReviews = new Map();
|
|
332
494
|
const all = results.flatMap(r => r.servers);
|
|
333
495
|
// The same server listed by two clients is checked once per entry: they are separate launches.
|
|
334
496
|
const servers = await pool(all, opts.concurrency, async (server) => {
|
|
@@ -353,8 +515,11 @@ export async function runScan(opts, deps = {}) {
|
|
|
353
515
|
}
|
|
354
516
|
if (server.ref.transport === 'stdio' && !opts.launch) {
|
|
355
517
|
const findings = [...launchVerdict.findings, ...(lockMissing ? [lockMissing] : [])];
|
|
356
|
-
|
|
357
|
-
|
|
518
|
+
if (opts.policy.requireApproval)
|
|
519
|
+
findings.push({ gate: 'pinning', severity: 'high', code: 'TOOL_DEF_INSPECTION_REQUIRED',
|
|
520
|
+
message: 'Strict admission cannot verify the approved definitions without tools/list; launch-only inspection is incomplete.' });
|
|
521
|
+
return { ...base, status: 'launch-only', allow: !lockMissing && !opts.policy.requireApproval, score: launchVerdict.score, findings,
|
|
522
|
+
...(lockMissing ? { decidedBy: 'lock' } : opts.policy.requireApproval ? { decidedBy: 'pinning' } : {}), lock: lockStore ? { state: lockEntry ? 'match' : 'missing' } : undefined };
|
|
358
523
|
}
|
|
359
524
|
let fetched;
|
|
360
525
|
try {
|
|
@@ -365,7 +530,20 @@ export async function runScan(opts, deps = {}) {
|
|
|
365
530
|
const msg = err instanceof McpClientError ? err.message : err instanceof Error ? err.message : String(err);
|
|
366
531
|
return { ...base, status: 'error', error: displaySafe(msg, 400), findings: lockMissing ? [lockMissing] : [], allow: lockMissing ? false : null, ...(lockMissing ? { decidedBy: 'lock' } : {}) };
|
|
367
532
|
}
|
|
368
|
-
|
|
533
|
+
let inspection, inspectionError;
|
|
534
|
+
if (opts.classifier && fetched.tools.length) {
|
|
535
|
+
try {
|
|
536
|
+
inspection = await inspectTools(fetched.tools, { ...opts.classifier, requireComplete: opts.classifier.blocks });
|
|
537
|
+
if (opts.classifier.blocks)
|
|
538
|
+
semanticReviews.set(fetched.tools, inspection);
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
inspectionError = err;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const reviewedWarden = Warden.create({ policy: opts.policy, threatFeed: feed, store,
|
|
545
|
+
semanticReview: semanticReviews.get(fetched.tools) });
|
|
546
|
+
const verdict = await reviewedWarden.vet(server.ref, fetched.tools);
|
|
369
547
|
const findings = [...verdict.findings];
|
|
370
548
|
let allow = verdict.allow, decidedBy = verdict.decidedBy, score = verdict.score;
|
|
371
549
|
if (fetched.instructions) {
|
|
@@ -395,21 +573,33 @@ export async function runScan(opts, deps = {}) {
|
|
|
395
573
|
mtlError = err instanceof MtlError ? err.code : String(err);
|
|
396
574
|
}
|
|
397
575
|
let histor;
|
|
398
|
-
if (opts.histor
|
|
399
|
-
const target =
|
|
400
|
-
|
|
576
|
+
if (opts.histor) {
|
|
577
|
+
const target = server.ref.url
|
|
578
|
+
? historEndpoint(server.ref.url)
|
|
579
|
+
: historPackage(server.ref.command ?? '', server.ref.args ?? []);
|
|
580
|
+
if (!target.endpoint && !target.package)
|
|
401
581
|
histor = { notSent: target.reason };
|
|
402
582
|
else {
|
|
403
583
|
try {
|
|
404
|
-
const
|
|
405
|
-
|
|
584
|
+
const query = target.endpoint ? { endpoint: target.endpoint } : { package: target.package };
|
|
585
|
+
const answer = await historCheck(opts.historUrl, { ...query, ...(mtlDigest ? { toolSetDigest: mtlDigest } : {}) }, { fetchImpl: deps.historFetch, userAgent: `warden-scan/${version}` });
|
|
586
|
+
const seenVersion = typeof answer.target?.packageVersion === 'string' ? displaySafe(answer.target.packageVersion, 64) : undefined;
|
|
587
|
+
const targetId = typeof answer.target?.id === 'string' && /^[0-9a-f]{16}$/.test(answer.target.id) ? answer.target.id : undefined;
|
|
588
|
+
histor = { ...(target.package ? { package: target.package } : {}), ...(seenVersion ? { packageVersion: seenVersion } : {}), ...(targetId ? { targetId } : {}),
|
|
589
|
+
match: String(answer.match), note: typeof answer.note === 'string' ? displaySafe(answer.note, 400) : undefined,
|
|
406
590
|
page: answer.target?.page, unchangedSince: answer.observed?.unchangedSince, changes: answer.observed?.changes };
|
|
407
591
|
if (answer.match === 'different')
|
|
408
592
|
findings.push({ gate: 'histor', severity: 'medium', code: 'HISTOR_UNSEEN_TOOLSET', advisory: true,
|
|
409
|
-
message:
|
|
593
|
+
message: target.package
|
|
594
|
+
? `HISTOR has never observed the tool set this package gave you (${mtlDigest}) in any version it ran${seenVersion ? ` (latest it ran: ${seenVersion})` : ''}. Either you run a version HISTOR has not reached yet, or the package on this machine is not the one published.`
|
|
595
|
+
: `HISTOR has never observed the tool set this server served you (${mtlDigest}). Either it changed after the last crawl, or it serves you something it does not serve the public crawler.` });
|
|
596
|
+
if (target.package)
|
|
597
|
+
findings.push(...packageFindings(answer));
|
|
410
598
|
if (answer.match === 'previously-observed')
|
|
411
599
|
findings.push({ gate: 'histor', severity: 'low', code: 'HISTOR_OLDER_TOOLSET', advisory: true,
|
|
412
|
-
message:
|
|
600
|
+
message: target.package
|
|
601
|
+
? `You run an older version of this package: HISTOR observed this tool set before${seenVersion ? `, and version ${seenVersion} serves a different one` : ''}.`
|
|
602
|
+
: 'HISTOR observed this tool set earlier, but the server now serves the public a different one.' });
|
|
413
603
|
// The log's own classifier verdict on the set you were served, when it holds one: advisory, never a block.
|
|
414
604
|
const cls = answer.classifier;
|
|
415
605
|
if (answer.match === 'same' && cls && cls.status === 'classified' && Array.isArray(cls.findings)) {
|
|
@@ -430,11 +620,26 @@ export async function runScan(opts, deps = {}) {
|
|
|
430
620
|
let classifier;
|
|
431
621
|
if (opts.classifier && fetched.tools.length) {
|
|
432
622
|
try {
|
|
433
|
-
|
|
434
|
-
|
|
623
|
+
if (!inspection)
|
|
624
|
+
throw inspectionError ?? new ClassifierError("inspection unavailable");
|
|
625
|
+
const verdicts = inspection.findings;
|
|
626
|
+
classifier = { model: opts.classifier.model, flagged: verdicts.length, ...(inspection.retried ? { retried: inspection.retried } : {}) };
|
|
627
|
+
if (inspection.incomplete.length) {
|
|
628
|
+
classifier.error = inspection.incomplete.map(r => r.error).join("; ").slice(0, 400);
|
|
629
|
+
classifier.incompleteTools = inspection.incomplete.map(r => r.tool);
|
|
630
|
+
for (const r of inspection.incomplete)
|
|
631
|
+
findings.push({ gate: "classifier", code: "CLASSIFIER_INCOMPLETE", severity: "high", tool: r.tool,
|
|
632
|
+
message: `Semantic inspection incomplete: ${displaySafe(r.error, 200)}. This is not an attack verdict.`, ...(!opts.classifier.blocks ? { advisory: true } : {}) });
|
|
633
|
+
if (opts.classifier.blocks) {
|
|
634
|
+
if (allow)
|
|
635
|
+
decidedBy = "classifier";
|
|
636
|
+
allow = false;
|
|
637
|
+
score = 0;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
435
640
|
for (const v of verdicts) {
|
|
436
641
|
const f = { gate: 'classifier', code: 'TOOL_DEF_CLASSIFIER', severity: v.severity, tool: v.tool,
|
|
437
|
-
message: `Model ${displaySafe(opts.classifier.model, 60)} reads this tool as ${v.categories.join(', ')}: ${displaySafe(v.reason, 300)} — at "${displaySafe(v.quote, 200)}"` };
|
|
642
|
+
message: `Model ${displaySafe(opts.classifier.model, 60)} reads this tool as ${v.categories.join(', ')}: ${displaySafe(v.reason, 300)} — ${v.evidenceFields ? "source fields " + v.evidenceFields.map(x => displaySafe(x, 60)).join(", ") + "; preview" : "at"} "${displaySafe(v.quote, 200)}"` };
|
|
438
643
|
if (!opts.classifier.blocks)
|
|
439
644
|
f.advisory = true;
|
|
440
645
|
findings.push(f);
|
|
@@ -447,6 +652,15 @@ export async function runScan(opts, deps = {}) {
|
|
|
447
652
|
}
|
|
448
653
|
catch (err) {
|
|
449
654
|
classifier = { model: opts.classifier.model, flagged: 0, error: displaySafe(err instanceof ClassifierError ? err.message : String(err), 200) };
|
|
655
|
+
findings.push({ gate: 'classifier', code: 'CLASSIFIER_INCOMPLETE', severity: 'high',
|
|
656
|
+
message: `Semantic inspection incomplete: ${classifier.error}. This is not an attack verdict.`,
|
|
657
|
+
...(!opts.classifier.blocks ? { advisory: true } : {}) });
|
|
658
|
+
if (opts.classifier.blocks) {
|
|
659
|
+
if (allow)
|
|
660
|
+
decidedBy = 'classifier';
|
|
661
|
+
allow = false;
|
|
662
|
+
score = 0;
|
|
663
|
+
}
|
|
450
664
|
}
|
|
451
665
|
}
|
|
452
666
|
let lock;
|
|
@@ -455,10 +669,13 @@ export async function runScan(opts, deps = {}) {
|
|
|
455
669
|
const drift = findings.some(f => f.code === 'TOOL_DEF_DRIFT' || f.code === 'SERVER_IDENTITY_DRIFT' || f.code === 'PIN_FORMAT_UPGRADE_REQUIRED' || (f.code === 'TOOL_DEF_UNCANONICAL' && !!lockEntry));
|
|
456
670
|
lock = { state: !lockEntry ? 'missing' : drift ? 'drift' : 'match', ...(changes.length && lockEntry ? { changes } : !lockEntry ? { changes } : {}) };
|
|
457
671
|
}
|
|
672
|
+
const classifierBlocked = new Set(opts.classifier?.blocks
|
|
673
|
+
? [...(classifier?.error ? classifier.incompleteTools ?? fetched.tools.map(t => t.name) : []),
|
|
674
|
+
...findings.filter(f => f.gate === 'classifier' && f.tool && !f.advisory && blocking(f, opts.policy)).map(f => f.tool)]
|
|
675
|
+
: []);
|
|
458
676
|
return { ...base, status: 'scanned', allow, decidedBy: allow ? undefined : decidedBy, score, findings,
|
|
459
|
-
allowedTools: verdict.allowedTools, blockedTools: verdict.blockedTools, toolCount: fetched.tools.length,
|
|
460
|
-
serverInfo: fetched.serverInfo, mtlDigest, mtlError, histor, classifier, lock, tools: fetched.tools
|
|
461
|
-
...(classifier && opts.classifier?.blocks ? { blockedTools: [...new Set([...verdict.blockedTools, ...findings.filter(f => f.gate === 'classifier' && !f.advisory && blocking(f, opts.policy)).map(f => f.tool)])] } : {}) };
|
|
677
|
+
allowedTools: verdict.allowedTools.filter(t => !classifierBlocked.has(t)), blockedTools: [...new Set([...verdict.blockedTools, ...classifierBlocked])], toolCount: fetched.tools.length,
|
|
678
|
+
serverInfo: fetched.serverInfo, mtlDigest, mtlError, histor, classifier, lock, tools: fetched.tools };
|
|
462
679
|
});
|
|
463
680
|
let lockSummary;
|
|
464
681
|
if (opts.lock && lockStore) {
|
|
@@ -479,9 +696,9 @@ export async function runScan(opts, deps = {}) {
|
|
|
479
696
|
continue;
|
|
480
697
|
}
|
|
481
698
|
// Approve only what the non-pinning gates accept, as `pins approve` does.
|
|
482
|
-
const fresh = await Warden.create({ policy: opts.policy, threatFeed: feed, store: NO_PINS }).vet(serverRef(s, all), s.tools);
|
|
699
|
+
const fresh = await Warden.create({ policy: { ...opts.policy, requireApproval: false }, threatFeed: feed, store: NO_PINS, semanticReview: semanticReviews.get(s.tools) }).vet(serverRef(s, all), s.tools);
|
|
483
700
|
const instructionsBlocked = s.findings.some(f => f.tool === 'initialize.instructions' && blocking(f, opts.policy));
|
|
484
|
-
if (!fresh.allow || instructionsBlocked) {
|
|
701
|
+
if (!fresh.allow || instructionsBlocked || (opts.classifier?.blocks && (s.classifier?.error || s.findings.some(f => f.gate === 'classifier' && blocking(f, opts.policy))))) {
|
|
485
702
|
if (previous)
|
|
486
703
|
next[s.id] = previous;
|
|
487
704
|
s.lock = { ...(s.lock ?? { state: 'refused' }), state: 'refused' };
|
|
@@ -492,7 +709,7 @@ export async function runScan(opts, deps = {}) {
|
|
|
492
709
|
const changed = !previous || previous.toolsHash !== entry.toolsHash || previous.identityHash !== entry.identityHash;
|
|
493
710
|
s.lock = { ...(s.lock ?? { state: 'updated' }), state: changed ? 'updated' : 'unchanged' };
|
|
494
711
|
// Once written, the server is reviewed: drop the findings that only said "not reviewed / drifted".
|
|
495
|
-
s.findings = s.findings.filter(f => !['LOCK_MISSING', 'TOOL_DEF_DRIFT', 'SERVER_IDENTITY_DRIFT', 'PIN_FORMAT_UPGRADE_REQUIRED', 'TOOL_DEF_UNPINNED'].includes(f.code));
|
|
712
|
+
s.findings = s.findings.filter(f => !['LOCK_MISSING', 'TOOL_DEF_DRIFT', 'SERVER_IDENTITY_DRIFT', 'PIN_FORMAT_UPGRADE_REQUIRED', 'TOOL_DEF_UNPINNED', 'TOOL_DEF_APPROVAL_REQUIRED'].includes(f.code));
|
|
496
713
|
s.allow = !s.findings.some(f => blocking(f, opts.policy));
|
|
497
714
|
if (s.allow) {
|
|
498
715
|
s.decidedBy = undefined;
|
|
@@ -517,7 +734,10 @@ export async function runScan(opts, deps = {}) {
|
|
|
517
734
|
return {
|
|
518
735
|
tool: 'warden-scan', version, scannedAt: now, ruleset: staticScanRulesetRef(), policy: opts.policy, launch: opts.launch,
|
|
519
736
|
sources: results.map(r => ({ path: r.source.path, client: r.source.client, scope: r.source.scope, servers: r.servers.length, ...(r.error ? { error: displaySafe(r.error, 300) } : {}) })),
|
|
520
|
-
servers, lock: lockSummary,
|
|
737
|
+
servers, lock: lockSummary,
|
|
738
|
+
flows: toxicFlows(servers.filter(s => s.status === 'scanned' && s.allow === true).map(s => ({ client: s.client, key: s.key, tools: s.tools?.filter(t => s.allowedTools.includes(t.name)), capabilities: s.tools ? boundCapabilities(serverRef(s, all), s.tools, opts.policy.capabilityBindings) : undefined }))),
|
|
739
|
+
...historWatchUrl(opts, servers),
|
|
740
|
+
summary,
|
|
521
741
|
};
|
|
522
742
|
}
|
|
523
743
|
function serverRef(s, all) {
|