security-mcp 1.1.4 → 1.3.1
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 +116 -264
- 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/security-policy.json +2 -2
- package/dist/cli/index.js +0 -0
- package/dist/gate/baseline.js +82 -7
- package/dist/gate/catalog.js +10 -2
- 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/crypto.js +423 -2
- package/dist/gate/checks/dependencies.js +571 -15
- package/dist/gate/checks/graphql.js +201 -19
- package/dist/gate/checks/infra.js +246 -1
- package/dist/gate/checks/injection-deep.js +827 -184
- package/dist/gate/checks/k8s.js +114 -1
- 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 +244 -13
- package/dist/gate/checks/supply-chain-deep.js +787 -0
- package/dist/gate/checks/web-nextjs.js +572 -48
- package/dist/gate/diff.js +17 -5
- package/dist/gate/evidence.js +8 -1
- package/dist/gate/exceptions.js +131 -9
- package/dist/gate/policy.js +280 -131
- package/dist/mcp/audit-chain.js +122 -28
- package/dist/mcp/auth.js +169 -0
- package/dist/mcp/learning.js +129 -4
- package/dist/mcp/model-router.js +158 -21
- package/dist/mcp/orchestration.js +186 -51
- package/dist/mcp/server.js +337 -53
- package/dist/repo/fs.js +24 -1
- package/dist/repo/search.js +31 -6
- package/dist/review/store.js +52 -1
- package/package.json +7 -7
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +109 -0
- package/skills/agentic-loop-exploiter/SKILL.md +368 -0
- package/skills/ai-llm-redteam/SKILL.md +104 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +103 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +98 -0
- package/skills/android-penetration-tester/SKILL.md +455 -46
- package/skills/anti-replay-tester/SKILL.md +106 -0
- package/skills/appsec-code-auditor/SKILL.md +85 -0
- package/skills/artifact-integrity-analyst/SKILL.md +441 -0
- package/skills/attack-navigator/SKILL.md +467 -8
- package/skills/auth-session-hacker/SKILL.md +102 -0
- package/skills/aws-penetration-tester/SKILL.md +456 -0
- package/skills/azure-penetration-tester/SKILL.md +490 -3
- package/skills/binary-auth-validator/SKILL.md +111 -0
- package/skills/bot-detection-specialist/SKILL.md +109 -0
- package/skills/business-logic-attacker/SKILL.md +231 -0
- package/skills/capec-code-mapper/SKILL.md +84 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +112 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +405 -0
- package/skills/ciso-orchestrator/SKILL.md +454 -43
- package/skills/cloud-infra-specialist/SKILL.md +118 -0
- package/skills/compliance-gap-analyst/SKILL.md +422 -0
- package/skills/compliance-grc/SKILL.md +85 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +84 -0
- package/skills/credential-stuffing-specialist/SKILL.md +102 -0
- package/skills/crypto-pki-specialist/SKILL.md +87 -0
- package/skills/csa-ccm-mapper/SKILL.md +84 -0
- package/skills/csf2-governance-mapper/SKILL.md +84 -0
- package/skills/deep-link-fuzzer/SKILL.md +109 -0
- package/skills/dependency-confusion-attacker/SKILL.md +415 -0
- package/skills/device-integrity-aggregator/SKILL.md +108 -0
- package/skills/dos-resilience-tester/SKILL.md +97 -0
- package/skills/dread-scorer/SKILL.md +84 -0
- package/skills/egress-policy-enforcer/SKILL.md +99 -0
- package/skills/evidence-collector/SKILL.md +98 -0
- package/skills/file-upload-attacker/SKILL.md +109 -0
- package/skills/gcp-penetration-tester/SKILL.md +459 -2
- package/skills/git-history-secret-scanner/SKILL.md +106 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +152 -0
- package/skills/incident-responder/SKILL.md +111 -0
- package/skills/injection-specialist/SKILL.md +102 -0
- package/skills/ios-security-auditor/SKILL.md +282 -0
- package/skills/json-ambiguity-tester/SKILL.md +0 -0
- package/skills/k8s-container-escaper/SKILL.md +384 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +98 -0
- package/skills/kill-switch-engineer/SKILL.md +102 -0
- package/skills/linddun-privacy-analyst/SKILL.md +102 -0
- package/skills/logic-race-fuzzer/SKILL.md +443 -0
- package/skills/mobile-api-network-attacker/SKILL.md +421 -0
- package/skills/mobile-binary-hardener/SKILL.md +102 -0
- package/skills/mobile-security-specialist/SKILL.md +85 -0
- package/skills/mobile-webview-auditor/SKILL.md +96 -0
- package/skills/model-extraction-attacker/SKILL.md +219 -0
- package/skills/multipart-abuse-tester/SKILL.md +84 -0
- package/skills/oauth-pkce-specialist/SKILL.md +104 -0
- package/skills/parser-exhaustion-tester/SKILL.md +142 -0
- package/skills/pentest-infra/SKILL.md +98 -0
- package/skills/pentest-social/SKILL.md +201 -0
- package/skills/pentest-team/SKILL.md +87 -0
- package/skills/pentest-web-api/SKILL.md +98 -0
- package/skills/privacy-flow-analyst/SKILL.md +234 -0
- package/skills/prompt-injection-specialist/SKILL.md +394 -0
- package/skills/quantum-migration-planner/SKILL.md +96 -0
- package/skills/rag-poisoning-specialist/SKILL.md +358 -0
- package/skills/registry-mirror-enforcer/SKILL.md +84 -0
- package/skills/rotation-validation-agent/SKILL.md +112 -0
- package/skills/samm-assessor/SKILL.md +85 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +100 -0
- package/skills/senior-security-engineer/SKILL.md +167 -0
- package/skills/serialization-memory-attacker/SKILL.md +332 -0
- package/skills/session-timeout-tester/SKILL.md +161 -0
- package/skills/slsa-level3-enforcer/SKILL.md +112 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +102 -0
- package/skills/ssrf-detection-validator/SKILL.md +108 -0
- package/skills/step-up-auth-enforcer/SKILL.md +84 -0
- package/skills/stride-pasta-analyst/SKILL.md +420 -0
- package/skills/supply-chain-devsecops/SKILL.md +98 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +84 -0
- package/skills/threat-modeler/SKILL.md +85 -0
- package/skills/tls-certificate-auditor/SKILL.md +573 -18
- package/skills/token-reuse-detector/SKILL.md +95 -0
- package/skills/trike-risk-modeler/SKILL.md +84 -0
- package/skills/unicode-homograph-tester/SKILL.md +84 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +97 -0
- package/skills/webhook-security-tester/SKILL.md +102 -0
- package/skills/zero-trust-architect/SKILL.md +109 -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,14 @@ 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";
|
|
34
37
|
const PolicySchema = z.object({
|
|
35
38
|
name: z.string(),
|
|
36
39
|
version: z.string(),
|
|
@@ -47,7 +50,16 @@ const PolicySchema = z.object({
|
|
|
47
50
|
type: z.enum(["gate", "control"]).default("gate"),
|
|
48
51
|
evidence: z.array(z.string()).default([])
|
|
49
52
|
}))
|
|
50
|
-
.default([])
|
|
53
|
+
.default([]),
|
|
54
|
+
// Fix 6: configurable severity blocking list
|
|
55
|
+
severity_block: z.array(z.string()).optional(),
|
|
56
|
+
// Fix 7: exceptions config with require_ticket
|
|
57
|
+
exceptions: z
|
|
58
|
+
.object({
|
|
59
|
+
require_ticket: z.boolean().optional(),
|
|
60
|
+
approval_roles: z.array(z.string()).optional()
|
|
61
|
+
})
|
|
62
|
+
.optional()
|
|
51
63
|
});
|
|
52
64
|
const SCOPE_IGNORE_GLOBS = ["**/node_modules/**", "**/.git/**", "**/dist/**"];
|
|
53
65
|
const SAFE_SCOPE_TARGET_RE = /^[a-zA-Z0-9_./-]+$/;
|
|
@@ -74,23 +86,82 @@ async function resolveScopedFiles(opts) {
|
|
|
74
86
|
const files = await fg(targets, {
|
|
75
87
|
onlyFiles: true,
|
|
76
88
|
dot: true,
|
|
77
|
-
ignore: SCOPE_IGNORE_GLOBS
|
|
89
|
+
ignore: SCOPE_IGNORE_GLOBS,
|
|
90
|
+
followSymbolicLinks: false
|
|
78
91
|
});
|
|
79
|
-
return Array.from(new Set(files)).sort();
|
|
92
|
+
return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b));
|
|
80
93
|
}
|
|
81
94
|
const folderGlobs = targets.map((target) => `${target.replace(/\/+$/, "")}/**/*`);
|
|
82
95
|
const files = await fg(folderGlobs, {
|
|
83
96
|
onlyFiles: true,
|
|
84
97
|
dot: true,
|
|
85
|
-
ignore: SCOPE_IGNORE_GLOBS
|
|
98
|
+
ignore: SCOPE_IGNORE_GLOBS,
|
|
99
|
+
followSymbolicLinks: false
|
|
86
100
|
});
|
|
87
|
-
return Array.from(new Set(files)).sort();
|
|
101
|
+
return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b));
|
|
102
|
+
}
|
|
103
|
+
// POC-8 fix: HMAC-SHA256 verification of the policy file on load.
|
|
104
|
+
// Minimal tamper that bypasses all HIGH/CRITICAL findings: change
|
|
105
|
+
// "severity_block": ["HIGH", "CRITICAL"] → "severity_block": []
|
|
106
|
+
// With HMAC verification that tampered file is detected and rejected.
|
|
107
|
+
const POLICY_HMAC_MIN_KEY_BYTES = 32;
|
|
108
|
+
function getPolicyHmacKey() {
|
|
109
|
+
const key = process.env["SECURITY_POLICY_HMAC_KEY"];
|
|
110
|
+
if (!key)
|
|
111
|
+
return null;
|
|
112
|
+
if (Buffer.byteLength(key, "utf-8") < POLICY_HMAC_MIN_KEY_BYTES) {
|
|
113
|
+
throw new Error(`SECURITY_POLICY_HMAC_KEY is too short (${Buffer.byteLength(key, "utf-8")} bytes). ` +
|
|
114
|
+
`Minimum ${POLICY_HMAC_MIN_KEY_BYTES} bytes required.`);
|
|
115
|
+
}
|
|
116
|
+
return key;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Write the HMAC signature for a policy file to <policyPath>.hmac.
|
|
120
|
+
* Call this after generating or updating the policy. Not exported from the
|
|
121
|
+
* module — callers use the CLI helper `security-mcp sign-policy`.
|
|
122
|
+
*/
|
|
123
|
+
export function signPolicyFile(raw, key) {
|
|
124
|
+
return createHmac("sha256", key).update(raw, "utf-8").digest("hex");
|
|
88
125
|
}
|
|
89
126
|
export async function loadPolicy(policyPath) {
|
|
90
127
|
const raw = await readFileSafe(policyPath);
|
|
128
|
+
// POC-8: verify HMAC when a key is configured
|
|
129
|
+
const hmacKey = getPolicyHmacKey();
|
|
130
|
+
// TM-001: warn when HMAC protection is absent so operators know the policy file
|
|
131
|
+
// can be silently tampered (e.g. severity_block cleared) without detection.
|
|
132
|
+
// Non-blocking — allows operation without the key — but makes the risk visible.
|
|
133
|
+
// Only warn in non-gate contexts — in gate mode stdout is JSON and mixing
|
|
134
|
+
// stderr into the output file (via 2>&1 hooks) would corrupt JSON parsing.
|
|
135
|
+
if (!hmacKey && !process.env["SECURITY_GATE_POLICY"]) {
|
|
136
|
+
console.warn("[loadPolicy] WARNING: SECURITY_POLICY_HMAC_KEY is not set. " +
|
|
137
|
+
"Policy file integrity is NOT verified — a local attacker could silently edit " +
|
|
138
|
+
`"${policyPath}" (e.g. clear severity_block) without detection. ` +
|
|
139
|
+
"Set SECURITY_POLICY_HMAC_KEY (≥32 bytes) and run `security-mcp sign-policy` to enable tamper protection.");
|
|
140
|
+
}
|
|
141
|
+
if (hmacKey) {
|
|
142
|
+
let storedSig = null;
|
|
143
|
+
try {
|
|
144
|
+
storedSig = (await readFileSafe(`${policyPath}.hmac`)).trim();
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// .hmac sidecar missing — reject to prevent stripping the sig to bypass verification
|
|
148
|
+
throw new Error(`[loadPolicy] Policy file "${policyPath}" has no .hmac sidecar but ` +
|
|
149
|
+
`SECURITY_POLICY_HMAC_KEY is set. Generate a signature with: security-mcp sign-policy`);
|
|
150
|
+
}
|
|
151
|
+
const expected = createHmac("sha256", hmacKey).update(raw, "utf-8").digest("hex");
|
|
152
|
+
const storedBuf = Buffer.from(storedSig, "hex");
|
|
153
|
+
const expectedBuf = Buffer.from(expected, "hex");
|
|
154
|
+
const valid = storedBuf.length === expectedBuf.length && timingSafeEqual(storedBuf, expectedBuf);
|
|
155
|
+
if (!valid) {
|
|
156
|
+
throw new Error(`[loadPolicy] HMAC verification failed for "${policyPath}" — policy file may have been tampered. ` +
|
|
157
|
+
`Re-sign with: security-mcp sign-policy`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
91
160
|
const parsed = JSON.parse(raw);
|
|
92
161
|
return PolicySchema.parse(parsed);
|
|
93
162
|
}
|
|
163
|
+
// Fix 8: pattern to detect security-relevant config files that must not get docs-tier bypass
|
|
164
|
+
const SECURITY_CONFIG_RE = /security-exceptions|security-policy|security-tools|\.checkov\.yaml|\.github\/workflows\//i;
|
|
94
165
|
/**
|
|
95
166
|
* Classify the change type based on file paths to apply appropriate gate tier.
|
|
96
167
|
*/
|
|
@@ -99,8 +170,14 @@ function classifyChangeType(files) {
|
|
|
99
170
|
return "general";
|
|
100
171
|
const allMatch = (pattern) => files.every((f) => pattern.test(f));
|
|
101
172
|
const anyMatch = (pattern) => files.some((f) => pattern.test(f));
|
|
102
|
-
if (allMatch(/\.(md|txt|rst)$|\/docs\/|README/i))
|
|
173
|
+
if (allMatch(/\.(md|txt|rst)$|\/docs\/|README/i)) {
|
|
174
|
+
// Fix 8: override docs tier when security config files are in the changeset
|
|
175
|
+
if (anyMatch(SECURITY_CONFIG_RE)) {
|
|
176
|
+
console.warn("[policy] Docs-tier override: security configuration file detected in changed files");
|
|
177
|
+
return "config";
|
|
178
|
+
}
|
|
103
179
|
return "docs";
|
|
180
|
+
}
|
|
104
181
|
if (anyMatch(/\/payment|\/stripe|\/checkout|\/billing|\/invoice/i))
|
|
105
182
|
return "payment";
|
|
106
183
|
if (anyMatch(/\/auth|\/login|\/session|\/token|\/jwt|\/oauth|\/permission/i))
|
|
@@ -123,6 +200,160 @@ function assignRiskSlas(findings) {
|
|
|
123
200
|
const now = new Date().toISOString();
|
|
124
201
|
return findings.map((f) => ({ ...f, sla: SLA_MAP[f.severity], slaAssignedAt: now }));
|
|
125
202
|
}
|
|
203
|
+
// Names aligned with check array order in runAllChecks — used for GATE_CHECK_CRASHED findings
|
|
204
|
+
const CHECK_NAMES = [
|
|
205
|
+
"required-artifacts",
|
|
206
|
+
"secrets",
|
|
207
|
+
"dependencies",
|
|
208
|
+
"scanner-readiness",
|
|
209
|
+
"evidence-coverage",
|
|
210
|
+
"web-nextjs",
|
|
211
|
+
"api",
|
|
212
|
+
"infra",
|
|
213
|
+
"mobile-ios",
|
|
214
|
+
"mobile-android",
|
|
215
|
+
"ai",
|
|
216
|
+
"graphql",
|
|
217
|
+
"kubernetes",
|
|
218
|
+
"database",
|
|
219
|
+
"crypto",
|
|
220
|
+
"dlp",
|
|
221
|
+
"sbom",
|
|
222
|
+
"playbook",
|
|
223
|
+
"ai-redteam",
|
|
224
|
+
"runtime",
|
|
225
|
+
"ci-pipeline",
|
|
226
|
+
"nuclei",
|
|
227
|
+
"injection-deep",
|
|
228
|
+
"auth-deep",
|
|
229
|
+
"supply-chain-deep",
|
|
230
|
+
"business-logic",
|
|
231
|
+
"scanners-run"
|
|
232
|
+
];
|
|
233
|
+
/** Run every applicable security check in parallel and collect findings. */
|
|
234
|
+
async function runAllChecks(opts) {
|
|
235
|
+
const { policy, changedFiles, targets, surfaces, scannerReadiness, evidenceCoverage } = opts;
|
|
236
|
+
const stagingUrl = process.env["SECURITY_STAGING_URL"];
|
|
237
|
+
const isApiOrWeb = surfaces.api || surfaces.web;
|
|
238
|
+
const settled = await Promise.allSettled([
|
|
239
|
+
checkRequiredArtifacts({ policy, changedFiles }),
|
|
240
|
+
checkSecrets({ changedFiles }),
|
|
241
|
+
checkDependencies({ changedFiles }),
|
|
242
|
+
Promise.resolve(scannerReadiness.findings),
|
|
243
|
+
Promise.resolve(evidenceCoverage.findings),
|
|
244
|
+
surfaces.web ? checkWebNextjs({ changedFiles }) : Promise.resolve([]),
|
|
245
|
+
surfaces.api ? checkApi({ changedFiles }) : Promise.resolve([]),
|
|
246
|
+
surfaces.infra ? checkInfra({ changedFiles }) : Promise.resolve([]),
|
|
247
|
+
surfaces.mobileIos ? checkMobileIos({ changedFiles }) : Promise.resolve([]),
|
|
248
|
+
surfaces.mobileAndroid ? checkMobileAndroid({ changedFiles }) : Promise.resolve([]),
|
|
249
|
+
surfaces.ai ? checkAi({ changedFiles }) : Promise.resolve([]),
|
|
250
|
+
checkGraphQL({ changedFiles }),
|
|
251
|
+
checkKubernetes({ changedFiles }),
|
|
252
|
+
checkDatabase({ changedFiles }),
|
|
253
|
+
checkCrypto({ changedFiles }),
|
|
254
|
+
checkDlp({ changedFiles }),
|
|
255
|
+
runSbomChecks({ changedFiles, targets }),
|
|
256
|
+
runPlaybookChecks({ changedFiles, surfaces }),
|
|
257
|
+
surfaces.ai ? runAiRedteamChecks({ changedFiles }) : Promise.resolve([]),
|
|
258
|
+
stagingUrl ? runRuntimeChecks({ targets, changedFiles }) : Promise.resolve([]),
|
|
259
|
+
runCiPipelineChecks({ changedFiles }),
|
|
260
|
+
stagingUrl ? runNucleiChecks({ changedFiles }) : Promise.resolve([]),
|
|
261
|
+
isApiOrWeb ? checkInjectionDeep({ changedFiles }) : Promise.resolve([]),
|
|
262
|
+
isApiOrWeb ? checkAuthDeep({ changedFiles }) : Promise.resolve([]),
|
|
263
|
+
checkSupplyChainDeep({ changedFiles }),
|
|
264
|
+
checkBusinessLogic({ changedFiles }),
|
|
265
|
+
runDockerChecks({ changedFiles }),
|
|
266
|
+
runScanners({ surfaces, changedFiles })
|
|
267
|
+
]);
|
|
268
|
+
const findings = [];
|
|
269
|
+
// Fix 5: crashed check modules generate HIGH findings instead of silent console.warn
|
|
270
|
+
for (let i = 0; i < settled.length; i++) {
|
|
271
|
+
const r = settled[i];
|
|
272
|
+
if (r.status === "fulfilled") {
|
|
273
|
+
findings.push(...r.value);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
const checkName = CHECK_NAMES[i] ?? `check-${i}`;
|
|
277
|
+
// CWE-200: sanitize error message before embedding in gate findings —
|
|
278
|
+
// raw Error.message can contain absolute filesystem paths that reveal
|
|
279
|
+
// internal directory structure to callers of the gate result.
|
|
280
|
+
const rawErrorMessage = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
|
281
|
+
const errorMessage = sanitizeErrorMessage(rawErrorMessage);
|
|
282
|
+
findings.push({
|
|
283
|
+
id: "GATE_CHECK_CRASHED",
|
|
284
|
+
title: "Security check module crashed — coverage gap",
|
|
285
|
+
severity: "HIGH",
|
|
286
|
+
evidence: [`Check module: ${checkName}`, `Error: ${errorMessage}`],
|
|
287
|
+
requiredActions: [
|
|
288
|
+
`The ${checkName} check module threw an unhandled error: ${errorMessage}. Findings from this module are unavailable, which may constitute a false negative.`
|
|
289
|
+
]
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return findings;
|
|
294
|
+
}
|
|
295
|
+
/** Build tooling-based control coverage from the catalog. */
|
|
296
|
+
function buildToolingCoverage(catalog, surfaces, scannerReadiness) {
|
|
297
|
+
return catalog.controls
|
|
298
|
+
.filter((c) => c.automation === "tooling" && controlApplies(c, surfaces))
|
|
299
|
+
.map((c) => {
|
|
300
|
+
const required = c.required_scanners ?? [];
|
|
301
|
+
const missing = required.filter((id) => !scannerReadiness.configured.includes(id) || scannerReadiness.missing.includes(id));
|
|
302
|
+
return {
|
|
303
|
+
id: c.id,
|
|
304
|
+
description: c.description,
|
|
305
|
+
automation: c.automation,
|
|
306
|
+
frameworks: c.frameworks,
|
|
307
|
+
status: missing.length > 0 ? "missing" : "satisfied",
|
|
308
|
+
details: missing.length > 0 ? missing : required
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Compute coverage and confidence scores from control coverage + scanner readiness. */
|
|
313
|
+
function computeConfidence(controlCoverage, scannerReadiness) {
|
|
314
|
+
const relevant = controlCoverage.filter((c) => c.status !== "not_applicable");
|
|
315
|
+
const satisfied = relevant.filter((c) => c.status === "satisfied").length;
|
|
316
|
+
const riskAccepted = relevant.filter((c) => c.status === "risk_accepted").length;
|
|
317
|
+
const missing = relevant.filter((c) => c.status === "missing").length;
|
|
318
|
+
const automatedCoverage = relevant.length === 0
|
|
319
|
+
? 100
|
|
320
|
+
: Math.round((satisfied + riskAccepted * 0.5) / relevant.length * 100);
|
|
321
|
+
const { configured, missing: scanMissing } = scannerReadiness;
|
|
322
|
+
const scannerScore = configured.length === 0
|
|
323
|
+
? 0
|
|
324
|
+
: Math.round((configured.length - scanMissing.length) / configured.length * 100);
|
|
325
|
+
const confidenceScore = Math.max(0, Math.min(100, Math.round(automatedCoverage * 0.7 + scannerScore * 0.3)));
|
|
326
|
+
return { automatedCoverage, scannerScore, confidenceScore, missingControls: missing, riskAcceptedControls: riskAccepted };
|
|
327
|
+
}
|
|
328
|
+
/** Inject regression findings when a baseline exists and controls have regressed. */
|
|
329
|
+
function applyBaselineDiff(findings, controlCoverage, previousBaseline, changedFiles, surfaces, confidence) {
|
|
330
|
+
const snapshot = {
|
|
331
|
+
findings,
|
|
332
|
+
controlCoverage,
|
|
333
|
+
confidence: { automatedCoverage: confidence.automatedCoverage, score: 0, missingControls: 0, scannerReadiness: 0, summary: "" },
|
|
334
|
+
status: "PASS",
|
|
335
|
+
policyVersion: "",
|
|
336
|
+
evaluatedAt: "",
|
|
337
|
+
scope: { changedFiles, surfaces }
|
|
338
|
+
};
|
|
339
|
+
const diff = compareBaseline(snapshot, previousBaseline);
|
|
340
|
+
if (diff.regressions.length === 0)
|
|
341
|
+
return { findings, diff };
|
|
342
|
+
const regressionFindings = diff.regressions.map((r) => ({
|
|
343
|
+
id: "BASELINE_REGRESSION",
|
|
344
|
+
title: `Security regression: control "${r.controlId}" was previously satisfied but is now missing`,
|
|
345
|
+
severity: "HIGH",
|
|
346
|
+
evidence: [`Control ${r.controlId}: "satisfied" → "missing" since last gate run`],
|
|
347
|
+
requiredActions: [
|
|
348
|
+
`Restore control "${r.controlId}" to a satisfied state.`,
|
|
349
|
+
"Investigate what change caused this regression and revert or remediate."
|
|
350
|
+
]
|
|
351
|
+
}));
|
|
352
|
+
return { findings: [...regressionFindings, ...findings], diff };
|
|
353
|
+
}
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Main gate entry point
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
126
357
|
export async function runPrGate(opts) {
|
|
127
358
|
const [policy, commitHash, previousBaseline] = await Promise.all([
|
|
128
359
|
loadPolicy(opts.policyPath),
|
|
@@ -132,133 +363,54 @@ export async function runPrGate(opts) {
|
|
|
132
363
|
const mode = opts.mode ?? "recent_changes";
|
|
133
364
|
const targets = normalizeTargets(opts.targets);
|
|
134
365
|
const changedFiles = await resolveScopedFiles({
|
|
135
|
-
mode,
|
|
136
|
-
targets,
|
|
366
|
+
mode, targets,
|
|
137
367
|
baseRef: opts.baseRef ?? "origin/main",
|
|
138
368
|
headRef: opts.headRef ?? "HEAD"
|
|
139
369
|
});
|
|
140
|
-
// Classify the change type to apply appropriate gate tier
|
|
141
370
|
const changeType = classifyChangeType(changedFiles);
|
|
142
371
|
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
|
-
});
|
|
372
|
+
const [catalog, scannerReadiness, evidenceCoverage] = await Promise.all([
|
|
373
|
+
loadControlCatalog(),
|
|
374
|
+
checkScannerReadiness({ surfaces }),
|
|
375
|
+
evaluateEvidenceCoverage({ policy, surfaces })
|
|
376
|
+
]);
|
|
377
|
+
// Collect raw findings — docs tier runs secrets-only to reduce overhead
|
|
378
|
+
const rawChecked = changeType === "docs"
|
|
379
|
+
? await checkSecrets({ changedFiles })
|
|
380
|
+
: await runAllChecks({ policy, changedFiles, targets, surfaces, scannerReadiness, evidenceCoverage });
|
|
381
|
+
const rawFindings = assignRiskSlas(rawChecked);
|
|
382
|
+
// Build control coverage
|
|
383
|
+
const toolingCoverage = buildToolingCoverage(catalog, surfaces, scannerReadiness);
|
|
204
384
|
const controlCoverage = [
|
|
205
|
-
...evidenceCoverage.controls.filter((
|
|
385
|
+
...evidenceCoverage.controls.filter((c) => c.automation === "evidence"),
|
|
206
386
|
...toolingCoverage
|
|
207
387
|
];
|
|
208
|
-
|
|
388
|
+
// Apply exceptions — Fix 7: pass require_ticket from policy config
|
|
389
|
+
const requireTicket = policy.exceptions?.require_ticket ?? false;
|
|
390
|
+
const exceptionResult = await applySecurityExceptions(rawFindings, { requireTicket });
|
|
209
391
|
const controlCoverageWithExceptions = controlCoverage.map((control) => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
status: "risk_accepted",
|
|
214
|
-
details: [...control.details, "Covered by an active approved control exception."]
|
|
215
|
-
};
|
|
392
|
+
const excepted = exceptionResult.activeControlExceptionIds.includes(control.id);
|
|
393
|
+
if (excepted && control.status === "missing") {
|
|
394
|
+
return { ...control, status: "risk_accepted", details: [...control.details, "Covered by an active approved control exception."] };
|
|
216
395
|
}
|
|
217
396
|
return control;
|
|
218
397
|
});
|
|
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
|
|
398
|
+
// Include exception warnings (e.g. CI_EXCEPTIONS_IN_LOCAL_SCAN, EXCEPTIONS_FILE_UNSIGNED) in findings
|
|
399
|
+
const baseFindings = [...exceptionResult.findings, ...exceptionResult.exceptionFindings, ...exceptionResult.warnings];
|
|
400
|
+
// Confidence metrics
|
|
401
|
+
const cm = computeConfidence(controlCoverageWithExceptions, scannerReadiness);
|
|
402
|
+
// Baseline regression injection
|
|
403
|
+
let effectiveFindings = baseFindings;
|
|
242
404
|
let baselineDiff;
|
|
243
405
|
if (previousBaseline) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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];
|
|
257
|
-
}
|
|
406
|
+
const br = applyBaselineDiff(baseFindings, controlCoverageWithExceptions, previousBaseline, changedFiles, surfaces, cm);
|
|
407
|
+
effectiveFindings = br.findings;
|
|
408
|
+
baselineDiff = br.diff;
|
|
258
409
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
410
|
+
// Fix 6: read severity_block from policy instead of hardcoding HIGH/CRITICAL
|
|
411
|
+
const blockedSeverities = policy.severity_block ?? ["HIGH", "CRITICAL"];
|
|
412
|
+
const status = effectiveFindings.some((f) => blockedSeverities.includes(f.severity))
|
|
413
|
+
? "FAIL" : "PASS";
|
|
262
414
|
const result = {
|
|
263
415
|
status,
|
|
264
416
|
policyVersion: policy.version,
|
|
@@ -267,17 +419,14 @@ export async function runPrGate(opts) {
|
|
|
267
419
|
findings: effectiveFindings,
|
|
268
420
|
suppressedFindings: exceptionResult.suppressed,
|
|
269
421
|
controlCoverage: controlCoverageWithExceptions,
|
|
270
|
-
scannerReadiness: {
|
|
271
|
-
configured: scannerReadiness.configured,
|
|
272
|
-
missing: scannerReadiness.missing
|
|
273
|
-
},
|
|
422
|
+
scannerReadiness: { configured: scannerReadiness.configured, missing: scannerReadiness.missing },
|
|
274
423
|
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}.`
|
|
424
|
+
score: cm.confidenceScore,
|
|
425
|
+
automatedCoverage: cm.automatedCoverage,
|
|
426
|
+
missingControls: cm.missingControls,
|
|
427
|
+
riskAcceptedControls: cm.riskAcceptedControls,
|
|
428
|
+
scannerReadiness: cm.scannerScore,
|
|
429
|
+
summary: `Automated coverage ${cm.automatedCoverage}%, scanner readiness ${cm.scannerScore}%, missing controls ${cm.missingControls}, risk-accepted controls ${cm.riskAcceptedControls}. Change type: ${changeType}.`
|
|
281
430
|
},
|
|
282
431
|
baselineDiff
|
|
283
432
|
};
|