@aldegad/safedeps 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +595 -0
- package/LICENSE +190 -0
- package/README.md +311 -0
- package/ROADMAP.md +131 -0
- package/SKILL.md +200 -0
- package/agents/openai.yaml +4 -0
- package/bin/safedeps +842 -0
- package/lib/ledger/ledger.sh +346 -0
- package/lib/providers/providers.sh +479 -0
- package/package.json +41 -0
- package/scripts/install/install-safedeps-hooks.mjs +209 -0
- package/scripts/install/install-safedeps-recheck-agent.mjs +203 -0
- package/scripts/install/migrate-safedeps-state.mjs +91 -0
- package/scripts/safedeps-post-verify.sh +584 -0
- package/scripts/safedeps-pre-guard.sh +427 -0
- package/scripts/safedeps-recheck-alert.sh +115 -0
- package/scripts/test/e2e.sh +107 -0
- package/scripts/test/fixture-provider.mjs +104 -0
- package/scripts/test/smoke.sh +89 -0
package/bin/safedeps
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# safedeps — multi-ecosystem dependency install safety gate (CLI).
|
|
3
|
+
# Phase 1 advisory gate: OSV (canonical) + CISA KEV (overlay) + GHSA (enrichment)
|
|
4
|
+
# → approved-spec ledger write. Hook (scripts/safedeps-pre-guard.sh,
|
|
5
|
+
# scripts/safedeps-post-verify.sh) only
|
|
6
|
+
# enforces the ledger; this CLI is the only place that talks to providers.
|
|
7
|
+
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
SAFEDEPS_VERSION="2.1.0"
|
|
11
|
+
|
|
12
|
+
# ---- repo / lib bootstrap ----------------------------------------------------
|
|
13
|
+
|
|
14
|
+
SAFEDEPS_BIN_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
15
|
+
SAFEDEPS_REPO_DIR=$(cd "${SAFEDEPS_BIN_DIR}/.." && pwd)
|
|
16
|
+
|
|
17
|
+
# shellcheck source=../lib/providers/providers.sh
|
|
18
|
+
source "${SAFEDEPS_REPO_DIR}/lib/providers/providers.sh"
|
|
19
|
+
# shellcheck source=../lib/ledger/ledger.sh
|
|
20
|
+
source "${SAFEDEPS_REPO_DIR}/lib/ledger/ledger.sh"
|
|
21
|
+
|
|
22
|
+
SAFEDEPS_HOME="${SAFEDEPS_HOME:-${HOME}/.safedeps}"
|
|
23
|
+
SAFEDEPS_LEDGER_DIR="${SAFEDEPS_LEDGER_DIR:-${SAFEDEPS_HOME}/approved-specs}"
|
|
24
|
+
SAFEDEPS_ADVISORY_LOG="${SAFEDEPS_ADVISORY_LOG:-${SAFEDEPS_HOME}/advisory.log}"
|
|
25
|
+
|
|
26
|
+
# ---- output mode -------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
SAFEDEPS_JSON_MODE=0
|
|
29
|
+
SAFEDEPS_NO_COLOR=0
|
|
30
|
+
|
|
31
|
+
sf_color_init() {
|
|
32
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]] || [[ "${SAFEDEPS_NO_COLOR}" -eq 1 ]] || [[ -n "${NO_COLOR:-}" ]] || [[ ! -t 1 ]]; then
|
|
33
|
+
C_RED=''; C_YELLOW=''; C_GREEN=''; C_GRAY=''; C_BOLD=''; C_DIM=''; C_RESET=''
|
|
34
|
+
else
|
|
35
|
+
C_RED=$'\033[31m'
|
|
36
|
+
C_YELLOW=$'\033[33m'
|
|
37
|
+
C_GREEN=$'\033[32m'
|
|
38
|
+
C_GRAY=$'\033[90m'
|
|
39
|
+
C_BOLD=$'\033[1m'
|
|
40
|
+
C_DIM=$'\033[2m'
|
|
41
|
+
C_RESET=$'\033[0m'
|
|
42
|
+
fi
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
sf_human() { [[ "${SAFEDEPS_JSON_MODE}" -eq 0 ]]; }
|
|
46
|
+
|
|
47
|
+
sf_info() { sf_human || return 0; printf '%s· %s%s\n' "${C_GRAY}" "$1" "${C_RESET}"; }
|
|
48
|
+
sf_ok() { sf_human || return 0; printf '%s✓ %s%s\n' "${C_GREEN}" "$1" "${C_RESET}"; }
|
|
49
|
+
sf_warn() { sf_human || return 0; printf '%s⚠ %s%s\n' "${C_YELLOW}" "$1" "${C_RESET}"; }
|
|
50
|
+
sf_err() { sf_human || return 0; printf '%s✗ %s%s\n' "${C_RED}" "$1" "${C_RESET}"; }
|
|
51
|
+
|
|
52
|
+
sf_eprintf() { printf '%s\n' "$*" >&2; }
|
|
53
|
+
|
|
54
|
+
# Lightweight spinner. No-op in JSON mode or non-tty.
|
|
55
|
+
SF_SPINNER_PID=""
|
|
56
|
+
sf_spinner_start() {
|
|
57
|
+
sf_human || return 0
|
|
58
|
+
[[ -t 1 ]] || return 0
|
|
59
|
+
local label="$1"
|
|
60
|
+
(
|
|
61
|
+
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
|
62
|
+
local i=0
|
|
63
|
+
while :; do
|
|
64
|
+
printf '\r%s%s%s %s' "${C_GRAY}" "${frames[$i]}" "${C_RESET}" "${label}" >&2
|
|
65
|
+
i=$(( (i + 1) % ${#frames[@]} ))
|
|
66
|
+
sleep 0.08
|
|
67
|
+
done
|
|
68
|
+
) &
|
|
69
|
+
SF_SPINNER_PID=$!
|
|
70
|
+
disown "${SF_SPINNER_PID}" 2>/dev/null || true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
sf_spinner_stop() {
|
|
74
|
+
[[ -n "${SF_SPINNER_PID}" ]] || return 0
|
|
75
|
+
kill "${SF_SPINNER_PID}" 2>/dev/null || true
|
|
76
|
+
wait "${SF_SPINNER_PID}" 2>/dev/null || true
|
|
77
|
+
SF_SPINNER_PID=""
|
|
78
|
+
sf_human && printf '\r\033[2K' >&2 || true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
trap 'sf_spinner_stop' EXIT
|
|
82
|
+
|
|
83
|
+
# ---- helpers -----------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
sf_require_jq() {
|
|
86
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
87
|
+
sf_eprintf "safedeps: jq is required"
|
|
88
|
+
exit 4
|
|
89
|
+
fi
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
sf_mktemp_evidence() {
|
|
93
|
+
local tmp_dir="${TMPDIR:-/tmp}"
|
|
94
|
+
|
|
95
|
+
mkdir -p "${tmp_dir}"
|
|
96
|
+
mktemp "${tmp_dir%/}/safedeps-evidence.XXXXXX"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# parse "pkg@range" or "@scope/pkg@range" → pkg / range
|
|
100
|
+
sf_parse_pkg_spec() {
|
|
101
|
+
local input="$1"
|
|
102
|
+
local pkg range
|
|
103
|
+
if [[ "${input}" =~ ^(.+)@([^@]+)$ ]]; then
|
|
104
|
+
pkg="${BASH_REMATCH[1]}"
|
|
105
|
+
range="${BASH_REMATCH[2]}"
|
|
106
|
+
else
|
|
107
|
+
pkg="${input}"
|
|
108
|
+
range=""
|
|
109
|
+
fi
|
|
110
|
+
printf '%s\n%s' "${pkg}" "${range}"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# heuristic — does this look like a semver range rather than a concrete version?
|
|
114
|
+
sf_is_range() {
|
|
115
|
+
local v="$1"
|
|
116
|
+
[[ -z "${v}" ]] && return 1
|
|
117
|
+
case "${v}" in
|
|
118
|
+
\^*|\~*|\**|\>*|\<*|\=*) return 0 ;;
|
|
119
|
+
esac
|
|
120
|
+
[[ "${v}" == *"||"* ]] && return 0
|
|
121
|
+
[[ "${v}" == *" - "* ]] && return 0
|
|
122
|
+
return 1
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# resolve a range to a concrete installable version via the ecosystem tool.
|
|
126
|
+
# Caller is responsible for handling resolve failure (returns 1).
|
|
127
|
+
sf_resolve_version() {
|
|
128
|
+
local ecosystem="$1" pkg="$2" range="$3"
|
|
129
|
+
|
|
130
|
+
if [[ -z "${range}" ]]; then
|
|
131
|
+
sf_eprintf "safedeps: missing version — usage: <ecosystem> <pkg>@<version|range>"
|
|
132
|
+
return 1
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
if ! sf_is_range "${range}"; then
|
|
136
|
+
printf '%s' "${range}"
|
|
137
|
+
return 0
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
case "${ecosystem}" in
|
|
141
|
+
npm)
|
|
142
|
+
if ! command -v npm >/dev/null 2>&1; then
|
|
143
|
+
sf_eprintf "safedeps: npm CLI required to resolve range '${range}' for ${pkg}"
|
|
144
|
+
return 1
|
|
145
|
+
fi
|
|
146
|
+
local resolved
|
|
147
|
+
resolved=$(npm view "${pkg}@${range}" version --json 2>/dev/null \
|
|
148
|
+
| jq -r 'if type=="array" then .[-1] elif type=="string" then . else empty end' 2>/dev/null) || true
|
|
149
|
+
[[ -n "${resolved}" ]] || {
|
|
150
|
+
sf_eprintf "safedeps: could not resolve ${pkg}@${range} via npm view"
|
|
151
|
+
return 1
|
|
152
|
+
}
|
|
153
|
+
printf '%s' "${resolved}"
|
|
154
|
+
;;
|
|
155
|
+
*)
|
|
156
|
+
sf_eprintf "safedeps: range resolution for ecosystem '${ecosystem}' not implemented yet — pass a concrete version"
|
|
157
|
+
return 1
|
|
158
|
+
;;
|
|
159
|
+
esac
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Extract lowest patched version greater than current_version from OSV vulns.
|
|
163
|
+
# Echoes the patched version on stdout, or returns 1 if no usable fix.
|
|
164
|
+
sf_extract_patched_version() {
|
|
165
|
+
local provider_json_file="$1" current_version="$2"
|
|
166
|
+
local fixed
|
|
167
|
+
fixed=$(jq -r '
|
|
168
|
+
[ .vulnerabilities[]?.affected[]?.ranges[]?.events[]?.fixed // empty ]
|
|
169
|
+
| unique
|
|
170
|
+
| .[]
|
|
171
|
+
' "${provider_json_file}" 2>/dev/null)
|
|
172
|
+
|
|
173
|
+
[[ -z "${fixed}" ]] && return 1
|
|
174
|
+
|
|
175
|
+
local candidate=""
|
|
176
|
+
while IFS= read -r v; do
|
|
177
|
+
[[ -z "${v}" ]] && continue
|
|
178
|
+
# require v > current_version
|
|
179
|
+
local higher
|
|
180
|
+
higher=$(printf '%s\n%s\n' "${v}" "${current_version}" | sort -V | tail -1)
|
|
181
|
+
[[ "${higher}" != "${v}" || "${v}" == "${current_version}" ]] && continue
|
|
182
|
+
if [[ -z "${candidate}" ]]; then
|
|
183
|
+
candidate="${v}"
|
|
184
|
+
else
|
|
185
|
+
local lower
|
|
186
|
+
lower=$(printf '%s\n%s\n' "${candidate}" "${v}" | sort -V | head -1)
|
|
187
|
+
candidate="${lower}"
|
|
188
|
+
fi
|
|
189
|
+
done <<< "${fixed}"
|
|
190
|
+
|
|
191
|
+
[[ -n "${candidate}" ]] || return 1
|
|
192
|
+
printf '%s' "${candidate}"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
sf_advisory_log() {
|
|
196
|
+
umask 077
|
|
197
|
+
mkdir -p "$(dirname "${SAFEDEPS_ADVISORY_LOG}")"
|
|
198
|
+
printf '[%s] %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$*" >> "${SAFEDEPS_ADVISORY_LOG}"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Emit either JSON or human text. Both forms describe the same event.
|
|
202
|
+
sf_emit_json() {
|
|
203
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
204
|
+
jq -c . <<< "$1"
|
|
205
|
+
fi
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# ---- check -------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
cmd_check() {
|
|
211
|
+
local ecosystem="" pkg_spec=""
|
|
212
|
+
while [[ $# -gt 0 ]]; do
|
|
213
|
+
case "$1" in
|
|
214
|
+
-h|--help) cmd_help check; return 0 ;;
|
|
215
|
+
--) shift; break ;;
|
|
216
|
+
-*) sf_eprintf "safedeps: unknown option for check: $1"; return 4 ;;
|
|
217
|
+
*)
|
|
218
|
+
if [[ -z "${ecosystem}" ]]; then ecosystem="$1"
|
|
219
|
+
elif [[ -z "${pkg_spec}" ]]; then pkg_spec="$1"
|
|
220
|
+
else sf_eprintf "safedeps: unexpected arg: $1"; return 4
|
|
221
|
+
fi
|
|
222
|
+
shift; continue
|
|
223
|
+
;;
|
|
224
|
+
esac
|
|
225
|
+
shift
|
|
226
|
+
done
|
|
227
|
+
|
|
228
|
+
if [[ -z "${ecosystem}" || -z "${pkg_spec}" ]]; then
|
|
229
|
+
sf_eprintf "usage: safedeps check <ecosystem> <pkg>@<version|range> [--json]"
|
|
230
|
+
return 4
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
sf_require_jq
|
|
234
|
+
|
|
235
|
+
local pkg range
|
|
236
|
+
pkg=$(sf_parse_pkg_spec "${pkg_spec}" | sed -n '1p')
|
|
237
|
+
range=$(sf_parse_pkg_spec "${pkg_spec}" | sed -n '2p')
|
|
238
|
+
|
|
239
|
+
local version
|
|
240
|
+
sf_spinner_start "버전 해석 중 (${pkg}@${range})"
|
|
241
|
+
if ! version=$(sf_resolve_version "${ecosystem}" "${pkg}" "${range}"); then
|
|
242
|
+
sf_spinner_stop
|
|
243
|
+
sf_err "버전 해석 실패: ${pkg}@${range}"
|
|
244
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
245
|
+
jq -nc --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg range "${range}" \
|
|
246
|
+
'{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, result:"error", error:"version_resolution_failed"}'
|
|
247
|
+
fi
|
|
248
|
+
return 4
|
|
249
|
+
fi
|
|
250
|
+
sf_spinner_stop
|
|
251
|
+
|
|
252
|
+
# Ledger lookup short-circuit
|
|
253
|
+
local ledger_check
|
|
254
|
+
if ledger_check=$(safedeps_ledger_check "${ecosystem}" "${pkg}" "${version}" 2>/dev/null); then
|
|
255
|
+
if [[ "$(jq -r '.approved' <<< "${ledger_check}")" == "true" ]]; then
|
|
256
|
+
local hash approved_at expires_at
|
|
257
|
+
hash=$(jq -r '.hash' <<< "${ledger_check}")
|
|
258
|
+
approved_at=$(jq -r '.spec.approved_at // "n/a"' <<< "${ledger_check}")
|
|
259
|
+
expires_at=$(jq -r '.spec.expires_at // "n/a"' <<< "${ledger_check}")
|
|
260
|
+
sf_ok "${pkg}@${version} 이미 승인됨 (until ${expires_at})"
|
|
261
|
+
sf_info "ledger: ${hash}"
|
|
262
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
263
|
+
jq -nc \
|
|
264
|
+
--arg ecosystem "${ecosystem}" \
|
|
265
|
+
--arg package "${pkg}" \
|
|
266
|
+
--arg range "${range}" \
|
|
267
|
+
--arg version "${version}" \
|
|
268
|
+
--arg hash "${hash}" \
|
|
269
|
+
--arg approved_at "${approved_at}" \
|
|
270
|
+
--arg expires_at "${expires_at}" \
|
|
271
|
+
'{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"already_approved", approved:true, spec_hash:$hash, approved_at:$approved_at, expires_at:$expires_at}'
|
|
272
|
+
fi
|
|
273
|
+
sf_advisory_log "check approve(cache) ecosystem=${ecosystem} package=${pkg} version=${version} hash=${hash}"
|
|
274
|
+
return 0
|
|
275
|
+
fi
|
|
276
|
+
fi
|
|
277
|
+
|
|
278
|
+
# Provider query (canonical truth = OSV; KEV overlay; GHSA enrichment)
|
|
279
|
+
local provider_json
|
|
280
|
+
sf_spinner_start "취약점 조회 중 (OSV / KEV / GHSA)"
|
|
281
|
+
if ! provider_json=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${version}"); then
|
|
282
|
+
sf_spinner_stop
|
|
283
|
+
sf_err "OSV primary 응답 없음 — fail-closed (cache miss + 라이브 실패)"
|
|
284
|
+
sf_advisory_log "check fail-closed ecosystem=${ecosystem} package=${pkg} version=${version}"
|
|
285
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
286
|
+
jq -nc \
|
|
287
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" \
|
|
288
|
+
--arg range "${range}" --arg version "${version}" \
|
|
289
|
+
'{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"provider_unavailable", approved:false, error:"OSV primary unavailable; no fresh cache"}'
|
|
290
|
+
fi
|
|
291
|
+
return 4
|
|
292
|
+
fi
|
|
293
|
+
sf_spinner_stop
|
|
294
|
+
|
|
295
|
+
local tmp_evidence
|
|
296
|
+
tmp_evidence=$(sf_mktemp_evidence)
|
|
297
|
+
printf '%s' "${provider_json}" > "${tmp_evidence}"
|
|
298
|
+
|
|
299
|
+
local status vuln_count kev_exploited
|
|
300
|
+
status=$(jq -r '.status' <<< "${provider_json}")
|
|
301
|
+
vuln_count=$(jq -r '(.vulnerabilities // []) | length' <<< "${provider_json}")
|
|
302
|
+
kev_exploited=$(jq -r '.kev.exploited // false' <<< "${provider_json}")
|
|
303
|
+
|
|
304
|
+
case "${status}" in
|
|
305
|
+
hard_block)
|
|
306
|
+
sf_err "KEV 매칭 — ${pkg}@${version} 은 실제 야생에서 exploit 확인됨. 설치 차단."
|
|
307
|
+
local kev_cves
|
|
308
|
+
kev_cves=$(jq -r '[.kev.matches[]?.cveID] | unique | join(", ")' <<< "${provider_json}")
|
|
309
|
+
[[ -n "${kev_cves}" ]] && sf_info "관련 CVE: ${kev_cves}"
|
|
310
|
+
sf_warn "대체 모듈을 검토하세요. 이 spec 은 ledger 에 승인되지 않습니다."
|
|
311
|
+
sf_advisory_log "check block(KEV) ecosystem=${ecosystem} package=${pkg} version=${version} cves=${kev_cves}"
|
|
312
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
313
|
+
jq -c \
|
|
314
|
+
--arg result "kev_hard_block" \
|
|
315
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" \
|
|
316
|
+
--arg range "${range}" --arg version "${version}" \
|
|
317
|
+
'. + {command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:$result, approved:false}' <<< "${provider_json}"
|
|
318
|
+
fi
|
|
319
|
+
rm -f "${tmp_evidence}"
|
|
320
|
+
return 3
|
|
321
|
+
;;
|
|
322
|
+
|
|
323
|
+
vulnerable)
|
|
324
|
+
local patched_version=""
|
|
325
|
+
if patched_version=$(sf_extract_patched_version "${tmp_evidence}" "${version}"); then
|
|
326
|
+
sf_warn "${pkg}@${version} 에 ${vuln_count} 개 CVE — 안전 버전 ${patched_version} 으로 좁혀 재조회합니다."
|
|
327
|
+
# narrow + recurse via providers (sub-call, no ledger short-circuit yet)
|
|
328
|
+
local narrow_json
|
|
329
|
+
sf_spinner_start "${patched_version} 재조회 중"
|
|
330
|
+
if ! narrow_json=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${patched_version}"); then
|
|
331
|
+
sf_spinner_stop
|
|
332
|
+
sf_err "${pkg}@${patched_version} 재조회 실패 — fail-closed"
|
|
333
|
+
rm -f "${tmp_evidence}"
|
|
334
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
335
|
+
jq -nc \
|
|
336
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" \
|
|
337
|
+
--arg range "${range}" --arg version "${version}" \
|
|
338
|
+
--arg patched "${patched_version}" \
|
|
339
|
+
'{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"provider_unavailable", approved:false}'
|
|
340
|
+
fi
|
|
341
|
+
return 4
|
|
342
|
+
fi
|
|
343
|
+
sf_spinner_stop
|
|
344
|
+
|
|
345
|
+
local narrow_status
|
|
346
|
+
narrow_status=$(jq -r '.status' <<< "${narrow_json}")
|
|
347
|
+
if [[ "${narrow_status}" == "clean" ]]; then
|
|
348
|
+
# approve patched version
|
|
349
|
+
local narrow_evidence
|
|
350
|
+
narrow_evidence=$(sf_mktemp_evidence)
|
|
351
|
+
printf '%s' "${narrow_json}" > "${narrow_evidence}"
|
|
352
|
+
local narrow_range="${patched_version}"
|
|
353
|
+
local spec_json
|
|
354
|
+
spec_json=$(safedeps_ledger_write_approved_spec "${ecosystem}" "${pkg}" "${patched_version}" "${narrow_range}" "safedeps-cli" "${narrow_evidence}")
|
|
355
|
+
rm -f "${narrow_evidence}" "${tmp_evidence}"
|
|
356
|
+
local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
|
|
357
|
+
local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
|
|
358
|
+
sf_ok "${pkg}@${patched_version} 승인 (until ${expires_at})"
|
|
359
|
+
sf_info "ledger: ${hash}"
|
|
360
|
+
sf_advisory_log "check approve(patched) ecosystem=${ecosystem} package=${pkg} version=${patched_version} hash=${hash} prev_version=${version}"
|
|
361
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
362
|
+
jq -nc \
|
|
363
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" \
|
|
364
|
+
--arg range "${range}" --arg version "${version}" \
|
|
365
|
+
--arg patched "${patched_version}" \
|
|
366
|
+
--arg hash "${hash}" --arg expires_at "${expires_at}" \
|
|
367
|
+
'{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"patched_available", approved:true, spec_hash:$hash, expires_at:$expires_at, install_hint:("install with " + $package + "@" + $patched)}'
|
|
368
|
+
fi
|
|
369
|
+
return 0
|
|
370
|
+
else
|
|
371
|
+
sf_err "패치 버전 ${patched_version} 도 깨끗하지 않음 (status=${narrow_status})"
|
|
372
|
+
rm -f "${tmp_evidence}"
|
|
373
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
374
|
+
jq -nc \
|
|
375
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" \
|
|
376
|
+
--arg range "${range}" --arg version "${version}" \
|
|
377
|
+
--arg patched "${patched_version}" --arg status "${narrow_status}" \
|
|
378
|
+
'{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"patched_still_vulnerable", approved:false, patched_status:$status}'
|
|
379
|
+
fi
|
|
380
|
+
return 2
|
|
381
|
+
fi
|
|
382
|
+
else
|
|
383
|
+
sf_warn "${pkg}@${version} 에 ${vuln_count} 개 CVE — 사용 가능한 patch 없음. 승인 보류."
|
|
384
|
+
sf_advisory_log "check warn(no-patch) ecosystem=${ecosystem} package=${pkg} version=${version} vulns=${vuln_count}"
|
|
385
|
+
rm -f "${tmp_evidence}"
|
|
386
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
387
|
+
jq -c \
|
|
388
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" \
|
|
389
|
+
--arg range "${range}" --arg version "${version}" \
|
|
390
|
+
'. + {command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"cve_unpatched", approved:false}' <<< "${provider_json}"
|
|
391
|
+
fi
|
|
392
|
+
return 2
|
|
393
|
+
fi
|
|
394
|
+
;;
|
|
395
|
+
|
|
396
|
+
clean)
|
|
397
|
+
local spec_json
|
|
398
|
+
spec_json=$(safedeps_ledger_write_approved_spec "${ecosystem}" "${pkg}" "${version}" "${range:-${version}}" "safedeps-cli" "${tmp_evidence}")
|
|
399
|
+
rm -f "${tmp_evidence}"
|
|
400
|
+
local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
|
|
401
|
+
local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
|
|
402
|
+
sf_ok "${pkg}@${version} 승인 (until ${expires_at})"
|
|
403
|
+
sf_info "ledger: ${hash}"
|
|
404
|
+
sf_advisory_log "check approve(clean) ecosystem=${ecosystem} package=${pkg} version=${version} hash=${hash}"
|
|
405
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
406
|
+
jq -nc \
|
|
407
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" \
|
|
408
|
+
--arg range "${range}" --arg version "${version}" \
|
|
409
|
+
--arg hash "${hash}" --arg expires_at "${expires_at}" \
|
|
410
|
+
'{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"clean", approved:true, spec_hash:$hash, expires_at:$expires_at, install_hint:("install with " + $package + "@" + $version)}'
|
|
411
|
+
fi
|
|
412
|
+
return 0
|
|
413
|
+
;;
|
|
414
|
+
|
|
415
|
+
*)
|
|
416
|
+
sf_err "예상치 못한 provider status: ${status}"
|
|
417
|
+
rm -f "${tmp_evidence}"
|
|
418
|
+
return 4
|
|
419
|
+
;;
|
|
420
|
+
esac
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
# ---- ledger ------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
cmd_ledger() {
|
|
426
|
+
while [[ $# -gt 0 ]]; do
|
|
427
|
+
case "$1" in
|
|
428
|
+
-h|--help) cmd_help ledger; return 0 ;;
|
|
429
|
+
*) sf_eprintf "safedeps: unexpected arg for ledger: $1"; return 4 ;;
|
|
430
|
+
esac
|
|
431
|
+
done
|
|
432
|
+
|
|
433
|
+
sf_require_jq
|
|
434
|
+
safedeps_ledger_init
|
|
435
|
+
|
|
436
|
+
local entries=()
|
|
437
|
+
if [[ -d "${SAFEDEPS_LEDGER_DIR}" ]]; then
|
|
438
|
+
while IFS= read -r -d '' f; do
|
|
439
|
+
entries+=("${f}")
|
|
440
|
+
done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)
|
|
441
|
+
fi
|
|
442
|
+
|
|
443
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
444
|
+
local merged='[]'
|
|
445
|
+
for f in "${entries[@]+${entries[@]}}"; do
|
|
446
|
+
local now_iso
|
|
447
|
+
now_iso=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
448
|
+
merged=$(jq -c --slurpfile spec "${f}" --arg now "${now_iso}" '
|
|
449
|
+
. + [
|
|
450
|
+
($spec[0]) as $s |
|
|
451
|
+
{
|
|
452
|
+
hash: $s.hash,
|
|
453
|
+
ecosystem: $s.ecosystem,
|
|
454
|
+
package: $s.package,
|
|
455
|
+
version: $s.version,
|
|
456
|
+
version_range: $s.version_range,
|
|
457
|
+
approved_at: $s.approved_at,
|
|
458
|
+
expires_at: $s.expires_at,
|
|
459
|
+
approved_by: $s.approved_by,
|
|
460
|
+
expired: ($s.expires_at < $now),
|
|
461
|
+
revoked: (($s.revoked_at // null) != null)
|
|
462
|
+
}
|
|
463
|
+
]
|
|
464
|
+
' <<< "${merged}")
|
|
465
|
+
done
|
|
466
|
+
jq -nc --argjson specs "${merged}" '{command:"ledger", count: ($specs | length), specs: $specs}'
|
|
467
|
+
return 0
|
|
468
|
+
fi
|
|
469
|
+
|
|
470
|
+
if [[ ${#entries[@]} -eq 0 ]]; then
|
|
471
|
+
sf_info "approved-specs 비어있음 (${SAFEDEPS_LEDGER_DIR})"
|
|
472
|
+
return 0
|
|
473
|
+
fi
|
|
474
|
+
|
|
475
|
+
printf '%s%-8s %-12s %-40s %-14s %-22s %-22s %s%s\n' \
|
|
476
|
+
"${C_BOLD}" "STATE" "ECOSYSTEM" "PACKAGE" "VERSION" "APPROVED" "EXPIRES" "HASH" "${C_RESET}"
|
|
477
|
+
for f in "${entries[@]+${entries[@]}}"; do
|
|
478
|
+
local hash ecosystem pkg version approved_at expires_at revoked_at expired state state_color
|
|
479
|
+
hash=$(jq -r '.hash // ""' "${f}")
|
|
480
|
+
ecosystem=$(jq -r '.ecosystem // ""' "${f}")
|
|
481
|
+
pkg=$(jq -r '.package // ""' "${f}")
|
|
482
|
+
version=$(jq -r '.version // ""' "${f}")
|
|
483
|
+
approved_at=$(jq -r '.approved_at // ""' "${f}")
|
|
484
|
+
expires_at=$(jq -r '.expires_at // ""' "${f}")
|
|
485
|
+
revoked_at=$(jq -r '.revoked_at // ""' "${f}")
|
|
486
|
+
|
|
487
|
+
expired=0
|
|
488
|
+
if [[ -n "${expires_at}" ]]; then
|
|
489
|
+
local exp_epoch now_epoch
|
|
490
|
+
if exp_epoch=$(safedeps_ledger_epoch "${expires_at}" 2>/dev/null); then
|
|
491
|
+
now_epoch=$(date +%s)
|
|
492
|
+
[[ "${exp_epoch}" -le "${now_epoch}" ]] && expired=1
|
|
493
|
+
fi
|
|
494
|
+
fi
|
|
495
|
+
|
|
496
|
+
if [[ -n "${revoked_at}" ]]; then
|
|
497
|
+
state="REVOKED"; state_color="${C_GRAY}"
|
|
498
|
+
elif [[ "${expired}" -eq 1 ]]; then
|
|
499
|
+
state="EXPIRED"; state_color="${C_YELLOW}"
|
|
500
|
+
else
|
|
501
|
+
state="ACTIVE"; state_color="${C_GREEN}"
|
|
502
|
+
fi
|
|
503
|
+
|
|
504
|
+
printf '%s%-8s%s %-12s %-40s %-14s %-22s %-22s %s\n' \
|
|
505
|
+
"${state_color}" "${state}" "${C_RESET}" \
|
|
506
|
+
"${ecosystem}" "${pkg}" "${version}" \
|
|
507
|
+
"${approved_at}" "${expires_at}" "${hash}"
|
|
508
|
+
done
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
# ---- revoke ------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
cmd_revoke() {
|
|
514
|
+
local arg1="" arg2="" reason=""
|
|
515
|
+
while [[ $# -gt 0 ]]; do
|
|
516
|
+
case "$1" in
|
|
517
|
+
-h|--help) cmd_help revoke; return 0 ;;
|
|
518
|
+
--reason) reason="${2:-}"; shift 2; continue ;;
|
|
519
|
+
--reason=*) reason="${1#--reason=}"; shift; continue ;;
|
|
520
|
+
-*) sf_eprintf "safedeps: unknown option for revoke: $1"; return 4 ;;
|
|
521
|
+
*)
|
|
522
|
+
if [[ -z "${arg1}" ]]; then arg1="$1"
|
|
523
|
+
elif [[ -z "${arg2}" ]]; then arg2="$1"
|
|
524
|
+
else sf_eprintf "safedeps: unexpected arg: $1"; return 4
|
|
525
|
+
fi
|
|
526
|
+
shift; continue
|
|
527
|
+
;;
|
|
528
|
+
esac
|
|
529
|
+
shift
|
|
530
|
+
done
|
|
531
|
+
|
|
532
|
+
if [[ -z "${arg1}" ]]; then
|
|
533
|
+
sf_eprintf "usage: safedeps revoke <hash> | <ecosystem> <pkg>@<version> | <pkg>@<version> [--reason <reason>]"
|
|
534
|
+
return 4
|
|
535
|
+
fi
|
|
536
|
+
|
|
537
|
+
sf_require_jq
|
|
538
|
+
safedeps_ledger_init
|
|
539
|
+
reason="${reason:-cli-revoke}"
|
|
540
|
+
|
|
541
|
+
local target_file=""
|
|
542
|
+
if [[ "${arg1}" == sha256:* ]]; then
|
|
543
|
+
target_file=$(safedeps_ledger_path_for_hash "${arg1}")
|
|
544
|
+
[[ -f "${target_file}" ]] || { sf_err "ledger entry 없음: ${arg1}"; return 1; }
|
|
545
|
+
else
|
|
546
|
+
# one or two args. Two = ecosystem + pkg@version. One = pkg@version (scan).
|
|
547
|
+
if [[ -n "${arg2}" ]]; then
|
|
548
|
+
local pkg version
|
|
549
|
+
pkg=$(sf_parse_pkg_spec "${arg2}" | sed -n '1p')
|
|
550
|
+
version=$(sf_parse_pkg_spec "${arg2}" | sed -n '2p')
|
|
551
|
+
[[ -n "${version}" ]] || { sf_eprintf "safedeps: revoke needs pkg@version, got '${arg2}'"; return 4; }
|
|
552
|
+
target_file=$(safedeps_ledger_path "${arg1}" "${pkg}" "${version}")
|
|
553
|
+
[[ -f "${target_file}" ]] || { sf_err "ledger entry 없음: ${arg1} ${pkg}@${version}"; return 1; }
|
|
554
|
+
else
|
|
555
|
+
local pkg version
|
|
556
|
+
pkg=$(sf_parse_pkg_spec "${arg1}" | sed -n '1p')
|
|
557
|
+
version=$(sf_parse_pkg_spec "${arg1}" | sed -n '2p')
|
|
558
|
+
[[ -n "${version}" ]] || { sf_eprintf "safedeps: revoke needs pkg@version or hash, got '${arg1}'"; return 4; }
|
|
559
|
+
local matches=()
|
|
560
|
+
while IFS= read -r -d '' f; do
|
|
561
|
+
local p v
|
|
562
|
+
p=$(jq -r '.package // ""' "${f}")
|
|
563
|
+
v=$(jq -r '.version // ""' "${f}")
|
|
564
|
+
if [[ "${p}" == "${pkg}" && "${v}" == "${version}" ]]; then
|
|
565
|
+
matches+=("${f}")
|
|
566
|
+
fi
|
|
567
|
+
done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)
|
|
568
|
+
case "${#matches[@]}" in
|
|
569
|
+
0) sf_err "ledger entry 없음: ${pkg}@${version}"; return 1 ;;
|
|
570
|
+
1) target_file="${matches[0]}" ;;
|
|
571
|
+
*) sf_err "${pkg}@${version} 가 여러 ecosystem 에서 매칭됨 — ecosystem 을 명시하세요"; return 4 ;;
|
|
572
|
+
esac
|
|
573
|
+
fi
|
|
574
|
+
fi
|
|
575
|
+
|
|
576
|
+
local ecosystem pkg version
|
|
577
|
+
ecosystem=$(jq -r '.ecosystem' "${target_file}")
|
|
578
|
+
pkg=$(jq -r '.package' "${target_file}")
|
|
579
|
+
version=$(jq -r '.version' "${target_file}")
|
|
580
|
+
|
|
581
|
+
local revoked_json
|
|
582
|
+
revoked_json=$(safedeps_ledger_revoke "${ecosystem}" "${pkg}" "${version}" "${reason}")
|
|
583
|
+
sf_advisory_log "revoke ecosystem=${ecosystem} package=${pkg} version=${version} reason=${reason}"
|
|
584
|
+
sf_ok "취소: ${ecosystem} ${pkg}@${version}"
|
|
585
|
+
sf_info "reason: ${reason}"
|
|
586
|
+
|
|
587
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
588
|
+
jq -c --arg reason "${reason}" \
|
|
589
|
+
'{command:"revoke", revoked:true, reason:$reason, spec: .}' <<< "${revoked_json}"
|
|
590
|
+
fi
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
# ---- re-check ----------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
cmd_recheck() {
|
|
596
|
+
while [[ $# -gt 0 ]]; do
|
|
597
|
+
case "$1" in
|
|
598
|
+
-h|--help) cmd_help re-check; return 0 ;;
|
|
599
|
+
*) sf_eprintf "safedeps: unexpected arg for re-check: $1"; return 4 ;;
|
|
600
|
+
esac
|
|
601
|
+
done
|
|
602
|
+
|
|
603
|
+
sf_require_jq
|
|
604
|
+
safedeps_ledger_init
|
|
605
|
+
|
|
606
|
+
local entries=()
|
|
607
|
+
while IFS= read -r -d '' f; do
|
|
608
|
+
entries+=("${f}")
|
|
609
|
+
done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)
|
|
610
|
+
|
|
611
|
+
local checked=0 still_clean=0
|
|
612
|
+
local newly_vuln_arr='[]' kev_hit_arr='[]' revoked_arr='[]'
|
|
613
|
+
|
|
614
|
+
for f in "${entries[@]+${entries[@]}}"; do
|
|
615
|
+
local ecosystem pkg version revoked_at
|
|
616
|
+
ecosystem=$(jq -r '.ecosystem' "${f}")
|
|
617
|
+
pkg=$(jq -r '.package' "${f}")
|
|
618
|
+
version=$(jq -r '.version' "${f}")
|
|
619
|
+
revoked_at=$(jq -r '.revoked_at // ""' "${f}")
|
|
620
|
+
[[ -n "${revoked_at}" ]] && continue
|
|
621
|
+
checked=$(( checked + 1 ))
|
|
622
|
+
|
|
623
|
+
sf_info "재검증 ${ecosystem} ${pkg}@${version}"
|
|
624
|
+
local pj
|
|
625
|
+
if ! pj=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${version}" 2>/dev/null); then
|
|
626
|
+
sf_warn " provider 응답 없음 — skip"
|
|
627
|
+
continue
|
|
628
|
+
fi
|
|
629
|
+
local s; s=$(jq -r '.status' <<< "${pj}")
|
|
630
|
+
case "${s}" in
|
|
631
|
+
clean)
|
|
632
|
+
still_clean=$(( still_clean + 1 ))
|
|
633
|
+
;;
|
|
634
|
+
vulnerable|hard_block)
|
|
635
|
+
local reason="re-check ${s}"
|
|
636
|
+
safedeps_ledger_revoke "${ecosystem}" "${pkg}" "${version}" "${reason}" >/dev/null
|
|
637
|
+
sf_advisory_log "re-check revoke ecosystem=${ecosystem} package=${pkg} version=${version} status=${s}"
|
|
638
|
+
if [[ "${s}" == "hard_block" ]]; then
|
|
639
|
+
sf_err " KEV 매칭 → revoke"
|
|
640
|
+
kev_hit_arr=$(jq -c \
|
|
641
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" \
|
|
642
|
+
'. + [{ecosystem:$ecosystem, package:$package, version:$version, status:"hard_block"}]' <<< "${kev_hit_arr}")
|
|
643
|
+
else
|
|
644
|
+
sf_warn " 새 CVE 매치 → revoke"
|
|
645
|
+
newly_vuln_arr=$(jq -c \
|
|
646
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" \
|
|
647
|
+
'. + [{ecosystem:$ecosystem, package:$package, version:$version, status:"vulnerable"}]' <<< "${newly_vuln_arr}")
|
|
648
|
+
fi
|
|
649
|
+
revoked_arr=$(jq -c \
|
|
650
|
+
--arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" --arg reason "${reason}" \
|
|
651
|
+
'. + [{ecosystem:$ecosystem, package:$package, version:$version, reason:$reason}]' <<< "${revoked_arr}")
|
|
652
|
+
;;
|
|
653
|
+
esac
|
|
654
|
+
done
|
|
655
|
+
|
|
656
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
657
|
+
jq -nc \
|
|
658
|
+
--argjson newly_vulnerable "${newly_vuln_arr}" \
|
|
659
|
+
--argjson kev_hit "${kev_hit_arr}" \
|
|
660
|
+
--argjson revoked "${revoked_arr}" \
|
|
661
|
+
--argjson checked "${checked}" \
|
|
662
|
+
--argjson still_clean "${still_clean}" \
|
|
663
|
+
'{command:"re-check", checked:$checked, still_clean:$still_clean, newly_vulnerable:$newly_vulnerable, kev_hit:$kev_hit, revoked:$revoked}'
|
|
664
|
+
return 0
|
|
665
|
+
fi
|
|
666
|
+
|
|
667
|
+
sf_info "검증 완료: ${checked} 개 중 ${still_clean} 개 clean"
|
|
668
|
+
local nv kv
|
|
669
|
+
nv=$(jq -r 'length' <<< "${newly_vuln_arr}")
|
|
670
|
+
kv=$(jq -r 'length' <<< "${kev_hit_arr}")
|
|
671
|
+
[[ "${nv}" -gt 0 ]] && sf_warn "새 CVE 매치로 ${nv} 개 revoke"
|
|
672
|
+
[[ "${kv}" -gt 0 ]] && sf_err "KEV 매치로 ${kv} 개 revoke"
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
# ---- migrate -----------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
cmd_migrate() {
|
|
678
|
+
local keep_legacy=0
|
|
679
|
+
while [[ $# -gt 0 ]]; do
|
|
680
|
+
case "$1" in
|
|
681
|
+
--keep-legacy) keep_legacy=1; shift ;;
|
|
682
|
+
-h|--help) cmd_help migrate; return 0 ;;
|
|
683
|
+
*) sf_eprintf "safedeps: unexpected arg for migrate: $1"; return 4 ;;
|
|
684
|
+
esac
|
|
685
|
+
done
|
|
686
|
+
|
|
687
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
688
|
+
sf_eprintf "safedeps: node is required for state migration"
|
|
689
|
+
return 4
|
|
690
|
+
fi
|
|
691
|
+
|
|
692
|
+
local migrate_script="${SAFEDEPS_REPO_DIR}/scripts/install/migrate-safedeps-state.mjs"
|
|
693
|
+
[[ -f "${migrate_script}" ]] || {
|
|
694
|
+
sf_eprintf "safedeps: migration script not found: ${migrate_script}"
|
|
695
|
+
return 4
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if [[ "${keep_legacy}" -eq 1 ]]; then
|
|
699
|
+
node "${migrate_script}" --keep-legacy
|
|
700
|
+
else
|
|
701
|
+
node "${migrate_script}"
|
|
702
|
+
fi
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
# ---- help / version ----------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
cmd_version() {
|
|
708
|
+
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
709
|
+
jq -nc --arg v "${SAFEDEPS_VERSION}" '{command:"version", version:$v}'
|
|
710
|
+
else
|
|
711
|
+
printf 'safedeps %s\n' "${SAFEDEPS_VERSION}"
|
|
712
|
+
fi
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
cmd_help() {
|
|
716
|
+
local topic="${1:-}"
|
|
717
|
+
case "${topic}" in
|
|
718
|
+
check)
|
|
719
|
+
cat <<'EOF'
|
|
720
|
+
safedeps check <ecosystem> <pkg>@<version|range> [--json]
|
|
721
|
+
|
|
722
|
+
Phase 1 advisory gate. Query OSV (canonical) + CISA KEV (overlay) + GHSA (enrichment),
|
|
723
|
+
classify, and — when clean or patched_available — write an approved-spec entry.
|
|
724
|
+
|
|
725
|
+
ecosystem: npm | pypi | crates.io | go | rubygems | maven | nuget
|
|
726
|
+
exit codes: 0 clean/approved · 1 reserved · 2 cve_unpatched · 3 kev_hard_block · 4 input/provider error
|
|
727
|
+
EOF
|
|
728
|
+
;;
|
|
729
|
+
ledger)
|
|
730
|
+
cat <<'EOF'
|
|
731
|
+
safedeps ledger [--json]
|
|
732
|
+
|
|
733
|
+
List approved specs from ~/.safedeps/approved-specs/.
|
|
734
|
+
Columns: STATE ECOSYSTEM PACKAGE VERSION APPROVED EXPIRES HASH.
|
|
735
|
+
EOF
|
|
736
|
+
;;
|
|
737
|
+
revoke)
|
|
738
|
+
cat <<'EOF'
|
|
739
|
+
safedeps revoke <hash> | <ecosystem> <pkg>@<version> | <pkg>@<version> [--reason <reason>] [--json]
|
|
740
|
+
|
|
741
|
+
Mark an approved-spec entry as revoked. The hook will then block install
|
|
742
|
+
commands for that spec until it is re-approved with `safedeps check`.
|
|
743
|
+
EOF
|
|
744
|
+
;;
|
|
745
|
+
re-check)
|
|
746
|
+
cat <<'EOF'
|
|
747
|
+
safedeps re-check [--json]
|
|
748
|
+
|
|
749
|
+
Re-query providers for every active approved spec.
|
|
750
|
+
Auto-revoke entries that newly match a CVE or KEV.
|
|
751
|
+
EOF
|
|
752
|
+
;;
|
|
753
|
+
migrate)
|
|
754
|
+
cat <<'EOF'
|
|
755
|
+
safedeps migrate [--keep-legacy]
|
|
756
|
+
|
|
757
|
+
Migrate legacy ~/.npm-reorg-guard state into ~/.safedeps.
|
|
758
|
+
By default the legacy directory is archived to remove the old active truth path.
|
|
759
|
+
EOF
|
|
760
|
+
;;
|
|
761
|
+
*)
|
|
762
|
+
cat <<EOF
|
|
763
|
+
safedeps ${SAFEDEPS_VERSION} — multi-ecosystem install safety gate
|
|
764
|
+
|
|
765
|
+
USAGE
|
|
766
|
+
safedeps <command> [args] [--json] [--no-color]
|
|
767
|
+
|
|
768
|
+
COMMANDS
|
|
769
|
+
check <ecosystem> <pkg>@<version|range> Phase 1 advisory gate + ledger approve.
|
|
770
|
+
ledger List approved specs.
|
|
771
|
+
revoke <hash | pkg@version> Revoke an approved spec.
|
|
772
|
+
re-check Re-query providers for all approved specs.
|
|
773
|
+
migrate Migrate legacy npm-reorg-guard state.
|
|
774
|
+
help [command] Show help.
|
|
775
|
+
version Print version.
|
|
776
|
+
|
|
777
|
+
GLOBAL FLAGS
|
|
778
|
+
--json Machine-readable JSON output (stable schema).
|
|
779
|
+
--no-color Disable ANSI colors.
|
|
780
|
+
|
|
781
|
+
EXIT CODES
|
|
782
|
+
0 clean / approved
|
|
783
|
+
2 CVE found without an upgrade path
|
|
784
|
+
3 CISA KEV match — hard block
|
|
785
|
+
4 input error or provider unavailable (fail-closed)
|
|
786
|
+
|
|
787
|
+
ENV
|
|
788
|
+
SAFEDEPS_HOME default ~/.safedeps
|
|
789
|
+
SAFEDEPS_LEDGER_DIR default \$SAFEDEPS_HOME/approved-specs
|
|
790
|
+
GITHUB_TOKEN optional, used for GHSA enrichment
|
|
791
|
+
EOF
|
|
792
|
+
;;
|
|
793
|
+
esac
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
# ---- main dispatch -----------------------------------------------------------
|
|
797
|
+
|
|
798
|
+
main() {
|
|
799
|
+
local positional=()
|
|
800
|
+
while [[ $# -gt 0 ]]; do
|
|
801
|
+
case "$1" in
|
|
802
|
+
--json) SAFEDEPS_JSON_MODE=1; shift ;;
|
|
803
|
+
--no-color) SAFEDEPS_NO_COLOR=1; shift ;;
|
|
804
|
+
-h|--help)
|
|
805
|
+
positional+=("help")
|
|
806
|
+
shift
|
|
807
|
+
;;
|
|
808
|
+
--version)
|
|
809
|
+
positional+=("version")
|
|
810
|
+
shift
|
|
811
|
+
;;
|
|
812
|
+
--) shift; while [[ $# -gt 0 ]]; do positional+=("$1"); shift; done ;;
|
|
813
|
+
*) positional+=("$1"); shift ;;
|
|
814
|
+
esac
|
|
815
|
+
done
|
|
816
|
+
|
|
817
|
+
sf_color_init
|
|
818
|
+
|
|
819
|
+
if [[ ${#positional[@]} -eq 0 ]]; then
|
|
820
|
+
cmd_help
|
|
821
|
+
return 0
|
|
822
|
+
fi
|
|
823
|
+
|
|
824
|
+
local cmd="${positional[0]}"
|
|
825
|
+
set -- "${positional[@]:1}"
|
|
826
|
+
|
|
827
|
+
case "${cmd}" in
|
|
828
|
+
check) cmd_check "$@" ;;
|
|
829
|
+
ledger) cmd_ledger "$@" ;;
|
|
830
|
+
revoke) cmd_revoke "$@" ;;
|
|
831
|
+
re-check|recheck) cmd_recheck "$@" ;;
|
|
832
|
+
migrate) cmd_migrate "$@" ;;
|
|
833
|
+
help) cmd_help "$@" ;;
|
|
834
|
+
version) cmd_version ;;
|
|
835
|
+
*)
|
|
836
|
+
sf_eprintf "safedeps: unknown command '${cmd}'. Try 'safedeps help'."
|
|
837
|
+
return 4
|
|
838
|
+
;;
|
|
839
|
+
esac
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
main "$@"
|