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.
Files changed (81) hide show
  1. package/README.md +963 -193
  2. package/defaults/agent-run-schema.json +98 -0
  3. package/defaults/checklists/ai.json +25 -0
  4. package/defaults/checklists/api.json +27 -0
  5. package/defaults/checklists/infra.json +27 -0
  6. package/defaults/checklists/mobile.json +25 -0
  7. package/defaults/checklists/payments.json +25 -0
  8. package/defaults/checklists/web.json +30 -0
  9. package/defaults/control-catalog.json +392 -0
  10. package/defaults/evidence-map.json +194 -0
  11. package/defaults/security-policy.json +41 -2
  12. package/dist/cli/index.js +13 -8
  13. package/dist/cli/install.js +80 -2
  14. package/dist/cli/onboarding.js +590 -0
  15. package/dist/cli/update.js +83 -15
  16. package/dist/gate/baseline.js +115 -0
  17. package/dist/gate/checks/ai-redteam.js +398 -0
  18. package/dist/gate/checks/api.js +93 -0
  19. package/dist/gate/checks/crypto.js +153 -0
  20. package/dist/gate/checks/database.js +144 -0
  21. package/dist/gate/checks/dependencies.js +126 -0
  22. package/dist/gate/checks/dlp.js +153 -0
  23. package/dist/gate/checks/graphql.js +122 -0
  24. package/dist/gate/checks/infra.js +126 -12
  25. package/dist/gate/checks/k8s.js +190 -0
  26. package/dist/gate/checks/playbook.js +160 -0
  27. package/dist/gate/checks/runtime.js +316 -0
  28. package/dist/gate/checks/sbom.js +199 -0
  29. package/dist/gate/checks/scanners.js +379 -8
  30. package/dist/gate/checks/secrets.js +85 -20
  31. package/dist/gate/exceptions.js +6 -1
  32. package/dist/gate/policy.js +85 -19
  33. package/dist/gate/threat-intel.js +157 -0
  34. package/dist/mcp/orchestration.js +586 -0
  35. package/dist/mcp/server.js +568 -16
  36. package/dist/repo/search.js +11 -1
  37. package/dist/review/store.js +133 -0
  38. package/dist/types/agent-run.js +8 -0
  39. package/package.json +5 -5
  40. package/prompts/SECURITY_PROMPT.md +415 -1
  41. package/skills/agentic-loop-exploiter/SKILL.md +69 -0
  42. package/skills/ai-llm-redteam/SKILL.md +118 -0
  43. package/skills/algorithm-implementation-reviewer/SKILL.md +85 -0
  44. package/skills/android-penetration-tester/SKILL.md +83 -0
  45. package/skills/appsec-code-auditor/SKILL.md +86 -0
  46. package/skills/artifact-integrity-analyst/SKILL.md +68 -0
  47. package/skills/attack-navigator/SKILL.md +64 -0
  48. package/skills/auth-session-hacker/SKILL.md +87 -0
  49. package/skills/aws-penetration-tester/SKILL.md +60 -0
  50. package/skills/azure-penetration-tester/SKILL.md +64 -0
  51. package/skills/business-logic-attacker/SKILL.md +76 -0
  52. package/skills/cicd-pipeline-hijacker/SKILL.md +81 -0
  53. package/skills/ciso-orchestrator/SKILL.md +165 -0
  54. package/skills/cloud-infra-specialist/SKILL.md +85 -0
  55. package/skills/compliance-gap-analyst/SKILL.md +77 -0
  56. package/skills/compliance-grc/SKILL.md +148 -0
  57. package/skills/crypto-pki-specialist/SKILL.md +136 -0
  58. package/skills/dependency-confusion-attacker/SKILL.md +78 -0
  59. package/skills/evidence-collector/SKILL.md +86 -0
  60. package/skills/gcp-penetration-tester/SKILL.md +63 -0
  61. package/skills/injection-specialist/SKILL.md +62 -0
  62. package/skills/ios-security-auditor/SKILL.md +77 -0
  63. package/skills/k8s-container-escaper/SKILL.md +74 -0
  64. package/skills/key-management-lifecycle-analyst/SKILL.md +92 -0
  65. package/skills/logic-race-fuzzer/SKILL.md +67 -0
  66. package/skills/mobile-api-network-attacker/SKILL.md +81 -0
  67. package/skills/mobile-security-specialist/SKILL.md +124 -0
  68. package/skills/model-extraction-attacker/SKILL.md +68 -0
  69. package/skills/pentest-infra/SKILL.md +69 -0
  70. package/skills/pentest-social/SKILL.md +72 -0
  71. package/skills/pentest-team/SKILL.md +126 -0
  72. package/skills/pentest-web-api/SKILL.md +71 -0
  73. package/skills/privacy-flow-analyst/SKILL.md +70 -0
  74. package/skills/prompt-injection-specialist/SKILL.md +76 -0
  75. package/skills/rag-poisoning-specialist/SKILL.md +71 -0
  76. package/skills/senior-security-engineer/SKILL.md +75 -13
  77. package/skills/serialization-memory-attacker/SKILL.md +78 -0
  78. package/skills/stride-pasta-analyst/SKILL.md +72 -0
  79. package/skills/supply-chain-devsecops/SKILL.md +82 -0
  80. package/skills/threat-modeler/SKILL.md +116 -0
  81. package/skills/tls-certificate-auditor/SKILL.md +76 -0
@@ -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
  }