@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/ARCHITECTURE.md +273 -463
- package/README.ko.md +76 -12
- package/README.md +107 -38
- package/ROADMAP.md +123 -84
- package/SECURITY.md +45 -0
- package/SKILL.md +86 -143
- package/bin/safedeps +419 -52
- package/lib/gates/audit.sh +36 -0
- package/lib/gates/doctor.sh +212 -0
- package/lib/gates/hooks.sh +131 -0
- package/lib/gates/repo-profile.sh +60 -0
- package/lib/gates/scan.sh +94 -0
- package/lib/gates/templates/gitleaks.private.toml.tmpl +45 -0
- package/lib/gates/templates/gitleaks.toml.tmpl +43 -0
- package/lib/gates/templates/pre-commit.tmpl +49 -0
- package/lib/ledger/ledger.sh +94 -16
- package/lib/npm/closure.sh +115 -0
- package/lib/providers/providers.sh +248 -26
- package/package.json +2 -1
- package/scripts/install/install-safedeps-hooks.mjs +65 -23
- package/scripts/release-gates.sh +252 -0
- package/scripts/safedeps-post-verify.sh +185 -15
- package/scripts/safedeps-pre-guard.sh +309 -39
- package/scripts/test/e2e.sh +228 -4
- package/scripts/test/fixture-provider.mjs +21 -0
- package/scripts/test/smoke.sh +212 -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.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
|
|
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,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"
|