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,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
+ }