@aldegad/safedeps 2.1.1 → 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/SKILL.md CHANGED
@@ -12,9 +12,11 @@ hooks:
12
12
 
13
13
  Safedeps protects development dependency installs with a three-phase flow:
14
14
 
15
- 1. **Phase 1 — Advisory gate (`safedeps check`)**: query OSV (canonical advisory truth), CISA KEV (overlay), and GitHub Advisory (enrichment) before install. Write the approved (ecosystem, package, version) tuple to `~/.safedeps/approved-specs/<hash>.json` with a 30-day TTL.
16
- 2. **Phase 2 — Hook enforcement (`scripts/safedeps-pre-guard.sh`)**: the PreToolUse hook does not call providers. It only checks the approved-spec ledger for the package/version in the about-to-run install command. Miss or expired → block with a structured message that names the exact `safedeps check` command to run next.
17
- 3. **Phase 3 — Post-install reorg (`scripts/safedeps-post-verify.sh`)**: v1 reorg engine rolls back when the lockfile diverges from the approved spec or install scripts look suspicious.
15
+ 1. **Phase 1 — Advisory gate (`safedeps check`)**: query OSV (canonical advisory truth), CISA KEV (overlay), and GitHub Advisory (enrichment) before install. For npm, resolve the full dependency closure with a script-free lockfile probe and OSV `/v1/querybatch`; write the approved direct spec plus `transitive_specs` to `~/.safedeps/approved-specs/<hash>.json` with a 30-day TTL.
16
+ 2. **Phase 2 — Fast command guard (`scripts/safedeps-pre-guard.sh`)**: the PreToolUse hook does not call providers. It checks the approved-spec ledger for package/version tokens in the about-to-run install command and snapshots dependency truth. Miss or expired → block with a structured message that names the exact `safedeps check` command to run next. On Claude Code it also rewrites an npm install to add `--ignore-scripts` (hook `updatedInput`), so the install runs inert until verified; Codex CLI lacks `updatedInput`, so it falls back to detect-and-rollback.
17
+ 3. **Phase 3 — Effect enforcement + reorg (`scripts/safedeps-post-verify.sh`)**: PostToolUse is the primary enforcement surface for npm. It compares the actual `package-lock.json` closure against direct ledger entries and their `transitive_specs`, re-checks the closure with OSV batch, and rolls back when any package is unapproved/vulnerable or install scripts look suspicious. After a verified inert install it runs `npm rebuild` so the now-trusted lifecycle scripts execute.
18
+
19
+ > **Release-time lane**: the `security-release-gates` orchestrator is absorbed into `safedeps gates`. It runs the repo-tree gates (secret scan, dependency audit, repo git-hook/CI check, install-guard presence) from one entry point, detecting and orchestrating the repo's `security:*` npm scripts, `scripts/security/*`, and gitleaks/npm-audit fallback. Splitting the individual `scan`/`audit`/`hooks`/`git` commands out and fully migrating a target repo's `scripts/security/*` is follow-up work. Design SSoT: [`ARCHITECTURE.md`](./ARCHITECTURE.md) §1 (Two Lanes).
18
20
 
19
21
  ## CLI Reference
20
22
 
@@ -24,6 +26,7 @@ safedeps ledger [--json]
24
26
  safedeps revoke <hash> | <ecosystem> <pkg>@<version> | <pkg>@<version> [--reason <r>] [--json]
25
27
  safedeps re-check [--json]
26
28
  safedeps migrate [--keep-legacy]
29
+ safedeps gates [run] [--root <repo>] [--strict] [--no-run]
27
30
  safedeps help [command]
28
31
  safedeps version
29
32
  ```
@@ -55,6 +58,8 @@ You are the primary user of this skill when you propose `npm install`, `pip inst
55
58
 
56
59
  Use `--json` so the output is parseable. Read the `result` field.
57
60
 
61
+ If `safedeps` is not on your PATH, invoke it at the skill path instead — `~/.claude/skills/safedeps/bin/safedeps` (Claude Code) or `~/.codex/skills/safedeps/bin/safedeps` (Codex CLI). The hook's block messages already name a runnable path, so when blocked you never have to resolve this yourself.
62
+
58
63
  2. **Decide from `result`**:
59
64
 
60
65
  | `result` | Action |
@@ -64,7 +69,7 @@ You are the primary user of this skill when you propose `npm install`, `pip inst
64
69
  | `cve_unpatched` | Do **not** install. Surface the CVE list to the human, propose an alternative package. |
65
70
  | `kev_hard_block` | Do **not** install. Recommend an alternative module — the package is actively exploited in the wild. |
66
71
  | `provider_unavailable` | OSV is unreachable and there is no fresh cache. Do not install. Retry later or tell the human. |
67
- | `error` | Argument parsing failed. Fix and retry. |
72
+ | `error` | Argument parsing or version/closure resolution failed (e.g. an unpublished version). Fix the spec and retry. |
68
73
 
69
74
  3. **Issue the install** only after the spec is approved. The hook re-checks the ledger; if the approved version differs from your install argument, the hook will block again — re-narrow and retry.
70
75
 
@@ -111,7 +116,7 @@ You are the primary user of this skill when you propose `npm install`, `pip inst
111
116
  }
112
117
  ```
113
118
 
114
- Both engines surface `permissionDecisionReason` back to you as the block message. Run the `safedeps check ...` command quoted inside backticks, parse the `--json` output, and retry the install with the approved version.
119
+ Both engines surface `permissionDecisionReason` back to you as the block message. The command quoted inside backticks is already runnable as-is — bare `safedeps` when the CLI is on PATH, otherwise an absolute path. Run it **verbatim** (do not rewrite a full path back down to a bare `safedeps`), parse the `--json` output, and retry the install with the approved version.
115
120
 
116
121
  ### Hard rules
117
122
 
@@ -159,7 +164,8 @@ Disable color with `--no-color` or `NO_COLOR=1`. Non-TTY pipes also strip color
159
164
  - `lib/providers/providers.sh` — OSV / CISA KEV / GitHub Advisory adapters with a single query interface and 24h local cache under `~/.safedeps/cache/`.
160
165
  - `lib/ledger/ledger.sh` — approved spec JSON ledger I/O under `~/.safedeps/approved-specs/`, deterministic spec hash, TTL checks, atomic writes.
161
166
  - `scripts/safedeps-pre-guard.sh` — PreToolUse hook. v1 command pattern guard + ledger lookup for install commands.
162
- - `scripts/safedeps-post-verify.sh` — PostToolUse hook. v1 rollback engine + approved-spec diff.
167
+ - `scripts/safedeps-post-verify.sh` — PostToolUse hook. v1 rollback engine + npm effect gate (lockfile closure vs ledger + OSV batch).
168
+ - `lib/npm/closure.sh` — npm lockfile closure extraction and script-free temp lockfile resolver.
163
169
  - `scripts/install/install-safedeps-hooks.mjs` — cross-engine installer. Symlinks `~/.claude/skills/safedeps` and `~/.codex/skills/safedeps`, idempotently patches `~/.claude/settings.json` and `~/.codex/hooks.json`. `--uninstall` removes both.
164
170
 
165
171
  ## Provider Failure Policy
@@ -182,7 +188,7 @@ What it does:
182
188
  - Symlink the repo at `~/.claude/skills/safedeps` (when `~/.claude` exists) and `~/.codex/skills/safedeps` (when `~/.codex` exists).
183
189
  - Patch `~/.claude/settings.json` `hooks.PreToolUse[matcher=Bash]` and `hooks.PostToolUse[matcher=Bash]` with the canonical script paths.
184
190
  - Patch `~/.codex/hooks.json` with the same matcher and paths.
185
- - Optionally symlink `~/.local/bin/safedeps -> bin/safedeps` so the CLI is on PATH.
191
+ - Optionally symlink `~/.local/bin/safedeps -> bin/safedeps` (via `--link-bin`) for a clean `safedeps` on PATH. **Not required** — the hook block messages and this skill fall back to the absolute skill-relative path, so the gate is fully self-contained without any PATH setup.
186
192
 
187
193
  Manual registration is also supported — see [Claude Code Hooks reference](https://code.claude.com/docs/en/hooks) and [Codex CLI Hooks](https://developers.openai.com/codex/hooks). The canonical script paths are:
188
194
 
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
  *)