security-mcp 1.0.5 → 1.1.1
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 +963 -193
- package/defaults/agent-run-schema.json +98 -0
- 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 +80 -2
- package/dist/cli/onboarding.js +590 -0
- package/dist/cli/update.js +83 -15
- package/dist/gate/baseline.js +115 -0
- package/dist/gate/checks/ai-redteam.js +398 -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 +316 -0
- package/dist/gate/checks/sbom.js +199 -0
- package/dist/gate/checks/scanners.js +379 -8
- package/dist/gate/checks/secrets.js +85 -20
- package/dist/gate/exceptions.js +6 -1
- package/dist/gate/policy.js +85 -19
- package/dist/gate/threat-intel.js +157 -0
- package/dist/mcp/orchestration.js +586 -0
- package/dist/mcp/server.js +568 -16
- package/dist/repo/search.js +11 -1
- package/dist/review/store.js +133 -0
- package/dist/types/agent-run.js +8 -0
- package/package.json +5 -5
- package/prompts/SECURITY_PROMPT.md +415 -1
- package/skills/agentic-loop-exploiter/SKILL.md +69 -0
- package/skills/ai-llm-redteam/SKILL.md +118 -0
- package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
- package/skills/android-penetration-tester/SKILL.md +83 -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/business-logic-attacker/SKILL.md +76 -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/crypto-pki-specialist/SKILL.md +136 -0
- package/skills/dependency-confusion-attacker/SKILL.md +78 -0
- package/skills/evidence-collector/SKILL.md +86 -0
- package/skills/gcp-penetration-tester/SKILL.md +63 -0
- package/skills/injection-specialist/SKILL.md +62 -0
- package/skills/ios-security-auditor/SKILL.md +77 -0
- package/skills/k8s-container-escaper/SKILL.md +74 -0
- package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
- package/skills/logic-race-fuzzer/SKILL.md +67 -0
- package/skills/mobile-api-network-attacker/SKILL.md +81 -0
- package/skills/mobile-security-specialist/SKILL.md +124 -0
- package/skills/model-extraction-attacker/SKILL.md +68 -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/rag-poisoning-specialist/SKILL.md +71 -0
- package/skills/senior-security-engineer/SKILL.md +75 -13
- package/skills/serialization-memory-attacker/SKILL.md +78 -0
- package/skills/stride-pasta-analyst/SKILL.md +72 -0
- package/skills/supply-chain-devsecops/SKILL.md +82 -0
- package/skills/threat-modeler/SKILL.md +116 -0
- package/skills/tls-certificate-auditor/SKILL.md +76 -0
package/dist/gate/checks/api.js
CHANGED
|
@@ -42,5 +42,98 @@ export async function checkApi(_) {
|
|
|
42
42
|
]
|
|
43
43
|
});
|
|
44
44
|
}
|
|
45
|
+
// Multi-tenancy isolation checks
|
|
46
|
+
// 1. Tenant ID from user input
|
|
47
|
+
const tenantIdInputHits = await searchRepo({
|
|
48
|
+
query: String.raw `tenantId\s*[:=]\s*(?:req\.(?:query|params|body)|request\.(?:query|params|body))`,
|
|
49
|
+
isRegex: true,
|
|
50
|
+
maxMatches: 200
|
|
51
|
+
});
|
|
52
|
+
if (tenantIdInputHits.length > 0) {
|
|
53
|
+
findings.push({
|
|
54
|
+
id: "API_TENANT_ID_FROM_INPUT",
|
|
55
|
+
title: "Tenant ID sourced from user-controlled input — insecure direct tenant access",
|
|
56
|
+
severity: "CRITICAL",
|
|
57
|
+
evidence: tenantIdInputHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
58
|
+
files: [...new Set(tenantIdInputHits.slice(0, 10).map((m) => m.file))],
|
|
59
|
+
requiredActions: [
|
|
60
|
+
"Tenant ID must come from the authenticated session/JWT claims, never from user-controlled input.",
|
|
61
|
+
"Validate that the tenant ID matches the authenticated user's tenant on every request."
|
|
62
|
+
]
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// 2. Missing tenant filter in DB queries (heuristic)
|
|
66
|
+
const ormQueryHits = await searchRepo({
|
|
67
|
+
query: String.raw `findAll|findMany|find\(|query\(|select\(`,
|
|
68
|
+
isRegex: true,
|
|
69
|
+
maxMatches: 200
|
|
70
|
+
});
|
|
71
|
+
const tenantScopeHits = await searchRepo({
|
|
72
|
+
query: String.raw `tenantId|tenant_id|organizationId|orgId`,
|
|
73
|
+
isRegex: true,
|
|
74
|
+
maxMatches: 200
|
|
75
|
+
});
|
|
76
|
+
if (ormQueryHits.length > 0 && tenantScopeHits.length === 0) {
|
|
77
|
+
findings.push({
|
|
78
|
+
id: "API_MISSING_TENANT_SCOPE",
|
|
79
|
+
title: "ORM queries found without tenant scoping — possible multi-tenant data leakage",
|
|
80
|
+
severity: "HIGH",
|
|
81
|
+
evidence: ormQueryHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
82
|
+
files: [...new Set(ormQueryHits.slice(0, 10).map((m) => m.file))],
|
|
83
|
+
requiredActions: [
|
|
84
|
+
"All database queries in multi-tenant systems must include a tenantId/organizationId filter.",
|
|
85
|
+
"Add tenant-scoped base repository or middleware to enforce tenant isolation automatically."
|
|
86
|
+
]
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// 3. Shared Redis cache without tenant namespacing
|
|
90
|
+
const cacheGetHits = await searchRepo({
|
|
91
|
+
query: String.raw `cache\.get\s*\(["'][^'"]*["']`,
|
|
92
|
+
isRegex: true,
|
|
93
|
+
maxMatches: 200
|
|
94
|
+
});
|
|
95
|
+
const redisGetHits = await searchRepo({
|
|
96
|
+
query: String.raw `redis\.get\s*\(`,
|
|
97
|
+
isRegex: true,
|
|
98
|
+
maxMatches: 200
|
|
99
|
+
});
|
|
100
|
+
const tenantKeyHits = await searchRepo({
|
|
101
|
+
query: String.raw `tenantId|tenant:|orgId|userId:`,
|
|
102
|
+
isRegex: true,
|
|
103
|
+
maxMatches: 200
|
|
104
|
+
});
|
|
105
|
+
const allCacheHits = [...cacheGetHits, ...redisGetHits];
|
|
106
|
+
if (allCacheHits.length > 0 && tenantKeyHits.length === 0) {
|
|
107
|
+
findings.push({
|
|
108
|
+
id: "API_CACHE_NOT_TENANT_SCOPED",
|
|
109
|
+
title: "Cache operations found without tenant-namespaced keys",
|
|
110
|
+
severity: "HIGH",
|
|
111
|
+
evidence: allCacheHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
112
|
+
files: [...new Set(allCacheHits.slice(0, 10).map((m) => m.file))],
|
|
113
|
+
requiredActions: [
|
|
114
|
+
"Prefix all cache keys with tenant ID (e.g. tenant:{id}:resource:{id}).",
|
|
115
|
+
"Never use bare resource IDs as cache keys in multi-tenant systems."
|
|
116
|
+
]
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// 4. Cross-tenant file access
|
|
120
|
+
const fileInputHits = await searchRepo({
|
|
121
|
+
query: String.raw `(?:readFile|writeFile|createReadStream)\s*\([^)]*(?:req\.|params\.|query\.|body\.)`,
|
|
122
|
+
isRegex: true,
|
|
123
|
+
maxMatches: 200
|
|
124
|
+
});
|
|
125
|
+
if (fileInputHits.length > 0) {
|
|
126
|
+
findings.push({
|
|
127
|
+
id: "API_FILE_PATH_FROM_INPUT",
|
|
128
|
+
title: "File operation with user-supplied path — path traversal and cross-tenant access risk",
|
|
129
|
+
severity: "CRITICAL",
|
|
130
|
+
evidence: fileInputHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
131
|
+
files: [...new Set(fileInputHits.slice(0, 10).map((m) => m.file))],
|
|
132
|
+
requiredActions: [
|
|
133
|
+
"Never use user-supplied paths for file operations.",
|
|
134
|
+
"Validate paths against an allowlist of permitted paths; use a content-addressed storage key instead."
|
|
135
|
+
]
|
|
136
|
+
});
|
|
137
|
+
}
|
|
45
138
|
return findings;
|
|
46
139
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
export async function checkCrypto(_opts) {
|
|
3
|
+
const findings = [];
|
|
4
|
+
try {
|
|
5
|
+
// 1. Weak hash algorithms
|
|
6
|
+
const weakHashHits = await searchRepo({
|
|
7
|
+
query: String.raw `createHash\s*\(\s*['"](?:md5|sha1|sha-1)['"]\s*\)|hashlib\.md5|hashlib\.sha1|DigestUtils\.md5`,
|
|
8
|
+
isRegex: true,
|
|
9
|
+
maxMatches: 200
|
|
10
|
+
});
|
|
11
|
+
if (weakHashHits.length > 0) {
|
|
12
|
+
findings.push({
|
|
13
|
+
id: "CRYPTO_WEAK_HASH",
|
|
14
|
+
title: "Weak hash algorithm (MD5/SHA-1) detected",
|
|
15
|
+
severity: "HIGH",
|
|
16
|
+
evidence: weakHashHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
17
|
+
files: [...new Set(weakHashHits.slice(0, 10).map((m) => m.file))],
|
|
18
|
+
requiredActions: [
|
|
19
|
+
"Use SHA-256 minimum (SHA-3 recommended for new code).",
|
|
20
|
+
"MD5/SHA-1 are broken for security purposes (NIST SP 800-131A Rev 2)."
|
|
21
|
+
]
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// 2. Weak symmetric ciphers
|
|
25
|
+
const weakCipherHits = await searchRepo({
|
|
26
|
+
query: String.raw `createCipheriv\s*\(\s*['"](?:des|rc4|rc2|blowfish|3des|des-ede)['"]\)|Cipher\.getInstance\(['"](?:DES|RC4|RC2|Blowfish)['"]`,
|
|
27
|
+
isRegex: true,
|
|
28
|
+
maxMatches: 200
|
|
29
|
+
});
|
|
30
|
+
if (weakCipherHits.length > 0) {
|
|
31
|
+
findings.push({
|
|
32
|
+
id: "CRYPTO_WEAK_CIPHER",
|
|
33
|
+
title: "Weak symmetric cipher (DES/RC4/3DES) detected",
|
|
34
|
+
severity: "CRITICAL",
|
|
35
|
+
evidence: weakCipherHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
36
|
+
files: [...new Set(weakCipherHits.slice(0, 10).map((m) => m.file))],
|
|
37
|
+
requiredActions: [
|
|
38
|
+
"Use AES-256-GCM for symmetric encryption.",
|
|
39
|
+
"DES/RC4/3DES are prohibited by NIST SP 800-131A Rev 2."
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// 3. Insecure random for security use
|
|
44
|
+
const insecureRandomHits = await searchRepo({
|
|
45
|
+
query: String.raw `Math\.random\(\)|random\.random\(\)|rand\(\)|srand\(`,
|
|
46
|
+
isRegex: true,
|
|
47
|
+
maxMatches: 200
|
|
48
|
+
});
|
|
49
|
+
const securityContextRe = /token|key|secret|password|nonce|salt|csrf|session/i;
|
|
50
|
+
const insecureSecRandom = insecureRandomHits.filter((m) => securityContextRe.test(m.preview));
|
|
51
|
+
if (insecureSecRandom.length > 0) {
|
|
52
|
+
findings.push({
|
|
53
|
+
id: "CRYPTO_INSECURE_RANDOM",
|
|
54
|
+
title: "Non-cryptographic random used in security-sensitive context",
|
|
55
|
+
severity: "HIGH",
|
|
56
|
+
evidence: insecureSecRandom.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
57
|
+
files: [...new Set(insecureSecRandom.slice(0, 10).map((m) => m.file))],
|
|
58
|
+
requiredActions: [
|
|
59
|
+
"Use crypto.randomBytes() (Node.js) for security-sensitive randomness.",
|
|
60
|
+
"Math.random() is not cryptographically secure and must never be used for tokens, keys, or nonces."
|
|
61
|
+
]
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// 4. Weak JWT algorithm
|
|
65
|
+
const weakJwtHits = await searchRepo({
|
|
66
|
+
query: String.raw `algorithm\s*[:=]\s*['"]HS(?:256|384|512)['"]|sign\(.*['"]HS256['"]`,
|
|
67
|
+
isRegex: true,
|
|
68
|
+
maxMatches: 200
|
|
69
|
+
});
|
|
70
|
+
if (weakJwtHits.length > 0) {
|
|
71
|
+
findings.push({
|
|
72
|
+
id: "CRYPTO_WEAK_JWT_ALGO",
|
|
73
|
+
title: "HS256/HS384/HS512 JWT algorithm detected — symmetric key shared with all verifiers",
|
|
74
|
+
severity: "HIGH",
|
|
75
|
+
evidence: weakJwtHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
76
|
+
files: [...new Set(weakJwtHits.slice(0, 10).map((m) => m.file))],
|
|
77
|
+
requiredActions: [
|
|
78
|
+
"Use RS256 or ES256 for stateless JWTs.",
|
|
79
|
+
"HS256 requires sharing the secret with every verifier — use asymmetric algorithms instead."
|
|
80
|
+
]
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// 5. Low PBKDF2 iterations
|
|
84
|
+
const pbkdf2Hits = await searchRepo({
|
|
85
|
+
query: String.raw `pbkdf2(?:Sync)?\s*\(`,
|
|
86
|
+
isRegex: true,
|
|
87
|
+
maxMatches: 200
|
|
88
|
+
});
|
|
89
|
+
// Check for numeric iteration counts in the context
|
|
90
|
+
for (const hit of pbkdf2Hits) {
|
|
91
|
+
const iterMatch = /pbkdf2(?:Sync)?\s*\([^)]*?,\s*[^,]+,\s*(\d+)/.exec(hit.preview);
|
|
92
|
+
if (iterMatch) {
|
|
93
|
+
const iters = parseInt(iterMatch[1], 10);
|
|
94
|
+
if (iters < 600000) {
|
|
95
|
+
findings.push({
|
|
96
|
+
id: "CRYPTO_LOW_PBKDF2_ITERATIONS",
|
|
97
|
+
title: `PBKDF2 iteration count too low (${iters} < 600,000)`,
|
|
98
|
+
severity: "HIGH",
|
|
99
|
+
evidence: [`${hit.file}:${hit.line}:${hit.preview}`],
|
|
100
|
+
files: [hit.file],
|
|
101
|
+
requiredActions: [
|
|
102
|
+
"Use ≥ 600,000 iterations for PBKDF2-SHA256 (OWASP 2023 recommendation).",
|
|
103
|
+
"Prefer bcrypt (cost ≥ 12) or Argon2id instead."
|
|
104
|
+
]
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// 6. Hardcoded IV/nonce
|
|
111
|
+
const hardcodedIvHits = await searchRepo({
|
|
112
|
+
query: String.raw `iv\s*[:=]\s*(?:Buffer\.from\(['"][0-9a-fA-F]+['"]\)|['"][0-9a-fA-F]{16,}['"])`,
|
|
113
|
+
isRegex: true,
|
|
114
|
+
maxMatches: 200
|
|
115
|
+
});
|
|
116
|
+
if (hardcodedIvHits.length > 0) {
|
|
117
|
+
findings.push({
|
|
118
|
+
id: "CRYPTO_HARDCODED_IV",
|
|
119
|
+
title: "Hardcoded IV/nonce detected in cryptographic operation",
|
|
120
|
+
severity: "CRITICAL",
|
|
121
|
+
evidence: hardcodedIvHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
122
|
+
files: [...new Set(hardcodedIvHits.slice(0, 10).map((m) => m.file))],
|
|
123
|
+
requiredActions: [
|
|
124
|
+
"Always generate a random IV/nonce using crypto.randomBytes(16) for AES-CBC.",
|
|
125
|
+
"Use a 12-byte nonce for AES-GCM; never reuse IVs."
|
|
126
|
+
]
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// 7. ECB mode
|
|
130
|
+
const ecbModeHits = await searchRepo({
|
|
131
|
+
query: String.raw `createCipheriv\s*\(\s*['"][^'"]*-ecb['"]|AES\/ECB|Cipher\.getInstance\(['"][^'"]*ECB['"]`,
|
|
132
|
+
isRegex: true,
|
|
133
|
+
maxMatches: 200
|
|
134
|
+
});
|
|
135
|
+
if (ecbModeHits.length > 0) {
|
|
136
|
+
findings.push({
|
|
137
|
+
id: "CRYPTO_ECB_MODE",
|
|
138
|
+
title: "ECB cipher mode detected — leaks plaintext patterns",
|
|
139
|
+
severity: "CRITICAL",
|
|
140
|
+
evidence: ecbModeHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
141
|
+
files: [...new Set(ecbModeHits.slice(0, 10).map((m) => m.file))],
|
|
142
|
+
requiredActions: [
|
|
143
|
+
"Replace ECB mode with AES-256-GCM (authenticated encryption).",
|
|
144
|
+
"ECB mode leaks plaintext patterns because identical blocks produce identical ciphertext."
|
|
145
|
+
]
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.warn("[checkCrypto] Internal error:", err instanceof Error ? err.message : String(err));
|
|
151
|
+
}
|
|
152
|
+
return findings;
|
|
153
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
export async function checkDatabase(_opts) {
|
|
3
|
+
const findings = [];
|
|
4
|
+
try {
|
|
5
|
+
// 1. SSL/TLS disabled in connection strings
|
|
6
|
+
const tlsDisabledHits = await searchRepo({
|
|
7
|
+
query: String.raw `sslmode=disable|ssl=false|ssl:\s*false|useSSL=false|TrustServerCertificate=true`,
|
|
8
|
+
isRegex: true,
|
|
9
|
+
maxMatches: 200
|
|
10
|
+
});
|
|
11
|
+
if (tlsDisabledHits.length > 0) {
|
|
12
|
+
findings.push({
|
|
13
|
+
id: "DB_TLS_DISABLED",
|
|
14
|
+
title: "Database connection with TLS/SSL disabled detected",
|
|
15
|
+
severity: "CRITICAL",
|
|
16
|
+
evidence: tlsDisabledHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
17
|
+
files: [...new Set(tlsDisabledHits.slice(0, 10).map((m) => m.file))],
|
|
18
|
+
requiredActions: [
|
|
19
|
+
"Always use sslmode=require or sslmode=verify-full for PostgreSQL.",
|
|
20
|
+
"Never disable TLS for database connections — transmits credentials and data in plaintext."
|
|
21
|
+
]
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// 2. Root/admin credentials in connection strings
|
|
25
|
+
const adminCredHits = await searchRepo({
|
|
26
|
+
query: String.raw `postgresql://root:|mysql://root:|mongodb://admin:|mongodb://root:|postgres://postgres:|//sa:`,
|
|
27
|
+
isRegex: true,
|
|
28
|
+
maxMatches: 200
|
|
29
|
+
});
|
|
30
|
+
if (adminCredHits.length > 0) {
|
|
31
|
+
findings.push({
|
|
32
|
+
id: "DB_ADMIN_CREDENTIALS",
|
|
33
|
+
title: "Root/admin database credentials detected in connection strings",
|
|
34
|
+
severity: "CRITICAL",
|
|
35
|
+
evidence: adminCredHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
36
|
+
files: [...new Set(adminCredHits.slice(0, 10).map((m) => m.file))],
|
|
37
|
+
requiredActions: [
|
|
38
|
+
"Create a least-privilege DB user scoped to only required tables and operations.",
|
|
39
|
+
"Never use root/admin/sa/postgres superuser credentials in application code."
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// 3. Plaintext credentials in ORM config
|
|
44
|
+
const hardcodedPwdHits = await searchRepo({
|
|
45
|
+
query: String.raw `password\s*[:=]\s*["'][^"'\n]{6,}["']`,
|
|
46
|
+
isRegex: true,
|
|
47
|
+
maxMatches: 200
|
|
48
|
+
});
|
|
49
|
+
// Filter for hits near ORM/DB keywords
|
|
50
|
+
const ormKeywordRe = /database|db|sequelize|typeorm|prisma|mongoose|knex/i;
|
|
51
|
+
const ormPwdHits = hardcodedPwdHits.filter((m) => ormKeywordRe.test(m.preview));
|
|
52
|
+
if (ormPwdHits.length > 0) {
|
|
53
|
+
findings.push({
|
|
54
|
+
id: "DB_HARDCODED_PASSWORD",
|
|
55
|
+
title: "Hardcoded database password detected in ORM/DB configuration",
|
|
56
|
+
severity: "CRITICAL",
|
|
57
|
+
evidence: ormPwdHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
58
|
+
files: [...new Set(ormPwdHits.slice(0, 10).map((m) => m.file))],
|
|
59
|
+
requiredActions: [
|
|
60
|
+
"Move database credentials to environment variables or a secrets manager.",
|
|
61
|
+
"Never hardcode passwords in source code."
|
|
62
|
+
]
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// 4. No connection pool limits
|
|
66
|
+
const poolInitHits = await searchRepo({
|
|
67
|
+
query: String.raw `new Pool|createPool|new Sequelize|DataSource\(|createConnection`,
|
|
68
|
+
isRegex: true,
|
|
69
|
+
maxMatches: 200
|
|
70
|
+
});
|
|
71
|
+
const poolLimitHits = await searchRepo({
|
|
72
|
+
query: String.raw `max:|pool_size|poolSize|connectionLimit`,
|
|
73
|
+
isRegex: true,
|
|
74
|
+
maxMatches: 200
|
|
75
|
+
});
|
|
76
|
+
if (poolInitHits.length > 0 && poolLimitHits.length === 0) {
|
|
77
|
+
findings.push({
|
|
78
|
+
id: "DB_NO_POOL_LIMITS",
|
|
79
|
+
title: "Database connection pool initialized without explicit limits",
|
|
80
|
+
severity: "MEDIUM",
|
|
81
|
+
evidence: poolInitHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
82
|
+
files: [...new Set(poolInitHits.slice(0, 10).map((m) => m.file))],
|
|
83
|
+
requiredActions: [
|
|
84
|
+
"Set connection pool limits (max, min) to prevent resource exhaustion.",
|
|
85
|
+
"Unbounded pools can crash the database under load or be exploited for DoS."
|
|
86
|
+
]
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// 5. Backup encryption not configured
|
|
90
|
+
const backupHits = await searchRepo({
|
|
91
|
+
query: String.raw `backup_retention|automated_backups|backup_window`,
|
|
92
|
+
isRegex: true,
|
|
93
|
+
maxMatches: 200
|
|
94
|
+
});
|
|
95
|
+
const encryptionHits = await searchRepo({
|
|
96
|
+
query: String.raw `encrypted|kms_key`,
|
|
97
|
+
isRegex: true,
|
|
98
|
+
maxMatches: 200
|
|
99
|
+
});
|
|
100
|
+
if (backupHits.length > 0 && encryptionHits.length === 0) {
|
|
101
|
+
findings.push({
|
|
102
|
+
id: "DB_BACKUP_NOT_ENCRYPTED",
|
|
103
|
+
title: "Database backup configured without encryption",
|
|
104
|
+
severity: "HIGH",
|
|
105
|
+
evidence: backupHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
106
|
+
files: [...new Set(backupHits.slice(0, 10).map((m) => m.file))],
|
|
107
|
+
requiredActions: [
|
|
108
|
+
"Enable backup encryption with a KMS key.",
|
|
109
|
+
"Unencrypted backups expose all data if storage is compromised."
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// 6. SQL string concatenation (SQLi risk)
|
|
114
|
+
const sqliHits = await searchRepo({
|
|
115
|
+
query: String.raw `["']\s*\+\s*(?:req\.|params\.|query\.|body\.|user\.|input\.)`,
|
|
116
|
+
isRegex: true,
|
|
117
|
+
maxMatches: 200
|
|
118
|
+
});
|
|
119
|
+
// Also check for template literal injection
|
|
120
|
+
const sqliTemplateHits = await searchRepo({
|
|
121
|
+
query: String.raw `\$\{.*(?:req\.|params\.|query\.|body\.)[^}]*\}`,
|
|
122
|
+
isRegex: true,
|
|
123
|
+
maxMatches: 200
|
|
124
|
+
});
|
|
125
|
+
const allSqliHits = [...sqliHits, ...sqliTemplateHits];
|
|
126
|
+
if (allSqliHits.length > 0) {
|
|
127
|
+
findings.push({
|
|
128
|
+
id: "DB_SQL_INJECTION_RISK",
|
|
129
|
+
title: "Possible SQL injection: user input concatenated into query string",
|
|
130
|
+
severity: "CRITICAL",
|
|
131
|
+
evidence: allSqliHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
132
|
+
files: [...new Set(allSqliHits.slice(0, 10).map((m) => m.file))],
|
|
133
|
+
requiredActions: [
|
|
134
|
+
"Use parameterized queries or ORM query builders — never concatenate user input into SQL.",
|
|
135
|
+
"CWE-89: SQL injection can lead to full database compromise."
|
|
136
|
+
]
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.warn("[checkDatabase] Internal error:", err instanceof Error ? err.message : String(err));
|
|
142
|
+
}
|
|
143
|
+
return findings;
|
|
144
|
+
}
|
|
@@ -1,5 +1,129 @@
|
|
|
1
1
|
import fg from "fast-glob";
|
|
2
2
|
import { readFileSafe } from "../../repo/fs.js";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
// 24-hour cache for OpenSSF Scorecard API responses
|
|
8
|
+
const scorecardCache = new Map();
|
|
9
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
async function fetchScorecardScore(dep) {
|
|
11
|
+
try {
|
|
12
|
+
const cached = scorecardCache.get(dep);
|
|
13
|
+
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
|
14
|
+
return cached.score;
|
|
15
|
+
}
|
|
16
|
+
// dep may be scoped (e.g. @org/pkg) — map to github owner/repo is heuristic
|
|
17
|
+
// We try the npm registry to find the repository
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
20
|
+
try {
|
|
21
|
+
const npmResp = await fetch(`https://registry.npmjs.org/${encodeURIComponent(dep)}/latest`, {
|
|
22
|
+
signal: controller.signal
|
|
23
|
+
});
|
|
24
|
+
if (!npmResp.ok)
|
|
25
|
+
return null;
|
|
26
|
+
const npmData = await npmResp.json();
|
|
27
|
+
const repoUrl = npmData?.repository?.url ?? "";
|
|
28
|
+
const ghMatch = /github\.com[/:]([^/]+\/[^/.]+)/.exec(repoUrl);
|
|
29
|
+
if (!ghMatch)
|
|
30
|
+
return null;
|
|
31
|
+
const ghPath = ghMatch[1].replace(/\.git$/, "");
|
|
32
|
+
const controller2 = new AbortController();
|
|
33
|
+
const timeout2 = setTimeout(() => controller2.abort(), 5000);
|
|
34
|
+
try {
|
|
35
|
+
const scoreResp = await fetch(`https://api.securityscorecards.dev/projects/github.com/${ghPath}`, { signal: controller2.signal });
|
|
36
|
+
if (!scoreResp.ok)
|
|
37
|
+
return null;
|
|
38
|
+
const scoreData = await scoreResp.json();
|
|
39
|
+
const score = scoreData?.score ?? null;
|
|
40
|
+
if (score !== null) {
|
|
41
|
+
scorecardCache.set(dep, { score, fetchedAt: Date.now() });
|
|
42
|
+
}
|
|
43
|
+
return score;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
clearTimeout(timeout2);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function checkNpmProvenance() {
|
|
58
|
+
const findings = [];
|
|
59
|
+
try {
|
|
60
|
+
// 1. npm audit signatures (npm 9+)
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execFileAsync("npm", ["audit", "signatures", "--json"], {
|
|
63
|
+
timeout: 30000,
|
|
64
|
+
env: process.env
|
|
65
|
+
});
|
|
66
|
+
if (stdout) {
|
|
67
|
+
let auditResult;
|
|
68
|
+
try {
|
|
69
|
+
auditResult = JSON.parse(stdout);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
auditResult = null;
|
|
73
|
+
}
|
|
74
|
+
if (auditResult && typeof auditResult === "object") {
|
|
75
|
+
const result = auditResult;
|
|
76
|
+
const invalid = result["invalid"] ?? [];
|
|
77
|
+
const missing = result["missing"] ?? [];
|
|
78
|
+
const noProvenance = [...invalid, ...missing];
|
|
79
|
+
if (noProvenance.length > 0) {
|
|
80
|
+
findings.push({
|
|
81
|
+
id: "DEP_NO_PROVENANCE",
|
|
82
|
+
title: `${noProvenance.length} production dependencies lack npm provenance attestation`,
|
|
83
|
+
severity: "MEDIUM",
|
|
84
|
+
evidence: noProvenance.slice(0, 10).map((p) => typeof p === "object" && p !== null ? JSON.stringify(p).slice(0, 120) : String(p)),
|
|
85
|
+
requiredActions: [
|
|
86
|
+
"Require packages with npm provenance attestation (npm 9+).",
|
|
87
|
+
"Pin dependencies to specific versions and verify signatures."
|
|
88
|
+
]
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// npm not available or < v9 — skip gracefully
|
|
96
|
+
}
|
|
97
|
+
// 2. OpenSSF Scorecard for top 5 production deps
|
|
98
|
+
try {
|
|
99
|
+
const pkgRaw = await readFile("package.json", "utf8");
|
|
100
|
+
const pkg = JSON.parse(pkgRaw);
|
|
101
|
+
const prodDeps = Object.keys(pkg.dependencies ?? {}).slice(0, 5);
|
|
102
|
+
for (const dep of prodDeps) {
|
|
103
|
+
const score = await fetchScorecardScore(dep);
|
|
104
|
+
if (score !== null && score < 5.0) {
|
|
105
|
+
findings.push({
|
|
106
|
+
id: "DEP_LOW_SCORECARD",
|
|
107
|
+
title: `Dependency "${dep}" has a low OpenSSF Scorecard score (${score.toFixed(1)}/10)`,
|
|
108
|
+
severity: "MEDIUM",
|
|
109
|
+
evidence: [`${dep}: score ${score.toFixed(1)}/10 (threshold: 5.0)`],
|
|
110
|
+
requiredActions: [
|
|
111
|
+
`Review the OpenSSF Scorecard for ${dep} at https://scorecard.dev.`,
|
|
112
|
+
"Consider replacing low-scored dependencies or accepting documented risk."
|
|
113
|
+
]
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// package.json unreadable or API unavailable — skip
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.warn("[checkNpmProvenance] Internal error:", err instanceof Error ? err.message : String(err));
|
|
124
|
+
}
|
|
125
|
+
return { findings };
|
|
126
|
+
}
|
|
3
127
|
export async function checkDependencies(_) {
|
|
4
128
|
const findings = [];
|
|
5
129
|
const manifests = await fg(["package.json"], { dot: true });
|
|
@@ -39,5 +163,7 @@ export async function checkDependencies(_) {
|
|
|
39
163
|
requiredActions: ["Fix package.json JSON syntax."]
|
|
40
164
|
});
|
|
41
165
|
}
|
|
166
|
+
const provenance = await checkNpmProvenance();
|
|
167
|
+
findings.push(...provenance.findings);
|
|
42
168
|
return findings;
|
|
43
169
|
}
|