getdoorman 1.0.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +181 -0
  3. package/bin/doorman.js +444 -0
  4. package/package.json +74 -0
  5. package/src/ai-fixer.js +559 -0
  6. package/src/ast-scanner.js +434 -0
  7. package/src/auth.js +149 -0
  8. package/src/baseline.js +48 -0
  9. package/src/compliance.js +539 -0
  10. package/src/config.js +466 -0
  11. package/src/custom-rules.js +32 -0
  12. package/src/dashboard.js +202 -0
  13. package/src/detector.js +142 -0
  14. package/src/fix-engine.js +48 -0
  15. package/src/fix-registry-extra.js +95 -0
  16. package/src/fix-registry-go-rust.js +77 -0
  17. package/src/fix-registry-java-csharp.js +77 -0
  18. package/src/fix-registry-js.js +99 -0
  19. package/src/fix-registry-mcp-ai.js +57 -0
  20. package/src/fix-registry-python.js +87 -0
  21. package/src/fixer-ruby-php.js +608 -0
  22. package/src/fixer.js +2113 -0
  23. package/src/hooks.js +115 -0
  24. package/src/ignore.js +176 -0
  25. package/src/index.js +384 -0
  26. package/src/metrics.js +126 -0
  27. package/src/monorepo.js +65 -0
  28. package/src/presets.js +54 -0
  29. package/src/reporter.js +975 -0
  30. package/src/rule-worker.js +36 -0
  31. package/src/rules/ast-rules.js +756 -0
  32. package/src/rules/bugs/accessibility.js +235 -0
  33. package/src/rules/bugs/ai-codegen-fixable.js +172 -0
  34. package/src/rules/bugs/ai-codegen.js +365 -0
  35. package/src/rules/bugs/code-smell-bugs.js +247 -0
  36. package/src/rules/bugs/crypto-bugs.js +195 -0
  37. package/src/rules/bugs/docker-bugs.js +158 -0
  38. package/src/rules/bugs/general.js +361 -0
  39. package/src/rules/bugs/go-bugs.js +279 -0
  40. package/src/rules/bugs/index.js +73 -0
  41. package/src/rules/bugs/js-api.js +257 -0
  42. package/src/rules/bugs/js-array-object.js +210 -0
  43. package/src/rules/bugs/js-async-fixable.js +223 -0
  44. package/src/rules/bugs/js-async.js +211 -0
  45. package/src/rules/bugs/js-closure-scope.js +182 -0
  46. package/src/rules/bugs/js-database.js +203 -0
  47. package/src/rules/bugs/js-error-handling.js +148 -0
  48. package/src/rules/bugs/js-logic.js +261 -0
  49. package/src/rules/bugs/js-memory.js +214 -0
  50. package/src/rules/bugs/js-node.js +361 -0
  51. package/src/rules/bugs/js-react.js +373 -0
  52. package/src/rules/bugs/js-regex.js +200 -0
  53. package/src/rules/bugs/js-state.js +272 -0
  54. package/src/rules/bugs/js-type-coercion.js +318 -0
  55. package/src/rules/bugs/nextjs-bugs.js +242 -0
  56. package/src/rules/bugs/nextjs-fixable.js +120 -0
  57. package/src/rules/bugs/node-fixable.js +178 -0
  58. package/src/rules/bugs/python-advanced.js +245 -0
  59. package/src/rules/bugs/python-fixable.js +98 -0
  60. package/src/rules/bugs/python.js +284 -0
  61. package/src/rules/bugs/react-fixable.js +207 -0
  62. package/src/rules/bugs/ruby-bugs.js +182 -0
  63. package/src/rules/bugs/shell-bugs.js +181 -0
  64. package/src/rules/bugs/silent-failures.js +261 -0
  65. package/src/rules/bugs/ts-bugs.js +235 -0
  66. package/src/rules/bugs/unused-vars.js +65 -0
  67. package/src/rules/compliance/accessibility-ext.js +468 -0
  68. package/src/rules/compliance/education.js +322 -0
  69. package/src/rules/compliance/financial.js +421 -0
  70. package/src/rules/compliance/frameworks.js +507 -0
  71. package/src/rules/compliance/healthcare.js +520 -0
  72. package/src/rules/compliance/index.js +2714 -0
  73. package/src/rules/compliance/regional-eu.js +480 -0
  74. package/src/rules/compliance/regional-international.js +903 -0
  75. package/src/rules/cost/index.js +1993 -0
  76. package/src/rules/data/index.js +2503 -0
  77. package/src/rules/dependencies/index.js +1684 -0
  78. package/src/rules/deployment/index.js +2050 -0
  79. package/src/rules/index.js +71 -0
  80. package/src/rules/infrastructure/index.js +3048 -0
  81. package/src/rules/performance/index.js +3455 -0
  82. package/src/rules/quality/index.js +3175 -0
  83. package/src/rules/reliability/index.js +3040 -0
  84. package/src/rules/scope-rules.js +815 -0
  85. package/src/rules/security/ai-api.js +1177 -0
  86. package/src/rules/security/auth.js +1328 -0
  87. package/src/rules/security/cors.js +127 -0
  88. package/src/rules/security/crypto.js +527 -0
  89. package/src/rules/security/csharp.js +862 -0
  90. package/src/rules/security/csrf.js +193 -0
  91. package/src/rules/security/dart.js +835 -0
  92. package/src/rules/security/deserialization.js +291 -0
  93. package/src/rules/security/file-upload.js +187 -0
  94. package/src/rules/security/go.js +850 -0
  95. package/src/rules/security/headers.js +235 -0
  96. package/src/rules/security/index.js +65 -0
  97. package/src/rules/security/injection.js +1639 -0
  98. package/src/rules/security/mcp-server.js +71 -0
  99. package/src/rules/security/misconfiguration.js +660 -0
  100. package/src/rules/security/oauth-jwt.js +329 -0
  101. package/src/rules/security/path-traversal.js +295 -0
  102. package/src/rules/security/php.js +1054 -0
  103. package/src/rules/security/prototype-pollution.js +283 -0
  104. package/src/rules/security/rate-limiting.js +208 -0
  105. package/src/rules/security/ruby.js +1061 -0
  106. package/src/rules/security/rust.js +693 -0
  107. package/src/rules/security/secrets.js +747 -0
  108. package/src/rules/security/shell.js +647 -0
  109. package/src/rules/security/ssrf.js +298 -0
  110. package/src/rules/security/supply-chain-advanced.js +393 -0
  111. package/src/rules/security/supply-chain.js +734 -0
  112. package/src/rules/security/swift.js +835 -0
  113. package/src/rules/security/taint.js +27 -0
  114. package/src/rules/security/xss.js +520 -0
  115. package/src/scan-cache.js +71 -0
  116. package/src/scanner.js +710 -0
  117. package/src/scope-analyzer.js +685 -0
  118. package/src/share.js +88 -0
  119. package/src/taint.js +300 -0
  120. package/src/telemetry.js +183 -0
  121. package/src/tracer.js +190 -0
  122. package/src/upload.js +35 -0
  123. package/src/worker.js +31 -0
@@ -0,0 +1,734 @@
1
+ // Supply Chain Security Rules
2
+ // Covers: package manager attacks, CI/CD pipeline attacks, source code integrity,
3
+ // build system attacks, container supply chain, Git security, registry security
4
+
5
+ const rules = [
6
+
7
+ // ─── PACKAGE MANAGER ──────────────────────────────────────────────────────
8
+
9
+ // SC-PKG-001: Malicious postinstall script
10
+ { id: 'SC-PKG-001', category: 'security', severity: 'high', title: 'postinstall Script — Supply Chain Attack Vector', confidence: 'likely',
11
+ check({ files }) {
12
+ const findings = [];
13
+ const pkg = files.get('package.json');
14
+ if (!pkg) return findings;
15
+ try {
16
+ const json = JSON.parse(pkg);
17
+ const scripts = json.scripts || {};
18
+ for (const hook of ['postinstall', 'preinstall', 'install', 'prepack', 'postpack', 'prepare']) {
19
+ if (scripts[hook]) {
20
+ findings.push({ ruleId: 'SC-PKG-001', category: 'security', severity: 'high',
21
+ title: `package.json "${hook}" script runs automatically on install — verify it is safe`,
22
+ description: `The "${hook}" lifecycle script executes on every npm install. This is the primary mechanism used in supply chain attacks (event-stream, node-ipc). Audit this script carefully and consider removing it if not essential.`,
23
+ file: 'package.json', fix: null });
24
+ }
25
+ }
26
+ } catch {}
27
+ return findings;
28
+ },
29
+ },
30
+
31
+ // SC-PKG-002: Dependency from git URL (no integrity check)
32
+ { id: 'SC-PKG-002', category: 'security', severity: 'high', title: 'Dependency Installed from Git URL', confidence: 'likely',
33
+ check({ files }) {
34
+ const findings = [];
35
+ const pkg = files.get('package.json');
36
+ if (!pkg) return findings;
37
+ try {
38
+ const json = JSON.parse(pkg);
39
+ for (const [name, version] of Object.entries({ ...json.dependencies, ...json.devDependencies })) {
40
+ if (typeof version === 'string' && (
41
+ version.startsWith('github:') || version.startsWith('git+') ||
42
+ version.startsWith('git://') || version.startsWith('bitbucket:') ||
43
+ version.startsWith('gitlab:') || /^[\w-]+\/[\w-]+/.test(version)
44
+ )) {
45
+ findings.push({ ruleId: 'SC-PKG-002', category: 'security', severity: 'high',
46
+ title: `Package "${name}" installed from git URL — no integrity verification, changes silently`,
47
+ description: 'Git URL dependencies bypass npm integrity checks. A compromised repo becomes a compromised dependency. Use published npm versions with lockfile integrity.',
48
+ file: 'package.json', fix: null });
49
+ }
50
+ }
51
+ } catch {}
52
+ return findings;
53
+ },
54
+ },
55
+
56
+ // SC-PKG-003: Dependency confusion — unscoped internal package names
57
+ { id: 'SC-PKG-003', category: 'security', severity: 'critical', title: 'Dependency Confusion Attack Risk', confidence: 'definite',
58
+ check({ files }) {
59
+ const findings = [];
60
+ const pkg = files.get('package.json');
61
+ if (!pkg) return findings;
62
+ try {
63
+ const json = JSON.parse(pkg);
64
+ const projectScope = (json.name || '').startsWith('@') ? json.name.split('/')[0] : null;
65
+ for (const depName of Object.keys({ ...json.dependencies, ...json.devDependencies })) {
66
+ if (!depName.startsWith('@') && (
67
+ depName.match(/internal|private|corp|shared|common|utils|lib/) ||
68
+ (projectScope && json.name && depName.includes(json.name.split('/').pop()?.split('-')[0]))
69
+ )) {
70
+ findings.push({ ruleId: 'SC-PKG-003', category: 'security', severity: 'critical',
71
+ title: `Unscoped package "${depName}" may be vulnerable to dependency confusion attack`,
72
+ description: 'An attacker can publish a higher-versioned package with the same name to the public npm registry, causing npm to pull the malicious version. Use scoped packages (@your-org/package-name) for all internal dependencies.',
73
+ file: 'package.json', fix: null });
74
+ }
75
+ }
76
+ } catch {}
77
+ return findings;
78
+ },
79
+ },
80
+
81
+ // SC-PKG-004: No lockfile — deterministic builds impossible
82
+ { id: 'SC-PKG-004', category: 'security', severity: 'high', title: 'No Lockfile — Dependency Resolution Non-Deterministic', confidence: 'likely',
83
+ check({ files, stack }) {
84
+ const findings = [];
85
+ if (stack.runtime !== 'node') return findings;
86
+ if (!files.has('package.json')) return findings;
87
+ const hasLock = files.has('package-lock.json') ||
88
+ [...files.keys()].some(f => f === 'yarn.lock' || f === 'pnpm-lock.yaml' || f === 'bun.lockb');
89
+ if (!hasLock) {
90
+ findings.push({ ruleId: 'SC-PKG-004', category: 'security', severity: 'high',
91
+ title: 'No lockfile found — builds may install different versions over time',
92
+ description: 'Without a lockfile, npm/yarn resolves ranges on every install, potentially pulling in compromised patch versions. Commit your lockfile and use `npm ci` in CI.',
93
+ fix: null });
94
+ }
95
+ return findings;
96
+ },
97
+ },
98
+
99
+ // SC-PKG-005: npm install instead of npm ci in CI
100
+ { id: 'SC-PKG-005', category: 'security', severity: 'medium', title: 'npm install in CI Instead of npm ci', confidence: 'likely',
101
+ check({ files }) {
102
+ const findings = [];
103
+ for (const [fp, c] of files) {
104
+ if (!fp.includes('.github/workflows') && !fp.includes('.gitlab-ci') && !fp.includes('.circleci')) continue;
105
+ if (c.match(/\bnpm install\b/) && !c.match(/\bnpm ci\b/)) {
106
+ findings.push({ ruleId: 'SC-PKG-005', category: 'security', severity: 'medium',
107
+ title: 'CI uses "npm install" instead of "npm ci"',
108
+ description: '"npm ci" strictly installs from the lockfile and fails if it does not match package.json — preventing surprise version changes. "npm install" can silently update the lockfile.',
109
+ file: fp, fix: null });
110
+ }
111
+ }
112
+ return findings;
113
+ },
114
+ },
115
+
116
+ // SC-PKG-006: Wildcard version on security library
117
+ { id: 'SC-PKG-006', category: 'security', severity: 'high', title: 'Wildcard Version on Security-Critical Package', confidence: 'likely',
118
+ check({ files }) {
119
+ const findings = [];
120
+ const pkg = files.get('package.json');
121
+ if (!pkg) return findings;
122
+ try {
123
+ const json = JSON.parse(pkg);
124
+ const secPkgs = ['jsonwebtoken', 'bcrypt', 'bcryptjs', 'passport', 'helmet', 'express', 'crypto-js', 'node-forge', 'argon2'];
125
+ for (const [name, version] of Object.entries({ ...json.dependencies, ...json.devDependencies })) {
126
+ if (secPkgs.includes(name) && (version === '*' || version === 'latest' || version === 'x')) {
127
+ findings.push({ ruleId: 'SC-PKG-006', category: 'security', severity: 'high',
128
+ title: `Security package "${name}" uses wildcard version "${version}" — can silently pull compromised version`,
129
+ description: 'Pin security-critical packages to exact semver ranges. Wildcards bypass integrity protection in your lockfile.',
130
+ file: 'package.json', fix: null });
131
+ }
132
+ }
133
+ } catch {}
134
+ return findings;
135
+ },
136
+ },
137
+
138
+ // SC-PKG-007: No private registry configured
139
+ { id: 'SC-PKG-007', category: 'security', severity: 'medium', title: 'No Private Registry — Typosquatting Risk', confidence: 'likely',
140
+ check({ files }) {
141
+ const findings = [];
142
+ const npmrc = files.get('.npmrc') || [...files.entries()].find(([f]) => f.endsWith('.npmrc'))?.[1];
143
+ const hasScopedRegistry = npmrc && npmrc.match(/@[\w-]+:registry\s*=/);
144
+ const pkg = files.get('package.json');
145
+ if (!pkg) return findings;
146
+ try {
147
+ const json = JSON.parse(pkg);
148
+ const hasScoped = Object.keys({ ...json.dependencies, ...json.devDependencies }).some(d => d.startsWith('@'));
149
+ if (hasScoped && !hasScopedRegistry) {
150
+ findings.push({ ruleId: 'SC-PKG-007', category: 'security', severity: 'medium',
151
+ title: 'Scoped packages used but no private registry configured in .npmrc',
152
+ description: 'Configure your scoped packages to resolve from a private registry to prevent dependency confusion. Add @your-org:registry=https://your-registry in .npmrc.',
153
+ fix: null });
154
+ }
155
+ } catch {}
156
+ return findings;
157
+ },
158
+ },
159
+
160
+ // SC-PKG-008: No SBOM generation
161
+ { id: 'SC-PKG-008', category: 'security', severity: 'low', title: 'No Software Bill of Materials (SBOM)', confidence: 'suggestion',
162
+ check({ files }) {
163
+ const has = [...files.values()].some(c => c.match(/sbom|cyclonedx|spdx|syft|trivy.*sbom/i)) ||
164
+ [...files.keys()].some(f => f.match(/sbom|cyclonedx|spdx/i));
165
+ if (!has) {
166
+ return [{ ruleId: 'SC-PKG-008', category: 'security', severity: 'low',
167
+ title: 'No SBOM (Software Bill of Materials) generated',
168
+ description: 'Generate an SBOM with Syft or CycloneDX to inventory all dependencies. Required for NTIA compliance and enterprise customers.',
169
+ fix: null }];
170
+ }
171
+ return [];
172
+ },
173
+ },
174
+
175
+ // ─── CI/CD PIPELINE ────────────────────────────────────────────────────────
176
+
177
+ // SC-CI-001: Third-party GitHub Actions not pinned to SHA
178
+ { id: 'SC-CI-001', category: 'security', severity: 'critical', title: 'GitHub Actions Not Pinned to Commit SHA', confidence: 'definite',
179
+ check({ files }) {
180
+ const findings = [];
181
+ for (const [fp, c] of files) {
182
+ if (!fp.includes('.github/workflows')) continue;
183
+ const lines = c.split('\n');
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const m = lines[i].match(/uses:\s+([\w/-]+)@([\w./-]+)/);
186
+ if (m && !m[2].match(/^[0-9a-f]{40}$/) && !m[1].startsWith('actions/') ) {
187
+ findings.push({ ruleId: 'SC-CI-001', category: 'security', severity: 'critical',
188
+ title: `Third-party action "${m[1]}@${m[2]}" not pinned to commit SHA`,
189
+ description: 'Third-party GitHub Actions should be pinned to full commit SHA (uses: owner/action@sha256abc...). Tags and branches are mutable — maintainer account takeover can inject malicious code. The tj-actions/changed-files incident (2023) affected thousands of repos.',
190
+ file: fp, line: i + 1, fix: null });
191
+ }
192
+ }
193
+ }
194
+ return findings;
195
+ },
196
+ },
197
+
198
+ // SC-CI-002: pull_request_target with checkout of PR branch
199
+ { id: 'SC-CI-002', category: 'security', severity: 'critical', title: 'pull_request_target Checks Out Untrusted Code', confidence: 'definite',
200
+ check({ files }) {
201
+ const findings = [];
202
+ for (const [fp, c] of files) {
203
+ if (!fp.includes('.github/workflows')) continue;
204
+ if (c.includes('pull_request_target') && c.match(/actions\/checkout/) &&
205
+ c.match(/ref.*github\.head_ref|ref.*github\.event\.pull_request\.head/)) {
206
+ findings.push({ ruleId: 'SC-CI-002', category: 'security', severity: 'critical',
207
+ title: 'pull_request_target workflow checks out PR branch code with write permissions',
208
+ description: 'This allows untrusted fork PRs to run code with repository write token access. This is a critical GitHub Actions vulnerability. Never check out PR branch code in pull_request_target context.',
209
+ file: fp, fix: null });
210
+ }
211
+ }
212
+ return findings;
213
+ },
214
+ },
215
+
216
+ // SC-CI-003: Workflow injection via user-controlled input
217
+ { id: 'SC-CI-003', category: 'security', severity: 'critical', title: 'GitHub Actions Workflow Injection', confidence: 'definite',
218
+ check({ files }) {
219
+ const findings = [];
220
+ for (const [fp, c] of files) {
221
+ if (!fp.includes('.github/workflows')) continue;
222
+ const lines = c.split('\n');
223
+ for (let i = 0; i < lines.length; i++) {
224
+ if (lines[i].match(/\$\{\{\s*github\.event\.(pull_request\.(title|body|head\.ref)|issue\.(title|body)|comment\.body)/)) {
225
+ const ctx = lines.slice(Math.max(0, i - 3), i + 3).join('\n');
226
+ if (ctx.match(/run:|echo|bash|sh\s/)) {
227
+ findings.push({ ruleId: 'SC-CI-003', category: 'security', severity: 'critical',
228
+ title: 'User-controlled GitHub context value interpolated in run: step — command injection',
229
+ description: 'PR titles, issue bodies, and comments can contain shell metacharacters. Assign to an env var and use that: env: TITLE: ${{ github.event.pull_request.title }}',
230
+ file: fp, line: i + 1, fix: null });
231
+ }
232
+ }
233
+ }
234
+ }
235
+ return findings;
236
+ },
237
+ },
238
+
239
+ // SC-CI-004: Secrets echoed in CI logs
240
+ { id: 'SC-CI-004', category: 'security', severity: 'critical', title: 'Secrets Echoed in CI Logs', confidence: 'definite',
241
+ check({ files }) {
242
+ const findings = [];
243
+ for (const [fp, c] of files) {
244
+ if (!fp.includes('.github/workflows') && !fp.includes('.gitlab-ci')) continue;
245
+ const lines = c.split('\n');
246
+ for (let i = 0; i < lines.length; i++) {
247
+ if (lines[i].match(/echo\s+\$\{\{.*secrets\.|print\s+.*secrets\./)) {
248
+ findings.push({ ruleId: 'SC-CI-004', category: 'security', severity: 'critical',
249
+ title: 'Secret value printed in CI run step — visible in build logs',
250
+ description: 'Never echo secrets. If you must verify a secret is set, check its length: echo ${#SECRET} > 0',
251
+ file: fp, line: i + 1, fix: null });
252
+ }
253
+ }
254
+ }
255
+ return findings;
256
+ },
257
+ },
258
+
259
+ // SC-CI-005: Self-hosted runners for public repo
260
+ { id: 'SC-CI-005', category: 'security', severity: 'high', title: 'Self-Hosted Runners for Public Repository', confidence: 'likely',
261
+ check({ files }) {
262
+ const findings = [];
263
+ for (const [fp, c] of files) {
264
+ if (!fp.includes('.github/workflows')) continue;
265
+ if (c.match(/runs-on:\s*self-hosted/) &&
266
+ (c.includes('pull_request:') || c.includes('pull_request_target:'))) {
267
+ findings.push({ ruleId: 'SC-CI-005', category: 'security', severity: 'high',
268
+ title: 'Self-hosted runner used for pull_request trigger in potentially public repo',
269
+ description: 'Anyone can fork a public repo and trigger workflows on your self-hosted runner, potentially accessing internal network and data. Use GitHub-hosted runners for untrusted code.',
270
+ file: fp, fix: null });
271
+ }
272
+ }
273
+ return findings;
274
+ },
275
+ },
276
+
277
+ // SC-CI-006: No required reviewers for production deployment
278
+ { id: 'SC-CI-006', category: 'security', severity: 'high', title: 'Production Deployment Without Required Reviewers', confidence: 'likely',
279
+ check({ files }) {
280
+ const findings = [];
281
+ for (const [fp, c] of files) {
282
+ if (!fp.includes('.github/workflows')) continue;
283
+ if (c.match(/production|prod/i) && c.match(/deploy/i)) {
284
+ if (!c.match(/environment:|reviewers:|required_reviewers/i)) {
285
+ findings.push({ ruleId: 'SC-CI-006', category: 'security', severity: 'high',
286
+ title: 'Production deployment workflow without required reviewers/environment protection',
287
+ description: 'Configure a "production" environment in GitHub with required reviewers. This prevents automated or accidental production deployments.',
288
+ file: fp, fix: null });
289
+ }
290
+ }
291
+ }
292
+ return findings;
293
+ },
294
+ },
295
+
296
+ // SC-CI-007: OIDC not used for cloud auth
297
+ { id: 'SC-CI-007', category: 'security', severity: 'high', title: 'Long-Lived Cloud Credentials in CI', confidence: 'likely',
298
+ check({ files }) {
299
+ const findings = [];
300
+ for (const [fp, c] of files) {
301
+ if (!fp.includes('.github/workflows')) continue;
302
+ if ((c.includes('AWS_ACCESS_KEY_ID') || c.includes('AWS_SECRET_ACCESS_KEY') || c.includes('GOOGLE_APPLICATION_CREDENTIALS')) &&
303
+ !c.match(/id-token.*write|oidc|aws-actions\/configure-aws-credentials/i)) {
304
+ findings.push({ ruleId: 'SC-CI-007', category: 'security', severity: 'high',
305
+ title: 'Long-lived AWS/GCP credentials instead of OIDC short-lived tokens',
306
+ description: 'Use GitHub Actions OIDC (permissions: id-token: write) with aws-actions/configure-aws-credentials. Long-lived keys in secrets are a persistent leak risk.',
307
+ file: fp, fix: null });
308
+ }
309
+ }
310
+ return findings;
311
+ },
312
+ },
313
+
314
+ // SC-CI-008: No CODEOWNERS file
315
+ { id: 'SC-CI-008', category: 'security', severity: 'medium', title: 'No CODEOWNERS File', confidence: 'likely',
316
+ check({ files }) {
317
+ const has = [...files.keys()].some(f => f.match(/CODEOWNERS/) || f.match(/\.github\/CODEOWNERS/));
318
+ if (!has) {
319
+ return [{ ruleId: 'SC-CI-008', category: 'security', severity: 'medium',
320
+ title: 'No CODEOWNERS file — anyone can approve changes to any file',
321
+ description: 'Add a CODEOWNERS file to require specific team members to review changes to security-sensitive files (auth, payments, CI workflows).',
322
+ fix: null }];
323
+ }
324
+ return [];
325
+ },
326
+ },
327
+
328
+ // ─── SOURCE CODE INTEGRITY ─────────────────────────────────────────────────
329
+
330
+ // SC-SRC-001: Unicode bidirectional control characters (Trojan Source)
331
+ { id: 'SC-SRC-001', category: 'security', severity: 'critical', title: 'Unicode Bidirectional Control Characters (Trojan Source)', confidence: 'definite',
332
+ check({ files }) {
333
+ const findings = [];
334
+ // CVE-2021-42574: Bidirectional text characters can make code appear different from what it does
335
+ const bidiChars = /[\u202a-\u202e\u2066-\u2069\u200f\u061c]/;
336
+ for (const [fp, c] of files) {
337
+ if (!fp.match(/\.(js|ts|jsx|tsx|py|go|rs|java|c|cpp|rb|php)$/)) continue;
338
+ if (bidiChars.test(c)) {
339
+ findings.push({ ruleId: 'SC-SRC-001', category: 'security', severity: 'critical',
340
+ title: 'Unicode bidirectional control characters found in source code (CVE-2021-42574 — Trojan Source)',
341
+ description: 'Bidirectional Unicode characters can make malicious code appear as comments or benign strings. Remove all bidirectional control characters from source files.',
342
+ file: fp, fix: null });
343
+ }
344
+ }
345
+ return findings;
346
+ },
347
+ },
348
+
349
+ // SC-SRC-002: Zero-width characters in source code
350
+ { id: 'SC-SRC-002', category: 'security', severity: 'high', title: 'Zero-Width Characters in Source Code', confidence: 'likely',
351
+ check({ files }) {
352
+ const findings = [];
353
+ const zeroWidth = /[\u200b\u200c\u200d\ufeff\u00ad]/;
354
+ for (const [fp, c] of files) {
355
+ if (!fp.match(/\.(js|ts|jsx|tsx|py|go|rs)$/)) continue;
356
+ if (zeroWidth.test(c)) {
357
+ findings.push({ ruleId: 'SC-SRC-002', category: 'security', severity: 'high',
358
+ title: 'Zero-width Unicode characters found in source code',
359
+ description: 'Zero-width characters are invisible but affect string comparisons. They can be used to bypass security checks or introduce subtle bugs. Remove all zero-width characters.',
360
+ file: fp, fix: null });
361
+ }
362
+ }
363
+ return findings;
364
+ },
365
+ },
366
+
367
+ // SC-SRC-003: No commit signing configured
368
+ { id: 'SC-SRC-003', category: 'security', severity: 'low', title: 'No Commit Signing Policy', confidence: 'suggestion',
369
+ check({ files }) {
370
+ const has = [...files.values()].some(c => c.match(/commit.*sign|gpg.*sign|sigstore|gitsign|vigilant.*mode/i)) ||
371
+ [...files.keys()].some(f => f.includes('.gitconfig') || f.includes('branch-protection'));
372
+ if (!has) {
373
+ return [{ ruleId: 'SC-SRC-003', category: 'security', severity: 'low',
374
+ title: 'No commit signing policy detected',
375
+ description: 'Enable signed commits (GPG or SSH signing) to verify commit authorship. Without signing, commit authorship can be spoofed trivially.',
376
+ fix: null }];
377
+ }
378
+ return [];
379
+ },
380
+ },
381
+
382
+ // ─── BUILD SYSTEM ──────────────────────────────────────────────────────────
383
+
384
+ // SC-BUILD-001: curl|bash in scripts
385
+ { id: 'SC-BUILD-001', category: 'security', severity: 'critical', title: 'Remote Script Execution Without Integrity Check', confidence: 'definite',
386
+ check({ files }) {
387
+ const findings = [];
388
+ for (const [fp, c] of files) {
389
+ const lines = c.split('\n');
390
+ for (let i = 0; i < lines.length; i++) {
391
+ if (lines[i].match(/curl.*\|\s*(?:bash|sh)|wget.*\|\s*(?:bash|sh)/)) {
392
+ findings.push({ ruleId: 'SC-BUILD-001', category: 'security', severity: 'critical',
393
+ title: 'Remote script piped directly to shell — no integrity verification',
394
+ description: 'curl|bash allows a compromised server or MITM to execute arbitrary code. Download the script, verify its SHA256 checksum, then execute.',
395
+ file: fp, line: i + 1, fix: null });
396
+ }
397
+ }
398
+ }
399
+ return findings;
400
+ },
401
+ },
402
+
403
+ // SC-BUILD-002: Build script downloads from internet without checksum
404
+ { id: 'SC-BUILD-002', category: 'security', severity: 'high', title: 'Downloaded Tool Without Checksum Verification', confidence: 'likely',
405
+ check({ files }) {
406
+ const findings = [];
407
+ for (const [fp, c] of files) {
408
+ if (!fp.match(/Makefile|\.sh$|Dockerfile|\.yml$|\.yaml$/)) continue;
409
+ const lines = c.split('\n');
410
+ for (let i = 0; i < lines.length; i++) {
411
+ if (lines[i].match(/curl.*-[oOL]|wget\s+/) && !lines[i].match(/sha256|sha512|checksum|gpg.*verify/)) {
412
+ const ctx = lines.slice(i, Math.min(i + 5, lines.length)).join('\n');
413
+ if (!ctx.match(/sha256|sha512|md5sum|gpg/)) {
414
+ findings.push({ ruleId: 'SC-BUILD-002', category: 'security', severity: 'high',
415
+ title: 'File downloaded in build script without checksum verification',
416
+ description: 'Verify SHA256 checksums for all downloaded tools and binaries in build scripts to prevent tampered downloads.',
417
+ file: fp, line: i + 1, fix: null });
418
+ }
419
+ }
420
+ }
421
+ }
422
+ return findings;
423
+ },
424
+ },
425
+
426
+ // SC-BUILD-003: Build artifact not signed
427
+ { id: 'SC-BUILD-003', category: 'security', severity: 'medium', title: 'Build Artifacts Not Signed', confidence: 'likely',
428
+ check({ files }) {
429
+ const findings = [];
430
+ for (const [fp, c] of files) {
431
+ if (!fp.includes('.github/workflows')) continue;
432
+ if (c.match(/npm publish|docker.*push|release.*assets/i)) {
433
+ if (!c.match(/sigstore|cosign|gpg.*sign|slsa|attest|provenance/i)) {
434
+ findings.push({ ruleId: 'SC-BUILD-003', category: 'security', severity: 'medium',
435
+ title: 'Published artifacts not signed or attested',
436
+ description: 'Sign published npm packages or container images with Sigstore/Cosign to provide verifiable provenance. SLSA Level 2+ requires signed provenance.',
437
+ file: fp, fix: null });
438
+ }
439
+ }
440
+ }
441
+ return findings;
442
+ },
443
+ },
444
+
445
+ // ─── CONTAINER SUPPLY CHAIN ────────────────────────────────────────────────
446
+
447
+ // SC-CONT-001: Base image from Docker Hub without digest
448
+ { id: 'SC-CONT-001', category: 'security', severity: 'high', title: 'Docker Base Image Not Pinned to Digest', confidence: 'likely',
449
+ check({ files }) {
450
+ const findings = [];
451
+ for (const [fp, c] of files) {
452
+ if (!fp.endsWith('Dockerfile') && !fp.match(/Dockerfile\.\w+$/)) continue;
453
+ const lines = c.split('\n');
454
+ for (let i = 0; i < lines.length; i++) {
455
+ if (lines[i].match(/^\s*FROM\s+/) && !lines[i].match(/@sha256:/) && !lines[i].match(/scratch/)) {
456
+ findings.push({ ruleId: 'SC-CONT-001', category: 'security', severity: 'high',
457
+ title: 'Docker base image not pinned to SHA256 digest — tag can be replaced',
458
+ description: 'Pin base images to their digest: FROM node:20@sha256:abc123... Tags are mutable and can be silently replaced with malicious images.',
459
+ file: fp, line: i + 1, fix: null });
460
+ }
461
+ }
462
+ }
463
+ return findings;
464
+ },
465
+ },
466
+
467
+ // SC-CONT-002: No image vulnerability scanning
468
+ { id: 'SC-CONT-002', category: 'security', severity: 'high', title: 'Container Images Not Scanned for Vulnerabilities', confidence: 'likely',
469
+ check({ files }) {
470
+ const findings = [];
471
+ for (const [fp, c] of files) {
472
+ if (!fp.includes('.github/workflows') && !fp.includes('.gitlab-ci')) continue;
473
+ if (c.match(/docker.*build|build.*docker/i)) {
474
+ if (!c.match(/trivy|grype|snyk.*container|docker.*scan|anchore|clair|scout/i)) {
475
+ findings.push({ ruleId: 'SC-CONT-002', category: 'security', severity: 'high',
476
+ title: 'Container image built without vulnerability scanning',
477
+ description: 'Add Trivy or Grype to scan images in CI. Container vulnerabilities (Log4Shell in base images, etc.) are a major attack surface.',
478
+ file: fp, fix: null });
479
+ }
480
+ }
481
+ }
482
+ return findings;
483
+ },
484
+ },
485
+
486
+ // SC-CONT-003: No image signing
487
+ { id: 'SC-CONT-003', category: 'security', severity: 'medium', title: 'Container Images Not Signed', confidence: 'likely',
488
+ check({ files }) {
489
+ const findings = [];
490
+ for (const [fp, c] of files) {
491
+ if (!fp.includes('.github/workflows')) continue;
492
+ if (c.match(/docker.*push|ecr.*push/i) && !c.match(/cosign|notary|sigstore|docker.*trust/i)) {
493
+ findings.push({ ruleId: 'SC-CONT-003', category: 'security', severity: 'medium',
494
+ title: 'Container images pushed without signing (Cosign/Notary)',
495
+ description: 'Sign container images with Cosign so consumers can verify they were built by your CI and not tampered with in the registry.',
496
+ file: fp, fix: null });
497
+ }
498
+ }
499
+ return findings;
500
+ },
501
+ },
502
+
503
+ // SC-CONT-004: Using Docker Hub for private images
504
+ { id: 'SC-CONT-004', category: 'security', severity: 'medium', title: 'Using Docker Hub for Private Images', confidence: 'likely',
505
+ check({ files }) {
506
+ const findings = [];
507
+ for (const [fp, c] of files) {
508
+ if (!fp.endsWith('Dockerfile') && !fp.match(/Dockerfile\.\w+$/) && !fp.match(/\.(yaml|yml)$/)) continue;
509
+ if (c.match(/^\s*FROM\s+(?!node:|python:|alpine:|ubuntu:|debian:|scratch)[\w]+\/[\w]+/m)) {
510
+ if (!c.match(/ecr\.|gcr\.io|ghcr\.io|azurecr\.io|registry\./)) {
511
+ findings.push({ ruleId: 'SC-CONT-004', category: 'security', severity: 'medium',
512
+ title: 'Image pulled from Docker Hub — consider using a private or mirrored registry',
513
+ description: 'Docker Hub images can be deleted, tampered with, or subject to rate limiting. Use ECR, GHCR, or a private registry for production workloads.',
514
+ file: fp, fix: null });
515
+ }
516
+ }
517
+ }
518
+ return findings;
519
+ },
520
+ },
521
+
522
+ // ─── GIT SECURITY ──────────────────────────────────────────────────────────
523
+
524
+ // SC-GIT-001: Git submodules from untrusted sources
525
+ { id: 'SC-GIT-001', category: 'security', severity: 'high', title: 'Git Submodules from Untrusted Sources', confidence: 'likely',
526
+ check({ files }) {
527
+ const findings = [];
528
+ const gitmodules = files.get('.gitmodules');
529
+ if (!gitmodules) return findings;
530
+ const lines = gitmodules.split('\n');
531
+ for (let i = 0; i < lines.length; i++) {
532
+ if (lines[i].match(/url\s*=/) && !lines[i].match(/github\.com\/(?:your-org)|gitlab\.com\/(?:your-org)/)) {
533
+ if (lines[i].match(/https?:\/\/|git@/)) {
534
+ findings.push({ ruleId: 'SC-GIT-001', category: 'security', severity: 'high',
535
+ title: 'Git submodule pointing to external repository',
536
+ description: 'Submodules from external repos can change without your knowledge. Pin submodules to specific commits and audit the submodule code.',
537
+ file: '.gitmodules', line: i + 1, fix: null });
538
+ }
539
+ }
540
+ }
541
+ return findings;
542
+ },
543
+ },
544
+
545
+ // SC-GIT-002: Git hooks in repository (can execute on clone)
546
+ { id: 'SC-GIT-002', category: 'security', severity: 'medium', title: 'Git Hooks Committed to Repository', confidence: 'likely',
547
+ check({ files }) {
548
+ const findings = [];
549
+ for (const [fp] of files) {
550
+ if (fp.includes('.git/hooks/') && !fp.includes('.git/hooks/.')) {
551
+ const hookName = fp.split('/').pop();
552
+ if (!hookName?.startsWith('.')) {
553
+ findings.push({ ruleId: 'SC-GIT-002', category: 'security', severity: 'medium',
554
+ title: `Git hook "${hookName}" committed to repository — auditors cannot easily inspect`,
555
+ description: 'Git hooks committed to .git/hooks execute automatically. Prefer husky/lefthook for hooks that are versioned, visible, and auditable.',
556
+ file: fp, fix: null });
557
+ }
558
+ }
559
+ }
560
+ return findings;
561
+ },
562
+ },
563
+
564
+ // SC-GIT-003: No branch protection documented
565
+ { id: 'SC-GIT-003', category: 'security', severity: 'medium', title: 'No Branch Protection Rules', confidence: 'likely',
566
+ check({ files }) {
567
+ const has = [...files.values()].some(c => c.match(/branch.*protection|protected.*branch|require.*review|status.*check.*required/i)) ||
568
+ [...files.keys()].some(f => f.match(/branch-protection|repo-settings/i));
569
+ if (!has) {
570
+ return [{ ruleId: 'SC-GIT-003', category: 'security', severity: 'medium',
571
+ title: 'No branch protection rules documented',
572
+ description: 'Enable branch protection on main/master: require PR reviews, require status checks, disallow force push. This prevents accidental or malicious direct pushes.',
573
+ fix: null }];
574
+ }
575
+ return [];
576
+ },
577
+ },
578
+
579
+ // ─── REGISTRY SECURITY ─────────────────────────────────────────────────────
580
+
581
+ // SC-REG-001: npm publish without 2FA
582
+ { id: 'SC-REG-001', category: 'security', severity: 'high', title: 'npm Package Published Without 2FA Requirement', confidence: 'likely',
583
+ check({ files }) {
584
+ const pkg = files.get('package.json');
585
+ if (!pkg) return [];
586
+ try {
587
+ const json = JSON.parse(pkg);
588
+ if (json.publishConfig && !json.publishConfig['2fa'] && !json.private) {
589
+ return [{ ruleId: 'SC-REG-001', category: 'security', severity: 'high',
590
+ title: 'npm package published without 2FA enforcement',
591
+ description: 'Enable 2FA for npm publishing (npm profile enable-2fa auth-and-publish). Account takeover without 2FA allows immediate publication of malicious versions.',
592
+ fix: null }];
593
+ }
594
+ } catch {}
595
+ return [];
596
+ },
597
+ },
598
+
599
+ // SC-REG-002: Package.json missing private field
600
+ { id: 'SC-REG-002', category: 'security', severity: 'high', title: 'Internal Package Missing "private: true"', confidence: 'likely',
601
+ check({ files }) {
602
+ const pkg = files.get('package.json');
603
+ if (!pkg) return [];
604
+ try {
605
+ const json = JSON.parse(pkg);
606
+ if (!json.private && !json.publishConfig && (json.name || '').match(/internal|private|corp/i)) {
607
+ return [{ ruleId: 'SC-REG-002', category: 'security', severity: 'high',
608
+ title: 'Internal package without "private": true — may be accidentally published to npm',
609
+ description: 'Add "private": true to package.json for internal packages to prevent accidental npm publish and potential secret leakage.',
610
+ fix: null }];
611
+ }
612
+ } catch {}
613
+ return [];
614
+ },
615
+ },
616
+ // SC-PKG-009: Known malicious/compromised packages
617
+ { id: 'SC-PKG-009', category: 'security', severity: 'critical', title: 'Known Compromised or Malicious Package in Dependencies', confidence: 'definite',
618
+ check({ files }) {
619
+ const findings = [];
620
+ const pkg = files.get('package.json');
621
+ if (!pkg) return findings;
622
+ // Packages with confirmed supply chain attacks or deliberate sabotage
623
+ const COMPROMISED = {
624
+ 'event-stream': 'Malicious code injected in 2018 targeting bitcoin wallet (flatmap-stream incident)',
625
+ 'node-ipc': 'Deliberate sabotage in 2022 (v10.1.1/10.1.2) that overwrote files on Russian/Belarusian IPs',
626
+ 'colors': 'Deliberate sabotage in 2022 (v1.4.44-liberty-2) that printed infinite garbage output',
627
+ 'faker': 'Deliberate sabotage in 2022 (v6.6.6) by same author as colors',
628
+ 'ua-parser-js': 'Hijacked in 2021 (v0.7.29/0.8.0/1.0.0) to install crypto-miners and trojans',
629
+ 'coa': 'Hijacked in 2021 (v2.0.3/2.0.4/3.1.3/3.1.4) same campaign as ua-parser-js',
630
+ 'rc': 'Hijacked in 2021 (v1.2.9) same campaign as ua-parser-js',
631
+ 'node-netmask': 'SSRF/RCE vulnerability used as supply chain vector (CVE-2021-28918)',
632
+ 'crossenv': 'Typosquatting attack against cross-env (2017) — installs coin miner',
633
+ 'cross-env.js': 'Typosquatting attack against cross-env',
634
+ 'd3.js': 'Typosquatting attack against d3',
635
+ 'jquery.js': 'Typosquatting attack against jquery',
636
+ 'socket.io.js': 'Typosquatting attack against socket.io',
637
+ 'lodash.js': 'Typosquatting attack against lodash',
638
+ };
639
+ try {
640
+ const json = JSON.parse(pkg);
641
+ const allDeps = { ...json.dependencies, ...json.devDependencies, ...json.peerDependencies };
642
+ for (const [name] of Object.entries(allDeps)) {
643
+ if (COMPROMISED[name]) {
644
+ findings.push({ ruleId: 'SC-PKG-009', category: 'security', severity: 'critical',
645
+ title: `Compromised package "${name}" in dependencies`,
646
+ description: COMPROMISED[name] + '. Remove or replace this package immediately.',
647
+ file: 'package.json', fix: null });
648
+ }
649
+ }
650
+ } catch {}
651
+ return findings;
652
+ },
653
+ },
654
+
655
+ // SC-PKG-010: .npmrc with hardcoded auth token
656
+ { id: 'SC-PKG-010', category: 'security', severity: 'critical', title: 'Hardcoded npm Auth Token in .npmrc', confidence: 'definite',
657
+ check({ files }) {
658
+ const findings = [];
659
+ for (const [fp, c] of files) {
660
+ if (!fp.endsWith('.npmrc')) continue;
661
+ const lines = c.split('\n');
662
+ for (let i = 0; i < lines.length; i++) {
663
+ // Match _authToken=<literal value> (not env var reference)
664
+ if (lines[i].match(/_authToken\s*=\s*(?!\$\{)[^\s$]/) || lines[i].match(/\/\/.*:_auth\s*=\s*(?!\$\{)[^\s$]/)) {
665
+ findings.push({ ruleId: 'SC-PKG-010', category: 'security', severity: 'critical',
666
+ title: 'Hardcoded npm auth token in .npmrc',
667
+ description: 'Use environment variable reference instead: //registry.npmjs.org/:_authToken=${NPM_TOKEN}. Committed tokens grant publish access to your packages.',
668
+ file: fp, line: i + 1, fix: null });
669
+ }
670
+ }
671
+ }
672
+ return findings;
673
+ },
674
+ },
675
+
676
+ // SC-CI-009: ACTIONS_ALLOW_UNSECURE_COMMANDS enables legacy injection vector
677
+ { id: 'SC-CI-009', category: 'security', severity: 'critical', title: 'ACTIONS_ALLOW_UNSECURE_COMMANDS Enables Legacy Command Injection', confidence: 'definite',
678
+ check({ files }) {
679
+ const findings = [];
680
+ for (const [fp, c] of files) {
681
+ if (!fp.match(/\.github\/workflows\/.*\.ya?ml$/)) continue;
682
+ if (c.match(/ACTIONS_ALLOW_UNSECURE_COMMANDS\s*:\s*true/)) {
683
+ findings.push({ ruleId: 'SC-CI-009', category: 'security', severity: 'critical',
684
+ title: 'ACTIONS_ALLOW_UNSECURE_COMMANDS=true re-enables deprecated injection vector',
685
+ description: 'The set-env and add-path workflow commands were disabled in Nov 2020 due to environment injection vulnerabilities. Setting ACTIONS_ALLOW_UNSECURE_COMMANDS=true re-enables them, allowing any step to poison the environment for subsequent steps.',
686
+ file: fp, fix: null });
687
+ }
688
+ }
689
+ return findings;
690
+ },
691
+ },
692
+
693
+ // SC-BUILD-004: Build artifact uploaded to untrusted registry
694
+ { id: 'SC-BUILD-004', category: 'security', severity: 'high', title: 'Docker Image Pushed to Unauthenticated or Public Registry', confidence: 'likely',
695
+ check({ files }) {
696
+ const findings = [];
697
+ for (const [fp, c] of files) {
698
+ if (!fp.match(/\.github\/workflows\/.*\.ya?ml$/)) continue;
699
+ const lines = c.split('\n');
700
+ for (let i = 0; i < lines.length; i++) {
701
+ // docker push to docker.io without explicit auth step
702
+ if (lines[i].match(/docker\s+push\s+[^\s]+/) && !lines[i].match(/\$\{\{/)) {
703
+ // Check there's a docker login in the surrounding workflow
704
+ if (!c.match(/docker\s+login|docker\/login-action/)) {
705
+ findings.push({ ruleId: 'SC-BUILD-004', category: 'security', severity: 'high',
706
+ title: 'Docker push without explicit registry authentication step',
707
+ description: 'Authenticate to the registry before pushing (docker/login-action or docker login with secrets). Unauthenticated pushes may silently succeed with ambient credentials or fail insecurely.',
708
+ file: fp, line: i + 1, fix: null });
709
+ }
710
+ }
711
+ }
712
+ }
713
+ return findings;
714
+ },
715
+ },
716
+
717
+ // SC-SRC-004: .env file accidentally committed
718
+ { id: 'SC-SRC-004', category: 'security', severity: 'critical', title: '.env File Committed to Repository', confidence: 'definite',
719
+ check({ files }) {
720
+ const findings = [];
721
+ for (const [fp] of files) {
722
+ if (fp.match(/(^|\/)\.(env|env\.(local|dev|prod|staging|test|development|production))$/) && !fp.includes('node_modules')) {
723
+ findings.push({ ruleId: 'SC-SRC-004', category: 'security', severity: 'critical',
724
+ title: `.env file committed to repository: ${fp}`,
725
+ description: '.env files often contain API keys, database passwords, and tokens. Add .env to .gitignore and rotate any secrets present. Use a secrets manager or CI environment variables instead.',
726
+ file: fp, fix: null });
727
+ }
728
+ }
729
+ return findings;
730
+ },
731
+ },
732
+ ];
733
+
734
+ export default rules;