security-mcp 1.0.4 → 1.1.0

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 (47) hide show
  1. package/README.md +77 -21
  2. package/defaults/checklists/ai.json +25 -0
  3. package/defaults/checklists/api.json +27 -0
  4. package/defaults/checklists/infra.json +27 -0
  5. package/defaults/checklists/mobile.json +25 -0
  6. package/defaults/checklists/payments.json +25 -0
  7. package/defaults/checklists/web.json +30 -0
  8. package/defaults/control-catalog.json +549 -0
  9. package/defaults/evidence-map.json +194 -0
  10. package/defaults/security-exceptions.json +4 -0
  11. package/defaults/security-policy.json +41 -2
  12. package/defaults/security-tools.json +41 -0
  13. package/dist/ci/pr-gate.js +2 -3
  14. package/dist/cli/index.js +63 -23
  15. package/dist/cli/install.js +47 -15
  16. package/dist/cli/onboarding.js +590 -0
  17. package/dist/cli/update.js +124 -0
  18. package/dist/gate/baseline.js +115 -0
  19. package/dist/gate/catalog.js +55 -0
  20. package/dist/gate/checks/ai-redteam.js +374 -0
  21. package/dist/gate/checks/ai.js +45 -14
  22. package/dist/gate/checks/api.js +93 -0
  23. package/dist/gate/checks/crypto.js +153 -0
  24. package/dist/gate/checks/database.js +144 -0
  25. package/dist/gate/checks/dependencies.js +130 -0
  26. package/dist/gate/checks/dlp.js +153 -0
  27. package/dist/gate/checks/graphql.js +122 -0
  28. package/dist/gate/checks/infra.js +126 -12
  29. package/dist/gate/checks/k8s.js +190 -0
  30. package/dist/gate/checks/playbook.js +160 -0
  31. package/dist/gate/checks/runtime.js +263 -0
  32. package/dist/gate/checks/sbom.js +199 -0
  33. package/dist/gate/checks/scanners.js +450 -0
  34. package/dist/gate/checks/secrets.js +119 -27
  35. package/dist/gate/diff.js +2 -2
  36. package/dist/gate/evidence.js +116 -0
  37. package/dist/gate/exceptions.js +85 -0
  38. package/dist/gate/policy.js +189 -17
  39. package/dist/gate/threat-intel.js +157 -0
  40. package/dist/mcp/server.js +938 -9
  41. package/dist/repo/fs.js +10 -5
  42. package/dist/repo/search.js +13 -1
  43. package/dist/review/store.js +208 -0
  44. package/dist/tests/run.js +103 -0
  45. package/package.json +13 -3
  46. package/prompts/SECURITY_PROMPT.md +455 -1
  47. package/skills/senior-security-engineer/SKILL.md +81 -4
@@ -0,0 +1,160 @@
1
+ /**
2
+ * IR Playbook enforcement checks.
3
+ * Verifies incident response playbooks exist and contain required sections.
4
+ */
5
+ import { stat } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import fg from "fast-glob";
8
+ import { readFileSafe } from "../../repo/fs.js";
9
+ const PLAYBOOK_BASE = "security/playbooks";
10
+ const REQUIRED_PLAYBOOKS = [
11
+ { surface: "web", path: "web-compromise.md", description: "Web compromise" },
12
+ { surface: "api", path: "api-compromise.md", description: "API compromise" },
13
+ { surface: "ai", path: "llm-prompt-injection.md", description: "LLM prompt injection" },
14
+ { surface: "ai", path: "model-data-poisoning.md", description: "Model data poisoning" },
15
+ { surface: "infra", path: "cloud-misconfiguration.md", description: "Cloud misconfiguration" },
16
+ { surface: "infra", path: "ransomware.md", description: "Ransomware" },
17
+ { surface: "mobileIos", path: "mobile-credential-theft.md", description: "Mobile credential theft" },
18
+ { surface: "mobileAndroid", path: "mobile-credential-theft.md", description: "Mobile credential theft" },
19
+ { surface: "payments", path: "payment-fraud.md", description: "Payment fraud" },
20
+ { surface: "payments", path: "pci-breach.md", description: "PCI breach" }
21
+ ];
22
+ const REQUIRED_SECTIONS = [
23
+ { key: "detection", patterns: [/detection criteria/i, /how to detect/i, /indicators of compromise/i, /detection/i] },
24
+ { key: "escalation", patterns: [/escalation/i, /incident commander/i, /security lead/i, /on-call/i] },
25
+ { key: "containment", patterns: [/containment/i, /contain/i, /isolat/i] },
26
+ { key: "eradication", patterns: [/eradication/i, /eradicate/i, /root cause/i] },
27
+ { key: "recovery", patterns: [/recovery/i, /restore/i, /recover/i] },
28
+ { key: "communication", patterns: [/communication/i, /notification/i, /stakeholder/i, /template/i] },
29
+ { key: "post-incident", patterns: [/post.incident/i, /lessons learned/i, /review/i, /retrospective/i] },
30
+ { key: "mttd-mttr", patterns: [/mttd|mttr|mean time/i, /target.{0,30}time/i, /response time/i] }
31
+ ];
32
+ const STALE_THRESHOLD_MS = 180 * 24 * 60 * 60 * 1000; // 180 days
33
+ function surfaceActive(surface, surfaces, activeSurfaces) {
34
+ if (surface === "payments")
35
+ return activeSurfaces.has("payments");
36
+ if (surface === "mobileIos")
37
+ return surfaces.mobileIos;
38
+ if (surface === "mobileAndroid")
39
+ return surfaces.mobileAndroid;
40
+ return surfaces[surface] === true;
41
+ }
42
+ async function validatePlaybook(playbookPath) {
43
+ const missingSections = [];
44
+ let content = "";
45
+ let isStale = false;
46
+ try {
47
+ content = await readFileSafe(playbookPath);
48
+ }
49
+ catch {
50
+ return { missingSections: REQUIRED_SECTIONS.map((s) => s.key), isStale: false };
51
+ }
52
+ for (const section of REQUIRED_SECTIONS) {
53
+ const found = section.patterns.some((pattern) => pattern.test(content));
54
+ if (!found) {
55
+ missingSections.push(section.key);
56
+ }
57
+ }
58
+ try {
59
+ const s = await stat(playbookPath);
60
+ if (Date.now() - s.mtimeMs > STALE_THRESHOLD_MS) {
61
+ isStale = true;
62
+ }
63
+ }
64
+ catch { /* ignore */ }
65
+ return { missingSections, isStale };
66
+ }
67
+ /**
68
+ * Checks that IR playbooks exist and contain required sections for active surfaces.
69
+ */
70
+ export async function runPlaybookChecks(opts) {
71
+ const findings = [];
72
+ // Detect if payments surface is active via file patterns
73
+ const activeSurfaces = new Set();
74
+ const paymentPatterns = /payment|stripe|braintree|adyen|checkout|pci/i;
75
+ if (opts.changedFiles.some((f) => paymentPatterns.test(f))) {
76
+ activeSurfaces.add("payments");
77
+ }
78
+ // Also scan repo for payment references
79
+ try {
80
+ const paymentFiles = await fg(["**/payment*.ts", "**/stripe*.ts", "**/checkout*.ts"], {
81
+ dot: true,
82
+ ignore: ["**/node_modules/**", "**/dist/**"]
83
+ });
84
+ if (paymentFiles.length > 0)
85
+ activeSurfaces.add("payments");
86
+ }
87
+ catch { /* ignore */ }
88
+ // Deduplicate required playbooks per surface
89
+ const checked = new Set();
90
+ for (const req of REQUIRED_PLAYBOOKS) {
91
+ if (!surfaceActive(req.surface, opts.surfaces, activeSurfaces))
92
+ continue;
93
+ const playbookPath = join(PLAYBOOK_BASE, req.path);
94
+ if (checked.has(playbookPath))
95
+ continue;
96
+ checked.add(playbookPath);
97
+ // Check if playbook exists
98
+ let exists = false;
99
+ try {
100
+ const matches = await fg([playbookPath], { dot: true });
101
+ exists = matches.length > 0;
102
+ }
103
+ catch { /* ignore */ }
104
+ if (!exists) {
105
+ findings.push({
106
+ id: "IR_PLAYBOOK_MISSING",
107
+ title: `IR playbook missing: ${req.description} (${playbookPath})`,
108
+ severity: "HIGH",
109
+ evidence: [`Expected path: ${playbookPath}`, `Surface: ${req.surface}`],
110
+ requiredActions: [
111
+ `Create the IR playbook at ${playbookPath}.`,
112
+ "Include all required sections: detection criteria, escalation path, containment, eradication, recovery, communication, post-incident review, and MTTD/MTTR targets."
113
+ ]
114
+ });
115
+ continue;
116
+ }
117
+ const { missingSections, isStale } = await validatePlaybook(playbookPath);
118
+ if (missingSections.length > 0) {
119
+ findings.push({
120
+ id: "IR_PLAYBOOK_INCOMPLETE",
121
+ title: `IR playbook incomplete: ${playbookPath}`,
122
+ severity: "MEDIUM",
123
+ evidence: [`Missing sections: ${missingSections.join(", ")}`, `Path: ${playbookPath}`],
124
+ requiredActions: [
125
+ `Add the missing sections to ${playbookPath}: ${missingSections.join(", ")}.`,
126
+ "Ensure each section has actionable steps, not just headers."
127
+ ]
128
+ });
129
+ }
130
+ if (isStale) {
131
+ findings.push({
132
+ id: "IR_PLAYBOOK_STALE",
133
+ title: `IR playbook not updated in 180+ days: ${playbookPath}`,
134
+ severity: "LOW",
135
+ evidence: [`Path: ${playbookPath}`],
136
+ requiredActions: [
137
+ `Review and update ${playbookPath} to reflect current infrastructure and contacts.`,
138
+ "Schedule quarterly playbook reviews."
139
+ ]
140
+ });
141
+ }
142
+ }
143
+ return findings;
144
+ }
145
+ /**
146
+ * Validate a single playbook file and return missing sections.
147
+ */
148
+ export async function validateSinglePlaybook(playbookPath) {
149
+ let exists = false;
150
+ try {
151
+ const matches = await fg([playbookPath], { dot: true });
152
+ exists = matches.length > 0;
153
+ }
154
+ catch { /* ignore */ }
155
+ if (!exists) {
156
+ return { path: playbookPath, exists: false, missingSections: REQUIRED_SECTIONS.map((s) => s.key), isStale: false };
157
+ }
158
+ const { missingSections, isStale } = await validatePlaybook(playbookPath);
159
+ return { path: playbookPath, exists: true, missingSections, isStale };
160
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Runtime evidence verification.
3
+ * Checks HTTP security headers and TLS configuration against a live target.
4
+ */
5
+ import * as https from "node:https";
6
+ import * as tls from "node:tls";
7
+ const REQUIRED_HEADERS = [
8
+ {
9
+ name: "content-security-policy",
10
+ findingId: "RUNTIME_HEADER_MISSING",
11
+ validate: (v) => {
12
+ if (/unsafe-inline|unsafe-eval/i.test(v)) {
13
+ return { ok: false, findingId: "RUNTIME_HEADER_UNSAFE", detail: "CSP contains unsafe-inline or unsafe-eval" };
14
+ }
15
+ return { ok: true };
16
+ }
17
+ },
18
+ {
19
+ name: "strict-transport-security",
20
+ findingId: "RUNTIME_HEADER_MISSING",
21
+ validate: (v) => {
22
+ const match = /max-age=(\d+)/i.exec(v);
23
+ const maxAge = match ? parseInt(match[1], 10) : 0;
24
+ if (maxAge < 31536000) {
25
+ return { ok: false, findingId: "RUNTIME_HEADER_UNSAFE", detail: `HSTS max-age ${maxAge} is below minimum 31536000` };
26
+ }
27
+ return { ok: true };
28
+ }
29
+ },
30
+ {
31
+ name: "x-frame-options",
32
+ findingId: "RUNTIME_HEADER_MISSING",
33
+ validate: (v) => {
34
+ if (!/^(deny|sameorigin)$/i.test(v.trim())) {
35
+ return { ok: false, findingId: "RUNTIME_HEADER_UNSAFE", detail: `X-Frame-Options value '${v}' is not DENY or SAMEORIGIN` };
36
+ }
37
+ return { ok: true };
38
+ }
39
+ },
40
+ { name: "x-content-type-options", findingId: "RUNTIME_HEADER_MISSING" },
41
+ { name: "referrer-policy", findingId: "RUNTIME_HEADER_MISSING" },
42
+ { name: "permissions-policy", findingId: "RUNTIME_HEADER_MISSING" }
43
+ ];
44
+ const WEAK_CIPHERS = [
45
+ "RC4", "DES", "3DES", "NULL", "EXPORT", "ADH", "AECDH", "aNULL", "eNULL"
46
+ ];
47
+ async function fetchHeaders(url, timeoutMs) {
48
+ return new Promise((resolve) => {
49
+ const timer = setTimeout(() => resolve(null), timeoutMs);
50
+ try {
51
+ const parsedUrl = new URL(url);
52
+ const options = {
53
+ hostname: parsedUrl.hostname,
54
+ port: parsedUrl.port || 443,
55
+ path: parsedUrl.pathname || "/",
56
+ method: "HEAD",
57
+ rejectUnauthorized: false, // we verify cert separately
58
+ timeout: timeoutMs
59
+ };
60
+ const req = https.request(options, (res) => {
61
+ clearTimeout(timer);
62
+ const headers = {};
63
+ for (const [k, v] of Object.entries(res.headers)) {
64
+ if (typeof v === "string")
65
+ headers[k.toLowerCase()] = v;
66
+ else if (Array.isArray(v))
67
+ headers[k.toLowerCase()] = v.join(", ");
68
+ }
69
+ res.resume();
70
+ resolve(headers);
71
+ });
72
+ req.on("error", () => { clearTimeout(timer); resolve(null); });
73
+ req.on("timeout", () => { req.destroy(); clearTimeout(timer); resolve(null); });
74
+ req.end();
75
+ }
76
+ catch {
77
+ clearTimeout(timer);
78
+ resolve(null);
79
+ }
80
+ });
81
+ }
82
+ async function checkTls(hostname, port, timeoutMs) {
83
+ return new Promise((resolve) => {
84
+ const timer = setTimeout(() => resolve(null), timeoutMs);
85
+ try {
86
+ const socket = tls.connect({ host: hostname, port, rejectUnauthorized: false, timeout: timeoutMs }, () => {
87
+ clearTimeout(timer);
88
+ const proto = socket.getProtocol() ?? "";
89
+ const cipher = socket.getCipher();
90
+ const certDer = socket.getPeerCertificate(true);
91
+ let cert = null;
92
+ if (certDer) {
93
+ const issuer = certDer.issuer ? JSON.stringify(certDer.issuer) : "";
94
+ const subject = certDer.subject ? JSON.stringify(certDer.subject) : "";
95
+ cert = {
96
+ subject,
97
+ issuer,
98
+ validTo: certDer.valid_to ?? "",
99
+ selfSigned: issuer !== "" && issuer === subject
100
+ };
101
+ }
102
+ socket.destroy();
103
+ resolve({
104
+ version: proto,
105
+ cipher: cipher?.name ?? "",
106
+ cert
107
+ });
108
+ });
109
+ socket.on("error", (err) => {
110
+ clearTimeout(timer);
111
+ resolve({ version: "", cipher: "", cert: null, error: err.message });
112
+ });
113
+ socket.on("timeout", () => {
114
+ socket.destroy();
115
+ clearTimeout(timer);
116
+ resolve(null);
117
+ });
118
+ }
119
+ catch {
120
+ clearTimeout(timer);
121
+ resolve(null);
122
+ }
123
+ });
124
+ }
125
+ /**
126
+ * Run HTTP header and TLS runtime checks against SECURITY_STAGING_URL or policy-provided targets.
127
+ */
128
+ export async function runRuntimeChecks(opts) {
129
+ const findings = [];
130
+ // Determine target URL
131
+ const stagingUrl = process.env["SECURITY_STAGING_URL"];
132
+ const targets = stagingUrl ? [stagingUrl, ...opts.targets] : opts.targets;
133
+ const uniqueTargets = [...new Set(targets)].filter((t) => t.startsWith("http"));
134
+ if (uniqueTargets.length === 0)
135
+ return findings;
136
+ const timeoutMs = 15_000;
137
+ for (const targetUrl of uniqueTargets) {
138
+ let parsedUrl;
139
+ try {
140
+ parsedUrl = new URL(targetUrl);
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ // --- HTTP Header checks ---
146
+ const headers = await fetchHeaders(targetUrl, timeoutMs);
147
+ if (headers !== null) {
148
+ for (const headerDef of REQUIRED_HEADERS) {
149
+ const value = headers[headerDef.name];
150
+ if (!value) {
151
+ findings.push({
152
+ id: "RUNTIME_HEADER_MISSING",
153
+ title: `Security header missing: ${headerDef.name} on ${targetUrl}`,
154
+ severity: "HIGH",
155
+ evidence: [`URL: ${targetUrl}`, `Missing header: ${headerDef.name}`],
156
+ requiredActions: [
157
+ `Add the '${headerDef.name}' response header to your application.`,
158
+ "Verify headers are set for all routes including error pages."
159
+ ]
160
+ });
161
+ }
162
+ else if (headerDef.validate) {
163
+ const check = headerDef.validate(value);
164
+ if (!check.ok) {
165
+ findings.push({
166
+ id: check.findingId ?? "RUNTIME_HEADER_UNSAFE",
167
+ title: check.detail ?? `Unsafe header value: ${headerDef.name} on ${targetUrl}`,
168
+ severity: "HIGH",
169
+ evidence: [`URL: ${targetUrl}`, `Header: ${headerDef.name}: ${value}`],
170
+ requiredActions: [
171
+ `Fix the '${headerDef.name}' header value.`,
172
+ check.detail ?? "Review security header configuration."
173
+ ]
174
+ });
175
+ }
176
+ }
177
+ }
178
+ }
179
+ // --- TLS checks ---
180
+ if (parsedUrl.protocol === "https:") {
181
+ const port = parsedUrl.port ? parseInt(parsedUrl.port, 10) : 443;
182
+ const tlsResult = await checkTls(parsedUrl.hostname, port, timeoutMs);
183
+ if (tlsResult && !tlsResult.error) {
184
+ const proto = tlsResult.version.toUpperCase();
185
+ if (proto === "TLSV1" || proto === "TLSV1.0" || proto === "TLSV1.1" || proto === "SSLV3") {
186
+ findings.push({
187
+ id: "RUNTIME_TLS_WEAK",
188
+ title: `Weak TLS version detected: ${tlsResult.version} on ${parsedUrl.hostname}`,
189
+ severity: "CRITICAL",
190
+ evidence: [`Host: ${parsedUrl.hostname}`, `TLS version: ${tlsResult.version}`],
191
+ requiredActions: [
192
+ "Disable TLS 1.0 and 1.1. Enforce TLS 1.2 minimum, TLS 1.3 preferred.",
193
+ "Update your server's SSL/TLS configuration."
194
+ ]
195
+ });
196
+ }
197
+ const cipherUpper = tlsResult.cipher.toUpperCase();
198
+ if (WEAK_CIPHERS.some((wc) => cipherUpper.includes(wc))) {
199
+ findings.push({
200
+ id: "RUNTIME_TLS_WEAK",
201
+ title: `Weak cipher suite in use: ${tlsResult.cipher} on ${parsedUrl.hostname}`,
202
+ severity: "CRITICAL",
203
+ evidence: [`Host: ${parsedUrl.hostname}`, `Cipher: ${tlsResult.cipher}`],
204
+ requiredActions: [
205
+ "Remove weak cipher suites from your TLS configuration.",
206
+ "Use only ECDHE/DHE with AES-GCM or ChaCha20 cipher suites."
207
+ ]
208
+ });
209
+ }
210
+ if (tlsResult.cert) {
211
+ const validTo = new Date(tlsResult.cert.validTo);
212
+ const now = new Date();
213
+ const daysRemaining = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
214
+ if (daysRemaining < 0) {
215
+ findings.push({
216
+ id: "RUNTIME_CERT_EXPIRED",
217
+ title: `TLS certificate has expired on ${parsedUrl.hostname}`,
218
+ severity: "CRITICAL",
219
+ evidence: [
220
+ `Host: ${parsedUrl.hostname}`,
221
+ `Expired: ${tlsResult.cert.validTo}`,
222
+ `Days overdue: ${Math.abs(daysRemaining)}`
223
+ ],
224
+ requiredActions: [
225
+ "Renew the TLS certificate immediately.",
226
+ "Set up certificate auto-renewal (e.g., Let's Encrypt with certbot)."
227
+ ]
228
+ });
229
+ }
230
+ else if (daysRemaining < 30) {
231
+ findings.push({
232
+ id: "RUNTIME_CERT_EXPIRING",
233
+ title: `TLS certificate expiring in ${daysRemaining} days on ${parsedUrl.hostname}`,
234
+ severity: "HIGH",
235
+ evidence: [
236
+ `Host: ${parsedUrl.hostname}`,
237
+ `Expires: ${tlsResult.cert.validTo}`,
238
+ `Days remaining: ${daysRemaining}`
239
+ ],
240
+ requiredActions: [
241
+ "Renew the TLS certificate before it expires.",
242
+ "Verify auto-renewal is configured and working."
243
+ ]
244
+ });
245
+ }
246
+ if (tlsResult.cert.selfSigned) {
247
+ findings.push({
248
+ id: "RUNTIME_TLS_WEAK",
249
+ title: `Self-signed certificate detected on ${parsedUrl.hostname}`,
250
+ severity: "CRITICAL",
251
+ evidence: [`Host: ${parsedUrl.hostname}`, `Issuer: ${tlsResult.cert.issuer}`],
252
+ requiredActions: [
253
+ "Replace the self-signed certificate with one from a trusted CA.",
254
+ "Use Let's Encrypt or your organization's PKI."
255
+ ]
256
+ });
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ return findings;
263
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * SBOM generation and SLSA provenance checks.
3
+ */
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { mkdir, readFile, stat } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import fg from "fast-glob";
9
+ const execFileAsync = promisify(execFile);
10
+ const SBOM_DIR = join(process.cwd(), ".mcp", "sbom");
11
+ const ATTESTATION_DIR = join(process.cwd(), ".mcp", "attestations");
12
+ const SBOM_PATH = join(SBOM_DIR, "latest.json");
13
+ const SBOM_MAX_AGE_MS = 24 * 60 * 60 * 1000;
14
+ async function ensureDir(dir) {
15
+ try {
16
+ await mkdir(dir, { recursive: true });
17
+ }
18
+ catch { /* ignore */ }
19
+ }
20
+ async function commandExists(cmd) {
21
+ try {
22
+ await execFileAsync(cmd, ["version"], { timeout: 5000 });
23
+ return true;
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function getSbomAge() {
30
+ try {
31
+ const s = await stat(SBOM_PATH);
32
+ return Date.now() - s.mtimeMs;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ async function readSbom() {
39
+ try {
40
+ const raw = await readFile(SBOM_PATH, "utf-8");
41
+ return JSON.parse(raw);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ async function getPackageJsonDeps() {
48
+ const manifests = await fg(["package.json", "**/package.json"], {
49
+ dot: true,
50
+ ignore: ["**/node_modules/**", "**/dist/**"]
51
+ });
52
+ const deps = [];
53
+ for (const manifest of manifests.slice(0, 5)) {
54
+ try {
55
+ const raw = await readFile(manifest, "utf-8");
56
+ const pkg = JSON.parse(raw);
57
+ const allDeps = {
58
+ ...(pkg["dependencies"] ?? {}),
59
+ ...(pkg["devDependencies"] ?? {})
60
+ };
61
+ deps.push(...Object.keys(allDeps));
62
+ }
63
+ catch { /* skip */ }
64
+ }
65
+ return [...new Set(deps)];
66
+ }
67
+ async function hasAttestation() {
68
+ try {
69
+ const files = await fg(["**/*.sig", "**/*.bundle", "**/*.att"], {
70
+ cwd: ATTESTATION_DIR,
71
+ dot: true
72
+ });
73
+ return files.length > 0;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /**
80
+ * Run SBOM and SLSA provenance checks.
81
+ */
82
+ export async function runSbomChecks(_opts) {
83
+ const findings = [];
84
+ await ensureDir(SBOM_DIR);
85
+ await ensureDir(ATTESTATION_DIR);
86
+ const syftAvailable = await commandExists("syft");
87
+ const cosignAvailable = await commandExists("cosign");
88
+ const autoSbom = process.env["SECURITY_AUTO_SBOM"] === "true";
89
+ const sbomAge = await getSbomAge();
90
+ // Auto-generate SBOM if enabled and Syft is available
91
+ if (autoSbom && syftAvailable && (sbomAge === null || sbomAge > SBOM_MAX_AGE_MS)) {
92
+ try {
93
+ await execFileAsync("syft", [".", `-o`, `cyclonedx-json=${SBOM_PATH}`], { cwd: process.cwd(), timeout: 120_000, maxBuffer: 50 * 1024 * 1024 });
94
+ }
95
+ catch (err) {
96
+ findings.push({
97
+ id: "SBOM_MISSING",
98
+ title: "SBOM generation failed",
99
+ severity: "HIGH",
100
+ evidence: [String(err)],
101
+ requiredActions: [
102
+ "Investigate Syft installation and run it manually: syft . -o cyclonedx-json=.mcp/sbom/latest.json",
103
+ "Ensure the .mcp/sbom/ directory is writable."
104
+ ]
105
+ });
106
+ }
107
+ }
108
+ // Re-check age after potential generation
109
+ const currentSbomAge = await getSbomAge();
110
+ if (currentSbomAge === null) {
111
+ if (syftAvailable) {
112
+ findings.push({
113
+ id: "SBOM_MISSING",
114
+ title: "No SBOM found. Syft is available — run it to generate one.",
115
+ severity: "HIGH",
116
+ evidence: [`Expected at: ${SBOM_PATH}`],
117
+ requiredActions: [
118
+ "Run: syft . -o cyclonedx-json=.mcp/sbom/latest.json",
119
+ "Or set SECURITY_AUTO_SBOM=true to auto-generate on each gate run."
120
+ ]
121
+ });
122
+ }
123
+ // If syft not available, skip SBOM checks gracefully
124
+ }
125
+ else {
126
+ if (currentSbomAge > SBOM_MAX_AGE_MS) {
127
+ findings.push({
128
+ id: "SBOM_STALE",
129
+ title: "SBOM is stale (older than 24 hours)",
130
+ severity: "MEDIUM",
131
+ evidence: [`SBOM age: ${Math.round(currentSbomAge / 3600000)}h`, `Path: ${SBOM_PATH}`],
132
+ requiredActions: [
133
+ "Regenerate the SBOM: syft . -o cyclonedx-json=.mcp/sbom/latest.json",
134
+ "Or set SECURITY_AUTO_SBOM=true to auto-regenerate."
135
+ ]
136
+ });
137
+ }
138
+ // Check cosign attestation
139
+ if (!cosignAvailable) {
140
+ // Skip cosign checks gracefully
141
+ }
142
+ else {
143
+ const attested = await hasAttestation();
144
+ if (!attested) {
145
+ findings.push({
146
+ id: "SBOM_UNSIGNED",
147
+ title: "SBOM exists but no cosign attestation found",
148
+ severity: "MEDIUM",
149
+ evidence: [`Attestation dir: ${ATTESTATION_DIR}`],
150
+ requiredActions: [
151
+ "Sign the SBOM with cosign: cosign attest --predicate .mcp/sbom/latest.json ...",
152
+ "Store attestation in .mcp/attestations/"
153
+ ]
154
+ });
155
+ }
156
+ }
157
+ // Cross-reference package.json deps vs SBOM components
158
+ const sbom = await readSbom();
159
+ if (sbom) {
160
+ const pkgDeps = await getPackageJsonDeps();
161
+ const sbomNames = new Set((sbom.components ?? []).map((c) => c.name?.toLowerCase() ?? ""));
162
+ const missing = pkgDeps.filter((dep) => !sbomNames.has(dep.toLowerCase())).slice(0, 20);
163
+ if (missing.length > 0) {
164
+ findings.push({
165
+ id: "SBOM_COMPONENT_MISMATCH",
166
+ title: "Dependencies in package.json not found in SBOM",
167
+ severity: "HIGH",
168
+ evidence: missing,
169
+ requiredActions: [
170
+ "Regenerate the SBOM to include all current dependencies.",
171
+ "Ensure Syft has access to node_modules when generating the SBOM."
172
+ ]
173
+ });
174
+ }
175
+ }
176
+ }
177
+ // SLSA provenance check
178
+ try {
179
+ const provenanceFiles = await fg(["**/*.intoto.jsonl", "**/provenance.json", "**/*.provenance"], {
180
+ cwd: ATTESTATION_DIR,
181
+ dot: true
182
+ });
183
+ if (provenanceFiles.length === 0) {
184
+ findings.push({
185
+ id: "PROVENANCE_MISSING",
186
+ title: "No SLSA provenance attestation found",
187
+ severity: "HIGH",
188
+ evidence: [`Attestation dir: ${ATTESTATION_DIR}`],
189
+ requiredActions: [
190
+ "Generate SLSA provenance during CI/CD build.",
191
+ "Use slsa-github-generator or equivalent to produce .intoto.jsonl attestations.",
192
+ "Store provenance in .mcp/attestations/"
193
+ ]
194
+ });
195
+ }
196
+ }
197
+ catch { /* directory doesn't exist yet */ }
198
+ return findings;
199
+ }