@quint-security/core 0.1.2
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/dist/auth-db.d.ts +17 -0
- package/dist/auth-db.d.ts.map +1 -0
- package/dist/auth-db.js +112 -0
- package/dist/auth-db.js.map +1 -0
- package/dist/auth.d.ts +41 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +101 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +143 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.d.ts +11 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +89 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db.d.ts +31 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +157 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +15 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +36 -0
- package/dist/log.js.map +1 -0
- package/dist/risk.d.ts +72 -0
- package/dist/risk.d.ts.map +1 -0
- package/dist/risk.js +177 -0
- package/dist/risk.js.map +1 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +35 -0
- package/dist/types.js.map +1 -0
- package/package.json +25 -0
- package/src/auth-db.ts +130 -0
- package/src/auth.ts +113 -0
- package/src/config.ts +163 -0
- package/src/crypto.ts +96 -0
- package/src/db.ts +184 -0
- package/src/index.ts +8 -0
- package/src/log.ts +32 -0
- package/src/risk.ts +228 -0
- package/src/types.ts +133 -0
- package/tsconfig.json +9 -0
package/src/db.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { AuditEntry } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const SCHEMA = `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
8
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
9
|
+
timestamp TEXT NOT NULL,
|
|
10
|
+
server_name TEXT NOT NULL,
|
|
11
|
+
direction TEXT NOT NULL,
|
|
12
|
+
method TEXT NOT NULL,
|
|
13
|
+
message_id TEXT,
|
|
14
|
+
tool_name TEXT,
|
|
15
|
+
arguments_json TEXT,
|
|
16
|
+
response_json TEXT,
|
|
17
|
+
verdict TEXT NOT NULL,
|
|
18
|
+
risk_score INTEGER,
|
|
19
|
+
risk_level TEXT,
|
|
20
|
+
policy_hash TEXT NOT NULL DEFAULT '',
|
|
21
|
+
prev_hash TEXT NOT NULL DEFAULT '',
|
|
22
|
+
nonce TEXT NOT NULL DEFAULT '',
|
|
23
|
+
signature TEXT NOT NULL,
|
|
24
|
+
public_key TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_timestamp ON audit_log(timestamp);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_server_name ON audit_log(server_name);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_tool_name ON audit_log(tool_name);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_verdict ON audit_log(verdict);
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
// Migration: add columns if they don't exist (for DBs created before this version)
|
|
34
|
+
const MIGRATIONS = [
|
|
35
|
+
`ALTER TABLE audit_log ADD COLUMN policy_hash TEXT NOT NULL DEFAULT ''`,
|
|
36
|
+
`ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''`,
|
|
37
|
+
`ALTER TABLE audit_log ADD COLUMN nonce TEXT NOT NULL DEFAULT ''`,
|
|
38
|
+
`ALTER TABLE audit_log ADD COLUMN risk_score INTEGER`,
|
|
39
|
+
`ALTER TABLE audit_log ADD COLUMN risk_level TEXT`,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export class AuditDb {
|
|
43
|
+
private db: Database.Database;
|
|
44
|
+
|
|
45
|
+
constructor(dbPath: string) {
|
|
46
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
47
|
+
this.db = new Database(dbPath);
|
|
48
|
+
this.db.pragma("journal_mode = WAL");
|
|
49
|
+
this.db.exec(SCHEMA);
|
|
50
|
+
this.migrate();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private migrate(): void {
|
|
54
|
+
for (const sql of MIGRATIONS) {
|
|
55
|
+
try {
|
|
56
|
+
this.db.exec(sql);
|
|
57
|
+
} catch {
|
|
58
|
+
// Column already exists — ignore
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Get the signature of the last entry (for hash chaining) */
|
|
64
|
+
getLastSignature(): string | null {
|
|
65
|
+
const row = this.db.prepare(
|
|
66
|
+
"SELECT signature FROM audit_log ORDER BY id DESC LIMIT 1"
|
|
67
|
+
).get() as { signature: string } | undefined;
|
|
68
|
+
return row?.signature ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Atomically read the last signature and insert a new entry.
|
|
73
|
+
* This prevents chain breaks when multiple proxy instances share a DB.
|
|
74
|
+
*/
|
|
75
|
+
insertAtomic(buildEntry: (prevSignature: string | null) => Omit<AuditEntry, "id">): number {
|
|
76
|
+
const insertStmt = this.db.prepare(`
|
|
77
|
+
INSERT INTO audit_log
|
|
78
|
+
(timestamp, server_name, direction, method, message_id, tool_name,
|
|
79
|
+
arguments_json, response_json, verdict, risk_score, risk_level,
|
|
80
|
+
policy_hash, prev_hash, nonce, signature, public_key)
|
|
81
|
+
VALUES
|
|
82
|
+
(@timestamp, @server_name, @direction, @method, @message_id, @tool_name,
|
|
83
|
+
@arguments_json, @response_json, @verdict, @risk_score, @risk_level,
|
|
84
|
+
@policy_hash, @prev_hash, @nonce, @signature, @public_key)
|
|
85
|
+
`);
|
|
86
|
+
const lastSigStmt = this.db.prepare(
|
|
87
|
+
"SELECT signature FROM audit_log ORDER BY id DESC LIMIT 1"
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
let rowId = 0;
|
|
91
|
+
this.db.transaction(() => {
|
|
92
|
+
const lastRow = lastSigStmt.get() as { signature: string } | undefined;
|
|
93
|
+
const entry = buildEntry(lastRow?.signature ?? null);
|
|
94
|
+
const result = insertStmt.run(entry);
|
|
95
|
+
rowId = result.lastInsertRowid as number;
|
|
96
|
+
})();
|
|
97
|
+
return rowId;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
insert(entry: Omit<AuditEntry, "id">): number {
|
|
101
|
+
const stmt = this.db.prepare(`
|
|
102
|
+
INSERT INTO audit_log
|
|
103
|
+
(timestamp, server_name, direction, method, message_id, tool_name,
|
|
104
|
+
arguments_json, response_json, verdict, risk_score, risk_level,
|
|
105
|
+
policy_hash, prev_hash, nonce, signature, public_key)
|
|
106
|
+
VALUES
|
|
107
|
+
(@timestamp, @server_name, @direction, @method, @message_id, @tool_name,
|
|
108
|
+
@arguments_json, @response_json, @verdict, @risk_score, @risk_level,
|
|
109
|
+
@policy_hash, @prev_hash, @nonce, @signature, @public_key)
|
|
110
|
+
`);
|
|
111
|
+
const result = stmt.run(entry);
|
|
112
|
+
return result.lastInsertRowid as number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getById(id: number): AuditEntry | undefined {
|
|
116
|
+
return this.db.prepare("SELECT * FROM audit_log WHERE id = ?").get(id) as AuditEntry | undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Get entries in ID order (ascending) for chain verification */
|
|
120
|
+
getRange(startId: number, endId: number): AuditEntry[] {
|
|
121
|
+
return this.db.prepare(
|
|
122
|
+
"SELECT * FROM audit_log WHERE id >= ? AND id <= ? ORDER BY id ASC"
|
|
123
|
+
).all(startId, endId) as AuditEntry[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Get all entries in ID order (ascending) for chain verification */
|
|
127
|
+
getAll(): AuditEntry[] {
|
|
128
|
+
return this.db.prepare("SELECT * FROM audit_log ORDER BY id ASC").all() as AuditEntry[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
query(opts: {
|
|
132
|
+
server?: string;
|
|
133
|
+
tool?: string;
|
|
134
|
+
verdict?: string;
|
|
135
|
+
since?: string;
|
|
136
|
+
limit?: number;
|
|
137
|
+
} = {}): AuditEntry[] {
|
|
138
|
+
const conditions: string[] = [];
|
|
139
|
+
const params: Record<string, unknown> = {};
|
|
140
|
+
|
|
141
|
+
if (opts.server) {
|
|
142
|
+
conditions.push("server_name = @server");
|
|
143
|
+
params.server = opts.server;
|
|
144
|
+
}
|
|
145
|
+
if (opts.tool) {
|
|
146
|
+
conditions.push("tool_name = @tool");
|
|
147
|
+
params.tool = opts.tool;
|
|
148
|
+
}
|
|
149
|
+
if (opts.verdict) {
|
|
150
|
+
conditions.push("verdict = @verdict");
|
|
151
|
+
params.verdict = opts.verdict;
|
|
152
|
+
}
|
|
153
|
+
if (opts.since) {
|
|
154
|
+
conditions.push("timestamp >= @since");
|
|
155
|
+
params.since = opts.since;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
159
|
+
const limit = opts.limit ?? 100;
|
|
160
|
+
|
|
161
|
+
return this.db.prepare(
|
|
162
|
+
`SELECT * FROM audit_log ${where} ORDER BY id DESC LIMIT ${limit}`
|
|
163
|
+
).all(params) as AuditEntry[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
getLast(n: number): AuditEntry[] {
|
|
167
|
+
return this.db.prepare(
|
|
168
|
+
"SELECT * FROM audit_log ORDER BY id DESC LIMIT ?"
|
|
169
|
+
).all(n) as AuditEntry[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
count(): number {
|
|
173
|
+
const row = this.db.prepare("SELECT COUNT(*) as cnt FROM audit_log").get() as { cnt: number };
|
|
174
|
+
return row.cnt;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
close(): void {
|
|
178
|
+
this.db.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function openAuditDb(dataDir: string): AuditDb {
|
|
183
|
+
return new AuditDb(join(dataDir, "quint.db"));
|
|
184
|
+
}
|
package/src/index.ts
ADDED
package/src/log.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const;
|
|
2
|
+
type Level = keyof typeof LEVELS;
|
|
3
|
+
|
|
4
|
+
let currentLevel: Level = "info";
|
|
5
|
+
|
|
6
|
+
export function setLogLevel(level: Level): void {
|
|
7
|
+
currentLevel = level;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getLogLevel(): Level {
|
|
11
|
+
return currentLevel;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function shouldLog(level: Level): boolean {
|
|
15
|
+
return LEVELS[level] >= LEVELS[currentLevel];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function logDebug(msg: string): void {
|
|
19
|
+
if (shouldLog("debug")) process.stderr.write(`quint [debug]: ${msg}\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function logInfo(msg: string): void {
|
|
23
|
+
if (shouldLog("info")) process.stderr.write(`quint: ${msg}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function logWarn(msg: string): void {
|
|
27
|
+
if (shouldLog("warn")) process.stderr.write(`quint [warn]: ${msg}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function logError(msg: string): void {
|
|
31
|
+
if (shouldLog("error")) process.stderr.write(`quint [error]: ${msg}\n`);
|
|
32
|
+
}
|
package/src/risk.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk scoring engine.
|
|
3
|
+
*
|
|
4
|
+
* Each intercepted action gets a risk score from 0–100.
|
|
5
|
+
* The score is based on:
|
|
6
|
+
* 1. The method being called (tools/call is riskier than tools/list)
|
|
7
|
+
* 2. The tool name (some tools are inherently more dangerous)
|
|
8
|
+
* 3. The arguments (e.g. destructive keywords like "delete", "drop", "rm")
|
|
9
|
+
* 4. Accumulated behavior (repeated high-risk attempts escalate)
|
|
10
|
+
*
|
|
11
|
+
* If the score exceeds a threshold, the action can be:
|
|
12
|
+
* - Flagged for manual approval
|
|
13
|
+
* - Auto-denied
|
|
14
|
+
* - Trigger session/token revocation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── Built-in risk patterns ──────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface RiskPattern {
|
|
20
|
+
/** Glob pattern for tool name */
|
|
21
|
+
tool: string;
|
|
22
|
+
/** Base risk score for this tool (0-100) */
|
|
23
|
+
baseScore: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TOOL_RISKS: RiskPattern[] = [
|
|
27
|
+
// Destructive file operations
|
|
28
|
+
{ tool: "Delete*", baseScore: 80 },
|
|
29
|
+
{ tool: "Remove*", baseScore: 80 },
|
|
30
|
+
{ tool: "Rm*", baseScore: 80 },
|
|
31
|
+
// Write operations
|
|
32
|
+
{ tool: "Write*", baseScore: 50 },
|
|
33
|
+
{ tool: "Create*", baseScore: 40 },
|
|
34
|
+
{ tool: "Update*", baseScore: 45 },
|
|
35
|
+
{ tool: "Edit*", baseScore: 45 },
|
|
36
|
+
// Database operations
|
|
37
|
+
{ tool: "*Sql*", baseScore: 60 },
|
|
38
|
+
{ tool: "*Query*", baseScore: 40 },
|
|
39
|
+
{ tool: "*Database*", baseScore: 55 },
|
|
40
|
+
// Execution
|
|
41
|
+
{ tool: "*Execute*", baseScore: 70 },
|
|
42
|
+
{ tool: "*Run*", baseScore: 65 },
|
|
43
|
+
{ tool: "*Shell*", baseScore: 75 },
|
|
44
|
+
{ tool: "*Bash*", baseScore: 75 },
|
|
45
|
+
{ tool: "*Command*", baseScore: 70 },
|
|
46
|
+
// Network
|
|
47
|
+
{ tool: "*Fetch*", baseScore: 35 },
|
|
48
|
+
{ tool: "*Http*", baseScore: 35 },
|
|
49
|
+
{ tool: "*Request*", baseScore: 35 },
|
|
50
|
+
// Read operations (low risk)
|
|
51
|
+
{ tool: "Read*", baseScore: 10 },
|
|
52
|
+
{ tool: "Get*", baseScore: 10 },
|
|
53
|
+
{ tool: "List*", baseScore: 5 },
|
|
54
|
+
{ tool: "Search*", baseScore: 10 },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Argument keywords that bump the risk score
|
|
58
|
+
const DANGEROUS_ARG_KEYWORDS = [
|
|
59
|
+
{ pattern: /\bdrop\b/i, boost: 30 },
|
|
60
|
+
{ pattern: /\bdelete\b/i, boost: 25 },
|
|
61
|
+
{ pattern: /\btruncate\b/i, boost: 25 },
|
|
62
|
+
{ pattern: /\brm\s+-rf\b/i, boost: 30 },
|
|
63
|
+
{ pattern: /\bformat\b/i, boost: 20 },
|
|
64
|
+
{ pattern: /\b(sudo|chmod|chown)\b/i, boost: 25 },
|
|
65
|
+
{ pattern: /\bpassword\b/i, boost: 15 },
|
|
66
|
+
{ pattern: /\bsecret\b/i, boost: 15 },
|
|
67
|
+
{ pattern: /\btoken\b/i, boost: 10 },
|
|
68
|
+
{ pattern: /\b(\.env|credentials)\b/i, boost: 20 },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// ── Risk scoring logic ──────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
import { globMatch } from "./config.js";
|
|
74
|
+
|
|
75
|
+
export interface RiskScore {
|
|
76
|
+
/** Final score 0-100 (capped) */
|
|
77
|
+
score: number;
|
|
78
|
+
/** Base score from tool pattern match */
|
|
79
|
+
baseScore: number;
|
|
80
|
+
/** Boost from argument analysis */
|
|
81
|
+
argBoost: number;
|
|
82
|
+
/** Boost from repeated high-risk behavior */
|
|
83
|
+
behaviorBoost: number;
|
|
84
|
+
/** Human-readable risk level */
|
|
85
|
+
level: "low" | "medium" | "high" | "critical";
|
|
86
|
+
/** Reasons contributing to the score */
|
|
87
|
+
reasons: string[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RiskThresholds {
|
|
91
|
+
/** Score at which action is flagged for review (default 60) */
|
|
92
|
+
flag: number;
|
|
93
|
+
/** Score at which action is auto-denied (default 85) */
|
|
94
|
+
deny: number;
|
|
95
|
+
/** Number of high-risk actions in window before revocation (default 5) */
|
|
96
|
+
revokeAfter: number;
|
|
97
|
+
/** Time window in ms for behavior tracking (default 5 minutes) */
|
|
98
|
+
windowMs: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const DEFAULT_THRESHOLDS: RiskThresholds = {
|
|
102
|
+
flag: 60,
|
|
103
|
+
deny: 85,
|
|
104
|
+
revokeAfter: 5,
|
|
105
|
+
windowMs: 5 * 60 * 1000,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* In-memory tracker for repeated high-risk behavior per subject.
|
|
110
|
+
*/
|
|
111
|
+
class BehaviorTracker {
|
|
112
|
+
// subjectId → timestamps of high-risk actions
|
|
113
|
+
private history: Map<string, number[]> = new Map();
|
|
114
|
+
private windowMs: number;
|
|
115
|
+
|
|
116
|
+
constructor(windowMs: number) {
|
|
117
|
+
this.windowMs = windowMs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
record(subjectId: string): void {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const entries = this.history.get(subjectId) ?? [];
|
|
123
|
+
entries.push(now);
|
|
124
|
+
this.history.set(subjectId, entries);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Count of high-risk actions within the sliding window. */
|
|
128
|
+
count(subjectId: string): number {
|
|
129
|
+
const cutoff = Date.now() - this.windowMs;
|
|
130
|
+
const entries = this.history.get(subjectId) ?? [];
|
|
131
|
+
const recent = entries.filter((t) => t > cutoff);
|
|
132
|
+
// Prune old entries
|
|
133
|
+
this.history.set(subjectId, recent);
|
|
134
|
+
return recent.length;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class RiskEngine {
|
|
139
|
+
private thresholds: RiskThresholds;
|
|
140
|
+
private tracker: BehaviorTracker;
|
|
141
|
+
private customPatterns: RiskPattern[];
|
|
142
|
+
|
|
143
|
+
constructor(opts?: { thresholds?: Partial<RiskThresholds>; customPatterns?: RiskPattern[] }) {
|
|
144
|
+
this.thresholds = { ...DEFAULT_THRESHOLDS, ...opts?.thresholds };
|
|
145
|
+
this.tracker = new BehaviorTracker(this.thresholds.windowMs);
|
|
146
|
+
this.customPatterns = opts?.customPatterns ?? [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Score a tool call.
|
|
151
|
+
* @param toolName The MCP tool being called
|
|
152
|
+
* @param argsJson JSON string of arguments (optional)
|
|
153
|
+
* @param subjectId Who is making the call (API key ID, session subject, or "anonymous")
|
|
154
|
+
*/
|
|
155
|
+
score(toolName: string, argsJson: string | null, subjectId: string = "anonymous"): RiskScore {
|
|
156
|
+
const reasons: string[] = [];
|
|
157
|
+
let baseScore = 20; // default for unknown tools
|
|
158
|
+
|
|
159
|
+
// Check custom patterns first, then defaults
|
|
160
|
+
const allPatterns = [...this.customPatterns, ...DEFAULT_TOOL_RISKS];
|
|
161
|
+
for (const pattern of allPatterns) {
|
|
162
|
+
if (globMatch(pattern.tool, toolName)) {
|
|
163
|
+
baseScore = pattern.baseScore;
|
|
164
|
+
reasons.push(`tool "${toolName}" matches pattern "${pattern.tool}" (base=${pattern.baseScore})`);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (reasons.length === 0) {
|
|
170
|
+
reasons.push(`tool "${toolName}" — no pattern match, using default base score`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Argument analysis
|
|
174
|
+
let argBoost = 0;
|
|
175
|
+
if (argsJson) {
|
|
176
|
+
for (const kw of DANGEROUS_ARG_KEYWORDS) {
|
|
177
|
+
if (kw.pattern.test(argsJson)) {
|
|
178
|
+
argBoost += kw.boost;
|
|
179
|
+
reasons.push(`argument contains "${kw.pattern.source}" (+${kw.boost})`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Behavior escalation
|
|
185
|
+
let behaviorBoost = 0;
|
|
186
|
+
const recentCount = this.tracker.count(subjectId);
|
|
187
|
+
if (recentCount > 0) {
|
|
188
|
+
// Each prior high-risk action in the window adds 5 points
|
|
189
|
+
behaviorBoost = recentCount * 5;
|
|
190
|
+
reasons.push(`${recentCount} high-risk action(s) in window (+${behaviorBoost})`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const raw = baseScore + argBoost + behaviorBoost;
|
|
194
|
+
const score = Math.min(100, Math.max(0, raw));
|
|
195
|
+
|
|
196
|
+
const level = score >= this.thresholds.deny ? "critical"
|
|
197
|
+
: score >= this.thresholds.flag ? "high"
|
|
198
|
+
: score >= 30 ? "medium"
|
|
199
|
+
: "low";
|
|
200
|
+
|
|
201
|
+
// Record if this was a high-risk action
|
|
202
|
+
if (score >= this.thresholds.flag) {
|
|
203
|
+
this.tracker.record(subjectId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { score, baseScore, argBoost, behaviorBoost, level, reasons };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check if the subject should be revoked based on repeated high-risk behavior.
|
|
211
|
+
*/
|
|
212
|
+
shouldRevoke(subjectId: string): boolean {
|
|
213
|
+
return this.tracker.count(subjectId) >= this.thresholds.revokeAfter;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Determine the action based on risk score.
|
|
218
|
+
*/
|
|
219
|
+
evaluate(risk: RiskScore): "allow" | "flag" | "deny" {
|
|
220
|
+
if (risk.score >= this.thresholds.deny) return "deny";
|
|
221
|
+
if (risk.score >= this.thresholds.flag) return "flag";
|
|
222
|
+
return "allow";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
getThresholds(): RiskThresholds {
|
|
226
|
+
return { ...this.thresholds };
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// ── JSON-RPC types ──────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface JsonRpcRequest {
|
|
4
|
+
jsonrpc: "2.0";
|
|
5
|
+
id?: string | number | null;
|
|
6
|
+
method: string;
|
|
7
|
+
params?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface JsonRpcResponse {
|
|
11
|
+
jsonrpc: "2.0";
|
|
12
|
+
id: string | number | null;
|
|
13
|
+
result?: unknown;
|
|
14
|
+
error?: JsonRpcError;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface JsonRpcError {
|
|
18
|
+
code: number;
|
|
19
|
+
message: string;
|
|
20
|
+
data?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse;
|
|
24
|
+
|
|
25
|
+
// ── MCP-specific helpers ────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface McpToolCallParams {
|
|
28
|
+
name: string;
|
|
29
|
+
arguments?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Policy types ────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export type Action = "allow" | "deny";
|
|
35
|
+
export type Verdict = "allow" | "deny" | "passthrough";
|
|
36
|
+
|
|
37
|
+
export interface ToolRule {
|
|
38
|
+
tool: string;
|
|
39
|
+
action: Action;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ServerPolicy {
|
|
43
|
+
server: string;
|
|
44
|
+
default_action: Action;
|
|
45
|
+
tools: ToolRule[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PolicyConfig {
|
|
49
|
+
version: number;
|
|
50
|
+
data_dir: string;
|
|
51
|
+
log_level: "debug" | "info" | "warn" | "error";
|
|
52
|
+
servers: ServerPolicy[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Audit log types ─────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface AuditEntry {
|
|
58
|
+
id?: number;
|
|
59
|
+
timestamp: string;
|
|
60
|
+
server_name: string;
|
|
61
|
+
direction: "request" | "response";
|
|
62
|
+
method: string;
|
|
63
|
+
message_id: string | null;
|
|
64
|
+
tool_name: string | null;
|
|
65
|
+
arguments_json: string | null;
|
|
66
|
+
response_json: string | null;
|
|
67
|
+
verdict: Verdict;
|
|
68
|
+
risk_score: number | null;
|
|
69
|
+
risk_level: string | null;
|
|
70
|
+
policy_hash: string;
|
|
71
|
+
prev_hash: string;
|
|
72
|
+
nonce: string;
|
|
73
|
+
signature: string;
|
|
74
|
+
public_key: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Auth types ──────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export interface ApiKey {
|
|
80
|
+
id: string; // Public identifier (prefix: qk_)
|
|
81
|
+
key_hash: string; // SHA-256 hex of the raw key
|
|
82
|
+
owner_id: string; // Who created it
|
|
83
|
+
label: string; // Human-readable name
|
|
84
|
+
scopes: string; // Comma-separated scopes (e.g. "proxy:read,audit:write")
|
|
85
|
+
created_at: string; // ISO-8601
|
|
86
|
+
expires_at: string | null; // ISO-8601 or null for no expiry
|
|
87
|
+
revoked: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Session {
|
|
91
|
+
id: string; // Opaque session token (UUID v4)
|
|
92
|
+
subject_id: string; // API key ID or user ID
|
|
93
|
+
auth_method: string; // "api_key" | "passkey"
|
|
94
|
+
scopes: string; // Inherited from credential
|
|
95
|
+
issued_at: string; // ISO-8601
|
|
96
|
+
expires_at: string; // ISO-8601 (default: issued_at + 24h)
|
|
97
|
+
revoked: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Crypto types ────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export interface KeyPair {
|
|
103
|
+
publicKey: string; // SPKI PEM
|
|
104
|
+
privateKey: string; // PKCS8 PEM
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Helper: check if message is a JSON-RPC request ──────────────
|
|
108
|
+
|
|
109
|
+
export function isJsonRpcRequest(msg: unknown): msg is JsonRpcRequest {
|
|
110
|
+
if (typeof msg !== "object" || msg === null) return false;
|
|
111
|
+
const obj = msg as Record<string, unknown>;
|
|
112
|
+
return obj.jsonrpc === "2.0" && typeof obj.method === "string";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isJsonRpcResponse(msg: unknown): msg is JsonRpcResponse {
|
|
116
|
+
if (typeof msg !== "object" || msg === null) return false;
|
|
117
|
+
const obj = msg as Record<string, unknown>;
|
|
118
|
+
return obj.jsonrpc === "2.0" && ("result" in obj || "error" in obj);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isToolCallRequest(msg: JsonRpcRequest): boolean {
|
|
122
|
+
return msg.method === "tools/call";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function extractToolInfo(msg: JsonRpcRequest): { name: string; args: Record<string, unknown> } | null {
|
|
126
|
+
if (!isToolCallRequest(msg)) return null;
|
|
127
|
+
const params = msg.params as McpToolCallParams | undefined;
|
|
128
|
+
if (!params?.name) return null;
|
|
129
|
+
return {
|
|
130
|
+
name: params.name,
|
|
131
|
+
args: params.arguments ?? {},
|
|
132
|
+
};
|
|
133
|
+
}
|