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
package/dist/gate/policy.js
CHANGED
|
@@ -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 {
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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((
|
|
407
|
+
...evidenceCoverage.controls.filter((c) => c.automation === "evidence"),
|
|
206
408
|
...toolingCoverage
|
|
207
409
|
];
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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) =>
|
|
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),
|