@goodandready/dsh-github-ops 0.2.11 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/lib/commands.js +4 -4
- package/lib/scm-write.js +12 -0
- package/lib/status.js +14 -5
- package/lib/tools/mirror.js +4 -1
- package/lib/tools/register-collab.js +2 -2
- package/lib/tools/register-core.js +15 -7
- package/lib/tools/register-write.js +0 -3
- package/lib/tools/release-preflight.js +6 -1
- package/lib/tools/releases.js +0 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.12
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
- **Release Preflight Test Confirmation & CWD Containment** (#80): Required `confirm: true` to execute `npm test` during `gh_release_preflight` audit, and routed audit directory through `validateCwd` to prevent arbitrary directory test execution.
|
|
7
|
+
- **SCM Symlink Traversal Protection** (#96): Enhanced `validateCwd` with `realpathSync` resolution to prevent directory traversal and symlink escape into protected system directories (`/etc`, `/proc`, `/sys`, etc.).
|
|
8
|
+
- **Socket Remote Address Verification & IPv6 Support** (#97): Enforced socket `remoteAddress` validation in `isTrustedRequest` and `isTrustedWriteRequest` to block untrusted external clients from spoofing matching `Host` and `Origin` headers. Fixed unbracketed IPv6 address parsing in `isTrustedHost`.
|
|
9
|
+
|
|
10
|
+
### Refactored
|
|
11
|
+
- **Mirror Git Index Directory Isolation** (#91): Created mirror temporary git-index inside an isolated `fs.mkdtemp` (0700) directory with guaranteed cleanup in `finally`.
|
|
12
|
+
- **Dead Parameter Aliases Removed** (#92): Removed undeclared schema fallback aliases (`tagName`, `targetCommitish`, `previousTagName`, `configurationFilePath`, `pullNumber`) from tool registrations.
|
|
13
|
+
- **Unused Parameters & Re-exports Cleanup** (#95): Removed unused `repoHint` in slash commands, dead `liveConfig()` call in `gh_auth_status`, and unused re-export of `runReleasePreflight`.
|
|
14
|
+
|
|
3
15
|
## 0.2.11
|
|
4
16
|
|
|
5
17
|
### Security
|
package/lib/commands.js
CHANGED
|
@@ -72,7 +72,7 @@ export function buildGhInstruction({ topic, tool, args } = {}) {
|
|
|
72
72
|
* Register the commands. Every handler returns an instruction for the model; none of them
|
|
73
73
|
* touches GitHub.
|
|
74
74
|
*/
|
|
75
|
-
export function registerCommands(commands, { readGitState
|
|
75
|
+
export function registerCommands(commands, { readGitState } = {}) {
|
|
76
76
|
const disposers = []
|
|
77
77
|
const context = async () => {
|
|
78
78
|
try {
|
|
@@ -92,7 +92,7 @@ export function registerCommands(commands, { readGitState, repoHint } = {}) {
|
|
|
92
92
|
const git = await context()
|
|
93
93
|
return {
|
|
94
94
|
kind: 'success',
|
|
95
|
-
text: buildPrInstruction({ title: rest.join(' '), branch: git.branch, base: git.base || 'main', repo: git.repo
|
|
95
|
+
text: buildPrInstruction({ title: rest.join(' '), branch: git.branch, base: git.base || 'main', repo: git.repo, remoteUrl: git.remoteUrl }),
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
}))
|
|
@@ -107,7 +107,7 @@ export function registerCommands(commands, { readGitState, repoHint } = {}) {
|
|
|
107
107
|
const git = await context()
|
|
108
108
|
return {
|
|
109
109
|
kind: 'success',
|
|
110
|
-
text: buildReviewInstruction({ number, branch: git.branch, repo: git.repo
|
|
110
|
+
text: buildReviewInstruction({ number, branch: git.branch, repo: git.repo }),
|
|
111
111
|
}
|
|
112
112
|
},
|
|
113
113
|
}))
|
|
@@ -119,7 +119,7 @@ export function registerCommands(commands, { readGitState, repoHint } = {}) {
|
|
|
119
119
|
handler: async (invocation) => {
|
|
120
120
|
const [sub, ...rest] = String(invocation.rawInput || '').trim().split(/\s+/)
|
|
121
121
|
const git = await context()
|
|
122
|
-
const repo = git.repo
|
|
122
|
+
const repo = git.repo
|
|
123
123
|
if (sub === 'new') return { kind: 'success', text: buildIssueInstruction({ sub, title: rest.join(' '), repo }) }
|
|
124
124
|
if (sub === 'show' && /^\d+$/.test(rest[0] || '')) return { kind: 'success', text: buildIssueInstruction({ sub, number: Number(rest[0]), repo }) }
|
|
125
125
|
if (!sub || sub === 'list') return { kind: 'success', text: buildIssueInstruction({ sub: 'list', repo }) }
|
package/lib/scm-write.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
1
2
|
// Writing to a working copy, from the Source Control tab.
|
|
2
3
|
//
|
|
3
4
|
// Every action is a list of argv arrays — never a shell string. The route runs them one by one
|
|
@@ -172,5 +173,16 @@ export function validateCwd(path) {
|
|
|
172
173
|
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
173
174
|
}
|
|
174
175
|
}
|
|
176
|
+
let realPath = value
|
|
177
|
+
try {
|
|
178
|
+
realPath = realpathSync(value)
|
|
179
|
+
} catch {
|
|
180
|
+
realPath = value
|
|
181
|
+
}
|
|
182
|
+
for (const root of DISALLOWED_ROOTS) {
|
|
183
|
+
if (realPath === root || realPath.startsWith(root + '/')) {
|
|
184
|
+
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
175
187
|
return value
|
|
176
188
|
}
|
package/lib/status.js
CHANGED
|
@@ -14,8 +14,11 @@ function isTrustedHost(host) {
|
|
|
14
14
|
if (hostname.startsWith('[')) {
|
|
15
15
|
const end = hostname.indexOf(']')
|
|
16
16
|
if (end > 0) hostname = hostname.slice(1, end)
|
|
17
|
-
} else {
|
|
18
|
-
|
|
17
|
+
} else if (hostname.includes(':')) {
|
|
18
|
+
const colons = hostname.split(':').length - 1
|
|
19
|
+
if (colons === 1) {
|
|
20
|
+
hostname = hostname.split(':')[0]
|
|
21
|
+
}
|
|
19
22
|
}
|
|
20
23
|
hostname = hostname.toLowerCase()
|
|
21
24
|
if (!hostname) return false
|
|
@@ -43,6 +46,9 @@ export function isTrustedRequest(req) {
|
|
|
43
46
|
const cleanIp = address.replace(/^::ffff:/, '')
|
|
44
47
|
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
45
48
|
|
|
49
|
+
// If socket remoteAddress is present, it MUST be a trusted local/private IP (never an untrusted remote IP)
|
|
50
|
+
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
51
|
+
|
|
46
52
|
const host = header(req, 'host')
|
|
47
53
|
const origin = header(req, 'origin') || header(req, 'referer')
|
|
48
54
|
|
|
@@ -80,6 +86,12 @@ export function isTrustedWriteRequest(req) {
|
|
|
80
86
|
return false
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
90
|
+
const cleanIp = address.replace(/^::ffff:/, '')
|
|
91
|
+
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
92
|
+
|
|
93
|
+
if (cleanIp && !isTrustedHost(cleanIp)) return false
|
|
94
|
+
|
|
83
95
|
const host = header(req, 'host')
|
|
84
96
|
const origin = header(req, 'origin') || header(req, 'referer')
|
|
85
97
|
if (origin) {
|
|
@@ -91,9 +103,6 @@ export function isTrustedWriteRequest(req) {
|
|
|
91
103
|
}
|
|
92
104
|
} else {
|
|
93
105
|
// Write request without origin: only permit local loopback
|
|
94
|
-
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
95
|
-
const cleanIp = address.replace(/^::ffff:/, '')
|
|
96
|
-
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
97
106
|
if (!loopback) return false
|
|
98
107
|
}
|
|
99
108
|
|
package/lib/tools/mirror.js
CHANGED
|
@@ -162,7 +162,9 @@ export async function publishMirror({
|
|
|
162
162
|
|
|
163
163
|
if (dryRun) return { ...plan, mirror: mirrorRef, parent, commit: null, pushed: false }
|
|
164
164
|
|
|
165
|
-
const
|
|
165
|
+
const fs = await import('node:fs/promises')
|
|
166
|
+
const tmpIndexDir = await fs.mkdtemp(path.join(tmpdir(), 'dsh-mirror-index-'))
|
|
167
|
+
const indexFile = path.join(tmpIndexDir, `mirror-${randomUUID()}.index`)
|
|
166
168
|
const env = { GIT_INDEX_FILE: indexFile }
|
|
167
169
|
try {
|
|
168
170
|
await git(['read-tree', '--empty'], { cwd, env })
|
|
@@ -190,5 +192,6 @@ export async function publishMirror({
|
|
|
190
192
|
}
|
|
191
193
|
} finally {
|
|
192
194
|
await git(['update-index', '--refresh'], { cwd, env }).catch(() => {})
|
|
195
|
+
await fs.rm(tmpIndexDir, { recursive: true, force: true }).catch(() => {})
|
|
193
196
|
}
|
|
194
197
|
}
|
|
@@ -245,7 +245,7 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
245
245
|
const { owner, repo } = await asRepo(args, liveConfig())
|
|
246
246
|
return listReviewThreads(await client(), {
|
|
247
247
|
owner, repo,
|
|
248
|
-
number: args.number
|
|
248
|
+
number: args.number,
|
|
249
249
|
unresolvedOnly: Boolean(args.unresolvedOnly),
|
|
250
250
|
})
|
|
251
251
|
},
|
|
@@ -266,7 +266,7 @@ export function registerCollabTools({ tool, asRepo, client, liveConfig, reviewRu
|
|
|
266
266
|
const { owner, repo } = await asRepo(args, liveConfig())
|
|
267
267
|
return replyReviewThread(await client(), {
|
|
268
268
|
owner, repo,
|
|
269
|
-
number: args.number
|
|
269
|
+
number: args.number,
|
|
270
270
|
threadId: args.threadId,
|
|
271
271
|
commentId: args.commentId,
|
|
272
272
|
body: args.body,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateCwd } from '../scm-write.js'
|
|
1
2
|
import { runReleasePreflight } from './release-preflight.js'
|
|
2
3
|
// Core tool registrations: repository and file reads, releases, tags, the guarded
|
|
3
4
|
// generic API pass-through, and repository creation/edits.
|
|
@@ -255,10 +256,10 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig, defaultCwd
|
|
|
255
256
|
const { owner, repo } = await asRepo(args, liveConfig())
|
|
256
257
|
return generateReleaseNotes(await client(), {
|
|
257
258
|
owner, repo,
|
|
258
|
-
tag: args.tag
|
|
259
|
-
target: args.target
|
|
260
|
-
previousTag: args.previousTag
|
|
261
|
-
configFile: args.configFile
|
|
259
|
+
tag: args.tag,
|
|
260
|
+
target: args.target,
|
|
261
|
+
previousTag: args.previousTag,
|
|
262
|
+
configFile: args.configFile,
|
|
262
263
|
})
|
|
263
264
|
},
|
|
264
265
|
)
|
|
@@ -337,16 +338,23 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig, defaultCwd
|
|
|
337
338
|
|
|
338
339
|
tool(
|
|
339
340
|
'gh_release_preflight',
|
|
340
|
-
'Run automated pre-release audit on the plugin package: checks package.json version, cordis.patch.yml, CHANGELOG.md release notes, three READMEs (EN/RU/ZH), npm pack file sizes (<256 KiB), and git index hygiene.',
|
|
341
|
+
'Run automated pre-release audit on the plugin package: checks package.json version, cordis.patch.yml, CHANGELOG.md release notes, three READMEs (EN/RU/ZH), npm pack file sizes (<256 KiB), and git index hygiene. Running test suite requires confirm: true.',
|
|
341
342
|
{
|
|
342
343
|
cwd: { type: 'string', description: 'Directory to audit (defaults to workspace or process.cwd()).' },
|
|
343
|
-
runTests: { type: 'boolean', description: 'Whether to run npm test as part of the audit (
|
|
344
|
+
runTests: { type: 'boolean', description: 'Whether to run npm test as part of the audit (requires confirm: true).' },
|
|
344
345
|
maxFileSize: { type: 'number', description: 'Max allowed size in bytes per packaged file (default 262144).' },
|
|
346
|
+
confirm: { type: 'boolean', description: 'Must be true to execute npm test when runTests is true.' },
|
|
345
347
|
},
|
|
346
348
|
async (args) => {
|
|
349
|
+
if (args.runTests === true && args.confirm !== true) {
|
|
350
|
+
throw new Error('running test suite during preflight audit requires confirm: true')
|
|
351
|
+
}
|
|
352
|
+
const rawCwd = args.cwd || (typeof defaultCwd === 'function' ? defaultCwd() : process.cwd())
|
|
353
|
+
const validatedCwd = validateCwd(rawCwd)
|
|
347
354
|
return runReleasePreflight({
|
|
348
|
-
cwd:
|
|
355
|
+
cwd: validatedCwd,
|
|
349
356
|
runTests: args.runTests,
|
|
357
|
+
confirm: args.confirm,
|
|
350
358
|
maxFileSize: args.maxFileSize,
|
|
351
359
|
})
|
|
352
360
|
},
|
|
@@ -210,7 +210,6 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
210
210
|
'Report where the current access comes from and whether it works: the source (credential, environment, this plugin\'s sign-in, gh CLI), the account, and the remaining API rate limit. The value itself is never shown. Read-only.',
|
|
211
211
|
{},
|
|
212
212
|
async () => {
|
|
213
|
-
const cfg = liveConfig()
|
|
214
213
|
const access = await auth.resolve()
|
|
215
214
|
if (!access.value) return { configured: false, guidance: access.guidance }
|
|
216
215
|
const github = await client()
|
|
@@ -240,6 +239,4 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
|
|
|
240
239
|
},
|
|
241
240
|
)
|
|
242
241
|
|
|
243
|
-
// keep the imports honest for callers that compose their own flows
|
|
244
|
-
return { accessFilePath, readAccessFile }
|
|
245
242
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateCwd } from '../scm-write.js'
|
|
1
2
|
// lib/tools/release-preflight.js
|
|
2
3
|
// Automated release preflight audit for DSH plugins according to
|
|
3
4
|
// dhs-plugin-release-workflow and dsh-documentation-standard.
|
|
@@ -23,8 +24,12 @@ const FORBIDDEN_PACK_EXACT = ['AGENTS.md', '.env', '.env.local', 'deploy.sh']
|
|
|
23
24
|
* @param {object} [deps] Injectable dependencies for unit testing
|
|
24
25
|
*/
|
|
25
26
|
export async function runReleasePreflight(options = {}, deps = {}) {
|
|
26
|
-
const
|
|
27
|
+
const rawCwd = options.cwd || process.cwd()
|
|
28
|
+
const cwd = validateCwd(rawCwd)
|
|
27
29
|
const runTests = options.runTests === true
|
|
30
|
+
if (runTests && options.confirm !== true) {
|
|
31
|
+
throw new Error('running test suite during preflight audit requires confirm: true')
|
|
32
|
+
}
|
|
28
33
|
const maxFileSize = typeof options.maxFileSize === 'number' ? options.maxFileSize : MAX_FILE_SIZE_DEFAULT
|
|
29
34
|
const exec = deps.execFile || pExecFile
|
|
30
35
|
const fsRead = deps.readFileSync || readFileSync
|
package/lib/tools/releases.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|