@aldegad/safedeps 2.5.0 → 2.6.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/README.ko.md +28 -8
- package/README.md +28 -6
- package/bin/safedeps +41 -41
- package/package.json +1 -1
- package/scripts/safedeps-post-verify.sh +6 -6
- package/scripts/test/e2e.sh +52 -2
- package/scripts/test/smoke.sh +1 -1
package/README.ko.md
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# safedeps
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> **AI 코딩 에이전트가 취약하거나 미승인된 의존성을 설치하지 못하게 막고, 빠져나간 건 롤백한다.**
|
|
4
4
|
>
|
|
5
|
-
>
|
|
5
|
+
> `safedeps` 는 Claude Code·Codex CLI 에이전트가 실행하는 모든 의존성 install 을 게이트한다. 패키지를 OSV / CISA KEV / GitHub Advisory 로 사전 승인하고, 실제로 lockfile 에 깔린 closure 를 다시 검증하며, 어긋난 건 자동으로 롤백한다. 전부 로컬에서 돌고 런타임 의존성은 0.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **사전 승인** — 모든 `pkg@version` 과 (npm 은) 그 전체 transitive closure 를 설치 *전에* OSV(정본)·CISA KEV·GitHub Advisory 로 검사한다.
|
|
8
|
+
- **실제 effect 강제** — 설치 후 실제 `package-lock.json` closure 를 다시 확인하므로, 래핑·난독화된 명령도 게이트를 못 빠져나간다.
|
|
9
|
+
- **롤백** — 미승인·신규 취약 패키지는 마지막 확정 안전 snapshot 으로 되돌린다. Claude Code 에서는 install 이 inert(`--ignore-scripts`)로 돌아 거부된 패키지의 lifecycle script 가 아예 실행되지 않는다.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## Quickstart
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
```bash
|
|
14
|
+
# 1. CLI 설치 — npm 패키지는 scoped, @aldegad/ 접두사 주의
|
|
15
|
+
npm install -g @aldegad/safedeps
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
# 2. Claude Code / Codex 에 hook 연결 (idempotent)
|
|
18
|
+
cd "$(npm root -g)/@aldegad/safedeps" && node scripts/install/install-safedeps-hooks.mjs
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
# 3. 끝 — 이제 에이전트가 실행하는 모든 의존성 install 이 자동으로 게이트된다.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> `safedeps` 는 CLI 명령어이고, npm 패키지는 **`@aldegad/safedeps`** 다 — npm 의 unscoped `safedeps` 는 무관한 남의 패키지. 전체 skill 소스 트리를 원하면 [설치](#설치) 참고.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
*Detailed reference → [README.md](./README.md) (영문, SSoT)*
|
|
16
28
|
|
|
17
29
|
---
|
|
18
30
|
|
|
@@ -193,6 +205,14 @@ node scripts/install/install-safedeps-recheck-agent.mjs install --hour 9 --minut
|
|
|
193
205
|
|
|
194
206
|
---
|
|
195
207
|
|
|
208
|
+
## "reorg" 는 뭐고 왜 그 비유인가
|
|
209
|
+
|
|
210
|
+
블록체인에서 **reorg (재편성)** 은 미확정 블록 시퀀스를 무효화하고 마지막 확정된 안전 상태로 체인을 되돌린다. `safedeps` 는 모든 install 을 똑같이 본다 — 일련의 공급망 보안 검사를 통과하기 전까지는 미확정 블록 후보다. 설치된 effect 가 어긋나면 도구가 **reorg** 를 수행한다 — lock 파일, `package.json`, `node_modules` 를 마지막 확정된 안전 snapshot 으로 되돌린다.
|
|
211
|
+
|
|
212
|
+
하지만 reorg 는 **최전선이 아니라 backstop 이다.** 나쁜 install 의 대부분은 여기까지 오지도 않는다 — 사전 승인 게이트가 미승인·플래그된 패키지를 실행 전에 *거부* 하고, Claude Code 에서는 install 이 **inert (`--ignore-scripts`)** 로 돌아 closure 가 깨끗하다고 검증되기 전까지 lifecycle script 가 실행되지 않는다. reorg 가 발동하는 건 잔여 케이스뿐이다 — 승인된 직접 패키지가 미승인·취약 transitive 를 끌어오거나, 래핑된 명령이 advisory 계층을 빠져나간 경우 — 그리고 그때조차 실행되지도 못한 파일을 되돌린다.
|
|
213
|
+
|
|
214
|
+
빠른 advisory 피드백, 관측 가능한 rollback, silent fallback 없음. command guard 는 best-effort UX 이고, 설치 결과 effect 가 backstop 이다.
|
|
215
|
+
|
|
196
216
|
## 다른 도구와 뭐가 다른가
|
|
197
217
|
|
|
198
218
|
`safedeps` 는 **AI 에이전트가 코딩 중 install 명령을 작성하는 순간**에 끼어드는 도구다. CI 스캔, PR 권장, runtime sandbox 처럼 다른 시점에서 동작하는 도구들과 핵심 차별점이 여기 있다.
|
package/README.md
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# safedeps
|
|
2
2
|
|
|
3
|
-
> **
|
|
3
|
+
> **Stop your AI coding agent from installing vulnerable or unapproved dependencies — and roll back the ones that slip through.**
|
|
4
4
|
>
|
|
5
|
-
>
|
|
5
|
+
> `safedeps` gates every dependency install your Claude Code or Codex CLI agent runs. It pre-approves packages against OSV / CISA KEV / GitHub Advisory, re-verifies the closure that actually lands in your lockfile, and auto-rolls-back anything that diverges. Local-only, with zero runtime dependencies. *(한국어 README → [README.ko.md](./README.ko.md))*
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
- **Pre-approve** — every `pkg@version`, plus its full transitive closure for npm, is cleared against OSV (canonical), CISA KEV, and GitHub Advisory *before* it installs.
|
|
8
|
+
- **Enforce the real effect** — after the install, the actual `package-lock.json` closure is re-checked, so a wrapped or obfuscated command can't sneak a package past the gate.
|
|
9
|
+
- **Roll back** — anything unapproved or newly-vulnerable is reverted to the last confirmed safe snapshot. On Claude Code the install runs inert (`--ignore-scripts`), so a rejected package's lifecycle scripts never run.
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
## Quickstart
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
```bash
|
|
14
|
+
# 1. Install the CLI — the npm package is scoped, note the @aldegad/ prefix
|
|
15
|
+
npm install -g @aldegad/safedeps
|
|
16
|
+
|
|
17
|
+
# 2. Wire the hooks into Claude Code / Codex (idempotent)
|
|
18
|
+
cd "$(npm root -g)/@aldegad/safedeps" && node scripts/install/install-safedeps-hooks.mjs
|
|
19
|
+
|
|
20
|
+
# 3. Done — every dependency install your agent runs is now gated.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
> `safedeps` is the CLI command; the npm package is **`@aldegad/safedeps`** — the unscoped `safedeps` on npm is an unrelated package. Prefer the full skill source tree? See [Installation](#installation).
|
|
24
|
+
|
|
25
|
+

|
|
12
26
|
|
|
13
27
|
## Distribution Model
|
|
14
28
|
|
|
@@ -115,6 +129,14 @@ After the install command completes, the verify hook analyzes what changed. For
|
|
|
115
129
|
4. The event is logged to `~/.safedeps/reorg.log`.
|
|
116
130
|
5. Claude Code receives a system message detailing the detected threats and rollback actions.
|
|
117
131
|
|
|
132
|
+
## Why "reorg"?
|
|
133
|
+
|
|
134
|
+
The name borrows from blockchain, where a **reorganization (reorg)** invalidates a sequence of unconfirmed blocks and reverts the chain to its last confirmed safe state. `safedeps` treats every install the same way: an unconfirmed block candidate until it passes a battery of supply-chain checks. If the installed effect diverges, the tool performs a **reorg** -- rolling the lock file, `package.json`, and `node_modules` back to the last confirmed safe snapshot.
|
|
135
|
+
|
|
136
|
+
But the reorg is the **backstop, not the front line.** Most bad installs never reach it: the pre-approval gate *denies* an unapproved or flagged package before it runs, and on Claude Code the install runs **inert** (`--ignore-scripts`) so lifecycle scripts do not execute until the closure verifies clean. The reorg fires for the residual case -- an approved direct package that pulls in an unapproved or vulnerable transitive, or a wrapped command that slips past the advisory layer -- and even then it rolls back files that never got to run.
|
|
137
|
+
|
|
138
|
+
Fast advisory feedback, observable rollback, and no hidden fallback. The command guard is best-effort UX; the installed effect is the backstop.
|
|
139
|
+
|
|
118
140
|
## The Blockchain Analogy
|
|
119
141
|
|
|
120
142
|
| Blockchain Concept | Safedeps Equivalent |
|
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.6.0"
|
|
11
11
|
|
|
12
12
|
# ---- repo / lib bootstrap ----------------------------------------------------
|
|
13
13
|
|
|
@@ -340,7 +340,7 @@ sf_cmd_check_npm_full_closure() {
|
|
|
340
340
|
evidence_file=$(sf_mktemp_evidence)
|
|
341
341
|
transitive_file=$(sf_closure_temp_file)
|
|
342
342
|
|
|
343
|
-
sf_spinner_start "npm closure
|
|
343
|
+
sf_spinner_start "resolving npm closure (${pkg}@${version})"
|
|
344
344
|
if ! sf_npm_closure_for_spec "${pkg}" "${version}" "${closure_file}"; then
|
|
345
345
|
sf_spinner_stop
|
|
346
346
|
rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
|
|
@@ -352,10 +352,10 @@ sf_cmd_check_npm_full_closure() {
|
|
|
352
352
|
fi
|
|
353
353
|
sf_spinner_stop
|
|
354
354
|
|
|
355
|
-
sf_spinner_start "closure
|
|
355
|
+
sf_spinner_start "batch-checking closure for advisories (OSV / KEV)"
|
|
356
356
|
if ! sf_npm_batch_check_closure "${closure_file}" "${batch_file}"; then
|
|
357
357
|
sf_spinner_stop
|
|
358
|
-
sf_err "OSV batch
|
|
358
|
+
sf_err "no OSV batch response — fail-closed (closure cache miss + live query failed)"
|
|
359
359
|
sf_advisory_log "check fail-closed npm-closure package=${pkg} version=${version}"
|
|
360
360
|
rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
|
|
361
361
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
@@ -377,7 +377,7 @@ sf_cmd_check_npm_full_closure() {
|
|
|
377
377
|
if [[ "${kev_count}" -gt 0 ]]; then
|
|
378
378
|
local kev_summary
|
|
379
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
|
|
380
|
+
sf_err "KEV-matched package in closure — install blocked: ${kev_summary}"
|
|
381
381
|
sf_advisory_log "check block(KEV closure) package=${pkg} version=${version} affected=${kev_summary}"
|
|
382
382
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
383
383
|
sf_npm_emit_closure_block_json "kev_hard_block" "${ecosystem}" "${pkg}" "${range}" "${version}" "${batch_file}"
|
|
@@ -402,7 +402,7 @@ sf_cmd_check_npm_full_closure() {
|
|
|
402
402
|
fi
|
|
403
403
|
|
|
404
404
|
for patched_version in "${patched_versions[@]+${patched_versions[@]}}"; do
|
|
405
|
-
sf_warn "${pkg}@${version} direct
|
|
405
|
+
sf_warn "${pkg}@${version} direct vulnerable — re-checking closure for candidate ${patched_version}."
|
|
406
406
|
if ! sf_npm_closure_for_spec "${pkg}" "${patched_version}" "${closure_file}"; then
|
|
407
407
|
continue
|
|
408
408
|
fi
|
|
@@ -422,7 +422,7 @@ sf_cmd_check_npm_full_closure() {
|
|
|
422
422
|
local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
|
|
423
423
|
local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
|
|
424
424
|
local transitive_count; transitive_count=$(jq 'length' "${transitive_file}")
|
|
425
|
-
sf_ok "${pkg}@${patched_version} full closure
|
|
425
|
+
sf_ok "${pkg}@${patched_version} full closure cleared (transitive ${transitive_count}, until ${expires_at})"
|
|
426
426
|
sf_info "ledger: ${hash}"
|
|
427
427
|
sf_advisory_log "check approve(patched closure) package=${pkg} version=${patched_version} hash=${hash} transitive=${transitive_count} prev_version=${version}"
|
|
428
428
|
rm -f "${direct_evidence}" "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
|
|
@@ -443,7 +443,7 @@ sf_cmd_check_npm_full_closure() {
|
|
|
443
443
|
|
|
444
444
|
local affected_summary
|
|
445
445
|
affected_summary=$(jq -r '[.[] | select(.status == "vulnerable") | "\(.package)@\(.version)" + (if .direct then " (direct)" else " (transitive)" end)] | join(", ")' "${batch_file}")
|
|
446
|
-
sf_warn "
|
|
446
|
+
sf_warn "vulnerable package in closure — approval withheld: ${affected_summary}"
|
|
447
447
|
sf_advisory_log "check block(vulnerable closure) package=${pkg} version=${version} affected=${affected_summary}"
|
|
448
448
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
449
449
|
sf_npm_emit_closure_block_json "${block_result}" "${ecosystem}" "${pkg}" "${range}" "${version}" "${batch_file}"
|
|
@@ -459,7 +459,7 @@ sf_cmd_check_npm_full_closure() {
|
|
|
459
459
|
local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
|
|
460
460
|
local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
|
|
461
461
|
local transitive_count; transitive_count=$(jq 'length' "${transitive_file}")
|
|
462
|
-
sf_ok "${pkg}@${version} full closure
|
|
462
|
+
sf_ok "${pkg}@${version} full closure cleared (transitive ${transitive_count}, until ${expires_at})"
|
|
463
463
|
sf_info "ledger: ${hash}"
|
|
464
464
|
sf_advisory_log "check approve(clean closure) package=${pkg} version=${version} hash=${hash} transitive=${transitive_count}"
|
|
465
465
|
rm -f "${closure_file}" "${batch_file}" "${evidence_file}" "${transitive_file}"
|
|
@@ -506,10 +506,10 @@ cmd_check() {
|
|
|
506
506
|
range=$(sf_parse_pkg_spec "${pkg_spec}" | sed -n '2p')
|
|
507
507
|
|
|
508
508
|
local version
|
|
509
|
-
sf_spinner_start "
|
|
509
|
+
sf_spinner_start "resolving version (${pkg}@${range})"
|
|
510
510
|
if ! version=$(sf_resolve_version "${ecosystem}" "${pkg}" "${range}"); then
|
|
511
511
|
sf_spinner_stop
|
|
512
|
-
sf_err "
|
|
512
|
+
sf_err "version resolution failed: ${pkg}@${range}"
|
|
513
513
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
514
514
|
jq -nc --arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg range "${range}" \
|
|
515
515
|
'{command:"check", ecosystem:$ecosystem, package:$package, input_range:$range, result:"error", error:"version_resolution_failed"}'
|
|
@@ -526,7 +526,7 @@ cmd_check() {
|
|
|
526
526
|
hash=$(jq -r '.hash' <<< "${ledger_check}")
|
|
527
527
|
approved_at=$(jq -r '.spec.approved_at // "n/a"' <<< "${ledger_check}")
|
|
528
528
|
expires_at=$(jq -r '.spec.expires_at // "n/a"' <<< "${ledger_check}")
|
|
529
|
-
sf_ok "${pkg}@${version}
|
|
529
|
+
sf_ok "${pkg}@${version} already approved (until ${expires_at})"
|
|
530
530
|
sf_info "ledger: ${hash}"
|
|
531
531
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
532
532
|
jq -nc \
|
|
@@ -551,10 +551,10 @@ cmd_check() {
|
|
|
551
551
|
|
|
552
552
|
# Provider query (canonical truth = OSV; KEV overlay; GHSA enrichment)
|
|
553
553
|
local provider_json
|
|
554
|
-
sf_spinner_start "
|
|
554
|
+
sf_spinner_start "checking advisories (OSV / KEV / GHSA)"
|
|
555
555
|
if ! provider_json=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${version}"); then
|
|
556
556
|
sf_spinner_stop
|
|
557
|
-
sf_err "OSV primary
|
|
557
|
+
sf_err "no OSV primary response — fail-closed (cache miss + live query failed)"
|
|
558
558
|
sf_advisory_log "check fail-closed ecosystem=${ecosystem} package=${pkg} version=${version}"
|
|
559
559
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
560
560
|
jq -nc \
|
|
@@ -577,11 +577,11 @@ cmd_check() {
|
|
|
577
577
|
|
|
578
578
|
case "${status}" in
|
|
579
579
|
hard_block)
|
|
580
|
-
sf_err "KEV
|
|
580
|
+
sf_err "KEV match — ${pkg}@${version} is exploited in the wild. install blocked."
|
|
581
581
|
local kev_cves
|
|
582
582
|
kev_cves=$(jq -r '[.kev.matches[]?.cveID] | unique | join(", ")' <<< "${provider_json}")
|
|
583
|
-
[[ -n "${kev_cves}" ]] && sf_info "
|
|
584
|
-
sf_warn "
|
|
583
|
+
[[ -n "${kev_cves}" ]] && sf_info "related CVEs: ${kev_cves}"
|
|
584
|
+
sf_warn "consider an alternative package; this spec is not approved in the ledger."
|
|
585
585
|
sf_advisory_log "check block(KEV) ecosystem=${ecosystem} package=${pkg} version=${version} cves=${kev_cves}"
|
|
586
586
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
587
587
|
jq -c \
|
|
@@ -610,11 +610,11 @@ cmd_check() {
|
|
|
610
610
|
|
|
611
611
|
for patched_version in "${patched_versions[@]}"; do
|
|
612
612
|
last_patched="${patched_version}"
|
|
613
|
-
sf_warn "${pkg}@${version}
|
|
614
|
-
sf_spinner_start "${patched_version}
|
|
613
|
+
sf_warn "${pkg}@${version} has ${vuln_count} CVE(s) — re-checking candidate ${patched_version}."
|
|
614
|
+
sf_spinner_start "re-checking ${patched_version}"
|
|
615
615
|
if ! narrow_json=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${patched_version}"); then
|
|
616
616
|
sf_spinner_stop
|
|
617
|
-
sf_err "${pkg}@${patched_version}
|
|
617
|
+
sf_err "${pkg}@${patched_version} re-check failed — fail-closed"
|
|
618
618
|
rm -f "${tmp_evidence}"
|
|
619
619
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
620
620
|
jq -nc \
|
|
@@ -630,7 +630,7 @@ cmd_check() {
|
|
|
630
630
|
narrow_status=$(jq -r '.status' <<< "${narrow_json}")
|
|
631
631
|
last_status="${narrow_status}"
|
|
632
632
|
if [[ "${narrow_status}" != "clean" ]]; then
|
|
633
|
-
sf_warn "
|
|
633
|
+
sf_warn "patch candidate ${patched_version} is not clean either (status=${narrow_status}); trying the next candidate."
|
|
634
634
|
continue
|
|
635
635
|
fi
|
|
636
636
|
|
|
@@ -644,7 +644,7 @@ cmd_check() {
|
|
|
644
644
|
rm -f "${narrow_evidence}" "${tmp_evidence}"
|
|
645
645
|
local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
|
|
646
646
|
local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
|
|
647
|
-
sf_ok "${pkg}@${patched_version}
|
|
647
|
+
sf_ok "${pkg}@${patched_version} approved (until ${expires_at})"
|
|
648
648
|
sf_info "ledger: ${hash}"
|
|
649
649
|
sf_advisory_log "check approve(patched) ecosystem=${ecosystem} package=${pkg} version=${patched_version} hash=${hash} prev_version=${version}"
|
|
650
650
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
@@ -658,7 +658,7 @@ cmd_check() {
|
|
|
658
658
|
return 0
|
|
659
659
|
done
|
|
660
660
|
|
|
661
|
-
sf_err "
|
|
661
|
+
sf_err "re-checked all patch candidates but none are clean (last=${last_patched}, status=${last_status})"
|
|
662
662
|
rm -f "${tmp_evidence}"
|
|
663
663
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
664
664
|
jq -nc \
|
|
@@ -669,7 +669,7 @@ cmd_check() {
|
|
|
669
669
|
fi
|
|
670
670
|
return 2
|
|
671
671
|
else
|
|
672
|
-
sf_warn "${pkg}@${version}
|
|
672
|
+
sf_warn "${pkg}@${version} has ${vuln_count} CVE(s) — no patch available. approval withheld."
|
|
673
673
|
sf_advisory_log "check warn(no-patch) ecosystem=${ecosystem} package=${pkg} version=${version} vulns=${vuln_count}"
|
|
674
674
|
rm -f "${tmp_evidence}"
|
|
675
675
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
@@ -688,7 +688,7 @@ cmd_check() {
|
|
|
688
688
|
rm -f "${tmp_evidence}"
|
|
689
689
|
local hash; hash=$(jq -r '.hash' <<< "${spec_json}")
|
|
690
690
|
local expires_at; expires_at=$(jq -r '.expires_at' <<< "${spec_json}")
|
|
691
|
-
sf_ok "${pkg}@${version}
|
|
691
|
+
sf_ok "${pkg}@${version} approved (until ${expires_at})"
|
|
692
692
|
sf_info "ledger: ${hash}"
|
|
693
693
|
sf_advisory_log "check approve(clean) ecosystem=${ecosystem} package=${pkg} version=${version} hash=${hash}"
|
|
694
694
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
@@ -702,7 +702,7 @@ cmd_check() {
|
|
|
702
702
|
;;
|
|
703
703
|
|
|
704
704
|
*)
|
|
705
|
-
sf_err "
|
|
705
|
+
sf_err "unexpected provider status: ${status}"
|
|
706
706
|
rm -f "${tmp_evidence}"
|
|
707
707
|
return 4
|
|
708
708
|
;;
|
|
@@ -757,7 +757,7 @@ cmd_ledger() {
|
|
|
757
757
|
fi
|
|
758
758
|
|
|
759
759
|
if [[ ${#entries[@]} -eq 0 ]]; then
|
|
760
|
-
sf_info "approved-specs
|
|
760
|
+
sf_info "approved-specs is empty (${SAFEDEPS_LEDGER_DIR})"
|
|
761
761
|
return 0
|
|
762
762
|
fi
|
|
763
763
|
|
|
@@ -830,7 +830,7 @@ cmd_revoke() {
|
|
|
830
830
|
local target_file=""
|
|
831
831
|
if [[ "${arg1}" == sha256:* ]]; then
|
|
832
832
|
target_file=$(safedeps_ledger_path_for_hash "${arg1}")
|
|
833
|
-
[[ -f "${target_file}" ]] || { sf_err "ledger entry
|
|
833
|
+
[[ -f "${target_file}" ]] || { sf_err "no ledger entry: ${arg1}"; return 1; }
|
|
834
834
|
else
|
|
835
835
|
# one or two args. Two = ecosystem + pkg@version. One = pkg@version (scan).
|
|
836
836
|
if [[ -n "${arg2}" ]]; then
|
|
@@ -839,7 +839,7 @@ cmd_revoke() {
|
|
|
839
839
|
version=$(sf_parse_pkg_spec "${arg2}" | sed -n '2p')
|
|
840
840
|
[[ -n "${version}" ]] || { sf_eprintf "safedeps: revoke needs pkg@version, got '${arg2}'"; return 4; }
|
|
841
841
|
target_file=$(safedeps_ledger_path "${arg1}" "${pkg}" "${version}")
|
|
842
|
-
[[ -f "${target_file}" ]] || { sf_err "ledger entry
|
|
842
|
+
[[ -f "${target_file}" ]] || { sf_err "no ledger entry: ${arg1} ${pkg}@${version}"; return 1; }
|
|
843
843
|
else
|
|
844
844
|
local pkg version
|
|
845
845
|
pkg=$(sf_parse_pkg_spec "${arg1}" | sed -n '1p')
|
|
@@ -855,9 +855,9 @@ cmd_revoke() {
|
|
|
855
855
|
fi
|
|
856
856
|
done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)
|
|
857
857
|
case "${#matches[@]}" in
|
|
858
|
-
0) sf_err "ledger entry
|
|
858
|
+
0) sf_err "no ledger entry: ${pkg}@${version}"; return 1 ;;
|
|
859
859
|
1) target_file="${matches[0]}" ;;
|
|
860
|
-
*) sf_err "${pkg}@${version}
|
|
860
|
+
*) sf_err "${pkg}@${version} matched in multiple ecosystems — specify the ecosystem"; return 4 ;;
|
|
861
861
|
esac
|
|
862
862
|
fi
|
|
863
863
|
fi
|
|
@@ -870,7 +870,7 @@ cmd_revoke() {
|
|
|
870
870
|
local revoked_json
|
|
871
871
|
revoked_json=$(safedeps_ledger_revoke "${ecosystem}" "${pkg}" "${version}" "${reason}")
|
|
872
872
|
sf_advisory_log "revoke ecosystem=${ecosystem} package=${pkg} version=${version} reason=${reason}"
|
|
873
|
-
sf_ok "
|
|
873
|
+
sf_ok "revoked: ${ecosystem} ${pkg}@${version}"
|
|
874
874
|
sf_info "reason: ${reason}"
|
|
875
875
|
|
|
876
876
|
if [[ "${SAFEDEPS_JSON_MODE}" -eq 1 ]]; then
|
|
@@ -909,17 +909,17 @@ cmd_recheck() {
|
|
|
909
909
|
hash=$(jq -r '.hash // ""' "${f}")
|
|
910
910
|
[[ -n "${revoked_at}" ]] && continue
|
|
911
911
|
if [[ -f "${SAFEDEPS_ADVISORY_LOG}" ]] && ! sf_ledger_has_approval_provenance "${hash}" "${ecosystem}" "${pkg}" "${version}"; then
|
|
912
|
-
sf_warn " provenance
|
|
912
|
+
sf_warn " no provenance — flagged as suspected forgery (not revoked)"
|
|
913
913
|
suspected_forgery_arr=$(jq -c \
|
|
914
914
|
--arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" --arg hash "${hash}" \
|
|
915
915
|
'. + [{ecosystem:$ecosystem, package:$package, version:$version, hash:$hash, reason:"missing_advisory_log_approval"}]' <<< "${suspected_forgery_arr}")
|
|
916
916
|
fi
|
|
917
917
|
checked=$(( checked + 1 ))
|
|
918
918
|
|
|
919
|
-
sf_info "
|
|
919
|
+
sf_info "re-checking ${ecosystem} ${pkg}@${version}"
|
|
920
920
|
local pj
|
|
921
921
|
if ! pj=$(safedeps_providers_query "${ecosystem}" "${pkg}" "${version}" 2>/dev/null); then
|
|
922
|
-
sf_warn " provider
|
|
922
|
+
sf_warn " no provider response — skipped"
|
|
923
923
|
continue
|
|
924
924
|
fi
|
|
925
925
|
local s; s=$(jq -r '.status' <<< "${pj}")
|
|
@@ -932,12 +932,12 @@ cmd_recheck() {
|
|
|
932
932
|
safedeps_ledger_revoke "${ecosystem}" "${pkg}" "${version}" "${reason}" >/dev/null
|
|
933
933
|
sf_advisory_log "re-check revoke ecosystem=${ecosystem} package=${pkg} version=${version} status=${s}"
|
|
934
934
|
if [[ "${s}" == "hard_block" ]]; then
|
|
935
|
-
sf_err " KEV
|
|
935
|
+
sf_err " KEV match -> revoked"
|
|
936
936
|
kev_hit_arr=$(jq -c \
|
|
937
937
|
--arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" \
|
|
938
938
|
'. + [{ecosystem:$ecosystem, package:$package, version:$version, status:"hard_block"}]' <<< "${kev_hit_arr}")
|
|
939
939
|
else
|
|
940
|
-
sf_warn "
|
|
940
|
+
sf_warn " new CVE match -> revoked"
|
|
941
941
|
newly_vuln_arr=$(jq -c \
|
|
942
942
|
--arg ecosystem "${ecosystem}" --arg package "${pkg}" --arg version "${version}" \
|
|
943
943
|
'. + [{ecosystem:$ecosystem, package:$package, version:$version, status:"vulnerable"}]' <<< "${newly_vuln_arr}")
|
|
@@ -961,15 +961,15 @@ cmd_recheck() {
|
|
|
961
961
|
return 0
|
|
962
962
|
fi
|
|
963
963
|
|
|
964
|
-
sf_info "
|
|
964
|
+
sf_info "re-check complete: ${still_clean}/${checked} still clean"
|
|
965
965
|
local nv kv
|
|
966
966
|
nv=$(jq -r 'length' <<< "${newly_vuln_arr}")
|
|
967
967
|
kv=$(jq -r 'length' <<< "${kev_hit_arr}")
|
|
968
968
|
local fg
|
|
969
969
|
fg=$(jq -r 'length' <<< "${suspected_forgery_arr}")
|
|
970
|
-
[[ "${nv}" -gt 0 ]] && sf_warn "
|
|
971
|
-
[[ "${kv}" -gt 0 ]] && sf_err "
|
|
972
|
-
[[ "${fg}" -gt 0 ]] && sf_warn "
|
|
970
|
+
[[ "${nv}" -gt 0 ]] && sf_warn "revoked ${nv} for new CVE match"
|
|
971
|
+
[[ "${kv}" -gt 0 ]] && sf_err "revoked ${kv} for KEV match"
|
|
972
|
+
[[ "${fg}" -gt 0 ]] && sf_warn "flagged ${fg} ledger entries with no approval provenance"
|
|
973
973
|
}
|
|
974
974
|
|
|
975
975
|
# ---- migrate -----------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -781,12 +781,12 @@ LOG_EOF
|
|
|
781
781
|
--arg log_path "${GUARD_DIR}/reorg.log" \
|
|
782
782
|
'{
|
|
783
783
|
systemMessage: (
|
|
784
|
-
"safedeps:
|
|
785
|
-
"
|
|
786
|
-
"
|
|
787
|
-
"
|
|
788
|
-
(if $warnings == "" then "" else "\n\
|
|
789
|
-
"\n\
|
|
784
|
+
"safedeps: suspicious dependency change detected — rolled back to the last confirmed safe snapshot.\n\n" +
|
|
785
|
+
"Detected problems:\n" + $reasons + "\n\n" +
|
|
786
|
+
"Rollback snapshot: " + $rollback_snapshot + "\n" +
|
|
787
|
+
"Rolled-back files: " + $rolled_back +
|
|
788
|
+
(if $warnings == "" then "" else "\n\nAdditional warnings:\n" + $warnings end) +
|
|
789
|
+
"\n\nDetails log: " + $log_path
|
|
790
790
|
)
|
|
791
791
|
}'
|
|
792
792
|
exit 0
|
package/scripts/test/e2e.sh
CHANGED
|
@@ -190,6 +190,50 @@ EOF
|
|
|
190
190
|
grep -qx 'rebuild' "${tmp_root}/npm-calls.log" || fail "post hook runs npm rebuild after verified injected install"
|
|
191
191
|
pass "post hook rebuilds after verified inert install"
|
|
192
192
|
|
|
193
|
+
# Reorg must actually revert the on-disk lockfile, not just print the message. The
|
|
194
|
+
# missing-transitive test below proves the systemMessage; this proves the stronger
|
|
195
|
+
# claim — a tampered lockfile is restored byte-for-byte to the last confirmed safe
|
|
196
|
+
# snapshot on disk. Regression guard so a future change cannot break the rollback
|
|
197
|
+
# while keeping the message green. Stub npm keeps `npm ci` from rewriting the file.
|
|
198
|
+
revert_project="${tmp_root}/revert-project"
|
|
199
|
+
mkdir -p "${revert_project}"
|
|
200
|
+
printf '{"dependencies":{"fixture-parent":"1.0.0"}}\n' > "${revert_project}/package.json"
|
|
201
|
+
cat > "${revert_project}/package-lock.json" <<'EOF'
|
|
202
|
+
{
|
|
203
|
+
"name": "revert-project",
|
|
204
|
+
"lockfileVersion": 3,
|
|
205
|
+
"packages": {
|
|
206
|
+
"": {"dependencies": {"fixture-parent": "1.0.0"}},
|
|
207
|
+
"node_modules/fixture-parent": {"version": "1.0.0", "dependencies": {"fixture-child": "1.0.0"}},
|
|
208
|
+
"node_modules/fixture-child": {"version": "1.0.0"}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
EOF
|
|
212
|
+
cp "${revert_project}/package-lock.json" "${tmp_root}/revert-safe-lock.json"
|
|
213
|
+
scripts/safedeps-pre-guard.sh > /dev/null <<EOF
|
|
214
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${revert_project}"}
|
|
215
|
+
EOF
|
|
216
|
+
cat > "${revert_project}/package-lock.json" <<'EOF'
|
|
217
|
+
{
|
|
218
|
+
"name": "revert-project",
|
|
219
|
+
"lockfileVersion": 3,
|
|
220
|
+
"packages": {
|
|
221
|
+
"": {"dependencies": {"fixture-parent": "1.0.0"}},
|
|
222
|
+
"node_modules/fixture-parent": {"version": "1.0.0", "dependencies": {"fixture-child": "1.0.0"}},
|
|
223
|
+
"node_modules/fixture-child": {"version": "1.0.0"},
|
|
224
|
+
"node_modules/fixture-evil": {"version": "6.6.6", "resolved": "git://evil.example.com/fixture-evil.git"}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
EOF
|
|
228
|
+
revert_post=$(
|
|
229
|
+
PATH="${stub_bin}:${PATH}" scripts/safedeps-post-verify.sh <<EOF
|
|
230
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${revert_project}"}
|
|
231
|
+
EOF
|
|
232
|
+
)
|
|
233
|
+
grep -q 'suspicious dependency change detected' <<< "${revert_post}" || fail "reorg fires on a tampered lockfile"
|
|
234
|
+
cmp -s "${revert_project}/package-lock.json" "${tmp_root}/revert-safe-lock.json" || fail "reorg restores the exact safe lockfile content on disk"
|
|
235
|
+
pass "reorg reverts a tampered lockfile to safe content on disk"
|
|
236
|
+
|
|
193
237
|
export SAFEDEPS_HOME="${tmp_root}/safe-missing-transitive"
|
|
194
238
|
export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
|
|
195
239
|
export SAFEDEPS_OSV_BATCH_API_URL="http://127.0.0.1:${port}/osv/v1/querybatch"
|
|
@@ -223,9 +267,15 @@ missing_post=$(
|
|
|
223
267
|
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${missing_project}"}
|
|
224
268
|
EOF
|
|
225
269
|
)
|
|
226
|
-
grep -q '
|
|
270
|
+
grep -q 'suspicious dependency change detected' <<< "${missing_post}" || fail "post hook reorgs unapproved transitive package"
|
|
227
271
|
grep -q 'fixture-child@1.0.0' <<< "${missing_post}" || fail "post hook names unapproved transitive package"
|
|
228
|
-
|
|
272
|
+
# Not just the message — the unapproved transitive must be gone from the on-disk
|
|
273
|
+
# lockfile. (Reorg removes the tampered lockfile; a no-network reinstall may recreate
|
|
274
|
+
# an empty one, so assert fixture-child is absent rather than the file itself.)
|
|
275
|
+
if grep -q 'fixture-child' "${missing_project}/package-lock.json" 2>/dev/null; then
|
|
276
|
+
fail "post hook reorg leaves the unapproved transitive in the on-disk lockfile"
|
|
277
|
+
fi
|
|
278
|
+
pass "post hook reorgs unapproved transitive package (verified on disk)"
|
|
229
279
|
|
|
230
280
|
export SAFEDEPS_HOME="${tmp_root}/safe"
|
|
231
281
|
export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
|
package/scripts/test/smoke.sh
CHANGED
|
@@ -262,7 +262,7 @@ tamper_post=$(
|
|
|
262
262
|
jq -nc --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:"npm install ledger-tamper@1.0.0"},cwd:$cwd}' |
|
|
263
263
|
HOME="${tamper_home}" SAFEDEPS_HOME="${tamper_safe}" scripts/safedeps-post-verify.sh
|
|
264
264
|
)
|
|
265
|
-
grep -q '
|
|
265
|
+
grep -q 'suspicious dependency change detected' <<< "${tamper_post}" || fail "post hook reorgs safedeps ledger tamper script"
|
|
266
266
|
pass "post hook reorgs safedeps ledger tamper script"
|
|
267
267
|
|
|
268
268
|
fixture_json="${tmp_root}/recheck-fixture.json"
|