security-mcp 1.1.0 → 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 (118) hide show
  1. package/README.md +966 -193
  2. package/defaults/agent-run-schema.json +98 -0
  3. package/dist/ci/pr-gate.js +18 -1
  4. package/dist/cli/install.js +69 -2
  5. package/dist/cli/onboarding.js +82 -11
  6. package/dist/cli/update.js +83 -15
  7. package/dist/gate/checks/ai-redteam.js +83 -59
  8. package/dist/gate/checks/api.js +93 -0
  9. package/dist/gate/checks/ci-pipeline.js +135 -0
  10. package/dist/gate/checks/crypto.js +91 -22
  11. package/dist/gate/checks/database.js +5 -1
  12. package/dist/gate/checks/dependencies.js +297 -2
  13. package/dist/gate/checks/dlp.js +6 -1
  14. package/dist/gate/checks/graphql.js +6 -1
  15. package/dist/gate/checks/k8s.js +229 -181
  16. package/dist/gate/checks/nuclei.js +133 -0
  17. package/dist/gate/checks/runtime.js +75 -8
  18. package/dist/gate/checks/scanners.js +8 -2
  19. package/dist/gate/diff.js +2 -0
  20. package/dist/gate/exceptions.js +6 -1
  21. package/dist/gate/policy.js +47 -4
  22. package/dist/gate/result.js +7 -1
  23. package/dist/mcp/audit-chain.js +253 -0
  24. package/dist/mcp/learning.js +228 -0
  25. package/dist/mcp/model-router.js +544 -0
  26. package/dist/mcp/orchestration.js +604 -0
  27. package/dist/mcp/server.js +160 -12
  28. package/dist/repo/search.js +5 -7
  29. package/dist/review/store.js +15 -0
  30. package/dist/types/agent-run.js +8 -0
  31. package/package.json +5 -5
  32. package/skills/_TEMPLATE/SKILL.md +99 -0
  33. package/skills/advanced-dos-tester/SKILL.md +225 -0
  34. package/skills/agentic-loop-exploiter/SKILL.md +69 -0
  35. package/skills/ai-llm-redteam/SKILL.md +118 -0
  36. package/skills/ai-model-supply-chain-agent/SKILL.md +198 -0
  37. package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
  38. package/skills/android-penetration-tester/SKILL.md +83 -0
  39. package/skills/anti-replay-tester/SKILL.md +195 -0
  40. package/skills/appsec-code-auditor/SKILL.md +86 -0
  41. package/skills/artifact-integrity-analyst/SKILL.md +68 -0
  42. package/skills/attack-navigator/SKILL.md +64 -0
  43. package/skills/auth-session-hacker/SKILL.md +87 -0
  44. package/skills/aws-penetration-tester/SKILL.md +60 -0
  45. package/skills/azure-penetration-tester/SKILL.md +64 -0
  46. package/skills/binary-auth-validator/SKILL.md +184 -0
  47. package/skills/bot-detection-specialist/SKILL.md +221 -0
  48. package/skills/business-logic-attacker/SKILL.md +76 -0
  49. package/skills/capec-code-mapper/SKILL.md +163 -0
  50. package/skills/cert-pin-rotation-specialist/SKILL.md +200 -0
  51. package/skills/cicd-pipeline-hijacker/SKILL.md +81 -0
  52. package/skills/ciso-orchestrator/SKILL.md +165 -0
  53. package/skills/cloud-infra-specialist/SKILL.md +85 -0
  54. package/skills/compliance-gap-analyst/SKILL.md +77 -0
  55. package/skills/compliance-grc/SKILL.md +148 -0
  56. package/skills/compliance-lifecycle-tracker/SKILL.md +169 -0
  57. package/skills/credential-stuffing-specialist/SKILL.md +192 -0
  58. package/skills/crypto-pki-specialist/SKILL.md +136 -0
  59. package/skills/csa-ccm-mapper/SKILL.md +178 -0
  60. package/skills/csf2-governance-mapper/SKILL.md +159 -0
  61. package/skills/deep-link-fuzzer/SKILL.md +195 -0
  62. package/skills/dependency-confusion-attacker/SKILL.md +78 -0
  63. package/skills/device-integrity-aggregator/SKILL.md +221 -0
  64. package/skills/dos-resilience-tester/SKILL.md +184 -0
  65. package/skills/dread-scorer/SKILL.md +157 -0
  66. package/skills/egress-policy-enforcer/SKILL.md +208 -0
  67. package/skills/evidence-collector/SKILL.md +86 -0
  68. package/skills/file-upload-attacker/SKILL.md +208 -0
  69. package/skills/gcp-penetration-tester/SKILL.md +63 -0
  70. package/skills/git-history-secret-scanner/SKILL.md +182 -0
  71. package/skills/iam-privesc-graph-builder/SKILL.md +216 -0
  72. package/skills/incident-responder/SKILL.md +192 -0
  73. package/skills/injection-specialist/SKILL.md +62 -0
  74. package/skills/ios-security-auditor/SKILL.md +77 -0
  75. package/skills/json-ambiguity-tester/SKILL.md +175 -0
  76. package/skills/k8s-container-escaper/SKILL.md +74 -0
  77. package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
  78. package/skills/kill-switch-engineer/SKILL.md +205 -0
  79. package/skills/linddun-privacy-analyst/SKILL.md +196 -0
  80. package/skills/logic-race-fuzzer/SKILL.md +67 -0
  81. package/skills/mobile-api-network-attacker/SKILL.md +81 -0
  82. package/skills/mobile-binary-hardener/SKILL.md +199 -0
  83. package/skills/mobile-security-specialist/SKILL.md +124 -0
  84. package/skills/mobile-webview-auditor/SKILL.md +200 -0
  85. package/skills/model-extraction-attacker/SKILL.md +68 -0
  86. package/skills/multipart-abuse-tester/SKILL.md +146 -0
  87. package/skills/oauth-pkce-specialist/SKILL.md +191 -0
  88. package/skills/parser-exhaustion-tester/SKILL.md +177 -0
  89. package/skills/pentest-infra/SKILL.md +69 -0
  90. package/skills/pentest-social/SKILL.md +72 -0
  91. package/skills/pentest-team/SKILL.md +126 -0
  92. package/skills/pentest-web-api/SKILL.md +71 -0
  93. package/skills/privacy-flow-analyst/SKILL.md +70 -0
  94. package/skills/prompt-injection-specialist/SKILL.md +76 -0
  95. package/skills/quantum-migration-planner/SKILL.md +184 -0
  96. package/skills/rag-poisoning-specialist/SKILL.md +71 -0
  97. package/skills/registry-mirror-enforcer/SKILL.md +142 -0
  98. package/skills/rotation-validation-agent/SKILL.md +188 -0
  99. package/skills/samm-assessor/SKILL.md +168 -0
  100. package/skills/secrets-mask-bypass-tester/SKILL.md +167 -0
  101. package/skills/senior-security-engineer/SKILL.md +42 -12
  102. package/skills/serialization-memory-attacker/SKILL.md +78 -0
  103. package/skills/session-timeout-tester/SKILL.md +197 -0
  104. package/skills/slsa-level3-enforcer/SKILL.md +185 -0
  105. package/skills/slsa-provenance-enforcer/SKILL.md +181 -0
  106. package/skills/ssrf-detection-validator/SKILL.md +229 -0
  107. package/skills/step-up-auth-enforcer/SKILL.md +176 -0
  108. package/skills/stride-pasta-analyst/SKILL.md +72 -0
  109. package/skills/supply-chain-devsecops/SKILL.md +82 -0
  110. package/skills/threat-infrastructure-analyst/SKILL.md +167 -0
  111. package/skills/threat-modeler/SKILL.md +116 -0
  112. package/skills/tls-certificate-auditor/SKILL.md +76 -0
  113. package/skills/token-reuse-detector/SKILL.md +203 -0
  114. package/skills/trike-risk-modeler/SKILL.md +139 -0
  115. package/skills/unicode-homograph-tester/SKILL.md +179 -0
  116. package/skills/waf-rule-lifecycle-agent/SKILL.md +213 -0
  117. package/skills/webhook-security-tester/SKILL.md +184 -0
  118. 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
+ }
@@ -2,8 +2,62 @@
2
2
  * Runtime evidence verification.
3
3
  * Checks HTTP security headers and TLS configuration against a live target.
4
4
  */
5
+ import * as dns from "node:dns/promises";
6
+ import * as net from "node:net";
5
7
  import * as https from "node:https";
6
8
  import * as tls from "node:tls";
9
+ // CWE-918: SSRF guard — block private/link-local/metadata IP ranges
10
+ const PRIVATE_CIDR_PATTERNS = [
11
+ /^127\./, // loopback
12
+ /^10\./, // RFC-1918
13
+ /^172\.(1[6-9]|2\d|3[01])\./, // RFC-1918
14
+ /^192\.168\./, // RFC-1918
15
+ /^169\.254\./, // link-local / cloud metadata (169.254.169.254)
16
+ /^::1$/, // IPv6 loopback
17
+ /^fc/, // IPv6 ULA
18
+ /^fd/, // IPv6 ULA
19
+ /^0\./, // 0.0.0.0/8
20
+ /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // RFC-6598 shared address space
21
+ ];
22
+ function isPrivateIp(ip) {
23
+ return PRIVATE_CIDR_PATTERNS.some((re) => re.test(ip));
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).
27
+ async function isSafeUrl(rawUrl) {
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(rawUrl);
31
+ }
32
+ catch {
33
+ return { safe: false };
34
+ }
35
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
36
+ return { safe: false };
37
+ const host = parsed.hostname;
38
+ // Block bare IP references — "resolved" IP is the hostname itself
39
+ if (net.isIP(host)) {
40
+ return isPrivateIp(host) ? { safe: false } : { safe: true, resolvedIp: host };
41
+ }
42
+ // Block known metadata hostnames
43
+ if (host === "localhost" || host === "metadata.google.internal" ||
44
+ host === "169.254.169.254" || host.endsWith(".internal")) {
45
+ return { safe: false };
46
+ }
47
+ // Resolve DNS once — all returned IPs must be public; return the first for direct connection
48
+ try {
49
+ const resolved = await dns.lookup(host, { all: true });
50
+ for (const { address } of resolved) {
51
+ if (isPrivateIp(address))
52
+ return { safe: false };
53
+ }
54
+ const firstIp = resolved[0]?.address;
55
+ return firstIp ? { safe: true, resolvedIp: firstIp } : { safe: false };
56
+ }
57
+ catch {
58
+ return { safe: false }; // can't resolve → skip
59
+ }
60
+ }
7
61
  const REQUIRED_HEADERS = [
8
62
  {
9
63
  name: "content-security-policy",
@@ -44,17 +98,20 @@ const REQUIRED_HEADERS = [
44
98
  const WEAK_CIPHERS = [
45
99
  "RC4", "DES", "3DES", "NULL", "EXPORT", "ADH", "AECDH", "aNULL", "eNULL"
46
100
  ];
47
- async function fetchHeaders(url, timeoutMs) {
101
+ async function fetchHeaders(url, timeoutMs, resolvedIp // CWE-367: pass pre-validated IP to eliminate DNS TOCTOU race
102
+ ) {
48
103
  return new Promise((resolve) => {
49
104
  const timer = setTimeout(() => resolve(null), timeoutMs);
50
105
  try {
51
106
  const parsedUrl = new URL(url);
52
107
  const options = {
53
- 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,
54
111
  port: parsedUrl.port || 443,
55
112
  path: parsedUrl.pathname || "/",
56
113
  method: "HEAD",
57
- rejectUnauthorized: false, // we verify cert separately
114
+ rejectUnauthorized: true, // CWE-295: always validate TLS certificates
58
115
  timeout: timeoutMs
59
116
  };
60
117
  const req = https.request(options, (res) => {
@@ -129,12 +186,22 @@ export async function runRuntimeChecks(opts) {
129
186
  const findings = [];
130
187
  // Determine target URL
131
188
  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)
189
+ const rawTargets = stagingUrl ? [stagingUrl, ...opts.targets] : opts.targets;
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)
135
202
  return findings;
136
203
  const timeoutMs = 15_000;
137
- for (const targetUrl of uniqueTargets) {
204
+ for (const { t: targetUrl, resolvedIp } of checkedTargets) {
138
205
  let parsedUrl;
139
206
  try {
140
207
  parsedUrl = new URL(targetUrl);
@@ -143,7 +210,7 @@ export async function runRuntimeChecks(opts) {
143
210
  continue;
144
211
  }
145
212
  // --- HTTP Header checks ---
146
- const headers = await fetchHeaders(targetUrl, timeoutMs);
213
+ const headers = await fetchHeaders(targetUrl, timeoutMs, resolvedIp);
147
214
  if (headers !== null) {
148
215
  for (const headerDef of REQUIRED_HEADERS) {
149
216
  const value = headers[headerDef.name];