security-mcp 1.0.3 → 1.0.5

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.
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import fg from "fast-glob";
2
3
  import { getChangedFiles } from "./diff.js";
3
4
  import { detectSurfaces } from "./findings.js";
4
5
  import { checkRequiredArtifacts } from "./checks/required-artifacts.js";
@@ -10,6 +11,10 @@ import { checkInfra } from "./checks/infra.js";
10
11
  import { checkMobileIos } from "./checks/mobile-ios.js";
11
12
  import { checkMobileAndroid } from "./checks/mobile-android.js";
12
13
  import { checkAi } from "./checks/ai.js";
14
+ import { checkScannerReadiness } from "./checks/scanners.js";
15
+ import { evaluateEvidenceCoverage } from "./evidence.js";
16
+ import { applySecurityExceptions } from "./exceptions.js";
17
+ import { controlApplies, loadControlCatalog } from "./catalog.js";
13
18
  import { readFileSafe } from "../repo/fs.js";
14
19
  const PolicySchema = z.object({
15
20
  name: z.string(),
@@ -29,6 +34,43 @@ const PolicySchema = z.object({
29
34
  }))
30
35
  .default([])
31
36
  });
37
+ const SCOPE_IGNORE_GLOBS = ["**/node_modules/**", "**/.git/**", "**/dist/**"];
38
+ const SAFE_SCOPE_TARGET_RE = /^[a-zA-Z0-9_./-]+$/;
39
+ function validateScopeTarget(target) {
40
+ if (!target || target.includes("..") || target.startsWith("/") || !SAFE_SCOPE_TARGET_RE.test(target)) {
41
+ throw new Error(`Invalid scope target "${target}". Use a relative file/folder path with alphanumerics, "_", "-", ".", "/".`);
42
+ }
43
+ }
44
+ function normalizeTargets(targets) {
45
+ return (targets ?? []).map((t) => t.trim()).filter(Boolean);
46
+ }
47
+ async function resolveScopedFiles(opts) {
48
+ if (opts.mode === "recent_changes") {
49
+ return await getChangedFiles({ baseRef: opts.baseRef, headRef: opts.headRef });
50
+ }
51
+ const targets = normalizeTargets(opts.targets);
52
+ if (targets.length === 0) {
53
+ throw new Error(`Scan mode "${opts.mode}" requires "targets". ` +
54
+ `Provide one or more relative paths (folders for folder_by_folder, files for file_by_file).`);
55
+ }
56
+ for (const target of targets)
57
+ validateScopeTarget(target);
58
+ if (opts.mode === "file_by_file") {
59
+ const files = await fg(targets, {
60
+ onlyFiles: true,
61
+ dot: true,
62
+ ignore: SCOPE_IGNORE_GLOBS
63
+ });
64
+ return Array.from(new Set(files)).sort();
65
+ }
66
+ const folderGlobs = targets.map((target) => `${target.replace(/\/+$/, "")}/**/*`);
67
+ const files = await fg(folderGlobs, {
68
+ onlyFiles: true,
69
+ dot: true,
70
+ ignore: SCOPE_IGNORE_GLOBS
71
+ });
72
+ return Array.from(new Set(files)).sort();
73
+ }
32
74
  export async function loadPolicy(policyPath) {
33
75
  const raw = await readFileSafe(policyPath);
34
76
  const parsed = JSON.parse(raw);
@@ -36,17 +78,26 @@ export async function loadPolicy(policyPath) {
36
78
  }
37
79
  export async function runPrGate(opts) {
38
80
  const policy = await loadPolicy(opts.policyPath);
39
- const changedFiles = await getChangedFiles({
81
+ const mode = opts.mode ?? "recent_changes";
82
+ const targets = normalizeTargets(opts.targets);
83
+ const changedFiles = await resolveScopedFiles({
84
+ mode,
85
+ targets,
40
86
  baseRef: opts.baseRef ?? "origin/main",
41
87
  headRef: opts.headRef ?? "HEAD"
42
88
  });
43
89
  const surfaces = detectSurfaces(changedFiles);
44
- const findings = [
90
+ const catalog = await loadControlCatalog();
91
+ const scannerReadiness = await checkScannerReadiness({ surfaces });
92
+ const evidenceCoverage = await evaluateEvidenceCoverage({ policy, surfaces });
93
+ const rawFindings = [
45
94
  // Required artifacts first: threat models/checklists.
46
95
  ...(await checkRequiredArtifacts({ policy, changedFiles })),
47
96
  // Baseline scans / checks
48
97
  ...(await checkSecrets({ changedFiles })),
49
98
  ...(await checkDependencies({ changedFiles })),
99
+ ...scannerReadiness.findings,
100
+ ...evidenceCoverage.findings,
50
101
  // Surface-specific checks (only run if that surface is impacted or exists)
51
102
  ...(surfaces.web ? await checkWebNextjs({ changedFiles }) : []),
52
103
  ...(surfaces.api ? await checkApi({ changedFiles }) : []),
@@ -55,6 +106,47 @@ export async function runPrGate(opts) {
55
106
  ...(surfaces.mobileAndroid ? await checkMobileAndroid({ changedFiles }) : []),
56
107
  ...(surfaces.ai ? await checkAi({ changedFiles }) : [])
57
108
  ];
109
+ const toolingCoverage = catalog.controls
110
+ .filter((control) => control.automation === "tooling" && controlApplies(control, surfaces))
111
+ .map((control) => {
112
+ const required = control.required_scanners ?? [];
113
+ const missing = required.filter((scannerId) => !scannerReadiness.configured.includes(scannerId) || scannerReadiness.missing.includes(scannerId));
114
+ return {
115
+ id: control.id,
116
+ description: control.description,
117
+ automation: control.automation,
118
+ frameworks: control.frameworks,
119
+ status: missing.length > 0 ? "missing" : "satisfied",
120
+ details: missing.length > 0 ? missing : required
121
+ };
122
+ });
123
+ const controlCoverage = [
124
+ ...evidenceCoverage.controls.filter((control) => control.automation === "evidence"),
125
+ ...toolingCoverage
126
+ ];
127
+ const exceptionResult = await applySecurityExceptions(rawFindings);
128
+ const controlCoverageWithExceptions = controlCoverage.map((control) => {
129
+ if (exceptionResult.activeControlExceptionIds.includes(control.id) && control.status === "missing") {
130
+ return {
131
+ ...control,
132
+ status: "risk_accepted",
133
+ details: [...control.details, "Covered by an active approved control exception."]
134
+ };
135
+ }
136
+ return control;
137
+ });
138
+ const findings = [...exceptionResult.findings, ...exceptionResult.exceptionFindings];
139
+ const relevantControls = controlCoverageWithExceptions.filter((control) => control.status !== "not_applicable");
140
+ const satisfiedControls = relevantControls.filter((control) => control.status === "satisfied").length;
141
+ const riskAcceptedControls = relevantControls.filter((control) => control.status === "risk_accepted").length;
142
+ const automatedCoverage = relevantControls.length === 0
143
+ ? 100
144
+ : Math.round((((satisfiedControls) + (riskAcceptedControls * 0.5)) / relevantControls.length) * 100);
145
+ const scannerScore = scannerReadiness.configured.length === 0
146
+ ? 0
147
+ : Math.round(((scannerReadiness.configured.length - scannerReadiness.missing.length) / scannerReadiness.configured.length) * 100);
148
+ const confidenceScore = Math.max(0, Math.min(100, Math.round((automatedCoverage * 0.7) + (scannerScore * 0.3))));
149
+ const missingControls = relevantControls.filter((control) => control.status === "missing").length;
58
150
  const status = findings.some((f) => f.severity === "HIGH" || f.severity === "CRITICAL")
59
151
  ? "FAIL"
60
152
  : "PASS";
@@ -62,7 +154,21 @@ export async function runPrGate(opts) {
62
154
  status,
63
155
  policyVersion: policy.version,
64
156
  evaluatedAt: new Date().toISOString(),
65
- scope: { changedFiles, surfaces },
66
- findings
157
+ scope: { mode, targets, changedFiles, surfaces },
158
+ findings,
159
+ suppressedFindings: exceptionResult.suppressed,
160
+ controlCoverage: controlCoverageWithExceptions,
161
+ scannerReadiness: {
162
+ configured: scannerReadiness.configured,
163
+ missing: scannerReadiness.missing
164
+ },
165
+ confidence: {
166
+ score: confidenceScore,
167
+ automatedCoverage,
168
+ missingControls,
169
+ riskAcceptedControls,
170
+ scannerReadiness: scannerScore,
171
+ summary: `Automated coverage ${automatedCoverage}%, scanner readiness ${scannerScore}%, missing controls ${missingControls}, risk-accepted controls ${riskAcceptedControls}.`
172
+ }
67
173
  };
68
174
  }