@aldegad/safedeps 2.1.0 → 2.2.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 CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  set -euo pipefail
9
9
 
10
- SAFEDEPS_VERSION="2.1.0"
10
+ SAFEDEPS_VERSION="2.2.0"
11
11
 
12
12
  # ---- repo / lib bootstrap ----------------------------------------------------
13
13
 
@@ -18,6 +18,8 @@ SAFEDEPS_REPO_DIR=$(cd "${SAFEDEPS_BIN_DIR}/.." && pwd)
18
18
  source "${SAFEDEPS_REPO_DIR}/lib/providers/providers.sh"
19
19
  # shellcheck source=../lib/ledger/ledger.sh
20
20
  source "${SAFEDEPS_REPO_DIR}/lib/ledger/ledger.sh"
21
+ # shellcheck source=../lib/npm/closure.sh
22
+ source "${SAFEDEPS_REPO_DIR}/lib/npm/closure.sh"
21
23
 
22
24
  SAFEDEPS_HOME="${SAFEDEPS_HOME:-${HOME}/.safedeps}"
23
25
  SAFEDEPS_LEDGER_DIR="${SAFEDEPS_LEDGER_DIR:-${SAFEDEPS_HOME}/approved-specs}"
@@ -159,9 +161,10 @@ sf_resolve_version() {
159
161
  esac
160
162
  }
161
163
 
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() {
164
+ # Extract fixed version candidates greater than current_version from OSV vulns.
165
+ # Echoes unique candidates in ascending version order, bounded for deterministic
166
+ # provider retry behavior.
167
+ sf_extract_patched_versions() {
165
168
  local provider_json_file="$1" current_version="$2"
166
169
  local fixed
167
170
  fixed=$(jq -r '
@@ -172,24 +175,14 @@ sf_extract_patched_version() {
172
175
 
173
176
  [[ -z "${fixed}" ]] && return 1
174
177
 
175
- local candidate=""
176
178
  while IFS= read -r v; do
177
179
  [[ -z "${v}" ]] && continue
178
180
  # require v > current_version
179
181
  local higher
180
182
  higher=$(printf '%s\n%s\n' "${v}" "${current_version}" | sort -V | tail -1)
181
183
  [[ "${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}"
184
+ printf '%s\n' "${v}"
185
+ done <<< "${fixed}" | sort -Vu | head -20
193
186
  }
194
187
 
195
188
  sf_advisory_log() {
@@ -198,6 +191,21 @@ sf_advisory_log() {
198
191
  printf '[%s] %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$*" >> "${SAFEDEPS_ADVISORY_LOG}"
199
192
  }
200
193
 
194
+ sf_ledger_has_approval_provenance() {
195
+ local hash="$1"
196
+ local ecosystem="$2"
197
+ local package_name="$3"
198
+ local version="$4"
199
+
200
+ [[ -f "${SAFEDEPS_ADVISORY_LOG}" ]] || return 0
201
+ grep -F "check approve" "${SAFEDEPS_ADVISORY_LOG}" 2>/dev/null \
202
+ | grep -F "hash=${hash}" >/dev/null 2>&1 && return 0
203
+ grep -F "check approve" "${SAFEDEPS_ADVISORY_LOG}" 2>/dev/null \
204
+ | grep -F "ecosystem=${ecosystem}" \
205
+ | grep -F "package=${package_name}" \
206
+ | grep -F "version=${version}" >/dev/null 2>&1
207
+ }
208
+
201
209
  # Emit either JSON or human text. Both forms describe the same event.
202
210
  sf_emit_json() {
203
211
  if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
@@ -205,6 +213,267 @@ sf_emit_json() {
205
213
  fi
206
214
  }
207
215
 
216
+ sf_closure_temp_file() {
217
+ local tmp_dir="${TMPDIR:-/tmp}"
218
+
219
+ mkdir -p "${tmp_dir}"
220
+ mktemp "${tmp_dir%/}/safedeps-closure.XXXXXX"
221
+ }
222
+
223
+ sf_npm_closure_for_spec() {
224
+ local package_name="$1"
225
+ local version="$2"
226
+ local closure_file="$3"
227
+
228
+ if ! safedeps_npm_resolve_spec_closure "${package_name}" "${version}" > "${closure_file}"; then
229
+ sf_eprintf "safedeps: npm closure resolution failed for ${package_name}@${version}"
230
+ return 1
231
+ fi
232
+
233
+ jq -e 'type == "array" and length > 0' "${closure_file}" >/dev/null || {
234
+ sf_eprintf "safedeps: npm closure is empty for ${package_name}@${version}"
235
+ return 1
236
+ }
237
+ }
238
+
239
+ sf_npm_batch_check_closure() {
240
+ local closure_file="$1"
241
+ local batch_file="$2"
242
+
243
+ if ! safedeps_providers_query_batch "npm" "${closure_file}" > "${batch_file}"; then
244
+ return 1
245
+ fi
246
+ jq -e 'type == "array"' "${batch_file}" >/dev/null
247
+ }
248
+
249
+ sf_npm_transitive_file_from_closure() {
250
+ local closure_file="$1"
251
+ local package_name="$2"
252
+ local version="$3"
253
+ local output_file="$4"
254
+
255
+ jq -c --arg package "${package_name}" --arg version "${version}" '
256
+ [
257
+ .[]
258
+ | select(.package != $package or (.version | tostring) != $version)
259
+ | {ecosystem: "npm", package: .package, version: (.version | tostring)}
260
+ ]
261
+ | unique_by(.ecosystem + "\u0000" + .package + "\u0000" + .version)
262
+ | sort_by(.package, .version)
263
+ ' "${closure_file}" > "${output_file}"
264
+ }
265
+
266
+ sf_npm_evidence_file() {
267
+ local provider_file="$1"
268
+ local closure_file="$2"
269
+ local output_file="$3"
270
+
271
+ jq -cn \
272
+ --slurpfile provider "${provider_file}" \
273
+ --slurpfile closure "${closure_file}" \
274
+ '{
275
+ closure_checked: true,
276
+ closure_checked_at: (now | todateiso8601),
277
+ provider: {type: "osv-querybatch", results: $provider[0]},
278
+ closure: $closure[0]
279
+ }' > "${output_file}"
280
+ }
281
+
282
+ sf_ledger_hit_has_npm_closure() {
283
+ local ledger_check="$1"
284
+
285
+ jq -e '.approved == true and (.spec.evidence.closure_checked == true)' <<< "${ledger_check}" >/dev/null 2>&1
286
+ }
287
+
288
+ sf_npm_emit_closure_block_json() {
289
+ local result="$1"
290
+ local ecosystem="$2"
291
+ local package_name="$3"
292
+ local range="$4"
293
+ local version="$5"
294
+ local batch_file="$6"
295
+
296
+ jq -c \
297
+ --arg result "${result}" \
298
+ --arg ecosystem "${ecosystem}" \
299
+ --arg package "${package_name}" \
300
+ --arg range "${range}" \
301
+ --arg version "${version}" \
302
+ '{
303
+ command:"check",
304
+ ecosystem:$ecosystem,
305
+ package:$package,
306
+ input_range:$range,
307
+ resolved_version:$version,
308
+ result:$result,
309
+ approved:false,
310
+ closure_vulnerabilities: [
311
+ .[]
312
+ | select((.status == "vulnerable") or (.status == "hard_block"))
313
+ | {
314
+ package: .package,
315
+ version: .version,
316
+ direct: (.direct // false),
317
+ result: .status,
318
+ vulnerabilities: (.vulnerabilities // []),
319
+ kev: (.kev // {})
320
+ }
321
+ ]
322
+ }' "${batch_file}"
323
+ }
324
+
325
+ sf_cmd_check_npm_full_closure() {
326
+ local ecosystem="$1"
327
+ local pkg="$2"
328
+ local range="$3"
329
+ local version="$4"
330
+ local closure_file
331
+ local batch_file
332
+ local evidence_file
333
+ local transitive_file
334
+ local direct_status
335
+ local vulnerable_count
336
+ local kev_count
337
+
338
+ closure_file=$(sf_closure_temp_file)
339
+ batch_file=$(sf_closure_temp_file)
340
+ evidence_file=$(sf_mktemp_evidence)
341
+ transitive_file=$(sf_closure_temp_file)
342
+
343
+ sf_spinner_start "npm closure 해석 중 (${pkg}@${version})"
344
+ if ! sf_npm_closure_for_spec "${pkg}" "${version}" "${closure_file}"; then
345
+ sf_spinner_stop
346
+ rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
347
+ if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
348
+ jq -nc --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg range "${range}" --arg version "${version}" \
349
+ '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"error", approved:false, error:"npm_closure_resolution_failed"}'
350
+ fi
351
+ return 4
352
+ fi
353
+ sf_spinner_stop
354
+
355
+ sf_spinner_start "closure 취약점 batch 조회 중 (OSV / KEV)"
356
+ if ! sf_npm_batch_check_closure "${closure_file}" "${batch_file}"; then
357
+ sf_spinner_stop
358
+ sf_err "OSV batch 응답 없음 — fail-closed (closure cache miss + 라이브 실패)"
359
+ sf_advisory_log "check fail-closed npm-closure package=${pkg} version=${version}"
360
+ rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
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
+ '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"provider_unavailable", approved:false, error:"OSV batch primary unavailable; no fresh cache for full closure"}'
366
+ fi
367
+ return 4
368
+ fi
369
+ sf_spinner_stop
370
+
371
+ vulnerable_count=$(jq '[.[] | select(.status == "vulnerable")] | length' "${batch_file}")
372
+ kev_count=$(jq '[.[] | select(.status == "hard_block")] | length' "${batch_file}")
373
+ direct_status=$(jq -r --arg package "${pkg}" --arg version "${version}" '
374
+ map(select(.package == $package and .version == $version)) | .[0].status // "missing"
375
+ ' "${batch_file}")
376
+
377
+ if [[ "${kev_count}" -gt 0 ]]; then
378
+ local kev_summary
379
+ kev_summary=$(jq -r '[.[] | select(.status == "hard_block") | "\(.package)@\(.version)" + (if .direct then " (direct)" else " (transitive)" end)] | join(", ")' "${batch_file}")
380
+ sf_err "KEV 매칭 closure 감지 — 설치 차단: ${kev_summary}"
381
+ sf_advisory_log "check block(KEV closure) package=${pkg} version=${version} affected=${kev_summary}"
382
+ if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
383
+ sf_npm_emit_closure_block_json "kev_hard_block" "${ecosystem}" "${pkg}" "${range}" "${version}" "${batch_file}"
384
+ fi
385
+ rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
386
+ return 3
387
+ fi
388
+
389
+ if [[ "${vulnerable_count}" -gt 0 ]]; then
390
+ local block_result="closure_vulnerable"
391
+ if [[ "${direct_status}" == "vulnerable" ]]; then
392
+ local direct_json
393
+ local direct_evidence
394
+ local patched_versions=()
395
+ local patched_version
396
+ direct_evidence=$(sf_mktemp_evidence)
397
+ if safedeps_providers_query "${ecosystem}" "${pkg}" "${version}" > "${direct_evidence}"; then
398
+ while IFS= read -r patched_version; do
399
+ [[ -z "${patched_version}" ]] && continue
400
+ patched_versions+=("${patched_version}")
401
+ done < <(sf_extract_patched_versions "${direct_evidence}" "${version}" || true)
402
+ fi
403
+
404
+ for patched_version in "${patched_versions[@]+${patched_versions[@]}}"; do
405
+ sf_warn "${pkg}@${version} direct 취약 — 후보 ${patched_version} closure 재조회 중."
406
+ if ! sf_npm_closure_for_spec "${pkg}" "${patched_version}" "${closure_file}"; then
407
+ continue
408
+ fi
409
+ if ! sf_npm_batch_check_closure "${closure_file}" "${batch_file}"; then
410
+ rm -f "${direct_evidence}" "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
411
+ return 4
412
+ fi
413
+ vulnerable_count=$(jq '[.[] | select(.status == "vulnerable" or .status == "hard_block")] | length' "${batch_file}")
414
+ if [[ "${vulnerable_count}" -ne 0 ]]; then
415
+ continue
416
+ fi
417
+
418
+ sf_npm_transitive_file_from_closure "${closure_file}" "${pkg}" "${patched_version}" "${transitive_file}"
419
+ sf_npm_evidence_file "${batch_file}" "${closure_file}" "${evidence_file}"
420
+ local spec_json
421
+ spec_json=$(safedeps_ledger_write_approved_spec "${ecosystem}" "${pkg}" "${patched_version}" "${patched_version}" "safedeps-cli" "${evidence_file}" "${SAFEDEPS_LEDGER_DEFAULT_TTL_DAYS}" "${transitive_file}")
422
+ local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
423
+ local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
424
+ local transitive_count; transitive_count=$(jq 'length' "${transitive_file}")
425
+ sf_ok "${pkg}@${patched_version} full closure 승인 (transitive ${transitive_count}, until ${expires_at})"
426
+ sf_info "ledger: ${hash}"
427
+ sf_advisory_log "check approve(patched closure) package=${pkg} version=${patched_version} hash=${hash} transitive=${transitive_count} prev_version=${version}"
428
+ rm -f "${direct_evidence}" "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
429
+ if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
430
+ jq -nc \
431
+ --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
432
+ --arg range "${range}" --arg version "${version}" \
433
+ --arg patched "${patched_version}" \
434
+ --arg hash "${hash}" --arg expires_at "${expires_at}" \
435
+ --argjson transitive_count "${transitive_count}" \
436
+ '{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, transitive_count:$transitive_count, install_hint:("install with " + $package + "@" + $patched)}'
437
+ fi
438
+ return 0
439
+ done
440
+ block_result="cve_unpatched"
441
+ rm -f "${direct_evidence}"
442
+ fi
443
+
444
+ local affected_summary
445
+ affected_summary=$(jq -r '[.[] | select(.status == "vulnerable") | "\(.package)@\(.version)" + (if .direct then " (direct)" else " (transitive)" end)] | join(", ")' "${batch_file}")
446
+ sf_warn "closure 에 취약 패키지 감지 — 승인 보류: ${affected_summary}"
447
+ sf_advisory_log "check block(vulnerable closure) package=${pkg} version=${version} affected=${affected_summary}"
448
+ if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
449
+ sf_npm_emit_closure_block_json "${block_result}" "${ecosystem}" "${pkg}" "${range}" "${version}" "${batch_file}"
450
+ fi
451
+ rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
452
+ return 2
453
+ fi
454
+
455
+ sf_npm_transitive_file_from_closure "${closure_file}" "${pkg}" "${version}" "${transitive_file}"
456
+ sf_npm_evidence_file "${batch_file}" "${closure_file}" "${evidence_file}"
457
+ local spec_json
458
+ spec_json=$(safedeps_ledger_write_approved_spec "${ecosystem}" "${pkg}" "${version}" "${range:-${version}}" "safedeps-cli" "${evidence_file}" "${SAFEDEPS_LEDGER_DEFAULT_TTL_DAYS}" "${transitive_file}")
459
+ local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
460
+ local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
461
+ local transitive_count; transitive_count=$(jq 'length' "${transitive_file}")
462
+ sf_ok "${pkg}@${version} full closure 승인 (transitive ${transitive_count}, until ${expires_at})"
463
+ sf_info "ledger: ${hash}"
464
+ sf_advisory_log "check approve(clean closure) package=${pkg} version=${version} hash=${hash} transitive=${transitive_count}"
465
+ rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
466
+ if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
467
+ jq -nc \
468
+ --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
469
+ --arg range "${range}" --arg version "${version}" \
470
+ --arg hash "${hash}" --arg expires_at "${expires_at}" \
471
+ --argjson transitive_count "${transitive_count}" \
472
+ '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, result:"clean", approved:true, spec_hash:$hash, expires_at:$expires_at, transitive_count:$transitive_count, install_hint:("install with " + $package + "@" + $version)}'
473
+ fi
474
+ return 0
475
+ }
476
+
208
477
  # ---- check -------------------------------------------------------------------
209
478
 
210
479
  cmd_check() {
@@ -252,7 +521,7 @@ cmd_check() {
252
521
  # Ledger lookup short-circuit
253
522
  local ledger_check
254
523
  if ledger_check=$(safedeps_ledger_check "${ecosystem}" "${pkg}" "${version}" 2>/dev/null); then
255
- if [[ "$(jq -r '.approved' <<< "${ledger_check}")" == "true" ]]; then
524
+ if [[ "$(jq -r '.approved' <<< "${ledger_check}")" == "true" ]] && { [[ "${ecosystem}" != "npm" ]] || sf_ledger_hit_has_npm_closure "${ledger_check}"; }; then
256
525
  local hash approved_at expires_at
257
526
  hash=$(jq -r '.hash' <<< "${ledger_check}")
258
527
  approved_at=$(jq -r '.spec.approved_at // "n/a"' <<< "${ledger_check}")
@@ -275,6 +544,11 @@ cmd_check() {
275
544
  fi
276
545
  fi
277
546
 
547
+ if [[ "${ecosystem}" == "npm" ]]; then
548
+ sf_cmd_check_npm_full_closure "${ecosystem}" "${pkg}" "${range}" "${version}"
549
+ return $?
550
+ fi
551
+
278
552
  # Provider query (canonical truth = OSV; KEV overlay; GHSA enrichment)
279
553
  local provider_json
280
554
  sf_spinner_start "취약점 조회 중 (OSV / KEV / GHSA)"
@@ -321,30 +595,45 @@ cmd_check() {
321
595
  ;;
322
596
 
323
597
  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
598
+ local patched_versions=()
599
+ local patched_version
600
+ while IFS= read -r patched_version; do
601
+ [[ -z "${patched_version}" ]] && continue
602
+ patched_versions+=("${patched_version}")
603
+ done < <(sf_extract_patched_versions "${tmp_evidence}" "${version}" || true)
604
+
605
+ if [[ ${#patched_versions[@]} -gt 0 ]]; then
606
+ local narrow_json=""
607
+ local narrow_status=""
608
+ local last_patched=""
609
+ local last_status=""
610
+
611
+ for patched_version in "${patched_versions[@]}"; do
612
+ last_patched="${patched_version}"
613
+ sf_warn "${pkg}@${version} 에 ${vuln_count} 개 CVE — 후보 ${patched_version} 재조회 중."
614
+ sf_spinner_start "${patched_version} 재조회 중"
615
+ if ! narrow_json=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${patched_version}"); then
616
+ sf_spinner_stop
617
+ sf_err "${pkg}@${patched_version} 재조회 실패 — fail-closed"
618
+ rm -f "${tmp_evidence}"
619
+ if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
620
+ jq -nc \
621
+ --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
622
+ --arg range "${range}" --arg version "${version}" \
623
+ --arg patched "${patched_version}" \
624
+ '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"provider_unavailable", approved:false}'
625
+ fi
626
+ return 4
627
+ fi
331
628
  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}'
629
+
630
+ narrow_status=$(jq -r '.status' <<< "${narrow_json}")
631
+ last_status="${narrow_status}"
632
+ if [[ "${narrow_status}" != "clean" ]]; then
633
+ sf_warn "패치 후보 ${patched_version} 깨끗하지 않음 (status=${narrow_status}); 다음 후보를 확인합니다."
634
+ continue
340
635
  fi
341
- return 4
342
- fi
343
- sf_spinner_stop
344
636
 
345
- local narrow_status
346
- narrow_status=$(jq -r '.status' <<< "${narrow_json}")
347
- if [[ "${narrow_status}" == "clean" ]]; then
348
637
  # approve patched version
349
638
  local narrow_evidence
350
639
  narrow_evidence=$(sf_mktemp_evidence)
@@ -367,18 +656,18 @@ cmd_check() {
367
656
  '{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
657
  fi
369
658
  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
659
+ done
660
+
661
+ sf_err "패치 후보를 모두 재조회했지만 clean 후보가 없음 (last=${last_patched}, status=${last_status})"
662
+ rm -f "${tmp_evidence}"
663
+ if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
664
+ jq -nc \
665
+ --arg ecosystem "${ecosystem}" --arg package "${pkg}" \
666
+ --arg range "${range}" --arg version "${version}" \
667
+ --arg patched "${last_patched}" --arg status "${last_status}" \
668
+ '{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, resolved_version:$version, suggested_spec:$patched, result:"patched_still_vulnerable", approved:false, patched_status:$status}'
381
669
  fi
670
+ return 2
382
671
  else
383
672
  sf_warn "${pkg}@${version} 에 ${vuln_count} 개 CVE — 사용 가능한 patch 없음. 승인 보류."
384
673
  sf_advisory_log "check warn(no-patch) ecosystem=${ecosystem} package=${pkg} version=${version} vulns=${vuln_count}"
@@ -609,15 +898,22 @@ cmd_recheck() {
609
898
  done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)
610
899
 
611
900
  local checked=0 still_clean=0
612
- local newly_vuln_arr='[]' kev_hit_arr='[]' revoked_arr='[]'
901
+ local newly_vuln_arr='[]' kev_hit_arr='[]' revoked_arr='[]' suspected_forgery_arr='[]'
613
902
 
614
903
  for f in "${entries[@]+${entries[@]}}"; do
615
- local ecosystem pkg version revoked_at
904
+ local ecosystem pkg version revoked_at hash
616
905
  ecosystem=$(jq -r '.ecosystem' "${f}")
617
906
  pkg=$(jq -r '.package' "${f}")
618
907
  version=$(jq -r '.version' "${f}")
619
908
  revoked_at=$(jq -r '.revoked_at // ""' "${f}")
909
+ hash=$(jq -r '.hash // ""' "${f}")
620
910
  [[ -n "${revoked_at}" ]] && continue
911
+ if [[ -f "${SAFEDEPS_ADVISORY_LOG}" ]] && ! sf_ledger_has_approval_provenance "${hash}" "${ecosystem}" "${pkg}" "${version}"; then
912
+ sf_warn " provenance 없음 — 위조 의심 flag (revoke 안 함)"
913
+ suspected_forgery_arr=$(jq -c \
914
+ --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" --arg hash "${hash}" \
915
+ '. + [{ecosystem:$ecosystem, package:$package, version:$version, hash:$hash, reason:"missing_advisory_log_approval"}]' <<< "${suspected_forgery_arr}")
916
+ fi
621
917
  checked=$(( checked + 1 ))
622
918
 
623
919
  sf_info "재검증 ${ecosystem} ${pkg}@${version}"
@@ -658,9 +954,10 @@ cmd_recheck() {
658
954
  --argjson newly_vulnerable "${newly_vuln_arr}" \
659
955
  --argjson kev_hit "${kev_hit_arr}" \
660
956
  --argjson revoked "${revoked_arr}" \
957
+ --argjson suspected_forgery "${suspected_forgery_arr}" \
661
958
  --argjson checked "${checked}" \
662
959
  --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}'
960
+ '{command:"re-check", checked:$checked, still_clean:$still_clean, newly_vulnerable:$newly_vulnerable, kev_hit:$kev_hit, revoked:$revoked, suspected_forgery:$suspected_forgery}'
664
961
  return 0
665
962
  fi
666
963
 
@@ -668,8 +965,11 @@ cmd_recheck() {
668
965
  local nv kv
669
966
  nv=$(jq -r 'length' <<< "${newly_vuln_arr}")
670
967
  kv=$(jq -r 'length' <<< "${kev_hit_arr}")
968
+ local fg
969
+ fg=$(jq -r 'length' <<< "${suspected_forgery_arr}")
671
970
  [[ "${nv}" -gt 0 ]] && sf_warn "새 CVE 매치로 ${nv} 개 revoke"
672
971
  [[ "${kv}" -gt 0 ]] && sf_err "KEV 매치로 ${kv} 개 revoke"
972
+ [[ "${fg}" -gt 0 ]] && sf_warn "approval provenance 없는 ledger entry ${fg} 개 flag"
673
973
  }
674
974
 
675
975
  # ---- migrate -----------------------------------------------------------------
@@ -702,6 +1002,31 @@ cmd_migrate() {
702
1002
  fi
703
1003
  }
704
1004
 
1005
+ # ---- release-time gates (absorbed from security-release-gates) ---------------
1006
+
1007
+ cmd_gates() {
1008
+ local script="${SAFEDEPS_REPO_DIR}/scripts/release-gates.sh"
1009
+ if [[ ! -f "${script}" ]]; then
1010
+ sf_eprintf "safedeps: release-gates script not found: ${script}"
1011
+ return 4
1012
+ fi
1013
+ # default subcommand is 'run'
1014
+ if [[ "${1:-}" == "run" ]]; then shift; fi
1015
+ exec bash "${script}" "$@"
1016
+ }
1017
+
1018
+ cmd_scan() {
1019
+ exec bash "${SAFEDEPS_REPO_DIR}/lib/gates/scan.sh" "$@"
1020
+ }
1021
+
1022
+ cmd_audit() {
1023
+ exec bash "${SAFEDEPS_REPO_DIR}/lib/gates/audit.sh" "$@"
1024
+ }
1025
+
1026
+ cmd_hooks() {
1027
+ exec bash "${SAFEDEPS_REPO_DIR}/lib/gates/hooks.sh" "$@"
1028
+ }
1029
+
705
1030
  # ---- help / version ----------------------------------------------------------
706
1031
 
707
1032
  cmd_version() {
@@ -771,6 +1096,10 @@ COMMANDS
771
1096
  revoke <hash | pkg@version> Revoke an approved spec.
772
1097
  re-check Re-query providers for all approved specs.
773
1098
  migrate Migrate legacy npm-reorg-guard state.
1099
+ gates [run] Release-time repo gates: secret scan, dep audit, hook/CI check.
1100
+ scan secrets [--repo|--worktree|--staged] Run gitleaks secret scan (repo profile aware).
1101
+ audit [npm] Run npm lockfile audit.
1102
+ hooks <install|check> Install/verify repo-local git hooks.
774
1103
  help [command] Show help.
775
1104
  version Print version.
776
1105
 
@@ -830,6 +1159,10 @@ main() {
830
1159
  revoke) cmd_revoke "$@" ;;
831
1160
  re-check|recheck) cmd_recheck "$@" ;;
832
1161
  migrate) cmd_migrate "$@" ;;
1162
+ gates) cmd_gates "$@" ;;
1163
+ scan) cmd_scan "$@" ;;
1164
+ audit) cmd_audit "$@" ;;
1165
+ hooks) cmd_hooks "$@" ;;
833
1166
  help) cmd_help "$@" ;;
834
1167
  version) cmd_version ;;
835
1168
  *)
@@ -0,0 +1,36 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # safedeps audit npm — generic npm lockfile audit.
5
+ # Absorbed from kuma-studio scripts/security/run-npm-audit.sh.
6
+ # Missing lockfile stays fail-closed (no reproducible verdict without it).
7
+
8
+ REPO_ROOT=""
9
+ AUDIT_LEVEL="${SAFEDEPS_NPM_AUDIT_LEVEL:-${KUMA_NPM_AUDIT_LEVEL:-moderate}}"
10
+
11
+ usage() {
12
+ printf 'Usage: safedeps audit [npm] [--root <repo>] [--level <low|moderate|high|critical>]\n' >&2
13
+ }
14
+
15
+ while [ $# -gt 0 ]; do
16
+ case "$1" in
17
+ npm) shift ;; # allow `audit npm`
18
+ --root) REPO_ROOT="${2:?--root needs a path}"; shift 2 ;;
19
+ --level) AUDIT_LEVEL="${2:?--level needs a value}"; shift 2 ;;
20
+ -h|--help) usage; exit 0 ;;
21
+ *) usage; exit 64 ;;
22
+ esac
23
+ done
24
+
25
+ if [ -z "$REPO_ROOT" ]; then REPO_ROOT="$(pwd)"; fi
26
+ REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
27
+ cd "$REPO_ROOT"
28
+
29
+ if [ ! -f package-lock.json ]; then
30
+ cat >&2 <<'EOF'
31
+ ERROR: package-lock.json is missing, so npm audit cannot produce a reproducible dependency verdict.
32
+ EOF
33
+ exit 1
34
+ fi
35
+
36
+ exec npm audit --audit-level="$AUDIT_LEVEL"