@aldegad/safedeps 2.4.0 → 2.5.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 +1 -1
- package/README.ko.md +19 -2
- package/README.md +8 -2
- package/ROADMAP.md +25 -2
- package/SKILL.md +4 -2
- package/bin/safedeps +1 -1
- package/lib/gates/audit.sh +59 -7
- package/lib/gates/templates/pre-commit.tmpl +39 -10
- package/package.json +1 -1
- package/scripts/install/install-safedeps-hooks.mjs +46 -3
- package/scripts/safedeps-post-verify.sh +56 -10
- package/scripts/safedeps-pre-guard.sh +34 -2
- package/scripts/test/e2e.sh +63 -3
- package/scripts/test/smoke.sh +27 -4
package/ARCHITECTURE.md
CHANGED
|
@@ -43,7 +43,7 @@ safedeps owns security gates at two distinct moments, under one skill. It absorb
|
|
|
43
43
|
|
|
44
44
|
The two lanes differ in timing and scope (one package's effect before/after install vs. the whole repo before a release). They live under one umbrella but stay separated by command namespace.
|
|
45
45
|
|
|
46
|
-
**The secret-leak side of the release-time lane is per-repo and opt-in.** Its detection policy lives in the target repo, not in safedeps, so it does nothing until the repo provides a `.gitleaks` config and an active `.githooks/pre-commit`. `safedeps doctor` is the repo-entry diagnostic that closes that gap: it reports each piece of the secret-leak lane (`.gitleaks` policy, `pre-commit`, `core.hooksPath`, scanner availability) plus the global install-time gate, and exits non-zero when the per-repo lane has gaps. `safedeps doctor --fix` (= `safedeps hooks init` then `safedeps hooks install`) scaffolds a starter policy from `lib/gates/templates/` and activates the hooks. The scaffold is **non-destructive** — an existing repo-owned config is never overwritten — preserving the invariant that safedeps owns *execution*, not *policy*. The scaffolded `pre-commit`
|
|
46
|
+
**The secret-leak side of the release-time lane is per-repo and opt-in.** Its detection policy lives in the target repo, not in safedeps, so it does nothing until the repo provides a `.gitleaks` config and an active `.githooks/pre-commit`. `safedeps doctor` is the repo-entry diagnostic that closes that gap: it reports each piece of the secret-leak lane (`.gitleaks` policy, `pre-commit`, `core.hooksPath`, scanner availability) plus the global install-time gate, and exits non-zero when the per-repo lane has gaps. `safedeps doctor --fix` (= `safedeps hooks init` then `safedeps hooks install`) scaffolds a starter policy from `lib/gates/templates/` and activates the hooks. The scaffold is **non-destructive** — an existing repo-owned config is never overwritten — preserving the invariant that safedeps owns *execution*, not *policy*. The scaffolded `pre-commit` runs two checks. The secret scan (`safedeps scan secrets --staged`) runs on every commit and is fail-closed: an unresolvable `safedeps` or a missing scanner blocks rather than skipping silently. The npm dependency audit (`safedeps audit npm`) also runs on every commit in a repo with an npm lockfile — not only when the lockfile changes — so a CVE disclosed *after* a package was installed is caught at the next commit by re-querying the advisory DB. The audit separates the security verdict from an availability failure via meaningful exit codes (0 clean / 1 vulnerable / 2 could-not-run): a real finding **blocks** (fail-closed), while an unreachable advisory DB makes the hook **warn and allow the commit** — an explicit, observable availability failover (per the no-silent-fallback invariant: it is logged to the commit output and does not change canonical truth), with CI and the daily re-check re-covering what the offline commit could not verify.
|
|
47
47
|
|
|
48
48
|
**The effect-primary model is npm-only.** `pip`, `cargo`, `go`, `gem`, `maven`, and `nuget` stay on the v2.1 command-gate + reorg model until their closure resolvers land; they are not described as having PostToolUse closure authority.
|
|
49
49
|
|
package/README.ko.md
CHANGED
|
@@ -16,6 +16,19 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## 배포 모델
|
|
20
|
+
|
|
21
|
+
safedeps 에는 두 배포 surface 가 있다:
|
|
22
|
+
|
|
23
|
+
1. **Agent skill + hooks (canonical)** -- repo 자체가 skill folder 다. `SKILL.md`, hook script, provider/ledger library, install helper 가 한 디렉터리에 함께 있다.
|
|
24
|
+
2. **npm package (CLI convenience)** -- `@aldegad/safedeps` 는 `safedeps` command 를 설치한다. npm 설치만으로 Claude Code 나 Codex 가 skill 을 자동 discover 하지는 않는다. npm 설치 후에도 hook/skill installer 를 실행하거나 skill folder 를 수동 등록해야 한다.
|
|
25
|
+
|
|
26
|
+
전체 skill/hook source tree 를 canonical artifact 로 원하면 GitHub release 를 쓴다. versioned global CLI 가 주목적이면 npm 을 쓴다.
|
|
27
|
+
|
|
28
|
+
용어: safedeps 는 Claude/Codex hook 과 local CLI 로 동작하는 agent security skill 이다. plugin manifest 로 감싸기 전까지는 Codex plugin bundle 이 아니다.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
19
32
|
## 두 lane
|
|
20
33
|
|
|
21
34
|
`safedeps` 는 두 보안 lane 을 소유한다 (전체 설계: [`ARCHITECTURE.md`](./ARCHITECTURE.md) §1):
|
|
@@ -23,7 +36,7 @@
|
|
|
23
36
|
- **install-time** (이 README 의 초점) — advisory check + approved-spec ledger + 빠른 PreToolUse guard + PostToolUse effect enforcement + post-install reorg. 패키지 단위, install 명령과 실제 lockfile effect 주변.
|
|
24
37
|
- **release-time** — `safedeps gates run`, `safedeps scan secrets [--repo|--worktree|--staged]`, `safedeps audit npm`, `safedeps hooks install|check`. repo 트리 secret scan, 의존성 audit, repo-local git hook 설치/검사 (push/release 전). repo-specific policy(gitleaks config, privacy 경로)는 대상 repo 에 남고 safedeps 는 실행 owner. *(옛 `security-release-gates` 흡수.)*
|
|
25
38
|
|
|
26
|
-
release-time lane 의 secret 누출 쪽은 **repo 별이고 opt-in** 이다. `safedeps doctor` 가 그 repo-entry 점검이다 — repo 의 `.gitleaks` policy, `.githooks/pre-commit`, 활성 `core.hooksPath`, scanner 가용성을 진단하고(전역 install-time gate 상태도 같이 보고), `safedeps doctor --fix` 가 시작 policy 를 scaffold(`safedeps hooks init`)하고 활성화(`safedeps hooks install`)한다. scaffold 는 비파괴적이라 repo 가 소유한 기존 `.gitleaks.toml` 은 덮어쓰지 않으며, pre-commit hook 은 `safedeps scan secrets --staged`
|
|
39
|
+
release-time lane 의 secret 누출 쪽은 **repo 별이고 opt-in** 이다. `safedeps doctor` 가 그 repo-entry 점검이다 — repo 의 `.gitleaks` policy, `.githooks/pre-commit`, 활성 `core.hooksPath`, scanner 가용성을 진단하고(전역 install-time gate 상태도 같이 보고), `safedeps doctor --fix` 가 시작 policy 를 scaffold(`safedeps hooks init`)하고 활성화(`safedeps hooks install`)한다. scaffold 는 비파괴적이라 repo 가 소유한 기존 `.gitleaks.toml` 은 덮어쓰지 않으며, pre-commit hook 은 매 커밋 비밀키 스캔(`safedeps scan secrets --staged`)을 돌리고 npm repo 면 매 커밋 의존성 audit(`safedeps audit npm`)도 돌린다 — 실제 취약점은 차단(fail-closed)하고, 어드바이저리 DB 도달 불가 시에는 경고만 하고 커밋을 통과시킨다(관측 가능한 오프라인 failover). [secret 누출 lane (repo 별)](#secret-누출-lane-repo-별) 참고.
|
|
27
40
|
|
|
28
41
|
---
|
|
29
42
|
|
|
@@ -106,7 +119,11 @@ lane 구성 요소:
|
|
|
106
119
|
|
|
107
120
|
- **`safedeps hooks init`** 가 시작용 `.gitleaks.toml`(private repo 면 `.gitleaks.private.toml`)과 `.githooks/pre-commit` 을 scaffold 한다. 기존 파일은 덮지 않고 유지 — policy 는 repo 가 소유한다.
|
|
108
121
|
- **`safedeps hooks install`** 이 repo-local hook 을 활성화한다(`core.hooksPath = .githooks`).
|
|
109
|
-
- **pre-commit hook 은
|
|
122
|
+
- **pre-commit hook 은 두 검사를 돌린다**:
|
|
123
|
+
- **비밀키 스캔**(`safedeps scan secrets --staged`)을 매 커밋, **fail-closed**. scanner(로컬 `gitleaks` 또는 Docker)가 못 돌면 silent skip 이 아니라 커밋을 막는다.
|
|
124
|
+
- **npm 의존성 audit**(`safedeps audit npm`)을 npm lockfile 이 있는 repo 면 **매 커밋**. 취약한 직접·*transitive* 의존성을 잡는다 — 패키지를 깐 *뒤에* 공개된 CVE("그땐 안전해 보였는데 지금 발견됨")까지 포함해서, 사람이 손으로 절대 못 보는 그것. lockfile 이 바뀔 때만이 아니라 매 커밋 돌리는 게 핵심이다: 어드바이저리 DB 를 다시 조회하니까 이미 깔린 의존성에 *새로* 뜬 CVE 가 바로 다음 커밋에 드러난다. 보안 판정과 가용성 실패는 구분된다 — 실제 취약점은 **차단**(fail-closed)하지만, 어드바이저리 DB 가 **도달 불가**(오프라인/레지스트리 오류)면 **경고만 하고 커밋을 통과**시킨다(관측 가능한 가용성 failover, silent skip 아님). 오프라인 커밋이 못 본 건 CI 와 데일리 re-check 가 다시 메운다.
|
|
125
|
+
|
|
126
|
+
의도된 우회는 사람이 소유하는 `git commit --no-verify` 뿐이다.
|
|
110
127
|
|
|
111
128
|
scaffold 된 `.gitleaks.toml` 은 **네가 손보는 시작점**이다: gitleaks 기본 ruleset 을 extend 하고, 값이 할당된 `.env` 커밋을 잡는 rule 을 더하며(`.env.example`/`.sample`/`.template` 변형은 allowlist), fixture 용 repo-owned `[allowlist]` 블록을 남겨둔다. safedeps 는 *실행* — `safedeps scan secrets` 로 gitleaks 구동 — 만 소유하고 policy 내용은 소유하지 않는다.
|
|
112
129
|
|
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Safedeps has two distribution surfaces:
|
|
|
19
19
|
|
|
20
20
|
Use the GitHub release when you want the full skill/hook source tree as the canonical artifact. Use npm when you mainly want a versioned global CLI.
|
|
21
21
|
|
|
22
|
+
Terminology: safedeps is an agent security skill backed by Claude/Codex hooks and a local CLI. It is not a Codex plugin bundle unless it is later wrapped with a plugin manifest.
|
|
23
|
+
|
|
22
24
|
## Two Lanes
|
|
23
25
|
|
|
24
26
|
`safedeps` owns two security lanes (full design in [`ARCHITECTURE.md`](./ARCHITECTURE.md) §1):
|
|
@@ -26,7 +28,7 @@ Use the GitHub release when you want the full skill/hook source tree as the cano
|
|
|
26
28
|
- **Install-time** (the focus of this README) — advisory check + approved-spec ledger + fast PreToolUse guard + PostToolUse effect enforcement + post-install reorg. Per-package, around the install command and its actual lockfile effect.
|
|
27
29
|
- **Release-time** — `safedeps gates run`, `safedeps scan secrets [--repo|--worktree|--staged]`, `safedeps audit npm`, `safedeps hooks install|check`. Repo-tree secret scan, dependency audit, and repo-local git hook install/check before push/release. Repo-specific policy (gitleaks config, privacy paths) stays in the target repo; safedeps owns execution. *(Absorbed the former `security-release-gates`.)*
|
|
28
30
|
|
|
29
|
-
The secret-leak side of the release-time lane is **per-repo and opt-in**. `safedeps doctor` is its repo-entry check: it diagnoses the repo's `.gitleaks` policy, `.githooks/pre-commit`, the active `core.hooksPath`, and scanner availability (and reports the global install-time gate too), then `safedeps doctor --fix` scaffolds a starter policy (`safedeps hooks init`) and activates it (`safedeps hooks install`). The scaffold is non-destructive — an existing repo-owned `.gitleaks.toml` is never overwritten — and the pre-commit hook
|
|
31
|
+
The secret-leak side of the release-time lane is **per-repo and opt-in**. `safedeps doctor` is its repo-entry check: it diagnoses the repo's `.gitleaks` policy, `.githooks/pre-commit`, the active `core.hooksPath`, and scanner availability (and reports the global install-time gate too), then `safedeps doctor --fix` scaffolds a starter policy (`safedeps hooks init`) and activates it (`safedeps hooks install`). The scaffold is non-destructive — an existing repo-owned `.gitleaks.toml` is never overwritten — and the pre-commit hook runs a secret scan (`safedeps scan secrets --staged`) plus, on every commit in an npm repo, a dependency audit (`safedeps audit npm`): a real finding blocks (fail-closed), while an unreachable advisory DB only warns and lets the commit through (observable offline failover). See [Secret-Leak Lane (per-repo)](#secret-leak-lane-per-repo).
|
|
30
32
|
|
|
31
33
|
## How It Works
|
|
32
34
|
|
|
@@ -175,7 +177,11 @@ What the lane is made of:
|
|
|
175
177
|
|
|
176
178
|
- **`safedeps hooks init`** scaffolds a starter `.gitleaks.toml` (or `.gitleaks.private.toml` for a private repo) and a `.githooks/pre-commit`. Existing files are kept, never overwritten — the repo owns the policy.
|
|
177
179
|
- **`safedeps hooks install`** activates the repo-local hooks (`core.hooksPath = .githooks`).
|
|
178
|
-
- The **pre-commit hook
|
|
180
|
+
- The **pre-commit hook runs two checks**:
|
|
181
|
+
- **Secret scan** (`safedeps scan secrets --staged`) on every commit, **fail-closed**. If the scanner (local `gitleaks` or Docker) cannot run, it blocks the commit instead of skipping silently.
|
|
182
|
+
- **npm dependency audit** (`safedeps audit npm`) on **every commit** in a repo that has an npm lockfile. This catches a vulnerable direct *or transitive* dependency — including a CVE that was published *after* you installed the package ("looked safe then, flagged now"), the kind of thing a human never reviews by hand. Running it every commit (not only when the lockfile changes) is the point: it re-queries the advisory DB so a newly-disclosed CVE on an already-installed dependency surfaces at the very next commit. The verdict and an availability failure are kept apart: a real finding **blocks** (fail-closed), but if the advisory DB is **unreachable** (offline / registry error) the hook **warns and lets the commit through** — an observable availability failover, never a silent skip. (CI and the daily re-check then re-cover what the offline commit could not verify.)
|
|
183
|
+
|
|
184
|
+
The only intentional bypass is `git commit --no-verify`, which the human owns.
|
|
179
185
|
|
|
180
186
|
The scaffolded `.gitleaks.toml` is a **starter you tune**: it extends gitleaks' default ruleset, adds a rule for a committed `.env` with an assigned secret (the `.env.example`/`.sample`/`.template` variants are allowlisted), and leaves a repo-owned `[allowlist]` block for your fixtures. safedeps owns *execution* — running gitleaks via `safedeps scan secrets` — not the policy content.
|
|
181
187
|
|
package/ROADMAP.md
CHANGED
|
@@ -56,7 +56,7 @@ The internal engine keeps the v1 `reorg-guard` assets.
|
|
|
56
56
|
|
|
57
57
|
### Release notes
|
|
58
58
|
|
|
59
|
-
- The npm package version in `package.json` is the single source of truth. `bin/safedeps` `SAFEDEPS_VERSION` tracks it and the smoke test reads `package.json` to compare (current: v2.
|
|
59
|
+
- The npm package version in `package.json` is the single source of truth. `bin/safedeps` `SAFEDEPS_VERSION` tracks it and the smoke test reads `package.json` to compare (current: v2.5.0).
|
|
60
60
|
- `npm test` runs the release smoke suite; the full fixture E2E lives under `v2.1-tests`.
|
|
61
61
|
- The daily re-check uses no LLM tokens. It is opt-in: a macOS `launchd` user agent runs `safedeps re-check --json` daily, installed atomically by `install-safedeps-recheck-agent.mjs`. It writes `~/.safedeps/recheck.log` and `~/.safedeps/recheck-alerts.jsonl` and raises a macOS notification on a new CVE/KEV/revoke/provider-skip. Network is used only for OSV / CISA / GHSA queries.
|
|
62
62
|
|
|
@@ -129,10 +129,33 @@ Status: shipped as v2.4.0.
|
|
|
129
129
|
### Verification
|
|
130
130
|
|
|
131
131
|
- lock-unavailable install denies fail-closed and logs to `advisory.log`
|
|
132
|
-
- jq-missing
|
|
132
|
+
- jq-missing denies a likely install (best-effort fail-closed) and logs it; only non-install commands fall through
|
|
133
|
+
- a missing ledger library denies fail-closed instead of falling through to allow
|
|
133
134
|
- ShellCheck (`--severity=error`) is clean across all shell sources
|
|
134
135
|
- existing smoke + e2e regression suite remains green on both Linux and macOS
|
|
135
136
|
|
|
137
|
+
### v2.4.1 — concurrent-install race fix (#5)
|
|
138
|
+
|
|
139
|
+
The pending state PreToolUse hands to PostToolUse was a single global `current_state` file, so two installs overlapping in one project could clobber each other and the effect gate could verify the wrong install (or skip one). Pending state is now keyed **per install** — `dir_hash` + a hash of the command with the inert-install rewrite normalized out — so PreToolUse and PostToolUse of the same install agree on a key while concurrent installs stay isolated. A concurrency harness (two installs → two pending files; a post consumes only its own) guards it.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## v2.5 — pre-commit dependency audit (shipped)
|
|
144
|
+
|
|
145
|
+
Status: shipped as v2.5.0.
|
|
146
|
+
|
|
147
|
+
### What changed
|
|
148
|
+
|
|
149
|
+
- **Pre-commit dependency audit** — the scaffolded `.githooks/pre-commit` now runs `safedeps audit npm` on **every commit** in a repo with an npm lockfile, alongside the secret scan. It catches a vulnerable direct or *transitive* dependency — including a CVE disclosed *after* the package was installed ("looked safe then, flagged now") — at the next commit, by re-querying the advisory DB instead of waiting for the daily re-check. Real usage drove it: a transitive `hono` advisory that Dependabot missed was caught exactly this way.
|
|
150
|
+
- **Meaningful `audit npm` exit codes** — `0` clean / `1` vulnerable / `2` could-not-run (no lockfile, npm/jq missing, advisory DB unreachable). This separates the **security verdict** from an **availability failure**; npm audit collapses both into exit 1 on its own.
|
|
151
|
+
- **Observable offline failover** — when the advisory DB is unreachable the hook **warns and allows** the commit (exit 2) rather than fail-closing, so a network outage never blocks an offline commit; a real finding (exit 1) still **blocks**. Per the no-silent-fallback invariant the failover is loud (printed to the commit output), and CI / the daily re-check re-cover what the offline commit could not verify.
|
|
152
|
+
|
|
153
|
+
### Verification
|
|
154
|
+
|
|
155
|
+
- `audit npm` exit-code contract (clean=0 / vulnerable=1 / unreachable=2), deterministic via a fake npm
|
|
156
|
+
- pre-commit blocks a commit carrying a vulnerable dependency; warns + allows when the advisory DB is unreachable
|
|
157
|
+
- existing secret-lane + smoke + e2e regression suite remains green
|
|
158
|
+
|
|
136
159
|
---
|
|
137
160
|
|
|
138
161
|
## v3 (future)
|
package/SKILL.md
CHANGED
|
@@ -10,7 +10,9 @@ hooks:
|
|
|
10
10
|
|
|
11
11
|
# Safedeps
|
|
12
12
|
|
|
13
|
-
Two gates, one skill.
|
|
13
|
+
Two gates, one skill. Safedeps is an agent security skill backed by Claude/Codex hooks and a local CLI. It is not a Codex plugin bundle unless it is later wrapped with a plugin manifest.
|
|
14
|
+
|
|
15
|
+
You (the agent) are the primary user — drive both:
|
|
14
16
|
|
|
15
17
|
- **Install-time gate** — clear every dependency install through an OSV-backed advisory check before it runs.
|
|
16
18
|
- **Secret-leak gate** — stop a secret or a real `.env` from being committed (per-repo).
|
|
@@ -71,7 +73,7 @@ safedeps doctor --fix # scaffold the policy + activate the hooks (non-destruc
|
|
|
71
73
|
2. Gaps? Run `safedeps doctor --fix`. It scaffolds `.gitleaks.toml` (or `.gitleaks.private.toml`) and `.githooks/pre-commit`, then activates them. Existing repo files are never overwritten.
|
|
72
74
|
3. Tune the scaffolded `.gitleaks.toml` for the repo — allowlist fixtures, add rules. You own the policy; safedeps runs it (gitleaks via `safedeps scan secrets`).
|
|
73
75
|
|
|
74
|
-
The pre-commit hook
|
|
76
|
+
The pre-commit hook runs two checks: a secret scan (`safedeps scan secrets --staged`) on every commit (fail-closed), and an npm dependency audit (`safedeps audit npm`) on every commit in an npm repo — so a CVE published *after* you installed a package is caught at the next commit, not weeks later. `audit npm` exits 0 clean / 1 vulnerable / 2 could-not-run; the hook **blocks** on a real finding (1) but **warns and allows** when the advisory DB is unreachable (2 — observable offline failover). No secret scanner → blocks. The only bypass is the human's `git commit --no-verify`.
|
|
75
77
|
|
|
76
78
|
---
|
|
77
79
|
|
package/bin/safedeps
CHANGED
package/lib/gates/audit.sh
CHANGED
|
@@ -3,7 +3,17 @@ set -euo pipefail
|
|
|
3
3
|
|
|
4
4
|
# safedeps audit npm — generic npm lockfile audit.
|
|
5
5
|
# Absorbed from kuma-studio scripts/security/run-npm-audit.sh.
|
|
6
|
-
#
|
|
6
|
+
#
|
|
7
|
+
# Exit codes are meaningful so a caller (e.g. the pre-commit hook) can tell a
|
|
8
|
+
# security verdict apart from an availability problem — npm audit collapses both
|
|
9
|
+
# into exit 1 on its own:
|
|
10
|
+
# 0 clean — no advisory at or above the audit level
|
|
11
|
+
# 1 vulnerable — at least one advisory at or above the level (BLOCK)
|
|
12
|
+
# 2 could not produce a verdict — no lockfile, npm/jq missing, or the npm
|
|
13
|
+
# advisory database is unreachable (offline/registry error). This is an
|
|
14
|
+
# AVAILABILITY failure, not a clean bill of health; the caller decides
|
|
15
|
+
# whether to fail-closed or warn-and-continue.
|
|
16
|
+
# 64 usage error
|
|
7
17
|
|
|
8
18
|
REPO_ROOT=""
|
|
9
19
|
AUDIT_LEVEL="${SAFEDEPS_NPM_AUDIT_LEVEL:-${KUMA_NPM_AUDIT_LEVEL:-moderate}}"
|
|
@@ -26,11 +36,53 @@ if [ -z "$REPO_ROOT" ]; then REPO_ROOT="$(pwd)"; fi
|
|
|
26
36
|
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
|
|
27
37
|
cd "$REPO_ROOT"
|
|
28
38
|
|
|
29
|
-
if [ ! -f package-lock.json ]; then
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
if [ ! -f package-lock.json ] && [ ! -f npm-shrinkwrap.json ]; then
|
|
40
|
+
printf 'safedeps audit: no package-lock.json/npm-shrinkwrap.json — cannot produce a reproducible verdict.\n' >&2
|
|
41
|
+
exit 2
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
if ! command -v npm >/dev/null 2>&1; then
|
|
45
|
+
printf 'safedeps audit: npm not found — cannot produce a dependency verdict.\n' >&2
|
|
46
|
+
exit 2
|
|
34
47
|
fi
|
|
35
48
|
|
|
36
|
-
|
|
49
|
+
# Without jq we cannot reliably separate an offline failure from a real finding,
|
|
50
|
+
# so degrade to a plain, fail-closed audit: any non-zero exit blocks (safe).
|
|
51
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
52
|
+
npm audit --audit-level="$AUDIT_LEVEL"
|
|
53
|
+
exit $?
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# One JSON run. npm audit shares exit 1 between "vulnerable" and "could not run",
|
|
57
|
+
# so we read the verdict from the payload, not the exit code.
|
|
58
|
+
audit_json="$(npm audit --json 2>/dev/null || true)"
|
|
59
|
+
|
|
60
|
+
# Unreachable advisory DB / setup error → npm emits a top-level {"error":...},
|
|
61
|
+
# or no/invalid JSON. Availability failure, not a verdict.
|
|
62
|
+
if [ -z "$audit_json" ] \
|
|
63
|
+
|| ! printf '%s' "$audit_json" | jq -e . >/dev/null 2>&1 \
|
|
64
|
+
|| printf '%s' "$audit_json" | jq -e 'has("error")' >/dev/null 2>&1; then
|
|
65
|
+
printf 'safedeps audit: could not reach the npm advisory database (offline or registry error).\n' >&2
|
|
66
|
+
exit 2
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# Count advisories at or above the configured level.
|
|
70
|
+
n_at_level="$(printf '%s' "$audit_json" | jq --arg lvl "$AUDIT_LEVEL" '
|
|
71
|
+
(.metadata.vulnerabilities // {}) as $v
|
|
72
|
+
| ["low","moderate","high","critical"] as $order
|
|
73
|
+
| (($order | index($lvl)) // 1) as $min
|
|
74
|
+
| reduce $order[$min:][] as $s (0; . + ($v[$s] // 0))
|
|
75
|
+
')"
|
|
76
|
+
|
|
77
|
+
if [ "${n_at_level:-0}" -gt 0 ]; then
|
|
78
|
+
printf '%s' "$audit_json" | jq -r '
|
|
79
|
+
(.metadata.vulnerabilities // {}) as $v
|
|
80
|
+
| " severities: " +
|
|
81
|
+
([ "critical","high","moderate","low" ]
|
|
82
|
+
| map(select(($v[.] // 0) > 0) | "\($v[.]) \(.)") | join(", "))
|
|
83
|
+
' >&2
|
|
84
|
+
printf '%s' "$audit_json" | jq -r '(.vulnerabilities // {}) | keys[] | " - " + .' 2>/dev/null | head -20 >&2 || true
|
|
85
|
+
printf 'safedeps audit: %s advisory(ies) at or above "%s" — blocking. Run `npm audit` for the full report.\n' "$n_at_level" "$AUDIT_LEVEL" >&2
|
|
86
|
+
exit 1
|
|
87
|
+
fi
|
|
88
|
+
exit 0
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# .githooks/pre-commit — safedeps
|
|
2
|
+
# .githooks/pre-commit — safedeps repo gate (scaffolded by `safedeps hooks init`).
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
4
|
+
# Two checks:
|
|
5
|
+
# 1. Secret scan (always, fail-closed) — blocks a commit when gitleaks finds a
|
|
6
|
+
# secret in the STAGED changes, so a key or a real .env never enters
|
|
7
|
+
# history. The detection POLICY lives in this repo's .gitleaks.toml
|
|
8
|
+
# (public) / .gitleaks.private.toml (private); safedeps owns execution.
|
|
9
|
+
# 2. Dependency audit (npm) — on every commit when this repo has an npm
|
|
10
|
+
# lockfile. Catches a vulnerable direct or *transitive* dependency,
|
|
11
|
+
# including a CVE published AFTER you installed it ("looked safe then,
|
|
12
|
+
# flagged now"). A real finding blocks (fail-closed); only the advisory DB
|
|
13
|
+
# being unreachable (offline) warns and lets the commit through — an
|
|
14
|
+
# observable availability failover, never a silent skip.
|
|
9
15
|
#
|
|
10
16
|
# Intentional bypass (use sparingly — you own the risk): git commit --no-verify
|
|
11
17
|
set -euo pipefail
|
|
@@ -43,7 +49,30 @@ EOF
|
|
|
43
49
|
exit 1
|
|
44
50
|
fi
|
|
45
51
|
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
# 1. Secret scan (always). A non-zero exit means either a secret was found OR
|
|
53
|
+
# no scanner (gitleaks/docker) is available — both fail-closed, block commit.
|
|
54
|
+
"${sd}" scan secrets --staged --root "${repo_root}"
|
|
55
|
+
|
|
56
|
+
# 2. Dependency audit (npm) — every commit when this repo has an npm lockfile.
|
|
57
|
+
# `safedeps audit npm` exit codes: 0 clean / 1 vulnerable / 2 could-not-run.
|
|
58
|
+
# - vulnerable (1) → BLOCK (fail-closed on the security verdict).
|
|
59
|
+
# - unreachable (2) → WARN and ALLOW (availability failover, observable):
|
|
60
|
+
# an offline commit is never silently dropped, and CI / the daily
|
|
61
|
+
# re-check still cover what this commit could not verify.
|
|
62
|
+
if [ -f "${repo_root}/package-lock.json" ] || [ -f "${repo_root}/npm-shrinkwrap.json" ]; then
|
|
63
|
+
if "${sd}" audit npm --root "${repo_root}"; then
|
|
64
|
+
: # clean — no advisory at or above the audit level.
|
|
65
|
+
else
|
|
66
|
+
audit_rc=$?
|
|
67
|
+
if [ "${audit_rc}" -eq 2 ]; then
|
|
68
|
+
{
|
|
69
|
+
printf '\n'
|
|
70
|
+
printf 'safedeps pre-commit: dependency audit could not reach the advisory DB.\n'
|
|
71
|
+
printf ' -> commit ALLOWED without a fresh dependency verdict (offline failover).\n'
|
|
72
|
+
printf ' -> re-run `safedeps audit npm` once online; CI / the daily re-check also cover this.\n'
|
|
73
|
+
} >&2
|
|
74
|
+
else
|
|
75
|
+
exit "${audit_rc}" # vulnerable (or unknown failure) -> fail-closed.
|
|
76
|
+
fi
|
|
77
|
+
fi
|
|
78
|
+
fi
|
package/package.json
CHANGED
|
@@ -219,6 +219,51 @@ function unlinkBin() {
|
|
|
219
219
|
removeSymlink(join(HOME, ".local", "bin", "safedeps"));
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
function safedepsOnPath() {
|
|
223
|
+
const dirs = (process.env.PATH || "").split(":").filter(Boolean);
|
|
224
|
+
return dirs.some((d) => {
|
|
225
|
+
try { return existsSync(join(d, "safedeps")); } catch { return false; }
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The dependency-install gate is global and now active. The rest of the
|
|
230
|
+
// surface (secret-leak lane, dep audit) is per-repo and opt-in — its policy
|
|
231
|
+
// lives in each repo. Nudge with a recommended setup; never auto-write.
|
|
232
|
+
function printRecommendedSetup() {
|
|
233
|
+
const line = "─".repeat(58);
|
|
234
|
+
const out = [];
|
|
235
|
+
out.push("");
|
|
236
|
+
out.push("Recommended setup");
|
|
237
|
+
out.push(line);
|
|
238
|
+
out.push(" 1. Dependency-install gate ........ ✓ active now (global, all repos)");
|
|
239
|
+
out.push(" Every agent install is checked; for npm the installed");
|
|
240
|
+
out.push(" closure is reorged (rolled back) if it diverges.");
|
|
241
|
+
out.push("");
|
|
242
|
+
out.push(" The rest is per-repo and opt-in. Run these INSIDE a repo:");
|
|
243
|
+
out.push("");
|
|
244
|
+
out.push(" 2. Pre-commit gate ............... recommended");
|
|
245
|
+
out.push(" safedeps doctor # diagnose this repo (read-only)");
|
|
246
|
+
out.push(" safedeps doctor --fix # scaffold .gitleaks.toml + pre-commit, then activate");
|
|
247
|
+
out.push(" → every commit: blocks a secret / real .env (fail-closed).");
|
|
248
|
+
out.push(" → every commit (npm repos): audits deps for vulnerable transitives;");
|
|
249
|
+
out.push(" a real finding blocks, an offline advisory DB only warns + allows.");
|
|
250
|
+
out.push("");
|
|
251
|
+
out.push(" 3. Release / CI gate ............. optional");
|
|
252
|
+
out.push(" safedeps gates run # secret scan + npm dep audit + hook/CI check");
|
|
253
|
+
out.push(" → full-repo sweep for CI / pre-release: scans the whole tree (not just");
|
|
254
|
+
out.push(" the staged diff) and verifies the hooks themselves are installed.");
|
|
255
|
+
out.push("");
|
|
256
|
+
out.push(" Docs: README → \"Two Lanes\"");
|
|
257
|
+
if (!safedepsOnPath()) {
|
|
258
|
+
out.push("");
|
|
259
|
+
out.push(" Note: `safedeps` is not on your PATH. To run the commands above:");
|
|
260
|
+
out.push(" - re-run this installer with --link-bin (adds ~/.local/bin/safedeps), or");
|
|
261
|
+
out.push(" - use the full path: ~/.claude/skills/safedeps/bin/safedeps");
|
|
262
|
+
}
|
|
263
|
+
out.push("");
|
|
264
|
+
console.log(out.join("\n"));
|
|
265
|
+
}
|
|
266
|
+
|
|
222
267
|
function main() {
|
|
223
268
|
if (!existsSync(REPO_PRE_HOOK) || !existsSync(REPO_POST_HOOK)) {
|
|
224
269
|
throw new Error(`hook scripts not found at ${REPO_PRE_HOOK} / ${REPO_POST_HOOK}`);
|
|
@@ -242,9 +287,7 @@ function main() {
|
|
|
242
287
|
log("uninstall done.");
|
|
243
288
|
} else {
|
|
244
289
|
log("install done. New hook events fire on the next session start.");
|
|
245
|
-
|
|
246
|
-
// and stays opt-in (its policy lives in each repo). Nudge, do not auto-write.
|
|
247
|
-
log("secret-leak lane is per-repo — in a repo run: safedeps doctor (then `safedeps doctor --fix` to scaffold + activate).");
|
|
290
|
+
printRecommendedSetup();
|
|
248
291
|
}
|
|
249
292
|
}
|
|
250
293
|
|
|
@@ -120,6 +120,21 @@ compute_dir_hash() {
|
|
|
120
120
|
fi
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
# Per-install pending-state key (issue #5) — must match the PreToolUse derivation:
|
|
124
|
+
# dir hash + a hash of the command with the inert-install rewrite normalized out.
|
|
125
|
+
compute_pending_key() {
|
|
126
|
+
local dir_hash="$1" command="$2" norm cmd_hash
|
|
127
|
+
norm=$(printf '%s' "${command}" | sed -E 's/[[:space:]]+--ignore-scripts([[:space:]]|$)/ /g; s/[[:space:]]+/ /g; s/^ //; s/ $//')
|
|
128
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
129
|
+
cmd_hash=$(printf '%s' "${norm}" | md5sum | cut -d' ' -f1)
|
|
130
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
131
|
+
cmd_hash=$(md5 -q -s "${norm}")
|
|
132
|
+
else
|
|
133
|
+
cmd_hash=$(printf '%s' "${norm}" | cksum | cut -d' ' -f1)
|
|
134
|
+
fi
|
|
135
|
+
printf '%s_%s' "${dir_hash}" "${cmd_hash}"
|
|
136
|
+
}
|
|
137
|
+
|
|
123
138
|
hash_file() {
|
|
124
139
|
local file_path="$1"
|
|
125
140
|
|
|
@@ -376,25 +391,56 @@ if [[ "${TOOL_NAME}" != "Bash" ]]; then
|
|
|
376
391
|
exit 0
|
|
377
392
|
fi
|
|
378
393
|
|
|
394
|
+
# The command + cwd identify which pending install this PostToolUse belongs to
|
|
395
|
+
# (issue #5), so concurrent installs do not consume each other's state.
|
|
396
|
+
COMMAND=$(echo "${INPUT}" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
397
|
+
POST_CWD=$(echo "${INPUT}" | jq -r '.cwd // empty' 2>/dev/null)
|
|
398
|
+
[[ -z "${POST_CWD}" ]] && POST_CWD=$(pwd)
|
|
399
|
+
if command -v realpath >/dev/null 2>&1; then
|
|
400
|
+
POST_CWD=$(realpath "${POST_CWD}" 2>/dev/null || echo "${POST_CWD}")
|
|
401
|
+
elif command -v readlink >/dev/null 2>&1; then
|
|
402
|
+
POST_CWD=$(readlink -f "${POST_CWD}" 2>/dev/null || echo "${POST_CWD}")
|
|
403
|
+
fi
|
|
404
|
+
POST_DIR_HASH=$(compute_dir_hash "${POST_CWD}")
|
|
405
|
+
|
|
379
406
|
STATE_LOCK_HELD=true
|
|
380
407
|
acquire_state_lock
|
|
381
408
|
trap '[ "${STATE_LOCK_HELD:-}" = "true" ] && release_state_lock; STATE_LOCK_HELD=false' EXIT
|
|
382
409
|
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
410
|
+
# Resolve THIS install's pending state by its per-install key (issue #5). The
|
|
411
|
+
# filename also carries a snapshot id, so identical concurrent commands produce
|
|
412
|
+
# several files; consume exactly one (they verify the same closure), leaving the
|
|
413
|
+
# rest for their own post hooks. Fall back to the legacy global files for in-flight
|
|
414
|
+
# upgrades from a pre-#5 PreToolUse.
|
|
415
|
+
PENDING_PREFIX="${GUARD_DIR}/pending/$(compute_pending_key "${POST_DIR_HASH}" "${COMMAND}")__"
|
|
416
|
+
PENDING_FILE=""
|
|
417
|
+
for pending_candidate in "${PENDING_PREFIX}"*.json; do
|
|
418
|
+
[[ -f "${pending_candidate}" ]] && { PENDING_FILE="${pending_candidate}"; break; }
|
|
419
|
+
done
|
|
420
|
+
if [[ -n "${PENDING_FILE}" ]]; then
|
|
421
|
+
CURRENT_STATE=$(cat "${PENDING_FILE}")
|
|
422
|
+
SNAPSHOT_ID=$(echo "${CURRENT_STATE}" | jq -r '.snapshot_id // empty')
|
|
423
|
+
PROJECT_DIR=$(echo "${CURRENT_STATE}" | jq -r '.project_dir // empty')
|
|
424
|
+
DIR_HASH=$(echo "${CURRENT_STATE}" | jq -r '.dir_hash // empty')
|
|
425
|
+
rm -f "${PENDING_FILE}"
|
|
426
|
+
elif [[ -f "${GUARD_DIR}/current_state" ]]; then
|
|
393
427
|
CURRENT_STATE=$(cat "${GUARD_DIR}/current_state")
|
|
394
428
|
SNAPSHOT_ID=$(echo "${CURRENT_STATE}" | jq -r '.snapshot_id // empty')
|
|
395
429
|
PROJECT_DIR=$(echo "${CURRENT_STATE}" | jq -r '.project_dir // empty')
|
|
396
430
|
DIR_HASH=$(echo "${CURRENT_STATE}" | jq -r '.dir_hash // empty')
|
|
397
431
|
rm -f "${GUARD_DIR}/current_state"
|
|
432
|
+
elif [[ -f "${GUARD_DIR}/current_snapshot_id" ]]; then
|
|
433
|
+
SNAPSHOT_ID=$(cat "${GUARD_DIR}/current_snapshot_id")
|
|
434
|
+
PROJECT_DIR=$(cat "${GUARD_DIR}/current_project_dir" 2>/dev/null || pwd)
|
|
435
|
+
rm -f "${GUARD_DIR}/current_snapshot_id" "${GUARD_DIR}/current_project_dir"
|
|
436
|
+
else
|
|
437
|
+
# No pending state for this command. If it nonetheless looks like a dependency
|
|
438
|
+
# install, the effect gate could not verify it — record UNVERIFIED (observable)
|
|
439
|
+
# rather than disappearing silently (e.g. a payload with no `cwd`).
|
|
440
|
+
if printf '%s' "${COMMAND}" | grep -qiE '(npm|pnpm|yarn|bun)([^"]*)(install|add|dlx)|[^a-z]npx[[:space:]]|pip[0-9]*[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|poetry[[:space:]]+add|uv[[:space:]]+(add|pip)|pipenv[[:space:]]+install|mvn([^"]*)dependency:get|dotnet[[:space:]]+add[[:space:]]+package'; then
|
|
441
|
+
log_advisory "post-verify UNVERIFIED: no pending state for an install-looking command (missing cwd or pre hook never ran)."
|
|
442
|
+
fi
|
|
443
|
+
exit 0
|
|
398
444
|
fi
|
|
399
445
|
|
|
400
446
|
if [[ -z "${SNAPSHOT_ID}" ]]; then
|
|
@@ -123,6 +123,24 @@ compute_dir_hash() {
|
|
|
123
123
|
fi
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
# Per-install pending-state key (issue #5): dir hash + a hash of the command with
|
|
127
|
+
# the inert-install rewrite normalized out, so PreToolUse (original command) and
|
|
128
|
+
# PostToolUse (possibly `--ignore-scripts`-appended) of the SAME install resolve to
|
|
129
|
+
# the same key. This keeps concurrent installs in one project on separate pending
|
|
130
|
+
# files instead of clobbering a single global one.
|
|
131
|
+
compute_pending_key() {
|
|
132
|
+
local dir_hash="$1" command="$2" norm cmd_hash
|
|
133
|
+
norm=$(printf '%s' "${command}" | sed -E 's/[[:space:]]+--ignore-scripts([[:space:]]|$)/ /g; s/[[:space:]]+/ /g; s/^ //; s/ $//')
|
|
134
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
135
|
+
cmd_hash=$(printf '%s' "${norm}" | md5sum | cut -d' ' -f1)
|
|
136
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
137
|
+
cmd_hash=$(md5 -q -s "${norm}")
|
|
138
|
+
else
|
|
139
|
+
cmd_hash=$(printf '%s' "${norm}" | cksum | cut -d' ' -f1)
|
|
140
|
+
fi
|
|
141
|
+
printf '%s_%s' "${dir_hash}" "${cmd_hash}"
|
|
142
|
+
}
|
|
143
|
+
|
|
126
144
|
command_is_dependency_install() {
|
|
127
145
|
local command="$1"
|
|
128
146
|
local scan_command
|
|
@@ -678,10 +696,24 @@ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 ]]; then
|
|
|
678
696
|
fi
|
|
679
697
|
fi
|
|
680
698
|
|
|
681
|
-
# Write
|
|
699
|
+
# Write per-install pending state for PostToolUse, keyed by (dir_hash, normalized
|
|
700
|
+
# command) so concurrent installs in the same project keep separate state instead
|
|
701
|
+
# of clobbering one global file (issue #5). The single-file write is still atomic
|
|
702
|
+
# (write_state_file) to prevent TOCTOU within one install.
|
|
703
|
+
PENDING_DIR="${GUARD_DIR}/pending"
|
|
704
|
+
mkdir -p "${PENDING_DIR}"
|
|
705
|
+
# GC pending entries whose PostToolUse never fired (crash/no-op). 24h is well past
|
|
706
|
+
# any real install, so this never deletes an in-flight one (a 60-min window could
|
|
707
|
+
# have reaped a slow native build that was still running).
|
|
708
|
+
find "${PENDING_DIR}" -name '*.json' -type f -mmin +1440 -delete 2>/dev/null || true
|
|
709
|
+
# Key = (dir, normalized command); the snapshot id suffix makes the filename unique
|
|
710
|
+
# per install, so even two identical concurrent commands keep separate state.
|
|
711
|
+
PENDING_KEY=$(compute_pending_key "${DIR_HASH}" "${COMMAND}")
|
|
682
712
|
CURRENT_STATE=$(jq -n --arg sid "${SNAPSHOT_ID}" --arg pdir "${PROJECT_DIR}" --arg dhash "${DIR_HASH}" \
|
|
683
713
|
'{snapshot_id: $sid, project_dir: $pdir, dir_hash: $dhash}')
|
|
684
|
-
|
|
714
|
+
# $$ (this pre hook's PID) guarantees a unique filename even for two installs in
|
|
715
|
+
# the same second (SNAPSHOT_ID has only 1s resolution).
|
|
716
|
+
write_state_file "${PENDING_DIR}/${PENDING_KEY}__${SNAPSHOT_ID}_$$.json" "${CURRENT_STATE}"
|
|
685
717
|
|
|
686
718
|
if ! jq -e 'has("turn_id")' <<< "${INPUT}" >/dev/null 2>&1 && \
|
|
687
719
|
command_is_injectable_npm_install "${COMMAND}" && \
|
package/scripts/test/e2e.sh
CHANGED
|
@@ -146,7 +146,7 @@ cat > "${effect_project}/package-lock.json" <<'EOF'
|
|
|
146
146
|
EOF
|
|
147
147
|
effect_clean_post=$(
|
|
148
148
|
scripts/safedeps-post-verify.sh <<EOF
|
|
149
|
-
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
|
|
149
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${effect_project}"}
|
|
150
150
|
EOF
|
|
151
151
|
)
|
|
152
152
|
[[ -z "${effect_clean_post}" ]] || fail "post hook passes approved full closure"
|
|
@@ -183,7 +183,7 @@ EOF
|
|
|
183
183
|
chmod +x "${stub_bin}/npm"
|
|
184
184
|
inert_post=$(
|
|
185
185
|
PATH="${stub_bin}:${PATH}" scripts/safedeps-post-verify.sh <<EOF
|
|
186
|
-
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0 --ignore-scripts"}}
|
|
186
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0 --ignore-scripts"},"cwd":"${inert_project}"}
|
|
187
187
|
EOF
|
|
188
188
|
)
|
|
189
189
|
[[ -z "${inert_post}" ]] || fail "post hook keeps verified inert rebuild success quiet"
|
|
@@ -220,7 +220,7 @@ cat > "${missing_project}/package-lock.json" <<'EOF'
|
|
|
220
220
|
EOF
|
|
221
221
|
missing_post=$(
|
|
222
222
|
scripts/safedeps-post-verify.sh <<EOF
|
|
223
|
-
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
|
|
223
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${missing_project}"}
|
|
224
224
|
EOF
|
|
225
225
|
)
|
|
226
226
|
grep -q '의심스러운 패키지 변경 감지' <<< "${missing_post}" || fail "post hook reorgs unapproved transitive package"
|
|
@@ -328,4 +328,64 @@ else
|
|
|
328
328
|
printf 'ok - pre-commit gate behavior SKIPPED (needs gitleaks + openssl)\n'
|
|
329
329
|
fi
|
|
330
330
|
|
|
331
|
+
# --- Dependency audit gate (npm) — v2.5.0 -----------------------------------
|
|
332
|
+
# A fake `npm` makes the crucial distinction deterministic and offline: a
|
|
333
|
+
# vulnerable verdict (block) must never be confused with an unreachable advisory
|
|
334
|
+
# DB (warn + allow). If those two collapsed, an offline failover would silently
|
|
335
|
+
# let real vulnerabilities through.
|
|
336
|
+
fakebin="${tmp_root}/fakebin"
|
|
337
|
+
mkdir -p "${fakebin}"
|
|
338
|
+
cat > "${fakebin}/npm" <<'FAKE'
|
|
339
|
+
#!/bin/bash
|
|
340
|
+
[ "${1:-}" = "audit" ] || exit 0
|
|
341
|
+
case "${FAKE_NPM_MODE:-clean}" in
|
|
342
|
+
clean) printf '%s\n' '{"auditReportVersion":2,"vulnerabilities":{},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0,"total":0}}}'; exit 0 ;;
|
|
343
|
+
vuln) printf '%s\n' '{"auditReportVersion":2,"vulnerabilities":{"hono":{"name":"hono","severity":"moderate","via":[{"title":"JWT"}]}},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":4,"high":0,"critical":0,"total":4}}}'; exit 1 ;;
|
|
344
|
+
offline) printf '%s\n' '{"error":{"code":"ENOTFOUND","summary":"registry unreachable"}}'; exit 1 ;;
|
|
345
|
+
esac
|
|
346
|
+
FAKE
|
|
347
|
+
chmod +x "${fakebin}/npm"
|
|
348
|
+
|
|
349
|
+
if command -v jq >/dev/null 2>&1; then
|
|
350
|
+
audit_repo="${tmp_root}/audit-repo"
|
|
351
|
+
mkdir -p "${audit_repo}"
|
|
352
|
+
printf '{"name":"a","lockfileVersion":3}\n' > "${audit_repo}/package-lock.json"
|
|
353
|
+
run_audit() {
|
|
354
|
+
PATH="${fakebin}:${PATH}" FAKE_NPM_MODE="$1" \
|
|
355
|
+
"${ROOT_DIR}/bin/safedeps" audit npm --root "${audit_repo}" >/dev/null 2>&1
|
|
356
|
+
}
|
|
357
|
+
run_audit clean && rc=0 || rc=$?; [ "${rc}" = "0" ] || fail "audit exit 0 on a clean lockfile (got ${rc})"
|
|
358
|
+
run_audit vuln && rc=0 || rc=$?; [ "${rc}" = "1" ] || fail "audit exit 1 on a vulnerable lockfile (got ${rc})"
|
|
359
|
+
run_audit offline && rc=0 || rc=$?; [ "${rc}" = "2" ] || fail "audit exit 2 when the advisory DB is unreachable (got ${rc})"
|
|
360
|
+
pass "audit npm exit-code contract: clean=0 / vulnerable=1 / unreachable=2"
|
|
361
|
+
else
|
|
362
|
+
printf 'ok - audit exit-code contract SKIPPED (needs jq)\n'
|
|
363
|
+
fi
|
|
364
|
+
|
|
365
|
+
if command -v gitleaks >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then
|
|
366
|
+
dep_repo="${tmp_root}/dep-repo"
|
|
367
|
+
mkdir -p "${dep_repo}"
|
|
368
|
+
git -C "${dep_repo}" init -q
|
|
369
|
+
git -C "${dep_repo}" config user.email t@safedeps.test
|
|
370
|
+
git -C "${dep_repo}" config user.name safedeps-e2e
|
|
371
|
+
HOME="${tmp_root}/doc-home" "${ROOT_DIR}/bin/safedeps" doctor --fix --root "${dep_repo}" >/dev/null
|
|
372
|
+
printf '{"name":"a","lockfileVersion":3}\n' > "${dep_repo}/package-lock.json"
|
|
373
|
+
git -C "${dep_repo}" add package-lock.json
|
|
374
|
+
|
|
375
|
+
# Threat: a vulnerable dependency must BLOCK the commit (fail-closed verdict).
|
|
376
|
+
if PATH="${fakebin}:${PATH}" FAKE_NPM_MODE=vuln SAFEDEPS_BIN="${ROOT_DIR}/bin/safedeps" \
|
|
377
|
+
git -C "${dep_repo}" commit -q -m "vuln" 2>/dev/null; then
|
|
378
|
+
fail "pre-commit blocks a commit carrying a vulnerable dependency"
|
|
379
|
+
fi
|
|
380
|
+
|
|
381
|
+
# Availability failover: an unreachable advisory DB must WARN and ALLOW.
|
|
382
|
+
offline_out="$(PATH="${fakebin}:${PATH}" FAKE_NPM_MODE=offline SAFEDEPS_BIN="${ROOT_DIR}/bin/safedeps" \
|
|
383
|
+
git -C "${dep_repo}" commit -m "offline" 2>&1)" \
|
|
384
|
+
|| fail "pre-commit allows the commit when the advisory DB is unreachable (offline failover)"
|
|
385
|
+
grep -q "offline failover" <<< "${offline_out}" || fail "offline failover prints an observable warning"
|
|
386
|
+
pass "pre-commit dep gate: blocks on vuln, warns+allows when offline"
|
|
387
|
+
else
|
|
388
|
+
printf 'ok - pre-commit dep gate SKIPPED (needs gitleaks + jq)\n'
|
|
389
|
+
fi
|
|
390
|
+
|
|
331
391
|
printf 'e2e passed\n'
|
package/scripts/test/smoke.sh
CHANGED
|
@@ -103,7 +103,7 @@ allow_output=$(
|
|
|
103
103
|
)
|
|
104
104
|
[[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${allow_output}")" == "allow" ]] || fail "hook emits Claude allow decision for approved install"
|
|
105
105
|
[[ "$(jq -r '.hookSpecificOutput.updatedInput.command' <<< "${allow_output}")" == "npm install left-pad@1.3.0 --ignore-scripts" ]] || fail "hook injects --ignore-scripts for Claude npm install"
|
|
106
|
-
allow_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-allow/
|
|
106
|
+
allow_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-allow/pending/"*.json)
|
|
107
107
|
jq -e '.ignore_scripts_injected == true' "${tmp_root}/safe-hook-allow/snapshots/${allow_sid}_meta.json" >/dev/null || fail "hook records injected meta flag"
|
|
108
108
|
pass "hook injects --ignore-scripts for Claude approved install"
|
|
109
109
|
|
|
@@ -113,7 +113,7 @@ codex_allow_output=$(
|
|
|
113
113
|
run_codex_hook_command "${tmp_root}/home-hook-codex" "${tmp_root}/safe-hook-codex" "npm install left-pad@1.3.0"
|
|
114
114
|
)
|
|
115
115
|
[[ -z "${codex_allow_output}" ]] || fail "hook keeps Codex approved install as plain allow"
|
|
116
|
-
codex_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-codex/
|
|
116
|
+
codex_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-codex/pending/"*.json)
|
|
117
117
|
jq -e '.ignore_scripts_injected == false' "${tmp_root}/safe-hook-codex/snapshots/${codex_sid}_meta.json" >/dev/null || fail "hook does not record injected meta flag for Codex"
|
|
118
118
|
pass "hook keeps Codex approved install as plain allow"
|
|
119
119
|
|
|
@@ -129,7 +129,7 @@ ignore_scripts_output=$(
|
|
|
129
129
|
run_hook_command "${tmp_root}/home-hook-ignore-scripts" "${tmp_root}/safe-hook-ignore-scripts" "npm install left-pad@1.3.0 --ignore-scripts"
|
|
130
130
|
)
|
|
131
131
|
[[ -z "${ignore_scripts_output}" ]] || fail "hook does not duplicate --ignore-scripts"
|
|
132
|
-
ignore_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-ignore-scripts/
|
|
132
|
+
ignore_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-ignore-scripts/pending/"*.json)
|
|
133
133
|
jq -e '.ignore_scripts_injected == false' "${tmp_root}/safe-hook-ignore-scripts/snapshots/${ignore_sid}_meta.json" >/dev/null || fail "hook does not record injected meta flag when flag already exists"
|
|
134
134
|
pass "hook does not duplicate --ignore-scripts"
|
|
135
135
|
|
|
@@ -224,6 +224,29 @@ fc_noledger=$(
|
|
|
224
224
|
grep -q 'ledger library missing' "${fc_safe}/advisory.log" || fail "pre-guard logs the missing-ledger deny to advisory.log"
|
|
225
225
|
pass "pre-guard fails closed when the ledger library is missing (observable)"
|
|
226
226
|
|
|
227
|
+
# Concurrency (issue #5): two installs of the SAME command in one project must
|
|
228
|
+
# keep separate pending state — the per-install snapshot+PID suffix isolates them,
|
|
229
|
+
# not just the command hash — and a post hook must consume exactly one.
|
|
230
|
+
conc_safe="${tmp_root}/safe-concurrency"
|
|
231
|
+
mkdir -p "${conc_safe}"
|
|
232
|
+
SAFEDEPS_HOME="${conc_safe}" lib/ledger/ledger.sh approve npm conc-a 1.0.0 1.0.0 smoke >/dev/null
|
|
233
|
+
run_hook_command "${tmp_root}/home-conc" "${conc_safe}" "npm install conc-a@1.0.0" >/dev/null
|
|
234
|
+
run_hook_command "${tmp_root}/home-conc" "${conc_safe}" "npm install conc-a@1.0.0" >/dev/null
|
|
235
|
+
conc_pending=$(find "${conc_safe}/pending" -name '*.json' -type f | wc -l | tr -d ' ')
|
|
236
|
+
[[ "${conc_pending}" == "2" ]] || fail "two identical concurrent installs keep two separate pending files (got ${conc_pending}, want 2)"
|
|
237
|
+
jq -nc --arg c "npm install conc-a@1.0.0" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
|
|
238
|
+
HOME="${tmp_root}/home-conc" SAFEDEPS_HOME="${conc_safe}" scripts/safedeps-post-verify.sh >/dev/null 2>&1 || true
|
|
239
|
+
conc_left=$(find "${conc_safe}/pending" -name '*.json' -type f | wc -l | tr -d ' ')
|
|
240
|
+
[[ "${conc_left}" == "1" ]] || fail "post hook consumes exactly one identical-command install's pending state (left ${conc_left}, want 1)"
|
|
241
|
+
pass "concurrent installs (even identical commands) keep isolated pending state (issue #5)"
|
|
242
|
+
|
|
243
|
+
# A dependency-install PostToolUse with no pending state (e.g. a payload missing
|
|
244
|
+
# cwd) is recorded UNVERIFIED, not dropped silently (issue #5 review finding 3).
|
|
245
|
+
jq -nc '{tool_name:"Bash",tool_input:{command:"npm install orphan@1.0.0"}}' |
|
|
246
|
+
HOME="${tmp_root}/home-conc" SAFEDEPS_HOME="${conc_safe}" scripts/safedeps-post-verify.sh >/dev/null 2>&1 || true
|
|
247
|
+
grep -q 'UNVERIFIED: no pending state for an install-looking command' "${conc_safe}/advisory.log" || fail "post hook records an install with no pending state as UNVERIFIED"
|
|
248
|
+
pass "post hook records an install-looking command with no pending state as UNVERIFIED"
|
|
249
|
+
|
|
227
250
|
tamper_safe="${tmp_root}/safe-tamper"
|
|
228
251
|
tamper_home="${tmp_root}/home-tamper"
|
|
229
252
|
SAFEDEPS_HOME="${tamper_safe}" lib/ledger/ledger.sh approve npm ledger-tamper 1.0.0 1.0.0 smoke >/dev/null
|
|
@@ -236,7 +259,7 @@ cat > "${project_dir}/node_modules/ledger-tamper/package.json" <<'EOF'
|
|
|
236
259
|
{"name":"ledger-tamper","version":"1.0.0","scripts":{"postinstall":"node -e \"require('fs').writeFileSync(process.env.HOME + '/.safedeps/approved-specs/evil.json', '{}')\""}}
|
|
237
260
|
EOF
|
|
238
261
|
tamper_post=$(
|
|
239
|
-
jq -nc '{tool_name:"Bash",tool_input:{command:"npm install ledger-tamper@1.0.0"}}' |
|
|
262
|
+
jq -nc --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:"npm install ledger-tamper@1.0.0"},cwd:$cwd}' |
|
|
240
263
|
HOME="${tamper_home}" SAFEDEPS_HOME="${tamper_safe}" scripts/safedeps-post-verify.sh
|
|
241
264
|
)
|
|
242
265
|
grep -q '의심스러운 패키지 변경 감지' <<< "${tamper_post}" || fail "post hook reorgs safedeps ledger tamper script"
|