security-mcp 1.1.1 → 1.1.2

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 (70) hide show
  1. package/README.md +4 -1
  2. package/dist/ci/pr-gate.js +18 -1
  3. package/dist/cli/onboarding.js +78 -7
  4. package/dist/gate/checks/api.js +93 -0
  5. package/dist/gate/checks/ci-pipeline.js +135 -0
  6. package/dist/gate/checks/crypto.js +91 -22
  7. package/dist/gate/checks/database.js +5 -1
  8. package/dist/gate/checks/dependencies.js +297 -2
  9. package/dist/gate/checks/dlp.js +6 -1
  10. package/dist/gate/checks/graphql.js +6 -1
  11. package/dist/gate/checks/k8s.js +229 -181
  12. package/dist/gate/checks/nuclei.js +133 -0
  13. package/dist/gate/checks/runtime.js +32 -18
  14. package/dist/gate/checks/scanners.js +2 -1
  15. package/dist/gate/diff.js +2 -0
  16. package/dist/gate/policy.js +47 -4
  17. package/dist/gate/result.js +7 -1
  18. package/dist/mcp/audit-chain.js +253 -0
  19. package/dist/mcp/learning.js +228 -0
  20. package/dist/mcp/model-router.js +544 -0
  21. package/dist/mcp/orchestration.js +22 -4
  22. package/dist/mcp/server.js +92 -1
  23. package/dist/review/store.js +10 -0
  24. package/package.json +1 -1
  25. package/skills/_TEMPLATE/SKILL.md +99 -0
  26. package/skills/advanced-dos-tester/SKILL.md +225 -0
  27. package/skills/ai-model-supply-chain-agent/SKILL.md +198 -0
  28. package/skills/anti-replay-tester/SKILL.md +195 -0
  29. package/skills/binary-auth-validator/SKILL.md +184 -0
  30. package/skills/bot-detection-specialist/SKILL.md +221 -0
  31. package/skills/capec-code-mapper/SKILL.md +163 -0
  32. package/skills/cert-pin-rotation-specialist/SKILL.md +200 -0
  33. package/skills/compliance-lifecycle-tracker/SKILL.md +169 -0
  34. package/skills/credential-stuffing-specialist/SKILL.md +192 -0
  35. package/skills/csa-ccm-mapper/SKILL.md +178 -0
  36. package/skills/csf2-governance-mapper/SKILL.md +159 -0
  37. package/skills/deep-link-fuzzer/SKILL.md +195 -0
  38. package/skills/device-integrity-aggregator/SKILL.md +221 -0
  39. package/skills/dos-resilience-tester/SKILL.md +184 -0
  40. package/skills/dread-scorer/SKILL.md +157 -0
  41. package/skills/egress-policy-enforcer/SKILL.md +208 -0
  42. package/skills/file-upload-attacker/SKILL.md +208 -0
  43. package/skills/git-history-secret-scanner/SKILL.md +182 -0
  44. package/skills/iam-privesc-graph-builder/SKILL.md +216 -0
  45. package/skills/incident-responder/SKILL.md +192 -0
  46. package/skills/json-ambiguity-tester/SKILL.md +175 -0
  47. package/skills/kill-switch-engineer/SKILL.md +205 -0
  48. package/skills/linddun-privacy-analyst/SKILL.md +196 -0
  49. package/skills/mobile-binary-hardener/SKILL.md +199 -0
  50. package/skills/mobile-webview-auditor/SKILL.md +200 -0
  51. package/skills/multipart-abuse-tester/SKILL.md +146 -0
  52. package/skills/oauth-pkce-specialist/SKILL.md +191 -0
  53. package/skills/parser-exhaustion-tester/SKILL.md +177 -0
  54. package/skills/quantum-migration-planner/SKILL.md +184 -0
  55. package/skills/registry-mirror-enforcer/SKILL.md +142 -0
  56. package/skills/rotation-validation-agent/SKILL.md +188 -0
  57. package/skills/samm-assessor/SKILL.md +168 -0
  58. package/skills/secrets-mask-bypass-tester/SKILL.md +167 -0
  59. package/skills/session-timeout-tester/SKILL.md +197 -0
  60. package/skills/slsa-level3-enforcer/SKILL.md +185 -0
  61. package/skills/slsa-provenance-enforcer/SKILL.md +181 -0
  62. package/skills/ssrf-detection-validator/SKILL.md +229 -0
  63. package/skills/step-up-auth-enforcer/SKILL.md +176 -0
  64. package/skills/threat-infrastructure-analyst/SKILL.md +167 -0
  65. package/skills/token-reuse-detector/SKILL.md +203 -0
  66. package/skills/trike-risk-modeler/SKILL.md +139 -0
  67. package/skills/unicode-homograph-tester/SKILL.md +179 -0
  68. package/skills/waf-rule-lifecycle-agent/SKILL.md +213 -0
  69. package/skills/webhook-security-tester/SKILL.md +184 -0
  70. package/skills/zero-trust-architect/SKILL.md +211 -0
@@ -1,190 +1,238 @@
1
+ /**
2
+ * Kubernetes manifest security checks.
3
+ */
4
+ import { sanitizeErrorMessage } from "../result.js";
1
5
  import fg from "fast-glob";
2
6
  import { readFileSafe } from "../../repo/fs.js";
3
- export async function checkKubernetes(_opts) {
4
- const findings = [];
5
- try {
6
- // 1. Glob YAML files and filter to K8s manifests
7
- const yamlFiles = await fg(["**/*.yaml", "**/*.yml"], {
8
- ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"]
9
- });
10
- const k8sFiles = [];
11
- const k8sContents = new Map();
12
- for (const file of yamlFiles) {
13
- try {
14
- const content = await readFileSafe(file);
15
- if (/kind\s*:/.test(content)) {
16
- k8sFiles.push(file);
17
- k8sContents.set(file, content);
18
- }
19
- }
20
- catch {
21
- // skip unreadable files
7
+ function matching(ctx, pattern) {
8
+ return ctx.files.filter((f) => pattern.test(ctx.contents.get(f) ?? "")).slice(0, 10);
9
+ }
10
+ function filterFiles(ctx, predicate) {
11
+ return ctx.files.filter((f) => predicate(ctx.contents.get(f) ?? "")).slice(0, 10);
12
+ }
13
+ async function loadK8sManifests() {
14
+ const yamlFiles = await fg(["**/*.yaml", "**/*.yml"], {
15
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"]
16
+ });
17
+ const files = [];
18
+ const contents = new Map();
19
+ for (const file of yamlFiles) {
20
+ try {
21
+ const content = await readFileSafe(file);
22
+ if (/kind\s*:/.test(content)) {
23
+ files.push(file);
24
+ contents.set(file, content);
22
25
  }
23
26
  }
24
- if (k8sFiles.length === 0) {
25
- return [];
26
- }
27
- // Helper to collect files matching a pattern
28
- function filesMatching(pattern) {
29
- return k8sFiles.filter((f) => pattern.test(k8sContents.get(f) ?? "")).slice(0, 10);
30
- }
31
- // 3. Privileged containers
32
- const privilegedFiles = filesMatching(/privileged:\s*true/);
33
- if (privilegedFiles.length > 0) {
34
- findings.push({
35
- id: "K8S_PRIVILEGED_CONTAINER",
36
- title: "Kubernetes manifests with privileged containers detected",
37
- severity: "CRITICAL",
38
- files: privilegedFiles,
39
- requiredActions: [
40
- "Remove privileged: true from all container securityContexts.",
41
- "Use specific capability grants (e.g. NET_ADMIN) instead of full privileged mode."
42
- ]
43
- });
44
- }
45
- // 4. allowPrivilegeEscalation
46
- const escFiles = filesMatching(/allowPrivilegeEscalation:\s*true/);
47
- if (escFiles.length > 0) {
48
- findings.push({
49
- id: "K8S_PRIVILEGE_ESCALATION",
50
- title: "Kubernetes manifests allow privilege escalation",
51
- severity: "HIGH",
52
- files: escFiles,
53
- requiredActions: [
54
- "Set allowPrivilegeEscalation: false in all container securityContexts.",
55
- "This prevents child processes from gaining more privileges than their parent."
56
- ]
57
- });
58
- }
59
- // 5. Host namespaces
60
- const hostNsFiles = filesMatching(/hostPID:\s*true|hostNetwork:\s*true|hostIPC:\s*true/);
61
- if (hostNsFiles.length > 0) {
62
- findings.push({
63
- id: "K8S_HOST_NAMESPACE",
64
- title: "Kubernetes manifests use host namespaces (hostPID/hostNetwork/hostIPC)",
65
- severity: "HIGH",
66
- files: hostNsFiles,
67
- requiredActions: [
68
- "Remove hostPID, hostNetwork, and hostIPC settings from pod specs.",
69
- "Host namespace sharing breaks container isolation and exposes the host."
70
- ]
71
- });
72
- }
73
- // 6. Missing securityContext
74
- const missingSecCtxFiles = k8sFiles.filter((f) => {
75
- const c = k8sContents.get(f) ?? "";
76
- return /containers:/.test(c) && !/securityContext:/.test(c);
77
- }).slice(0, 10);
78
- if (missingSecCtxFiles.length > 0) {
79
- findings.push({
80
- id: "K8S_NO_SECURITY_CONTEXT",
81
- title: "Kubernetes manifests with containers but no securityContext",
82
- severity: "MEDIUM",
83
- files: missingSecCtxFiles,
84
- requiredActions: [
85
- "Add securityContext to all containers with runAsNonRoot: true, readOnlyRootFilesystem: true.",
86
- "Set allowPrivilegeEscalation: false and drop all capabilities."
87
- ]
88
- });
89
- }
90
- // 7. Secrets in ConfigMap
91
- const configMapFiles = k8sFiles.filter((f) => {
92
- const c = k8sContents.get(f) ?? "";
93
- return /kind:\s*ConfigMap/.test(c) && /password|secret|key|token/i.test(c);
94
- }).slice(0, 10);
95
- if (configMapFiles.length > 0) {
96
- findings.push({
97
- id: "K8S_SECRET_IN_CONFIGMAP",
98
- title: "Sensitive data (password/secret/key/token) found in Kubernetes ConfigMap",
99
- severity: "CRITICAL",
100
- files: configMapFiles,
101
- requiredActions: [
102
- "Move secrets to Kubernetes Secrets objects or a secrets manager (Vault, AWS SM).",
103
- "Never store plaintext credentials in ConfigMaps — they are not encrypted at rest by default."
104
- ]
105
- });
106
- }
107
- // 8. ClusterAdmin binding
108
- const clusterAdminFiles = k8sFiles.filter((f) => {
109
- const c = k8sContents.get(f) ?? "";
110
- return /kind:\s*ClusterRoleBinding/.test(c) && /cluster-admin/.test(c);
111
- }).slice(0, 10);
112
- if (clusterAdminFiles.length > 0) {
113
- findings.push({
114
- id: "K8S_CLUSTER_ADMIN_BINDING",
115
- title: "ClusterRoleBinding to cluster-admin detected",
116
- severity: "CRITICAL",
117
- files: clusterAdminFiles,
118
- requiredActions: [
119
- "Remove cluster-admin bindings and apply least-privilege RBAC roles.",
120
- "Create scoped Roles/ClusterRoles with only the permissions actually needed."
121
- ]
122
- });
123
- }
124
- // 9. No resource limits
125
- const noLimitsFiles = k8sFiles.filter((f) => {
126
- const c = k8sContents.get(f) ?? "";
127
- return /containers:/.test(c) && !/limits:/.test(c);
128
- }).slice(0, 10);
129
- if (noLimitsFiles.length > 0) {
130
- findings.push({
131
- id: "K8S_NO_RESOURCE_LIMITS",
132
- title: "Kubernetes containers without resource limits detected",
133
- severity: "MEDIUM",
134
- files: noLimitsFiles,
135
- requiredActions: [
136
- "Add resources.limits (cpu, memory) to all containers.",
137
- "Missing limits allow a single container to starve the entire node (DoS)."
138
- ]
139
- });
140
- }
141
- // 10. Default namespace
142
- const defaultNsFiles = k8sFiles.filter((f) => {
143
- const c = k8sContents.get(f) ?? "";
144
- return /namespace:\s*default/.test(c) || (!/namespace:/.test(c) && /kind:\s*(?:Deployment|Service|Pod|StatefulSet)/.test(c));
145
- }).slice(0, 10);
146
- if (defaultNsFiles.length > 0) {
147
- findings.push({
148
- id: "K8S_DEFAULT_NAMESPACE",
149
- title: "Kubernetes manifests use default namespace or have no namespace set",
150
- severity: "LOW",
151
- files: defaultNsFiles,
152
- requiredActions: [
153
- "Create dedicated namespaces for each application/team.",
154
- "Apply RBAC and NetworkPolicies scoped to those namespaces."
155
- ]
156
- });
157
- }
158
- // 11. Latest image tag
159
- const latestTagFiles = filesMatching(/:latest\b/);
160
- if (latestTagFiles.length > 0) {
161
- findings.push({
162
- id: "K8S_LATEST_IMAGE_TAG",
163
- title: "Kubernetes manifests use ':latest' image tag",
164
- severity: "HIGH",
165
- files: latestTagFiles,
166
- requiredActions: [
167
- "Pin container images to an immutable digest (e.g. image@sha256:...).",
168
- "Never use :latest in production — it leads to unpredictable deployments."
169
- ]
170
- });
171
- }
172
- // 12. No network policy
173
- const networkPolicyFiles = await fg(["**/NetworkPolicy*.yaml", "**/*network-policy*.yaml", "**/NetworkPolicy*.yml", "**/*network-policy*.yml"], { ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"] });
174
- if (networkPolicyFiles.length === 0) {
175
- findings.push({
176
- id: "K8S_NO_NETWORK_POLICY",
177
- title: "No Kubernetes NetworkPolicy found — all pod-to-pod traffic is allowed",
178
- severity: "HIGH",
179
- requiredActions: [
180
- "Create NetworkPolicy resources to restrict ingress and egress traffic.",
181
- "Default-deny all traffic and only allow explicitly required paths."
182
- ]
183
- });
27
+ catch {
28
+ // skip unreadable files
184
29
  }
185
30
  }
186
- catch (err) {
187
- console.warn("[checkKubernetes] Internal error:", err instanceof Error ? err.message : String(err));
31
+ return { files, contents };
32
+ }
33
+ function checkContainerSecurity(ctx) {
34
+ const findings = [];
35
+ const privilegedFiles = matching(ctx, /privileged:\s*true/);
36
+ if (privilegedFiles.length > 0) {
37
+ findings.push({
38
+ id: "K8S_PRIVILEGED_CONTAINER",
39
+ title: "Kubernetes manifests with privileged containers detected",
40
+ severity: "CRITICAL",
41
+ files: privilegedFiles,
42
+ requiredActions: [
43
+ "Remove privileged: true from all container securityContexts.",
44
+ "Use specific capability grants (e.g. NET_ADMIN) instead of full privileged mode."
45
+ ]
46
+ });
47
+ }
48
+ const escFiles = matching(ctx, /allowPrivilegeEscalation:\s*true/);
49
+ if (escFiles.length > 0) {
50
+ findings.push({
51
+ id: "K8S_PRIVILEGE_ESCALATION",
52
+ title: "Kubernetes manifests allow privilege escalation",
53
+ severity: "HIGH",
54
+ files: escFiles,
55
+ requiredActions: [
56
+ "Set allowPrivilegeEscalation: false in all container securityContexts.",
57
+ "This prevents child processes from gaining more privileges than their parent."
58
+ ]
59
+ });
60
+ }
61
+ const hostNsFiles = matching(ctx, /hostPID:\s*true|hostNetwork:\s*true|hostIPC:\s*true/);
62
+ if (hostNsFiles.length > 0) {
63
+ findings.push({
64
+ id: "K8S_HOST_NAMESPACE",
65
+ title: "Kubernetes manifests use host namespaces (hostPID/hostNetwork/hostIPC)",
66
+ severity: "HIGH",
67
+ files: hostNsFiles,
68
+ requiredActions: [
69
+ "Remove hostPID, hostNetwork, and hostIPC settings from pod specs.",
70
+ "Host namespace sharing breaks container isolation and exposes the host."
71
+ ]
72
+ });
73
+ }
74
+ const missingSecCtxFiles = filterFiles(ctx, (c) => /containers:/.test(c) && !/securityContext:/.test(c));
75
+ if (missingSecCtxFiles.length > 0) {
76
+ findings.push({
77
+ id: "K8S_NO_SECURITY_CONTEXT",
78
+ title: "Kubernetes manifests with containers but no securityContext",
79
+ severity: "MEDIUM",
80
+ files: missingSecCtxFiles,
81
+ requiredActions: [
82
+ "Add securityContext to all containers with runAsNonRoot: true, readOnlyRootFilesystem: true.",
83
+ "Set allowPrivilegeEscalation: false and drop all capabilities."
84
+ ]
85
+ });
86
+ }
87
+ const noReadOnlyRootFiles = filterFiles(ctx, (c) => /containers:/.test(c) && !/readOnlyRootFilesystem:\s*true/.test(c));
88
+ if (noReadOnlyRootFiles.length > 0) {
89
+ findings.push({
90
+ id: "K8S_NO_READONLY_ROOT",
91
+ title: "Kubernetes containers without readOnlyRootFilesystem: true",
92
+ severity: "MEDIUM",
93
+ files: noReadOnlyRootFiles,
94
+ requiredActions: [
95
+ "Set `readOnlyRootFilesystem: true` in all container securityContexts.",
96
+ "A writable root filesystem allows an attacker to modify binaries or write persistence mechanisms.",
97
+ "Mount writable paths explicitly via emptyDir volumes for directories that legitimately need writes."
98
+ ]
99
+ });
100
+ }
101
+ return findings;
102
+ }
103
+ function checkRbacAndConfig(ctx) {
104
+ const findings = [];
105
+ const configMapFiles = filterFiles(ctx, (c) => /kind:\s*ConfigMap/.test(c) && /password|secret|key|token/i.test(c));
106
+ if (configMapFiles.length > 0) {
107
+ findings.push({
108
+ id: "K8S_SECRET_IN_CONFIGMAP",
109
+ title: "Sensitive data (password/secret/key/token) found in Kubernetes ConfigMap",
110
+ severity: "CRITICAL",
111
+ files: configMapFiles,
112
+ requiredActions: [
113
+ "Move secrets to Kubernetes Secrets objects or a secrets manager (Vault, AWS SM).",
114
+ "Never store plaintext credentials in ConfigMaps — they are not encrypted at rest by default."
115
+ ]
116
+ });
117
+ }
118
+ const clusterAdminFiles = filterFiles(ctx, (c) => /kind:\s*ClusterRoleBinding/.test(c) && /cluster-admin/.test(c));
119
+ if (clusterAdminFiles.length > 0) {
120
+ findings.push({
121
+ id: "K8S_CLUSTER_ADMIN_BINDING",
122
+ title: "ClusterRoleBinding to cluster-admin detected",
123
+ severity: "CRITICAL",
124
+ files: clusterAdminFiles,
125
+ requiredActions: [
126
+ "Remove cluster-admin bindings and apply least-privilege RBAC roles.",
127
+ "Create scoped Roles/ClusterRoles with only the permissions actually needed."
128
+ ]
129
+ });
130
+ }
131
+ const noLimitsFiles = filterFiles(ctx, (c) => /containers:/.test(c) && !/limits:/.test(c));
132
+ if (noLimitsFiles.length > 0) {
133
+ findings.push({
134
+ id: "K8S_NO_RESOURCE_LIMITS",
135
+ title: "Kubernetes containers without resource limits detected",
136
+ severity: "MEDIUM",
137
+ files: noLimitsFiles,
138
+ requiredActions: [
139
+ "Add resources.limits (cpu, memory) to all containers.",
140
+ "Missing limits allow a single container to starve the entire node (DoS)."
141
+ ]
142
+ });
143
+ }
144
+ const defaultNsFiles = filterFiles(ctx, (c) => /namespace:\s*default/.test(c) ||
145
+ (!/namespace:/.test(c) && /kind:\s*(?:Deployment|Service|Pod|StatefulSet)/.test(c)));
146
+ if (defaultNsFiles.length > 0) {
147
+ findings.push({
148
+ id: "K8S_DEFAULT_NAMESPACE",
149
+ title: "Kubernetes manifests use default namespace or have no namespace set",
150
+ severity: "LOW",
151
+ files: defaultNsFiles,
152
+ requiredActions: [
153
+ "Create dedicated namespaces for each application/team.",
154
+ "Apply RBAC and NetworkPolicies scoped to those namespaces."
155
+ ]
156
+ });
157
+ }
158
+ const latestTagFiles = matching(ctx, /:latest\b/);
159
+ if (latestTagFiles.length > 0) {
160
+ findings.push({
161
+ id: "K8S_LATEST_IMAGE_TAG",
162
+ title: "Kubernetes manifests use ':latest' image tag",
163
+ severity: "HIGH",
164
+ files: latestTagFiles,
165
+ requiredActions: [
166
+ "Pin container images to an immutable digest (e.g. image@sha256:...).",
167
+ "Never use :latest in production — it leads to unpredictable deployments."
168
+ ]
169
+ });
170
+ }
171
+ return findings;
172
+ }
173
+ async function checkNetworkAndAdmission(ctx) {
174
+ const findings = [];
175
+ const networkPolicyFiles = await fg(["**/NetworkPolicy*.yaml", "**/*network-policy*.yaml", "**/NetworkPolicy*.yml", "**/*network-policy*.yml"], { ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"] });
176
+ if (networkPolicyFiles.length === 0) {
177
+ findings.push({
178
+ id: "K8S_NO_NETWORK_POLICY",
179
+ title: "No Kubernetes NetworkPolicy found — all pod-to-pod traffic is allowed",
180
+ severity: "HIGH",
181
+ requiredActions: [
182
+ "Create NetworkPolicy resources to restrict ingress and egress traffic.",
183
+ "Default-deny all traffic and only allow explicitly required paths."
184
+ ]
185
+ });
186
+ }
187
+ const nsWithoutPsa = ctx.files
188
+ .filter((f) => {
189
+ const c = ctx.contents.get(f) ?? "";
190
+ return /kind:\s*Namespace/.test(c) && !/pod-security\.kubernetes\.io\/enforce/.test(c);
191
+ })
192
+ .slice(0, 10);
193
+ if (nsWithoutPsa.length > 0) {
194
+ findings.push({
195
+ id: "K8S_NO_PSA_LABEL",
196
+ title: "Kubernetes Namespace missing PodSecurityAdmission enforce label",
197
+ severity: "HIGH",
198
+ files: nsWithoutPsa,
199
+ requiredActions: [
200
+ "Add `pod-security.kubernetes.io/enforce: restricted` label to all Namespace manifests.",
201
+ "PodSecurityAdmission (PSA) is the replacement for PodSecurityPolicy — without it, pod security rules are not enforced.",
202
+ "Enforce via OPA Gatekeeper ConstraintTemplate — run `security_generate_opa_rego` to generate policy."
203
+ ]
204
+ });
205
+ }
206
+ const hostPathFiles = matching(ctx, /hostPath\s*:/);
207
+ if (hostPathFiles.length > 0) {
208
+ findings.push({
209
+ id: "K8S_HOSTPATH_MOUNT",
210
+ title: "Kubernetes manifests mount host filesystem paths (hostPath)",
211
+ severity: "HIGH",
212
+ files: hostPathFiles,
213
+ requiredActions: [
214
+ "Remove hostPath volume mounts — they expose the node's filesystem to the container.",
215
+ "Use emptyDir, PersistentVolumeClaims, or ConfigMaps instead.",
216
+ "Enforce via OPA Gatekeeper ConstraintTemplate — run `security_generate_opa_rego` to generate policy."
217
+ ]
218
+ });
188
219
  }
189
220
  return findings;
190
221
  }
222
+ export async function checkKubernetes(_opts) {
223
+ try {
224
+ const ctx = await loadK8sManifests();
225
+ if (ctx.files.length === 0)
226
+ return [];
227
+ const networkFindings = await checkNetworkAndAdmission(ctx);
228
+ return [
229
+ ...checkContainerSecurity(ctx),
230
+ ...checkRbacAndConfig(ctx),
231
+ ...networkFindings
232
+ ];
233
+ }
234
+ catch (err) {
235
+ console.warn("[checkKubernetes] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
236
+ return [];
237
+ }
238
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * DAST integration via Nuclei (https://github.com/projectdiscovery/nuclei).
3
+ * Only runs when SECURITY_STAGING_URL is set — requires a live target.
4
+ * Gracefully skips if the nuclei binary is not installed.
5
+ */
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { sanitizeErrorMessage } from "../result.js";
9
+ const execFileAsync = promisify(execFile);
10
+ // Template categories focused on high-signal findings with low false-positive rates
11
+ const NUCLEI_TEMPLATES = [
12
+ "network",
13
+ "http/misconfiguration",
14
+ "http/exposed-panels",
15
+ "http/default-logins",
16
+ "http/exposed-tokens",
17
+ "ssl"
18
+ ];
19
+ function mapSeverity(nucleiSev) {
20
+ switch ((nucleiSev ?? "").toLowerCase()) {
21
+ case "critical": return "CRITICAL";
22
+ case "high": return "HIGH";
23
+ case "medium": return "MEDIUM";
24
+ default: return "LOW";
25
+ }
26
+ }
27
+ async function isNucleiAvailable() {
28
+ try {
29
+ await execFileAsync("nuclei", ["--version"], { timeout: 5000 });
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ export async function runNucleiChecks(_opts) {
37
+ const targetUrl = process.env["SECURITY_STAGING_URL"];
38
+ if (!targetUrl)
39
+ return [];
40
+ // Basic URL validation — block private/metadata ranges (CWE-918)
41
+ try {
42
+ const parsed = new URL(targetUrl);
43
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
44
+ return [];
45
+ const host = parsed.hostname;
46
+ if (host === "localhost" ||
47
+ host === "169.254.169.254" ||
48
+ host === "metadata.google.internal" ||
49
+ host.endsWith(".internal") ||
50
+ /^(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|127\.)/.test(host)) {
51
+ console.warn("[runNucleiChecks] SECURITY_STAGING_URL resolves to private/metadata address — skipping DAST scan.");
52
+ return [];
53
+ }
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ if (!(await isNucleiAvailable())) {
59
+ // Silent skip — nuclei is optional. Scanner readiness check will flag missing tooling.
60
+ return [];
61
+ }
62
+ const findings = [];
63
+ try {
64
+ const templateArgs = NUCLEI_TEMPLATES.flatMap((t) => ["-t", t]);
65
+ let stdout = "";
66
+ try {
67
+ const result = await execFileAsync("nuclei", [
68
+ "-u", targetUrl,
69
+ ...templateArgs,
70
+ "-json",
71
+ "-silent",
72
+ "-timeout", "30",
73
+ "-max-host-error", "5",
74
+ "-rate-limit", "50"
75
+ ], {
76
+ timeout: 120_000, // 2 min hard cap
77
+ // CWE-526: pass only PATH — do not propagate secrets/tokens from parent env.
78
+ env: { PATH: process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin" },
79
+ maxBuffer: 50 * 1024 * 1024 // 50 MB — nuclei output can be large in full-template scans
80
+ });
81
+ stdout = result.stdout;
82
+ }
83
+ catch (execErr) {
84
+ // nuclei exits non-zero when findings exist — that's expected
85
+ const err = execErr;
86
+ stdout = err.stdout ?? "";
87
+ }
88
+ if (!stdout.trim())
89
+ return [];
90
+ // nuclei -json outputs newline-delimited JSON (one object per line)
91
+ const seen = new Set();
92
+ for (const line of stdout.split("\n")) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed || !trimmed.startsWith("{"))
95
+ continue;
96
+ let result;
97
+ try {
98
+ result = JSON.parse(trimmed);
99
+ }
100
+ catch {
101
+ continue;
102
+ }
103
+ const templateId = result["template-id"] ?? "unknown";
104
+ const host = result.host ?? targetUrl;
105
+ const dedupeKey = `${templateId}:${host}`;
106
+ if (seen.has(dedupeKey))
107
+ continue;
108
+ seen.add(dedupeKey);
109
+ const name = result.info?.name ?? templateId;
110
+ const severity = mapSeverity(result.info?.severity);
111
+ const matched = result.matched ?? host;
112
+ findings.push({
113
+ id: `NUCLEI_${templateId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`,
114
+ title: `[DAST] ${name}`,
115
+ severity,
116
+ evidence: [
117
+ `Template: ${templateId}`,
118
+ `Target: ${host}`,
119
+ `Matched: ${matched}`
120
+ ],
121
+ requiredActions: [
122
+ `Review the Nuclei finding for template "${templateId}" against ${host}.`,
123
+ "Reproduce with: `nuclei -u " + targetUrl + " -t " + templateId + " -debug`",
124
+ "Remediate before deploying to production — this was detected against a live staging environment."
125
+ ]
126
+ });
127
+ }
128
+ }
129
+ catch (err) {
130
+ console.warn("[runNucleiChecks] Internal error:", sanitizeErrorMessage(err instanceof Error ? err.message : String(err)));
131
+ }
132
+ return findings;
133
+ }
@@ -22,38 +22,41 @@ const PRIVATE_CIDR_PATTERNS = [
22
22
  function isPrivateIp(ip) {
23
23
  return PRIVATE_CIDR_PATTERNS.some((re) => re.test(ip));
24
24
  }
25
+ // CWE-367: return the resolved IP alongside safe/unsafe so callers can connect
26
+ // directly to the IP (eliminating the TOCTOU race between DNS check and actual request).
25
27
  async function isSafeUrl(rawUrl) {
26
28
  let parsed;
27
29
  try {
28
30
  parsed = new URL(rawUrl);
29
31
  }
30
32
  catch {
31
- return false;
33
+ return { safe: false };
32
34
  }
33
35
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
34
- return false;
36
+ return { safe: false };
35
37
  const host = parsed.hostname;
36
- // Block bare IP references
38
+ // Block bare IP references — "resolved" IP is the hostname itself
37
39
  if (net.isIP(host)) {
38
- return !isPrivateIp(host);
40
+ return isPrivateIp(host) ? { safe: false } : { safe: true, resolvedIp: host };
39
41
  }
40
42
  // Block known metadata hostnames
41
43
  if (host === "localhost" || host === "metadata.google.internal" ||
42
44
  host === "169.254.169.254" || host.endsWith(".internal")) {
43
- return false;
45
+ return { safe: false };
44
46
  }
45
- // Resolve DNS and check all resolved IPs
47
+ // Resolve DNS once all returned IPs must be public; return the first for direct connection
46
48
  try {
47
49
  const resolved = await dns.lookup(host, { all: true });
48
50
  for (const { address } of resolved) {
49
51
  if (isPrivateIp(address))
50
- return false;
52
+ return { safe: false };
51
53
  }
54
+ const firstIp = resolved[0]?.address;
55
+ return firstIp ? { safe: true, resolvedIp: firstIp } : { safe: false };
52
56
  }
53
57
  catch {
54
- return false; // can't resolve → skip
58
+ return { safe: false }; // can't resolve → skip
55
59
  }
56
- return true;
57
60
  }
58
61
  const REQUIRED_HEADERS = [
59
62
  {
@@ -95,17 +98,20 @@ const REQUIRED_HEADERS = [
95
98
  const WEAK_CIPHERS = [
96
99
  "RC4", "DES", "3DES", "NULL", "EXPORT", "ADH", "AECDH", "aNULL", "eNULL"
97
100
  ];
98
- async function fetchHeaders(url, timeoutMs) {
101
+ async function fetchHeaders(url, timeoutMs, resolvedIp // CWE-367: pass pre-validated IP to eliminate DNS TOCTOU race
102
+ ) {
99
103
  return new Promise((resolve) => {
100
104
  const timer = setTimeout(() => resolve(null), timeoutMs);
101
105
  try {
102
106
  const parsedUrl = new URL(url);
103
107
  const options = {
104
- hostname: parsedUrl.hostname,
108
+ // Connect to the already-validated IP directly; use the original hostname for SNI
109
+ hostname: resolvedIp ?? parsedUrl.hostname,
110
+ servername: resolvedIp ? parsedUrl.hostname : undefined,
105
111
  port: parsedUrl.port || 443,
106
112
  path: parsedUrl.pathname || "/",
107
113
  method: "HEAD",
108
- rejectUnauthorized: false, // we verify cert separately
114
+ rejectUnauthorized: true, // CWE-295: always validate TLS certificates
109
115
  timeout: timeoutMs
110
116
  };
111
117
  const req = https.request(options, (res) => {
@@ -181,13 +187,21 @@ export async function runRuntimeChecks(opts) {
181
187
  // Determine target URL
182
188
  const stagingUrl = process.env["SECURITY_STAGING_URL"];
183
189
  const rawTargets = stagingUrl ? [stagingUrl, ...opts.targets] : opts.targets;
184
- // CWE-918: resolve hostnames and reject private/metadata IPs before making requests
185
- const safeChecks = await Promise.all(rawTargets.map(async (t) => ({ t, safe: await isSafeUrl(t) })));
186
- const uniqueTargets = [...new Set(safeChecks.filter((x) => x.safe).map((x) => x.t))];
187
- if (uniqueTargets.length === 0)
190
+ // CWE-918 / CWE-367: resolve hostnames once, reject private/metadata IPs, and
191
+ // carry the resolved IP forward so fetchHeaders connects to the validated IP
192
+ // directly eliminating the TOCTOU race between DNS check and actual request.
193
+ const safeChecks = await Promise.all(rawTargets.map(async (t) => ({ t, ...(await isSafeUrl(t)) })));
194
+ // Deduplicate by URL, keeping the first resolved IP for each unique URL
195
+ const seen = new Map();
196
+ for (const c of safeChecks) {
197
+ if (c.safe && !seen.has(c.t))
198
+ seen.set(c.t, c);
199
+ }
200
+ const checkedTargets = [...seen.values()];
201
+ if (checkedTargets.length === 0)
188
202
  return findings;
189
203
  const timeoutMs = 15_000;
190
- for (const targetUrl of uniqueTargets) {
204
+ for (const { t: targetUrl, resolvedIp } of checkedTargets) {
191
205
  let parsedUrl;
192
206
  try {
193
207
  parsedUrl = new URL(targetUrl);
@@ -196,7 +210,7 @@ export async function runRuntimeChecks(opts) {
196
210
  continue;
197
211
  }
198
212
  // --- HTTP Header checks ---
199
- const headers = await fetchHeaders(targetUrl, timeoutMs);
213
+ const headers = await fetchHeaders(targetUrl, timeoutMs, resolvedIp);
200
214
  if (headers !== null) {
201
215
  for (const headerDef of REQUIRED_HEADERS) {
202
216
  const value = headers[headerDef.name];