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,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
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { searchRepo } from "../../repo/search.js";
|
|
2
|
+
export async function checkDlp(_opts) {
|
|
3
|
+
const findings = [];
|
|
4
|
+
try {
|
|
5
|
+
// 1. SSN in logs
|
|
6
|
+
const ssnHits = await searchRepo({
|
|
7
|
+
query: String.raw `(?:console\.log|logger\.\w+|log\.\w+)\s*\([^)]*\b\d{3}[-\s]?\d{2}[-\s]?\d{4}\b`,
|
|
8
|
+
isRegex: true,
|
|
9
|
+
maxMatches: 200
|
|
10
|
+
});
|
|
11
|
+
if (ssnHits.length > 0) {
|
|
12
|
+
findings.push({
|
|
13
|
+
id: "DLP_SSN_IN_LOGS",
|
|
14
|
+
title: "Social Security Number pattern detected in log statement",
|
|
15
|
+
severity: "CRITICAL",
|
|
16
|
+
evidence: ssnHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
17
|
+
files: [...new Set(ssnHits.slice(0, 10).map((m) => m.file))],
|
|
18
|
+
requiredActions: [
|
|
19
|
+
"Remove SSN values from log statements immediately.",
|
|
20
|
+
"HIPAA requires protection of SSNs as Protected Health Information (PHI).",
|
|
21
|
+
"Use tokenization or masking before logging any government ID."
|
|
22
|
+
]
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// 2. Credit card in logs (PAN)
|
|
26
|
+
const panHits = await searchRepo({
|
|
27
|
+
query: String.raw `(?:console\.log|logger\.\w+)\s*\([^)]*\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13})\b`,
|
|
28
|
+
isRegex: true,
|
|
29
|
+
maxMatches: 200
|
|
30
|
+
});
|
|
31
|
+
if (panHits.length > 0) {
|
|
32
|
+
findings.push({
|
|
33
|
+
id: "DLP_PAN_IN_LOGS",
|
|
34
|
+
title: "Credit card PAN pattern detected in log statement — PCI DSS violation",
|
|
35
|
+
severity: "CRITICAL",
|
|
36
|
+
evidence: panHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
37
|
+
files: [...new Set(panHits.slice(0, 10).map((m) => m.file))],
|
|
38
|
+
requiredActions: [
|
|
39
|
+
"Remove all PAN values from log statements immediately.",
|
|
40
|
+
"PCI DSS Requirement 3: Never log full card numbers.",
|
|
41
|
+
"Use masked PANs (show only last 4 digits) if logging is required."
|
|
42
|
+
]
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// 3. Full request body logged
|
|
46
|
+
const reqBodyLogHits = await searchRepo({
|
|
47
|
+
query: String.raw `(?:console\.log|logger\.\w+)\s*\(\s*(?:req\.body|request\.body|ctx\.body|\{\.\.\.req)`,
|
|
48
|
+
isRegex: true,
|
|
49
|
+
maxMatches: 200
|
|
50
|
+
});
|
|
51
|
+
if (reqBodyLogHits.length > 0) {
|
|
52
|
+
findings.push({
|
|
53
|
+
id: "DLP_REQUEST_BODY_LOGGED",
|
|
54
|
+
title: "Full request body logged — may expose PII/credentials",
|
|
55
|
+
severity: "HIGH",
|
|
56
|
+
evidence: reqBodyLogHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
57
|
+
files: [...new Set(reqBodyLogHits.slice(0, 10).map((m) => m.file))],
|
|
58
|
+
requiredActions: [
|
|
59
|
+
"Never log full request bodies — use field allowlists to log only non-sensitive fields.",
|
|
60
|
+
"GDPR Article 5: data minimization applies to logs. HIPAA prohibits logging PHI."
|
|
61
|
+
]
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
// 4. User object logged
|
|
65
|
+
const userLogHits = await searchRepo({
|
|
66
|
+
query: String.raw `(?:console\.log|logger\.\w+)\s*\(\s*(?:user|currentUser|req\.user|session\.user)\s*[,)]`,
|
|
67
|
+
isRegex: true,
|
|
68
|
+
maxMatches: 200
|
|
69
|
+
});
|
|
70
|
+
if (userLogHits.length > 0) {
|
|
71
|
+
findings.push({
|
|
72
|
+
id: "DLP_USER_OBJECT_LOGGED",
|
|
73
|
+
title: "User object logged — may expose PII, hashed passwords, or tokens",
|
|
74
|
+
severity: "HIGH",
|
|
75
|
+
evidence: userLogHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
76
|
+
files: [...new Set(userLogHits.slice(0, 10).map((m) => m.file))],
|
|
77
|
+
requiredActions: [
|
|
78
|
+
"Log only specific non-sensitive user fields (e.g. userId, role).",
|
|
79
|
+
"Never log the full user object — it likely contains PII and auth data (GDPR, HIPAA)."
|
|
80
|
+
]
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// 5. Email in logs
|
|
84
|
+
const emailLogHits = await searchRepo({
|
|
85
|
+
query: String.raw `(?:console\.log|logger\.\w+)\s*\([^)]*[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`,
|
|
86
|
+
isRegex: true,
|
|
87
|
+
maxMatches: 200
|
|
88
|
+
});
|
|
89
|
+
if (emailLogHits.length > 0) {
|
|
90
|
+
findings.push({
|
|
91
|
+
id: "DLP_EMAIL_IN_LOGS",
|
|
92
|
+
title: "Email address detected in log statement",
|
|
93
|
+
severity: "MEDIUM",
|
|
94
|
+
evidence: emailLogHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
95
|
+
files: [...new Set(emailLogHits.slice(0, 10).map((m) => m.file))],
|
|
96
|
+
requiredActions: [
|
|
97
|
+
"Mask or hash email addresses before logging (GDPR Article 5 — data minimization).",
|
|
98
|
+
"Use a user ID or anonymized identifier in logs instead of the email."
|
|
99
|
+
]
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// 6. Stack traces in API responses
|
|
103
|
+
const stackTraceHits = await searchRepo({
|
|
104
|
+
query: String.raw `(?:res\.json|res\.send|response\.json)\s*\(\s*\{[^}]*(?:stack|stackTrace|error\.stack)`,
|
|
105
|
+
isRegex: true,
|
|
106
|
+
maxMatches: 200
|
|
107
|
+
});
|
|
108
|
+
if (stackTraceHits.length > 0) {
|
|
109
|
+
findings.push({
|
|
110
|
+
id: "DLP_STACK_TRACE_IN_RESPONSE",
|
|
111
|
+
title: "Stack trace exposed in API response — CWE-209 information leakage",
|
|
112
|
+
severity: "HIGH",
|
|
113
|
+
evidence: stackTraceHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
114
|
+
files: [...new Set(stackTraceHits.slice(0, 10).map((m) => m.file))],
|
|
115
|
+
requiredActions: [
|
|
116
|
+
"Never expose stack traces in API responses (CWE-209).",
|
|
117
|
+
"Log errors internally with a correlation ID; return only a safe error message to clients."
|
|
118
|
+
]
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// 7. Server version disclosure
|
|
122
|
+
const poweredByHits = await searchRepo({
|
|
123
|
+
query: String.raw `X-Powered-By|Server:\s*(?:Express|nginx|Apache)|app\.set\s*\(\s*['"]x-powered-by['"]`,
|
|
124
|
+
isRegex: true,
|
|
125
|
+
maxMatches: 200
|
|
126
|
+
});
|
|
127
|
+
if (poweredByHits.length > 0) {
|
|
128
|
+
// Check if x-powered-by is disabled nearby
|
|
129
|
+
const disableHits = await searchRepo({
|
|
130
|
+
query: String.raw `app\.disable\s*\(\s*['"]x-powered-by['"]`,
|
|
131
|
+
isRegex: true,
|
|
132
|
+
maxMatches: 200
|
|
133
|
+
});
|
|
134
|
+
if (disableHits.length === 0) {
|
|
135
|
+
findings.push({
|
|
136
|
+
id: "DLP_SERVER_HEADER_DISCLOSURE",
|
|
137
|
+
title: "Server technology disclosed via X-Powered-By or Server response header",
|
|
138
|
+
severity: "MEDIUM",
|
|
139
|
+
evidence: poweredByHits.slice(0, 10).map((m) => `${m.file}:${m.line}:${m.preview}`),
|
|
140
|
+
files: [...new Set(poweredByHits.slice(0, 10).map((m) => m.file))],
|
|
141
|
+
requiredActions: [
|
|
142
|
+
"Call app.disable('x-powered-by') in Express.",
|
|
143
|
+
"Remove or obscure Server headers — version disclosure aids attacker reconnaissance."
|
|
144
|
+
]
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.warn("[checkDlp] Internal error:", err instanceof Error ? err.message : String(err));
|
|
151
|
+
}
|
|
152
|
+
return findings;
|
|
153
|
+
}
|