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.
Files changed (35) hide show
  1. package/defaults/checklists/ai.json +25 -0
  2. package/defaults/checklists/api.json +27 -0
  3. package/defaults/checklists/infra.json +27 -0
  4. package/defaults/checklists/mobile.json +25 -0
  5. package/defaults/checklists/payments.json +25 -0
  6. package/defaults/checklists/web.json +30 -0
  7. package/defaults/control-catalog.json +392 -0
  8. package/defaults/evidence-map.json +194 -0
  9. package/defaults/security-policy.json +41 -2
  10. package/dist/cli/index.js +13 -8
  11. package/dist/cli/install.js +11 -0
  12. package/dist/cli/onboarding.js +590 -0
  13. package/dist/gate/baseline.js +115 -0
  14. package/dist/gate/checks/ai-redteam.js +374 -0
  15. package/dist/gate/checks/api.js +93 -0
  16. package/dist/gate/checks/crypto.js +153 -0
  17. package/dist/gate/checks/database.js +144 -0
  18. package/dist/gate/checks/dependencies.js +126 -0
  19. package/dist/gate/checks/dlp.js +153 -0
  20. package/dist/gate/checks/graphql.js +122 -0
  21. package/dist/gate/checks/infra.js +126 -12
  22. package/dist/gate/checks/k8s.js +190 -0
  23. package/dist/gate/checks/playbook.js +160 -0
  24. package/dist/gate/checks/runtime.js +263 -0
  25. package/dist/gate/checks/sbom.js +199 -0
  26. package/dist/gate/checks/scanners.js +373 -7
  27. package/dist/gate/checks/secrets.js +85 -20
  28. package/dist/gate/policy.js +85 -19
  29. package/dist/gate/threat-intel.js +157 -0
  30. package/dist/mcp/server.js +500 -5
  31. package/dist/repo/search.js +13 -1
  32. package/dist/review/store.js +128 -0
  33. package/package.json +1 -1
  34. package/prompts/SECURITY_PROMPT.md +415 -1
  35. 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
- const secretManagerRefs = await searchRepo({
5
- query: "secretmanager|Secret Manager|google_secret_manager",
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 (secretManagerRefs.length === 0) {
79
+ if (iamWildcards.length > 0) {
10
80
  findings.push({
11
- id: "SECRET_MANAGER_NOT_DETECTED",
12
- title: "GCP Secret Manager usage not detected in infra/app config",
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
- "Store secrets only in GCP Secret Manager.",
16
- "Configure workload identity / service accounts to access secrets, never plaintext env in repo."
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: String.raw `0\.0\.0\.0/0|::/0|public\s*=\s*true|allowAll|allUsers`,
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: "Potential public exposure patterns detected in IaC/config",
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}:${m.preview}`),
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
- "Remove or justify public ingress. Enforce Zero Trust. No implicit trust for any request or service call.",
33
- "Use private services, IAM-based auth, and least-privileged firewall rules."
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
+ }