@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/ARCHITECTURE.md +268 -462
- package/README.ko.md +168 -0
- package/README.md +88 -44
- package/ROADMAP.md +82 -87
- package/SKILL.md +13 -7
- package/bin/safedeps +385 -52
- package/lib/gates/audit.sh +36 -0
- package/lib/gates/hooks.sh +93 -0
- package/lib/gates/repo-profile.sh +60 -0
- package/lib/gates/scan.sh +94 -0
- package/lib/ledger/ledger.sh +94 -16
- package/lib/npm/closure.sh +115 -0
- package/lib/providers/providers.sh +244 -25
- package/package.json +2 -1
- package/scripts/install/install-safedeps-hooks.mjs +62 -23
- package/scripts/release-gates.sh +252 -0
- package/scripts/safedeps-post-verify.sh +167 -10
- package/scripts/safedeps-pre-guard.sh +270 -32
- package/scripts/test/e2e.sh +180 -4
- package/scripts/test/fixture-provider.mjs +21 -0
- package/scripts/test/smoke.sh +135 -10
package/bin/safedeps
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
set -euo pipefail
|
|
9
9
|
|
|
10
|
-
SAFEDEPS_VERSION="2.
|
|
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
|
|
163
|
-
# Echoes
|
|
164
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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"
|