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.
Files changed (129) hide show
  1. package/README.md +116 -264
  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/security-policy.json +2 -2
  9. package/dist/cli/index.js +0 -0
  10. package/dist/gate/baseline.js +82 -7
  11. package/dist/gate/catalog.js +10 -2
  12. package/dist/gate/checks/ai.js +757 -39
  13. package/dist/gate/checks/auth-deep.js +920 -216
  14. package/dist/gate/checks/business-logic.js +751 -0
  15. package/dist/gate/checks/ci-pipeline.js +399 -4
  16. package/dist/gate/checks/crypto.js +423 -2
  17. package/dist/gate/checks/dependencies.js +571 -15
  18. package/dist/gate/checks/graphql.js +201 -19
  19. package/dist/gate/checks/infra.js +246 -1
  20. package/dist/gate/checks/injection-deep.js +827 -184
  21. package/dist/gate/checks/k8s.js +114 -1
  22. package/dist/gate/checks/mobile-android.js +917 -3
  23. package/dist/gate/checks/mobile-ios.js +797 -5
  24. package/dist/gate/checks/required-artifacts.js +194 -0
  25. package/dist/gate/checks/runtime.js +178 -0
  26. package/dist/gate/checks/secrets.js +244 -13
  27. package/dist/gate/checks/supply-chain-deep.js +787 -0
  28. package/dist/gate/checks/web-nextjs.js +572 -48
  29. package/dist/gate/diff.js +17 -5
  30. package/dist/gate/evidence.js +8 -1
  31. package/dist/gate/exceptions.js +131 -9
  32. package/dist/gate/policy.js +280 -131
  33. package/dist/mcp/audit-chain.js +122 -28
  34. package/dist/mcp/auth.js +169 -0
  35. package/dist/mcp/learning.js +129 -4
  36. package/dist/mcp/model-router.js +158 -21
  37. package/dist/mcp/orchestration.js +186 -51
  38. package/dist/mcp/server.js +337 -53
  39. package/dist/repo/fs.js +24 -1
  40. package/dist/repo/search.js +31 -6
  41. package/dist/review/store.js +52 -1
  42. package/package.json +7 -7
  43. package/skills/_TEMPLATE/SKILL.md +99 -0
  44. package/skills/advanced-dos-tester/SKILL.md +109 -0
  45. package/skills/agentic-loop-exploiter/SKILL.md +368 -0
  46. package/skills/ai-llm-redteam/SKILL.md +104 -0
  47. package/skills/ai-model-supply-chain-agent/SKILL.md +103 -0
  48. package/skills/algorithm-implementation-reviewer/SKILL.md +98 -0
  49. package/skills/android-penetration-tester/SKILL.md +455 -46
  50. package/skills/anti-replay-tester/SKILL.md +106 -0
  51. package/skills/appsec-code-auditor/SKILL.md +85 -0
  52. package/skills/artifact-integrity-analyst/SKILL.md +441 -0
  53. package/skills/attack-navigator/SKILL.md +467 -8
  54. package/skills/auth-session-hacker/SKILL.md +102 -0
  55. package/skills/aws-penetration-tester/SKILL.md +456 -0
  56. package/skills/azure-penetration-tester/SKILL.md +490 -3
  57. package/skills/binary-auth-validator/SKILL.md +111 -0
  58. package/skills/bot-detection-specialist/SKILL.md +109 -0
  59. package/skills/business-logic-attacker/SKILL.md +231 -0
  60. package/skills/capec-code-mapper/SKILL.md +84 -0
  61. package/skills/cert-pin-rotation-specialist/SKILL.md +112 -0
  62. package/skills/cicd-pipeline-hijacker/SKILL.md +405 -0
  63. package/skills/ciso-orchestrator/SKILL.md +454 -43
  64. package/skills/cloud-infra-specialist/SKILL.md +118 -0
  65. package/skills/compliance-gap-analyst/SKILL.md +422 -0
  66. package/skills/compliance-grc/SKILL.md +85 -0
  67. package/skills/compliance-lifecycle-tracker/SKILL.md +84 -0
  68. package/skills/credential-stuffing-specialist/SKILL.md +102 -0
  69. package/skills/crypto-pki-specialist/SKILL.md +87 -0
  70. package/skills/csa-ccm-mapper/SKILL.md +84 -0
  71. package/skills/csf2-governance-mapper/SKILL.md +84 -0
  72. package/skills/deep-link-fuzzer/SKILL.md +109 -0
  73. package/skills/dependency-confusion-attacker/SKILL.md +415 -0
  74. package/skills/device-integrity-aggregator/SKILL.md +108 -0
  75. package/skills/dos-resilience-tester/SKILL.md +97 -0
  76. package/skills/dread-scorer/SKILL.md +84 -0
  77. package/skills/egress-policy-enforcer/SKILL.md +99 -0
  78. package/skills/evidence-collector/SKILL.md +98 -0
  79. package/skills/file-upload-attacker/SKILL.md +109 -0
  80. package/skills/gcp-penetration-tester/SKILL.md +459 -2
  81. package/skills/git-history-secret-scanner/SKILL.md +106 -0
  82. package/skills/iam-privesc-graph-builder/SKILL.md +152 -0
  83. package/skills/incident-responder/SKILL.md +111 -0
  84. package/skills/injection-specialist/SKILL.md +102 -0
  85. package/skills/ios-security-auditor/SKILL.md +282 -0
  86. package/skills/json-ambiguity-tester/SKILL.md +0 -0
  87. package/skills/k8s-container-escaper/SKILL.md +384 -0
  88. package/skills/key-management-lifecycle-analyst/SKILL.md +98 -0
  89. package/skills/kill-switch-engineer/SKILL.md +102 -0
  90. package/skills/linddun-privacy-analyst/SKILL.md +102 -0
  91. package/skills/logic-race-fuzzer/SKILL.md +443 -0
  92. package/skills/mobile-api-network-attacker/SKILL.md +421 -0
  93. package/skills/mobile-binary-hardener/SKILL.md +102 -0
  94. package/skills/mobile-security-specialist/SKILL.md +85 -0
  95. package/skills/mobile-webview-auditor/SKILL.md +96 -0
  96. package/skills/model-extraction-attacker/SKILL.md +219 -0
  97. package/skills/multipart-abuse-tester/SKILL.md +84 -0
  98. package/skills/oauth-pkce-specialist/SKILL.md +104 -0
  99. package/skills/parser-exhaustion-tester/SKILL.md +142 -0
  100. package/skills/pentest-infra/SKILL.md +98 -0
  101. package/skills/pentest-social/SKILL.md +201 -0
  102. package/skills/pentest-team/SKILL.md +87 -0
  103. package/skills/pentest-web-api/SKILL.md +98 -0
  104. package/skills/privacy-flow-analyst/SKILL.md +234 -0
  105. package/skills/prompt-injection-specialist/SKILL.md +394 -0
  106. package/skills/quantum-migration-planner/SKILL.md +96 -0
  107. package/skills/rag-poisoning-specialist/SKILL.md +358 -0
  108. package/skills/registry-mirror-enforcer/SKILL.md +84 -0
  109. package/skills/rotation-validation-agent/SKILL.md +112 -0
  110. package/skills/samm-assessor/SKILL.md +85 -0
  111. package/skills/secrets-mask-bypass-tester/SKILL.md +100 -0
  112. package/skills/senior-security-engineer/SKILL.md +167 -0
  113. package/skills/serialization-memory-attacker/SKILL.md +332 -0
  114. package/skills/session-timeout-tester/SKILL.md +161 -0
  115. package/skills/slsa-level3-enforcer/SKILL.md +112 -0
  116. package/skills/slsa-provenance-enforcer/SKILL.md +102 -0
  117. package/skills/ssrf-detection-validator/SKILL.md +108 -0
  118. package/skills/step-up-auth-enforcer/SKILL.md +84 -0
  119. package/skills/stride-pasta-analyst/SKILL.md +420 -0
  120. package/skills/supply-chain-devsecops/SKILL.md +98 -0
  121. package/skills/threat-infrastructure-analyst/SKILL.md +84 -0
  122. package/skills/threat-modeler/SKILL.md +85 -0
  123. package/skills/tls-certificate-auditor/SKILL.md +573 -18
  124. package/skills/token-reuse-detector/SKILL.md +95 -0
  125. package/skills/trike-risk-modeler/SKILL.md +84 -0
  126. package/skills/unicode-homograph-tester/SKILL.md +84 -0
  127. package/skills/waf-rule-lifecycle-agent/SKILL.md +97 -0
  128. package/skills/webhook-security-tester/SKILL.md +102 -0
  129. package/skills/zero-trust-architect/SKILL.md +109 -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,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 { randomUUID } from "node:crypto";
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 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
- });
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((control) => control.automation === "evidence"),
385
+ ...evidenceCoverage.controls.filter((c) => c.automation === "evidence"),
206
386
  ...toolingCoverage
207
387
  ];
208
- const exceptionResult = await applySecurityExceptions(rawFindings);
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
- 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
- };
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
- 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
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
- 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];
257
- }
406
+ const br = applyBaselineDiff(baseFindings, controlCoverageWithExceptions, previousBaseline, changedFiles, surfaces, cm);
407
+ effectiveFindings = br.findings;
408
+ baselineDiff = br.diff;
258
409
  }
259
- const status = effectiveFindings.some((f) => f.severity === "HIGH" || f.severity === "CRITICAL")
260
- ? "FAIL"
261
- : "PASS";
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
  };