patchdrill 0.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 (169) hide show
  1. package/.patchdrill.yml +33 -0
  2. package/CHANGELOG.md +150 -0
  3. package/CONTRIBUTING.md +59 -0
  4. package/LICENSE +21 -0
  5. package/README.md +601 -0
  6. package/SECURITY.md +28 -0
  7. package/action.yml +338 -0
  8. package/dist/baseline.d.ts +9 -0
  9. package/dist/baseline.js +38 -0
  10. package/dist/baseline.js.map +1 -0
  11. package/dist/cli.d.ts +19 -0
  12. package/dist/cli.js +662 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/codeowners.d.ts +14 -0
  15. package/dist/codeowners.js +104 -0
  16. package/dist/codeowners.js.map +1 -0
  17. package/dist/command-plan.d.ts +3 -0
  18. package/dist/command-plan.js +26 -0
  19. package/dist/command-plan.js.map +1 -0
  20. package/dist/demo.d.ts +5 -0
  21. package/dist/demo.js +525 -0
  22. package/dist/demo.js.map +1 -0
  23. package/dist/dependency.d.ts +4 -0
  24. package/dist/dependency.js +1424 -0
  25. package/dist/dependency.js.map +1 -0
  26. package/dist/doctor.d.ts +26 -0
  27. package/dist/doctor.js +183 -0
  28. package/dist/doctor.js.map +1 -0
  29. package/dist/evidence.d.ts +64 -0
  30. package/dist/evidence.js +352 -0
  31. package/dist/evidence.js.map +1 -0
  32. package/dist/git.d.ts +16 -0
  33. package/dist/git.js +349 -0
  34. package/dist/git.js.map +1 -0
  35. package/dist/i18n-catalog.d.ts +8 -0
  36. package/dist/i18n-catalog.js +446 -0
  37. package/dist/i18n-catalog.js.map +1 -0
  38. package/dist/i18n.d.ts +20 -0
  39. package/dist/i18n.js +67 -0
  40. package/dist/i18n.js.map +1 -0
  41. package/dist/init.d.ts +13 -0
  42. package/dist/init.js +312 -0
  43. package/dist/init.js.map +1 -0
  44. package/dist/markdown-links.d.ts +18 -0
  45. package/dist/markdown-links.js +180 -0
  46. package/dist/markdown-links.js.map +1 -0
  47. package/dist/package-scripts.d.ts +3 -0
  48. package/dist/package-scripts.js +55 -0
  49. package/dist/package-scripts.js.map +1 -0
  50. package/dist/planner.d.ts +8 -0
  51. package/dist/planner.js +2351 -0
  52. package/dist/planner.js.map +1 -0
  53. package/dist/policy.d.ts +12 -0
  54. package/dist/policy.js +255 -0
  55. package/dist/policy.js.map +1 -0
  56. package/dist/project.d.ts +2 -0
  57. package/dist/project.js +1085 -0
  58. package/dist/project.js.map +1 -0
  59. package/dist/release-readiness.d.ts +25 -0
  60. package/dist/release-readiness.js +426 -0
  61. package/dist/release-readiness.js.map +1 -0
  62. package/dist/report-annotations.d.ts +3 -0
  63. package/dist/report-annotations.js +28 -0
  64. package/dist/report-annotations.js.map +1 -0
  65. package/dist/report-contract.d.ts +2 -0
  66. package/dist/report-contract.js +82 -0
  67. package/dist/report-contract.js.map +1 -0
  68. package/dist/report-html.d.ts +7 -0
  69. package/dist/report-html.js +706 -0
  70. package/dist/report-html.js.map +1 -0
  71. package/dist/report-sarif.d.ts +2 -0
  72. package/dist/report-sarif.js +90 -0
  73. package/dist/report-sarif.js.map +1 -0
  74. package/dist/report.d.ts +14 -0
  75. package/dist/report.js +310 -0
  76. package/dist/report.js.map +1 -0
  77. package/dist/risk.d.ts +19 -0
  78. package/dist/risk.js +1226 -0
  79. package/dist/risk.js.map +1 -0
  80. package/dist/runner.d.ts +8 -0
  81. package/dist/runner.js +113 -0
  82. package/dist/runner.js.map +1 -0
  83. package/dist/scan.d.ts +2 -0
  84. package/dist/scan.js +195 -0
  85. package/dist/scan.js.map +1 -0
  86. package/dist/schema.d.ts +12 -0
  87. package/dist/schema.js +30 -0
  88. package/dist/schema.js.map +1 -0
  89. package/dist/stack-coverage.d.ts +8 -0
  90. package/dist/stack-coverage.js +94 -0
  91. package/dist/stack-coverage.js.map +1 -0
  92. package/dist/types.d.ts +206 -0
  93. package/dist/types.js +2 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/verification.d.ts +11 -0
  96. package/dist/verification.js +108 -0
  97. package/dist/verification.js.map +1 -0
  98. package/docs/ANNOTATIONS.md +34 -0
  99. package/docs/ARCHITECTURE.md +79 -0
  100. package/docs/BASELINES.md +32 -0
  101. package/docs/CASE_STUDIES.md +106 -0
  102. package/docs/CODEOWNERS.md +23 -0
  103. package/docs/DASHBOARD.md +87 -0
  104. package/docs/EVIDENCE.md +55 -0
  105. package/docs/LAUNCH_PLAYBOOK.md +103 -0
  106. package/docs/MONOREPOS.md +74 -0
  107. package/docs/POLICY.md +98 -0
  108. package/docs/PROOF_PACKS.md +57 -0
  109. package/docs/PR_COMMENTS.md +56 -0
  110. package/docs/RELEASE.md +35 -0
  111. package/docs/ROADMAP.md +152 -0
  112. package/docs/RULE_CATALOG.md +90 -0
  113. package/docs/SARIF.md +74 -0
  114. package/docs/SCHEMAS.md +49 -0
  115. package/docs/SECURITY_POSTURE.md +32 -0
  116. package/docs/STACK_COVERAGE.md +20 -0
  117. package/docs/assets/patchdrill-demo.svg +21 -0
  118. package/docs/media/patchdrill-dashboard.png +0 -0
  119. package/docs/media/patchdrill-demo.gif +0 -0
  120. package/examples/case-studies/README.md +20 -0
  121. package/examples/demo/README.md +21 -0
  122. package/examples/demo/patchdrill-demo-summary.md +35 -0
  123. package/examples/demo/patchdrill-demo.html +623 -0
  124. package/examples/demo/patchdrill-demo.json +355 -0
  125. package/examples/demo/patchdrill-demo.md +120 -0
  126. package/examples/demo/patchdrill-demo.sarif +195 -0
  127. package/examples/report.md +128 -0
  128. package/examples/risky-agent-pr/README.md +15 -0
  129. package/examples/risky-agent-pr/patchdrill-demo-summary.md +41 -0
  130. package/examples/risky-agent-pr/patchdrill-demo.html +681 -0
  131. package/examples/risky-agent-pr/patchdrill-demo.json +483 -0
  132. package/examples/risky-agent-pr/patchdrill-demo.md +140 -0
  133. package/examples/risky-agent-pr/patchdrill-demo.sarif +398 -0
  134. package/fixtures/stacks/README.md +4 -0
  135. package/fixtures/stacks/android-gradle/fixture.json +33 -0
  136. package/fixtures/stacks/aspnet-core-service/fixture.json +36 -0
  137. package/fixtures/stacks/bazel-workspace/fixture.json +30 -0
  138. package/fixtures/stacks/buck2-workspace/fixture.json +30 -0
  139. package/fixtures/stacks/cargo-workspace/fixture.json +48 -0
  140. package/fixtures/stacks/django-app/fixture.json +25 -0
  141. package/fixtures/stacks/docker-compose/fixture.json +17 -0
  142. package/fixtures/stacks/dockerfile-service/fixture.json +17 -0
  143. package/fixtures/stacks/dotnet-service/fixture.json +36 -0
  144. package/fixtures/stacks/dotnet-solution-filter/fixture.json +62 -0
  145. package/fixtures/stacks/fastapi-app/fixture.json +29 -0
  146. package/fixtures/stacks/go-workspace/fixture.json +48 -0
  147. package/fixtures/stacks/java-gradle/fixture.json +29 -0
  148. package/fixtures/stacks/java-maven/fixture.json +32 -0
  149. package/fixtures/stacks/kubernetes-helm/fixture.json +25 -0
  150. package/fixtures/stacks/kubernetes-kustomize/fixture.json +21 -0
  151. package/fixtures/stacks/nested-go-workspace/fixture.json +51 -0
  152. package/fixtures/stacks/nextjs-app/fixture.json +34 -0
  153. package/fixtures/stacks/node-turbo-workspace/fixture.json +39 -0
  154. package/fixtures/stacks/pants-python/fixture.json +33 -0
  155. package/fixtures/stacks/php-composer/fixture.json +31 -0
  156. package/fixtures/stacks/python-service/fixture.json +21 -0
  157. package/fixtures/stacks/rails-app/fixture.json +25 -0
  158. package/fixtures/stacks/spring-boot-gradle/fixture.json +29 -0
  159. package/fixtures/stacks/spring-boot-maven/fixture.json +43 -0
  160. package/fixtures/stacks/swift-package/fixture.json +21 -0
  161. package/fixtures/stacks/terraform-module/fixture.json +17 -0
  162. package/fixtures/stacks/uv-python-service/fixture.json +47 -0
  163. package/fixtures/stacks/xcode-app/fixture.json +72 -0
  164. package/package.json +80 -0
  165. package/schemas/patchdrill-doctor.schema.json +171 -0
  166. package/schemas/patchdrill-evidence.schema.json +239 -0
  167. package/schemas/patchdrill-policy.schema.json +170 -0
  168. package/schemas/patchdrill-release-check.schema.json +78 -0
  169. package/schemas/patchdrill-report.schema.json +647 -0
package/dist/risk.js ADDED
@@ -0,0 +1,1226 @@
1
+ import { matchesPolicyRule } from "./policy.js";
2
+ const SECRET_PATTERNS = [
3
+ /(^|\/)\.env(\.|$)/,
4
+ /(^|\/)(id_rsa|id_dsa|id_ed25519)(\.pub)?$/,
5
+ /(^|\/).*secret.*\.(json|ya?ml|txt)$/i,
6
+ /(^|\/).*credential.*\.(json|ya?ml|txt)$/i
7
+ ];
8
+ const HIGH_IMPACT_PATTERNS = [
9
+ /(^|\/)(auth|authentication|authorization|session|oauth|jwt)(\/|\.|$)/i,
10
+ /(^|\/)(payment|billing|invoice|checkout|stripe)(\/|\.|$)/i,
11
+ /(^|\/)(migration|migrations|schema|prisma)(\/|\.|$)/i,
12
+ /(^|\/)(security|crypto|permission|policy)(\/|\.|$)/i
13
+ ];
14
+ const INFRA_PATTERNS = [
15
+ /(^|\/)\.github\/workflows\//,
16
+ /(^|\/)(Dockerfile|compose\.ya?ml|docker-compose\.ya?ml)$/,
17
+ /\.(tf|tfvars)$/,
18
+ /(^|\/)(k8s|kubernetes|helm|charts)\//
19
+ ];
20
+ const LOCKFILES = [
21
+ "package-lock.json",
22
+ "pnpm-lock.yaml",
23
+ "yarn.lock",
24
+ "bun.lock",
25
+ "bun.lockb",
26
+ "Cargo.lock",
27
+ "go.sum",
28
+ "poetry.lock",
29
+ "uv.lock",
30
+ "Pipfile.lock",
31
+ "Gemfile.lock",
32
+ "composer.lock"
33
+ ];
34
+ const AGENT_CONTROL_FILE_PATTERNS = [
35
+ /(^|\/)(AGENTS|CLAUDE|GEMINI|CURSOR)\.md$/i,
36
+ /(^|\/)\.github\/copilot-instructions\.md$/i,
37
+ /(^|\/)\.cursor\/rules\//i,
38
+ /(^|\/)\.windsurfrules$/i,
39
+ /(^|\/)\.claude\/(commands|settings)\//i
40
+ ];
41
+ const MCP_CONFIG_PATTERNS = [
42
+ /(^|\/)\.mcp\.json$/i,
43
+ /(^|\/)mcp\.json$/i,
44
+ /(^|\/)\.cursor\/mcp\.json$/i,
45
+ /(^|\/)claude_desktop_config\.json$/i
46
+ ];
47
+ const ADDED_SECRET_PATTERNS = [
48
+ {
49
+ ruleId: "secret.private-key",
50
+ title: "Private key material added",
51
+ pattern: /-----BEGIN (RSA|DSA|EC|OPENSSH|PRIVATE) PRIVATE KEY-----/,
52
+ remediation: "Revoke the key, remove it from git history, and replace it with a secret-manager reference."
53
+ },
54
+ {
55
+ ruleId: "secret.aws-access-key",
56
+ title: "AWS access key-looking value added",
57
+ pattern: /\bAKIA[0-9A-Z]{16}\b/,
58
+ remediation: "Revoke the credential and move access through workload identity or a secret manager."
59
+ },
60
+ {
61
+ ruleId: "secret.github-token",
62
+ title: "GitHub token-looking value added",
63
+ pattern: /\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/,
64
+ remediation: "Revoke the token and use GitHub Actions secrets or fine-grained tokens outside the repository."
65
+ },
66
+ {
67
+ ruleId: "secret.openai-key",
68
+ // Modern OpenAI keys use a long base64url body (which can contain "-"/"_").
69
+ // Distinguish them from kebab-case slugs/CSS classes (e.g.
70
+ // "sk-button-primary-large-rounded") by requiring a long body that contains
71
+ // both an uppercase letter and a digit — real keys have both, lowercase
72
+ // hyphenated slugs do not.
73
+ title: "OpenAI API key-looking value added",
74
+ pattern: /\bsk-(proj-)?(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{40,}\b/,
75
+ remediation: "Revoke the key and inject it through runtime secret configuration."
76
+ },
77
+ {
78
+ ruleId: "secret.generic-assignment",
79
+ title: "Secret-looking assignment added",
80
+ pattern: /\b(api[_-]?key|secret|token|password)\b\s*[:=]\s*["'][^"'\s]{12,}["']/i,
81
+ remediation: "Keep only placeholder names in source and load sensitive values from environment or a secret manager."
82
+ }
83
+ ];
84
+ const PROMPT_INJECTION_PATTERNS = [
85
+ /ignore\s+(all\s+)?(previous|prior|above)\s+instructions/i,
86
+ /disregard\s+(all\s+)?(previous|prior|above)\s+instructions/i,
87
+ /reveal\s+(the\s+)?(system\s+prompt|secrets?|api\s+keys?|tokens?)/i,
88
+ /exfiltrate\s+(secrets?|tokens?|credentials?)/i,
89
+ /print\s+(all\s+)?(environment\s+variables|secrets?|tokens?)/i,
90
+ /you\s+are\s+now\s+in\s+developer\s+mode/i
91
+ ];
92
+ const AGENT_TOOL_ABUSE_PATTERNS = [
93
+ // Match any single rm flag cluster containing both recursive and force flags,
94
+ // in either order (-rf, -fr, -Rf, -rfv, ...), targeting a dangerous root.
95
+ /\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(\/|\$HOME|~|\*)/i,
96
+ /\b(curl|wget)\b.+\|\s*(sh|bash)\b/i,
97
+ /\bsudo\s+(rm|chmod|chown|dd|mkfs|shutdown|reboot)\b/i,
98
+ /\bchmod\s+777\b/i,
99
+ /\b(delete|wipe|destroy)\s+(all\s+)?(files|database|cloud\s+resources|system)\b/i
100
+ ];
101
+ const PACKAGE_LIFECYCLE_SCRIPTS = new Set([
102
+ "preinstall",
103
+ "install",
104
+ "postinstall",
105
+ "prepare",
106
+ "prepublish",
107
+ "prepublishOnly",
108
+ "prepack",
109
+ "postpack",
110
+ "publish",
111
+ "postpublish"
112
+ ]);
113
+ const VERIFICATION_SCRIPT_NAMES = new Set([
114
+ "test",
115
+ "test:unit",
116
+ "unit",
117
+ "check",
118
+ "typecheck",
119
+ "check:types",
120
+ "types",
121
+ "lint",
122
+ "build",
123
+ "verify"
124
+ ]);
125
+ const WORKFLOW_ADDED_LINE_RULES = [
126
+ {
127
+ ruleId: "workflow.pull-request-target",
128
+ severity: "high",
129
+ title: "pull_request_target trigger added",
130
+ detail: "A newly added workflow line changes GitHub Actions trust boundaries.",
131
+ matches: (content) => /^\s*pull_request_target\s*:/i.test(content),
132
+ remediation: "Use pull_request unless the workflow is intentionally designed for untrusted fork safety.",
133
+ tags: ["ci", "github-actions", "trust-boundary"]
134
+ },
135
+ {
136
+ ruleId: "workflow.write-all",
137
+ severity: "high",
138
+ title: "Broad GitHub token write permissions added",
139
+ detail: "A newly added workflow line changes GitHub token privilege.",
140
+ matches: (content) => /^\s*permissions\s*:\s*write-all\s*$/i.test(content),
141
+ remediation: "Use least-privilege per-scope permissions instead of write-all.",
142
+ tags: ["ci", "github-actions", "supply-chain"]
143
+ },
144
+ {
145
+ ruleId: "workflow.write-scope",
146
+ severity: "medium",
147
+ title: "GitHub token write scope added",
148
+ detail: "A newly added workflow line grants GitHub token write access.",
149
+ matches: (content) => /^\s*(actions|checks|contents|deployments|id-token|issues|packages|pull-requests|security-events)\s*:\s*write\s*$/i.test(content),
150
+ remediation: "Confirm the workflow needs this exact write permission and cannot use read-only access.",
151
+ tags: ["ci", "github-actions", "supply-chain"]
152
+ },
153
+ {
154
+ ruleId: "workflow.inherited-secrets",
155
+ severity: "high",
156
+ title: "Workflow secret inheritance added",
157
+ detail: "A newly added workflow line expands secret exposure across workflow boundaries.",
158
+ matches: (content) => /^\s*secrets\s*:\s*inherit\s*$/i.test(content),
159
+ remediation: "Avoid inherited secrets in reusable workflows unless trust boundaries and callers are tightly controlled.",
160
+ tags: ["ci", "github-actions", "secrets"]
161
+ },
162
+ {
163
+ ruleId: "workflow.unpinned-action",
164
+ severity: "medium",
165
+ title: "Unpinned GitHub Action reference added",
166
+ detail: "A newly added workflow action uses a mutable or missing ref instead of a full commit SHA.",
167
+ matches: workflowUsesUnpinnedAction,
168
+ remediation: "Pin third-party and reusable actions to reviewed full-length commit SHAs, then update intentionally.",
169
+ tags: ["ci", "github-actions", "supply-chain"]
170
+ },
171
+ {
172
+ ruleId: "workflow.mutable-docker-action",
173
+ severity: "medium",
174
+ title: "Mutable Docker action image added",
175
+ detail: "A newly added workflow action uses a Docker image tag or implicit latest image instead of an immutable digest.",
176
+ matches: workflowUsesMutableDockerAction,
177
+ remediation: "Pin docker:// action images to a reviewed sha256 digest instead of a mutable tag.",
178
+ tags: ["ci", "github-actions", "supply-chain", "docker"]
179
+ },
180
+ {
181
+ ruleId: "workflow.remote-script-pipe",
182
+ severity: "high",
183
+ title: "Remote script pipe added to workflow",
184
+ detail: "A newly added workflow line pipes a remote download directly into an interpreter.",
185
+ matches: (content) => /\b(curl|wget)\b.+\|\s*(sudo\s+)?(sh|bash|zsh|python|node)\b/i.test(content),
186
+ remediation: "Download artifacts with checksum verification or use a pinned, reviewed action instead of piping remote code to a shell.",
187
+ tags: ["ci", "github-actions", "supply-chain"]
188
+ },
189
+ {
190
+ ruleId: "workflow.untrusted-pr-context",
191
+ severity: "high",
192
+ title: "Untrusted pull request context added to workflow",
193
+ detail: "A newly added workflow line interpolates attacker-controlled pull request metadata.",
194
+ matches: (content) => /\${{\s*github\.event\.pull_request\.(title|body|head\.ref|head\.label|head\.repo\.full_name)\s*}}/i.test(content),
195
+ remediation: "Pass untrusted PR metadata through environment variables and quote it carefully, or avoid using it in shell commands.",
196
+ tags: ["ci", "github-actions", "injection"]
197
+ }
198
+ ];
199
+ const DEPENDENCY_PROOF_RULES = [
200
+ {
201
+ ecosystem: "Node",
202
+ manifest: (path) => baseName(path) === "package.json",
203
+ lockfile: (path) => ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"].includes(baseName(path)),
204
+ expectedLockfiles: "package-lock.json, pnpm-lock.yaml, yarn.lock, or bun.lock"
205
+ },
206
+ {
207
+ ecosystem: "Python",
208
+ manifest: (path) => baseName(path) === "pyproject.toml" || isRequirementsFileName(baseName(path)),
209
+ lockfile: (path) => ["poetry.lock", "uv.lock", "Pipfile.lock"].includes(baseName(path)),
210
+ expectedLockfiles: "poetry.lock, uv.lock, or Pipfile.lock"
211
+ },
212
+ {
213
+ ecosystem: "Rust",
214
+ manifest: (path) => baseName(path) === "Cargo.toml",
215
+ lockfile: (path) => baseName(path) === "Cargo.lock",
216
+ expectedLockfiles: "Cargo.lock"
217
+ },
218
+ {
219
+ ecosystem: "Go",
220
+ manifest: (path) => baseName(path) === "go.mod",
221
+ lockfile: (path) => baseName(path) === "go.sum",
222
+ expectedLockfiles: "go.sum"
223
+ },
224
+ {
225
+ ecosystem: "Ruby",
226
+ manifest: (path) => baseName(path) === "Gemfile",
227
+ lockfile: (path) => baseName(path) === "Gemfile.lock",
228
+ expectedLockfiles: "Gemfile.lock"
229
+ },
230
+ {
231
+ ecosystem: "PHP",
232
+ manifest: (path) => baseName(path) === "composer.json",
233
+ lockfile: (path) => baseName(path) === "composer.lock",
234
+ expectedLockfiles: "composer.lock"
235
+ }
236
+ ];
237
+ const severityWeights = {
238
+ info: 1,
239
+ low: 4,
240
+ medium: 10,
241
+ high: 18,
242
+ critical: 35
243
+ };
244
+ class RiskAccumulator {
245
+ score = 0;
246
+ values = [];
247
+ seen = new Set();
248
+ // Deduplicate on add so the score only ever counts findings the report
249
+ // actually displays — the EXPLAINABLE promise requires the score to be
250
+ // reconstructable from the visible findings.
251
+ add(weight, finding) {
252
+ const key = findingKey(finding);
253
+ if (this.seen.has(key))
254
+ return;
255
+ this.seen.add(key);
256
+ this.score += weight;
257
+ this.values.push(finding);
258
+ }
259
+ addWeighted(finding) {
260
+ this.add(severityWeights[finding.severity], finding);
261
+ }
262
+ get risk() {
263
+ return this.score;
264
+ }
265
+ get findings() {
266
+ return this.values;
267
+ }
268
+ }
269
+ export function assessRisk(changedFiles, commandResults, options = {}) {
270
+ const accumulator = new RiskAccumulator();
271
+ if (changedFiles.length > 0) {
272
+ accumulator.add(10, {
273
+ ruleId: "patch.changed-files",
274
+ severity: "info",
275
+ title: "Patch changes repository files",
276
+ detail: `${changedFiles.length} changed file${changedFiles.length === 1 ? "" : "s"} require review and verification evidence.`,
277
+ remediation: "Review the changed files and run the inferred verification plan before merge.",
278
+ tags: ["review"]
279
+ });
280
+ }
281
+ const totalAdditions = changedFiles.reduce((sum, file) => sum + file.additions, 0);
282
+ const totalDeletions = changedFiles.reduce((sum, file) => sum + file.deletions, 0);
283
+ const changedSourceFiles = changedFiles.filter((file) => isSourceFile(file.path) && !isTestFile(file.path) && !isDeclarationFile(file.path));
284
+ const changedTestPaths = new Set(changedFiles.filter((file) => isTestFile(file.path)).map((file) => file.path));
285
+ for (const file of changedFiles) {
286
+ if (SECRET_PATTERNS.some((pattern) => pattern.test(file.path))) {
287
+ accumulator.add(40, {
288
+ ruleId: "file.secret-bearing",
289
+ severity: "critical",
290
+ title: "Possible secret-bearing file changed",
291
+ detail: "Files that commonly hold credentials should not be committed without explicit review.",
292
+ file: file.path,
293
+ remediation: "Move secrets to a secret manager and keep only templates or documented variable names in git.",
294
+ tags: ["security", "secrets"]
295
+ });
296
+ }
297
+ if (!isDocumentationFile(file.path) && !isTestFile(file.path) && HIGH_IMPACT_PATTERNS.some((pattern) => pattern.test(file.path))) {
298
+ accumulator.add(18, {
299
+ ruleId: "file.high-impact-area",
300
+ severity: "high",
301
+ title: "High-impact product area changed",
302
+ detail: "Authentication, billing, migrations, or security changes need stronger regression proof.",
303
+ file: file.path,
304
+ remediation: "Add targeted tests and include manual verification notes in the PR.",
305
+ tags: ["review", "regression"]
306
+ });
307
+ }
308
+ if (isAgentControlFile(file.path)) {
309
+ accumulator.add(18, {
310
+ ruleId: "agent.control-file",
311
+ severity: "high",
312
+ title: "Agent instruction surface changed",
313
+ detail: "Files consumed by AI coding agents can alter goals, tool choices, review behavior, or memory-like context.",
314
+ file: file.path,
315
+ remediation: "Review this file as executable agent policy. Keep untrusted examples and external content out of agent instruction surfaces.",
316
+ tags: ["ai-safety", "agentic-ai", "owasp:ASI01", "owasp:ASI09"]
317
+ });
318
+ }
319
+ if (isMcpConfigFile(file.path)) {
320
+ accumulator.add(30, {
321
+ ruleId: "agent.mcp-config",
322
+ severity: "critical",
323
+ title: "MCP or agent tool configuration changed",
324
+ detail: "MCP and agent tool configs can grant local tools, credentials, or network access to autonomous agents.",
325
+ file: file.path,
326
+ remediation: "Require owner review for tool allowlists, command arguments, environment variables, and credential sources.",
327
+ tags: ["ai-safety", "mcp", "owasp:ASI02", "owasp:ASI03", "owasp:ASI04"]
328
+ });
329
+ }
330
+ if (INFRA_PATTERNS.some((pattern) => pattern.test(file.path))) {
331
+ accumulator.add(14, {
332
+ ruleId: "file.infrastructure",
333
+ severity: "medium",
334
+ title: "Infrastructure or CI behavior changed",
335
+ detail: "Build, deployment, or workflow changes can alter release safety outside application tests.",
336
+ file: file.path,
337
+ remediation: "Review permissions, environment access, rollback behavior, and deployment triggers.",
338
+ tags: ["ci", "deployment"]
339
+ });
340
+ }
341
+ if (LOCKFILES.some((lockfile) => file.path.endsWith(lockfile))) {
342
+ accumulator.add(12, {
343
+ ruleId: "file.lockfile",
344
+ severity: "medium",
345
+ title: "Dependency lockfile changed",
346
+ detail: "Dependency graph changes can introduce supply-chain, licensing, or runtime regressions.",
347
+ file: file.path,
348
+ remediation: "Review direct and transitive dependency changes before merge.",
349
+ tags: ["dependencies", "supply-chain"]
350
+ });
351
+ }
352
+ if (file.binary && isBinaryBunLockfile(file.path)) {
353
+ accumulator.add(14, {
354
+ ruleId: "file.bun-lockb",
355
+ severity: "medium",
356
+ title: "Binary Bun lockfile changed",
357
+ detail: "Legacy bun.lockb files are binary, so package-level dependency changes cannot be summarized from a normal diff.",
358
+ file: file.path,
359
+ remediation: "Prefer the text bun.lock format. Migrate with `bun install --save-text-lockfile --frozen-lockfile --lockfile-only`, verify the result, then remove bun.lockb.",
360
+ tags: ["dependencies", "supply-chain", "bun"]
361
+ });
362
+ }
363
+ if (isDependencyManifest(file.path)) {
364
+ accumulator.add(12, {
365
+ ruleId: "file.dependency-manifest",
366
+ severity: "medium",
367
+ title: "Dependency manifest changed",
368
+ detail: "Dependency manifest changes can introduce supply-chain, licensing, or runtime regressions.",
369
+ file: file.path,
370
+ remediation: "Review direct dependency intent and ensure the lockfile or environment was updated consistently.",
371
+ tags: ["dependencies", "supply-chain"]
372
+ });
373
+ }
374
+ if (file.status === "deleted") {
375
+ accumulator.add(8, {
376
+ ruleId: "file.deleted",
377
+ severity: "low",
378
+ title: "File deleted",
379
+ detail: "Deleted files can break runtime imports, generated references, or deployment packaging.",
380
+ file: file.path
381
+ });
382
+ }
383
+ if (file.binary && !isBinaryBunLockfile(file.path)) {
384
+ accumulator.add(10, {
385
+ ruleId: "file.binary",
386
+ severity: "medium",
387
+ title: "Binary file changed",
388
+ detail: "Binary changes are difficult to review from a normal code diff.",
389
+ file: file.path,
390
+ remediation: "Verify provenance and expected runtime use of the binary artifact.",
391
+ tags: ["review"]
392
+ });
393
+ }
394
+ }
395
+ for (const line of options.addedLines ?? []) {
396
+ for (const secretPattern of ADDED_SECRET_PATTERNS) {
397
+ if (!secretPattern.pattern.test(line.content))
398
+ continue;
399
+ accumulator.add(45, {
400
+ ruleId: secretPattern.ruleId,
401
+ severity: "critical",
402
+ title: secretPattern.title,
403
+ detail: "A newly added line matches a high-confidence credential pattern. The secret value is intentionally omitted from this report.",
404
+ file: line.file,
405
+ line: line.line,
406
+ remediation: secretPattern.remediation,
407
+ tags: ["security", "secrets"]
408
+ });
409
+ break;
410
+ }
411
+ if (PROMPT_INJECTION_PATTERNS.some((pattern) => pattern.test(line.content))) {
412
+ const agentVisible = isAgentVisibleFile(line.file);
413
+ accumulator.add(agentVisible ? 24 : 12, {
414
+ ruleId: "agent.prompt-injection",
415
+ severity: agentVisible ? "high" : "medium",
416
+ title: "Prompt-injection instruction added",
417
+ detail: agentVisible
418
+ ? "A newly added agent-visible line appears to instruct AI tools to ignore policy or reveal sensitive information."
419
+ : "A newly added line looks like a prompt-injection payload. Review before feeding this diff to an AI agent.",
420
+ file: line.file,
421
+ line: line.line,
422
+ remediation: "Keep untrusted prompt-like content out of agent instruction files and avoid passing it to privileged AI review contexts.",
423
+ tags: ["ai-safety", "prompt-injection", "owasp:ASI01"]
424
+ });
425
+ }
426
+ if ((isAgentVisibleFile(line.file) || isAgentControlFile(line.file)) && AGENT_TOOL_ABUSE_PATTERNS.some((pattern) => pattern.test(line.content))) {
427
+ accumulator.add(22, {
428
+ ruleId: "agent.tool-abuse-instruction",
429
+ severity: "high",
430
+ title: "Agent tool-abuse instruction added",
431
+ detail: "An agent-visible line appears to encourage destructive local commands, privilege changes, or remote shell execution.",
432
+ file: line.file,
433
+ line: line.line,
434
+ remediation: "Move destructive examples behind explicit human-only documentation and keep them out of privileged agent instruction context.",
435
+ tags: ["ai-safety", "tool-misuse", "owasp:ASI02", "owasp:ASI05"]
436
+ });
437
+ }
438
+ if (line.file.startsWith(".github/workflows/")) {
439
+ for (const workflowRule of WORKFLOW_ADDED_LINE_RULES) {
440
+ if (!workflowRule.matches(line.content))
441
+ continue;
442
+ accumulator.addWeighted({
443
+ ruleId: workflowRule.ruleId,
444
+ severity: workflowRule.severity,
445
+ title: workflowRule.title,
446
+ detail: workflowRule.detail,
447
+ file: line.file,
448
+ line: line.line,
449
+ remediation: workflowRule.remediation,
450
+ tags: workflowRule.tags
451
+ });
452
+ }
453
+ }
454
+ }
455
+ for (const finding of workflowContextFindings(options.addedLines ?? [], options.workflowFiles ?? [])) {
456
+ accumulator.addWeighted(finding);
457
+ }
458
+ for (const finding of packageScriptFindings(options.packageScriptChanges ?? [])) {
459
+ accumulator.addWeighted(finding);
460
+ }
461
+ for (const finding of dependencyProofFindings(changedFiles, options.dependencyChanges ?? [])) {
462
+ accumulator.addWeighted(finding);
463
+ }
464
+ for (const finding of missingRequiredVerificationFindings(changedFiles, options.commandPlan ?? [], commandResults)) {
465
+ accumulator.addWeighted(finding);
466
+ }
467
+ for (const rule of options.policy?.rules ?? []) {
468
+ for (const file of changedFiles) {
469
+ if (!matchesPolicyRule(file.path, rule))
470
+ continue;
471
+ accumulator.add(rule.weight ?? severityWeights[rule.severity], {
472
+ ruleId: `policy.${rule.id}`,
473
+ severity: rule.severity,
474
+ title: rule.title,
475
+ detail: rule.detail ?? `Policy rule "${rule.id}" matched this path.`,
476
+ file: file.path,
477
+ ...(rule.remediation ? { remediation: rule.remediation } : {}),
478
+ ...(rule.tags ? { tags: rule.tags } : {})
479
+ });
480
+ }
481
+ }
482
+ if (totalAdditions + totalDeletions > 2000) {
483
+ accumulator.add(24, {
484
+ ruleId: "patch.large",
485
+ severity: "high",
486
+ title: "Large patch",
487
+ detail: `${totalAdditions + totalDeletions} lines changed. Large patches deserve split review or stronger test evidence.`,
488
+ remediation: "Split unrelated changes or attach a clear verification report."
489
+ });
490
+ }
491
+ else if (totalAdditions + totalDeletions > 500) {
492
+ accumulator.add(12, {
493
+ ruleId: "patch.medium",
494
+ severity: "medium",
495
+ title: "Medium-sized patch",
496
+ detail: `${totalAdditions + totalDeletions} lines changed. Review should focus on changed behavior, not only file count.`
497
+ });
498
+ }
499
+ const untestedSourceFiles = changedSourceFiles.filter((file) => !hasMatchingChangedTest(file.path, changedTestPaths));
500
+ if (untestedSourceFiles.length > 0) {
501
+ const examples = untestedSourceFiles.slice(0, 5).map((file) => file.path);
502
+ accumulator.add(16, {
503
+ ruleId: "test.source-without-test-change",
504
+ severity: "medium",
505
+ title: "Source changed without matching test changes",
506
+ detail: `No changed test file matched ${examples.join(", ")}${untestedSourceFiles.length > examples.length ? ", ..." : ""}. Existing suites may still cover this, but the PR should prove it.`,
507
+ remediation: `Add or update nearby tests such as ${suggestedTestPaths(untestedSourceFiles[0]?.path ?? "").slice(0, 3).join(", ")}, or explain why existing tests cover the patch.`
508
+ });
509
+ }
510
+ for (const result of commandResults) {
511
+ if (result.exitCode !== 0) {
512
+ accumulator.add(30, {
513
+ ruleId: "command.failed",
514
+ severity: "critical",
515
+ title: "Verification command failed",
516
+ detail: `"${result.command}" exited with ${result.exitCode}.`,
517
+ remediation: "Fix the failing command before merging."
518
+ });
519
+ }
520
+ }
521
+ const dedupedFindings = accumulator.findings;
522
+ const riskScore = clamp(accumulator.risk, 0, 100);
523
+ const confidenceScore = 100 - riskScore;
524
+ const status = commandResults.some((result) => result.exitCode !== 0)
525
+ ? "fail"
526
+ : riskScore >= 70
527
+ ? "fail"
528
+ : riskScore >= 35
529
+ ? "warn"
530
+ : "pass";
531
+ return { riskScore, confidenceScore, status, findings: dedupedFindings };
532
+ }
533
+ function isSourceFile(path) {
534
+ return /\.(ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|rb|php|cs|fs|swift|scala)$/.test(path);
535
+ }
536
+ function isDeclarationFile(path) {
537
+ return /\.d\.[cm]?ts$/i.test(path);
538
+ }
539
+ function isDocumentationFile(path) {
540
+ return path.startsWith("docs/") || /\.(md|mdx|rst|adoc|txt)$/i.test(path);
541
+ }
542
+ function isAgentVisibleFile(path) {
543
+ return (/(^|\/)(AGENTS|CLAUDE|GEMINI|CURSOR|README|CONTRIBUTING)\.md$/i.test(path) ||
544
+ path.startsWith(".github/ISSUE_TEMPLATE/") ||
545
+ path.startsWith(".github/PULL_REQUEST_TEMPLATE") ||
546
+ /\.(md|mdx|txt)$/i.test(path));
547
+ }
548
+ function isAgentControlFile(path) {
549
+ return AGENT_CONTROL_FILE_PATTERNS.some((pattern) => pattern.test(path));
550
+ }
551
+ function isMcpConfigFile(path) {
552
+ return MCP_CONFIG_PATTERNS.some((pattern) => pattern.test(path));
553
+ }
554
+ function isTestFile(path) {
555
+ return /(^|\/)(__tests__|tests?|spec)\//i.test(path) || /\.(test|spec)\.[a-z0-9]+$/i.test(path);
556
+ }
557
+ function hasMatchingChangedTest(sourcePath, changedTestPaths) {
558
+ const candidates = testCandidates(sourcePath);
559
+ return candidates.some((candidate) => changedTestPaths.has(candidate));
560
+ }
561
+ function testCandidates(sourcePath) {
562
+ const parsed = parsePath(sourcePath);
563
+ if (!parsed)
564
+ return [];
565
+ const testNames = testFileNames(parsed.name, parsed.extension);
566
+ const mirroredDirectories = testMirrorDirectories(parsed.directory);
567
+ const candidates = new Set();
568
+ for (const testName of testNames) {
569
+ candidates.add(joinPath(parsed.directory, testName));
570
+ candidates.add(joinPath(parsed.directory, "__tests__", testName));
571
+ candidates.add(joinPath("tests", parsed.directory, testName));
572
+ candidates.add(joinPath("test", parsed.directory, testName));
573
+ candidates.add(joinPath("spec", parsed.directory, testName));
574
+ candidates.add(joinPath("tests", testName));
575
+ candidates.add(joinPath("test", testName));
576
+ candidates.add(joinPath("spec", testName));
577
+ for (const directory of mirroredDirectories) {
578
+ candidates.add(joinPath(directory, testName));
579
+ candidates.add(joinPath("tests", directory, testName));
580
+ candidates.add(joinPath("test", directory, testName));
581
+ candidates.add(joinPath("spec", directory, testName));
582
+ candidates.add(joinPath("tests", "Unit", directory, testName));
583
+ candidates.add(joinPath("tests", "Feature", directory, testName));
584
+ }
585
+ }
586
+ return [...candidates];
587
+ }
588
+ function testFileNames(name, extension) {
589
+ const names = new Set();
590
+ for (const testExtension of relatedTestExtensions(extension)) {
591
+ names.add(`${name}.test${testExtension}`);
592
+ names.add(`${name}.spec${testExtension}`);
593
+ names.add(`test_${name}${testExtension}`);
594
+ names.add(`${name}_test${testExtension}`);
595
+ names.add(`${name}_spec${testExtension}`);
596
+ names.add(`${name}Test${testExtension}`);
597
+ names.add(`${name}Tests${testExtension}`);
598
+ names.add(`${name}Spec${testExtension}`);
599
+ names.add(`${name}Specs${testExtension}`);
600
+ }
601
+ return [...names];
602
+ }
603
+ function relatedTestExtensions(extension) {
604
+ if (extension === ".tsx")
605
+ return [".tsx", ".ts", ".jsx", ".js"];
606
+ if (extension === ".ts")
607
+ return [".ts", ".tsx", ".js", ".jsx"];
608
+ if (extension === ".jsx")
609
+ return [".jsx", ".js", ".tsx", ".ts"];
610
+ if (extension === ".js" || extension === ".mjs" || extension === ".cjs")
611
+ return [extension, ".js", ".jsx", ".ts", ".tsx"];
612
+ return [extension];
613
+ }
614
+ // Suggest test paths using the convention idiomatic to the source file's
615
+ // language (e.g. foo_test.go, test_foo.py, FooTests.cs) rather than the
616
+ // JavaScript-style foo.test.js for every language.
617
+ function suggestedTestPaths(sourcePath) {
618
+ const parsed = parsePath(sourcePath);
619
+ if (!parsed)
620
+ return [];
621
+ const { name, extension, directory } = parsed;
622
+ // Rust unit tests are inline #[cfg(test)] modules; integration tests live under tests/.
623
+ if (extension === ".rs")
624
+ return [joinPath("tests", `${name}${extension}`)];
625
+ const fileName = idiomaticTestFileName(name, extension);
626
+ // Java/Kotlin/Scala mirror src/main/<lang> -> src/test/<lang>.
627
+ if (extension === ".java" || extension === ".kt" || extension === ".scala") {
628
+ const mirror = directory.replace("/main/", "/test/");
629
+ return mirror !== directory ? [joinPath(mirror, fileName), joinPath(directory, fileName)] : [joinPath(directory, fileName)];
630
+ }
631
+ const testRoot = extension === ".rb" ? "spec" : "tests";
632
+ return [joinPath(directory, fileName), joinPath(testRoot, fileName)];
633
+ }
634
+ function idiomaticTestFileName(name, extension) {
635
+ switch (extension) {
636
+ case ".go":
637
+ return `${name}_test${extension}`;
638
+ case ".py":
639
+ return `test_${name}${extension}`;
640
+ case ".rb":
641
+ return `${name}_spec${extension}`;
642
+ case ".cs":
643
+ case ".fs":
644
+ case ".vb":
645
+ case ".swift":
646
+ return `${name}Tests${extension}`;
647
+ case ".java":
648
+ case ".kt":
649
+ case ".scala":
650
+ case ".php":
651
+ return `${name}Test${extension}`;
652
+ default:
653
+ return `${name}.test${extension}`;
654
+ }
655
+ }
656
+ function testMirrorDirectories(directory) {
657
+ const directories = new Set();
658
+ directories.add(directory);
659
+ for (const root of ["src", "app"]) {
660
+ if (directory.startsWith(`${root}/`)) {
661
+ directories.add(directory.slice(root.length + 1));
662
+ }
663
+ }
664
+ for (const root of ["src/main/java", "src/main/kotlin", "src/main/scala"]) {
665
+ if (directory.startsWith(`${root}/`)) {
666
+ directories.add(directory.replace(root, root.replace("/main/", "/test/")));
667
+ }
668
+ }
669
+ return [...directories];
670
+ }
671
+ function parsePath(path) {
672
+ const slash = path.lastIndexOf("/");
673
+ const directory = slash >= 0 ? path.slice(0, slash) : "";
674
+ const fileName = slash >= 0 ? path.slice(slash + 1) : path;
675
+ const dot = fileName.lastIndexOf(".");
676
+ if (dot <= 0)
677
+ return undefined;
678
+ return {
679
+ directory,
680
+ name: fileName.slice(0, dot),
681
+ extension: fileName.slice(dot)
682
+ };
683
+ }
684
+ function joinPath(...parts) {
685
+ return parts.filter(Boolean).join("/");
686
+ }
687
+ function isDependencyManifest(path) {
688
+ const fileName = baseName(path);
689
+ return (fileName === "pyproject.toml" ||
690
+ fileName === "composer.json" ||
691
+ fileName === "Gemfile" ||
692
+ fileName === "go.mod" ||
693
+ fileName === "Cargo.toml" ||
694
+ fileName === "pom.xml" ||
695
+ fileName === "build.gradle" ||
696
+ fileName === "build.gradle.kts" ||
697
+ fileName === "libs.versions.toml" ||
698
+ isRequirementsFileName(fileName) ||
699
+ /\.(csproj|fsproj|vbproj)$/i.test(fileName) ||
700
+ fileName === "Directory.Packages.props");
701
+ }
702
+ function isBinaryBunLockfile(path) {
703
+ return baseName(path) === "bun.lockb";
704
+ }
705
+ function workflowUsesUnpinnedAction(content) {
706
+ const match = /^\s*(?:-\s*)?uses\s*:\s*['"]?([^'"\s#]+)['"]?\s*(?:#.*)?$/i.exec(content);
707
+ if (!match?.[1])
708
+ return false;
709
+ const action = match[1];
710
+ if (action.startsWith("./") || action.startsWith("docker://"))
711
+ return false;
712
+ const refSeparator = action.lastIndexOf("@");
713
+ if (refSeparator < 0)
714
+ return true;
715
+ const ref = action.slice(refSeparator + 1);
716
+ return !/^[a-f0-9]{40}$/i.test(ref);
717
+ }
718
+ function workflowUsesMutableDockerAction(content) {
719
+ const match = /^\s*(?:-\s*)?uses\s*:\s*['"]?([^'"\s#]+)['"]?\s*(?:#.*)?$/i.exec(content);
720
+ if (!match?.[1])
721
+ return false;
722
+ const action = match[1];
723
+ if (!action.startsWith("docker://"))
724
+ return false;
725
+ const image = action.slice("docker://".length);
726
+ return !/@sha256:[a-f0-9]{64}$/i.test(image);
727
+ }
728
+ function packageScriptFindings(scriptChanges) {
729
+ const findings = [];
730
+ for (const change of scriptChanges) {
731
+ if (change.after && packageScriptPipesRemoteCode(change.after)) {
732
+ findings.push({
733
+ ruleId: "package-script.remote-script-pipe",
734
+ severity: "critical",
735
+ title: `Package script pipes remote code to shell: ${change.scriptName}`,
736
+ detail: `package.json script "${change.scriptName}" ${change.changeType === "added" ? "was added" : "was changed"} and downloads remote code directly into an interpreter.`,
737
+ file: change.file,
738
+ remediation: "Replace remote shell pipes with pinned package dependencies, checksum-verified downloads, or reviewed local scripts.",
739
+ tags: ["dependencies", "supply-chain", "package-script"]
740
+ });
741
+ }
742
+ if (change.after && isLifecyclePackageScript(change.scriptName)) {
743
+ findings.push({
744
+ ruleId: "package-script.lifecycle",
745
+ severity: "high",
746
+ title: `Package lifecycle script changed: ${change.scriptName}`,
747
+ detail: `package.json lifecycle script "${change.scriptName}" ${change.changeType === "added" ? "was added" : "was changed"}, creating code that can run during install, prepare, pack, or publish flows.`,
748
+ file: change.file,
749
+ remediation: "Review the script as executable supply-chain surface. Prefer explicit CI steps or documented commands over implicit install-time behavior.",
750
+ tags: ["dependencies", "supply-chain", "package-script"]
751
+ });
752
+ }
753
+ if (change.after && isVerificationPackageScript(change.scriptName) && isDisabledVerificationCommand(change.after)) {
754
+ findings.push({
755
+ ruleId: "package-script.disabled-verification",
756
+ severity: "high",
757
+ title: `Verification script disabled: ${change.scriptName}`,
758
+ detail: `package.json verification script "${change.scriptName}" now appears to exit successfully without running meaningful checks.`,
759
+ file: change.file,
760
+ remediation: "Restore the real verification command or explain why this repository no longer has that check.",
761
+ tags: ["testing", "ci", "package-script"]
762
+ });
763
+ }
764
+ if (change.changeType === "removed" && isVerificationPackageScript(change.scriptName)) {
765
+ findings.push({
766
+ ruleId: "package-script.removed-verification",
767
+ severity: "medium",
768
+ title: `Verification script removed: ${change.scriptName}`,
769
+ detail: `package.json verification script "${change.scriptName}" was removed, reducing the commands reviewers and CI can run by convention.`,
770
+ file: change.file,
771
+ remediation: "Replace the removed script with an equivalent check or update PatchDrill policy with the new required command.",
772
+ tags: ["testing", "ci", "package-script"]
773
+ });
774
+ }
775
+ }
776
+ return dedupeFindings(findings);
777
+ }
778
+ function dependencyProofFindings(changedFiles, dependencyChanges) {
779
+ const findings = [];
780
+ for (const rule of DEPENDENCY_PROOF_RULES) {
781
+ const manifestChanges = dependencyChanges.filter((change) => change.dependencyType !== "lockfile" && rule.manifest(change.file));
782
+ const lockfileChanges = dependencyChanges.filter((change) => change.dependencyType === "lockfile" && rule.lockfile(change.file));
783
+ const lockfileFileChanged = changedFiles.some((file) => rule.lockfile(file.path));
784
+ if (manifestChanges.length > 0 && !lockfileFileChanged) {
785
+ for (const [file, changes] of changesByFile(manifestChanges)) {
786
+ findings.push({
787
+ ruleId: "dependency.manifest-without-lockfile",
788
+ severity: "medium",
789
+ title: `${rule.ecosystem} dependency manifest changed without lockfile evidence`,
790
+ detail: `${file} changed ${changes.length} direct dependenc${changes.length === 1 ? "y" : "ies"} (${dependencyChangeExamples(changes)}), but no ${rule.expectedLockfiles} change was detected in this patch.`,
791
+ file,
792
+ remediation: "Update the matching lockfile, or attach equivalent install/resolution evidence if this repository intentionally does not commit lockfiles.",
793
+ tags: ["dependencies", "supply-chain", "evidence"]
794
+ });
795
+ }
796
+ }
797
+ if (lockfileChanges.length > 0 && manifestChanges.length === 0) {
798
+ for (const [file, changes] of changesByFile(lockfileChanges)) {
799
+ findings.push({
800
+ ruleId: "dependency.lockfile-without-manifest",
801
+ severity: "low",
802
+ title: `${rule.ecosystem} lockfile changed without manifest dependency change`,
803
+ detail: `${file} changed ${changes.length} resolved dependenc${changes.length === 1 ? "y" : "ies"} (${dependencyChangeExamples(changes)}), but no matching direct dependency manifest change was detected.`,
804
+ file,
805
+ remediation: "Confirm this is an intentional transitive resolution refresh and not an unreviewed supply-chain drift.",
806
+ tags: ["dependencies", "supply-chain", "evidence"]
807
+ });
808
+ }
809
+ }
810
+ }
811
+ return dedupeFindings(findings);
812
+ }
813
+ function changesByFile(changes) {
814
+ const grouped = new Map();
815
+ for (const change of changes) {
816
+ const values = grouped.get(change.file) ?? [];
817
+ values.push(change);
818
+ grouped.set(change.file, values);
819
+ }
820
+ return grouped;
821
+ }
822
+ function dependencyChangeExamples(changes) {
823
+ const examples = changes.slice(0, 4).map((change) => {
824
+ const before = change.before ? ` ${change.before}` : "";
825
+ const after = change.after ? ` -> ${change.after}` : "";
826
+ return `${change.packageName}${before}${after}`;
827
+ });
828
+ return `${examples.join(", ")}${changes.length > examples.length ? ", ..." : ""}`;
829
+ }
830
+ function missingRequiredVerificationFindings(changedFiles, commandPlan, commandResults) {
831
+ if (changedFiles.length === 0)
832
+ return [];
833
+ const completedIds = new Set(commandResults.map((result) => result.id));
834
+ const missing = commandPlan.filter((command) => command.required && !completedIds.has(command.id));
835
+ if (missing.length === 0)
836
+ return [];
837
+ const examples = missing.slice(0, 3).map((command) => command.command);
838
+ const suffix = missing.length > examples.length ? ", ..." : "";
839
+ return [
840
+ {
841
+ ruleId: "verification.required-not-run",
842
+ severity: "medium",
843
+ title: "Required verification was planned but not run",
844
+ detail: `${missing.length} required verification command${missing.length === 1 ? " was" : "s were"} not executed: ${examples.join(", ")}${suffix}.`,
845
+ remediation: "Run PatchDrill with --run, or attach equivalent command evidence before merge.",
846
+ tags: ["testing", "evidence", "verification"]
847
+ }
848
+ ];
849
+ }
850
+ function isLifecyclePackageScript(scriptName) {
851
+ return PACKAGE_LIFECYCLE_SCRIPTS.has(scriptName);
852
+ }
853
+ function isVerificationPackageScript(scriptName) {
854
+ return VERIFICATION_SCRIPT_NAMES.has(scriptName) || /^test[:_-]/i.test(scriptName) || /^lint[:_-]/i.test(scriptName);
855
+ }
856
+ function isDisabledVerificationCommand(command) {
857
+ const normalized = command.trim().toLowerCase();
858
+ if (/^(true|:|exit 0|node -e ['"]?process\.exit\(0\)['"]?)$/.test(normalized))
859
+ return true;
860
+ return /^echo\b.*&&\s*(true|exit 0)\s*$/i.test(normalized);
861
+ }
862
+ function packageScriptPipesRemoteCode(command) {
863
+ return /\b(curl|wget)\b.+\|\s*(sudo\s+)?(sh|bash|zsh|python|node)\b/i.test(command);
864
+ }
865
+ function baseName(path) {
866
+ return path.split("/").at(-1) ?? path;
867
+ }
868
+ function isRequirementsFileName(fileName) {
869
+ return /^requirements([-.].*)?\.txt$/i.test(fileName) || /^.*[-.]requirements\.txt$/i.test(fileName);
870
+ }
871
+ function workflowContextFindings(addedLines, workflowFiles) {
872
+ const findings = [];
873
+ const workflowLinesByFile = new Map();
874
+ for (const line of addedLines) {
875
+ if (!line.file.startsWith(".github/workflows/"))
876
+ continue;
877
+ const lines = workflowLinesByFile.get(line.file) ?? [];
878
+ lines.push(line);
879
+ workflowLinesByFile.set(line.file, lines);
880
+ }
881
+ for (const [file, lines] of workflowLinesByFile) {
882
+ const finding = workflowHeadCheckoutFinding(file, lines, "New workflow lines combine the privileged pull_request_target event with checkout of attacker-controlled pull request code.");
883
+ if (finding)
884
+ findings.push(finding);
885
+ findings.push(...workflowReusableSecretFindings(file, lines));
886
+ findings.push(...workflowOidcTrustBoundaryFindings(file, lines));
887
+ }
888
+ for (const workflowFile of workflowFiles) {
889
+ const lines = workflowFile.content.split(/\r?\n/).map((content, index) => ({ file: workflowFile.file, line: index + 1, content }));
890
+ const finding = workflowHeadCheckoutFinding(workflowFile.file, lines, "The changed workflow combines the privileged pull_request_target event with checkout of attacker-controlled pull request code.");
891
+ if (finding)
892
+ findings.push(finding);
893
+ findings.push(...workflowReusableSecretFindings(workflowFile.file, lines));
894
+ findings.push(...workflowOidcTrustBoundaryFindings(workflowFile.file, lines));
895
+ }
896
+ return dedupeFindings(findings);
897
+ }
898
+ function workflowHeadCheckoutFinding(file, lines, detail) {
899
+ if (!lines.some((line) => /^\s*pull_request_target\s*:/i.test(line.content)))
900
+ return undefined;
901
+ if (!lines.some((line) => workflowUsesCheckoutAction(line.content)))
902
+ return undefined;
903
+ const headCheckoutLine = lines.find((line) => workflowUsesPullRequestHeadContext(line.content));
904
+ if (!headCheckoutLine)
905
+ return undefined;
906
+ return {
907
+ ruleId: "workflow.pull-request-target-head-checkout",
908
+ severity: "critical",
909
+ title: "pull_request_target checks out pull request head",
910
+ detail,
911
+ file,
912
+ line: headCheckoutLine.line,
913
+ remediation: "Use pull_request for untrusted code, or keep pull_request_target jobs on trusted base code with least-privilege permissions.",
914
+ tags: ["ci", "github-actions", "supply-chain", "trust-boundary"]
915
+ };
916
+ }
917
+ function workflowUsesCheckoutAction(content) {
918
+ return /^\s*(?:-\s*)?uses\s*:\s*['"]?actions\/checkout@[^'"\s#]+['"]?\s*(?:#.*)?$/i.test(content);
919
+ }
920
+ function workflowUsesPullRequestHeadContext(content) {
921
+ return /\${{\s*(github\.event\.pull_request\.head\.(sha|ref|repo\.full_name)|github\.head_ref)\s*}}/i.test(content);
922
+ }
923
+ function workflowReusableSecretFindings(file, lines) {
924
+ const findings = [];
925
+ for (const job of workflowReusableSecretJobs(lines)) {
926
+ findings.push({
927
+ ruleId: "workflow.reusable-inherited-secrets",
928
+ severity: "high",
929
+ title: "Reusable workflow inherits all caller secrets",
930
+ detail: `Job "${job.jobId}" calls ${job.uses} with secrets: inherit, passing every caller-accessible organization, repository, and environment secret across a workflow boundary.`,
931
+ file,
932
+ line: job.secretsInheritLine,
933
+ remediation: "Pass only named secrets needed by the called workflow, and review the called workflow's repository, ref, permissions, and runner trust.",
934
+ tags: ["ci", "github-actions", "secrets", "trust-boundary"]
935
+ });
936
+ if (!reusableWorkflowUsesMutableRemoteRef(job.uses))
937
+ continue;
938
+ findings.push({
939
+ ruleId: "workflow.reusable-unpinned-secret-call",
940
+ severity: "critical",
941
+ title: "Mutable reusable workflow receives inherited secrets",
942
+ detail: `Job "${job.jobId}" passes inherited secrets to the remote reusable workflow ${job.uses}, but the workflow ref is not pinned to a full commit SHA.`,
943
+ file,
944
+ line: job.usesLine,
945
+ remediation: "Pin remote reusable workflows that receive secrets to a reviewed full-length commit SHA, or call a local workflow from the same commit.",
946
+ tags: ["ci", "github-actions", "supply-chain", "secrets", "trust-boundary"]
947
+ });
948
+ }
949
+ return findings;
950
+ }
951
+ function workflowOidcTrustBoundaryFindings(file, lines) {
952
+ const findings = [];
953
+ const workflowPermission = workflowPermissionState(lines, -1);
954
+ const pullRequestTargetOidcLine = workflowHasPullRequestTarget(lines) ? firstEffectiveOidcPermissionLine(lines, workflowPermission) : undefined;
955
+ if (pullRequestTargetOidcLine) {
956
+ findings.push({
957
+ ruleId: "workflow.pull-request-target-oidc",
958
+ severity: "high",
959
+ title: "pull_request_target workflow can mint OIDC tokens",
960
+ detail: "The workflow combines the privileged pull_request_target event with id-token: write, allowing jobs to request cloud identity tokens from a fork-triggerable trust boundary.",
961
+ file,
962
+ line: pullRequestTargetOidcLine.line,
963
+ remediation: "Move OIDC deployment to push, workflow_dispatch, or a protected environment path that never executes fork-controlled code.",
964
+ tags: ["ci", "github-actions", "oidc", "trust-boundary"]
965
+ });
966
+ }
967
+ for (const job of workflowJobBlocks(lines)) {
968
+ const jobPermission = workflowPermissionState(job.lines, job.indent);
969
+ const oidcPermissionLine = jobPermission.specified ? jobPermission.idTokenWriteLine : workflowPermission.idTokenWriteLine;
970
+ if (!oidcPermissionLine)
971
+ continue;
972
+ const directChildren = workflowDirectChildLines(job.lines, job.indent);
973
+ const environmentLine = directChildren.find((line) => readYamlScalar(line.content)?.key === "environment");
974
+ const cloudOidcLine = workflowCloudOidcCredentialLine(job.lines);
975
+ if (environmentLine) {
976
+ findings.push({
977
+ ruleId: "workflow.environment-oidc-token",
978
+ severity: "high",
979
+ title: "Environment job can mint OIDC tokens",
980
+ detail: `Job "${job.jobId}" targets a GitHub environment and grants id-token: write, so environment reviewers and OIDC cloud-role conditions both become part of the deployment trust boundary.`,
981
+ file,
982
+ line: oidcPermissionLine.line,
983
+ remediation: "Verify the environment protection rules, cloud OIDC subject/audience conditions, and branch restrictions before merging.",
984
+ tags: ["ci", "github-actions", "oidc", "environment", "deployment"]
985
+ });
986
+ }
987
+ if (cloudOidcLine && !environmentLine) {
988
+ findings.push({
989
+ ruleId: "workflow.cloud-oidc-without-environment",
990
+ severity: "medium",
991
+ title: "Cloud OIDC credential exchange lacks environment protection",
992
+ detail: `Job "${job.jobId}" grants id-token: write and uses a cloud credential exchange action without a GitHub environment gate.`,
993
+ file,
994
+ line: cloudOidcLine.line,
995
+ remediation: "Bind cloud OIDC roles to protected GitHub environments or verify equivalent branch, subject, and audience restrictions in the cloud identity policy.",
996
+ tags: ["ci", "github-actions", "oidc", "cloud", "deployment"]
997
+ });
998
+ }
999
+ const usesLine = directChildren.find((line) => readYamlScalar(line.content)?.key === "uses");
1000
+ const usesValue = usesLine ? readYamlScalar(usesLine.content)?.value : undefined;
1001
+ if (!usesLine || !usesValue || !isRemoteReusableWorkflowUse(usesValue))
1002
+ continue;
1003
+ findings.push({
1004
+ ruleId: "workflow.reusable-oidc-token-boundary",
1005
+ severity: "high",
1006
+ title: "Remote reusable workflow can mint caller OIDC tokens",
1007
+ detail: `Job "${job.jobId}" calls ${usesValue} with id-token: write, allowing the called workflow to request OIDC tokens in the caller's trust context.`,
1008
+ file,
1009
+ line: oidcPermissionLine.line,
1010
+ remediation: "Grant id-token: write only to reviewed reusable workflows with explicit cloud role conditions and a trusted repository/ref owner.",
1011
+ tags: ["ci", "github-actions", "oidc", "reusable-workflow", "trust-boundary"]
1012
+ });
1013
+ if (!reusableWorkflowUsesMutableRemoteRef(usesValue))
1014
+ continue;
1015
+ findings.push({
1016
+ ruleId: "workflow.reusable-unpinned-oidc-call",
1017
+ severity: "critical",
1018
+ title: "Mutable reusable workflow can mint caller OIDC tokens",
1019
+ detail: `Job "${job.jobId}" grants id-token: write to remote reusable workflow ${usesValue}, but the workflow ref is not pinned to a full commit SHA.`,
1020
+ file,
1021
+ line: usesLine.line,
1022
+ remediation: "Pin remote reusable workflows that receive OIDC permissions to a reviewed full-length commit SHA.",
1023
+ tags: ["ci", "github-actions", "supply-chain", "oidc", "trust-boundary"]
1024
+ });
1025
+ }
1026
+ return findings;
1027
+ }
1028
+ function workflowCloudOidcCredentialLine(lines) {
1029
+ return lines.find((line) => workflowUsesCloudOidcCredentialAction(line.content));
1030
+ }
1031
+ function workflowUsesCloudOidcCredentialAction(content) {
1032
+ const match = /^\s*(?:-\s*)?uses\s*:\s*['"]?([^'"\s#]+)['"]?\s*(?:#.*)?$/i.exec(content);
1033
+ if (!match?.[1])
1034
+ return false;
1035
+ const action = match[1].split("@", 1)[0]?.toLowerCase();
1036
+ return Boolean(action &&
1037
+ [
1038
+ "aws-actions/configure-aws-credentials",
1039
+ "azure/login",
1040
+ "google-github-actions/auth",
1041
+ "hashicorp/vault-action"
1042
+ ].includes(action));
1043
+ }
1044
+ function firstEffectiveOidcPermissionLine(lines, workflowPermission) {
1045
+ if (workflowPermission.idTokenWriteLine)
1046
+ return workflowPermission.idTokenWriteLine;
1047
+ for (const job of workflowJobBlocks(lines)) {
1048
+ const jobPermission = workflowPermissionState(job.lines, job.indent);
1049
+ if (jobPermission.idTokenWriteLine)
1050
+ return jobPermission.idTokenWriteLine;
1051
+ }
1052
+ return undefined;
1053
+ }
1054
+ function workflowReusableSecretJobs(lines) {
1055
+ const jobs = workflowJobBlocks(lines);
1056
+ return jobs.flatMap((job) => {
1057
+ const directChildren = workflowDirectChildLines(job.lines, job.indent);
1058
+ const usesLine = directChildren.find((line) => readYamlScalar(line.content)?.key === "uses");
1059
+ const usesValue = usesLine ? readYamlScalar(usesLine.content)?.value : undefined;
1060
+ if (!usesLine || !usesValue || !isReusableWorkflowUse(usesValue))
1061
+ return [];
1062
+ const secretsLine = directChildren.find((line) => {
1063
+ const scalar = readYamlScalar(line.content);
1064
+ return scalar?.key === "secrets" && unquoteYamlScalar(scalar.value).toLowerCase() === "inherit";
1065
+ });
1066
+ if (!secretsLine)
1067
+ return [];
1068
+ return [
1069
+ {
1070
+ jobId: job.jobId,
1071
+ uses: usesValue,
1072
+ usesLine: usesLine.line,
1073
+ secretsInheritLine: secretsLine.line
1074
+ }
1075
+ ];
1076
+ });
1077
+ }
1078
+ function workflowJobBlocks(lines) {
1079
+ const jobsLineIndex = lines.findIndex((line) => readYamlScalar(line.content)?.key === "jobs");
1080
+ if (jobsLineIndex < 0)
1081
+ return [];
1082
+ const jobsLine = lines[jobsLineIndex];
1083
+ if (!jobsLine)
1084
+ return [];
1085
+ const jobsIndent = indentation(jobsLine.content);
1086
+ const jobIndent = lines.slice(jobsLineIndex + 1).find((line) => isYamlContentLine(line.content) && indentation(line.content) > jobsIndent);
1087
+ if (!jobIndent)
1088
+ return [];
1089
+ const directJobIndent = indentation(jobIndent.content);
1090
+ const blocks = [];
1091
+ let current;
1092
+ for (const line of lines.slice(jobsLineIndex + 1)) {
1093
+ if (!isYamlContentLine(line.content)) {
1094
+ if (current)
1095
+ current.lines.push(line);
1096
+ continue;
1097
+ }
1098
+ const indent = indentation(line.content);
1099
+ if (indent <= jobsIndent)
1100
+ break;
1101
+ const scalar = readYamlScalar(line.content);
1102
+ if (indent === directJobIndent && scalar?.value.length === 0) {
1103
+ if (current)
1104
+ blocks.push(current);
1105
+ current = { jobId: scalar.key, indent, lines: [line] };
1106
+ continue;
1107
+ }
1108
+ if (current)
1109
+ current.lines.push(line);
1110
+ }
1111
+ if (current)
1112
+ blocks.push(current);
1113
+ return blocks;
1114
+ }
1115
+ function workflowDirectChildLines(lines, parentIndent) {
1116
+ const childIndent = lines.find((line) => isYamlContentLine(line.content) && indentation(line.content) > parentIndent);
1117
+ if (!childIndent)
1118
+ return [];
1119
+ const directChildIndent = indentation(childIndent.content);
1120
+ return lines.filter((line) => isYamlContentLine(line.content) && indentation(line.content) === directChildIndent);
1121
+ }
1122
+ function isReusableWorkflowUse(value) {
1123
+ const normalized = unquoteYamlScalar(value);
1124
+ return normalized.startsWith("./.github/workflows/") || /^[^/\s]+\/[^/\s]+\/\.github\/workflows\/[^@\s]+(?:@[^@\s]+)?$/.test(normalized);
1125
+ }
1126
+ function isRemoteReusableWorkflowUse(value) {
1127
+ const normalized = unquoteYamlScalar(value);
1128
+ return /^[^/\s]+\/[^/\s]+\/\.github\/workflows\/[^@\s]+(?:@[^@\s]+)?$/.test(normalized);
1129
+ }
1130
+ function reusableWorkflowUsesMutableRemoteRef(value) {
1131
+ const normalized = unquoteYamlScalar(value);
1132
+ if (normalized.startsWith("./"))
1133
+ return false;
1134
+ const atIndex = normalized.lastIndexOf("@");
1135
+ if (atIndex < 0)
1136
+ return true;
1137
+ const ref = normalized.slice(atIndex + 1);
1138
+ return !/^[a-f0-9]{40}$/i.test(ref);
1139
+ }
1140
+ function workflowPermissionState(lines, parentIndent) {
1141
+ const directChildren = parentIndent < 0 ? workflowRootChildLines(lines) : workflowDirectChildLines(lines, parentIndent);
1142
+ const permissionsLine = directChildren.find((line) => readYamlScalar(line.content)?.key === "permissions");
1143
+ if (!permissionsLine)
1144
+ return { specified: false };
1145
+ const value = unquoteYamlScalar(readYamlScalar(permissionsLine.content)?.value ?? "").toLowerCase();
1146
+ if (value === "write-all" || /\bid-token\s*:\s*write\b/i.test(value)) {
1147
+ return { specified: true, idTokenWriteLine: permissionsLine };
1148
+ }
1149
+ if (value.length > 0)
1150
+ return { specified: true };
1151
+ const idTokenLine = workflowBlockDirectChildLines(lines, permissionsLine).find((line) => {
1152
+ const scalar = readYamlScalar(line.content);
1153
+ return scalar?.key === "id-token" && unquoteYamlScalar(scalar.value).toLowerCase() === "write";
1154
+ });
1155
+ return {
1156
+ specified: true,
1157
+ ...(idTokenLine ? { idTokenWriteLine: idTokenLine } : {})
1158
+ };
1159
+ }
1160
+ function workflowRootChildLines(lines) {
1161
+ return lines.filter((line) => isYamlContentLine(line.content) && indentation(line.content) === 0);
1162
+ }
1163
+ function workflowBlockDirectChildLines(lines, parentLine) {
1164
+ const parentIndex = lines.indexOf(parentLine);
1165
+ if (parentIndex < 0)
1166
+ return [];
1167
+ const parentIndent = indentation(parentLine.content);
1168
+ let childIndent;
1169
+ const children = [];
1170
+ for (const line of lines.slice(parentIndex + 1)) {
1171
+ if (!isYamlContentLine(line.content))
1172
+ continue;
1173
+ const indent = indentation(line.content);
1174
+ if (indent <= parentIndent)
1175
+ break;
1176
+ childIndent ??= indent;
1177
+ if (indent === childIndent)
1178
+ children.push(line);
1179
+ }
1180
+ return children;
1181
+ }
1182
+ function workflowHasPullRequestTarget(lines) {
1183
+ return lines.some((line) => /^\s*pull_request_target\s*:/i.test(line.content));
1184
+ }
1185
+ function readYamlScalar(content) {
1186
+ const withoutComment = stripYamlComment(content);
1187
+ const match = /^\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$/.exec(withoutComment);
1188
+ if (!match?.[1])
1189
+ return undefined;
1190
+ return { key: match[1], value: match[2] ?? "" };
1191
+ }
1192
+ function stripYamlComment(content) {
1193
+ const hashIndex = content.indexOf("#");
1194
+ return hashIndex >= 0 ? content.slice(0, hashIndex) : content;
1195
+ }
1196
+ function unquoteYamlScalar(value) {
1197
+ const trimmed = value.trim();
1198
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
1199
+ return trimmed.slice(1, -1);
1200
+ }
1201
+ return trimmed;
1202
+ }
1203
+ function isYamlContentLine(content) {
1204
+ const trimmed = content.trim();
1205
+ return trimmed.length > 0 && !trimmed.startsWith("#");
1206
+ }
1207
+ function indentation(content) {
1208
+ return /^\s*/.exec(content)?.[0].length ?? 0;
1209
+ }
1210
+ function clamp(value, min, max) {
1211
+ return Math.min(max, Math.max(min, value));
1212
+ }
1213
+ function findingKey(finding) {
1214
+ return `${finding.severity}:${finding.title}:${finding.file ?? ""}:${finding.line ?? ""}`;
1215
+ }
1216
+ function dedupeFindings(findings) {
1217
+ const seen = new Set();
1218
+ return findings.filter((finding) => {
1219
+ const key = findingKey(finding);
1220
+ if (seen.has(key))
1221
+ return false;
1222
+ seen.add(key);
1223
+ return true;
1224
+ });
1225
+ }
1226
+ //# sourceMappingURL=risk.js.map