@aldegad/safedeps 2.1.1 → 2.4.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.4.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,36 @@ 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
+
1030
+ cmd_doctor() {
1031
+ exec env SAFEDEPS_JSON_MODE="${SAFEDEPS_JSON_MODE}" \
1032
+ bash "${SAFEDEPS_REPO_DIR}/lib/gates/doctor.sh" "$@"
1033
+ }
1034
+
705
1035
  # ---- help / version ----------------------------------------------------------
706
1036
 
707
1037
  cmd_version() {
@@ -756,6 +1086,33 @@ safedeps migrate [--keep-legacy]
756
1086
 
757
1087
  Migrate legacy ~/.npm-reorg-guard state into ~/.safedeps.
758
1088
  By default the legacy directory is archived to remove the old active truth path.
1089
+ EOF
1090
+ ;;
1091
+ doctor)
1092
+ cat <<'EOF'
1093
+ safedeps doctor [--root <repo>] [--fix] [--json]
1094
+
1095
+ Diagnose this repo's security posture: the per-repo secret-leak lane
1096
+ (.gitleaks policy + .githooks/pre-commit + active core.hooksPath + an
1097
+ available scanner) plus the global dependency-install gate. Read-only by
1098
+ default; exits 1 when the secret-leak lane has gaps.
1099
+
1100
+ --fix Scaffold the missing policy (hooks init) and activate it (hooks
1101
+ install). Non-destructive: existing repo-owned files are kept.
1102
+
1103
+ The scaffolded .gitleaks.toml is a STARTER you own and tune; safedeps owns
1104
+ execution (running gitleaks via `safedeps scan secrets`), not the policy.
1105
+ EOF
1106
+ ;;
1107
+ hooks)
1108
+ cat <<'EOF'
1109
+ safedeps hooks <install|check|init> [--root <repo>] [--hooks-path <dir>] [--auto]
1110
+
1111
+ init Scaffold the repo's secret-leak policy from starter templates
1112
+ (.gitleaks[.private].toml + .githooks/pre-commit). Non-destructive.
1113
+ install Activate repo-local git hooks (sets core.hooksPath). Requires the
1114
+ hook file to exist — run `init` first if the repo has none.
1115
+ check Verify the hooks are active, executable, and a scanner is available.
759
1116
  EOF
760
1117
  ;;
761
1118
  *)
@@ -771,6 +1128,11 @@ COMMANDS
771
1128
  revoke <hash | pkg@version> Revoke an approved spec.
772
1129
  re-check Re-query providers for all approved specs.
773
1130
  migrate Migrate legacy npm-reorg-guard state.
1131
+ gates [run] Release-time repo gates: secret scan, dep audit, hook/CI check.
1132
+ scan secrets [--repo|--worktree|--staged] Run gitleaks secret scan (repo profile aware).
1133
+ audit [npm] Run npm lockfile audit.
1134
+ hooks <install|check|init> Install/verify/scaffold repo-local git hooks.
1135
+ doctor [--fix] Diagnose this repo's secret-leak lane (--fix scaffolds + activates).
774
1136
  help [command] Show help.
775
1137
  version Print version.
776
1138
 
@@ -830,6 +1192,11 @@ main() {
830
1192
  revoke) cmd_revoke "$@" ;;
831
1193
  re-check|recheck) cmd_recheck "$@" ;;
832
1194
  migrate) cmd_migrate "$@" ;;
1195
+ gates) cmd_gates "$@" ;;
1196
+ scan) cmd_scan "$@" ;;
1197
+ audit) cmd_audit "$@" ;;
1198
+ hooks) cmd_hooks "$@" ;;
1199
+ doctor) cmd_doctor "$@" ;;
833
1200
  help) cmd_help "$@" ;;
834
1201
  version) cmd_version ;;
835
1202
  *)
@@ -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"