security-mcp 1.0.5 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/defaults/checklists/ai.json +25 -0
- package/defaults/checklists/api.json +27 -0
- package/defaults/checklists/infra.json +27 -0
- package/defaults/checklists/mobile.json +25 -0
- package/defaults/checklists/payments.json +25 -0
- package/defaults/checklists/web.json +30 -0
- package/defaults/control-catalog.json +392 -0
- package/defaults/evidence-map.json +194 -0
- package/defaults/security-policy.json +41 -2
- package/dist/cli/index.js +13 -8
- package/dist/cli/install.js +11 -0
- package/dist/cli/onboarding.js +590 -0
- package/dist/gate/baseline.js +115 -0
- package/dist/gate/checks/ai-redteam.js +374 -0
- package/dist/gate/checks/api.js +93 -0
- package/dist/gate/checks/crypto.js +153 -0
- package/dist/gate/checks/database.js +144 -0
- package/dist/gate/checks/dependencies.js +126 -0
- package/dist/gate/checks/dlp.js +153 -0
- package/dist/gate/checks/graphql.js +122 -0
- package/dist/gate/checks/infra.js +126 -12
- package/dist/gate/checks/k8s.js +190 -0
- package/dist/gate/checks/playbook.js +160 -0
- package/dist/gate/checks/runtime.js +263 -0
- package/dist/gate/checks/sbom.js +199 -0
- package/dist/gate/checks/scanners.js +373 -7
- package/dist/gate/checks/secrets.js +85 -20
- package/dist/gate/policy.js +85 -19
- package/dist/gate/threat-intel.js +157 -0
- package/dist/mcp/server.js +500 -5
- package/dist/repo/search.js +13 -1
- package/dist/review/store.js +128 -0
- package/package.json +1 -1
- package/prompts/SECURITY_PROMPT.md +415 -1
- package/skills/senior-security-engineer/SKILL.md +35 -3
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
4
|
+
export async function checkGraphQL(_opts) {
|
|
5
|
+
const findings = [];
|
|
6
|
+
try {
|
|
7
|
+
// 1. Detect if GraphQL is in use
|
|
8
|
+
const graphqlHits = await searchRepo({
|
|
9
|
+
query: "graphql|typeDefs|makeExecutableSchema|gql`|@graphql|graphene|strawberry",
|
|
10
|
+
isRegex: true,
|
|
11
|
+
maxMatches: 200
|
|
12
|
+
});
|
|
13
|
+
if (graphqlHits.length === 0) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
// 2. Introspection enabled in prod
|
|
17
|
+
const introspectionHits = await searchRepo({
|
|
18
|
+
query: String.raw `introspection.*true|disableIntrospection.*false|GraphQLSchema.*introspection`,
|
|
19
|
+
isRegex: true,
|
|
20
|
+
maxMatches: 200
|
|
21
|
+
});
|
|
22
|
+
if (introspectionHits.length > 0) {
|
|
23
|
+
findings.push({
|
|
24
|
+
id: "GRAPHQL_INTROSPECTION_ENABLED",
|
|
25
|
+
title: "GraphQL introspection is enabled — exposes full schema to attackers",
|
|
26
|
+
severity: "HIGH",
|
|
27
|
+
evidence: introspectionHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
28
|
+
files: [...new Set(introspectionHits.slice(0, 10).map((m) => m.file))],
|
|
29
|
+
requiredActions: [
|
|
30
|
+
"Disable introspection in non-dev environments.",
|
|
31
|
+
"Use persisted queries instead of ad-hoc introspection in production."
|
|
32
|
+
]
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// 3. No query depth/complexity limiting
|
|
36
|
+
const depthLimitHits = await searchRepo({
|
|
37
|
+
query: String.raw `depthLimit|complexityLimit|queryComplexity|createComplexityRule|maxDepth`,
|
|
38
|
+
isRegex: true,
|
|
39
|
+
maxMatches: 200
|
|
40
|
+
});
|
|
41
|
+
if (depthLimitHits.length === 0) {
|
|
42
|
+
findings.push({
|
|
43
|
+
id: "GRAPHQL_NO_DEPTH_LIMIT",
|
|
44
|
+
title: "No GraphQL query depth or complexity limiting detected",
|
|
45
|
+
severity: "HIGH",
|
|
46
|
+
requiredActions: [
|
|
47
|
+
"Add graphql-depth-limit or graphql-query-complexity library.",
|
|
48
|
+
"Set max depth ≤ 10 to prevent deeply nested query DoS attacks."
|
|
49
|
+
]
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// 4. No query batching limits
|
|
53
|
+
const batchingHits = await searchRepo({
|
|
54
|
+
query: String.raw `queryBatching|batchRequests|allowBatchedQueries`,
|
|
55
|
+
isRegex: true,
|
|
56
|
+
maxMatches: 200
|
|
57
|
+
});
|
|
58
|
+
if (batchingHits.length === 0) {
|
|
59
|
+
findings.push({
|
|
60
|
+
id: "GRAPHQL_NO_BATCH_LIMIT",
|
|
61
|
+
title: "No GraphQL query batching limits detected",
|
|
62
|
+
severity: "MEDIUM",
|
|
63
|
+
requiredActions: [
|
|
64
|
+
"Configure batching limits to prevent batch-based DoS attacks.",
|
|
65
|
+
"Limit the number of operations per batch request."
|
|
66
|
+
]
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// 5. Schema files found but no auth directives
|
|
70
|
+
const schemaFiles = await fg(["**/*.graphql", "**/*.gql"], {
|
|
71
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"]
|
|
72
|
+
});
|
|
73
|
+
if (schemaFiles.length > 0) {
|
|
74
|
+
let hasAuthDirectives = false;
|
|
75
|
+
for (const file of schemaFiles) {
|
|
76
|
+
try {
|
|
77
|
+
const content = await readFileSafe(file);
|
|
78
|
+
if (/@auth|@authenticated|@hasRole|@requiresAuth|directive.*auth/i.test(content)) {
|
|
79
|
+
hasAuthDirectives = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// skip unreadable files
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!hasAuthDirectives) {
|
|
88
|
+
findings.push({
|
|
89
|
+
id: "GRAPHQL_NO_FIELD_AUTH",
|
|
90
|
+
title: "GraphQL schema files found but no auth directives detected",
|
|
91
|
+
severity: "HIGH",
|
|
92
|
+
files: schemaFiles.slice(0, 10),
|
|
93
|
+
requiredActions: [
|
|
94
|
+
"Add @auth, @authenticated, or @hasRole directives to protect sensitive fields.",
|
|
95
|
+
"Use a GraphQL auth plugin (e.g. graphql-shield) for field-level authorization."
|
|
96
|
+
]
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// 6. N+1 query protection
|
|
101
|
+
const dataloaderHits = await searchRepo({
|
|
102
|
+
query: String.raw `DataLoader|dataloader|BatchLoader`,
|
|
103
|
+
isRegex: true,
|
|
104
|
+
maxMatches: 200
|
|
105
|
+
});
|
|
106
|
+
if (dataloaderHits.length === 0) {
|
|
107
|
+
findings.push({
|
|
108
|
+
id: "GRAPHQL_NO_DATALOADER",
|
|
109
|
+
title: "No DataLoader detected — GraphQL resolvers may be vulnerable to N+1 query attacks",
|
|
110
|
+
severity: "MEDIUM",
|
|
111
|
+
requiredActions: [
|
|
112
|
+
"Add DataLoader (or equivalent batch loader) to batch and cache resolver requests.",
|
|
113
|
+
"Prevent N+1 database queries which can be exploited as a DoS vector."
|
|
114
|
+
]
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
console.warn("[checkGraphQL] Internal error:", err instanceof Error ? err.message : String(err));
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
@@ -1,36 +1,150 @@
|
|
|
1
1
|
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
// Split into two patterns to stay under the 256-char ReDoS guard in searchRepo.
|
|
3
|
+
// AWS + GCP secret managers
|
|
4
|
+
const SECRET_MANAGER_PATTERN_A = [
|
|
5
|
+
"secretsmanager", // AWS Secrets Manager
|
|
6
|
+
"ssm:GetParameter|GetSecretValue", // AWS SSM Parameter Store
|
|
7
|
+
String.raw `secretmanager\.googleapis`, // GCP Secret Manager REST/gRPC
|
|
8
|
+
"google_secret_manager", // GCP Terraform resource
|
|
9
|
+
"SecretManagerServiceClient", // GCP Secret Manager client lib
|
|
10
|
+
].join("|");
|
|
11
|
+
// Azure + HashiCorp Vault + Doppler + 1Password
|
|
12
|
+
const SECRET_MANAGER_PATTERN_B = [
|
|
13
|
+
"@azure/keyvault", // Azure Key Vault SDK (JS/TS)
|
|
14
|
+
String.raw `azure\.keyvault`, // Azure Key Vault (Python)
|
|
15
|
+
"KeyVaultSecret|SecretClient", // Azure Key Vault client classes
|
|
16
|
+
String.raw `vault\.read|vault\.write`, // HashiCorp Vault API calls
|
|
17
|
+
"hvault:|vault_generic_secret", // HashiCorp Vault Terraform
|
|
18
|
+
"doppler run|DOPPLER_TOKEN", // Doppler
|
|
19
|
+
"op run|op read|onepassword", // 1Password Secrets Automation
|
|
20
|
+
].join("|");
|
|
21
|
+
// IAM wildcard patterns — any cloud provider
|
|
22
|
+
const IAM_WILDCARD_PATTERN = String.raw `"Action"\s*:\s*"\*"|` + // AWS IAM wildcard action
|
|
23
|
+
String.raw `"Resource"\s*:\s*"\*"|` + // AWS IAM wildcard resource
|
|
24
|
+
String.raw `roles/owner|roles/editor|` + // GCP over-privileged built-in roles
|
|
25
|
+
String.raw `allUsers|allAuthenticatedUsers|` + // GCP public IAM
|
|
26
|
+
String.raw `"role"\s*:\s*"roles/owner"|` + // GCP Terraform owner binding
|
|
27
|
+
String.raw `contributor|Owner\b.*roleDefinitionId`; // Azure Contributor/Owner
|
|
28
|
+
// Public network exposure — Terraform, K8s, CloudFormation, ARM, CDK
|
|
29
|
+
const PUBLIC_INGRESS_PATTERN = String.raw `0\.0\.0\.0/0|::/0|` +
|
|
30
|
+
String.raw `public\s*=\s*true|` +
|
|
31
|
+
String.raw `PubliclyAccessible\s*:\s*true|` + // AWS RDS
|
|
32
|
+
String.raw `allow_stopping_for_update.*true|` +
|
|
33
|
+
String.raw `internet-facing|` + // AWS ALB scheme
|
|
34
|
+
String.raw `"Scheme"\s*:\s*"internet-facing"|` +
|
|
35
|
+
String.raw `block_public_acls\s*=\s*false|` + // AWS S3 block public access disabled
|
|
36
|
+
String.raw `restrict_public_buckets\s*=\s*false`;
|
|
37
|
+
// Logging / audit disabled
|
|
38
|
+
const LOGGING_DISABLED_PATTERN = String.raw `enable_logging\s*=\s*false|` +
|
|
39
|
+
String.raw `log_config\s*\{\s*\}|` + // GCP empty log config
|
|
40
|
+
String.raw `"CloudWatchLogs"\s*:\s*\{\s*\}|` + // AWS empty CloudWatch config
|
|
41
|
+
String.raw `disable_api_termination\s*=\s*true|` +
|
|
42
|
+
String.raw `deletion_protection\s*=\s*false`;
|
|
43
|
+
// Encryption disabled
|
|
44
|
+
const ENCRYPTION_DISABLED_PATTERN = String.raw `encrypted\s*=\s*false|` + // AWS EBS, RDS
|
|
45
|
+
String.raw `enable_encryption\s*=\s*false|` +
|
|
46
|
+
String.raw `kms_key_id\s*=\s*""|` +
|
|
47
|
+
String.raw `storage_encrypted\s*=\s*false|` +
|
|
48
|
+
String.raw `"EnableEncryption"\s*:\s*false`;
|
|
2
49
|
export async function checkInfra(_) {
|
|
3
50
|
const findings = [];
|
|
4
|
-
|
|
5
|
-
|
|
51
|
+
// 1. Secret manager usage — cloud-agnostic check (split across two searches
|
|
52
|
+
// to stay under the 256-char ReDoS guard in searchRepo)
|
|
53
|
+
const [smRefsA, smRefsB] = await Promise.all([
|
|
54
|
+
searchRepo({ query: SECRET_MANAGER_PATTERN_A, isRegex: true, maxMatches: 5 }),
|
|
55
|
+
searchRepo({ query: SECRET_MANAGER_PATTERN_B, isRegex: true, maxMatches: 5 })
|
|
56
|
+
]);
|
|
57
|
+
const secretManagerRefs = [...smRefsA, ...smRefsB];
|
|
58
|
+
if (secretManagerRefs.length === 0) {
|
|
59
|
+
findings.push({
|
|
60
|
+
id: "SECRET_MANAGER_NOT_DETECTED",
|
|
61
|
+
title: "No secret manager usage detected — secrets may be hardcoded or in env files",
|
|
62
|
+
severity: "HIGH",
|
|
63
|
+
requiredActions: [
|
|
64
|
+
"Integrate a cloud secret manager appropriate for your platform:",
|
|
65
|
+
" • AWS: AWS Secrets Manager or SSM Parameter Store (SecureString)",
|
|
66
|
+
" • GCP: Secret Manager with Workload Identity",
|
|
67
|
+
" • Azure: Azure Key Vault with Managed Identity",
|
|
68
|
+
" • Multi-cloud / self-hosted: HashiCorp Vault, Doppler, or 1Password Secrets Automation",
|
|
69
|
+
"Never store secrets in environment files committed to the repo, CI log output, or container images."
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// 2. IAM wildcards / over-privileged roles
|
|
74
|
+
const iamWildcards = await searchRepo({
|
|
75
|
+
query: IAM_WILDCARD_PATTERN,
|
|
6
76
|
isRegex: true,
|
|
7
77
|
maxMatches: 200
|
|
8
78
|
});
|
|
9
|
-
if (
|
|
79
|
+
if (iamWildcards.length > 0) {
|
|
10
80
|
findings.push({
|
|
11
|
-
id: "
|
|
12
|
-
title: "
|
|
81
|
+
id: "IAM_OVERPRIVILEGED",
|
|
82
|
+
title: "Overprivileged IAM role or wildcard permission detected",
|
|
13
83
|
severity: "HIGH",
|
|
84
|
+
evidence: iamWildcards.slice(0, 20).map((m) => `${m.file}:${m.line}: ${m.preview}`),
|
|
14
85
|
requiredActions: [
|
|
15
|
-
"
|
|
16
|
-
"
|
|
86
|
+
"Apply least-privilege to every IAM role — enumerate only the specific actions and resources required.",
|
|
87
|
+
"Replace wildcard actions ('*') with explicit action lists.",
|
|
88
|
+
"Replace Owner/Contributor/Editor bindings with purpose-scoped custom roles.",
|
|
89
|
+
"Run IAM Access Analyzer (AWS) or Policy Analyzer (GCP) to detect unused permissions."
|
|
17
90
|
]
|
|
18
91
|
});
|
|
19
92
|
}
|
|
93
|
+
// 3. Public network exposure
|
|
20
94
|
const publicIngress = await searchRepo({
|
|
21
|
-
query:
|
|
95
|
+
query: PUBLIC_INGRESS_PATTERN,
|
|
22
96
|
isRegex: true,
|
|
23
97
|
maxMatches: 200
|
|
24
98
|
});
|
|
25
99
|
if (publicIngress.length > 0) {
|
|
26
100
|
findings.push({
|
|
27
101
|
id: "PUBLIC_EXPOSURE_RISK",
|
|
28
|
-
title: "
|
|
102
|
+
title: "Public network exposure detected in IaC or cloud config",
|
|
29
103
|
severity: "HIGH",
|
|
30
|
-
evidence: publicIngress.slice(0, 20).map((m) => `${m.file}:${m.line}
|
|
104
|
+
evidence: publicIngress.slice(0, 20).map((m) => `${m.file}:${m.line}: ${m.preview}`),
|
|
105
|
+
requiredActions: [
|
|
106
|
+
"Restrict ingress to known CIDR ranges or private VPC subnets only.",
|
|
107
|
+
"Place public load balancers in a DMZ; never expose internal services directly.",
|
|
108
|
+
"Enable S3 Block Public Access at the account level.",
|
|
109
|
+
"Use Zero Trust network access (BeyondCorp / Zscaler / Cloudflare Access) instead of IP allowlisting."
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// 4. Encryption disabled
|
|
114
|
+
const encryptionDisabled = await searchRepo({
|
|
115
|
+
query: ENCRYPTION_DISABLED_PATTERN,
|
|
116
|
+
isRegex: true,
|
|
117
|
+
maxMatches: 200
|
|
118
|
+
});
|
|
119
|
+
if (encryptionDisabled.length > 0) {
|
|
120
|
+
findings.push({
|
|
121
|
+
id: "ENCRYPTION_DISABLED",
|
|
122
|
+
title: "Encryption at rest explicitly disabled in IaC config",
|
|
123
|
+
severity: "HIGH",
|
|
124
|
+
evidence: encryptionDisabled.slice(0, 20).map((m) => `${m.file}:${m.line}: ${m.preview}`),
|
|
125
|
+
requiredActions: [
|
|
126
|
+
"Enable encryption at rest on all storage resources (RDS, EBS, S3, GCS, Azure Blob, etc.).",
|
|
127
|
+
"Use customer-managed keys (CMK/CMEK) for regulated data (PCI, HIPAA, SOC 2).",
|
|
128
|
+
"Never set encrypted=false or storage_encrypted=false in Terraform."
|
|
129
|
+
]
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// 5. Audit logging disabled
|
|
133
|
+
const loggingDisabled = await searchRepo({
|
|
134
|
+
query: LOGGING_DISABLED_PATTERN,
|
|
135
|
+
isRegex: true,
|
|
136
|
+
maxMatches: 200
|
|
137
|
+
});
|
|
138
|
+
if (loggingDisabled.length > 0) {
|
|
139
|
+
findings.push({
|
|
140
|
+
id: "AUDIT_LOGGING_DISABLED",
|
|
141
|
+
title: "Audit logging or deletion protection explicitly disabled in IaC config",
|
|
142
|
+
severity: "MEDIUM",
|
|
143
|
+
evidence: loggingDisabled.slice(0, 20).map((m) => `${m.file}:${m.line}: ${m.preview}`),
|
|
31
144
|
requiredActions: [
|
|
32
|
-
"
|
|
33
|
-
"
|
|
145
|
+
"Enable audit logging on all cloud resources and ship logs to a centralised, tamper-evident store.",
|
|
146
|
+
"Enable deletion protection on databases and stateful resources.",
|
|
147
|
+
"Retain audit logs for at least 1 year (SOC 2 / PCI DSS requirement)."
|
|
34
148
|
]
|
|
35
149
|
});
|
|
36
150
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fg from "fast-glob";
|
|
2
|
+
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
|
|
22
|
+
}
|
|
23
|
+
}
|
|
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
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
console.warn("[checkKubernetes] Internal error:", err instanceof Error ? err.message : String(err));
|
|
188
|
+
}
|
|
189
|
+
return findings;
|
|
190
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IR Playbook enforcement checks.
|
|
3
|
+
* Verifies incident response playbooks exist and contain required sections.
|
|
4
|
+
*/
|
|
5
|
+
import { stat } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import fg from "fast-glob";
|
|
8
|
+
import { readFileSafe } from "../../repo/fs.js";
|
|
9
|
+
const PLAYBOOK_BASE = "security/playbooks";
|
|
10
|
+
const REQUIRED_PLAYBOOKS = [
|
|
11
|
+
{ surface: "web", path: "web-compromise.md", description: "Web compromise" },
|
|
12
|
+
{ surface: "api", path: "api-compromise.md", description: "API compromise" },
|
|
13
|
+
{ surface: "ai", path: "llm-prompt-injection.md", description: "LLM prompt injection" },
|
|
14
|
+
{ surface: "ai", path: "model-data-poisoning.md", description: "Model data poisoning" },
|
|
15
|
+
{ surface: "infra", path: "cloud-misconfiguration.md", description: "Cloud misconfiguration" },
|
|
16
|
+
{ surface: "infra", path: "ransomware.md", description: "Ransomware" },
|
|
17
|
+
{ surface: "mobileIos", path: "mobile-credential-theft.md", description: "Mobile credential theft" },
|
|
18
|
+
{ surface: "mobileAndroid", path: "mobile-credential-theft.md", description: "Mobile credential theft" },
|
|
19
|
+
{ surface: "payments", path: "payment-fraud.md", description: "Payment fraud" },
|
|
20
|
+
{ surface: "payments", path: "pci-breach.md", description: "PCI breach" }
|
|
21
|
+
];
|
|
22
|
+
const REQUIRED_SECTIONS = [
|
|
23
|
+
{ key: "detection", patterns: [/detection criteria/i, /how to detect/i, /indicators of compromise/i, /detection/i] },
|
|
24
|
+
{ key: "escalation", patterns: [/escalation/i, /incident commander/i, /security lead/i, /on-call/i] },
|
|
25
|
+
{ key: "containment", patterns: [/containment/i, /contain/i, /isolat/i] },
|
|
26
|
+
{ key: "eradication", patterns: [/eradication/i, /eradicate/i, /root cause/i] },
|
|
27
|
+
{ key: "recovery", patterns: [/recovery/i, /restore/i, /recover/i] },
|
|
28
|
+
{ key: "communication", patterns: [/communication/i, /notification/i, /stakeholder/i, /template/i] },
|
|
29
|
+
{ key: "post-incident", patterns: [/post.incident/i, /lessons learned/i, /review/i, /retrospective/i] },
|
|
30
|
+
{ key: "mttd-mttr", patterns: [/mttd|mttr|mean time/i, /target.{0,30}time/i, /response time/i] }
|
|
31
|
+
];
|
|
32
|
+
const STALE_THRESHOLD_MS = 180 * 24 * 60 * 60 * 1000; // 180 days
|
|
33
|
+
function surfaceActive(surface, surfaces, activeSurfaces) {
|
|
34
|
+
if (surface === "payments")
|
|
35
|
+
return activeSurfaces.has("payments");
|
|
36
|
+
if (surface === "mobileIos")
|
|
37
|
+
return surfaces.mobileIos;
|
|
38
|
+
if (surface === "mobileAndroid")
|
|
39
|
+
return surfaces.mobileAndroid;
|
|
40
|
+
return surfaces[surface] === true;
|
|
41
|
+
}
|
|
42
|
+
async function validatePlaybook(playbookPath) {
|
|
43
|
+
const missingSections = [];
|
|
44
|
+
let content = "";
|
|
45
|
+
let isStale = false;
|
|
46
|
+
try {
|
|
47
|
+
content = await readFileSafe(playbookPath);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { missingSections: REQUIRED_SECTIONS.map((s) => s.key), isStale: false };
|
|
51
|
+
}
|
|
52
|
+
for (const section of REQUIRED_SECTIONS) {
|
|
53
|
+
const found = section.patterns.some((pattern) => pattern.test(content));
|
|
54
|
+
if (!found) {
|
|
55
|
+
missingSections.push(section.key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const s = await stat(playbookPath);
|
|
60
|
+
if (Date.now() - s.mtimeMs > STALE_THRESHOLD_MS) {
|
|
61
|
+
isStale = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
return { missingSections, isStale };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Checks that IR playbooks exist and contain required sections for active surfaces.
|
|
69
|
+
*/
|
|
70
|
+
export async function runPlaybookChecks(opts) {
|
|
71
|
+
const findings = [];
|
|
72
|
+
// Detect if payments surface is active via file patterns
|
|
73
|
+
const activeSurfaces = new Set();
|
|
74
|
+
const paymentPatterns = /payment|stripe|braintree|adyen|checkout|pci/i;
|
|
75
|
+
if (opts.changedFiles.some((f) => paymentPatterns.test(f))) {
|
|
76
|
+
activeSurfaces.add("payments");
|
|
77
|
+
}
|
|
78
|
+
// Also scan repo for payment references
|
|
79
|
+
try {
|
|
80
|
+
const paymentFiles = await fg(["**/payment*.ts", "**/stripe*.ts", "**/checkout*.ts"], {
|
|
81
|
+
dot: true,
|
|
82
|
+
ignore: ["**/node_modules/**", "**/dist/**"]
|
|
83
|
+
});
|
|
84
|
+
if (paymentFiles.length > 0)
|
|
85
|
+
activeSurfaces.add("payments");
|
|
86
|
+
}
|
|
87
|
+
catch { /* ignore */ }
|
|
88
|
+
// Deduplicate required playbooks per surface
|
|
89
|
+
const checked = new Set();
|
|
90
|
+
for (const req of REQUIRED_PLAYBOOKS) {
|
|
91
|
+
if (!surfaceActive(req.surface, opts.surfaces, activeSurfaces))
|
|
92
|
+
continue;
|
|
93
|
+
const playbookPath = join(PLAYBOOK_BASE, req.path);
|
|
94
|
+
if (checked.has(playbookPath))
|
|
95
|
+
continue;
|
|
96
|
+
checked.add(playbookPath);
|
|
97
|
+
// Check if playbook exists
|
|
98
|
+
let exists = false;
|
|
99
|
+
try {
|
|
100
|
+
const matches = await fg([playbookPath], { dot: true });
|
|
101
|
+
exists = matches.length > 0;
|
|
102
|
+
}
|
|
103
|
+
catch { /* ignore */ }
|
|
104
|
+
if (!exists) {
|
|
105
|
+
findings.push({
|
|
106
|
+
id: "IR_PLAYBOOK_MISSING",
|
|
107
|
+
title: `IR playbook missing: ${req.description} (${playbookPath})`,
|
|
108
|
+
severity: "HIGH",
|
|
109
|
+
evidence: [`Expected path: ${playbookPath}`, `Surface: ${req.surface}`],
|
|
110
|
+
requiredActions: [
|
|
111
|
+
`Create the IR playbook at ${playbookPath}.`,
|
|
112
|
+
"Include all required sections: detection criteria, escalation path, containment, eradication, recovery, communication, post-incident review, and MTTD/MTTR targets."
|
|
113
|
+
]
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const { missingSections, isStale } = await validatePlaybook(playbookPath);
|
|
118
|
+
if (missingSections.length > 0) {
|
|
119
|
+
findings.push({
|
|
120
|
+
id: "IR_PLAYBOOK_INCOMPLETE",
|
|
121
|
+
title: `IR playbook incomplete: ${playbookPath}`,
|
|
122
|
+
severity: "MEDIUM",
|
|
123
|
+
evidence: [`Missing sections: ${missingSections.join(", ")}`, `Path: ${playbookPath}`],
|
|
124
|
+
requiredActions: [
|
|
125
|
+
`Add the missing sections to ${playbookPath}: ${missingSections.join(", ")}.`,
|
|
126
|
+
"Ensure each section has actionable steps, not just headers."
|
|
127
|
+
]
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (isStale) {
|
|
131
|
+
findings.push({
|
|
132
|
+
id: "IR_PLAYBOOK_STALE",
|
|
133
|
+
title: `IR playbook not updated in 180+ days: ${playbookPath}`,
|
|
134
|
+
severity: "LOW",
|
|
135
|
+
evidence: [`Path: ${playbookPath}`],
|
|
136
|
+
requiredActions: [
|
|
137
|
+
`Review and update ${playbookPath} to reflect current infrastructure and contacts.`,
|
|
138
|
+
"Schedule quarterly playbook reviews."
|
|
139
|
+
]
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return findings;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Validate a single playbook file and return missing sections.
|
|
147
|
+
*/
|
|
148
|
+
export async function validateSinglePlaybook(playbookPath) {
|
|
149
|
+
let exists = false;
|
|
150
|
+
try {
|
|
151
|
+
const matches = await fg([playbookPath], { dot: true });
|
|
152
|
+
exists = matches.length > 0;
|
|
153
|
+
}
|
|
154
|
+
catch { /* ignore */ }
|
|
155
|
+
if (!exists) {
|
|
156
|
+
return { path: playbookPath, exists: false, missingSections: REQUIRED_SECTIONS.map((s) => s.key), isStale: false };
|
|
157
|
+
}
|
|
158
|
+
const { missingSections, isStale } = await validatePlaybook(playbookPath);
|
|
159
|
+
return { path: playbookPath, exists: true, missingSections, isStale };
|
|
160
|
+
}
|