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
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { createHmac, timingSafeEqual, randomUUID } from "node:crypto";
2
3
  import fg from "fast-glob";
4
+ import { sanitizeErrorMessage } from "./result.js";
3
5
  import { getChangedFiles } from "./diff.js";
4
6
  import { detectSurfaces } from "./findings.js";
5
7
  import { checkRequiredArtifacts } from "./checks/required-artifacts.js";
@@ -11,7 +13,7 @@ import { checkInfra } from "./checks/infra.js";
11
13
  import { checkMobileIos } from "./checks/mobile-ios.js";
12
14
  import { checkMobileAndroid } from "./checks/mobile-android.js";
13
15
  import { checkAi } from "./checks/ai.js";
14
- import { checkScannerReadiness } from "./checks/scanners.js";
16
+ import { checkScannerReadiness, runScanners } from "./checks/scanners.js";
15
17
  import { evaluateEvidenceCoverage } from "./evidence.js";
16
18
  import { applySecurityExceptions } from "./exceptions.js";
17
19
  import { controlApplies, loadControlCatalog } from "./catalog.js";
@@ -24,13 +26,21 @@ import { checkDlp } from "./checks/dlp.js";
24
26
  import { runSbomChecks } from "./checks/sbom.js";
25
27
  import { runPlaybookChecks } from "./checks/playbook.js";
26
28
  import { runAiRedteamChecks } from "./checks/ai-redteam.js";
27
- import { runRuntimeChecks } from "./checks/runtime.js";
29
+ import { runRuntimeChecks, runDockerChecks } from "./checks/runtime.js";
28
30
  import { runCiPipelineChecks } from "./checks/ci-pipeline.js";
29
31
  import { runNucleiChecks } from "./checks/nuclei.js";
30
32
  import { getCommitHash, loadBaseline, saveBaseline, compareBaseline } from "./baseline.js";
31
33
  import { checkInjectionDeep } from "./checks/injection-deep.js";
32
34
  import { checkAuthDeep } from "./checks/auth-deep.js";
33
- import { randomUUID } from "node:crypto";
35
+ import { checkSupplyChainDeep } from "./checks/supply-chain-deep.js";
36
+ import { checkBusinessLogic } from "./checks/business-logic.js";
37
+ import { checkAgenticInstructions } from "./checks/agentic-instructions.js";
38
+ import { checkAiGovernance } from "./checks/ai-governance.js";
39
+ import { checkIac } from "./checks/iac.js";
40
+ import { checkGitOps } from "./checks/gitops.js";
41
+ import { checkDataPlatform } from "./checks/data-platform.js";
42
+ import { checkDockerDeep } from "./checks/docker-deep.js";
43
+ import { checkCloudControls } from "./checks/cloud-controls.js";
34
44
  const PolicySchema = z.object({
35
45
  name: z.string(),
36
46
  version: z.string(),
@@ -47,7 +57,16 @@ const PolicySchema = z.object({
47
57
  type: z.enum(["gate", "control"]).default("gate"),
48
58
  evidence: z.array(z.string()).default([])
49
59
  }))
50
- .default([])
60
+ .default([]),
61
+ // Fix 6: configurable severity blocking list
62
+ severity_block: z.array(z.string()).optional(),
63
+ // Fix 7: exceptions config with require_ticket
64
+ exceptions: z
65
+ .object({
66
+ require_ticket: z.boolean().optional(),
67
+ approval_roles: z.array(z.string()).optional()
68
+ })
69
+ .optional()
51
70
  });
52
71
  const SCOPE_IGNORE_GLOBS = ["**/node_modules/**", "**/.git/**", "**/dist/**"];
53
72
  const SAFE_SCOPE_TARGET_RE = /^[a-zA-Z0-9_./-]+$/;
@@ -74,23 +93,82 @@ async function resolveScopedFiles(opts) {
74
93
  const files = await fg(targets, {
75
94
  onlyFiles: true,
76
95
  dot: true,
77
- ignore: SCOPE_IGNORE_GLOBS
96
+ ignore: SCOPE_IGNORE_GLOBS,
97
+ followSymbolicLinks: false
78
98
  });
79
- return Array.from(new Set(files)).sort();
99
+ return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b));
80
100
  }
81
101
  const folderGlobs = targets.map((target) => `${target.replace(/\/+$/, "")}/**/*`);
82
102
  const files = await fg(folderGlobs, {
83
103
  onlyFiles: true,
84
104
  dot: true,
85
- ignore: SCOPE_IGNORE_GLOBS
105
+ ignore: SCOPE_IGNORE_GLOBS,
106
+ followSymbolicLinks: false
86
107
  });
87
- return Array.from(new Set(files)).sort();
108
+ return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b));
109
+ }
110
+ // POC-8 fix: HMAC-SHA256 verification of the policy file on load.
111
+ // Minimal tamper that bypasses all HIGH/CRITICAL findings: change
112
+ // "severity_block": ["HIGH", "CRITICAL"] → "severity_block": []
113
+ // With HMAC verification that tampered file is detected and rejected.
114
+ const POLICY_HMAC_MIN_KEY_BYTES = 32;
115
+ function getPolicyHmacKey() {
116
+ const key = process.env["SECURITY_POLICY_HMAC_KEY"];
117
+ if (!key)
118
+ return null;
119
+ if (Buffer.byteLength(key, "utf-8") < POLICY_HMAC_MIN_KEY_BYTES) {
120
+ throw new Error(`SECURITY_POLICY_HMAC_KEY is too short (${Buffer.byteLength(key, "utf-8")} bytes). ` +
121
+ `Minimum ${POLICY_HMAC_MIN_KEY_BYTES} bytes required.`);
122
+ }
123
+ return key;
124
+ }
125
+ /**
126
+ * Write the HMAC signature for a policy file to <policyPath>.hmac.
127
+ * Call this after generating or updating the policy. Not exported from the
128
+ * module — callers use the CLI helper `security-mcp sign-policy`.
129
+ */
130
+ export function signPolicyFile(raw, key) {
131
+ return createHmac("sha256", key).update(raw, "utf-8").digest("hex");
88
132
  }
89
133
  export async function loadPolicy(policyPath) {
90
134
  const raw = await readFileSafe(policyPath);
135
+ // POC-8: verify HMAC when a key is configured
136
+ const hmacKey = getPolicyHmacKey();
137
+ // TM-001: warn when HMAC protection is absent so operators know the policy file
138
+ // can be silently tampered (e.g. severity_block cleared) without detection.
139
+ // Non-blocking — allows operation without the key — but makes the risk visible.
140
+ // Only warn in non-gate contexts — in gate mode stdout is JSON and mixing
141
+ // stderr into the output file (via 2>&1 hooks) would corrupt JSON parsing.
142
+ if (!hmacKey && !process.env["SECURITY_GATE_POLICY"]) {
143
+ console.warn("[loadPolicy] WARNING: SECURITY_POLICY_HMAC_KEY is not set. " +
144
+ "Policy file integrity is NOT verified — a local attacker could silently edit " +
145
+ `"${policyPath}" (e.g. clear severity_block) without detection. ` +
146
+ "Set SECURITY_POLICY_HMAC_KEY (≥32 bytes) and run `security-mcp sign-policy` to enable tamper protection.");
147
+ }
148
+ if (hmacKey) {
149
+ let storedSig = null;
150
+ try {
151
+ storedSig = (await readFileSafe(`${policyPath}.hmac`)).trim();
152
+ }
153
+ catch {
154
+ // .hmac sidecar missing — reject to prevent stripping the sig to bypass verification
155
+ throw new Error(`[loadPolicy] Policy file "${policyPath}" has no .hmac sidecar but ` +
156
+ `SECURITY_POLICY_HMAC_KEY is set. Generate a signature with: security-mcp sign-policy`);
157
+ }
158
+ const expected = createHmac("sha256", hmacKey).update(raw, "utf-8").digest("hex");
159
+ const storedBuf = Buffer.from(storedSig, "hex");
160
+ const expectedBuf = Buffer.from(expected, "hex");
161
+ const valid = storedBuf.length === expectedBuf.length && timingSafeEqual(storedBuf, expectedBuf);
162
+ if (!valid) {
163
+ throw new Error(`[loadPolicy] HMAC verification failed for "${policyPath}" — policy file may have been tampered. ` +
164
+ `Re-sign with: security-mcp sign-policy`);
165
+ }
166
+ }
91
167
  const parsed = JSON.parse(raw);
92
168
  return PolicySchema.parse(parsed);
93
169
  }
170
+ // Fix 8: pattern to detect security-relevant config files that must not get docs-tier bypass
171
+ const SECURITY_CONFIG_RE = /security-exceptions|security-policy|security-tools|\.checkov\.yaml|\.github\/workflows\//i;
94
172
  /**
95
173
  * Classify the change type based on file paths to apply appropriate gate tier.
96
174
  */
@@ -99,8 +177,14 @@ function classifyChangeType(files) {
99
177
  return "general";
100
178
  const allMatch = (pattern) => files.every((f) => pattern.test(f));
101
179
  const anyMatch = (pattern) => files.some((f) => pattern.test(f));
102
- if (allMatch(/\.(md|txt|rst)$|\/docs\/|README/i))
180
+ if (allMatch(/\.(md|txt|rst)$|\/docs\/|README/i)) {
181
+ // Fix 8: override docs tier when security config files are in the changeset
182
+ if (anyMatch(SECURITY_CONFIG_RE)) {
183
+ console.warn("[policy] Docs-tier override: security configuration file detected in changed files");
184
+ return "config";
185
+ }
103
186
  return "docs";
187
+ }
104
188
  if (anyMatch(/\/payment|\/stripe|\/checkout|\/billing|\/invoice/i))
105
189
  return "payment";
106
190
  if (anyMatch(/\/auth|\/login|\/session|\/token|\/jwt|\/oauth|\/permission/i))
@@ -123,6 +207,175 @@ function assignRiskSlas(findings) {
123
207
  const now = new Date().toISOString();
124
208
  return findings.map((f) => ({ ...f, sla: SLA_MAP[f.severity], slaAssignedAt: now }));
125
209
  }
210
+ // Names aligned with check array order in runAllChecks — used for GATE_CHECK_CRASHED findings
211
+ const CHECK_NAMES = [
212
+ "required-artifacts",
213
+ "secrets",
214
+ "dependencies",
215
+ "scanner-readiness",
216
+ "evidence-coverage",
217
+ "web-nextjs",
218
+ "api",
219
+ "infra",
220
+ "mobile-ios",
221
+ "mobile-android",
222
+ "ai",
223
+ "graphql",
224
+ "kubernetes",
225
+ "database",
226
+ "crypto",
227
+ "dlp",
228
+ "sbom",
229
+ "playbook",
230
+ "ai-redteam",
231
+ "runtime",
232
+ "ci-pipeline",
233
+ "nuclei",
234
+ "injection-deep",
235
+ "auth-deep",
236
+ "supply-chain-deep",
237
+ "business-logic",
238
+ "docker",
239
+ "scanners-run",
240
+ "agentic-instructions",
241
+ "ai-governance",
242
+ "iac",
243
+ "gitops",
244
+ "data-platform",
245
+ "docker-deep",
246
+ "cloud-controls"
247
+ ];
248
+ /** Run every applicable security check in parallel and collect findings. */
249
+ async function runAllChecks(opts) {
250
+ const { policy, changedFiles, targets, surfaces, scannerReadiness, evidenceCoverage } = opts;
251
+ const stagingUrl = process.env["SECURITY_STAGING_URL"];
252
+ const isApiOrWeb = surfaces.api || surfaces.web;
253
+ const settled = await Promise.allSettled([
254
+ checkRequiredArtifacts({ policy, changedFiles }),
255
+ checkSecrets({ changedFiles }),
256
+ checkDependencies({ changedFiles }),
257
+ Promise.resolve(scannerReadiness.findings),
258
+ Promise.resolve(evidenceCoverage.findings),
259
+ surfaces.web ? checkWebNextjs({ changedFiles }) : Promise.resolve([]),
260
+ surfaces.api ? checkApi({ changedFiles }) : Promise.resolve([]),
261
+ surfaces.infra ? checkInfra({ changedFiles }) : Promise.resolve([]),
262
+ surfaces.mobileIos ? checkMobileIos({ changedFiles }) : Promise.resolve([]),
263
+ surfaces.mobileAndroid ? checkMobileAndroid({ changedFiles }) : Promise.resolve([]),
264
+ surfaces.ai ? checkAi({ changedFiles }) : Promise.resolve([]),
265
+ checkGraphQL({ changedFiles }),
266
+ checkKubernetes({ changedFiles }),
267
+ checkDatabase({ changedFiles }),
268
+ checkCrypto({ changedFiles }),
269
+ checkDlp({ changedFiles }),
270
+ runSbomChecks({ changedFiles, targets }),
271
+ runPlaybookChecks({ changedFiles, surfaces }),
272
+ surfaces.ai ? runAiRedteamChecks({ changedFiles }) : Promise.resolve([]),
273
+ stagingUrl ? runRuntimeChecks({ targets, changedFiles }) : Promise.resolve([]),
274
+ runCiPipelineChecks({ changedFiles }),
275
+ stagingUrl ? runNucleiChecks({ changedFiles }) : Promise.resolve([]),
276
+ isApiOrWeb ? checkInjectionDeep({ changedFiles }) : Promise.resolve([]),
277
+ isApiOrWeb ? checkAuthDeep({ changedFiles }) : Promise.resolve([]),
278
+ checkSupplyChainDeep({ changedFiles }),
279
+ checkBusinessLogic({ changedFiles }),
280
+ runDockerChecks({ changedFiles }),
281
+ runScanners({ surfaces, changedFiles }),
282
+ surfaces.agentic ? checkAgenticInstructions({ changedFiles }) : Promise.resolve([]),
283
+ surfaces.ai ? checkAiGovernance({ changedFiles }) : Promise.resolve([]),
284
+ checkIac({ changedFiles }),
285
+ checkGitOps({ changedFiles }),
286
+ checkDataPlatform({ changedFiles }),
287
+ checkDockerDeep({ changedFiles }),
288
+ checkCloudControls({ changedFiles })
289
+ ]);
290
+ const findings = [];
291
+ // Fix 5: crashed check modules generate HIGH findings instead of silent console.warn
292
+ for (let i = 0; i < settled.length; i++) {
293
+ const r = settled[i];
294
+ if (r.status === "fulfilled") {
295
+ findings.push(...r.value);
296
+ }
297
+ else {
298
+ const checkName = CHECK_NAMES[i] ?? `check-${i}`;
299
+ // CWE-200: sanitize error message before embedding in gate findings —
300
+ // raw Error.message can contain absolute filesystem paths that reveal
301
+ // internal directory structure to callers of the gate result.
302
+ const rawErrorMessage = r.reason instanceof Error ? r.reason.message : String(r.reason);
303
+ const errorMessage = sanitizeErrorMessage(rawErrorMessage);
304
+ findings.push({
305
+ id: "GATE_CHECK_CRASHED",
306
+ title: "Security check module crashed — coverage gap",
307
+ severity: "HIGH",
308
+ evidence: [`Check module: ${checkName}`, `Error: ${errorMessage}`],
309
+ requiredActions: [
310
+ `The ${checkName} check module threw an unhandled error: ${errorMessage}. Findings from this module are unavailable, which may constitute a false negative.`
311
+ ]
312
+ });
313
+ }
314
+ }
315
+ return findings;
316
+ }
317
+ /** Build tooling-based control coverage from the catalog. */
318
+ function buildToolingCoverage(catalog, surfaces, scannerReadiness) {
319
+ return catalog.controls
320
+ .filter((c) => c.automation === "tooling" && controlApplies(c, surfaces))
321
+ .map((c) => {
322
+ const required = c.required_scanners ?? [];
323
+ const missing = required.filter((id) => !scannerReadiness.configured.includes(id) || scannerReadiness.missing.includes(id));
324
+ return {
325
+ id: c.id,
326
+ description: c.description,
327
+ automation: c.automation,
328
+ frameworks: c.frameworks,
329
+ status: missing.length > 0 ? "missing" : "satisfied",
330
+ details: missing.length > 0 ? missing : required
331
+ };
332
+ });
333
+ }
334
+ /** Compute coverage and confidence scores from control coverage + scanner readiness. */
335
+ function computeConfidence(controlCoverage, scannerReadiness) {
336
+ const relevant = controlCoverage.filter((c) => c.status !== "not_applicable");
337
+ const satisfied = relevant.filter((c) => c.status === "satisfied").length;
338
+ const riskAccepted = relevant.filter((c) => c.status === "risk_accepted").length;
339
+ const missing = relevant.filter((c) => c.status === "missing").length;
340
+ const automatedCoverage = relevant.length === 0
341
+ ? 100
342
+ : Math.round((satisfied + riskAccepted * 0.5) / relevant.length * 100);
343
+ const { configured, missing: scanMissing } = scannerReadiness;
344
+ const scannerScore = configured.length === 0
345
+ ? 0
346
+ : Math.round((configured.length - scanMissing.length) / configured.length * 100);
347
+ const confidenceScore = Math.max(0, Math.min(100, Math.round(automatedCoverage * 0.7 + scannerScore * 0.3)));
348
+ return { automatedCoverage, scannerScore, confidenceScore, missingControls: missing, riskAcceptedControls: riskAccepted };
349
+ }
350
+ /** Inject regression findings when a baseline exists and controls have regressed. */
351
+ function applyBaselineDiff(findings, controlCoverage, previousBaseline, changedFiles, surfaces, confidence) {
352
+ const snapshot = {
353
+ findings,
354
+ controlCoverage,
355
+ confidence: { automatedCoverage: confidence.automatedCoverage, score: 0, missingControls: 0, scannerReadiness: 0, summary: "" },
356
+ status: "PASS",
357
+ policyVersion: "",
358
+ evaluatedAt: "",
359
+ scope: { changedFiles, surfaces }
360
+ };
361
+ const diff = compareBaseline(snapshot, previousBaseline);
362
+ if (diff.regressions.length === 0)
363
+ return { findings, diff };
364
+ const regressionFindings = diff.regressions.map((r) => ({
365
+ id: "BASELINE_REGRESSION",
366
+ title: `Security regression: control "${r.controlId}" was previously satisfied but is now missing`,
367
+ severity: "HIGH",
368
+ evidence: [`Control ${r.controlId}: "satisfied" → "missing" since last gate run`],
369
+ requiredActions: [
370
+ `Restore control "${r.controlId}" to a satisfied state.`,
371
+ "Investigate what change caused this regression and revert or remediate."
372
+ ]
373
+ }));
374
+ return { findings: [...regressionFindings, ...findings], diff };
375
+ }
376
+ // ---------------------------------------------------------------------------
377
+ // Main gate entry point
378
+ // ---------------------------------------------------------------------------
126
379
  export async function runPrGate(opts) {
127
380
  const [policy, commitHash, previousBaseline] = await Promise.all([
128
381
  loadPolicy(opts.policyPath),
@@ -132,133 +385,69 @@ export async function runPrGate(opts) {
132
385
  const mode = opts.mode ?? "recent_changes";
133
386
  const targets = normalizeTargets(opts.targets);
134
387
  const changedFiles = await resolveScopedFiles({
135
- mode,
136
- targets,
388
+ mode, targets,
137
389
  baseRef: opts.baseRef ?? "origin/main",
138
390
  headRef: opts.headRef ?? "HEAD"
139
391
  });
140
- // Classify the change type to apply appropriate gate tier
141
392
  const changeType = classifyChangeType(changedFiles);
142
393
  const surfaces = detectSurfaces(changedFiles);
143
- const catalog = await loadControlCatalog();
144
- const scannerReadiness = await checkScannerReadiness({ surfaces });
145
- const evidenceCoverage = await evaluateEvidenceCoverage({ policy, surfaces });
146
- let rawFindings;
147
- // "docs" tier: only run secrets check to avoid unnecessary overhead
148
- if (changeType === "docs") {
149
- rawFindings = await checkSecrets({ changedFiles });
150
- }
151
- else {
152
- // Run all independent checks in parallel
153
- const checkResults = await Promise.allSettled([
154
- checkRequiredArtifacts({ policy, changedFiles }),
155
- checkSecrets({ changedFiles }),
156
- checkDependencies({ changedFiles }),
157
- Promise.resolve(scannerReadiness.findings),
158
- Promise.resolve(evidenceCoverage.findings),
159
- surfaces.web ? checkWebNextjs({ changedFiles }) : Promise.resolve([]),
160
- surfaces.api ? checkApi({ changedFiles }) : Promise.resolve([]),
161
- surfaces.infra ? checkInfra({ changedFiles }) : Promise.resolve([]),
162
- surfaces.mobileIos ? checkMobileIos({ changedFiles }) : Promise.resolve([]),
163
- surfaces.mobileAndroid ? checkMobileAndroid({ changedFiles }) : Promise.resolve([]),
164
- surfaces.ai ? checkAi({ changedFiles }) : Promise.resolve([]),
165
- checkGraphQL({ changedFiles }),
166
- checkKubernetes({ changedFiles }),
167
- checkDatabase({ changedFiles }),
168
- checkCrypto({ changedFiles }),
169
- checkDlp({ changedFiles }),
170
- runSbomChecks({ changedFiles, targets }),
171
- runPlaybookChecks({ changedFiles, surfaces }),
172
- surfaces.ai ? runAiRedteamChecks({ changedFiles }) : Promise.resolve([]),
173
- process.env["SECURITY_STAGING_URL"] ? runRuntimeChecks({ targets, changedFiles }) : Promise.resolve([]),
174
- runCiPipelineChecks({ changedFiles }),
175
- process.env["SECURITY_STAGING_URL"] ? runNucleiChecks({ changedFiles }) : Promise.resolve([]),
176
- (surfaces.api || surfaces.web) ? checkInjectionDeep({ changedFiles }) : Promise.resolve([]),
177
- (surfaces.api || surfaces.web) ? checkAuthDeep({ changedFiles }) : Promise.resolve([])
178
- ]);
179
- rawFindings = [];
180
- for (const result of checkResults) {
181
- if (result.status === "fulfilled") {
182
- rawFindings.push(...result.value);
183
- }
184
- else {
185
- console.warn("[policy] Check failed:", result.reason);
186
- }
187
- }
188
- }
189
- rawFindings = assignRiskSlas(rawFindings);
190
- const toolingCoverage = catalog.controls
191
- .filter((control) => control.automation === "tooling" && controlApplies(control, surfaces))
192
- .map((control) => {
193
- const required = control.required_scanners ?? [];
194
- const missing = required.filter((scannerId) => !scannerReadiness.configured.includes(scannerId) || scannerReadiness.missing.includes(scannerId));
195
- return {
196
- id: control.id,
197
- description: control.description,
198
- automation: control.automation,
199
- frameworks: control.frameworks,
200
- status: missing.length > 0 ? "missing" : "satisfied",
201
- details: missing.length > 0 ? missing : required
202
- };
203
- });
394
+ const [catalog, scannerReadiness, evidenceCoverage] = await Promise.all([
395
+ loadControlCatalog(),
396
+ checkScannerReadiness({ surfaces }),
397
+ evaluateEvidenceCoverage({ policy, surfaces })
398
+ ]);
399
+ // Collect raw findings — docs tier runs secrets-only to reduce overhead
400
+ const rawChecked = changeType === "docs"
401
+ ? await checkSecrets({ changedFiles })
402
+ : await runAllChecks({ policy, changedFiles, targets, surfaces, scannerReadiness, evidenceCoverage });
403
+ const rawFindings = assignRiskSlas(rawChecked);
404
+ // Build control coverage
405
+ const toolingCoverage = buildToolingCoverage(catalog, surfaces, scannerReadiness);
204
406
  const controlCoverage = [
205
- ...evidenceCoverage.controls.filter((control) => control.automation === "evidence"),
407
+ ...evidenceCoverage.controls.filter((c) => c.automation === "evidence"),
206
408
  ...toolingCoverage
207
409
  ];
208
- const exceptionResult = await applySecurityExceptions(rawFindings);
410
+ // Apply exceptions Fix 7: pass require_ticket from policy config
411
+ const requireTicket = policy.exceptions?.require_ticket ?? false;
412
+ const exceptionResult = await applySecurityExceptions(rawFindings, { requireTicket });
209
413
  const controlCoverageWithExceptions = controlCoverage.map((control) => {
210
- if (exceptionResult.activeControlExceptionIds.includes(control.id) && control.status === "missing") {
211
- return {
212
- ...control,
213
- status: "risk_accepted",
214
- details: [...control.details, "Covered by an active approved control exception."]
215
- };
414
+ const excepted = exceptionResult.activeControlExceptionIds.includes(control.id);
415
+ if (excepted && control.status === "missing") {
416
+ return { ...control, status: "risk_accepted", details: [...control.details, "Covered by an active approved control exception."] };
216
417
  }
217
418
  return control;
218
419
  });
219
- const findings = [...exceptionResult.findings, ...exceptionResult.exceptionFindings];
220
- // Apply risk-based adaptive gating tier overrides
221
- let effectiveFindings = findings;
222
- if (changeType === "payment") {
223
- // Payment changes: treat as prod-equivalent — block on all HIGH+
224
- effectiveFindings = findings;
225
- }
226
- else if (changeType === "auth") {
227
- // Auth changes: always block on HIGH+ even in dev
228
- effectiveFindings = findings;
229
- }
230
- const relevantControls = controlCoverageWithExceptions.filter((control) => control.status !== "not_applicable");
231
- const satisfiedControls = relevantControls.filter((control) => control.status === "satisfied").length;
232
- const riskAcceptedControls = relevantControls.filter((control) => control.status === "risk_accepted").length;
233
- const automatedCoverage = relevantControls.length === 0
234
- ? 100
235
- : Math.round((((satisfiedControls) + (riskAcceptedControls * 0.5)) / relevantControls.length) * 100);
236
- const scannerScore = scannerReadiness.configured.length === 0
237
- ? 0
238
- : Math.round(((scannerReadiness.configured.length - scannerReadiness.missing.length) / scannerReadiness.configured.length) * 100);
239
- const confidenceScore = Math.max(0, Math.min(100, Math.round((automatedCoverage * 0.7) + (scannerScore * 0.3))));
240
- const missingControls = relevantControls.filter((control) => control.status === "missing").length;
241
- // Baseline regression detection: compare current run against previous baseline
420
+ // Include exception warnings (e.g. CI_EXCEPTIONS_IN_LOCAL_SCAN, EXCEPTIONS_FILE_UNSIGNED) in findings
421
+ const baseFindings = [...exceptionResult.findings, ...exceptionResult.exceptionFindings, ...exceptionResult.warnings];
422
+ // Confidence metrics
423
+ const cm = computeConfidence(controlCoverageWithExceptions, scannerReadiness);
424
+ // Baseline regression injection
425
+ let effectiveFindings = baseFindings;
242
426
  let baselineDiff;
243
427
  if (previousBaseline) {
244
- baselineDiff = compareBaseline({ findings: effectiveFindings, controlCoverage: controlCoverageWithExceptions, confidence: { automatedCoverage, score: 0, missingControls: 0, scannerReadiness: 0, summary: "" }, status: "PASS", policyVersion: "", evaluatedAt: "", scope: { changedFiles, surfaces } }, previousBaseline);
245
- if (baselineDiff.regressions.length > 0) {
246
- const regressionFindings = baselineDiff.regressions.map((r) => ({
247
- id: "BASELINE_REGRESSION",
248
- title: `Security regression: control "${r.controlId}" was previously satisfied but is now missing`,
249
- severity: "HIGH",
250
- evidence: [`Control ${r.controlId}: "satisfied" "missing" since last gate run`],
251
- requiredActions: [
252
- `Restore control "${r.controlId}" to a satisfied state.`,
253
- "Investigate what change caused this regression and revert or remediate."
254
- ]
255
- }));
256
- effectiveFindings = [...regressionFindings, ...effectiveFindings];
428
+ const br = applyBaselineDiff(baseFindings, controlCoverageWithExceptions, previousBaseline, changedFiles, surfaces, cm);
429
+ effectiveFindings = br.findings;
430
+ baselineDiff = br.diff;
431
+ }
432
+ // Fix 6: read severity_block from policy instead of hardcoding HIGH/CRITICAL
433
+ let blockedSeverities = policy.severity_block ?? ["HIGH", "CRITICAL"];
434
+ // SECURITY (silent-bypass hardening): when the policy file is NOT integrity-verified
435
+ // (no SECURITY_POLICY_HMAC_KEY — the default), an attacker who can edit the unsigned
436
+ // .mcp/policies/security-policy.json could set "severity_block": [] and force every
437
+ // verdict to PASS with unlimited HIGH/CRITICAL findings. Refuse to let an unverified
438
+ // policy RELAX the gate below the safe HIGH/CRITICAL floor. To intentionally weaken it,
439
+ // operators must sign the policy (SECURITY_POLICY_HMAC_KEY + `security-mcp sign-policy`).
440
+ // When a key IS set, loadPolicy has already HMAC-verified the file (or thrown), so the
441
+ // operator's configured severity_block is trusted as-is.
442
+ const policyIntegrityVerified = !!process.env["SECURITY_POLICY_HMAC_KEY"];
443
+ if (!policyIntegrityVerified) {
444
+ for (const floor of ["HIGH", "CRITICAL"]) {
445
+ if (!blockedSeverities.includes(floor))
446
+ blockedSeverities = [...blockedSeverities, floor];
257
447
  }
258
448
  }
259
- const status = effectiveFindings.some((f) => f.severity === "HIGH" || f.severity === "CRITICAL")
260
- ? "FAIL"
261
- : "PASS";
449
+ const status = effectiveFindings.some((f) => blockedSeverities.includes(f.severity))
450
+ ? "FAIL" : "PASS";
262
451
  const result = {
263
452
  status,
264
453
  policyVersion: policy.version,
@@ -267,17 +456,14 @@ export async function runPrGate(opts) {
267
456
  findings: effectiveFindings,
268
457
  suppressedFindings: exceptionResult.suppressed,
269
458
  controlCoverage: controlCoverageWithExceptions,
270
- scannerReadiness: {
271
- configured: scannerReadiness.configured,
272
- missing: scannerReadiness.missing
273
- },
459
+ scannerReadiness: { configured: scannerReadiness.configured, missing: scannerReadiness.missing },
274
460
  confidence: {
275
- score: confidenceScore,
276
- automatedCoverage,
277
- missingControls,
278
- riskAcceptedControls,
279
- scannerReadiness: scannerScore,
280
- summary: `Automated coverage ${automatedCoverage}%, scanner readiness ${scannerScore}%, missing controls ${missingControls}, risk-accepted controls ${riskAcceptedControls}. Change type: ${changeType}.`
461
+ score: cm.confidenceScore,
462
+ automatedCoverage: cm.automatedCoverage,
463
+ missingControls: cm.missingControls,
464
+ riskAcceptedControls: cm.riskAcceptedControls,
465
+ scannerReadiness: cm.scannerScore,
466
+ summary: `Automated coverage ${cm.automatedCoverage}%, scanner readiness ${cm.scannerScore}%, missing controls ${cm.missingControls}, risk-accepted controls ${cm.riskAcceptedControls}. Change type: ${changeType}.`
281
467
  },
282
468
  baselineDiff
283
469
  };
@@ -139,6 +139,12 @@ export async function checkActiveExploitation(cveIds, cacheDir) {
139
139
  if (cveIds.length === 0) {
140
140
  return { kevMatches: [], highEpss: [], failed: false };
141
141
  }
142
+ // CWE-200: the EPSS lookup places this repo's CVE IDs in a cleartext query to
143
+ // a third party (api.first.org). Operators of private repos can disable all
144
+ // threat-intel egress with SECURITY_OFFLINE so the unpatched-CVE set never leaves.
145
+ if (process.env["SECURITY_OFFLINE"] === "1" || process.env["SECURITY_OFFLINE"] === "true") {
146
+ return { kevMatches: [], highEpss: [], failed: false };
147
+ }
142
148
  try {
143
149
  const [kevSet, epssMap] = await Promise.all([
144
150
  fetchCisaKev(cacheDir),