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.
- package/README.md +4 -1
- package/dist/ci/pr-gate.js +18 -1
- package/dist/cli/onboarding.js +78 -7
- package/dist/gate/checks/api.js +93 -0
- package/dist/gate/checks/ci-pipeline.js +135 -0
- package/dist/gate/checks/crypto.js +91 -22
- package/dist/gate/checks/database.js +5 -1
- package/dist/gate/checks/dependencies.js +297 -2
- package/dist/gate/checks/dlp.js +6 -1
- package/dist/gate/checks/graphql.js +6 -1
- package/dist/gate/checks/k8s.js +229 -181
- package/dist/gate/checks/nuclei.js +133 -0
- package/dist/gate/checks/runtime.js +32 -18
- package/dist/gate/checks/scanners.js +2 -1
- package/dist/gate/diff.js +2 -0
- package/dist/gate/policy.js +47 -4
- package/dist/gate/result.js +7 -1
- package/dist/mcp/audit-chain.js +253 -0
- package/dist/mcp/learning.js +228 -0
- package/dist/mcp/model-router.js +544 -0
- package/dist/mcp/orchestration.js +22 -4
- package/dist/mcp/server.js +92 -1
- package/dist/review/store.js +10 -0
- package/package.json +1 -1
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +225 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +198 -0
- package/skills/anti-replay-tester/SKILL.md +195 -0
- package/skills/binary-auth-validator/SKILL.md +184 -0
- package/skills/bot-detection-specialist/SKILL.md +221 -0
- package/skills/capec-code-mapper/SKILL.md +163 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +200 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +169 -0
- package/skills/credential-stuffing-specialist/SKILL.md +192 -0
- package/skills/csa-ccm-mapper/SKILL.md +178 -0
- package/skills/csf2-governance-mapper/SKILL.md +159 -0
- package/skills/deep-link-fuzzer/SKILL.md +195 -0
- package/skills/device-integrity-aggregator/SKILL.md +221 -0
- package/skills/dos-resilience-tester/SKILL.md +184 -0
- package/skills/dread-scorer/SKILL.md +157 -0
- package/skills/egress-policy-enforcer/SKILL.md +208 -0
- package/skills/file-upload-attacker/SKILL.md +208 -0
- package/skills/git-history-secret-scanner/SKILL.md +182 -0
- package/skills/iam-privesc-graph-builder/SKILL.md +216 -0
- package/skills/incident-responder/SKILL.md +192 -0
- package/skills/json-ambiguity-tester/SKILL.md +175 -0
- package/skills/kill-switch-engineer/SKILL.md +205 -0
- package/skills/linddun-privacy-analyst/SKILL.md +196 -0
- package/skills/mobile-binary-hardener/SKILL.md +199 -0
- package/skills/mobile-webview-auditor/SKILL.md +200 -0
- package/skills/multipart-abuse-tester/SKILL.md +146 -0
- package/skills/oauth-pkce-specialist/SKILL.md +191 -0
- package/skills/parser-exhaustion-tester/SKILL.md +177 -0
- package/skills/quantum-migration-planner/SKILL.md +184 -0
- package/skills/registry-mirror-enforcer/SKILL.md +142 -0
- package/skills/rotation-validation-agent/SKILL.md +188 -0
- package/skills/samm-assessor/SKILL.md +168 -0
- package/skills/secrets-mask-bypass-tester/SKILL.md +167 -0
- package/skills/session-timeout-tester/SKILL.md +197 -0
- package/skills/slsa-level3-enforcer/SKILL.md +185 -0
- package/skills/slsa-provenance-enforcer/SKILL.md +181 -0
- package/skills/ssrf-detection-validator/SKILL.md +229 -0
- package/skills/step-up-auth-enforcer/SKILL.md +176 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +167 -0
- package/skills/token-reuse-detector/SKILL.md +203 -0
- package/skills/trike-risk-modeler/SKILL.md +139 -0
- package/skills/unicode-homograph-tester/SKILL.md +179 -0
- package/skills/waf-rule-lifecycle-agent/SKILL.md +213 -0
- package/skills/webhook-security-tester/SKILL.md +184 -0
- package/skills/zero-trust-architect/SKILL.md +211 -0
package/dist/gate/checks/k8s.js
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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];
|