@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/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 "$@"