@goodandready/dsh-github-ops 0.6.4 → 0.6.6

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/lib/client.js CHANGED
@@ -127,6 +127,15 @@ window.__ModuleLoader__.load({
127
127
  panelPulls: 'Pull requests',
128
128
  panelRuns: 'Actions',
129
129
  panelInbox: 'Inbox',
130
+ panelSecurity: 'Security',
131
+ panelSecurityEmpty: 'No security alerts found.',
132
+ panelSecurityDisabled: 'Security features are disabled or token lacks security scope.',
133
+ panelDiagnose: 'Diagnose',
134
+ panelDiagnosing: 'Diagnosing…',
135
+ panelDiagnoseAsk: 'Ask Agent to Diagnose & Fix',
136
+ panelDiagnoseCopied: 'Prompt copied to clipboard!',
137
+ panelSteps: 'Steps',
138
+ panelFailedStep: 'Failed Step',
130
139
  panelOpen: 'Open in browser',
131
140
  panelEmpty: 'Nothing here yet.',
132
141
  panelLoading: 'Loading…',
@@ -240,6 +249,15 @@ window.__ModuleLoader__.load({
240
249
  panelPulls: 'Pull request',
241
250
  panelRuns: 'Actions',
242
251
  panelInbox: '收件箱',
252
+ panelSecurity: '安全',
253
+ panelSecurityEmpty: '未发现安全警报。',
254
+ panelSecurityDisabled: '安全功能已禁用或令牌缺少安全权限。',
255
+ panelDiagnose: '诊断',
256
+ panelDiagnosing: '诊断中…',
257
+ panelDiagnoseAsk: '请智能体诊断并修复',
258
+ panelDiagnoseCopied: '提示词已复制到剪贴板!',
259
+ panelSteps: '步骤',
260
+ panelFailedStep: '失败步骤',
243
261
  panelOpen: '在浏览器中打开',
244
262
  panelEmpty: '这里还没有内容。',
245
263
  panelLoading: '加载中…',
@@ -784,6 +802,11 @@ window.__ModuleLoader__.load({
784
802
  const [items, setItems] = React.useState({ phase: 'idle', list: [] })
785
803
  const [error, setError] = React.useState('')
786
804
  const [loading, setLoading] = React.useState(false)
805
+ const [securityData, setSecurityData] = React.useState({ phase: 'idle', data: null })
806
+ const [diagnostics, setDiagnostics] = React.useState(null)
807
+ const [diagnoseLoadingId, setDiagnoseLoadingId] = React.useState(null)
808
+ const [expandedSteps, setExpandedSteps] = React.useState({})
809
+ const [copiedPrompt, setCopiedPrompt] = React.useState(false)
787
810
 
788
811
  const call = React.useCallback((params) => {
789
812
  if (typeof fetch !== 'function') return Promise.reject(new Error('fetch is unavailable in this client'))
@@ -823,6 +846,37 @@ window.__ModuleLoader__.load({
823
846
  .catch((err) => { setItems({ phase: 'error', list: [] }); setError(String((err && err.message) || err)) })
824
847
  }, [call, repo])
825
848
 
849
+ const loadSecurity = React.useCallback(() => {
850
+ if (!repo) return
851
+ setSecurityData({ phase: 'loading', data: null })
852
+ call({ what: 'security', repo })
853
+ .then((payload) => setSecurityData({ phase: 'ready', data: payload }))
854
+ .catch((err) => { setSecurityData({ phase: 'error', data: null }); setError(String((err && err.message) || err)) })
855
+ }, [call, repo])
856
+
857
+ const loadDiagnostics = (runId) => {
858
+ if (!repo || !runId) return
859
+ setDiagnoseLoadingId(runId)
860
+ call({ what: 'diagnose_run', repo, runId })
861
+ .then((res) => {
862
+ setDiagnostics(res)
863
+ if (res.failedStep) setExpandedSteps({ [res.failedStep.name]: true })
864
+ })
865
+ .catch((err) => setError(String((err && err.message) || err)))
866
+ .finally(() => setDiagnoseLoadingId(null))
867
+ }
868
+
869
+ const askAgent = (diag) => {
870
+ const text = `Please diagnose and fix CI failure for repository ${repo} run #${diag.runId} (${diag.jobName || 'job'}):\nFailed Step: ${diag.failedStep ? diag.failedStep.name : 'Unknown'}\n\nTop Errors:\n` +
871
+ ((diag.failedStep && diag.failedStep.errorHighlights) || []).map((e) => ' - ' + e).join('\n') +
872
+ (diag.failedStep && diag.failedStep.lines ? `\n\nStep Log excerpt:\n` + diag.failedStep.lines.slice(-30).join('\n') : '')
873
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
874
+ navigator.clipboard.writeText(text).catch(() => {})
875
+ }
876
+ setCopiedPrompt(true)
877
+ setTimeout(() => setCopiedPrompt(false), 4000)
878
+ }
879
+
826
880
  const loadInbox = React.useCallback(() => {
827
881
  setItems({ phase: 'loading', list: [] })
828
882
  call({ what: 'inbox' })
@@ -835,8 +889,9 @@ window.__ModuleLoader__.load({
835
889
  if (tab === 'tree') loadTree('')
836
890
  else if (tab === 'inbox') loadInbox()
837
891
  else if (tab === 'matrix') loadMatrix()
892
+ else if (tab === 'security') loadSecurity()
838
893
  else loadList(tab)
839
- }, [repo, tab, loadTree, loadList, loadInbox])
894
+ }, [repo, tab, loadTree, loadList, loadInbox, loadSecurity])
840
895
 
841
896
  const openFile = (entry) => {
842
897
  if (entry.type === 'tree') { loadTree(entry.path); return }
@@ -887,10 +942,42 @@ window.__ModuleLoader__.load({
887
942
  const list = (() => {
888
943
  if (items.phase === 'loading') return React.createElement('div', { className: 'gho-panel-muted' }, t('panelLoading'))
889
944
  if (!items.list.length) return React.createElement('div', { className: 'gho-panel-muted' }, t('panelEmpty'))
890
- return items.list.slice(0, 40).map((item, index) => React.createElement('div', { className: 'gho-panel-item', key: `${item.number || item.id || index}` },
891
- React.createElement('span', { className: 'gho-panel-file' }, item.number ? `#${item.number}` : (item.name || '')),
892
- React.createElement('span', null, item.title || item.displayTitle || item.reason || item.name || ''),
893
- item.url || item.html_url ? React.createElement('a', { className: 'gho-link', href: item.url || item.html_url, target: '_blank', rel: 'noreferrer' }, '↗') : null,
945
+ return items.list.slice(0, 40).map((item, index) => React.createElement('div', { key: `${item.number || item.id || index}` },
946
+ React.createElement('div', { className: 'gho-panel-item' },
947
+ React.createElement('span', { className: 'gho-panel-file' }, item.number ? `#${item.number}` : (item.name || '')),
948
+ React.createElement('span', null, item.title || item.displayTitle || item.reason || item.name || ''),
949
+ tab === 'runs' ? React.createElement('button', {
950
+ type: 'button',
951
+ className: 'gho-diagnose-btn',
952
+ style: { marginLeft: '8px' },
953
+ onClick: (e) => { e.stopPropagation(); loadDiagnostics(item.id) },
954
+ }, diagnoseLoadingId === item.id ? t('panelDiagnosing') : t('panelDiagnose')) : null,
955
+ item.url || item.html_url ? React.createElement('a', { className: 'gho-link', href: item.url || item.html_url, target: '_blank', rel: 'noreferrer' }, '↗') : null,
956
+ ),
957
+ diagnostics && diagnostics.runId === item.id ? React.createElement('div', { style: { padding: '8px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', margin: '4px 0 8px', background: 'var(--dsw-alias-bg-layer-2)' } },
958
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' } },
959
+ React.createElement('span', { style: { fontWeight: 600, fontSize: '12px' } }, diagnostics.summary || t('panelSteps')),
960
+ React.createElement('button', {
961
+ type: 'button',
962
+ className: 'gho-diagnose-btn',
963
+ style: { background: 'var(--dsw-alias-color-primary, #1f6feb)', color: '#fff', border: 0 },
964
+ onClick: () => askAgent(diagnostics),
965
+ }, copiedPrompt ? t('panelDiagnoseCopied') : t('panelDiagnoseAsk')),
966
+ ),
967
+ (diagnostics.steps || []).map((step, sIdx) => {
968
+ const isExp = expandedSteps[step.name] ?? step.hasError
969
+ return React.createElement('div', { key: sIdx, className: `gho-step-folded ${step.hasError ? 'gho-step-err' : ''}`, style: { margin: '4px 0' } },
970
+ React.createElement('div', {
971
+ style: { display: 'flex', justifyContent: 'space-between', cursor: 'pointer', padding: '4px 6px' },
972
+ onClick: () => setExpandedSteps((prev) => ({ ...prev, [step.name]: !isExp })),
973
+ },
974
+ React.createElement('span', { style: { fontSize: '12px', fontWeight: step.hasError ? 600 : 400 } }, `${isExp ? '▾' : '▸'} ${step.name} (${step.lineCount} lines)`),
975
+ step.hasError ? React.createElement('span', { className: 'gho-badge gho-badge-failing' }, t('panelFailedStep')) : React.createElement('span', { style: { color: 'var(--dsw-alias-color-success, #2ea043)' } }, '✓'),
976
+ ),
977
+ isExp && step.lines && step.lines.length ? React.createElement('pre', { className: 'gho-panel-pre gho-step-lines' }, step.lines.join('\n')) : null,
978
+ )
979
+ }),
980
+ ) : null,
894
981
  ))
895
982
  })()
896
983
 
@@ -951,6 +1038,44 @@ window.__ModuleLoader__.load({
951
1038
  )
952
1039
  })()
953
1040
 
1041
+ const securityView = (() => {
1042
+ if (securityData.phase === 'loading') return React.createElement('div', { className: 'gho-panel-muted' }, t('panelLoading'))
1043
+ const data = securityData.data
1044
+ if (!data) return React.createElement('div', { className: 'gho-panel-muted' }, t('panelEmpty'))
1045
+
1046
+ const allDisabled = !data.dependabot?.enabled && !data.secretScanning?.enabled && !data.codeScanning?.enabled
1047
+ if (allDisabled) {
1048
+ return React.createElement('div', { className: 'gho-panel-muted', style: { padding: '8px 0' } },
1049
+ t('panelSecurityDisabled'),
1050
+ data.dependabot?.reason ? React.createElement('div', { style: { fontSize: '11px', marginTop: '4px' } }, data.dependabot.reason) : null,
1051
+ )
1052
+ }
1053
+
1054
+ const alerts = [
1055
+ ...(data.dependabot?.alerts || []).map((a) => ({ ...a, kind: 'dependabot' })),
1056
+ ...(data.secretScanning?.alerts || []).map((a) => ({ ...a, kind: 'secret' })),
1057
+ ...(data.codeScanning?.alerts || []).map((a) => ({ ...a, kind: 'code' })),
1058
+ ]
1059
+
1060
+ if (!alerts.length) {
1061
+ return React.createElement('div', { className: 'gho-panel-muted', style: { padding: '8px 0' } }, t('panelSecurityEmpty'))
1062
+ }
1063
+
1064
+ return React.createElement('div', null,
1065
+ React.createElement('div', { style: { display: 'flex', gap: '8px', margin: '4px 0 8px' } },
1066
+ React.createElement('span', { className: 'gho-badge gho-badge-active' }, `${data.totalCount || 0} Total`),
1067
+ data.criticalCount ? React.createElement('span', { className: 'gho-badge gho-badge-failing' }, `${data.criticalCount} Critical`) : null,
1068
+ data.highCount ? React.createElement('span', { className: 'gho-badge gho-badge-building' }, `${data.highCount} High`) : null,
1069
+ ),
1070
+ alerts.map((alert, idx) => React.createElement('div', { key: alert.number || idx, className: 'gho-panel-item' },
1071
+ React.createElement('span', { className: 'gho-panel-file' }, alert.kind === 'dependabot' ? (alert.dependency?.packageName || 'pkg') : (alert.kind === 'secret' ? (alert.secretTypeDisplayName || 'Secret') : (alert.ruleId || 'CodeQL'))),
1072
+ React.createElement('span', { style: { flex: 1, fontSize: '12px' } }, alert.advisory?.summary || alert.message || alert.secretType || ''),
1073
+ React.createElement('span', { className: `gho-badge gho-badge-${(alert.advisory?.severity || alert.ruleSeverity || 'active').toLowerCase() === 'critical' ? 'failing' : 'building'}` }, alert.advisory?.severity || alert.ruleSeverity || alert.state),
1074
+ alert.url ? React.createElement('a', { className: 'gho-link', href: alert.url, target: '_blank', rel: 'noreferrer' }, '↗') : null,
1075
+ )),
1076
+ )
1077
+ })()
1078
+
954
1079
  return React.createElement('div', { className: 'gho-panel' },
955
1080
  header,
956
1081
  React.createElement('div', { className: 'gho-panel-tabs' },
@@ -958,12 +1083,13 @@ window.__ModuleLoader__.load({
958
1083
  tabButton('issues', t('panelIssues')),
959
1084
  tabButton('pulls', t('panelPulls')),
960
1085
  tabButton('runs', t('panelRuns')),
1086
+ tabButton('security', t('panelSecurity')),
961
1087
  tabButton('inbox', t('panelInbox')),
962
1088
  tabButton('matrix', t('panelMatrix')),
963
1089
  React.createElement('button', { type: 'button', className: 'gho-panel-tab', onClick: () => { setRepo(''); setFile(null) } }, t('panelRepo')),
964
1090
  ),
965
1091
  error ? React.createElement('div', { className: 'gho-panel-muted' }, error) : null,
966
- tab === 'tree' ? treeView : (tab === 'matrix' ? matrixView : list),
1092
+ tab === 'tree' ? treeView : (tab === 'matrix' ? matrixView : (tab === 'security' ? securityView : list)),
967
1093
  )
968
1094
  }
969
1095
 
@@ -998,6 +1124,10 @@ window.__ModuleLoader__.load({
998
1124
  '.gho-badge-building{background:var(--dsw-alias-color-warning-bg,rgba(210,153,34,0.15));color:var(--dsw-alias-color-warning,#d29922)}',
999
1125
  '.gho-badge-released{background:var(--dsw-alias-color-info-bg,rgba(56,139,253,0.15));color:var(--dsw-alias-color-info,#388bfd)}',
1000
1126
  '.gho-badge-active{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-secondary)}',
1127
+ '.gho-step-folded{border-radius:6px;background:var(--dsw-alias-bg-layer-2);margin-bottom:4px}',
1128
+ '.gho-step-err{border-left:3px solid var(--dsw-alias-label-error,#f85149);background:rgba(248,81,73,0.08)}',
1129
+ '.gho-step-lines{max-height:220px;overflow-y:auto;font-size:11px;padding:6px;margin:2px 0 6px}',
1130
+ '.gho-diagnose-btn{font-size:11px;padding:2px 6px;border-radius:4px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:transparent}',
1001
1131
  '.gho-matrix-table{width:100%;border-collapse:collapse;font-size:11px;margin-top:6px}',
1002
1132
  '.gho-matrix-th{text-align:left;padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}',
1003
1133
  '.gho-matrix-td{padding:5px 6px;border-bottom:1px solid var(--dsw-alias-border-l2);vertical-align:middle}',
package/lib/routes.js CHANGED
@@ -8,13 +8,14 @@
8
8
  import { existsSync, statSync, readFileSync } from 'node:fs'
9
9
  import { join as joinPath } from 'node:path'
10
10
 
11
- import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
11
+ import { statusPayload, isTrustedRequest, isTrustedWriteRequest, getRejectionStatus, getWriteRejectionStatus, sendJson } from './status.js'
12
12
  import { buildAction, validateCwd, validateStashRef, resolveConflictFile } from './scm-write.js'
13
13
  import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks, parseDiffHunks, parseGitGraph } from './scm.js'
14
14
  import { SseEventManager } from './events.js'
15
15
  import { encodeRepoPath } from './github.js'
16
16
  import { listIssues } from './tools/issues.js'
17
- import { listRuns } from './tools/actions.js'
17
+ import { listRuns, diagnoseRun } from './tools/actions.js'
18
+ import { listSecurityAlerts } from './tools/security.js'
18
19
  import { listNotifications, repoTreeView } from './tools/panel-data.js'
19
20
  import { mirrorStatus, syncMirror } from './tools/mirror.js'
20
21
  import { matrixStatus } from './tools/matrix.js'
@@ -64,6 +65,25 @@ export function registerRoutes(ctx, deps = {}) {
64
65
  makeGitRunner = () => () => ({ code: 1, stdout: '', stderr: 'git not available' }),
65
66
  cacheStats = () => null,
66
67
  } = deps
68
+
69
+ const resolveConnection = deps.resolveConnection || (() => {
70
+ try {
71
+ if (ctx && typeof ctx.get === 'function') {
72
+ const c = ctx.get('connection')
73
+ if (c) return c
74
+ }
75
+ } catch { /* ignore */ }
76
+ try {
77
+ if (ctx && ctx.connection) return ctx.connection
78
+ } catch { /* ignore */ }
79
+ try {
80
+ const c = Reflect.get(ctx, 'connection')
81
+ if (c) return c
82
+ } catch { /* ignore */ }
83
+ return undefined
84
+ })
85
+ const routeOptions = () => ({ get connection() { return resolveConnection() } })
86
+
67
87
  // The card reads the access status from here instead of from a tool result: the model has
68
88
  // no business holding the access value, and the payload describes this machine's account,
69
89
  // so only a local or same-origin caller is answered.
@@ -74,7 +94,10 @@ export function registerRoutes(ctx, deps = {}) {
74
94
  path: STATUS_PATH,
75
95
  handler: async (req, res) => {
76
96
  if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
77
- if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
97
+ if (!isTrustedRequest(req, routeOptions())) {
98
+ const status = getRejectionStatus(req, routeOptions()) || 403
99
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
100
+ }
78
101
  const access = await resolveToken()
79
102
  let identity = null
80
103
  let rateLimit = null
@@ -120,9 +143,15 @@ export function registerRoutes(ctx, deps = {}) {
120
143
  try {
121
144
  if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
122
145
  if (req.method === 'POST') {
123
- if (!isTrustedWriteRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
146
+ if (!isTrustedWriteRequest(req, routeOptions())) {
147
+ const status = getWriteRejectionStatus(req, routeOptions()) || 403
148
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
149
+ }
124
150
  } else {
125
- if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
151
+ if (!isTrustedRequest(req, routeOptions())) {
152
+ const status = getRejectionStatus(req, routeOptions()) || 403
153
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
154
+ }
126
155
  }
127
156
  const git = makeGitRunner()
128
157
  const url = new URL(req.url || '/', 'http://localhost')
@@ -306,7 +335,10 @@ export function registerRoutes(ctx, deps = {}) {
306
335
  path: PANEL_PATH,
307
336
  handler: async (req, res) => {
308
337
  if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
309
- if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
338
+ if (!isTrustedRequest(req, routeOptions())) {
339
+ const status = getRejectionStatus(req, routeOptions()) || 403
340
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
341
+ }
310
342
  const access = await resolveToken()
311
343
  if (!access.value) return sendJson(res, 200, { configured: false, guidance: access.guidance })
312
344
  const url = new URL(req.url || '/', 'http://localhost')
@@ -360,6 +392,17 @@ export function registerRoutes(ctx, deps = {}) {
360
392
  const result = await listRuns(github, { ...repo, limit: 20 })
361
393
  return sendJson(res, 200, { configured: true, items: result.runs })
362
394
  }
395
+ if (what === 'security') {
396
+ const severity = url.searchParams.get('severity') || undefined
397
+ const result = await listSecurityAlerts(github, { ...repo, state, severity, limit: 50 })
398
+ return sendJson(res, 200, { configured: true, ...result })
399
+ }
400
+ if (what === 'diagnose_run') {
401
+ const runId = Number(url.searchParams.get('runId'))
402
+ const jobId = url.searchParams.get('jobId') ? Number(url.searchParams.get('jobId')) : undefined
403
+ const result = await diagnoseRun(github, { ...repo, runId, jobId })
404
+ return sendJson(res, 200, { configured: true, ...result })
405
+ }
363
406
  if (what === 'inbox') {
364
407
  const result = await listNotifications(github, { limit: 30 })
365
408
  return sendJson(res, 200, { configured: true, ...result })
@@ -381,9 +424,15 @@ export function registerRoutes(ctx, deps = {}) {
381
424
  try {
382
425
  if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
383
426
  if (req.method === 'POST') {
384
- if (!isTrustedWriteRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
427
+ if (!isTrustedWriteRequest(req, routeOptions())) {
428
+ const status = getWriteRejectionStatus(req, routeOptions()) || 403
429
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
430
+ }
385
431
  } else {
386
- if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
432
+ if (!isTrustedRequest(req, routeOptions())) {
433
+ const status = getRejectionStatus(req, routeOptions()) || 403
434
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
435
+ }
387
436
  }
388
437
  const git = makeGitRunner()
389
438
  const url = new URL(req.url || '/', 'http://localhost')
@@ -451,7 +500,10 @@ export function registerRoutes(ctx, deps = {}) {
451
500
  handler: async (req, res) => {
452
501
  try {
453
502
  if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
454
- if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
503
+ if (!isTrustedRequest(req, routeOptions())) {
504
+ const status = getRejectionStatus(req, routeOptions()) || 403
505
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
506
+ }
455
507
  const access = await resolveToken()
456
508
  if (!access.value) return sendJson(res, 200, { configured: false, guidance: access.guidance })
457
509
  const url = new URL(req.url || '/', 'http://localhost')
@@ -474,7 +526,10 @@ export function registerRoutes(ctx, deps = {}) {
474
526
  handler: async (req, res) => {
475
527
  try {
476
528
  if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
477
- if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
529
+ if (!isTrustedRequest(req, routeOptions())) {
530
+ const status = getRejectionStatus(req, routeOptions()) || 403
531
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
532
+ }
478
533
  const git = makeGitRunner()
479
534
  const url = new URL(req.url || '/', 'http://localhost')
480
535
  const requested = validateCwd(url.searchParams.get('cwd') || '')
@@ -515,7 +570,10 @@ export function registerRoutes(ctx, deps = {}) {
515
570
  path: EVENTS_PATH,
516
571
  handler: async (req, res) => {
517
572
  if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
518
- if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
573
+ if (!isTrustedRequest(req, routeOptions())) {
574
+ const status = getRejectionStatus(req, routeOptions()) || 403
575
+ return sendJson(res, status, { error: status === 401 ? 'unauthorized' : 'forbidden' })
576
+ }
519
577
  res.writeHead(200, {
520
578
  'Content-Type': 'text/event-stream',
521
579
  'Cache-Control': 'no-cache, no-transform',
package/lib/status.js CHANGED
@@ -8,8 +8,46 @@ import { auditTokenScopes } from './token-audit.js'
8
8
  // The payload never contains the access value. The route answers only a local or same-origin
9
9
  // caller, because the payload describes the machine's account.
10
10
 
11
- /** Whether a request may read the status payload. Fail-closed. */
12
- function isTrustedHost(host) {
11
+ /** Whether an address or host string represents local loopback. */
12
+ export function isLoopbackAddress(val) {
13
+ if (!val || typeof val !== 'string') return false
14
+ let h = val.trim().toLowerCase()
15
+ if (h.startsWith('[')) {
16
+ const end = h.indexOf(']')
17
+ if (end > 0) h = h.slice(1, end)
18
+ } else if (h.includes(':')) {
19
+ const colons = h.split(':').length - 1
20
+ if (colons === 1) h = h.split(':')[0]
21
+ }
22
+ const clean = h.replace(/^::ffff:/, '')
23
+ return clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.') || clean === 'localhost' || clean === 'localhost.' || clean === '[::1]'
24
+ }
25
+ export const isLoopback = isLoopbackAddress
26
+
27
+ /** Whether an address or host string represents a private LAN network. */
28
+ export function isLanAddress(ip) {
29
+ if (!ip || typeof ip !== 'string') return false
30
+ let h = ip.trim().toLowerCase()
31
+ if (h.startsWith('[')) {
32
+ const end = h.indexOf(']')
33
+ if (end > 0) h = h.slice(1, end)
34
+ } else if (h.includes(':')) {
35
+ const colons = h.split(':').length - 1
36
+ if (colons === 1) h = h.split(':')[0]
37
+ }
38
+ const clean = h.replace(/^::ffff:/, '')
39
+ if (isLoopbackAddress(clean)) return true
40
+ if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
41
+ const match172 = clean.match(/^172\.(\d+)\./)
42
+ if (match172) {
43
+ const octet = parseInt(match172[1], 10)
44
+ if (octet >= 16 && octet <= 31) return true
45
+ }
46
+ return false
47
+ }
48
+
49
+ /** Whether a host header passes DNS-rebinding guards. Fail-closed. */
50
+ export function isTrustedHost(host) {
13
51
  if (!host || typeof host !== 'string') return false
14
52
  let hostname = host.trim()
15
53
  if (hostname.startsWith('[')) {
@@ -24,146 +62,226 @@ function isTrustedHost(host) {
24
62
  hostname = hostname.toLowerCase()
25
63
  if (!hostname) return false
26
64
  if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.lan')) return true
27
- const clean = hostname.replace(/^::ffff:/, '')
28
- if (clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.')) return true
29
- if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
30
- const match172 = clean.match(/^172\.(\d+)\./)
31
- if (match172) {
32
- const octet = parseInt(match172[1], 10)
33
- if (octet >= 16 && octet <= 31) return true
34
- }
35
- return false
65
+ return isLoopbackAddress(hostname) || isLanAddress(hostname)
36
66
  }
37
67
 
38
- /** Whether an address or host string represents local loopback. */
39
- function isLoopback(val) {
40
- if (!val || typeof val !== 'string') return false
41
- let h = val.trim().toLowerCase()
42
- if (h.startsWith('[')) {
43
- const end = h.indexOf(']')
44
- if (end > 0) h = h.slice(1, end)
45
- } else if (h.includes(':')) {
46
- const colons = h.split(':').length - 1
47
- if (colons === 1) h = h.split(':')[0]
48
- }
49
- const clean = h.replace(/^::ffff:/, '')
50
- return clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.') || clean === 'localhost' || clean === 'localhost.'
68
+ /** Resolves remote client IP from socket or request metadata. */
69
+ export function getClientIp(req) {
70
+ return (
71
+ req?.socket?.remoteAddress ||
72
+ req?.socket?.address?.()?.address ||
73
+ req?.connection?.remoteAddress ||
74
+ req?.info?.remoteAddress ||
75
+ ''
76
+ )
51
77
  }
52
78
 
53
- /** Whether a request may read the status payload. Fail-closed. */
54
- export function isTrustedRequest(req) {
55
- if (!req) return false
79
+ /**
80
+ * Evaluates whether an incoming HTTP request is trusted and authenticated (#129).
81
+ *
82
+ * Requirements:
83
+ * 1. If DSH connection service is available (options.connection), requestRejection(req)
84
+ * is the authoritative gate for browser session authentication and trusted network access.
85
+ * If rejection is returned, the numeric status (e.g. 401 or 403) is returned.
86
+ * 2. Host header, if present, must pass isTrustedHost (prevents DNS rebinding).
87
+ * 3. Cross-site requests (sec-fetch-site: cross-site) are strictly rejected with 403.
88
+ * 4. Origin & Referer headers (if present) must match Host or a trusted host.
89
+ * 5. Fallback when connection service is absent (unit tests, standalone runner):
90
+ * - Loopback callers (127.0.0.1, ::1) are allowed.
91
+ * - Remote / LAN callers without connection authentication are rejected with 403 (fail closed).
92
+ * - Unknown remote address (socket present but empty remoteAddress) fails closed with 403.
93
+ *
94
+ * @param {import('node:http').IncomingMessage} req
95
+ * @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
96
+ * @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
97
+ */
98
+ export function getRejectionStatus(req, options = {}) {
99
+ if (!req) return 403
56
100
 
57
- // Fail-closed against cross-site fetches regardless of source IP
58
- const secFetchSite = header(req, 'sec-fetch-site')
59
- if (secFetchSite === 'cross-site') return false
101
+ const connection = options?.connection
102
+ if (connection && typeof connection.requestRejection === 'function') {
103
+ try {
104
+ const rejection = connection.requestRejection(req)
105
+ if (rejection !== undefined) {
106
+ if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
107
+ return rejection
108
+ }
109
+ return rejection === 401 ? 401 : 403
110
+ }
111
+ } catch {
112
+ // Fail closed if connection service throws
113
+ return 403
114
+ }
115
+ }
60
116
 
61
- const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
62
- const cleanIp = address.replace(/^::ffff:/, '')
63
- const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
117
+ const host = header(req, 'host')
64
118
 
65
- // If socket remoteAddress is present, it MUST be a trusted local/private IP (never an untrusted remote IP)
66
- if (cleanIp && !isTrustedHost(cleanIp)) return false
119
+ // 1. Host header must be present and trusted if provided
120
+ if (host && !isTrustedHost(host)) {
121
+ return 403
122
+ }
67
123
 
68
- const host = header(req, 'host')
69
- // If host is provided, it MUST be a trusted local/private host (DNS rebinding guard)
70
- if (host && !isTrustedHost(host)) return false
124
+ // 2. Strict rejection of cross-site requests
125
+ const site = header(req, 'sec-fetch-site')
126
+ if (site === 'cross-site') {
127
+ return 403
128
+ }
71
129
 
130
+ // 3. Origin header validation if present
72
131
  const origin = header(req, 'origin')
73
- const referer = header(req, 'referer')
74
-
75
- // Check origin header first
76
132
  if (origin) {
133
+ if (origin === 'null') return 403
77
134
  try {
78
135
  const originUrl = new URL(origin)
79
- if (!isTrustedHost(originUrl.hostname)) return false
80
- if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return false
81
- return true
136
+ if (!isTrustedHost(originUrl.hostname)) return 403
137
+ if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return 403
82
138
  } catch {
83
- return false
139
+ return 403
84
140
  }
85
141
  }
86
142
 
87
- // Check referer header
143
+ // 4. Referer header validation if present
144
+ const referer = header(req, 'referer')
88
145
  if (referer) {
89
146
  try {
90
147
  const refUrl = new URL(referer)
91
148
  if (refUrl.protocol === 'http:' || refUrl.protocol === 'https:') {
92
- if (!isTrustedHost(refUrl.hostname)) return false
93
- if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return false
94
- return true
95
- }
96
- // Non-http referer (e.g. dsh-app://app/ in DSH Desktop Electron):
97
- // Desktop removes origin/sec-fetch-site and sets custom scheme referer.
98
- // Accept only if caller is loopback (socket or loopback host).
99
- const hostIsLoopback = host ? isLoopback(host) : false
100
- if (loopback || (!address && hostIsLoopback)) {
101
- return true
149
+ if (!isTrustedHost(refUrl.hostname)) return 403
150
+ if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return 403
151
+ } else {
152
+ // Non-http custom scheme referer (e.g. dsh-app://app/ in DSH Desktop Electron):
153
+ // Permitted only from local loopback
154
+ const ip = getClientIp(req)
155
+ const hostIsLoopback = host ? isLoopbackAddress(host) : false
156
+ const isLocal = ip ? isLoopbackAddress(ip) : hostIsLoopback
157
+ if (!isLocal) return 403
102
158
  }
103
- return false
104
159
  } catch {
105
- return false
160
+ return 403
106
161
  }
107
162
  }
108
163
 
109
- // If browser headers (origin/referer/sec-fetch-site) are absent and caller is loopback, allow local caller
110
- if (!secFetchSite && loopback) return true
164
+ // 5. Network boundary validation when connection service is absent:
165
+ const hasSocket = !!(req.socket || req.connection)
166
+ const ip = getClientIp(req)
111
167
 
112
- // Non-loopback caller without origin/referer is refused
113
- if (!loopback) return false
168
+ if (hasSocket) {
169
+ // Fail closed on missing/unknown remoteAddress
170
+ if (!ip) {
171
+ return 403
172
+ }
173
+ // Remote / LAN callers without connection authentication are rejected (fail closed!)
174
+ if (!isLoopbackAddress(ip)) {
175
+ if (!connection) {
176
+ return 403
177
+ }
178
+ }
179
+ } else if (!connection) {
180
+ // Synthetic mock request without socket: allow loopback host for unit tests
181
+ if (host && !isLoopbackAddress(host)) {
182
+ return 403
183
+ }
184
+ }
114
185
 
115
- return true
116
- }
186
+ // If browser headers are absent and caller is non-loopback without connection
187
+ if (!connection) {
188
+ const isLocal = ip ? isLoopbackAddress(ip) : (host ? isLoopbackAddress(host) : false)
189
+ if (!isLocal) return 403
190
+ }
117
191
 
118
- /** Whether a write/mutating request (e.g. POST /scm) is safe. Strict write guard against CSRF. */
119
- export function isTrustedWriteRequest(req) {
120
- if (!req) return false
121
- if (!isTrustedRequest(req)) return false
192
+ return undefined
193
+ }
122
194
 
123
- const secFetchSite = header(req, 'sec-fetch-site')
124
- if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
125
- return false
195
+ /**
196
+ * Evaluates whether an incoming HTTP write request is safe for mutation (#129).
197
+ *
198
+ * Additional Write Requirements:
199
+ * 1. Must satisfy all getRejectionStatus checks (including connection.requestRejection).
200
+ * 2. If Origin header is present:
201
+ * - must match Host exactly (same-origin).
202
+ * 3. If Referer header is present:
203
+ * - must match Host exactly (same-origin).
204
+ * 4. If Sec-Fetch-Site header is present:
205
+ * - must be "same-origin" or "none" (same-site and cross-site are rejected).
206
+ * 5. If connection service is absent, only local loopback callers are allowed for writes.
207
+ *
208
+ * @param {import('node:http').IncomingMessage} req
209
+ * @param {{ connection?: { requestRejection?: (req: any) => number | undefined } }} [options]
210
+ * @returns {number|undefined} Rejection HTTP status code (401, 403), or undefined if allowed.
211
+ */
212
+ export function getWriteRejectionStatus(req, options = {}) {
213
+ const readStatus = getRejectionStatus(req, options)
214
+ if (readStatus !== undefined) {
215
+ return readStatus
126
216
  }
127
217
 
128
- const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
129
- const cleanIp = address.replace(/^::ffff:/, '')
130
- const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
131
-
132
- if (cleanIp && !isTrustedHost(cleanIp)) return false
218
+ const site = header(req, 'sec-fetch-site')
219
+ if (site && site !== 'same-origin' && site !== 'none') {
220
+ return 403
221
+ }
133
222
 
134
223
  const host = header(req, 'host')
135
224
  const origin = header(req, 'origin')
136
225
  const referer = header(req, 'referer')
226
+ const ip = getClientIp(req)
227
+ const connection = options?.connection
137
228
 
138
229
  if (origin) {
230
+ if (origin === 'null') return 403
139
231
  try {
140
232
  const originUrl = new URL(origin)
141
- if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return false
233
+ if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return 403
142
234
  } catch {
143
- return false
235
+ return 403
144
236
  }
145
237
  } else if (referer) {
146
238
  try {
147
239
  const refUrl = new URL(referer)
148
240
  if (refUrl.protocol === 'http:' || refUrl.protocol === 'https:') {
149
- if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return false
241
+ if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return 403
150
242
  } else {
151
- // Non-http custom scheme referer from loopback: permitted as local loopback writer
152
- const hostIsLoopback = host ? isLoopback(host) : false
153
- if (!loopback && (address || !hostIsLoopback)) return false
243
+ const hostIsLoopback = host ? isLoopbackAddress(host) : false
244
+ const isLocal = ip ? isLoopbackAddress(ip) : hostIsLoopback
245
+ if (!isLocal) return 403
154
246
  }
155
247
  } catch {
156
- return false
248
+ return 403
157
249
  }
158
250
  } else {
159
251
  // Write request without origin/referer: only permit local loopback
160
- if (!loopback) return false
252
+ const hostIsLoopback = host ? isLoopbackAddress(host) : false
253
+ const isLocal = ip ? isLoopbackAddress(ip) : hostIsLoopback
254
+ if (!isLocal) return 403
161
255
  }
162
256
 
163
- return true
257
+ // If connection service is absent, only local loopback callers are allowed for writes
258
+ if (!connection) {
259
+ const hasSocket = !!(req.socket || req.connection)
260
+ if (hasSocket) {
261
+ if (!ip || !isLoopbackAddress(ip)) {
262
+ return 403
263
+ }
264
+ } else {
265
+ if (!host || !isLoopbackAddress(host)) {
266
+ return 403
267
+ }
268
+ }
269
+ }
270
+
271
+ return undefined
272
+ }
273
+
274
+ /** Whether a request may read the status payload. Fail-closed. */
275
+ export function isTrustedRequest(req, options = {}) {
276
+ return getRejectionStatus(req, options) === undefined
277
+ }
278
+
279
+ /** Whether a write/mutating request (e.g. POST /scm) is safe. Strict write guard against CSRF. */
280
+ export function isTrustedWriteRequest(req, options = {}) {
281
+ return getWriteRejectionStatus(req, options) === undefined
164
282
  }
165
283
 
166
- function header(req, name) {
284
+ export function header(req, name) {
167
285
  if (!req || !req.headers) return ''
168
286
  const value = req.headers[name] || req.headers[name.toLowerCase()]
169
287
  return typeof value === 'string' ? value.trim() : ''
@@ -185,3 +185,148 @@ export async function getJobLogSummary(client, { owner, repo, runId, jobId, maxL
185
185
  errorHighlights,
186
186
  }
187
187
  }
188
+
189
+ export function parseFoldedLog(rawText) {
190
+ if (!rawText || typeof rawText !== 'string') {
191
+ return { steps: [], failedStep: null }
192
+ }
193
+ const stripped = rawText.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '').replace(/\r/g, '')
194
+ const masked = stripped
195
+ .replace(/(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}/g, '[REDACTED_TOKEN]')
196
+ .replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED_TOKEN]')
197
+
198
+ const lines = masked.split('\n')
199
+ const steps = []
200
+ let currentStep = {
201
+ name: 'Setup',
202
+ lines: [],
203
+ hasError: false,
204
+ errorHighlights: [],
205
+ }
206
+
207
+ const pushCurrent = () => {
208
+ if (currentStep.lines.length > 0 || currentStep.name !== 'Setup') {
209
+ currentStep.errorHighlights = currentStep.lines
210
+ .filter((l) => /\b(error|failed|failure|fatal)\b|##\[error\]|npm err!/i.test(l))
211
+ .slice(-10)
212
+ steps.push(currentStep)
213
+ }
214
+ }
215
+
216
+ for (const rawLine of lines) {
217
+ const line = rawLine.replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s?/, '')
218
+ const groupMatch = line.match(/^##\[group\](.*)$/)
219
+ if (groupMatch) {
220
+ pushCurrent()
221
+ currentStep = {
222
+ name: groupMatch[1].trim() || 'Step',
223
+ lines: [],
224
+ hasError: false,
225
+ errorHighlights: [],
226
+ }
227
+ continue
228
+ }
229
+
230
+ if (line.startsWith('##[endgroup]')) {
231
+ pushCurrent()
232
+ currentStep = {
233
+ name: 'Step',
234
+ lines: [],
235
+ hasError: false,
236
+ errorHighlights: [],
237
+ }
238
+ continue
239
+ }
240
+
241
+ if (/\b(error|failed|failure|fatal)\b|##\[error\]|npm err!/i.test(line)) {
242
+ currentStep.hasError = true
243
+ }
244
+ currentStep.lines.push(line)
245
+ }
246
+
247
+ pushCurrent()
248
+
249
+ const validSteps = steps.filter((s) => s.lines.length > 0)
250
+ const failed = validSteps.slice().reverse().find((s) => s.hasError) || null
251
+
252
+ return {
253
+ steps: validSteps.map((s) => ({
254
+ name: s.name,
255
+ lineCount: s.lines.length,
256
+ hasError: s.hasError,
257
+ errorHighlights: s.errorHighlights,
258
+ lines: s.lines,
259
+ })),
260
+ failedStep: failed ? {
261
+ name: failed.name,
262
+ lineCount: failed.lines.length,
263
+ hasError: true,
264
+ lines: failed.lines,
265
+ errorHighlights: failed.errorHighlights,
266
+ } : null,
267
+ }
268
+ }
269
+
270
+ export async function diagnoseRun(client, { owner, repo, runId, jobId, maxLines = 150 } = {}) {
271
+ ensureRepo({ owner, repo })
272
+ let targetJobId = jobId
273
+ let targetJobName = ''
274
+
275
+ if (!targetJobId) {
276
+ if (!runId) throw new Error('either runId or jobId is required')
277
+ const { jobs } = await listRunJobs(client, { owner, repo, runId })
278
+ const failedJob = jobs.find((j) => j.conclusion === 'failure') || jobs[0]
279
+ if (!failedJob) {
280
+ return { note: `No jobs found for run ${runId}`, steps: [], failedStep: null, summary: 'No jobs found.' }
281
+ }
282
+ targetJobId = failedJob.id
283
+ targetJobName = failedJob.name
284
+ }
285
+
286
+ let logText = ''
287
+ try {
288
+ const res = await client.getRaw(`/repos/${owner}/${repo}/actions/jobs/${targetJobId}/logs`)
289
+ logText = typeof res === 'string' ? res : (res && res.data ? res.data : String(res || ''))
290
+ } catch (err) {
291
+ return {
292
+ runId,
293
+ jobId: targetJobId,
294
+ jobName: targetJobName,
295
+ error: `Could not retrieve logs: ${err && err.message}`,
296
+ steps: [],
297
+ failedStep: null,
298
+ summary: `Failed to fetch logs: ${err && err.message}`,
299
+ }
300
+ }
301
+
302
+ const parsed = parseFoldedLog(logText)
303
+ const cap = Math.max(1, Math.min(Number(maxLines) || 150, 500))
304
+
305
+ let failedStep = parsed.failedStep
306
+ if (failedStep && failedStep.lines.length > cap) {
307
+ failedStep = {
308
+ ...failedStep,
309
+ lines: failedStep.lines.slice(-cap),
310
+ lineCount: failedStep.lines.length,
311
+ }
312
+ }
313
+
314
+ const summary = failedStep
315
+ ? `Failed in job "${targetJobName || targetJobId}" at step "${failedStep.name}". Error lines:\n` +
316
+ failedStep.errorHighlights.map((e) => `• ${e}`).join('\n')
317
+ : `No failing step detected in job "${targetJobName || targetJobId}".`
318
+
319
+ return {
320
+ runId,
321
+ jobId: targetJobId,
322
+ jobName: targetJobName,
323
+ steps: parsed.steps.map((s) => ({
324
+ name: s.name,
325
+ lineCount: s.lineCount,
326
+ hasError: s.hasError,
327
+ errorHighlights: s.errorHighlights,
328
+ })),
329
+ failedStep,
330
+ summary,
331
+ }
332
+ }
@@ -1,3 +1,4 @@
1
+ import { listSecurityAlerts } from './security.js'
1
2
  // Composed reports: the answers a person actually asks for.
2
3
  //
3
4
  // Each function is a small composition over the plain reads, so the cache in the client
@@ -58,14 +59,23 @@ export async function listContributors(client, { owner, repo, limit = 10 } = {})
58
59
  export async function repoReport(client, { owner, repo, commits: commitLimit = 5, issues: issueLimit = 5, contributors: contributorLimit = 5 } = {}) {
59
60
  ensureRepo({ owner, repo })
60
61
  const overview = await getRepo(client, { owner, repo })
61
- const [releases, openIssues, commitList, contributorList] = await Promise.all([
62
+ const [releases, openIssues, commitList, contributorList, securityAlerts] = await Promise.all([
62
63
  listReleases(client, { owner, repo, limit: 1 }).catch(() => ({ releases: [] })),
63
64
  listIssues(client, { owner, repo, state: 'open', limit: issueLimit }).catch(() => ({ issues: [] })),
64
65
  listCommits(client, { owner, repo, branch: overview.defaultBranch, limit: commitLimit }).catch(() => ({ commits: [] })),
65
66
  listContributors(client, { owner, repo, limit: contributorLimit }).catch(() => ({ contributors: [] })),
67
+ listSecurityAlerts(client, { owner, repo, limit: 10 }).catch(() => null),
66
68
  ])
67
69
  return {
68
70
  repo: overview.fullName,
71
+ security: securityAlerts ? {
72
+ totalCount: securityAlerts.totalCount,
73
+ criticalCount: securityAlerts.criticalCount,
74
+ highCount: securityAlerts.highCount,
75
+ dependabotEnabled: securityAlerts.dependabot?.enabled,
76
+ secretScanningEnabled: securityAlerts.secretScanning?.enabled,
77
+ codeScanningEnabled: securityAlerts.codeScanning?.enabled,
78
+ } : { enabled: false },
69
79
  description: overview.description,
70
80
  stars: overview.stars,
71
81
  forks: overview.forks,
@@ -137,7 +147,7 @@ export async function listNotifications(client, { all = false, participating = f
137
147
  * A transparent maintenance heuristic. Every point of the score carries its evidence, and the
138
148
  * result states plainly that it is not a security audit.
139
149
  */
140
- export function healthOf({ repo, releases = [], issues = [], pulls = [], commits = [] } = {}) {
150
+ export function healthOf({ repo, releases = [], issues = [], pulls = [], commits = [], security = null } = {}) {
141
151
  const dimensions = []
142
152
  const daysSince = (value) => (value ? Math.floor((Date.now() - Date.parse(value)) / (24 * 60 * 60 * 1000)) : null)
143
153
 
@@ -166,12 +176,14 @@ export function healthOf({ repo, releases = [], issues = [], pulls = [], commits
166
176
  if (!(repo && repo.license)) risks.push('no license declared')
167
177
  if (!(repo && repo.description)) risks.push('no description')
168
178
  if (openCount > 10) risks.push('large open issue backlog')
179
+ if (security && security.criticalCount > 0) risks.push(`${security.criticalCount} critical security alert(s) detected`)
169
180
 
170
181
  const nextActions = []
171
182
  if (!releases.length) nextActions.push('publish the first release')
172
183
  if (!(repo && repo.license)) nextActions.push('add a LICENSE')
173
184
  if (!(repo && repo.description)) nextActions.push('write a repository description')
174
185
  if (pushed !== null && pushed > 90) nextActions.push('push a change or archive the repository')
186
+ if (security && security.criticalCount > 0) nextActions.push('resolve critical security alerts')
175
187
 
176
188
  return {
177
189
  repo: repo && repo.fullName,
@@ -187,14 +199,15 @@ export function healthOf({ repo, releases = [], issues = [], pulls = [], commits
187
199
  /** Repository health for one repository, gathering its own evidence. */
188
200
  export async function repoHealth(client, { owner, repo } = {}) {
189
201
  ensureRepo({ owner, repo })
190
- const [repository, releases, issues, pulls, commits] = await Promise.all([
202
+ const [repository, releases, issues, pulls, commits, security] = await Promise.all([
191
203
  getRepo(client, { owner, repo }),
192
204
  listReleases(client, { owner, repo, limit: 3 }).catch(() => ({ releases: [] })),
193
205
  listIssues(client, { owner, repo, state: 'open', limit: 30 }).catch(() => ({ issues: [] })),
194
206
  listIssues(client, { owner, repo, state: 'open', kind: 'pr', limit: 30 }).catch(() => ({ issues: [] })),
195
207
  listCommits(client, { owner, repo, limit: 10 }).catch(() => ({ commits: [] })),
208
+ listSecurityAlerts(client, { owner, repo, limit: 10 }).catch(() => null),
196
209
  ])
197
- return healthOf({ repo: repository, releases: releases.releases, issues: issues.issues, pulls: pulls.issues, commits: commits.commits })
210
+ return healthOf({ repo: repository, releases: releases.releases, issues: issues.issues, pulls: pulls.issues, commits: commits.commits, security })
198
211
  }
199
212
 
200
213
  /** Two repositories side by side, with numeric deltas. */
@@ -1,3 +1,4 @@
1
+ import { listSecurityAlerts, getDependabotAlert } from './security.js'
1
2
  // Registrations for the composed reports: the answers a person asks for in one call.
2
3
 
3
4
  import {
@@ -123,4 +124,33 @@ export function registerInsightTools({ tool, asRepo, client, liveConfig }) {
123
124
  return listCommits(await client(), { owner, repo, branch: args.branch, since: args.since, limit: args.limit })
124
125
  },
125
126
  )
127
+
128
+ tool(
129
+ 'gh_security_alerts',
130
+ 'Summary of Dependabot, Secret Scanning, and CodeQL alerts for a repository. Gracefully degrades if alerts are disabled or token lacks security scope. Read-only.',
131
+ repoParams({
132
+ state: { type: 'string', description: 'Filter by state (open, fixed, dismissed, all; default "open").' },
133
+ severity: { type: 'string', description: 'Filter by severity (critical, high, medium, low).' },
134
+ limit: { type: 'number', description: 'Max alerts per category (default 30, max 100).' },
135
+ }),
136
+ async (args) => {
137
+ const { owner, repo } = await asRepo(args, liveConfig())
138
+ return listSecurityAlerts(await client(), {
139
+ owner, repo, state: args.state, severity: args.severity, limit: args.limit,
140
+ })
141
+ },
142
+ )
143
+
144
+ tool(
145
+ 'gh_dependabot_alert_view',
146
+ 'Read details of a Dependabot alert: vulnerable package, advisory summary, CVSS score, and recommended safe version. Read-only.',
147
+ repoParams({
148
+ alertNumber: { type: 'number', description: 'The Dependabot alert number.' },
149
+ }),
150
+ async (args) => {
151
+ const { owner, repo } = await asRepo(args, liveConfig())
152
+ const num = args.alertNumber ?? args.alert_number
153
+ return getDependabotAlert(await client(), { owner, repo, alertNumber: num })
154
+ },
155
+ )
126
156
  }
@@ -13,6 +13,7 @@ import {
13
13
  getRunLogsUrl,
14
14
  dispatchWorkflow,
15
15
  getJobLogSummary,
16
+ diagnoseRun,
16
17
  } from './actions.js'
17
18
  import {
18
19
  listVariables,
@@ -146,6 +147,19 @@ export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
146
147
  })),
147
148
  )
148
149
 
150
+ tool(
151
+ 'gh_run_diagnose',
152
+ 'Diagnose a GitHub Actions workflow run failure: parses the job log into folded steps, identifies the failing step, strips ANSI codes, and extracts error highlights and root causes. Read-only.',
153
+ repoParams({
154
+ runId: { type: 'number', description: 'Workflow run id (e.g. from gh_run_list).' },
155
+ jobId: { type: 'number', description: 'Specific job id to inspect (optional, defaults to first failed job).' },
156
+ maxLines: { type: 'number', description: 'Maximum log lines to return for the failed step (default 150, max 500).' },
157
+ }),
158
+ async (args) => withRepo(args, async ({ owner, repo }) => diagnoseRun(await client(), {
159
+ owner, repo, runId: args.runId, jobId: args.jobId, maxLines: args.maxLines,
160
+ })),
161
+ )
162
+
149
163
  // ---------------------------------------------------------------- variables
150
164
 
151
165
  tool(
@@ -0,0 +1,204 @@
1
+ // GitHub Security Alerts: Dependabot, Secret Scanning, and CodeQL (Code Scanning).
2
+ //
3
+ // These endpoints return 404 or 403 if the repository does not have the feature enabled
4
+ // or if the token lacks the security_events / repo scope. The tools degrade gracefully
5
+ // with { enabled: false, reason: "..." } instead of failing the caller.
6
+
7
+ function ensureRepo({ owner, repo }) {
8
+ if (!owner || !repo) {
9
+ throw new Error('owner and repo are required (pass "repository": "owner/repo" or set defaults in settings)')
10
+ }
11
+ }
12
+
13
+ function redactSecrets(text) {
14
+ if (typeof text !== 'string') return text
15
+ return text
16
+ .replace(/(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}/g, '[REDACTED_TOKEN]')
17
+ .replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED_TOKEN]')
18
+ }
19
+
20
+ async function safeAlertCall(client, path, query, featureName) {
21
+ try {
22
+ const { data } = await client.get(path, { query })
23
+ return { enabled: true, data: Array.isArray(data) ? data : [] }
24
+ } catch (err) {
25
+ const status = err && err.status
26
+ if (status === 404 || status === 403) {
27
+ return {
28
+ enabled: false,
29
+ reason: `${featureName} is not enabled or token lacks required security scope (HTTP ${status})`,
30
+ data: [],
31
+ }
32
+ }
33
+ throw err
34
+ }
35
+ }
36
+
37
+ export function normalizeDependabotAlert(item) {
38
+ if (!item || typeof item !== 'object') return item
39
+ const adv = item.security_advisory || {}
40
+ const dep = item.dependency || {}
41
+ const pkg = dep.package || {}
42
+ const vuln = item.security_vulnerability || {}
43
+ return {
44
+ number: item.number,
45
+ state: item.state,
46
+ dependency: {
47
+ packageName: pkg.name || '',
48
+ ecosystem: pkg.ecosystem || '',
49
+ manifestPath: dep.manifest_path || '',
50
+ scope: dep.scope || '',
51
+ },
52
+ advisory: {
53
+ ghsaId: adv.ghsa_id || '',
54
+ cveId: adv.cve_id || '',
55
+ summary: redactSecrets(adv.summary || ''),
56
+ severity: adv.severity || '',
57
+ cvssScore: (adv.cvss && adv.cvss.score) ?? null,
58
+ },
59
+ fixedIn: (vuln.first_patched_version && vuln.first_patched_version.identifier) || null,
60
+ url: item.html_url || '',
61
+ createdAt: item.created_at || '',
62
+ }
63
+ }
64
+
65
+ export function normalizeSecretAlert(item) {
66
+ if (!item || typeof item !== 'object') return item
67
+ return {
68
+ number: item.number,
69
+ secretType: item.secret_type || '',
70
+ secretTypeDisplayName: item.secret_type_display_name || item.secret_type || '',
71
+ state: item.state || '',
72
+ resolution: item.resolution || null,
73
+ url: item.html_url || '',
74
+ createdAt: item.created_at || '',
75
+ }
76
+ }
77
+
78
+ export function normalizeCodeScanningAlert(item) {
79
+ if (!item || typeof item !== 'object') return item
80
+ const rule = item.rule || {}
81
+ const inst = item.most_recent_instance || {}
82
+ const loc = inst.location || {}
83
+ return {
84
+ number: item.number,
85
+ ruleId: rule.id || '',
86
+ ruleSeverity: rule.security_severity_level || rule.severity || '',
87
+ ruleDescription: rule.description || '',
88
+ state: item.state || '',
89
+ message: redactSecrets((inst.message && inst.message.text) || ''),
90
+ path: loc.path || '',
91
+ startLine: loc.start_line ?? null,
92
+ url: item.html_url || '',
93
+ createdAt: item.created_at || '',
94
+ }
95
+ }
96
+
97
+ /**
98
+ * List security alerts across Dependabot, Secret Scanning, and CodeQL.
99
+ * Gracefully degrades if any feature is disabled or permissions are missing.
100
+ */
101
+ export async function listSecurityAlerts(client, { owner, repo, state = 'open', severity, limit = 30 } = {}) {
102
+ ensureRepo({ owner, repo })
103
+ const perPage = Math.max(1, Math.min(Number(limit) || 30, 100))
104
+
105
+ const depQuery = { per_page: perPage }
106
+ if (state && state !== 'all') depQuery.state = state
107
+ if (severity) depQuery.severity = severity
108
+
109
+ const secQuery = { per_page: perPage }
110
+ if (state && state !== 'all') secQuery.state = state === 'fixed' || state === 'dismissed' ? 'resolved' : state
111
+
112
+ const codeQuery = { per_page: perPage }
113
+ if (state && state !== 'all') codeQuery.state = state
114
+ if (severity) codeQuery.severity = severity
115
+
116
+ const [depRes, secRes, codeRes] = await Promise.all([
117
+ safeAlertCall(client, `/repos/${owner}/${repo}/dependabot/alerts`, depQuery, 'Dependabot'),
118
+ safeAlertCall(client, `/repos/${owner}/${repo}/secret-scanning/alerts`, secQuery, 'Secret Scanning'),
119
+ safeAlertCall(client, `/repos/${owner}/${repo}/code-scanning/alerts`, codeQuery, 'Code Scanning'),
120
+ ])
121
+
122
+ const dependabotAlerts = depRes.data.map(normalizeDependabotAlert)
123
+ const secretAlerts = secRes.data.map(normalizeSecretAlert)
124
+ const codeAlerts = codeRes.data.map(normalizeCodeScanningAlert)
125
+
126
+ let criticalCount = 0
127
+ let highCount = 0
128
+ let mediumCount = 0
129
+ let lowCount = 0
130
+
131
+ for (const a of dependabotAlerts) {
132
+ const s = String(a.advisory?.severity || '').toLowerCase()
133
+ if (s === 'critical') criticalCount++
134
+ else if (s === 'high') highCount++
135
+ else if (s === 'medium') mediumCount++
136
+ else if (s === 'low') lowCount++
137
+ }
138
+
139
+ for (const a of codeAlerts) {
140
+ const s = String(a.ruleSeverity || '').toLowerCase()
141
+ if (s === 'critical') criticalCount++
142
+ else if (s === 'high' || s === 'error') highCount++
143
+ else if (s === 'medium' || s === 'warning') mediumCount++
144
+ else if (s === 'low' || s === 'note') lowCount++
145
+ }
146
+
147
+ for (const a of secretAlerts) {
148
+ if (a.state === 'open') criticalCount++
149
+ }
150
+
151
+ const totalCount = dependabotAlerts.length + secretAlerts.length + codeAlerts.length
152
+
153
+ return {
154
+ owner,
155
+ repo,
156
+ totalCount,
157
+ criticalCount,
158
+ highCount,
159
+ mediumCount,
160
+ lowCount,
161
+ dependabot: {
162
+ enabled: depRes.enabled,
163
+ reason: depRes.reason,
164
+ count: dependabotAlerts.length,
165
+ alerts: dependabotAlerts,
166
+ },
167
+ secretScanning: {
168
+ enabled: secRes.enabled,
169
+ reason: secRes.reason,
170
+ count: secretAlerts.length,
171
+ alerts: secretAlerts,
172
+ },
173
+ codeScanning: {
174
+ enabled: codeRes.enabled,
175
+ reason: codeRes.reason,
176
+ count: codeAlerts.length,
177
+ alerts: codeAlerts,
178
+ },
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Get details for a single Dependabot alert.
184
+ */
185
+ export async function getDependabotAlert(client, { owner, repo, alertNumber }) {
186
+ ensureRepo({ owner, repo })
187
+ if (!alertNumber) throw new Error('alertNumber is required')
188
+ const num = Number(alertNumber)
189
+ const { data } = await client.get(`/repos/${owner}/${repo}/dependabot/alerts/${num}`)
190
+ const norm = normalizeDependabotAlert(data)
191
+ const adv = (data && data.security_advisory) || {}
192
+ return {
193
+ ...norm,
194
+ advisory: {
195
+ ...norm.advisory,
196
+ description: redactSecrets(adv.description || ''),
197
+ references: (adv.references || []).map((r) => r.url || r),
198
+ },
199
+ updatedAt: data.updated_at || '',
200
+ dismissedAt: data.dismissed_at || '',
201
+ dismissReason: data.dismiss_reason || '',
202
+ fixedAt: data.fixed_at || '',
203
+ }
204
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
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",