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.
- package/README.md +966 -193
- package/defaults/agent-run-schema.json +98 -0
- package/dist/ci/pr-gate.js +18 -1
- package/dist/cli/install.js +69 -2
- package/dist/cli/onboarding.js +82 -11
- package/dist/cli/update.js +83 -15
- package/dist/gate/checks/ai-redteam.js +83 -59
- 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 +75 -8
- package/dist/gate/checks/scanners.js +8 -2
- package/dist/gate/diff.js +2 -0
- package/dist/gate/exceptions.js +6 -1
- 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 +604 -0
- package/dist/mcp/server.js +160 -12
- package/dist/repo/search.js +5 -7
- package/dist/review/store.js +15 -0
- package/dist/types/agent-run.js +8 -0
- package/package.json +5 -5
- package/skills/_TEMPLATE/SKILL.md +99 -0
- package/skills/advanced-dos-tester/SKILL.md +225 -0
- package/skills/agentic-loop-exploiter/SKILL.md +69 -0
- package/skills/ai-llm-redteam/SKILL.md +118 -0
- package/skills/ai-model-supply-chain-agent/SKILL.md +198 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
- package/skills/android-penetration-tester/SKILL.md +83 -0
- package/skills/anti-replay-tester/SKILL.md +195 -0
- package/skills/appsec-code-auditor/SKILL.md +86 -0
- package/skills/artifact-integrity-analyst/SKILL.md +68 -0
- package/skills/attack-navigator/SKILL.md +64 -0
- package/skills/auth-session-hacker/SKILL.md +87 -0
- package/skills/aws-penetration-tester/SKILL.md +60 -0
- package/skills/azure-penetration-tester/SKILL.md +64 -0
- package/skills/binary-auth-validator/SKILL.md +184 -0
- package/skills/bot-detection-specialist/SKILL.md +221 -0
- package/skills/business-logic-attacker/SKILL.md +76 -0
- package/skills/capec-code-mapper/SKILL.md +163 -0
- package/skills/cert-pin-rotation-specialist/SKILL.md +200 -0
- package/skills/cicd-pipeline-hijacker/SKILL.md +81 -0
- package/skills/ciso-orchestrator/SKILL.md +165 -0
- package/skills/cloud-infra-specialist/SKILL.md +85 -0
- package/skills/compliance-gap-analyst/SKILL.md +77 -0
- package/skills/compliance-grc/SKILL.md +148 -0
- package/skills/compliance-lifecycle-tracker/SKILL.md +169 -0
- package/skills/credential-stuffing-specialist/SKILL.md +192 -0
- package/skills/crypto-pki-specialist/SKILL.md +136 -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/dependency-confusion-attacker/SKILL.md +78 -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/evidence-collector/SKILL.md +86 -0
- package/skills/file-upload-attacker/SKILL.md +208 -0
- package/skills/gcp-penetration-tester/SKILL.md +63 -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/injection-specialist/SKILL.md +62 -0
- package/skills/ios-security-auditor/SKILL.md +77 -0
- package/skills/json-ambiguity-tester/SKILL.md +175 -0
- package/skills/k8s-container-escaper/SKILL.md +74 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
- package/skills/kill-switch-engineer/SKILL.md +205 -0
- package/skills/linddun-privacy-analyst/SKILL.md +196 -0
- package/skills/logic-race-fuzzer/SKILL.md +67 -0
- package/skills/mobile-api-network-attacker/SKILL.md +81 -0
- package/skills/mobile-binary-hardener/SKILL.md +199 -0
- package/skills/mobile-security-specialist/SKILL.md +124 -0
- package/skills/mobile-webview-auditor/SKILL.md +200 -0
- package/skills/model-extraction-attacker/SKILL.md +68 -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/pentest-infra/SKILL.md +69 -0
- package/skills/pentest-social/SKILL.md +72 -0
- package/skills/pentest-team/SKILL.md +126 -0
- package/skills/pentest-web-api/SKILL.md +71 -0
- package/skills/privacy-flow-analyst/SKILL.md +70 -0
- package/skills/prompt-injection-specialist/SKILL.md +76 -0
- package/skills/quantum-migration-planner/SKILL.md +184 -0
- package/skills/rag-poisoning-specialist/SKILL.md +71 -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/senior-security-engineer/SKILL.md +42 -12
- package/skills/serialization-memory-attacker/SKILL.md +78 -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/stride-pasta-analyst/SKILL.md +72 -0
- package/skills/supply-chain-devsecops/SKILL.md +82 -0
- package/skills/threat-infrastructure-analyst/SKILL.md +167 -0
- package/skills/threat-modeler/SKILL.md +116 -0
- package/skills/tls-certificate-auditor/SKILL.md +76 -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
|
+
}
|
|
@@ -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
|
|
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:
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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];
|