@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 +136 -6
- package/lib/routes.js +69 -11
- package/lib/status.js +204 -86
- package/lib/tools/actions.js +145 -0
- package/lib/tools/insights.js +17 -4
- package/lib/tools/register-insights.js +30 -0
- package/lib/tools/register-ops.js +14 -0
- package/lib/tools/security.js +204 -0
- package/package.json +1 -1
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', {
|
|
891
|
-
React.createElement('
|
|
892
|
-
|
|
893
|
-
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
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
|
|
12
|
-
function
|
|
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
|
-
|
|
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
|
-
/**
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
//
|
|
66
|
-
if (
|
|
119
|
+
// 1. Host header must be present and trusted if provided
|
|
120
|
+
if (host && !isTrustedHost(host)) {
|
|
121
|
+
return 403
|
|
122
|
+
}
|
|
67
123
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
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
|
|
80
|
-
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return
|
|
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
|
|
139
|
+
return 403
|
|
84
140
|
}
|
|
85
141
|
}
|
|
86
142
|
|
|
87
|
-
//
|
|
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
|
|
93
|
-
if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
160
|
+
return 403
|
|
106
161
|
}
|
|
107
162
|
}
|
|
108
163
|
|
|
109
|
-
//
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
if (!req) return false
|
|
121
|
-
if (!isTrustedRequest(req)) return false
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
122
194
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
233
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return 403
|
|
142
234
|
} catch {
|
|
143
|
-
return
|
|
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
|
|
241
|
+
if (host && refUrl.host.toLowerCase() !== host.toLowerCase()) return 403
|
|
150
242
|
} else {
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
if (!
|
|
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
|
|
248
|
+
return 403
|
|
157
249
|
}
|
|
158
250
|
} else {
|
|
159
251
|
// Write request without origin/referer: only permit local loopback
|
|
160
|
-
|
|
252
|
+
const hostIsLoopback = host ? isLoopbackAddress(host) : false
|
|
253
|
+
const isLocal = ip ? isLoopbackAddress(ip) : hostIsLoopback
|
|
254
|
+
if (!isLocal) return 403
|
|
161
255
|
}
|
|
162
256
|
|
|
163
|
-
|
|
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() : ''
|
package/lib/tools/actions.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/tools/insights.js
CHANGED
|
@@ -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.
|
|
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",
|